モバイルアプリ開発において宣言的UIフレームワークを利用する際のコンポーネント粒度についての考察

こんにちは、クックパッド事業本部 買物サービス開発部の佐藤(@n_atmark)です。

私の所属する買物サービス開発部ではクックパッドアプリにおける買い物機能*1の開発を行なっており、私は2020年の上期から買い物機能のモバイルアプリ開発の担当をしています。

2020年上期〜2021年上期では、クックパッドiOSアプリの買い物機能開発に、

2021年下期からは、クックパッドAndroidアプリの買い物機能開発に携わっています。

クックパッドアプリの買い物機能開発に関する詳細はクックパッド開発者ブログにもまとまっていますので、ぜひ合わせてご覧ください。

techlife.cookpad.com

techlife.cookpad.com

前述の通りクックパッドアプリにおける買い物機能をiOS/Android両プラットフォーム向けに開発しているのですが、実はそれらの開発には共通している点があります。

クックパッドiOSアプリの買い物機能ではSwiftUIというAppleプラットフォーム向けの宣言的UIフレームワークを利用しており、 クックパッドAndroidアプリの買い物機能ではJetpack ComposeというAndroid向けの宣言的UIフレームワークを利用している点です。

モバイルアプリ開発で宣言的UIフレームワークを利用する利点

SwiftUIやJetpack ComposeといったUIフレームワークを利用することで、従来のUI構築の手法と比べて以下のようなメリットがあります。

  • コード量を減らすことができる
  • 複雑・多様な条件のある画面実装をシンプルに書ける
  • コンポーネント単位で使い回し・改変がしやすい
  • UIのプレビュー表示が可能で、アプリ全体をビルドせずにUIの確認ができる

UI変更がより柔軟に、簡単になることで、作って壊しの試行錯誤の回数を増やすことができ、スピード感を持ってサービス開発を行うことができるため、買い物機能では宣言的UIフレームワークを積極的に利用しています。

宣言的UIフレームワークを用いた開発を続けていく中で直面した問題

先行開発していたiOS向けの買い物機能をリリースしてから1年が経ち、宣言的UIフレームワークを利用したコードも増え、開発に携わる人も増えてきました。

開発を続けていくにつれて宣言的UIフレームワークの柔軟性の高さゆえに、UIの組み方が人によって大きく変わってしまい、かえって可読性が落ちてしまっていたり、保守しづらいコードが現れてきていると感じるようになりました。

そこで、チーム内で時間を設けて設計について話す会を設けてみました。

設計について話す会の様子
チーム内で開催したSwiftUIの設計について話す会の様子

宣言的UIフレームワークを用いたUI組みに関して、暗黙知としてよしなに実装している部分が多く、コンポーネントの分割粒度や、適切なレイアウト設計について悩んでいることが分かりました。

せっかく作って壊しやすいという利点を尊重して採用した宣言的UIフレームワークが、かえって既存の実装を変えたい場合に、既存実装が読みづらく変更に弱い状態になっているのは勿体ないと感じました。

今後もチームで宣言的UIフレームワークを活用していくために、宣言的UIフレームワークを使っていて感じている問題は一つずつ取り除いていけるとよいはずです。

開発者それぞれがコンポーネント分割の粒度を把握できるようにするために

UIの組み方が人によって変わってしまう問題を解決するために、コンポーネントを各開発者が適切な粒度で分割できるように何かしらルールのようなものが存在するとよいのではないか、と考えるようになりました。

チーム内で他のプラットフォームの事例を参考に、コンポーネント粒度の考え方として、Atomic Designに目をつけました。

https://bradfrost.com/wp-content/uploads/2013/06/atomic-design.png
https://atomicdesign.bradfrost.com/

Atomic Designは分解可能な最小単位でパーツを作成し、パーツを組み合わせてより大きなコンポーネントを作成し、コンポーネントを組み合わせて画面を定義していくようなデザイン手法です。

Atomic Designはモバイルアプリ開発において宣言的UIフレームワークを利用する際の銀の弾丸になりうるのか

Atomic Designによって、コンポーネント分割の粒度を示すことができました。例えば買い物機能で利用されている、商品グリッドをこのように分割してみました。

Atomic Designに沿って買い物機能で利用されているコンポーネントを粒度分割した図
Atomic Designに沿って買い物機能で利用されているコンポーネントを粒度分割した図

一見すると、これによって開発者がAtomic Designに沿ってコンポーネントを粒度によって分割できそうに見えます。

ところが、実際にはそう上手くはいきません。

Atomic Designを用いた画面設計を運用をしたことのある社内のデザイナーに話を聞いてみると

  • Atomic Designを運用していたけれどやめてしまった
    • Atoms/Moleculesを分離するメリットが薄いと感じた
    • Molecules/Organismsの分割が難しい
    • コンポーネントをどう分けるかで議論を生んでしまうこともあり、かえって消耗してしまった

と伺うことができました。

厳密にAtomic Designに沿ってコンポーネント分割を行おうとすると下のような点に問題が出てくることが分かりました。

  • MoleculesとOrganismsどっちに分類すべきなのか分からない
  • Atomsレベルのコンポーネント分割を厳密にやろうとすると開発速度が出ない

