1年かけてサイボウズ OfficeのAndroidアプリをまるごと書き直した話

これはCybozu Advent Calendar 2021の14日目の記事です。

こんにちは、モバイルチームの向井田 (@mr_mkeeda) です✌️

2021年10月14日にサイボウズ Office のAndroid利用を目的とした「サイボウズ Office 新着通知 Android」をアップデートしました。

office-users.cybozu.co.jp

実はこのアップデートは、モバイルチームが1年間取り組んできたサイボウズ Office 新着通知 Androidのコードリプレイス完了を意味していました。 そこで今回は、新しくなったサイボウズ Office 新着通知 Androidのコードについて紹介していきます。

※ 以後、「サイボウズ Office 新着通知 Android」は「Office Android」と呼称します。

この記事を読むにあたって

本記事はDroidKaigi 2021のスポンサーとしてDroidKaigiに寄稿した開発事例記事では詳しく語ることができなかった技術的な内容を紹介していきます。 コードリプレイスに至った経緯や開発メンバーについての話は、開発事例記事にて紹介していますので下記を参照してください。

droidkaigi.jp

方針

製品のコードをイチから書き直すにあたって最も大切にした方針は 実際にコードを書くチーム/メンバーに合った選択をする です。 この方針は今回のコードリプレイスだけでなくOffice Android開発全体に対する方針です。 この方針に基づいた意思決定の例を少し紹介します。

コーディングの意思決定で関わるメンバーを最小にした

Office Androidは私ともうひとりのAndroidエンジニアの2名での専属開発です。 実際にコードを書く2名のみでコーディングの意思決定をしており、Office Android以外の事情は関わりません。 割と当たり前な選択だと思われるかもしれませんが、Office Androidは最初からこの方針だったわけではありませんでした。

コードリプレイス初期の頃は、iOSとAndroidでアーキテクチャを揃える案がありました。 この案の背景にはサイボウズの別アプリであるkintoneモバイルが、iOS/Androidで共通のアーキテクチャによって開発されていた事情があります。 kintoneモバイルはiOS/Androidをどちらも同じメンバーで開発していました。 (詳しくはDroidKaigiの開発事例記事を参照してください)

Office AndroidでもiOSとアーキテクチャを揃えようとしましたが、揃えるとなるとOffice iOSのメンバーも意思決定に関わってきます。 実際にOffice Androidのコードを書かないOffice iOSのチームにOffice Androidのコードの都合を説明するのが大変でした。 そこで、チームメンバーや状況が異なるならば、その時々で最適な選択をしたほうがチームのパフォーマンスが高まると考え直しました。

以上の経緯があって、Office Androidのコーディングの意思決定はAndroidエンジニア2名だけでするスタイルと変わりました。

すべての選択に自分たちの理由をつける

Office AndroidのコードはGoogle I/O, DroidKaigi 2020, Plaid, TiviなどOSSで公開されている様々なアプリを参考にしています。 外部のアプリで用いられている技術や考え方は積極的に取り入れますが、必ずOffice Androidのコンテキストで再解釈した上で取り入れています。

例えば、Office AndroidではGoogleの推奨する技術 (Jetpack, Kotlin Coroutinesなど) を積極的に取り入れています。 この背景には、人手不足で開発が停滞していたOffice Androidが今後二度と同じ轍を踏まないため、できるだけAndroidコミュニティで広く多く使われている技術をベースにすることで開発できる人を絶やさない意図があります。

コード構成

Office Androidのコード構成はAndroid Developersの Guide to app architectureをベースにしています。 ある画面の機能を構成する要素は以下の図のようになっています。

Office Android のコード構成図
Office Android のコード構成図

基本的にはGuide to app architectureを踏襲し、各要素の接続に Kotlin Coroutines を利用しています。 この構成のおかげで一般的に広く利用されているJetpackの恩恵を受けやすく、技術的なハマりどころの多くはAndroidコミュニティで解決策が提示されているというメリットがあります。

