FlutterKaigi 2022登壇報告と当社モバイルアプリ開発におけるクロスプラットフォーム事情

リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。2022年11月16日(水)から18日(金)の3日間に渡って行われたFlutterKaigi 2022で登壇しました。この記事ではその報告と、それに関連して当社モバイルアプリ開発におけるクロスプラットフォーム事情を紹介します。

発表内容

発表タイトルは「Flutterアプリの安全な変化と拡大を支えるアーキテクチャ単体テスト」です。

プロポーザルにあるとおり、Android公式のアーキテクチャガイドと、PEAKSから出版されている「チームで育てるAndroidアプリ設計」を主な引用元として、変化を続けるFlutterアプリのアーキテクチャ単体テストを解説する基礎的な内容の発表でした。なぜAndroidの情報をFlutterに適用したかというと、Flutterの公式ドキュメントには推奨アーキテクチャが無いですが、モバイルアプリのアーキテクチャAndroidとFlutterでほぼ同じ考え方が使えるからです。

当社のAndroidアプリ開発体制との関連

当社では現在Flutterは使っていません。しかし今回の発表で紹介した内容はすべて当社のAndroidアプリ開発で取り入れている施策です。一部紹介するとこのような施策を行っています。

当社のFlutter事情

先ほど当社ではFlutterを使っていないと説明しました。しかし将来に渡ってFlutterを使うことがないとは言えないです。ビジネスの展開によっては新たなアプリを作る可能性があります。そのときの要件やメンバーのバックグランドを考慮して、Flutterがベストな選択と判断したらFlutterを使います。CODEについても今後の状況次第ではソースコード通化のためにFlutterのApp-to-appを導入する可能性もあります。(可能性の話であり具体的な話にはなっていないです。)

当社のKotlin Multiplatform Mobile事情

iOS/Androidコード共通化の文脈では、Kotlin Multiplatform Mobile(KMM)がよくFlutterと比較されます。

といった違いがあります。

つい最近まで、もし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)と申します。 弊社サービス CODEiOS版アプリの開発をしています。

CODEのiOS版ではアーキテクチャを刷新しようとしていまして、候補としてはTCAを考えています。

技術選定の判断要素として、移行期間に新旧を並行して同居させられるかどうかがあると思います。 CODEはアプリの規模が大きく新機能の追加開発中でもあり、一度に全てを移行することが難しかったのでこの点について調査しました。

TCAとは

公式リポジトリ

The Composable Architecture の略。pointfree.coが開発。

システムアーキテクチャの一種で、Reduxとかなり近い構成です。 現時点ではSwift用のライブラリで実現可能な状況なので「TCA」とは現状ではほぼSwift用の用語となっています。

スマホアプリは画面の生存期間が長く、Webアプリと同等以上にRedux的アーキテクチャの恩恵を受けられると思ったので採用の候補にしました。

TCAのメリット

公式より

このライブラリは、さまざまな目的や複雑さのアプリケーションを構築するために使用できる、いくつかのコアツールを提供します。アプリケーションを構築する際に日々遭遇する多くの問題を解決するために、以下のような説得力のあるストーリーが提供されています。

  • 状態管理

    • シンプルな値型を使用してアプリケーションの状態を管理し、多くの画面で状態を共有して、ある画面での状態の変更を別の画面ですぐに observe できるようにする方法。
  • コンポジション

    • 大きな機能を小さな Component に分解し、それぞれを独立したモジュールに抽出し、簡単にそれらを繋いで機能を形成する方法。
  • 副作用

    • 可能な限りテスト可能で理解しやすい方法で、アプリケーションの特定の部分を外界と対話させる方法。
  • テスト

    • アーキテクチャで構築された機能をテストするだけでなく、多くの Component で構成された機能の Integration test を書いたり、副作用がアプリケーションに与える影響を理解するために E2E テストを書いたりする方法。これにより、ビジネスロジックが期待通りに動作していることを強く保証することができる。
  • 開発者にとっての使いやすさ

    • 上記の全てを、できるだけ少ないコンセプトと動作するパーツからなるシンプルな 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軸の概念があって、後から入れたビューが前に入れていたビューをブロックします。なので、最後に入れたHStackContentViewの上にいます、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を適用します。
  • ButtonforegroundStyleについて

    • 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を追加しています。そうすると.preferenceproxy内の値取り出すことが可能です。 - 取ったあと.onPreferenceChangeで値を保存できます。

  • 参照2について
    • .overlayで一番上にバーを置きます。バーの出る場所ですが、選択されたタブの上にしたいので、ここはSpacerを使いました。ホームの場合右に一個Spacerを入れます。検索の場合右二個左一個入れます。評価の場合は右一個左二個入れます。家計簿は左一個入れます。
    • Rectangleのframeを3回設定してます、一回目はバーのframeを設定(width: 28, height: 5)、二番目はバーを真ん中に表示するため先ほど取得したtabItemWidthをwitdhに設定します、そして三つ目はmaxHeight: .infinityにして高さを最大にします、そうするとバーが一番上に表示されます。