モバイルアプリ開発における宣言的UIフレームワークを利用する際のコンポーネント粒度に関する最適解とは

Atomic Designをそのまま取り入れることに関して

  • そもそもWebの考え方なのでモバイルアプリには当てはまらない部分もある
  • 厳密にコンポーネントの粒度を定義したいことが目的ではない
  • Atomic Designにおける用語(Atoms, Molecules...)が一人歩きしてしまう

という点を踏まえて改めて当初の目的を考えてみると、 「開発者がコンポーネント分割をルールに沿って無心でできるようにすることで、保守しやすい状態は保ちつつ、開発速度をあげたい」 のが目的でした。

そこで、Atomic Designのエッセンスだけを取り入れつつ、モバイルアプリ開発に適したコンポーネント粒度を考えるのが良さそうと考えました。

モバイルアプリ開発における特徴を踏まえた上で設計を考える

モバイルアプリ開発(というと、やや主語が大きいので、特にクックパッドのアプリ開発)においては、下のように縦スクロールの画面が多く登場します。

クックパッドアプリの画面構成の例
クックパッドアプリの画面デザインの例

例えば、次のような買い物機能のカート画面*2を実装するとします。その時、適切にコンポーネントが分割されないと実装も縦に長くなりがちです。(SwiftUIの例を提示します。)

買い物機能のカート画面
買い物機能のカート画面

struct CartView: View {
    var body: some View {
        ScrollView {
            VStack {
                Text("ご注文内容")
                    .padding(.top, 32)
                    .padding(.bottom, 4)
                    .padding(.horizontal, 16)

                VStack {
                    ForEach(dataSource.cart.cartProducts) { cartProduct in
                        HStack(alignment: .bottom) {
                            AsyncImage(url: cartProduct.product.thumbnailUrl)
                                .frame(width: 74, height: 74)
                                .cornerRadius(6)
                            VStack {
                                Text(cartProduct.product.name)
                                    .font(.subheadline)
                                Text("\(cartProduct.product.salesUnit) \(cartProduct.product.productionArea)")
                                    .font(.callout)
                                    .foregroundColor(.gray)
                                Spacer()


                                HStack {
                                    Image(systemName: "minus")
                                        .foregroundColor(.gray)
                                        .font(.system(size: 14))
                                        .frame(width: 30, height: 30, alignment: .center)
                                        .cornerRadius(15)
                                        .onTapGesture(perform: decrement)

                                    Text("\(cartProduct.count)")
                                        .font(.subheadline)
                                        .frame(minWidth: 26, alignment: .center)

                                    Image(systemName: "plus")
                                        .foregroundColor(.gray)
                                        .font(.system(size: 14))
                                        .frame(width: 30, height: 30, alignment: .center)
                                        .cornerRadius(15)
                                        .onTapGesture(perform: increment)
                                }
                                .padding(5)
                                .overlay(
                                    Capsule()
                                        .stroke(Color.gray, lineWidth: 0.5)
                                )
                            }

                            Text("単価 ¥").font(.subheadline) + Text("\(cartProduct.product.price)").font(.headline)
                        }
                            .padding(.horizontal, 20)
                            .padding(.vertical, 16)

                        Divider()
                    }
                }

                VStack(spacing: 16) {
                    HStack {
                        Text("商品小計").font(.title)
                        Spacer()
                        Text("¥").font(.subheadline) + Text("\(cart.subTotal)").font(.body)
                    }

                    HStack {
                        Text("消費税").font(.title)
                        Spacer()
                        Text("¥").font(.subheadline) + Text("\(cart.tax)").font(.body)
                    }
                }
                .padding(.horizontal, 16)
                .padding(.vertical, 24)

                Divider()

                HStack {
                    Text("計").font(.body)
                    Spacer()
                    (Text("¥").font(.headline) + Text("\(cart.subTotal)").font(.system(size: 28)))
                        .foregroundColor(.orange)
                }
                    .padding(.horizontal, 16)
                    .padding(.vertical, 20)

                Divider()

                // ・・・以降省略
        }
    }
}

適切にコンポーネント分割なされていない例

CartViewに90行ほどのコードを書いてみましたが、これでも下のスクリーンショットの赤枠部分の実装しかありません。

例示コードの実装部分
例示コードの実装部分

これを適切にコンポーネント分割したい場合、どこから取り掛かれば良いでしょうか。

ここで、宣言的UIフレームワークを導入する前のことを考えてみましょう。

我々モバイルアプリ開発エンジニアは、従来のUI構築では意味のあるまとまりごとにUITableViewにおけるUITableViewCellや、RecyclerViewにおけるItemViewといった単位で画面を行分割することによってUIを構築してきました。 (iOSの例を提示します。)

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    switch Section.allCases[indexPath.section] {
    case .cartProducts:
        return tableView.dequeueReusableCell(with: CartProductsTableViewCell.self, for: indexPath)
    case .cartPrice:
        return tableView.dequeueReusableCell(with: CartPriceTableViewCell.self, for: indexPath)
    case .pickupNameSetting:
        return tableView.dequeueReusableCell(with: PickupNameSettingTableViewCell.self, for: indexPath)
    case .deliveryInformation:
        return tableView.dequeueReusableCell(with: DeliveryInformationTableViewCell.self, for: indexPath)
    case .pickupSteps:
        return tableView.dequeueReusableCell(with: PickupStepsTableViewCell.self, for: indexPath)
    case .notes:
        return tableView.dequeueReusableCell(with: NotesTableViewCell.self, for: indexPath)
    case .faq:
        return tableView.dequeueReusableCell(with: FAQTableViewCell.self, for: indexPath)
    }
}

