【マルチデバイスチーム ブログリレー5日目】
エンジニアリンググループ マルチデバイスチームの渡辺です。
m3.com 電子書籍アプリはエムスリーで開発しているスマホアプリの中で最も歴史のあるアプリです。
アプリの機能やデザインのリニューアルをしたい、しかし長年積み続けてきた技術的負債がそれを妨げ、簡単そうに見える改修でさえ難しいという状態でした。継続的な開発を可能とするためにどのように式年遷宮(リファクタリング)をしているか紹介します。
iOSは先行してリファクタリングが完了しており、今回はそれに追従するかたちで進めているAndroid側の内容です。
m3.com 電子書籍アプリについて
画像はiOSのもので、Androidもリニューアル予定
m3.com 電子書籍アプリは医師・薬剤師・医学生向けの電子書籍アプリです。Webで購入した電子書籍をダウンロードし、オフラインで閲覧や書籍を横断した内容の検索、メモ・テキストの書き込みなどができます。
エムスリーでは医師・薬剤師・医学生が医学を継続的に学ぶための重要な手段として電子書籍サービスを運営しています。読書体験の質が医療従事者の学習効率に少なからず関わると認識しており、モバイルデバイスでの利用体験を高速に改善し続けることで、より良い医療の提供に僅かでも寄与できることを目指しています。しかし長年積み重ねてきた技術的負債が機能改善のスピードを妨げ、些細な改修でさえ困難を極める状態でした。この持続可能な開発を実現し、医療従事者への価値提供を加速するため、リファクタリングを決断しました。
リファクタリング前の状態
- CI
- ビルド
- Lint
- ライブラリ管理
- build.gradle
- 言語
- Kotlin 1.7.20
- 至る所でJavaの空気を感じたため、おそらくAndroid Studioの機能で Java -> Kotlin へ自動変換
- Lint, Formatter
- アーキテクチャ
- なし
- Activity、Fragment、View、ArrayAdapter、Util系クラスなどに処理が分散
- DI
- なし
- インスタンスの共有はKotlinのobjectではなく、Javaの一般的なシングルトンの書き方で用意されたものを利用
- ログ
Log.d()
、Exception#printStackTrace()
- 非同期処理
AsyncTask
- DB操作はUIスレッドをブロックして同期的に実行している箇所もある
- Web API通信
- メインは
java.net.URLConnection
と各種InputStream系の組み合わせ、一部は volley を使用 - レスポンスのJSONを
org.json.JSONObject
、XMLをandroid.util.Xml
で手動パース - 常に本番環境を向いている
- メインは
- DB
- SQLiteDatabase
- UI
- Android View
- Viewの参照はKotlin Android Extensionsによる自動生成の
kotlinx.android.synthetic
とfindViewById()
- データモデル
- DBの取得結果、画面表示、画面遷移時のパラメータなどあらゆるところで
android.content.ContentValues
が使われている - Kotlinの
Map<String, Any!>
のような、key/valueでnullableの何かを管理しているイメージ
- DBの取得結果、画面表示、画面遷移時のパラメータなどあらゆるところで
何が辛かったか
古くからAndroidアプリ開発をしている方は上記の環境を見てなんとなく察していただけるかと思います。課題感は山程あったのですが、書ききれないため特に辛かった3つを挙げます。
- QAエンジニアによる手動テストはあったがユニットテストがなく、修正の影響がわかりにくい
- 様々な場所にロジックが分散している
- 型のないデータ構造が溢れかえっている
リファクタリングで目指す状態
一般的なAndroidアプリエンジニアが快適に開発できるように、一般的な技術スタックに寄せるようにしました。
リファクタリング前との比較のため、差分は 太字 にしました。
- CI
- ビルド
- Lint
- ユニットテスト
- ライブラリ管理
- Version Catalog
- renovateによる自動アップデート
- アーキテクチャ
- Android Developersの アプリ アーキテクチャ ガイド をベース
- マルチモジュールによる責務と依存の分離
- DI
- ログ
- 非同期処理
- Kotlin Coroutines
- Web API通信
- Retrofit
- kotlinx.serialization によるレスポンスのパース
- Build Variantで本番/開発環境の向き先を選択
- DB
- Room
- UI
- Jetpack Compose
- AAC ViewModel
- データモデル
- 適切にKotlinのdata classを定義
リファクタリングのステップ
1. データレイヤ
アプリアーキテクチャガイドより引用
アプリアーキテクチャガイドでいうデータレイヤの機能が巨大なUtilクラスで共通化されていました。UIレイヤから着手した場合、すぐにデータレイヤの修正が必要となり、レイヤ横断での変更が多発することが予想されます。そのため土台となるデータ層を安定させることで、その後のUIレイヤの改修をスムーズに進める戦略を取りました。
1-1. DI
まず最初にDIを行うためにDagger Hiltを導入しました。当初はDIするものがなかったのですが、RetrofitやRoomのインスタンスを適切に管理するための事前準備です。
1-2. Web API, DB
次にWeb APIをRetrofit + kotlinx.serialization、DBをRoomで実装しました。
既存のAPI/DBの利用箇所を置き換えるのですが、前述のとおり様々な場所から利用されています。一対一で置き換えるために、一旦ViewModelやRepositoryなどは挟まず直接ServiceやDaoを参照するようにしました。ActivityやFragmentはDagger HiltでDIし、AsyncTaskを lifecycleScope.launch { }
に置き換えてその中で実行するようにしました。一方でViewやAdapter、Util系などはDI、CoroutineScopeの用意が難しいです。DIは一時的にApplicationクラスにシングルトンを持たせて参照させます。これは一時的な措置でしたが、大規模なレガシーコードに対して段階的にDIを浸透させるための現実的な選択でした。CoroutineScopeに関しては元々同期的に実行されていたため、 runBlocking { }
で元の挙動と同じになるようにしました。ただしrunBlockingの乱用はスレッドをブロッキングするため、あくまで一時的な回避策として、UIレイヤの修正時に削除する想定でした。
元々DBはContentValuesを返すようになっていて、様々な箇所で使われています。そのため Dao -> データモデル -> ContentValues のように一時的にContentValuesへ変換することでUIレイヤの影響を抑えました。この変換は、データレイヤとUIレイヤの間の衝撃を和らげるクッションのような役割を果たしました。
ここまでで動作に問題がないことを確認し、ServiceやDaoをRepositoryに持たせて利用箇所はRepositoryを経由して実行するように変更しました。
2. UIレイヤ
アプリアーキテクチャガイドより引用
2-1. ContentValuesをデータモデルへ置き換え
UIレイヤでは様々な用途でContentValuesを使われていました。それぞれのContentValuesはどのようなkeyを持っているのか、valueの型は何なのか、nullableなのか、などの情報はありません。DBから取得するのでテーブルのスキーマを見れば生成直後の状態は予想がつくのですが、取得後の様々な経路でkeyの追加・削除が行われており、操作フローごとに中身が変わります。そのため、逆のアプローチで「ContentValuesに指定しているkey」だけを見てどのようなデータモデルが必要なのかを推測し、ContentValuesを該当のデータモデルに置き換えていきます。
2-2. UI 実装のリファクタリング
データの整理が完了したらAAC ViewModelを用意し、UIの状態やイベントの管理をViewModelに移行します。
あとはAndroid ViewをJetpack Composeに置き換えて1画面のリファクタリングが完了です。他の画面との連携もあるためActivity、Fragmentはコンテナとして残し、内側のレイアウトのみJetpack Composeで実装しています。
まとめ
データレイヤのリファクタリングは完了し、現在はUIレイヤのリファクタリングを画面ごとに進めています。体感で全画面の50%程度完了したので、リファクタリングは続けつつ並行して機能・デザインのリニューアルを考えています。
We are hiring!
エムスリーでは、m3.com 電子書籍アプリをはじめ様々なアプリを開発しています。もし興味がありましたらお気軽にお問い合わせください!