切替が若干味気ないのでアニメーションをいれましょう。SwiftUIのアニメーションはとてもシンプルでわかりやすいです、selectedTab = item.tabwithAnimationに入れるだけです。

TabBar.swift

withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
    selectedTab = item.tab
}

完成しました、最後の効果をみてみましょう。ついでにLandscapeでも確認します。

問題なさそうですね。

まとめ

SwiftUIを実際にやるとやはりUIKitと大きい違いがありますね、でも慣れるとスピードも上がると思います。最も重要な点はxibとさよならできることだと思います。今後もSwiftUI移行を前向きに取り組んでいきます。

参考

SwiftUI Handbook


リサーチ・アンド・イノベーションではエンジニアを募集中です。

興味を持っていただけた方はお気軽にご連絡ください!

採用情報

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("メッセージ")

DangerfileはRuby DSLで記述します。

今回はメッセージをコメントするだけですが、他にも様々な機能が用意されているため細かいチェックを行うことが可能です。

使用できるAPIについては こちら をご覧ください。

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. プラグインの読み込み
  2. Android Lintの実行
  3. 画面密度毎の画像リソースのチェック
  4. Roomマイグレーション有無のチェック

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部です。

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で行っていました。

Navigation 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関数を持つ構成となりました。

1Activityに1画面分のComposable関数

そうなった理由はFirebase Analyticsです。Firebase Analyticsには画面遷移の情報を自動で送信する機能があります。イベント名がscreen_viewでActivityクラス名がfirebase_screen_classパラメータで送信されます。このような情報はデータ分析エンジニアやAndroidエンジニアが利用実態を把握して、改修方針を検討するために利用されます。

参考 スクリーン ビューを測定する

BigQueryにエクスポートしたFirebase Analyticsの情報

しかし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エンジニアを募集しています。

採用情報 - 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を使っていない画面のモジュール構成は前々回に解説したアーキテクチャに沿って、このようになっています。

Jetpack Compose不使用画面のモジュール構成
Jetpack Compose不使用画面のモジュール構成

機能別モジュールにはホーム画面や買い物登録など、大まかに分類された各機能のActivity/Fragment、リソース、ViewModelとUse Caseのクラスを格納しています。各機能で共通して使われるリソースやAPIエラー処理などはviewCommonモジュールに置いています。別モジュールにあるActivityを呼び出すためのActivity aliasもこちらに置いています。 このモジュール構成のまま機能別モジュールにComposable関数を実装すると、プレビューのためのビルドでrepository、localDataStore、remoteDataStoreモジュールが含まれてしまい時間がかかります。よってComposable関数を持つモジュールではそれらを含まないようにしています。

*1

Jetpack Compose使用画面のモジュール構成

Jetpack Composeを使う画面では、機能別モジュールをこのようにpresentation、compose、useCaseの3モジュールに分けました。モジュール名の先頭に付いているsubscriptionは前回説明した繰り返し一括登録機能のモジュール群であることを表し、機能ごとにディレクトリが分かれます。

Jetpack Compose使用画面のモジュール構成
Jetpack Compose使用画面のモジュール構成

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エンジニアを募集しています。

採用情報 - Androidエンジニア

*1:説明のため一部のモジュール名を実際のソースコードから変更しています

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を使うよりもデグレードを防げる範囲を広げることができます。カバレッジJaCoCoCodecovで確認しています。

ViewModelの単体テスト
ViewModelの単体テスト

タスクを分割しやすくする

Jetpack ComposeにはAndroid Studioによるプレビュー機能があります。まだ、データレイヤーやドメインレイヤーがまだ作られていなかったり、新規にジョインしたエンジニアにそれらをいきなりお任せするのは難しいときでも、先行してComposable関数を作って頂くことが可能です。プルリク単位では、Android Studioによるプレビューを持って完了とすることが可能です。

Android Studioによるプレビュー機能
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エンジニアを募集しています。

採用情報 - Androidエンジニア