UITableViewを用いたUI構築の例

これはモバイルアプリ開発エンジニアにとっても馴染みの深いコンポーネント分割と言えるでしょう。 意味のあるまとまりでレイアウトを分割することができます。

Section単位での行分割の図
Section単位での行分割の図

同じように、これを宣言的UIフレームワークで実現すると

// SwiftUIの例
struct CartView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                CartProductsSection()
                CartPriceSection()
                PickupNameSettingSection()
                DeliveryInformationSection()
                PickupStepsSection()
                NotesSection()
                FAQSection()
            }
        }
    }
}
// Jetpack Composeの例
@Composable
fun CartScreen() {
    LazyColumn {
        item {
            CartProductsSection()
        }
        item {
            CartPriceSection()
        }
        item {
            PickupNameSettingSection()
        }
        item {
            DeliveryInformationSection()
        }
        item {
            PickupStepsSection()
        }
        item {
            NotesSection()
        }
        item {
            FaqSection()
        }
    }
}

このように画面をSectionという意味のある単位で行分割することで、画面実装をシンプルかつ、見通しよく書くことができるようになりました。 画面実装におけるUI定義のトップレベルであるViewのbodyやScreen関数の中でこのように記述することで、それらが 画面の設計図として機能するようになります。

また、条件に応じたViewのトルツメなども

// SwiftUIの例
struct CartView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                CartProductsSection()
                CartPriceSection()
                PickupNameSettingSection()
                DeliveryInformationSection()
                if shouldShowPickupSteps { PickupStepsSection() }
                NotesSection()
                FAQSection()
            }
        }
    }
}
// Jetpack Composeの例
@Composable
fun CartScreen() {
    LazyColumn {
        item {
            CartProductsSection()
        }
        item {
            CartPriceSection()
        }
        item {
            PickupNameSettingSection()
        }
        item {
            DeliveryInformationSection()
        }
        if(shouldShowPickupSteps) { 
            item {
                PickupStepsSection()
            }
        }
        item {
            NotesSection()
        }
        item {
            FaqSection()
        }
    }
}

のように、見通しよく書くことができます。

これによってAtomic DesignのOrganisms相当のコンポーネント粒度に関して、Atomic Designの定義を使わずに分割の指標を示すことができました。

実際にクックパッドiOS/Androidの買い物機能でもSection-suffixな命名で画面をこのように分割を行なっており、モバイルアプリ開発者にも分かりやすく使える指標として今のところ上手くワークしているのではないかと考えています。

Sectionに関する補足

意味のあるまとまりで画面を行分割したそれぞれのコンポーネントをSectionと呼んでいます。 Sectionは単体で意味のあるまとまりとして機能し、さらに小さな粒度のコンポーネントをとりまとめる役割を持っています。

意味のあるまとまりとしてのSectionの図
意味のあるまとまりとしてのSectionの図

例えば、この赤枠の部分を実装する時は「受け取り名」のラベルと「受け取り用のニックネームを登録」のボタンをまとめたものをSectionと呼べそうです。

Sectionの役割は、複数コンポーネントをとりまとめ、コンポーネント間のマージン調整をすることです。

複数コンポーネントの取りまとめと、マージン調整レイヤーとしてのSectionの役割を示す図
複数コンポーネントの取りまとめと、マージン調整レイヤーとしてのSectionの役割を示す図

例えば、下のような実装ができそうです。

// SwiftUIの例
private struct PickupNameSettingSection: View {
    var didTap: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("受け取り名")
            CookpadButton(style: .secondary, text: "受け取り用のニックネームを登録", didTap: didTap)
        }
        .padding([.leading, .trailing, .bottom], 20)
        .padding(.top, 28)
    }
}
// Jetpack Composeの例
@Composable
private fun PickupNameSettingSection(onClick: () -> Unit) {
    Column(
        verticalArrangement = Arrangement.spacedBy(12.dp),
        horizontalAlignment = Alignment.Start,
        modifier = Modifier
            .padding(
                start = 20.dp, 
                top = 28.dp, 
                end = 20.dp, 
                bottom = 20.dp
            )
    ) {
        Text(
            text = stringResource(R.string.pickup_name_setting_section_title), // 受け取り名
        )

        CookpadButton(
            style = CookpadButtonStyle.secondary,
            text = stringResource(R.string.pickup_name_setting_section_button_text),  // 受け取り用のニックネームを登録
            onClick = onClick
        )
    }
}