Guide to app architectureを読んだことのある方はお気づきと思いますが、Office Androidのコードの特徴はViewModelとRepositoryの間にUseCaseというオブジェクトがいる点です。 UseCaseを作っている理由を説明していきます。

2021/12/17 追記

この記事を投稿した数時間後にGuide to app architectureのドキュメントが更新され、Domain layer というドキュメントが追加されました。 Domain layerではUseCaseの例が出てきており、奇しくもGoogleの推奨するアーキテクチャにより近づく結果となりました。

developer.android.com

状態管理に着目したViewModelの設計

我々が感じるコードの課題として、ViewModelの肥大化があります。 よくサンプルとして紹介されるViewModelは、UIへのデータ提供とインタラクションに応じたデータの変更を担っている場合が多いです。 ViewModelが肥大化してしまう原因は、ViewModelが担っている責務が多いからです。 そこでOffice Androidでは、ViewModelの責務を再定義しました。

Reactの登場により、近年ではGUIアプリケーションの実装はUIの状態管理が肝だという考え方が広まってきています。 Reactの影響を受けたJetpack Composeはまさに状態を入力としたUIの描画を実現しており、今後のAndroidアプリ開発はますます状態管理への関心が増していくでしょう。 Office AndroidはまだJetpack Composeを導入していません。 しかしAndroid ViewでもGUIアプリケーションの本質は変わらないと考え、ViewModelの責務を考えるにあたり状態管理に着目しました。

UIを記述するにあたって必要な状態管理の要素は以下の3つです。

  1. 状態の保持
  2. 状態遷移関数
  3. 状態遷移フローの構築

結論から言うと、Office Androidではこの3要素のうち 状態遷移関数UseCaseという名前でViewModelから切り出しました。 そしてViewModelは 状態の保持状態遷移フローの構築 を責務としています。

状態遷移のイメージ図
状態遷移のイメージ図

状態の保持

Androidにおける理想の状態保持とは、ActivityやFragmentなどのConfiguration Changeに影響されず、UIの状態を表したデータ構造を公開する責務です。 この責務はConfiguration Changeでインスタンスが破棄されないように設計されたandroidx.lifecycle.ViewModelの役割としました。

状態遷移関数

ある入力データを受けてビジネスロジックに従いデータを加工して出力するオブジェクトをUseCaseと定義しています。 UseCaseは状態を持たず、入力された状態を元に新たな状態を作り出す純粋関数を持ちます。 UseCaseの概念を定義したため、ビジネスロジックのテストをしたい場合はUseCaseをテストするだけでよくなりました。 ビジネスロジックのバグは生みにくい構造だと思います。

状態遷移フローの構築

複数のUseCaseを組み合わせて複雑な状態遷移を組み立てます。 具体的にはKotlin Coroutines FlowのmapflatMapLatestオペレータを利用して、UseCaseの入出力をつなぎ合わせます。

Office AndroidのViewModelの実装イメージ

サイボウズ Office内の新着通知を表示する機能を作る場合を取り上げて、ViewModelとUseCaseの実装をイメージしたコードが以下になります。

※実際のOffice Androidの実装から大幅に省略したコードになってますので、実際のコードとは異なります。

class FetchNotificationUseCase(...) {
    suspend fun execute(): Flow<List<Notification>> {
        // バックエンドから新着通知のデータを取ってくる
    }
}

class FilterMentionByMeUseCase(...) {
    suspend fun execute(param: List<Notification>): List<Notification> {
        // 新着通知の中から自分宛ての通知だけ返却する
    }
}

class NotificationViewModel(
    fetchNotificationUseCase: FetchNotificationUseCase,
    filterMentionByMeUseCase: FilterMentionByMeUseCase,
) : ViewModel() {
    private val _displayNotification = MutableStateFlow(emptyList())
    val displayNotification: Flow<List<Notification>> = _displayNotification

    init {
        // オペレータを使って状態遷移関数を接続する
        fetchNotificationUseCase.execite()
            .map {
                filterMentionByMeUseCase.execute(param = it)
            }.collect { notifications ->
                _displayNotification.value = notifications
            }
    }
}

