アーキテクチャから見直した Android 版 Zaim の大規模リファクタリング #Zaim
はじめに
こんにちは、Zaim の Android アプリ開発チームの原です。Zaim では、2018 年 5 月に丸々一ヶ月かけて Android アプリの大規模なリファクタリングを実施しました。
今回は、その背景や開発時の判断および実装について、四つの構成に分けて共有してみようと思います。ソースコードがないので多少曖昧な部分もありますが、少しでも参考になれば幸いです。
目次
1. 取り組むべき三つの課題
2. リファクタリングの方針決定のプロセス
3. リファクタリング時につまづいた箇所
4. 今後の計画
1. 取り組むべき三つの問題
Android 版の Zaim は、2012 年のリリース当初は Titanium という Javascript のフレームワークで動いていました。しかし、非常に不安定だったことから、2013 年に Java ですべて書き直したバージョンを新しくリリース(以降ネイティブ版 ver.1 とします)。ネイティブに書き換えてからは非常に安定して稼働するようになった結果、多くの機能を追加でき、今では iOS と合わせて 800 万近くダウンロードされるサービスとなりました。
とはいえ、今やネイティブ版 ver.1 のリリースからもかなりの時間が経過しており、機能追加や仕様変更、時には機能の削除を繰り返すことで、以下のような課題を抱え始めていました。
1-1. 可読性の問題
ネイティブ版 ver.1 では、きちんとした設計や規約が存在しておらず、各開発者の力量と一定割合のコードレビューで可読性を担保してきました。
しかし、新しいメンバーが入社したり、実装の担当者が変わるなどして、徐々に命名規則や責務の分離で実装の差が生まれ、それが長い年月で大きくなる一方でした。
その結果、一部の機能を理解するのに複数のメソッドやクラス全体を読まなければ、実装内容や影響範囲が理解できない状況となってしまいました。
1-2. メンテナンスの問題
上記のように可読性の問題を抱え始めると、当然メンテナンスの問題にも発展します。特に Zaim でも一部のコードではありますが、俗に言う「Fat な Activity」が存在し、また下記のような異なるレイヤーのコードが入り組んでしまう状況が生じました。
ビューを表示するコード
ビジネスロジックを担当するコード
API を叩いて通信するコード
当然、異なるレイヤー同士が疎結合ではないので、どこか一箇所を修正しようとすると、すべてのレイヤーの修正が必要になってしまいます。ビューだけ変えたくても、データベースから取得するローデータを直接読み込んでいるため、データレイヤーも同時に修正する必要が出てきました。
1-3. パフォーマンスの問題
ネイティブ版 ver.1 で ORM(Object-relational mapping)に ActiveAndroid というライブラリを採用していました。
このライブラリは 2013 年頃はメジャーだったものの、パフォーマンスが非常に悪く、また ALTER TABLE する際に発生する不具合がずっと治らないなどの問題を抱えていました。
何よりも、このライブラリは 2014 年頃からメンテナンスされておらず、何か大きな問題が見つかっても修正されないという点は、ユーザーを多く抱えている以上、一刻も早くリファクタリングして別の ORM に載せ替えたいという動機になりました。
2. リファクタリングの方針決定のプロセス
Zaimでは、継続的に機能や UI/UX を改善しているため、リファクタリングをしながらも開発を続ける必要があります。
そこで Android チームでは、すべてを一気にリファクタリングをするのではなく、後回しにすることで、ユーザーに影響を与えてしまう可能性がある問題から取り組むことに決めました。
2-1. リファクタリングの対象範囲を決定
上記の方針のもと、パフォーマンス及び保守の問題を抱えている ActiveAndroid のリプレースを主軸とし、下記の三つを実施していくことに決めました。
ActiveAndroid を別の ORM に置き換える
Data(データベースおよび API)を取り扱う層を分離する
リファクタリングで書き直すコードは Java から Kotlin にする
2-2. アーキテクチャはクリーンアーキテクチャを参照
こうした背景をもとに「ネイティブ版 ver.2」としてリファクタリングすべき対象は絞ったものの、木を見て森を見ず といったことにはならないよう、プロジェクト全体のアーキテクチャの方針も検討しました。
まず、関心の分離をしたいと考え、リファクタリングの対象として、データレイヤーを中途半端にではなく完全に分離すると決定していました。
そこで、クリーンアーキテクチャの概念を参考にしつつも、Android Architecture Blueprints を見ながら、チーム内でディスカッションし、以下のようなレイヤーの分け方としました。
プレゼンテーションレイヤー
アクティビティ、フラグメントを含み、UI の表示やボタンタップなどのイベントをハンドリングします。
ドメインレイヤー
ビジネスロジックをこのレイヤーで実行します。
データレイヤー
Android 端末ローカルのデータベースへのアクセスやサーバーとの通信を実施します。
今回のリファクタリング対象はデータレイヤーのみに絞りますが、このレイヤー化を意識して進めました。また、データレイヤーを完全に分離できたので、サーバー通信などをモックに置き換えてテストが可能になりました。
3. リファクタリング時につまづいた箇所
上記の内容について、今回のリファクタリングで実際に取り組んだ際に気づいたこと、づまづいた内容を、もう少し詳しく記載していこうと思います。
3-1. Object Mapper として Room を採用
ActiveAndroid の代替としては Google の AAC(以下 Android Architecture Components)にある Room を採用しました。Room を採用した理由としては、下記になります。
ActiveAndroid と同じくデータソースが SQLite なので既存データが使える
ActiveAndroid よりパフォーマンスが高い
アノテーションで SQL が記述できるうえ Android Studio で補完が効く
コンパイル時に静的解析ができる
AAC なのでメンテナンスが期待できる!
また、移行の場合の注意した点としては 2 点ありました。
一つ目は「Room の @Entity で記述するテーブル、インデックスの定義は既存の SQLite の定義と一致させる」ということです。
というのも、一致していないと実行時に例外(IllegalStateException)が発生してしまうためです。特に手間だったのが、not null 制約です。Room でテーブルのカラムを null 許容にするには、Kotlin で記述する際に変数を nullable で宣言する必要があります。null になりえないのであれば、Kotlin で記述する際に nullable の変数は使いたくないですよね。その場合、マイグレーションを記述する必要があったり、もしくはコード上で nullable を気にしながら記述することになります。
二つ目は「Room で指定する database version は、ActiveAndroid ですでにリリース済の database version 以降にする」です。
この Room のアノテーションで指定する version は Room 固有のバージョンではなく、Android SQLite として管理している database version なのです。Android SDK の SQLiteOpenHelper.javaのgetDatabaseLocked を読んでいただくと分かると思いますが、現在のバージョンと Room などで指定したバージョンに差異があると、onUpgradeもしくは onDowngrade がコールされるのです。決して初めての Room リリースだからといって、version = 1 としないようにしてください。
最後に、パフォーマンスがどれだけ改善されたかが気になりますよね。レコード数及び端末スペックによってかなり差はありますが、例えば 1 万件の家計簿データをサーバーから Room 経由でローカルのデータベースに保存した場合、数十倍は早くなりました。
詳しいベンチマークが下記で公開されているので、検討の際にはぜひ参考にしてください。というか、ActiveAndroid...遅いですね。
3-2. データレイヤーをモジュールとして分離
データレイヤーをモジュールとして分離することで得られたメリットとしては、大きくは三つありました。
一つ目は、コンパイル速度が向上したことです。
変更がないモジュールはキャッシュを効かせたり、モジュールビルドを並列実行できるようになりました。
二つ目は、使う側と使われる側を明確にできたことです。
これは、レイヤー化アーキテクチャの養成ギプスと表現している人もいらっしゃいます。データレイヤーを使う上位レイヤーの build.gradle の dependencies には データレイヤーのモジュールを追加する一方で、データレイヤーの build.gradle には上位レイヤーのモジュールを追加しないようにします。これで、上位レイヤーからはデータレイヤーのクラスを参照できますが、データレイヤーからは上位のレイヤーのクラスを参照できないようにし、強制的にレイヤー化を意識させることが可能になりました。
三つ目は、データレイヤーから上位レイヤーに公開するクラスやメソッドを限定できたことです。
この効果は二つ目と一緒で、見方が逆転しているとも言えます。Kotlin には、同一モジュール内からしか見えないようにするためのアクセス修飾子 internal があります。これを活用することで、データレイヤー内だけでの利用を想定しているクラスを上位レイヤーから使われないように強制することで、記述方法が統一化されます。
例えば Room の DAO インターフェースなどは、上位レイヤーが意識するものではないので、internal 修飾子をつけて非公開としても問題ありません。また、Repository クラスは、インターフェースだけ公開し、実装クラスは非公開にしておきます。こうすることで、上位レイヤーが Repository クラスを利用したい場合は、常にファクトリクラスメソッド経由でしか得られないようにすることなども可能です。
3-3. 実装は Kotlin
これは、開発者のモチベーションのためでもありますが、今回のリファクタリングはほぼすべて Kotlin で実装しました。実際に Kotlin で記述すると Java と比べて記述量が少なくなる上に、とても可読性が高くなったと思います。
スコープ関数(let、apply、run、also など使い分けは最初に決めるべき)などは、とても便利です。また、拡張関数を利用することで、既存のクラスを継承などでサブクラスを作ることなく、簡単に拡張できたり、data クラスなどで不要な getter/setter を生成しなくてもよいなどメリットはあります。
Java と Kotlin の同居で一点、注意が必要なのは、やはり null の取り扱いです。Kotlin 化するコードではなるべく nullable にしたくないのですが、上位レイヤーから Kotlin で書かれたデータレイヤーに null を渡してくるコードがあったりすると、実行時に例外が発生してしまいます…。これは実行時に起こるので、なかなか気がつきにくいところですね。
なお GitHub で確認したところ、今回のリファクタリングで Zaim の Android アプリは 20%弱が Kotlin 化しています。
4. 今後の計画
5 月に実施したリファクタリングでは以上となりますが、それ以降も下記のようなことを検討し、進めています。
4-1. AAC の ViewModel でライフサイクル改善
こうしたリファクタリングの過程で、一部の新規画面からライフサイクルを改善することで安定性を向上させています。
もともと Android 特有の問題としてライフサイクルの扱いに悩むことが多く、例えば Zaim では「API の通信中に画面回転させると落ちやすい」というような問題がありました。
前述した Google の AAC に含まれる ViewModel では、強力なライフサイクルの機構が備わっています。これを採用することで、こうした問題は格段に減少しました。今後は既存画面にも適用していこうと考えています。
4-2. 100% Kotlin 化
また、Android 版 Zaim はそれなりの規模なアプリなので、時間もそれなりにかかる覚悟ですが、Kotlin を 100%にしていきたいと考えています!
そのために、いまいまはプレゼンテーションレイヤーの Kotlin 化を推し進めているところです。ただし、既存画面は開発の負荷が大きいので、まずは新規に作成する画面から着手しています。
おわりに
引き続き、新規機能とともにリファクタリングを進め、開発しやすく、かつモダンな作りを保っていきたいと考えています。
リファクタリングやアーキテクチャなど Zaim Android 版に興味のあるエンジニアを募集しています。「リファクタリングの話を聞いてみたい」ということでもよいので、ぜひ遊びに来てください!