Sectionはあくまで画面に紐づくものとして用意していて、複数画面で利用しないようにしています。

コンポーネントをどう並べるかであったり、どういうマージンを持たせるかは画面の中で特定のコンテキストを持つ場合が多いので、あえて汎化させていません。 (それでもSectionを汎化させたいような場合は、Section内で利用しているコンポーネントの粒度を見直した上で、Section内部にコンポーネントを一つだけ持つSectionを作るようにしています)

Sectionより小さなコンポーネント

「モバイルアプリ開発における特徴を踏まえた上で設計を考える」の章で、Atomic DesignのOrganisms相当のコンポーネント粒度を、Sectionという行分割されたコンポーネントによって指標を示せた。という話を書きました。 Atomic Designの定義に沿うと、Organismsより小さなコンポーネント粒度としてMolecules/Atomsレベルのものを考える必要がありそうです。

ここでAtoms/Moleculesに関しては

  • Atoms: 分解可能な最小単位のコンポーネント
  • Molecules: 複数のAtomsがまとまったコンポーネント

と便宜上定義することにします (これは厳密にはAtomic Designの定義とは少し外れています)

Atoms相当のコンポーネントとは

前項の商品グリッドの分割の例を見てみると、Atomsに関しては割と明確にコンポーネントを分割できそうです

Atoms単位のコンポーネント分割の例

しかし、Atomsレベルのコンポーネントに関して

  • Atomsレベルのコンポーネント分割を厳密にやろうとすると開発速度が出ない

という課題もあります。

そこで、Atomsレベルのコンポーネントのうち共通化されるものだけを考えてみます。共通化して使われるものに関しては適切にAtomsレベルのコンポーネント分割をする意味があると言えるでしょう。

アプリ内で共通化されるものは、例えばデザインシステム*3で定義されているボタンなどが挙げられます。

https://assets.st-note.com/production/uploads/images/56051609/picture_pc_a88d51300483095fe7e7e8a13d5ede68.png?width=800
クックパッドで利用しているデザインシステム(Apron)の例

これらは、アプリ内で共通して利用されることが分かっており、利用されるスタイルがデザインシステムによって定められているため、コンポーネント化もしやすそうです。

他にもテキストの実装を考えてみます。場所によってフォントサイズやテキストカラーが異なりますが、これらそれぞれのコンポーネント化は過剰と言えるでしょう。

その代わりに、デザインシステムで定義されているテキスト色やフォントサイズの種類などをスタイルとして提供する方が柔軟に利用できるでしょう。

// SwiftUIの例
struct HeaderText {
    var text: String
    var body: some View {
        Text(text)
            .fontWeight(.bold)
            .font(.system(18))
            .foregroundColor(.black)
    }
}
// Jetpack Composeの例
@Composable
fun HeaderText(text: String) {
    Text(
        text = text,
        fontSize = 18.sp,
        fontWeight = FontWeight.Bold,
        color = colorResource(R.color.black)
    )
}

コンポーネントとして提供する例

// SwiftUIの例
Text("テキスト")
    .cookpadFont(size: .large, weight: .bold)
    .foregroundColor(.cookpad(.black))
// Jetpack Composeの例
Text(
    text = "テキスト",
    style = CookpadTextStyle.Large.bold().black()
)

スタイルとして提供する例

デザインシステムで定義されているスタイルやコンポーネントはプロジェクト内で共通して使えると良さそうなので、例えばUIライブラリとして提供したり、UIモジュールとして提供したりできそうです。

アプリ内で共通して利用されることがわかっているコンポーネントやスタイルに関してはプロジェクト全体で利用できるようにしておき、画面実装の際にデザインシステムで定義されていないAtomsレベルのコンポーネント分割に関してはそこまで頑張らなくても良いのではないかと考えています。

Molecules相当のコンポーネントとは

画面実装の際にAtomsレベルのコンポーネント分割はほとんど必要なさそう。と書きましたが、実際の利用シーンを考えてみてもデザインシステムで定義されていないようなコンポーネントに関しては、使い回しの単位はMolecules相当の大きさくらいにならないとあまり発生しないだろうと考えています。

そのため、画面実装の際に一番意識する必要があるのがMolecules相当のコンポーネントだと考えています。

複数の要素を組み合わせたコンポーネントの例
複数の要素を組み合わせたコンポーネントの例

特に、商品タイルと商品グリッドのように、あるコンポーネントと、そのコンポーネントを内包する別コンポーネントのような関係が容易に発生します。

ここで、厳密にAtomic Designの定義に沿おうとすると、商品タイルはMoleculesっぽいということは分かりますが、商品グリッドはグリッドとして構成される最低限の機能を持つMoleculesと言えるかもしれませんし、MoleculesであるタイルをまとめたグループとしてのOrganismsとみなせるかもしれません。

  • MoleculesとOrganismsどっちに分類すべきなのか分からない

というような課題が残ります。

ここで、解決したいのはコンポーネントを適切に分割できることであってMolecules/Organismsの厳密な定義ではないので、一旦全てをフラットなコンポーネントとして考えてみます。