ViewModelではStateFlowを用いて最新のUI状態を保持します。 StateFlowにいれる状態は機能特有のロジックによって加工され、UIを変更させます。 状態はUseCaseのチェーンを経由して変更されていきます。

コードを書き始めた当初は、状態遷移関数はグローバルな関数かFlowやCollectionの拡張関数で良いのではないかと考えました。 しかし、状態の変更ロジックにAPI通信やDB操作が必要となると、状態遷移関数に依存オブジェクトが必要な場合があります。 そのため、DI可能なクラスとしてUseCaseを定義する形に落ち着きました。

状態遷移関数をUseCaseとしてViewModelから切り離したため、ViewModelでは状態遷移フローの構築と状態の保持に専念できます。 個人的にはある機能の状態の流れが俯瞰でき、コードの理解がしやすいと感じています。

一方で上記の例はシンプルな状態遷移の例ですが、実際にはもっと複雑な状態遷移になる機能も存在します。 状態の量や遷移が増えると可読性が低下していき、コード変更による挙動が読みにくくなってしまいます。 WebアプリのRedux、AndroidだとMVIのように、状態遷移フローの定義にもルールを作って状態の交通整理をしてあげると、読みやすく将来の変更に強いコードになるかもしれません。 方針はまだ決まっていませんが、状態遷移フローの定義方法は今後も改善していくつもりです。

モジュール

Office Androidはマルチモジュール構成になっています。

モジュールの依存関係図
モジュールの依存関係図

  • :domain
    • ビジネスルールを表現したデータ構造やデータを加工するビジネスロジックが属するモジュール
    • アプリの最上位モジュールであり、他に依存するアプリ内モジュールは無い
  • :view-*
    • UIを構成するコード群が属するモジュール
    • 機能ごとに分割して定義している (例: :view-notification, :view-login)
  • :infra
    • アプリの基盤モジュール
    • データの永続化、取得、編集などデータソースにまつわる実装を配置する
    • WorkerなどUIでは無いがAndroid OSの機能に依存するコード群もここに属する
  • :app
    • アプリのエントリーポイントとなるモジュール
    • すべてのモジュールを知っている
  • :log
    • コードのログを送信するためのコード群が属するモジュール
    • Crashlytics に送信する
  • :slash-*
    • cybozu.com の共通仕様となるログインロジックにまつわるコード群が属するモジュール
    • 別リポジトリにある社内ライブラリとしてインポートしている

マルチモジュールを採用している理由は Clean Architecture でも言及されている「関心の分離」を表現するためです。 変更の理由が異なるコードは分離しておくほうが変化に柔軟になります。 例えば、Android Viewで実装した:view-*をJetpack Composeに置き換えるとします。 UIの実装方法の変更は:domain:infraからは関係の無い変更であるため、影響範囲は:view-*のみに限定できます。 逆に:domain:infraがUIの実装方法を知っていた場合、Android Viewでしか機能しないコードが:view-*以外に存在する可能性などが考えられ、影響範囲が広がってしまいます。

現在、:infra:view-* に依存している箇所 (図内の🚨の部分) が存在します。本来は :infra は UI の存在を知らなくていいはずです。これについてはモジュール整理で解消できる見込みです。

おわりに

今回のコードリプレイスのリリースは、Office Androidの機能が増えたわけではありません。 しかし、Office Androidに新機能を追加するための下準備は完了しました。 現在のOffice Android開発チームは新規機能を絶賛開発中です。 サイボウズ Office を利用している方々に魅力的な価値を提供できるようがんばります💪

この記事を読んでOffice Android開発の話をもっと聞いてみたいよという方、Office Android開発チームは一緒に働く Android エンジニアの仲間を募集しています。 興味のある方はぜひ詳細をご覧ください。

(2022年11月24日)公開時のものから一部内容を改変しています。