FlutterKaigi 2022登壇報告と当社モバイルアプリ開発におけるクロスプラットフォーム事情
リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。2022年11月16日(水)から18日(金)の3日間に渡って行われたFlutterKaigi 2022で登壇しました。この記事ではその報告と、それに関連して当社モバイルアプリ開発におけるクロスプラットフォーム事情を紹介します。
発表内容
発表タイトルは「Flutterアプリの安全な変化と拡大を支えるアーキテクチャと単体テスト」です。
プロポーザルにあるとおり、Android公式のアーキテクチャガイドと、PEAKSから出版されている「チームで育てるAndroidアプリ設計」を主な引用元として、変化を続けるFlutterアプリのアーキテクチャや単体テストを解説する基礎的な内容の発表でした。なぜAndroidの情報をFlutterに適用したかというと、Flutterの公式ドキュメントには推奨アーキテクチャが無いですが、モバイルアプリのアーキテクチャはAndroidとFlutterでほぼ同じ考え方が使えるからです。
当社のAndroidアプリ開発体制との関連
当社では現在Flutterは使っていません。しかし今回の発表で紹介した内容はすべて当社のAndroidアプリ開発で取り入れている施策です。一部紹介するとこのような施策を行っています。
- パッケージ配置やクラス、メソッド名などの命名規則をチームの共通認識とする
- 人によって書き方が違う箇所は定例会議の議題にする。
- UIレイヤ、データレイヤだけでなく、任意追加のドメインレイヤも作る。
- Codecovで単体テストの作成抜けを可視化する。
当社のFlutter事情
先ほど当社ではFlutterを使っていないと説明しました。しかし将来に渡ってFlutterを使うことがないとは言えないです。ビジネスの展開によっては新たなアプリを作る可能性があります。そのときの要件やメンバーのバックグランドを考慮して、Flutterがベストな選択と判断したらFlutterを使います。CODEについても今後の状況次第ではソースコード共通化のためにFlutterのApp-to-appを導入する可能性もあります。(可能性の話であり具体的な話にはなっていないです。)
当社のKotlin Multiplatform Mobile事情
iOS/Androidコード共通化の文脈では、Kotlin Multiplatform Mobile(KMM)がよくFlutterと比較されます。
- Flutterは見た目の部分まで共通化できる。
- KMMは状態ホルダ(またはドメインレイヤ)より下の層を共通化して、見た目の部分はiOS/Androidネイティブ(例えばSwiftUI/Jetpack Compose)で記載する。
といった違いがあります。
つい最近まで、もしCODEでiOS/Androidのコード共通化を考えるならば、FlutterのApp-to-appではなくて、KMMだと思っていました。理由としては広告のための外部SDKのFlutter対応が一部ベータ版になっていたため、広告を貼る見た目の部分はiOS/Androidネイティブで作ることになるためです。
しかし広告のための外部SDKの使用状況もビジネスの状況が変化したことで、Flutterにも対応している外部SDKに変更することになりました。事前に動作確認を行う必要はありますが、FlutterのApp-to-appを選択する可能性が高まりました。
まとめ
クロスプラットフォーム事情はカジュアル面談でよく聞かれる質問のため、FlutterKaigi 2022で登壇した報告と併せて紹介しました。
当社リサーチ・アンド・イノベーションでは、例えば、登壇や記事投稿などで積極的に発信したり、iOS/Androidの壁を取りたいiOSエンジニアまたはAndroidエンジニアを募集しています。
iOS TCAを新アーキテクチャとして導入する際に既存のアーキテクチャと同居させる
こちらはiOS Advent Calendar 2022の6日目の記事です。
こんにちは。リサーチ・アンド・イノベーションの小川(J-ogawa)と申します。 弊社サービス CODEのiOS版アプリの開発をしています。
CODEのiOS版ではアーキテクチャを刷新しようとしていまして、候補としてはTCAを考えています。
技術選定の判断要素として、移行期間に新旧を並行して同居させられるかどうかがあると思います。 CODEはアプリの規模が大きく新機能の追加開発中でもあり、一度に全てを移行することが難しかったのでこの点について調査しました。
TCAとは
The Composable Architecture の略。pointfree.coが開発。
システムアーキテクチャの一種で、Reduxとかなり近い構成です。 現時点ではSwift用のライブラリで実現可能な状況なので「TCA」とは現状ではほぼSwift用の用語となっています。
スマホアプリは画面の生存期間が長く、Webアプリと同等以上にRedux的アーキテクチャの恩恵を受けられると思ったので採用の候補にしました。
TCAのメリット
公式より
このライブラリは、さまざまな目的や複雑さのアプリケーションを構築するために使用できる、いくつかのコアツールを提供します。アプリケーションを構築する際に日々遭遇する多くの問題を解決するために、以下のような説得力のあるストーリーが提供されています。
状態管理
- シンプルな値型を使用してアプリケーションの状態を管理し、多くの画面で状態を共有して、ある画面での状態の変更を別の画面ですぐに observe できるようにする方法。
-
- 大きな機能を小さな Component に分解し、それぞれを独立したモジュールに抽出し、簡単にそれらを繋いで機能を形成する方法。
副作用
- 可能な限りテスト可能で理解しやすい方法で、アプリケーションの特定の部分を外界と対話させる方法。
テスト
開発者にとっての使いやすさ
- 上記の全てを、できるだけ少ないコンセプトと動作するパーツからなるシンプルな API で実現する方法。
リソース
公式の動画
TCAだけでなく、Swift・関数型プログラミングがテーマ。 毎週更新されています。無料と有料の動画があり全て見るには月額$14ほどの料金です。
サンプルコード
公式リポジトリに6種類のアプリサンプルがあります。
yimajoさんの記事
日本語で一番詳しく紹介されている方です。
TCAと既存パーツの同居
TCAの新規導入については公式に有益なサンプルがありますので割愛し、この記事では既存プロダクトと並行してTCAを導入する際の実現方法について書きます。
以下の2点を行います。
TCAをUIKit使用の画面に適用
TCAのStateと既存のデータを同期
TCAをUIKit使用の画面に適用
CODEではTCAの導入と同時にSwiftUIの本格導入を行う予定です。しかし、既存画面では主にUIKitを使っていて、すぐにその画面構成を変更することは難しいです。
そこで、データ部分だけでもTCAのStateを使うようにしたいと考えました。
TCAはUIKitでも実は使用可能です(公式リポジトリでも軽く紹介されています)
State, Action, Environmentの例
状態として1つ数値があり、それを インクリメント|デクリメント するアクションがあるものとします。
import Foundation import ComposableArchitecture struct AppState: Equatable { var localState = LocalState() } struct LocalState: Equatable { var amount = 100 } enum AppAction: Equatable { case decrement case increment } struct AppEnvironment { } let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in switch action { case .decrement: state.localState.amount -= 1 return .none case .increment: state.localState.amount += 1 return .none } }
表示画面の例
単純な値表示のラベルと +
, -
で値を増減させるボタンを表示します。
- 値のUIへの紐付けは
Combine
経由でviewStore.publisher.map { ... }.assign(to:)
で行います。 - 値の変更はボタンアクション時に
viewStore.send
でActionを実行します。
import UIKit import Combine import ComposableArchitecture class TCAMoneyViewController: UIViewController { let store: Store<AppState, AppAction> let viewStore: ViewStore<AppState, AppAction> var cancellables: Set<AnyCancellable> = [] init(store: Store<AppState, AppAction>) { self.store = store self.viewStore = ViewStore(store) super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() let label = UILabel() label.font = UIFont.preferredFont(forTextStyle: .title2) label.numberOfLines = 0 let incrementButton = UIButton(type: .system) incrementButton.setTitle("+", for: .normal) incrementButton.addTarget(self, action: #selector(incrementButtonTapped(sender:)), for: .touchUpInside) let decrementButton = UIButton(type: .system) decrementButton.setTitle("-", for: .normal) decrementButton.addTarget(self, action: #selector(decrementButtonTapped(sender:)), for: .touchUpInside) let rootStackView = UIStackView(arrangedSubviews: [ label, incrementButton, decrementButton, ]) rootStackView.isLayoutMarginsRelativeArrangement = true rootStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) rootStackView.translatesAutoresizingMaskIntoConstraints = false rootStackView.axis = .vertical rootStackView.spacing = 24 view.addSubview(rootStackView) NSLayoutConstraint.activate([ rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) viewStore.publisher .map { "Amount: \($0.state.amount)" } .assign(to: \.text, on: label) .store(in: &self.cancellables) } @objc private func incrementButtonTapped(sender: UIButton) { self.viewStore.send(.increment) } @objc private func decrementButtonTapped(sender: UIButton) { self.viewStore.send(.decrement) } }
簡単な例ですが、これでUIKit上でTCAのStateを扱うことができました。
TCAのStateと既存のデータを同期
次に、以下の順でデータ面での同期を実現していきます。
- TCAでの変更を既存データに反映
- 既存データの変更をTCAに反映
TCAでの変更を既存データに反映
強引ですが、TCAのState内の変数を監視し、そこで処理をしました。
struct LocalState: Equatable { var amount = 100 { didSet { OldDataModel.sharedInstance().setValue(amount, forKey: "amount") } } }
CODEの既存アーキテクチャではこの OldDataModel
のプロパティを監視していてここを書き換えるとUIが変更されるようにしています。
実際にこれでTCA側のStateの変更に旧画面の変更を同期させることができました。
既存データの変更をTCAに反映
- TCAのStoreをシングルトンにして既存パーツから扱えるようにする
- 値更新用のアクションを作成する
- シングルトンに対し該当アクションを実行できるメソッドを作って旧データソース更新時に叩く
値同期用のアクション(setAmount)と外部から叩けるメソッド(syncLocalState)を用意
enum AppAction: Equatable { case decrement case increment case setAmount } let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in switch action { case .decrement: state.localState.amount -= 1 return .none case .increment: state.localState.amount += 1 return .none case .setAmount(let value): state.localState.amount = value return .none } } class TCACore: NSObject { private static let store = Store(initialState: AppState(), reducer: appReducer, environment: AppEnvironment()) static func syncLocalState(amount: Int) { ViewStore(store).send(.setAmount(value: value)) } }
旧データソースが値(amountとする)を更新したタイミングで以下を呼び出す
TCACore.syncLocalState(amount: amount)
こちらでも旧データソース変更処理とTCA適用の画面の表示を同期させることができました。
まとめ
TCAは既存の実装と同居させることが可能でした。
TCAはSwiftUI用の便利な機能を多く提供しているのでSwiftUIでこそ真価を発揮すると思いますが、一度に全面刷新するのが無理な場合でも導入できそうです。
自分としては単一のデータソースにアプリの状態が集約されるところが魅力なので、まずは同居させ、徐々にTCAの洗練された書き方を導入していければと考えています。
リサーチ・アンド・イノベーションではエンジニアを募集中です。
興味を持っていただけた方はお気軽にご連絡ください!
SwiftUI カスタマイズTabBar
リサーチ・アンド・イノベーションのhongです。iOSエンジニアをやっています。最近SwiftUIを触り始めました。メモを残したいので、Blogの初投稿をしようと思います。今回はSwiftUIでカスタマイズのTabBar作成について解説いたします。SwiftUIの自由度はとっても高いと思いますので、他の作り方もあると思います。
この記事の構成
この記事は3セッションに分けようと思います
セッション1
はSwiftUIが提供するTabBarを紹介します。
セッション2
は提供されたTabBarを使わずにViewでTabBarを作ります。
セッション3
はちょっとカスタマイズの要素を入れます。
セッション1 SwiftUIが提供するTabBar
とりあえず公式のTabBarをみてみましょう。
タブを二つ追加して、テキストとイメージを設置します。それぞれタップ可能です。
コードは以下となります。
TabBar.swift
import SwiftUI struct TabBar: View { var body: some View { TabView { HomeView() .tabItem { Image(systemName: "house") Text("ホーム") } CalendarView() .tabItem { Image(systemName: "calendar") Text("家計簿") } } } }
HomeView.swift
import SwiftUI struct HomeView: View { var body: some View { Text("ホーム") }
CalendarView.swift
import SwiftUI struct CalendarView: View { var body: some View { Text("家計簿") } }
ただの数行でこの様な効果が出ます。タップイベントは自動で設定されますし、UIKitと比べるととてもシンプルですね。 ちなみに使ったアイコンはSF Symbolsから探しました。
Appleが提供したTabBarはとても使いやすいですが、場合によって自分達でカスタマイズする必要あります。例えば真ん中のTabを大きくして、ユーザーにアピールしたい場合など。
例えば、弊社サービスCODEのアプリのTabBarはそうなっていたりします。
セッション2 Tab Barを作る
先ずは一個のタブのデータモデルを設計しましょう、タブが含む要素は多いですが、とりあえず重要な物から書きます:
- タイトル
- アイコン
- 識別子(タイトルを使ってもいいですが、あった方が見やすいかと)
最低限の要素を書いたので、モデルを書きましょう。以下となります:
Tab.swift
import SwiftUI enum Tab { case home case explore case review case calendar } struct TabItem: Identifiable { let id = UUID() let text: String let icon: String let tab: Tab } // sample data var tabItems = [ TabItem(text: "ホーム", icon: "house", tab: .home), TabItem(text: "検索", icon: "magnifyingglass", tab: .explore), TabItem(text: "評価", icon: "square.and.pencil.circle", tab: .review), TabItem(text: "家計簿", icon: "calendar", tab: .calendar) ]
モデルを作りました。Tabのenumを追加していて、これを識別子として使う予定です。一応後で使うサンプルデータも作りました。
モデル作成した後はいよいよSwift UIの出番ですね。とりあえずタップなしUIだけを作成します、いい感じに出来ています:
早速コードをみてみましょう:
TabBar.swift
struct TabBar: View { var body: some View { ZStack(alignment: .bottom) { ContentView() .frame(maxWidth: .infinity, maxHeight: .infinity) HStack { ForEach(tabItems) { item in VStack(spacing: 0) { Image(systemName: item.icon) .symbolVariant(.fill) .font(.body.bold()) .frame(width: 44, height: 29) Text(item.text) .font(.caption2) .lineLimit(1) } .frame(maxWidth: .infinity) } } .padding(.horizontal, 8) .padding(.top, 14) .frame(height: 88, alignment: .top) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 34, style: .continuous)) .frame(maxHeight: .infinity, alignment: .bottom) .ignoresSafeArea() } } }
ZStack
の中にHStack
を入れてます。HStack
はよく使うかもしれませんが、ZStack
というものがあります。これはZ軸の概念があって、後から入れたビューが前に入れていたビューをブロックします。なので、最後に入れたHStack
はContentView
の上にいます、TabBarぽいですね。
最後にMaterial
指定のバックグラウンドを入れました。一行で綺麗なガラス効果を入れられるのは楽です。(ちなみにContentView
はただのオレンジビューでした)
UIは大体完成しました、あとはタップイベントを追加ですね。ボタンを入れて簡単にできると思います。
まず同じように完成した効果を見ましょう:
タップした際に画面が変更されてます。そして選択されたタブにはハイライトが付き、他のタブがグレーアウトします。
コードは以下となります
TabBar.swift
struct TabBar: View { // 選択されたタブのプロパティを追加 @State var selectedTab: Tab = .home var body: some View { ZStack(alignment: .bottom) { Group { // 選択したタブごとContentViewを切り替える switch selectedTab { case .home: ContentView() case .explore: ContentView2() case .review: ContentView3() case .calendar: ContentView4() } } .frame(maxWidth: .infinity, maxHeight: .infinity) HStack { ForEach(tabItems) { item in // VStackの外にButtonを追加して、ボタンのアクションにはitem.tabへの変更を設定します。そしてImageとTextをlabel:内に移動します Button { selectedTab = item.tab } label: { VStack(spacing: 0) { Image(systemName: item.icon) .symbolVariant(.fill) .font(.body.bold()) .frame(width: 44, height: 29) Text(item.text) .font(.caption2) .lineLimit(1) } .frame(maxWidth: .infinity) } .foregroundStyle(selectedTab == item.tab ? .primary : .secondary) } } .padding(.horizontal, 8) .padding(.top, 14) .frame(height: 88, alignment: .top) .background(.ultraThinMaterial) .frame(maxHeight: .infinity, alignment: .bottom) .ignoresSafeArea() } } }
コード内書いたコメント以外にもいくつか説明したい点があって:
- Group
について
- `selectedTab`の`switch`は`Group`内にいれています。`Group`内部全てのViewに同じmodifierを適用します。
Button
のforegroundStyle
について- secondaryとprimaryを使っています。secondaryの方はprimaryに比べて、わずかに透明で背後の色が少し透けて見えます。
item.tab
について、- 忘れたかもしれませんが、Tabのモデル作った時に入れてます^_^
自作のTabBarが大体完成しました。あとはカスタマイズの要素を入れましょう
セッション3 カスタマイズの要素を入れる
カスタマイズの要素を入れる点についてですが、そこまで珍しいものを入れるのではなく、とりあえずタブ上部に細いバーを追加しようと思います。
完成の効果をみてみましょう
タブの上に細いバーを入れました、アイコンだけの時より見やすいと思います。
では、コード側をみてみましょう
TabBar.swift
struct TabBar: View { @State var selectedTab: Tab = .home // 新しく追加されたプロパティです // バーのカラーとバーの長さです @State var color: Color = .black @State var tabItemWidth: CGFloat = 0 var body: some View { ZStack(alignment: .bottom) { // 前と同じなので省略します... HStack { ForEach(tabItems) { item in Button { selectedTab = item.tab } label: { VStack(spacing: 0) { Image(systemName: item.icon) .symbolVariant(.fill) .font(.body.bold()) .frame(width: 44, height: 29) Text(item.text) .font(.caption2) .lineLimit(1) } .frame(maxWidth: .infinity) } .foregroundStyle(selectedTab == item.tab ? .primary : .secondary) // overlayを置くと、buttonと同じframeの層を作ります .overlay( // GeometryReaderを使って buttonの長さを取得します GeometryReader { proxy in // ----- 参照1 ----- Color.clear.preference(key: TabWidthPreferenceKey.self, value: proxy.size.width) } ) .onPreferenceChange(TabWidthPreferenceKey.self) { value in tabItemWidth = value } } } .padding(.horizontal, 8) .padding(.top, 14) .frame(height: 88, alignment: .top) .background(.ultraThinMaterial) // overlayを使ってバーを一番上に置きます .overlay( overlay ) .frame(maxHeight: .infinity, alignment: .bottom) .ignoresSafeArea() } } var overlay: some View { HStack { // ----- 参照2 ----- if selectedTab == .calendar { Spacer() } if selectedTab == .explore { Spacer() } if selectedTab == .review { Spacer() Spacer() } Rectangle() .fill(color) .frame(width: 28, height: 5) .cornerRadius(3) .frame(width: tabItemWidth) .frame(maxHeight: .infinity, alignment: .top) if selectedTab == .home { Spacer() } if selectedTab == .explore { Spacer() Spacer() } if selectedTab == .review { Spacer() } } .padding(.horizontal, 8) } }
Tab.swift
import SwiftUI //... struct TabWidthPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } }
コード内に書いた 参照
コメント箇所について解説します:
- 参照1
について
- まずGeometryReader
を使ってますが、シンプルにproxy取得場所内にtabItemWidth = proxy.size.width
を書くとエラーが発生します。ここの値を取りたい場合、PreferenceKey
を使わないとダメです。Tab.swift内でTabWidthPreferenceKey
を追加しています。そうすると.preference
でproxy
内の値取り出すことが可能です。
- 取ったあと.onPreferenceChange
で値を保存できます。
参照2
について.overlay
で一番上にバーを置きます。バーの出る場所ですが、選択されたタブの上にしたいので、ここはSpacer
を使いました。ホームの場合右に一個Spacer
を入れます。検索の場合右二個左一個入れます。評価の場合は右一個左二個入れます。家計簿は左一個入れます。- Rectangleのframeを3回設定してます、一回目はバーのframeを設定(width: 28, height: 5)、二番目はバーを真ん中に表示するため先ほど取得した
tabItemWidth
をwitdhに設定します、そして三つ目はmaxHeight: .infinity
にして高さを最大にします、そうするとバーが一番上に表示されます。
切替が若干味気ないのでアニメーションをいれましょう。SwiftUIのアニメーションはとてもシンプルでわかりやすいです、selectedTab = item.tab
をwithAnimation
に入れるだけです。
TabBar.swift
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { selectedTab = item.tab }
完成しました、最後の効果をみてみましょう。ついでにLandscapeでも確認します。
問題なさそうですね。
まとめ
SwiftUIを実際にやるとやはりUIKitと大きい違いがありますね、でも慣れるとスピードも上がると思います。最も重要な点はxibとさよならできることだと思います。今後もSwiftUI移行を前向きに取り組んでいきます。
参考
リサーチ・アンド・イノベーションではエンジニアを募集中です。
興味を持っていただけた方はお気軽にご連絡ください!
Dangerでプルリクエストのチェックを自動化する
こんにちは。
リサーチ・アンド・イノベーションでAndroidエンジニアをしている jageishi です。
今回は、弊社で利用している Danger の簡単な説明と導入事例を紹介します。
Dangerとは
Dangerはプルリクエストの形式化や機械的なチェックを自動化するのに使える便利なツールです。
以下のような特徴があります。
- Dangerfileにルールを記述する
- ルールはプラグインとして公開されているものがある
- 様々なコードホスティングサービスをサポートしている
- Ruby版の他にJavaScript、Kotlin版等が存在する
Dangerの導入
今回はRuby版を使ってGitHub ActionsのワークフローでDangerを実行して、プルリクエストにコメントする方法を例に説明します。
Gemfile
公式からもBundlerの利用が推奨されているため、Gemfileを用意します。
# frozen_string_literal: true source "https://rubygems.org" gem "danger"
Dangerfile
次に、Dangerfileをプロジェクト直下のディレクトリに作成します。
message("メッセージ")
今回はメッセージをコメントするだけですが、他にも様々な機能が用意されているため細かいチェックを行うことが可能です。
GitHub Actions
pull_request
イベントをトリガーとして実行されるワークフローを作成します。
name: Danger on: pull_request: jobs: run: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: ruby/setup-ruby@v1 with: ruby-version: '3.0' bundler-cache: true - run: bundle exec danger env: DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Rubyを使用するために ruby/setup-ruby を利用しています。 こちらのアクションはデフォルトでBundlerもインストールされるため、別途gem install bundler
を実行する必要はありません。
また、bundler-cache: true
を設定することで併せて bundle install
が実行され、gemがキャッシュされるようになります。
最後に環境変数 DANGER_GITHUB_API_TOKEN
にアクセストークンを設定して bundle exec danger
を実行すればOKです。
プルリクエスト
以上の設定を行うことで、プルリクエストの作成時に以下のようにコメントされるようになります。
このように導入のハードルが低く、手軽に試すことができます。
弊社の導入事例
ここからは弊社での導入事例を紹介します。
CODEのAndroidアプリ開発で利用しているDangerfileの構成は以下の通りです。
danger.import_plugin("danger/plugins/*.rb") # Android Lint android_lint.gradle_task = "app:lintFlavorDebug" android_lint.report_file = "app/build/reports/lint-results-flavorDebug.xml" android_lint.filtering = true android_lint.lint # 画面密度毎に画像リソースが存在するか確認する image_resource_checker.check # Roomマイグレーションの有無を確認する schema_changes_checker.check
かなりシンプルな内容ですが、以下のような流れになっています。
1. プラグインの読み込み
danger.import_plugin("danger/plugins/*.rb")
Dangerはプラグインとしてルールを配布できますが、私達はDangerfileの肥大化を防ぐ目的でローカルにプラグインを作成してファイルを分離しています。
こちらはそれらのプラグインを読み込んでDangerfile内で参照できるようにするものです。
2. Android Lintの実行
android_lint.gradle_task = "app:lintFlavorDebug" android_lint.report_file = "app/build/reports/lint-results-flavorDebug.xml" android_lint.filtering = true android_lint.lint
Android Lintの実行には loadsmart/danger-android_lint を利用しています。Androidアプリ開発者にはおなじみかもしれません。
Android版CODEはマルチモジュール構成になっているため、checkDependencies true
を設定することで全てのモジュールに対してチェックが行われるようにしています。
android { lintOptions { checkDependencies true } }
前もって各モジュールに対してLintを実行しておいて、出力された複数のレポートを処理する方法もよく見かけますね。
3. 画面密度毎の画像リソースのチェック
image_resource_checker.check
module Danger class ImageResourceChecker < Plugin attr_accessor :target_densities, :target_extensions def target_densities return @target_densities || ["mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"] end def target_extensions return @target_extensions || ["png", "jpg"] end def check renamed_files = git.renamed_files target_files = git.added_files \ + git.modified_files \ + renamed_files.map { |f| f[:after] } \ - git.deleted_files \ - renamed_files.map { |f| f[:before] } regex = /drawable-(#{target_densities.join("|") })\/.*\.(#{target_extensions.join("|")})$/ target_image_files = target_files.filter { |f| regex.match?(f) } checked_files = [] missing_density_files = [] target_image_files.each do |f| next if checked_files.include?(f) target_densities.each do |d| image_file = f.gsub(/drawable-(#{target_densities.join("|")})/, "drawable-#{d}") missing_density_files << image_file unless File.exists?(image_file) checked_files << image_file end end unless missing_density_files.empty? header = "### 画像ファイルを追加してください:pray:\n" header << "| ファイル |\n" header << "| --- |\n" message = missing_density_files.map { |f| "| `#{f}` |\n" }.join markdown(header + message) end end end end
こちらでは画像リソースに変更が加えられた際に、画面密度毎に用意されているかをチェックしています。
Android LintにIconDensitiesというissue idがあり、まさに同じようなチェックを行ってくれるのですが、以下の理由から自作することにしました。
- ディレクトリ毎に検出されるためdanger-android_lintで
android_lint.filtering = true
を指定して対象を追加、および変更されたファイルに絞っている場合はプルリクエストにコメントされない - xxxhdpiがチェック対象外になっている
4. Roomマイグレーション有無のチェック
schema_changes_checker.check
module Danger class SchemaChangesChecker < Plugin def check schema_json_path = "<Your schema location>" has_changed_schema = (git.added_files + git.modified_files).filter { |f| f.start_with?(schema_json_path) }.any? if has_changed_schema markdown_text = "### マイグレーションが必要です\n" markdown_text << "- [ ] マイグレーション処理を実装済み\n" markdown_text << "- [ ] Google Play公開バージョンからのマイグレーションをテスト済み\n" markdown(markdown_text) end end end end
弊社ではRoomを利用しており、スキーマファイルを出力してリポジトリに含める運用になっています。
こちらはスキーマファイルに変更が加えられた際に、マイグレーションが必要である旨をコメントして確認を促すようなものです。
スキーマが変更されたもののマイグレーション処理が抜けた状態でメインのブランチにマージされてしまい、アプリをアップデートした際にエラーが発生してしまう状態になっているのをリリース前のテストまで気づけなかったことがありました。 そういった経緯もあり、対応漏れを未然に防ぐためにチェックを行っています。
出力内容
出力は以下のようになります。
ちなみに、同じプルリクエストでDangerが再度実行された場合は、すでにDangerによって投稿されたコメントが更新されるような挙動になっており、プルリクエストがコメントで溢れかえってしまうようなことはありません。
こういった部分にも配慮されているため、Dangerはとても使い勝手の良いツールだと思います。
まとめ
Dangerと弊社での導入事例について紹介しました。
チェックが自動化されることで、問題の早期発展に繋がったり、コードレビューの負担を減らせていると感じています。
導入済みの方も多いと思いますが、まだ導入していないという方は是非お試しください。その際にこの記事が少しでも参考になれば幸いです!
便利な機能は他にもたくさんありますので、詳しくは 公式ホームページ のガイドやリファレンスをご覧ください。
リサーチ・アンド・イノベーションではエンジニアを募集中です。
興味を持っていただけた方はお気軽にご連絡ください!
Jetpack Composeを本番投入しました(画面遷移編)
リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。前回はAndroid版CODEアプリにおけるJetpack Compose使用画面でのマルチモジュール構成について解説しましたが、今回は画面遷移における技術選定について解説いたします。
3部構成
この記事は3部構成の第3部です。
- ViewModel編
- マルチモジュール編
- 画面遷移編(今回)
Jetpack Composeにおける画面遷移の方法一覧
DroidKaigi 2021でのセッション「プロダクトレベルで必要になる Jetpack Compose テクニック」では、3種類の画面遷移の方法が紹介されていました。
Activity | 画面遷移 |
---|---|
複数Activity | Activity遷移、Fragment差し替え |
1 Activity | Fragment差し替え |
1 Activity | Compose |
結論
CODEでは複数のActivityをstartActivityメソッドやActivity Result APIで遷移することで、画面遷移を実現しています。言い換えると1 Activityは1画面分のComposable関数しか持っていません。そのような技術選定になった経緯を次の節から解説します。
複数Activityになった経緯
Jetpack Composeを採用する前の話
Jetpack Composeを採用する前から複数Activityの構成になっていました。2020年にホーム画面をリニューアルしたときに、Jetpack Navigationを一時期採用していましたが、最終的に複数Activityの構成になりました。理由はホーム画面はアプリ内広告のAdMobを設置する画面であったことで、AdMobのバナー広告はアドネットワークからダウンロードしたコンテンツをViewの中に持つ作りに対して、Jetpack Navigationは前の画面に戻ったときもFragment内のViewが再生成される挙動のため、相性が良くなかったからです。Jetpack Navigationを使うと、AdMobバナー広告を持つ画面に戻ったときに再びアドネットワークからコンテンツをダウンロードすることを防ぐために、AdMobのViewを剥がしてまた付けるという分かりにくいコードを含むことになります。それがJetpack Navigationを使うことのメリットを上回ると思わなかったので、結局複数Activityを使い続けていました。
とりあえずNavigation Composeを採用
当初はNavigation Composeを使っていました。前々回の記事で繰り返し一括登録機能の2画面(一覧画面、編集画面)をJetpack Composeで開発したことを解説しましたが、その2画面をこのような構成でNavigation Composeで遷移していました。ViewModelの注入はkoin-androidx-composeで行っていました。
/** * 一覧画面 */ private const val LIST_PATH = "list" /** * 編集画面 */ private const val EDIT_PATH = "edit/{id}" /** * 繰り返し一括登録画面の一覧と編集の2画面を含むComposable関数 * * @param subscriptionViewModel SubscriptionActivityを制御するためのViewModel */ @Composable fun SubscriptionContent( subscriptionViewModel: SubscriptionViewModel ) { val navController = rememberNavController() NavHost(navController = navController, startDestination = LIST_PATH) { composable(LIST_PATH) { // 一覧画面 val viewModel = getViewModel<SubscriptionListViewModel>() SubscriptionListScreen(viewModel = viewModel, navigateToEdit = { id -> // 編集画面に遷移する navController.navigate("edit/$id") }) { // 前のActivity(家計簿設定画面)に戻る subscriptionViewModel.backToHouseholdSetting() } } composable( EDIT_PATH, arguments = listOf( navArgument("id") { type = NavType.LongType } ) ) { backStackEntry -> // 編集画面 val id = backStackEntry.arguments?.getLong("id") ?: 0L val viewModel = getViewModel<SubscriptionEditViewModel>() SubscriptionEditScreen(viewModel = viewModel, id = id) { // 一覧画面に戻る navController.popBackStack() } } } }
Firebase Analyticsによる計測を優先して、複数Activityになった
しかし最終的にはこのように1Activityに1画面分のComposable関数を持つ構成となりました。
そうなった理由はFirebase Analyticsです。Firebase Analyticsには画面遷移の情報を自動で送信する機能があります。イベント名がscreen_viewでActivityクラス名がfirebase_screen_classパラメータで送信されます。このような情報はデータ分析エンジニアやAndroidエンジニアが利用実態を把握して、改修方針を検討するために利用されます。
しかしNavigation Composeでは画面遷移の情報を自動で送信しません。よって当初はNavController.OnDestinationChangedListenerを使用して独自に送信しようと考えていました。
@Composable fun SubscriptionContent( subscriptionViewModel: SubscriptionViewModel ) { val navController = rememberNavController() navController.addOnDestinationChangedListener { _, destination, _ -> when (destination.route) { LIST_PATH -> { // 一覧画面への遷移イベントの送信 // screen_view(firebase_screen_class=SubscriptionListScreen) subscriptionViewModel.onList() } EDIT_PATH -> { // 編集画面への遷移イベントの送信 // screen_view(firebase_screen_class=SubscriptionEditScreen) subscriptionViewModel.onEdit() } else -> { } } } // 略 }
しかし、編集画面(SubscriptionEditScreen)から、Jetpack Compose導入以前から存在したActivityであるカテゴリ選択画面(CategorySelectActvity)を呼ぶ実装があり、そこで新たな問題が発覚しました。
こちらの動画のような画面遷移を想定します。
- 一覧画面
- 編集画面
- カテゴリ選択画面
- 編集画面
理想としては、このように画面遷移を送信したかったです。
- SubscriptionListScreen (一覧画面)
- SubscriptionEditScreen (編集画面)
- CategorySelectActivity (カテゴリ選択画面)
- SubscriptionEditScreen (編集画面)
しかし実際はこのような送信になってしまいました。
- SubscriptionListScreen (一覧画面)
- SubscriptionEditScreen (編集画面)
- CategorySelectActivity (カテゴリ選択画面)
- SubscriptionActivity (一覧画面と編集画面をNavigation Composeで持つActivity)
SubscriptionEditScreenでCategorySelectActivityから戻ってきた時もSubscriptionEditScreenに遷移したイベントを送ることで解決しようと思いましたが、実装漏れを起こしてデータ分析エンジニアに迷惑をかけてしまう懸念がメンバーから上がりました。よって最終的にはチームとして、計測を重視してNavigation Composeは使わないという結論になりました。
まとめ
今回はCODEにおけるJetpack Compose使用画面の画面遷移について、技術選定の様子を解説しました。これが正解ということでは無く、選択当時のメンバーによるその時点での状況を踏まえたディスカッションの結果、複数Activityによる画面遷移になりました。
これまで3回にわたり、Jetpack Compose本番投入の様子を紹介してきました。本番投入はうまくいったので、新画面ではJetpack Composeを積極的に使用していく方針です。そして、まだまだベストプラクティスを模索しながら実装している状態です。メンバーの増員や新たな課題、Kotlin Multiplatform Mobile導入機運の高まりによっては、また別の技術やアーキテクチャを採用する可能性があります。
Androidエンジニア募集中
弊社リサーチ・アンド・イノベーションでは、例えば技術選定をディスカッションしたいAndroidエンジニアを募集しています。
Jetpack Composeを本番投入しました(マルチモジュール編)
リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。前回はAndroid版CODEアプリにおけるJetpack Compose使用画面でのViewModel設計について解説しましたが、今回はマルチモジュール構成について解説いたします。
3部構成
この記事は3部構成の第2部です。
- ViewModel編
- マルチモジュール編(今回)
- 画面遷移編
Jetpack Composeのプレビューを速くする
Jetpack Composeを採用した画面ではマルチモジュールの構成を変更しているのですが、そのモチベーションはJetpack Composeのプレビューを速くするためです。DroidKaigi/conference-app-2021でも言及されている通り、プレビュー対象のComposable関数の依存モジュールを必要最低限にすることによって、プレビューを速くしています。1度フルビルドが完了した後のプレビューの再表示のためのビルドは、3〜5秒ほどで完了します。(M1 Max, 64GBメモリで計測)
Jetpack Compose不使用画面のモジュール構成
Jetpack Composeを使っていない画面のモジュール構成は前々回に解説したアーキテクチャに沿って、このようになっています。
機能別モジュールにはホーム画面や買い物登録など、大まかに分類された各機能のActivity/Fragment、リソース、ViewModelとUse Caseのクラスを格納しています。各機能で共通して使われるリソースやAPIエラー処理などはviewCommonモジュールに置いています。別モジュールにあるActivityを呼び出すためのActivity aliasもこちらに置いています。 このモジュール構成のまま機能別モジュールにComposable関数を実装すると、プレビューのためのビルドでrepository、localDataStore、remoteDataStoreモジュールが含まれてしまい時間がかかります。よってComposable関数を持つモジュールではそれらを含まないようにしています。
Jetpack Compose使用画面のモジュール構成
Jetpack Composeを使う画面では、機能別モジュールをこのようにpresentation、compose、useCaseの3モジュールに分けました。モジュール名の先頭に付いているsubscriptionは前回説明した繰り返し一括登録機能のモジュール群であることを表し、機能ごとにディレクトリが分かれます。
composeモジュール
Composable関数とそのプレビューを持つcomposeモジュールは、viewCommonとcomposeCommonモジュールにしか依存しないため、プレビューを速く表示できます。 ViewModelのインターフェースと固定の状態を持つプレビュー用ViewModel実装もこちらに置きます。
composeCommonモジュール
Jetpack Composeで実装する複数の機能で共通して使われるComposable関数はこちらに置きます。テーマやツールバー、エラー表示などがあります。
presentationモジュール
CODEはActivityによる画面遷移を行っているため、このようにComposable関数を使っています。
class SubscriptionListActivity : ComponentActivity() { /** * ViewModel実装の注入。Koinを使用。 */ private val viewModel: SubscriptionListViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { // テーマ CodeComposeTheme { // 繰り返し一括登録の一覧画面(composeモジュール) SubscriptionListScreen(viewModel) } } } }
ViewModel本番実装もprensetationモジュールに持っていて、内部ではUseCaseを呼び出しています。
useCaseモジュール
各種UseCaseクラスを持っています。ViewModel実装とUseCaseは同じモジュールでも良いと思ったのですが、適切な名前が思いつかなかったことと、今後のチームの拡大を考えるとレイヤードアーキテクチャに対する強制力を持たせた方が良いと思い、UseCase用のモジュールを作ることにしました。
まとめ
今回はCODEに対するJetpack Composeの導入について、マルチモジュール構成について説明しました。Composable関数とそのプレビューに必要なクラスのみをモジュールに切り出して、そのモジュールが依存するモジュールを最小限にすることで、許容範囲の3〜5秒ほどのプレビュー表示時間を実現しました。それによってJetpack Composeを導入したことで、開発体験が下がる事態を防ぐことができました。 次回はActivityやJetpack Navigationなど、複数のアプローチがある画面遷移について、CODEはどのような事情があって何を選定したかの記事を公開予定です。
Androidエンジニア募集中
弊社リサーチ・アンド・イノベーションでは、例えば開発体験にもこだわりがあり一緒に向上して頂けるAndroidエンジニアを募集しています。
Jetpack Composeを本番投入しました(ViewModel編)
リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。去年、Android版CODEアプリのアーキテクチャと使用ライブラリを執筆した時点ではJetpack Composeを導入してなかったのですが、2月からJetpack Composeによる開発を開始して、6月にそれを使った新機能をリリースできました。そして現在、新機能の開発はすべてJetpack Composeで行っています。この記事ではAndroid版CODEアプリ開発チームにおける、Jetpack Compose導入のモチベーションや最初に採用した機能、技術選定の結果などを紹介します。
3部構成
この記事は3部構成を予定しています。
- ViewModel編(今回)
- マルチモジュール編
- 画面遷移編
導入のモチベーション
品質の向上
Jetpack Composeは宣言的UIであり、UIには状態を持たず、ViewModel(この記事ではUIの状態管理担当クラスをViewModelと呼ぶようにします)にUIの状態を持っています。すでにCODEではViewModelに対して単体テストを書いてCIでチェックする体制ができているので、既存のViewを使うよりもデグレードを防げる範囲を広げることができます。カバレッジもJaCoCoとCodecovで確認しています。
タスクを分割しやすくする
Jetpack ComposeにはAndroid Studioによるプレビュー機能があります。まだ、データレイヤーやドメインレイヤーがまだ作られていなかったり、新規にジョインしたエンジニアにそれらをいきなりお任せするのは難しいときでも、先行してComposable関数を作って頂くことが可能です。プルリク単位では、Android Studioによるプレビューを持って完了とすることが可能です。
最初にJetpack Composeを採用した機能
CODEには家計簿としての機能があります。
カレンダー | グラフ |
---|---|
さらに、繰り返し一括登録という、例えば毎月スマホの通信費に3000円使っているといった定期的な収入・支出を登録する機能がありますが、長くiOS版にありAndroid版に無い状態が続いていました。開発できるタイミングが来て、新規に2画面作成することになったので、このタイミングで導入しました。
繰り返し一括登録の一覧 | 繰り返し一括登録の編集 |
---|---|
参考にしたOSS
ViewModelの書き方はDroidKaigi/conference-app-2021を参考にしています。すべてが一緒というわけではなく、CODEの歴史的経緯や事情に合わせて、変えている部分があります。
ViewModel
ViewModelの共通インターフェース
Jetpack Composeを使う画面で使用するViewModelの共通インターフェースがこちらになります。
interface UnidirectionalViewModel<EVENT, EFFECT, STATE> { /** * 初期状態を返却する */ fun createDefaultState(): STATE /** * 画面の状態 */ val state: LiveData<STATE> /** * 画面に対する効果(画面遷移など) */ val effect: Flow<EFFECT> /** * 画面で発生したライフサイクルイベントやユーザ操作をオブジェクトとして渡す * * @param event イベントオブジェクト */ fun event(event: EVENT) }
DroidKaigi/conference-app-2021ではStateFlowを使用していますが、CODEでは、これまで使用していたViewModelの単体テストにおいてLiveDataの値の変化をチェックするための拡張関数を引き続き使用するために、LiveDataを使用しています。次節から、Jetpack ComposeにおけるLiveDataの使用方法を実装例とともに解説します。
ViewModelの実装例
繰り返し一括登録の一覧画面で登録された情報を削除する部分を例に解説します。
あるユーザは月1000円の動画配信サービスを契約していましたが、解約することにしたので、未来の登録を削除する操作をしました。
※ 削除処理は非同期で行われてるため、すぐには一覧に反映されません。
この操作で使用されたViewModelの実装がこのようになります。 削除処理以外の処理は省略しています。
class SubscriptionListViewModelImpl( private val subscriptionListDeleteUseCase: SubscriptionListDeleteUseCase ) : SubscriptionListViewModel() { override fun createDefaultState() = SubscriptionListState() private val _state = MutableLiveData(createDefaultState()) override val state: LiveData<SubscriptionListState> = _state // effectは省略 /** * 削除対象ID */ private var deleteId = 0L /** * 全期間を削除or未来の登録を削除 */ private var deleteAll = false override fun event(event: SubscriptionListEvent) { viewModelScope.launch { when (event) { is SubscriptionListEvent.Delete -> { // モーダルボトムシートで削除が選択された // 削除対象ID deleteId = event.id // 全期間を削除 or 未来の登録を削除 deleteAll = event.deleteAll // 削除確認ダイアログを表示する _state.update { copy(showConfirmDelete = true) } } SubscriptionListEvent.OkConfirmDelete -> { // 削除確認ダイアログでOKボタンが押された try { // 削除確認ダイアログを閉じる // 削除プログレスを表示する _state.update { copy( showConfirmDelete = false, progress = SubscriptionListProgress.DELETE ) } // 削除処理を実行する val result = subscriptionListDeleteUseCase.execute( deleteId, deleteAll ) // プログレスを非表示にする // 一覧を更新する // 削除完了ダイアログを表示する _state.update { copy( progress = SubscriptionListProgress.NO, items = result.items.map { SubscriptionListStateItem( it.subscription, it.iconSpendingCategory ) }, showEndDelete = true ) } } catch (e: Throwable) { // エラー処理は省略 } } SubscriptionListEvent.CancelConfirmDelete -> { // 削除確認ダイアログでキャンセルされた // 削除確認ダイアログを閉じる _state.update { copy( showConfirmDelete = false ) } } SubscriptionListEvent.CloseEndDelete -> { // 削除完了ダイアログが閉じられた _state.update { copy( showEndDelete = false ) } } } } } }
updateメソッドはMutableLiveDataの値の一部フィールドをdata classのcopyメソッドで更新するための拡張関数です。
fun <T> MutableLiveData<T>.update(action: T.() -> T) { value = requireNotNull(value).action() }
ViewModelのテストコード
前回の記事と同様にMockKを使って単体テストを記述しています。coEveryメソッド等でUseCaseの返却値を定義したのち、ViewModelのeventメソッドを呼び出し、coVerifySequence メソッド等でUseCaseの呼び出しとLiveDataの値の変化を確認しています。LiveDataの値の変化はObserverのonChangedメソッドの呼び出しを確認する形で検証しています。
class SubscriptionListViewModelTest { @Test fun deleteFuture() = runBlocking { val mockStateObserver = viewModel.state.mockObserver("state") // 削除UseCaseの返却を定義する coEvery { subscriptionListDeleteUseCase.execute(3L, false) } returns SubscriptionListDeleteUseCaseResult(/* 省略 */) // 削除を選ぶ viewModel.event(SubscriptionListEvent.Delete(3L, false)) // OKボタンを押す viewModel.event(SubscriptionListEvent.OkConfirmDelete) // 削除完了ダイアログを閉じる viewModel.event(SubscriptionListEvent.CloseEndDelete) // UseCaseの呼び出しとLiveDataの値の変化を確認する coVerifySequence { // 初期状態 mockStateObserver.onChanged(SubscriptionListState()) // 削除確認ダイアログを表示する mockStateObserver.onChanged(SubscriptionListState(showConfirmDelete = true)) // 削除確認ダイアログを閉じる // 削除プログレスを表示する mockStateObserver.onChanged( SubscriptionListState( progress = SubscriptionListProgress.DELETE, showConfirmDelete = false ) ) // 削除処理を行う subscriptionListDeleteUseCase.execute(3L, false) // プログレスを非表示にする // 削除完了ダイアログを表示する // 一覧を更新する mockStateObserver.onChanged( SubscriptionListState( progress = SubscriptionListProgress.NO, showEndDelete = true, items = listOf(/* 省略 */) ) ) // 削除完了ダイアログを非表示にする mockStateObserver.onChanged( SubscriptionListState( progress = SubscriptionListProgress.NO, items = listOf(/* 省略 */) showEndDelete = false ) ) } } }
LiveDataをCompose用のStateに変換する
androidx.compose.runtime:runtime-livedata
ライブラリを使い、LiveDataをCompose用のStateに変換しています。
val state = viewModel.state.observeAsState(viewModel.createDefaultState()).value
LiveDataとStateFlowは似ていますが、LiveDataは初期状態が必須でないところに違いがあります。 参考 StateFlow、Flow、LiveData
なので LiveData<T>.observeAsState
メソッドに初期値を渡さないとnullableな値を持つStateに変換されてしまい使いにくくなってしまいます。そのため、ViewModelの共通インターフェースに初期値としてnon-nullなStateを返すメソッドを定義しています。
Preview用ViewModel
画面全体をAndroid Studioでプレビューするときは、固定の状態を持ったPreview用のViewModelを作成して、Composable関数に渡しています。画面で発生したユーザ操作をEventオブジェクトとして渡す設計は、ユーザ操作ごとにメソッドがある設計とは違い、後で操作が増えてもプレビュー用のViewModel実装の変更が不要な点が良いです。
/** * 繰り返し一括登録の一覧画面のプレビュー用ViewModel * * @param プレビュー用の固定の状態 */ class SubscriptionListViewModelPreview( private val previewState: SubscriptionListState ) : SubscriptionListViewModel() { override fun createDefaultState() = previewState override val state: LiveData<SubscriptionListState> get() = MutableLiveData(createDefaultState()) override val effect: Flow<SubscriptionListEffect> get() = flow { } override fun event(event: SubscriptionListEvent) { } } /** * 繰り返し一括登録の一覧画面のプレビュー(0件ケース) */ @Preview @Composable fun SubscriptionListScreenPreviewEmpty() { CodeComposeTheme { // 0件の時の状態 val previewState = SubscriptionListState( progress = SubscriptionListProgress.NO ) // 固定の状態を持ったViewModelを作成する val viewModel = SubscriptionListViewModelPreview(previewState) // そのViewModelを持ってComposable関数を呼び出す SubscriptionListScreen(viewModel) } }
まとめ
今回はCODEに対するJetpack Composeの導入について、ViewModelの設計を解説しました。UIの状態はLiveDataで持って、プレビューと単体テストに対応することができました。
次回はJetpack Composeのプレビュー高速化ために設計変更したモジュール構成を説明いたします。
Androidエンジニア募集中
弊社リサーチ・アンド・イノベーションでは例えば一緒にJetpack Composeを書きたいAndroidエンジニアを募集しています。