Molecules相当のコンポーネント分割で気にすべきもの

例としてSectionでもなく、ライブラリとして提供されるスタイルでもない粒度のものを、全てComponent-suffixをつけてコンポーネント化してみました。

全てComponent-suffixをつければ良いので、開発者が粒度を気にすることなくコンポーネントを分割できて、一見問題を解決しているように見えます。

しかし、利用する際のことを考えてみると、

// SwiftUIの例
VStack {
     HeaderComponent() // ヘッダーっぽい
     ProductsComponent() // 商品を表示してそう
} 
// Jetpack Composeの例
Column {
    HeaderComponent() // ヘッダーっぽい
    ProductsComponent() // 商品を表示してそう
}

利用されているのがコンポーネントであることは分かるものの、なんのコンポーネントなのか全然分かりません。

上の例示における ProductsComponent() は商品を表示していそうなことは分かるのですが、例えばグリッドで商品を表示しているのか、カルーセルで商品を表示しているのか、リストで商品を表示しているのか、コンポーネント側の実装を見ないと把握できません。

これを踏まえて、Moleculesレベルのコンポーネントに関しては、コンポーネントの粒度を分類することよりも、種類を分類して適切に命名することが大事なのではないか と考えるようになりました。

Moleculesレベルの種類の分類を適切にするために

コンポーネントに関して、コンポーネントの役割が分かるような種類で分類を行い、適切な命名をすると良さそうなことがわかったのですが、その命名はどうしても開発者依存になってしまいます。 しかし、この部分を厳密に定めてしまうとMolecules/Organismsのどっちに分類すべき問題に再度遭遇してしまうため、コンポーネントの種類の分類を厳密に定めることはしない方が良さそうです。

そのため、何かしらの方法で開発者が適切に種類を分類できる方法を提供できると良さそうです。 例えば、コンポーネントの例としてList/Grid/Carousel/Tableなどの凡例をカタログとして用意しておくとどうでしょうか。

コンポーネント分類のためのカタログ
コンポーネント分類のためのカタログ

カタログを見ながら開発者がコンポーネントの種類を判断できると良さそうです。

カタログを見て分類できるものは、カタログにそった命名にする (ProductsGrid/ProductsCarousel等)、カタログに分類できないものに関してはComponent-suffixのコンポーネントにしてしまうなどの方針を立てられると、コストは抑えつつ、ある程度保守しやすい状態にできるのではないかと考えています。

考察を踏まえて

これらの考察を踏まえてカート画面の実装を再度考えてみると、下のような実装ができそうです。(SwiftUIの例を提示します。)

struct CartView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                CartProductsSection()
                CartPriceSection()
                PickupNameSettingSection()
                DeliveryInformationSection()
                if shouldShowPickupSteps { PickupStepsSection() }
                NotesSection()
                FAQSection()
            }
        }
    }

    private struct CartProductsSection: View {
        var body: some View {
            SectionHeader("ご注文内容")
            CartProductList()
        }
    }

    private struct CartPriceSection: View {
        var body: some View {
            CartPriceTable()
        }
    }

    private struct PickupNameSettingSection: View {
        var body: some View {
            SectionHeader("受け取り情報")
            CookpadButton("受け取り用のニックネームを登録") {  // 略 }
                .padding(16)
        }
    }

    private struct DeliveryInformationSection: View {
        var body: some View {
            DeliveryInformationTable()
        }
    }


    private struct PickupStepsSection: View {
        var body: some View {
            SectionHeader("受け取りまでの流れ")
            PickupStepsCarousel()
        }
    }

    private struct NotesSection: View {
        var body: some View {
            SectionHeader("注意事項")
            NotesComponent()
            TermsList()
        }
    }

    private struct FAQSection: View {
        var body: some View {
            FAQList()
        }
    }
}

だいぶ見通しが良くなり、画面に紐づくViewのbodyやScreenの定義と、それに紐づくSectionの実装をみるだけでおおよその画面の構成を把握することができるようになったと言えるでしょう。

まとめ

  • Atomic Designはモバイルアプリ開発において宣言的UIフレームワークを利用する際の銀の弾丸ではなさそう
  • Atomic Designをそのまま利用しても効果は薄そうだが、Atomic Designの考え方のエッセンス自体は参考にできる部分が多い
  • 従来のUI構築で画面を意味のあるまとまりで行分割を行なっていた考え方は、モバイルアプリ開発における宣言的UIフレームワークを用いたコンポーネント分割の指標として相性が良かった
  • デザインシステムで定義されているようなコンポーネントやスタイルはプロジェクト全体で利用できるようにしておくと良い
  • 画面実装の際に使い回し利用するようなコンポーネントは厳密に粒度を分類するより、適切に種類を分類する方が良さそう

さいごに

Webに比べると、モバイルアプリ開発で宣言的UIフレームワークを用いてチーム開発をしたり、既存実装の保守・運用を行う知見についてはまだまだ少なく、試行錯誤を繰り返していく中で「正解は分からないけど、なんとなくこういう方針が良いんじゃないか」と考えていることを記事にしてみました。

