こんにちは、クックパッド事業本部 買物サービス開発部の佐藤(@n_atmark)です。
私の所属する買物サービス開発部ではクックパッドアプリにおける買い物機能*1の開発を行なっており、私は2020年の上期から買い物機能のモバイルアプリ開発の担当をしています。
2020年上期〜2021年上期では、クックパッドiOSアプリの買い物機能開発に、
2021年下期からは、クックパッドAndroidアプリの買い物機能開発に携わっています。
クックパッドアプリの買い物機能開発に関する詳細はクックパッド開発者ブログにもまとまっていますので、ぜひ合わせてご覧ください。
前述の通りクックパッドアプリにおける買い物機能を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の組み方が人によって大きく変わってしまい、かえって可読性が落ちてしまっていたり、保守しづらいコードが現れてきていると感じるようになりました。
そこで、チーム内で時間を設けて設計について話す会を設けてみました。
宣言的UIフレームワークを用いたUI組みに関して、暗黙知としてよしなに実装している部分が多く、コンポーネントの分割粒度や、適切なレイアウト設計について悩んでいることが分かりました。
せっかく作って壊しやすいという利点を尊重して採用した宣言的UIフレームワークが、かえって既存の実装を変えたい場合に、既存実装が読みづらく変更に弱い状態になっているのは勿体ないと感じました。
今後もチームで宣言的UIフレームワークを活用していくために、宣言的UIフレームワークを使っていて感じている問題は一つずつ取り除いていけるとよいはずです。
開発者それぞれがコンポーネント分割の粒度を把握できるようにするために
UIの組み方が人によって変わってしまう問題を解決するために、コンポーネントを各開発者が適切な粒度で分割できるように何かしらルールのようなものが存在するとよいのではないか、と考えるようになりました。
チーム内で他のプラットフォームの事例を参考に、コンポーネント粒度の考え方として、Atomic Designに目をつけました。
Atomic Designは分解可能な最小単位でパーツを作成し、パーツを組み合わせてより大きなコンポーネントを作成し、コンポーネントを組み合わせて画面を定義していくようなデザイン手法です。
Atomic Designはモバイルアプリ開発において宣言的UIフレームワークを利用する際の銀の弾丸になりうるのか
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) } }
これはモバイルアプリ開発エンジニアにとっても馴染みの深いコンポーネント分割と言えるでしょう。 意味のあるまとまりでレイアウトを分割することができます。
同じように、これを宣言的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の役割は、複数コンポーネントをとりまとめ、コンポーネント間のマージン調整をすることです。
例えば、下のような実装ができそうです。
// 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レベルのコンポーネント分割をする意味があると言えるでしょう。
アプリ内で共通化されるものは、例えばデザインシステム*3で定義されているボタンなどが挙げられます。
これらは、アプリ内で共通して利用されることが分かっており、利用されるスタイルがデザインシステムによって定められているため、コンポーネント化もしやすそうです。
他にもテキストの実装を考えてみます。場所によってフォントサイズやテキストカラーが異なりますが、これらそれぞれのコンポーネント化は過剰と言えるでしょう。
その代わりに、デザインシステムで定義されているテキスト色やフォントサイズの種類などをスタイルとして提供する方が柔軟に利用できるでしょう。
// 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フレームワークを用いて、保守しやすい状態は保ちつつ、素早くユーザーに価値を届けられるようなサービス開発を一緒に進めてくださるエンジニアを大募集しています。
カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください。
*1:近隣地域の生産者や市場直送の新鮮でおいしい食材を、1品から送料無料で購入できる。https://info.cookpad.com/pr/news/press_2020_1015
*2:説明用に実際のものとは少し構成を変えています
*3:クックパッドではApronというデザインシステムを用いています。 https://note.com/fjkn/n/nf73742ec925a