はじめに
こんにちは。レシピ事業部でアルバイト中の松本 (@matsumo0922) です。クックパッドでは以前からモバイルアプリケーション向けのロギングライブラリである Puree を公開していましたが、今回新しく Kotlin Multiplatform1を用いた KMP 版をリリースしました。また、このライブラリは既に iOS、 Android 両方のクックパッドアプリで動作しており、クックパッド初の Kotlin Multiplatform 導入事例ということで、難しかった点などをライブラリの紹介と合わせてご紹介します。
Pureeとは
サービスのログを収集する際に、Web アプリケーションであればサーバー側でログを収集することが可能ですが、モバイルアプリケーションは画面の操作はアプリ側でコントロールされるため、アプリがログを収集して送信する必要があります。アプリの操作は、ユーザーのデバイスの状態(通信環境や電池残量など)、アプリのライフサイクル、ユーザーの複数回タップなど様々な要因を考慮せねばならず、Web アプリケーションのロギングより複雑になりやすいのが現状です。
そこで、上記の様な面倒臭い処理を全て行ってくれるのが「Puree」というライブラリです。Puree は以下の機能を全て内蔵しており、ロギングは Puree に任せてアプリの主要機能の実装に注力することが可能となります。
- フィルタリング
- 特定のログに対して共通のパラメータを付与したり、サンプリングを行うことが可能
- バッファリング
- 特定時間内に複数回送られたログを一時的にバッファ
- 通信に失敗した場合や送信前にアプリが kill された場合でも、内部データベースに自動保存
- リトライ
- 何らかの要因でログが送信されない場合でも、自動的にリトライ
puree-kmp
上記の様に便利なライブラリである Puree ですが、実は様々な言語、OS向けに複数のライブラリがリリースされています。
2014年にリリースされた元祖 Puree である、puree-android。続いてリリースされた iOS 向け Puree である、puree-ios。2017年には ReactNative 向けの react-native-puree。翌年の2018年では Swift で一新された puree-swift、Android の公式開発言語が Kotlin へと移行してしばらく経った2021年には、Java から Kotlin に一新された、puree-kotlinがリリースされています。
techlife.cookpad.comtechlife.cookpad.com
この様に様々な種類と歴史がある Puree ですが、それ故に実際の開発現場では様々な弊害も存在しました。Puree のおおまかな仕様は全て似通っていますが、詳細部分ではそれぞれのコードベースごとに異なる仕様があり、実運用してみると iOS と Android でログの送信されるタイミングや数が異なるといった問題が度々発生していました。加えて、すでにレガシーとなっている Java や Objective-C で書かれた puree-android と puree-ios はもちろんのこと、日々進化していくモバイルアプリ界隈で複数のコードベースのライブラリを保守していくのはコスト的にも難しいものがありました。
そこで、Kotlin Multiplatform(以下 KMP)というマルチプラットフォーム技術を用いて複数のコードベースを統合し、一つの Puree にするという目標を掲げて作られたのが「puree-kmp」です。
特徴
puree-kmp は puree-kotlin / puree-swift の設計思想を踏襲しつつ、KMP 化に際して以下の様な変更を行なっています。
型ベースの Filter / Output
puree-swift ではタグパターンを用いて文字列ベースの Filter & Output の設定を行っていましたが、それぞれ独立してパターンを設計するのが難しく、より安全な型マッチング方式に移行すべきという議論が社内でも度々起こっていました。満を持して型ベースに移行です。
デフォルトの Filter / Output のサポート
型ベースに移行したことにより、全ての型に対し適応する Filter と Output を登録する必要が出てきました。クックパッドで記録しているログの数は膨大であるため、その全てを Puree に登録していくのは効率的で無いと判断し、デフォルトの Filter と Output をサポートする様に変更しました。
Full Kotlin
これは言語としての Java を使用していないという意味ではなく、依存しているライブラリも含めて全て Kotlin で書かれているという意味です。KMP なので当たり前ではありますが、これによりレガシーなライブラリへの依存も一掃することができました。Androidではログのシリアライズには kotlinx.serialization、データの永続化には androidx.room を採用するなど、積極的に開発が行われている公式のライブラリを多用しているため、保守性はもちろんライブラリとしての寿命も伸ばすことができているはずです。
Swift Friendly
KMP のライブラリは同じ KMP プロジェクトから参照されることを前提にしているものが多く、Swift から参照されることをあまり考慮されていません。これは KMP としての仕様も少し絡んできますが、KMP は Kotlin → LLVM → Objective-C → Swift という手順を踏んで Swift から参照されるため、それぞれの言語毎の差異を累積してしまいます。例えば、Kotlin の null 安全が Objective-C を通すことによって曖昧になったり、Kotlin の interface は Objective-C の protocol に変換されますが、Objective-C の protocol は Swift の struct で準拠できないと言った問題等、意識して Swift Friendly に書かなければ Swift では利用しづらいライブラリとなってしまいます。puree-kmp では基礎設計をできるだけ共通にしつつ、Swift 向け固有のコードを用意し、Swift からでも利用しやすいライブラリとなることを心がけています。
各プラットフォームへの拡張性
現状 puree-kmp がサポートしているプラットフォームは Android と iOS だけですが、ほぼ全てのコードを共通モジュールで記述しているため、別プラットフォームへの拡張も容易に行える設計となっています。将来的には JVM、Desktop、WebAssembly へ展開していければと考えています。
使い方
前述の通り、設計は以前の Puree から引き継いでいるので、現在 puree-kotlin または puree-swift を利用している場合スムーズに移行することができます。実装例として、以下のような ClickLog を送信する例を考えてみます。
Puree を扱うには以下の4ステップが必要です。
- 依存の追加
- ログを定義
- PureeFilter と PureeOutput を実装
- ログの型をベースにどの PureeFilter と PureeOutput を使うか登録する
より詳しい使い方は README をご覧ください。
導入
Android では Maven Central、iOS では Swift Package manager から導入することができます。
dependencies { implementation("com.cookpad.puree:puree-kmp:$latestVersion") }
ログ定義
送信するログを定義します。PureeLog を継承 / 準拠することで Puree で扱えるログクラスとなります。Android ではデフォルトで kotlinx.serialization を使用しているので、@Serializable アノテーションをつけるのを忘れないでください。なお、kotlinx.serialization を使用する場合の注意として、data object でログを定義してしまうとプロパティがシリアライズされませんのでご注意ください(一敗)。
@Serializabledataclass ClickLog( @SerialName("button_name") val buttonName: String, ) : PureeLog
Swift でログクラスを定義する場合は KMP の仕様上 struct は使えませんので、class で実装してください。また、後述しますが Swift で書かれたクラスに対してはもちろん kotlinx.serialization を使うことができないので、自分のシリアライズ機構を実装する必要があります。以下の例では Encodable を用いていますが、class → Json の変換ができればなんでも良いです。プロジェクトに合わせて実装してください。
classClickLog:PureeLog, Encodable { letbuttonName:Stringinit(buttonName:String) { self.buttonName = buttonName } }
PureeFilter の実装
PureeFilter を実装してフィルタリング機構を追加します。ここで特定のログを弾いたり、任意のペイロードを追加で載せることも可能です。クックパッドアプリではユーザーの情報や時間などを追加しています。
class AddTimeFilter : PureeFilter { overridefun applyFilter(log: JsonObject): JsonObject? { buildJsonObject { put("time", System.currentTimeMillis()) put("log_id", UUID.randomUUID().toString()) }.also { return JsonObject(log + it) } } }
classAddTimeFilter:PureeFilter { funcapplyFilter(log:String) ->String? { guardvarjson= parseJSON(log) else { return log } letdateFormatter= ISO8601DateFormatter() json["time"] = dateFormatter.string(from:Date()) json["log_id"] = UUID().uuidString return stringifyJSON(json) ?? log } }
PureeOutput の実装
PureeOutput ではログの送信機構を実装します。送信方法には二種類あり、ログが発生したら瞬時に送信を行う PureeOutput
と、一定期間ログをバッファリングしてまとめて送信を行う PureeBufferedOutput
があります。ニーズに合わせて使い分けてください。以下の例では単に標準出力を行うだけですが、本来であれば各々のログ基盤へ送信するネットワーク通信を用いた実装になるはずです。
class LogcatOutput : PureeOutput { overridefun emit(log: JsonObject) { Log.d("Puree", log.toString()) } }
classOSLogOutput:PureeOutput { funcemit(log:String) { os_log("Puree: %s", log:osLogger, type: .debug, log) } }
PureeBufferedOutput
ではログの送信タイミングをカスタマイズすることでき、例えばバッファする時間を指定したり、バッファされるログの数を指定したりすることが可能です。以下の例では flushInterval
を用いてバッファする時間を指定しています。この関数は非同期で呼び出されるため、送信に成功または失敗した場合のコールバックを呼び出す必要があることに注意してください。特に失敗した場合は、Puree が自動的にリトライへとハンドリングしてくれます。
class LogcatBufferedOutput(uniqueId: String) : PureeBufferedOutput(uniqueId) { init { flushInterval = 5.seconds } overridefun emit(logs: List<JsonObject>, onSuccess: () ->Unit, onFailed: (Throwable) ->Unit) { // Process logs in batch Log.d("Puree", "Logs: $logs") onSuccess() } }
classOSLogBufferedOutput:PureeBufferedOutput { overrideinit (uniqueId:String) { super.init(uniqueId:uniqueId) setFlushInterval(flushIntervalMillis:5000) } overridefuncemit(logs:[String], onSuccess:@escaping ()->Void, onFailed:@escaping (KotlinThrowable)->Void) { os_log("Puree Buffered Logs: %s", log:osLogger, type: .debug, log) onSuccess() } }
Puree を構成
今まで実装したクラスたちを用いて Puree を構成していきます。ログの型と PureeFilter、PureeOutput を用いてどの型にどの Filter / Output を適用するのかを決めていきます。なお、defaultOutput()
と defaultFilter()
は全てのログに対し適用されます。
val logger = Puree( logStore = DefaultPureeLogStore("puree.db"), ) .output(LogcatBufferedOutput("buffered"), ClickLog::class, MenuLog::class) .defaultOutput(LogcatOutput()) .defaultFilter(AddTimeFilter()) .build()
Swift で書く場合は前述の通り自分でシリアライズ機構を書く必要があります。
privateclassDefaultPureeLogSerializer:PureeLogSerializer { funcserialize(log:any PureeLog, platformClass:PlatformClass<anyPureeLog>) ->String { return (log as?PureeLog& Encodable)?.encode() ??"" } } privateletlogger:PureeLogger= { letlogStore= PlatformDefaultPureeLogStore(dbName:"puree.db") letlogSerializer= DefaultPureeLogSerializer() return Puree(logStore:logStore, logSerializer:logSerializer) .output(output:OSLogBufferedOutput(uniqueId:"buffered"), logTypes:[ClickLog.self, MenuLog.self]) .defaultOutput(outputs:[OSLogOutput()].toKotlinArray()) .defaultFilter(filters:[AddTimeFilter()].toKotlinArray()) .build() }()
送信
ここまでセットアップすれば残りは簡単です。ログクラスを Puree に渡して送信するだけです。
logger?.send(ClickLog("button1"))
ライフサイクル
アプリのライフサイクルに応じてログの送信タイミングをコントロールするには PureeLogger.resume()
、PureeLogger.pause()
を使用します。なお、Android においては ProcessLifecycleOwner
を用いて Puree が自動で送信タイミングをコントロールするため、手動での操作は特に不要です。
iOS でも UIApplicationDidBecomeActiveNotification
等ユニバーサルにアプリの状態を通知してくれる仕組みは存在しますが、これらは CREATE → START といった状態遷移の連続性が保証されていない上に、着信や Siri の割り込みといったユーザー主体でない遷移では発火しないため、ProcessLifecycleOwner
ほど包括的なライフサイクル管理はできないと判断し、iOS での送信タイミングのコントロールは実装されていません。
難しかった点
一般的に KMP ライブラリは KMP プロジェクトから呼び出されることを想定して設計されると思いますが、クックパッドアプリは依然として KMP ではないため、それぞれのネイティブコードから呼び出される前提でコードを書く必要がありました。その過程で難しかった点や躓いた点をお話しします。
型の受け渡し
Kotlin → Swift へは容易に型を渡すことが可能ですが、今回取り組んだのは Swift → Kotlin へと型を受け渡すことでした。Kotlin では型は KClass
として扱うことができますが、Swift の場合は KMP の仕様としてインターフェース上のジェネリクス型パラメータは ObjC に変換された時点で消失してしまうため、Swift → Kotlin へと型を渡した際もObjCClass
としか分からなくなってしまいます。そこで型情報を String に変換して持つ expect class を実装し、共通コードではこれを取り回すことでログの識別を行っています。
/** * Platform-specific class wrapper for PureeLog types. * * This class provides a common interface for platform-specific class references, * allowing the logging system to work with class information in a platform-independent way. * Each platform (Android, iOS) implements this class differently to handle its specific * class reference mechanism. * * @param T The type of PureeLog this class represents * @property simpleName The simple name of the class, used for log type identification */expectclass PlatformClass<T : PureeLog> { val simpleName: String } // androidMainactualclass PlatformClass<T : PureeLog>(val kClass: KClass<T>) { actualval simpleName: Stringget() = kClass.simpleName.orEmpty() } // iosMainactualclass PlatformClass<T : PureeLog>(privateval clazz: ObjCClass) { actualval simpleName: Stringget() = NSStringFromClass(clazz) }
依存している KMP ライブラリが Swift Friendly ではない
前述の通り、KMP ライブラリは KMP プロジェクトから参照されることを前提としていることが多く、それは公式の Jetbrains や Google の KMP ライブラリでも例外ではありません。例えば、PureeBufferedOutput
ではバッファする時間を設定するために kotlin.time の Duration
を用いています。「PureeOutput の実装」のセクションでは実装例として Kotlin / Swift の両方で BufferedOutput を実装していますが、Kotlin では flushInterval = 5.seconds
と Duration を用いて書けているのに対し、Swift では setter メソッドを呼び出しています。これは Duration を Swift から見た際に Int64、しかも半ナノ秒単位という意味が分からない形に変換されるからです。こういった問題もあり、puree-kmp ではできるだけ依存している KMP ライブラリを Swift 側に公開しないようラップするなどして対策を行いました。
SwiftUI Preview が動かない
ライブラリを静的フレームワークとして作成していると、iOS プロジェクト側で SwiftUI Preview が動作しなくなる問題があるようです 2。 Xcode16 から導入された新しい Preview エンジンとの相性が悪いらしいですが、詳細な原因は不明です。ライブラリを動的フレームワークへと変更することで無事 Preview が動作するようになりました。なお、Bitcode を用いている場合、動的フレームワークだと本番環境でクラッシュ等が発生しても dSYM
がダウンロードできないという問題が別途存在しますのでご注意ください 3。
まとめ
クックパッド初の KMP 開発事例ということもあり、知見が少ない状態でのスタートだったので様々な問題や躓きがありましたが、なんとか当初の目標である Puree のコードベースを一つにするという目標を達成することができました。今回の知見を今後の開発に活かしていければ 4と思います。
- KMPとは↩
- Dynamic Framework だと SwiftUI Preview が行えない問題↩
- Dynamic Framework だと dSYM がダウンロードできない問題↩
- ログといえば、クックパッドでは Markdown からログクラスを生成する daifukuという OSS が存在します。この KMP 版となる
daifuku-kmp
を開発し、共通コードにログクラスを生成できればログ周りを全て KMP で行うことができ、各プラットフォームによるログの差異を完全に無くすことができるなぁと(個人的に)考えています。↩