SwiftUI/Jetpack Composeを用いて開発されているプロダクトが世間的にも増えてきている背景の中で、今後保守・運用のしやすさについて注目されることも増えてくると思うので、今回の記事が参考になれば嬉しいです。

クックパッドでは一緒に働く仲間を募集しています!

モバイルアプリ開発において宣言的UIフレームワークを利用する際のコンポーネント粒度について、普段開発しているプロダクトの特徴などを踏まえて考察を書いてみました。

クックパッドでは、SwiftUIやJetpack Composeなどの宣言的UIフレームワークを用いて、保守しやすい状態は保ちつつ、素早くユーザーに価値を届けられるようなサービス開発を一緒に進めてくださるエンジニアを大募集しています。

カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください。

info.cookpad.com

*1:近隣地域の生産者や市場直送の新鮮でおいしい食材を、1品から送料無料で購入できる。https://info.cookpad.com/pr/news/press_2020_1015

*2:説明用に実際のものとは少し構成を変えています

*3:クックパッドではApronというデザインシステムを用いています。 https://note.com/fjkn/n/nf73742ec925a

「サービス開発とその進め方」というタイトルで授業をしました

こんにちは、クリエイション開発部の橋本和幸(@funwarioisii)です。今期は同じ本部に橋本さんが3人いたので3倍反応しました。

12月2日に(一昨年私が卒業した)岩手県立大学で、「サービス開発とその進め方」について講義をしました。*1

この記事では「何を提供しようとしたか」「どういう反応が得られたか」を紹介します。 それでは背景から説明していきます。

背景

授業の立て付け

授業の名前はシステムデザインPBLで、デザイン思考を学び、アジャイルに開発してみようというものでした。 外部からの講師を招待し、どういう開発をしているかの話が聞ける授業でもありました。特にアジャイルやデザイン思考など、現在の大学の枠組みで教えることが難しいものを中心に扱う授業でした。

私も学生時代に受講していて、コードを書くというよりモノを作ってる感じがして、つらくも楽しい授業でした。 一方で何か物足りず、何かが欠けていたような気がしていました。 それは「何を作るかの決め方」、つまり企画の話でした。この視点は入社し、働くうちに得られたと思い、授業ではそれを補完することを個人的な目標としました。

発言の気まずさ

大学の授業、特に外部講師の授業を思い出してみると、最後の質問コーナーは気まずい雰囲気が流れがちだったのはどこの大学も同じでしょうか。 明日も顔を合わせる人(同期や後輩など)の前で、ちょっと間の抜けた質問なんかはできないな…という意識が働いたのを思い出します。

また、授業中に講師が学生に問いかけると、みんな無表情でフリーズを起こす、あの時間が苦手でした。話しかける側からすると、理解がどのくらいまでされているのかを知りたいのですが、話しかけられた側はその講師との関係性が1対1ではなく、周りに他の学生がいます。上述の通り明日からのこともあるものですから、油断した返答はできません。緊張した雰囲気が流れます。 授業中に大学で何かを発言するのはリスキーな行為でした。

何を提供しようとしたか

なので、以下の2点を提供するような授業を考えていました。

  1. 授業で話されていない企画の話
  2. 雑に発言できる場

授業で話されていない企画の話

どういう話を提供したかというと、最初に貼ったスライドの通り、サービス開発の進め方についてです。

特にスライドに入れたかったトピックは学生時代にわからなかった、「どうアイデアを出すか」「いいユーザ体験を生むためにコードを書かずにできることは何か」でした。 この2つの話だけをしても仕方がないので、やはりサービス開発全体の話に盛り込む必要があると判断しました。

さらっとサービス開発というものを説明しても良いのですが、自分ごとに考えられると理解が深まると思ったので、シナリオを設定しました。 「サービス開発は知らない人にプレゼント」というのは弊社でよく語られる喩えですが、これを少し改変することで、自分ごととして考えやすくなりそうなので、これを説明の軸にしました。

雑に発言できる場

授業中にリスクなく発言できる場もほしいです。 眠い授業をしたくないし、ひっそり盛り上がると楽しいじゃないですか。 そのため、私は以下の3点が必要だと思っていました。

  • 完全に匿名
  • 盛り上がってる感がある
  • 簡易なリアクションができる

また、最後に質疑応答のコーナーを設ける必要がありました。 大量に質問が来た時に備えて「いいね順に並び替え」があると便利そうでした。

これらを満たしそうな既存のサービスを調べてみたのですが、コメントに投票できそうなものは見つけられませんでした。 なので直前に Flutter Web と Firebase で作りました。 ビルドしたものを Firebase Hosting に置いて、 Firestore を読み書きするシンプルな構成のチャットアプリです。

これを授業前にQRコードとURLをスクリーンに掲出し、案内しました。 画像は授業で使ったものです。

授業で使ったチャットアプリ
授業で使ったチャットアプリ

どういう反応が得られたか

授業後アンケートを担当教員からいただいたので、目を通しました。 すごく熱量の高いコメントが多く、やった意義を強く感じられました。 アンケートに答えてくださり、ありがとうございます。

「授業で話されていない企画の話」について

