エムスリーエンジニアリンググループ マルチデバイスチーム所属の荒谷(@_a_akira)です。
弊社では、昨年の12月に医師向けの新規アプリをAndroid, iOS向けにネイティブ実装しリリースしました。 今回は、その際Kotlin Multiplatform Projectを用いてユーザの行動ログ送信部分を共通化した話をしたいと思います。
Kotlin Multiplatform Projectとは
Kotlin Multiplatform Project(以後MPP)とは、 Kotlinで書かれた単一のコードを
- Kotlin/JVM,(Android, Server等)
- Kotlin/Native(iOS, Windows, Linux等)
- Kotlin/JS
の各プラットフォーム向けにトランスパイル可能なプロジェクトのことです
もっと詳しく知りたい方は 公式ドキュメントだったり、私のKotlin Fest 2019での発表や、先日発売されたみんなのKotlinの第4章をご覧下さい。
共通化へのモチベーション
1つのサービスでAndroidとiOSアプリを提供していると、どうしても似たような仕様やロジック部分を複数回開発しなければなりません。
また、各プラットフォームでそれぞれ実装をしていると、非効率なだけでなく、バグが生まれてしまう確率も上がってしまいます。
エムスリーでも1つのサービスにおける各OSの開発体制は1~2人の場合が多く、少ない人数で日々追加される新たな機能や既存コードの修正を実装する必要があります。
現在はFlutterやReactNativeを用いて全て1ソースで作成して、Android, iOSのアプリを作成するクロスプラットフォームの流れも来ています。 ただ画面も共通化されてしまうので、各OSに準じたネイティブUIを求めるアプリでは、逆に実装が複雑になってしまうデメリットもあります。
現在私はAndroidの開発以外にFlutterでプロトタイプを作成して検証するアプリを作っていたり、検証が終わり本格開発段階にあるアプリもFlutterで作成しています。 本格開発も終え既にFlutterで作成されたアプリもリリースされています。 もちろんデメリットもありますが、開発人数が少ない場合にAndroid, iOSのアプリが1つのソースで作られるメリットはとても大きいです。
一方で、MPPの場合はUIは共通化せずに、ロジック部分のみを共通化する事が可能です。 また、AndroidアプリにとってはMPPのコードはモジュールとして見えるだけなので、既存のコードへの追加コストがほとんど0に近くなっています。 iOSに関しても、frameworkファイルを読み込むだけですので、外部ライブラリとして読み込むだけで使う事ができ、iOSエンジニアの理解が必要となりますが、導入コスト自体はそこまで高くはありません。
今回は既にAndroid, iOSそれぞれがネイティブ実装をしていたため、MPPを採用して開発しました。
共通箇所
MPPを用いて、全てのロジック部分を共通化することもできますが、MPPはまだベータ版ということもあり、 現段階で積極的に全面採用するにはそれなりの覚悟が必要となります。 そこで、今回はアプリ自体には影響が少なく、共通化によるメリットが大きいログの送信基盤部分を共通化しました。
今回作成するMPPによるログの送信基盤の機能として
- ログを端末ローカルのDB(データベース)に保存
- 画面遷移やアプリ終了時にまとめて送信
を仕様としています。
このようにしたのは、既存のアプリでは実はアクション発生時の度にログをサーバへ送信していたという背景があります。
これだと通信が失敗した場合にログが消失してしまったり、何か操作をする度にサーバへのリクエストをしてしまうため、ユーザ側、サーバ側共に嬉しくありません。
そこでこのアプリでは、操作ログを一度ローカルDBに保存して画面遷移やアプリ終了時のタイミングでログをサーバへ送信するようにしています。
送信に成功したログはローカルDBから削除し、送信に失敗したログは次の画面遷移のタイミングや、次回起動時などに再度送信されるため正確なログを送る事ができます。
このように今回のログ送信機能はローカルDBに保存する処理が入るため、OS毎に実装するとそれなりにコストがかかる内容となっています。 工数削減のメリット以外にも、ログのイベントキーを共通化できるため、似たような名前のイベントキーをtypoして片方のOSはログが送れていなかった というミスを防ぐ事ができます。
実装
パッケージ構成
※ MPPの構成はプロジェクトの性質により異なるため、1つの例として参考にして下さい
MPPのプロジェクトを1つのモジュールとして開発をしていくと通常はこのようになると思います。
. ├─ Android Project ├─ MPP Project └─ iOS Project
一番簡単な方法はこのディレクトリ全体をGitで管理することです。 ただこれだとIssue管理やPull RequestがAndroidとiOSで混ざってしまい色々と不便な事が多いのが想像できると思います。
そこで今回のプロジェクトでは、MPPを別の新規リポジトリで管理し、これをAndroidではGitのsubmoduleを使ってGradleのmoduleとして取り込み、iOSでは社内のMaven Repository経由で参照する事にしました。 Maven Repositoryにした理由は、社内のライブラリをアップするサーバが元々あって管理が楽だったという理由だけなので、同じ構成を取る場合の保存先はS3でもなんでも良いと思います。(Zipファイルを直接アップロードしているためMaven Repositoryの正しい使い方ではないので注意して下さい)
こうするとAndroid, iOS, MPPをそれぞれ別リポジトリで管理できるので、IssueやCIは各リポジトリでできるのが利点です。
配布方法
Androidに関してはGradleで参照できるため特に意識する必要は無いのですが、 iOSはframeworkの参照になるためDebugビルドとReleaseビルドを用意する必要があります。 さらに、iOSはSimulatorと実機用にx64とarm64に分けてあげる必要もあります。 ただSimulatorでReleaseビルドを使うことはほぼ無いため、 今回は
- arm64Release
- arm64Debug
- x64Debug
の3種類を生成することにしました。
上記のframeworkはGradleタスクを記述して作成しています。
生成先のディレクトリは適宜変えてください。
kotlin { ... iosX64('iosX64') { binaries { framework { embedBitcode('disable') } } } iosArm64('iosArm64') { binaries { framework { embedBitcode('bitcode') } } } ... sourceSets { ... } // arm64 release task releaseArm64Framework(type: FatFrameworkTask) { baseName = project.name destinationDir = file("$buildDir/mpp/release/arm64") from targets.iosArm64.binaries.getFramework("RELEASE") } // arm64 debug task debugArm64Framework(type: FatFrameworkTask) { baseName = project.name destinationDir = file("$buildDir/mpp/debug/arm64") from targets.iosArm64.binaries.getFramework("DEBUG") } // debug64 release task debugX64Framework(type: FatFrameworkTask) { baseName = project.name destinationDir = file("$buildDir/mpp/debug/x64") from targets.iosX64.binaries.getFramework("DEBUG") } } ... task buildFrameworks(dependsOn: [debugX64Framework, debugArm64Framework, releaseArm64Framework]) task zipFrameworks(type: Zip, dependsOn: packForXcode) { from "$buildDir/mpp" archiveFileName = 'mpp-frameworks.zip' destinationDirectory = file("$buildDir") }
Simulator用のframeworkにbitcodeは不要なためdisableを指定しています。 ※ bitcode
buildFrameworks
タスクで debugX64, debugArm64, releaseArm64の3つをまとめてビルドしています。
さらに生成したアーティファクトをiOSエンジニアに渡すため、frameworkの作成とは別にzipFramework
というタスクも作成し、作成したframework達をzipファイルとしてまとめています。
zipファイル共有部分はチームの状況により異なるので適宜変更してください。
コード
今回はログをデータベースに保存し、サーバに送信する必要があるため
- データベース
- HTTPクライアント
の2つのライブラリを主に使用しました。
他には
- 非同期
- シリアライズ
- DI
- DateTime
- SharedPreferences, UserDefault
- ロギング
等を使用しました。
他にもMPP対応しているライブラリをまとめたリポジトリを作っているので、参考にしてみてください。
Ktor Clientに関しては、HTTP Clientとしての使い方をしているので、今回はSQLDelightについて少し付け足したいと思います。 SQLDelight自体の使い方は、個人ブログですが以前書いたので参考にしてみて下さい。
このアプリではAppLogDaoというクラスを作成してDBの処理を行っています。
SQLDelightは、.sq
ファイルを作成するとクエリのKotlinファイルが自動生成されるので、作成したDaoクラスからアクセスしています。
- app_log.sq
CREATE TABLE AppLog( time TEXT NOT NULL, action TEXT NOT NULL, ... ); insertItem: INSERT OR REPLACE INTO AppLog( time, action, ... ) VALUES(?,?,...); selectAll: SELECT * FROM AppLog ORDER BY time ASC; selectFromTime: SELECT * FROM AppLog WHERE time > ? ORDER BY time ASC; deleteBeforeTime: DELETE FROM AppLog WHERE time <= ?;
- AppLogDao.kt
internal class AppLogDao { private val appLogDatabase = createDb() // platform毎に実装 private val queries = appLogDatabase.appLogQueries fun insertLog(log: AppLog) { queries.transaction { queries.insertItem( log.time, log.action, // それぞれ必要なパラメータ .... ) } } fun getAppLogs(): List<AppLog> = queries.selectAll(mapper = { time, action, ... -> AppLog(time, action, ...) }).executeAsList() fun getAppLogsFromTime(fromTime: String): List<AppLog> = queries.selectFromTime( fromTime, mapper = { time, action, ... -> AppLog(time, action, ...) }).executeAsList() fun deleteBeforeTime(time: String) { queries.transaction { queries.deleteBeforeTime(time) } } } @Serializable data class AppLog( val time: String, val action: String, ... }
保存の形式に関しては、Kotlin.Serializationを用いてシリアライズとデシリアライズをしています。 SQLiteは自分でトランザクションを張る必要があるので注意して下さい。
読み書きさえできればあとはKtor Clientを使い、ログをまとめてサーバに送信するだけです。
トラブル
アプリのリリースから約半年経過していますが、Android, iOS共にMPPを起因としたクラッシュは特に起きておらず問題なく動作しています。 MPP自体は直接関係が無かったのですが、トラブルシューティングが一番知見になると思いますので、今回起きた問題の対応について解説しておきます。
リリースしてしばらくしてから、分析の方から一部の人からログが2重に送信されて登録されてしまっているという報告がありました。 調査してみると一度送信してDBから削除したはずのログが消えておらず、Androidは約2%、iOSは約13%のログが再度送信されていることがわかりました。
MPP(Kotlin Coroutines)にはiOSにおいて、現状メインスレッドでのみ動作するという制限があります。 (高い優先度で対応されているため、近い将来この問題は無くなると思われます)
最初はこの問題を疑い、 送信に成功したログをローカルDBから削除する処理が失敗してしまうと考えトランザクションを工夫したりしたのですが解決しませんでした。 他にも、KotlinやCoroutinesのバージョンアップも積極的に行ったりしてみたのですがこれも駄目でした。 バグで一番困るのは、手元では再現しないが一部の人には起きてしまうバグの修正対応ですね...
調査の結果、ログをサーバにリクエストした後の送信結果で、SharedPreferencesとUserDefaultsに保存するライブラリを使って送信したログの最後の時間を保存し、次回起動時に保存されている時間によってDBの削除を行っていたのですが、通信リクエストのレスポンスが返ってきた段階でログの時間が保存できずに、次回起動時にDBからログが削除されていない事が原因と推定しました。
- Common
suspend fun sendAllLogs() { mutex.withLock { try { val lastTime = mppSettingsRepository.getLatestTime() val logs = logRepository.getLops(lastTime) if (logs.isEmpty()) return // send logs logRepository.postLogs(logs) // save last time mppSettingsRepository.saveLatestTime(logs.last().time) } catch (e: Exception) { Napier.e("Failed to send logs.", e) } } }
Android, iOS共に呼び出し側はGlobalScopeで定義しているため、アプリを終了しただけではレスポンス後に時間を保存できるはずです。
ただタスクキルをしてアプリをOSのスタックから消してしまうとリクエストが返ってきた後に時間を保存をすることができません。
そのためアプリ自体がスタックからキルされていると予想しました。
数字的にもAndroidユーザーよりもiOSユーザーの方がアプリを終了した後にタスクをキルする割合が多いように思えるので体感値と合っているような気もします。
この操作をされてしまうと、MPP関係なくアプリ開発者側からはどうすることもできません。 そこで、今回はログのイベントに一意のIDを追加して分析側で重複を弾いて貰う事で解決しました。
分析側に多少負担をかけてしまいましたが、対応版をリリースしてからはログの重複問題は無事解決しています。
まとめ・所感
MPPでログの送信部分を共通化したことで、AndroidかiOSのどちらかがログの実装を行っていれば、片方のOSはそのコードを呼びだすだけで済み、
正直あまりテンションが上がらない ログの実装をスムーズに行うことができるようになりました。
また、当初ログのキーを間違えて実装してしまっていたのですが、後から実装したiOSのエンジニアがキーの間違いを指摘してくれた事で、
無事にAndroid, iOSで差異の無い正しいログを送ることができました。
現状MPPのライブラリ間でKotlinのバージョンを合わせる必要があるため、全てのライブラリが対応されるまで最新のバージョンにアップデートができずに微妙なところもあるのですが、 総じてメリットの方が多く導入して良かったと思っています。
今回はメンバーにも、両OS実装できるエンジニアが多くiOSエンジニア側からの理解があるので夢のMPPライブラリを実現できました。
クロスプラットフォームによる開発は適材適所です。 前半でも話した通りエムスリーでは、プロジェクトによってFlutterを使っていたり、ネイティブ開発が必要なアプリは現在も新規アプリをネイティブで開発中ですし、今回のようにMPPで実装する箇所もあります。 一番大切なのは状況に応じて技術を使い分けることだと思っています。
まだMPPの導入事例が少ないと思いますので、これからMPPを導入しようとしている方の参考事例となれば幸いです。
We are hiring!
このように、エムスリーは目的を達成することができれば、技術選定は自由な環境です。(正当な理由が説明できれば)
ネイティブアプリ、MPP、Flutter、サーバサイド問わず幅広くエンジニアを募集しています!