いただいたコメントから、いくつか紹介させていただくと

品質、提供時間、保守性のバランスについて、品質の良いものだけを作れば良いだけではないとおっしゃられていましたが、実際自分が買う時は一番良いものを買うとはほとんどないにも関わらず、企画する際には一番良いものを考えようとしてしまっていました

このコメントでは普段の自分の購買行動と結びつけ、今回の講義を捉え直しているのが素晴らしいと思いました。私も見落としていた考え方だったので勉強になります。

私は研究活動でも調査や分析に見切りをつけることを苦手としていて,ついつい時間を割いてしまいます.

企画からまた企画への流れなども,開発だけではなく研究へも取り入れることが出来そう

というコメントもありました。研究活動に転用できそうと、自分の中で解釈して他の活動に結びつけようとしている方がいたのはうれしいです。

また、「アイデアの取捨選択」に言及されている方が多かったのは一見、意外でした。 ただ、授業を受けていた頃を思い返すと、私もどれに手を付ければいいのかわからない経験をしていたので納得です。

拝読したコメントから、話したことがうまく伝わったと感じられました。

「雑に発言できる場」について

スマホでの質問システムよかったです.

チャットあると口頭では聞きにくい質問もできるのでありがたかったです。

と、ポジティブな意見がありうれしいです。

また、盛り上がらないだろうと思っていたQ&Aコーナーで挙手してくれる学生さんがいたのは嬉しい誤算でした。

Q&Aで良いなと思ったのは、「このサービスに次に機能を追加するなら何を追加したいですか?」という質問です。 「上部の絵文字ボタンを取る」「他の授業でも使えるようにする」あたりでしょうか。以降の反省点にも書く内容ですが、機能追加の前に、これを活用した授業の再設計から試すのが良いかもしれません。 普段から授業をしている先生方にも聞いてみたいです。

ちなみに、Q&Aもチャットも盛り上がらなかったときのフォールバック先として、「会社員になってわかったこと」コーナーも用意していました。 このコーナーをする必要がない程に、皆さんからの質問をチャットとQ&Aコーナーで頂きました。

反省点

せっかくなのでいくつか反省点も書いておきます。

Q&Aで「学生時代にもっと勉強しておけばよかったと思う科目は?」という質問に対して、私は統計と答えました。その結果、アンケートではちゃんと統計を勉強しようと思います。という回答を頂いていました。 誠に申し訳ないのですが、自分が不真面目だったのが祟っただけで、同じ学年に卒業した同期は、かなりすぐA/Bテストを理解していました。

次に、双方向性のある体験をうまく作れていなかった点です。 双方向性がある講義が必ずしも素晴らしいとは思いませんが、チャットアプリを用意した割に、いささか中途半端なものになってしまいました。30分という時間は長くなく、レスポンスを待つことが難しいというのもありました。

最後に、検証企画に終始してしまった点です。 大きな絵を描いた上で、細かく取り組めるといいのですが、細かい話が多かったかもしれません。

アンケートでコメントを頂けたことで、自分が至らなかった点や期待した点について評価できました。ご回答いただきありがとうございました。 自分が期待した以上に話が伝わり、自分にとって新しい学びが得られました。

また機会があれば、反省点を踏まえて発表したいものです。

最後に

この記事では、授業で企画の話をするために考えたことと、実際にどう授業を設計したかについて説明しました。学生時代から2年弱しか時間が経ってないこともあり、学生に比較的近い目線で彼らが聞きたい話を提供できた実感があります。 PBL関係の授業を設計されている先生や、外部講師として招待された方への参考になればと思います。

また、発表したスライドも公開していますので、御覧ください。時間の都合で十分に議論されていない部分もありますが、進め方においては網羅的に説明しているつもりです。進め方に悩んでいる方の参考になればと思います。

最後の最後に今回の記事でクックパッドのサービス開発が気になった方へ! クックパッドでは、サービス開発に取り組む就業型インターン・そして新卒採用・中途採用を通年で受付けています。 是非下記のウェブサイトよりご応募ください。

info.cookpad.com

*1:リモート授業で、Zoom越しでした。

Ruby 3.1はエラー表示をちょっと親切にします

こんにちは、ruby-devチームの遠藤(@mametter)です。 Among Usというゲームをやってるのですが、友達が少なくてあまり開催できないのが悩みです。

今日は、Ruby 3.1に導入される予定のerror_highlightという機能を紹介します。

どんな機能?

NoMethodErrorが起きたとき、次のような表示が出るようになります。

f:id:ku-ma-me:20211201172801p:plain
error_highlightの動作例

どこのメソッド呼び出しで失敗したかが一目瞭然ですね。これだけの機能ですが、使ってみると意外と便利です。

もう少し詳しく

この機能が本領を発揮するのは、RailsのparamsやJSONデータの取り扱いなどのときです。 たとえばjson[:articles][:title]みたいなコードを書いて、undefined method '[]' for nil:NilClassという例外が出たとします。 このとき、変数jsonnilだったのか、json[:articles]の返り値がnilだったのかは、残念ながらコードだけ見ても判断できません。 特定するには、デバッグ出力を挟んで再実行する必要がありました。

error_highlightがあると、これがひと目で判別できます。

$ ruby test.rb
test.rb:2:in `<main>': undefined method `[]' for nil:NilClass (NoMethodError)

title = json[:articles][:title]
            ^^^^^^^^^^^

↑は、jsonnilだったケースです。

$ ruby test.rb
test.rb:2:in `<main>': undefined method `[]' for nil:NilClass (NoMethodError)

title = json[:articles][:title]
                       ^^^^^^^^

↑は、json[:articles]nilを返したことがわかります。

実装について

error_highlightはRubyインタプリタの実装に深く関わってます。ざっくりイメージで紹介します。

Rubyは、プログラムを抽象構文木に変換し、それをバイトコードにコンパイルした上で実行しています。 たとえば json[:articles][:title] というコードは、次のような抽象構文木(イメージ)に変換されます。

f:id:ku-ma-me:20211201163014p:plain
Rubyの抽象構文木(イメージ)

それぞれの四角は抽象構文木のノードを表します。ノードは、メソッド名などの付加情報や、レシーバや引数などの子ノードへの参照を持ちます。それに加え、ノードは"ID"と"column"という情報を持っています。"ID"はノードを一意に特定する番号です。"column"は、そのノードに対応するコードの範囲を表しています*1

それから、この抽象構文木をおおよそ次のようなバイトコード(イメージ)にコンパイルします。RubyのVMはスタックマシンで、まあなんとなく読めるかと思います*2

1: getlocal  :json     # ID: 3
2: putobject :articles # ID: 4
3: send :[]            # ID: 2
4: putobject :title    # ID: 5
5: send :[]            # ID: 1

このとき、各命令が由来となったノードのIDを持っているのがRuby 3.1で新たに実装したところで、error_highlightの肝になります。

json[:articles]nilを返し、nilに対して[]メソッドを呼んでしまった場合、5番目のsend命令が失敗します。このとき、5番目の命令は"ID: 1"という参照を持っているので、どのノードで実行失敗したのかがわかります。そして、抽象構文木のノードには"column"情報があるので、コード中のどの範囲でエラーが起きたかという情報を得ることができます。

ID 1のノードのcolumnは 0...23 となっていて、これは json[:articles][:title] という文字列全体の範囲に対応しています。この全体に下線を引くとどこでエラーが起きたかわかりにくいので、error_highlightはなるべくメソッド名の位置を特定して線を引くようにしています。大まかに言えば、レシーバの子ノードである ID 2のノードの終端より後、つまり [:title] の下にだけ下線を引くようになっています。

クレジットと裏話

ノードにカラム情報をもたせるようにしたのはyui-knkさんです(RubyKaigi 2018の発表)。 error_highlightの原型もyui-knkさんが作っていたのですが、放置状態になっていたので、今回遠藤が引き取って完成させました。

なぜ引き取ったかと言うと、RubyKaigi Takeout 2021で遠藤が発表したTypeProf for IDEの実装のために必要だったからです。 IDEではエラー箇所をカラム単位で下線を引いて示すのが普通なので、ほぼ同じ機構が必要でした。 そのために必要な拡張を実装すると、そのおまけとしてerror_highlightが実現可能になりました。

命令ごとに由来となったノードIDを記録するためには、多少メモリを消費してしまいます*3 *4。 ここは、開発体験向上とのトレードオフでした。 各命令にノードIDではなくカラム位置自体をもたせてしまう方法もあるのですが、よりメモリ消費量が大きくなるので、抽象構文木を経由して位置を特定する現在の方法になりました。

なお、抽象構文木自体はメモリに保存されておらず、必要になった時(つまりエラーが発生したとき)に、ファイルにあるソースコードを再度読み込んでパースし直しています。 このアプローチでは、ソースファイルが書き換えられた場合などはノードを正しく同定できなくなる可能性があります。 また、evalで実行されているソースコードについてはerror_highlightは動きません(このため、現在のところirbでは動きません)。 現在のところ隠しコマンドですが、RubyVM.keep_script_lines = trueとすると、インタプリタがソースコードを保持し続けるようになり、ソースコードの再読み込みは行われなくなり、evalについてもerror_highlightが動くようになります。

まとめ

Ruby 3.1では、NoMethodErrorのエラー表示がちょっと親切になります。 ささやかな改良なので過剰に期待されると困りますが、「Ruby 3.1.0-preview1を実際に使ってみたら予想外に開発体験がよくなった」という声をちらほら頂いているので、ほどほどにご期待ください。

*1:実際にはノードはカラム番号だけでなく、行番号も持っていますが、省略しています。

*2:getlocalはローカル変数読み出し、putobjectは即値オブジェクトのpush、sendはメソッド呼び出しです。

*3:機能提案時点の実験では、rails newして作ったWebアプリのメモリ消費量が97 MBから100 MBくらいに増えました。

*4:余談ですがノードIDの記録方法は、ちょっと工夫されています。以前書いた記事『簡潔ビットベクトルでRubyをlog N倍速くした』をご参照ください。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/