この記事はレコチョク Advent Calendar 2023の11日目の記事となります。
はじめに
こんにちは、永田です。
株式会社レコチョクでiOSアプリ開発をしています。
今年の私的音楽トピックは
- 周年ライブ開催により、もう15年も凛として時雨を追っていることに気付く
- NewJeans・LE SSERAFIMを皮切りにK-POPにハマる
- USB DACデビューで逆に有線イヤホンに回帰する
の3本でした。
さて、レコチョクでは「新しい音楽体験の創出」に向けた取り組みを多数行っています。
その一環として、P!TNEというアプリを開発しました。
対応商品に内蔵されたNFCタグをスキャンすることでアプリが起動し、商品に紐づく音源を再生できるアプリです。
このアプリの開発に取り組んだ2か月強の期間の中で、技術的にも新しい取り組みを行ってきました。その中でも特に注力した
- Kotlin Multiplatform
- App Clip
について、乗り越えた難所や所感についてご紹介します。
Kotlin Multiplatformとは
Kotlin Multiplatform(以下KMP)とは、
Kotlinで書いたコードを複数のプラットフォーム(Android, iOS, Webなど)で共有するための技術です。
プラットフォーム間でのビジネスロジックの実装差異をなくすことができ、開発効率の向上が期待できるなどのメリットがあります。
導入の経緯
P!TNEアプリでは、開発期間の短さ(2か月)やアプリ開発者が少数精鋭であること(iOS/Androidそれぞれ1名ずつ)から、非常に効率的な開発が求められていました。
そこで、技術投資の一環として部門で検証を進めていた
- Flutter
- Kotlin Multiplatform
- Compose Multiplatform
の3つのマルチプラットフォーム開発技術からどれかを採用することで、これを実現しようと考えました。
その中でも
- アプリチーム全体として学習コストの低さ
- ネイティブの体験を損なうことがない
- App Clipの実装のため、アプリ自体のサイズを小さくする必要がある
などの理由からKMPが最適であると判断し、導入を決定しました。
App Clipとは
App Clipとは、アプリの一部機能をコンパクトに提供するための手段です。
NFCタグやQRコードの読み取りをトリガーとして、端末にオンデマンドにインストールされます。
P!TNEでは「NFCタグをスキャンするとすぐに音が再生できる」という体験を実現するため、App Clipの開発を行いました。
Kotlin Multiplatform × App Clipの難所
これら2つの技術を活かして開発したP!TNEアプリですが、やはりいくつか難所がありました。
その中でも特に悩まされた
- App Clipとアプリ本体の間でのデータ共有
- App Clipの容量制限
の2点について、それぞれどう解決したのかをご紹介します。
App Clipとアプリ本体の間でのデータ共有
まず、App Clip・アプリ本体間のデータ共有をどのよう実現するかが1つの課題でした。
P!TNEではUserDefaultsとSQLiteでデータ保存をしており、App Clipとアプリ本体でデータを共有しています。
一般的なデータ共有の実装に関しては、公式のドキュメントで解説されています。
KMPを用いている場合でも、まずApp Groupsを作成した上でデータ共有のためのコードを書く必要があります。
UserDefaults
UserDefaults
のデータを共有する場合、普段よく使うであろうUserDefaults.standard
は使いません。
UserDefaults.init(suiteName:)
にApp GroupsのIdentifierを渡して生成したインスタンスを使用します。
P!TNEではUserDefaults
とSharedPreferences
の抽象化にrusshwolf/multiplatform-settingsを採用していたため、iosMainで以下のようなコードを実装し、UserDefaults
のデータ共有を実現しました。
internal actual class SettingsFactory {
actual fun create(): Settings {
return NSUserDefaultsSettings.Factory().create(
name = Identifiers.APP_GROUP_IDENTIFIER
)
}
}
SQLite
P!TNEではSQLite操作の抽象化にcashapp/sqldelightを使用しています。
これを用いてSQLiteのデータ共通を行うために、こちらのIssueの内容を参考に、iosMainで以下のようなコードを実装しました。
FileManager.containerURL(forSecurityApplicationGroupIdentifier:)
を用いてApp Groups間の共通領域を参照しています。
internal actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
val schema = PitneDatabase.Schema
return NativeSqliteDriver(
configuration = DatabaseConfiguration(
name = "pitne.db",
version = schema.version.toInt(),
create = { connection ->
wrapConnection(connection) { driver ->
schema.create(driver)
}
},
upgrade = { connection, oldVersion, newVersion ->
wrapConnection(connection) { driver ->
schema.migrate(
driver,
oldVersion.toLong(),
newVersion.toLong()
)
}
},
extendedConfig = DatabaseConfiguration.Extended(
basePath = NSFileManager
.defaultManager
.containerURLForSecurityApplicationGroupIdentifier(
groupIdentifier = Identifiers.APP_GROUP_IDENTIFIER
)
?.absoluteString
)
)
)
}
}
App Clipの容量制限
続いて2つ目の難所は、App Clipの容量制限問題です。
App Clipはその性質上、バイナリサイズを小さく抑える必要があります。
その閾値はDeployment Targetの設定によって異なり、
- iOS 15以下: 10MB
- iOS 16以降: 15MB
となっています。
P!TNEの開発でもこの容量制限にかなり悩まされました。最終的には、KMP側の対応も含め
- App ClipのDeployment Targetを変更する
- Build Settingsを変更する
- アクセスレベルを変更する
- Swift Packageでのマルチモジュール構成にする
- CIでApp Clipの容量を監視する
の5つの対策を講じました。これらについて詳しく見ていきましょう。
App ClipのDeployment Targetを変更する
P!TNEでは、App ClipのDeployment TargetをiOS 16にすることで、サイズ制限を15MBまで緩和させました。
アプリ本体とは別々に設定できるので、App ClipでiOS 15をサポートしたい特別な理由がなければiOS 16にしてしまった方が楽かと思います。
Build Settingsを変更する
Build Settingsを変更し、ビルド時にアプリサイズを最適化するようにしました。
具体的には下記のように設定しています。
-
GCC_OPTIMIZATION_LEVEL
:Smallest, Aggressive Size Optimizations [-Oz]
-
SWIFT_OPTIMIZATION_LEVEL
:Optimize for Size [-Osize]
-
SWIFT_COMPILATION_MODE
:Whole Module
-
ASSETCATALOG_COMPILER_OPTIMIZATION
:space
-
ENABLE_TESTABILITY
:No
fastlaneからビルドする際は、gym
のxcargs
に渡すこともできます。
gym(
clean: true,
export_method: "app-store",
export_options: {
thinning: "<thin-for-all-variants>"
},
scheme: "App-Debug",
silent: true,
workspace: "pitne.xcworkspace",
xcargs: "GCC_OPTIMIZATION_LEVEL='z' SWIFT_OPTIMIZATION_LEVEL='-Osize' SWIFT_COMPILATION_MODE='wholemodule' ASSETCATALOG_COMPILER_OPTIMIZATION='space' ENABLE_TESTABILITY='NO'"
)
また、KMPのFrameworkのサイズを最適化するには、build.gradle.kts
でNativeBinary.optimized
をtrue
にします(RELEASEビルドの際は自動でtrue
になります)。
ただしKMPの通常の最適化レベルはFastest [-O3]
なので、サイズに対してより積極的に最適化を行いたい場合はNativeBinary.freeCompilerArgs
を上書きします。
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
kotlin {
targetHierarchy.default()
// 中略
val iosArm64 = iosArm64()
val iosSimulatorX64 = iosX64()
val iosSimulatorArm64 = iosSimulatorArm64()
listOf(
iosArm64,
iosSimulatorX64,
iosSimulatorArm64
).forEach { target ->
target.binaries.framework {
isStatic = true
embedBitcode(DISABLE)
// https://youtrack.jetbrains.com/issue/KT-37368
// https://github.com/JetBrains/kotlin/blob/master/kotlin-native/konan/konan.properties
if (buildType == RELEASE || System.getenv("OPTIMIZE_KMP_LIBRARY").toBoolean()) {
optimized = true
val optimizationLevel = "-Oz"
val iosArm64OptFlags ="clangOptFlags.ios_arm64=$optimizationLevel"
val iosSimulatorX64OptFlags = "clangOptFlags.ios_x64=$optimizationLevel"
val iosSimulatorArm64OptFlags = "clangOptFlags.ios_simulator_arm64=$optimizationLevel"
freeCompilerArgs += listOf("-Xoverride-konan-properties=$iosArm64OptFlags;$iosSimulatorX64OptFlags;$iosSimulatorArm64OptFlags")
}
}
}
// 中略
}
アクセスレベルを変更する
クラス・メソッド定義のアクセスレベルを適切に絞ることで、サイズの削減を行いました。
Swiftでは普段から意識していたのですが、Kotlinでは「とりあえず何も書かなきゃinternal
になるだろ」と後回しにしていました。
しかしKotlinではデフォルトのVisibility Modifierはpublic
になるらしく、KMPのFrameworkで大量のヘッダー定義が生成されてしまいました。
これに対し、iOS/Androidから参照する必要のないKMPのクラス・メソッド等にinternal
を付与することで、ヘッダー定義の生成量を抑えました。
これによりアプリのサイズが1.4MBほど削減できました。
Swift Packageでのマルチモジュール構成にする
社内の多くのアプリでは、project.pbxproj
のコンフリクト回避のためにXcodeGenを導入しています。
P!TNEアプリでも同様にXcodeGenを導入し、Embedded Frameworkによるマルチモジュール開発をしていました。
しかし、アプリサイズ削減の余地を探している中、この構成をやめてみたらどうか?とアドバイスをもらいました。
そこでアプリの構成変更を検討していた際、Swift Packageを用いたマルチモジュール構成についての資料を読み直しました。
いくつかのアプリでサイズ削減の効果が出ている事実を確認し、P!TNEでも同様の構成に変更しました。
これにより、開発途中の段階でアプリサイズを3.2MBほど削減できました。
また、Swift PackageからKMPのコードを参照する際は、KMPコードをXCFrameworkとして書き出し、binaryTarget
としてアプリに導入しています。
CIでApp Clipの容量を監視する
CIを用いてApp Clipのサイズを定期的に計測できるようにしました。
具体的には、Pull Requestの提出時にCI(Bitrise)上でアプリをビルドし、DangerでApp Clipのサイズを計算してコメントしています。
本当はApp Thinningを有効にした際に生成されるApp Thinning Size Report.txt
から情報を抜き出したかったのですが、fastlane, xcodebuildコマンド等からビルドすると当該ファイルが生成されないバグが存在したため、App Clipバンドル内のファイルサイズを合算して参考値を出しています。
require "fileutils"
require "zip"
IPA_FILE_NAME = "DebugApp.ipa"
ZIP_FILE_NAME = "DebugApp.zip"
ZIP_EXTRACT_DESTINATION = "DebugApp/"
APP_CLIP_SIZE_LIMIT = 15.0
# ワーキングディレクトリをiosAppに変更
ios_dir = File.join(File.dirname(__FILE__), "../")
Dir.chdir(ios_dir)
FileUtils.cp(IPA_FILE_NAME, ZIP_FILE_NAME)
Zip::File.open(ZIP_FILE_NAME) do |zip|
zip.each do |entry|
dir = File.join(ZIP_EXTRACT_DESTINATION, File.dirname(entry.name))
FileUtils.mkpath(dir)
zip.extract(entry, ZIP_EXTRACT_DESTINATION + entry.name)
end
end
fileSize = Dir["#{ZIP_EXTRACT_DESTINATION}Payload/DebugApp.app/AppClips/DebugClip.app/**/*"]
.select { |f| File.file?(f) }
.sum { |f| File.size(f) }
megabyteFileSize = fileSize.fdiv(1000 * 1000)
message("App Clipのファイルサイズは #{megabyteFileSize}MB です。")
if megabyteFileSize >= APP_CLIP_SIZE_LIMIT
warn("App Clipのサイズが#{APP_CLIP_SIZE_LIMIT}MBを超過しています。サイズを削減してください。")
end
これらの取り組みの末、現状P!TNEのApp Clipは約11MBになりました。
未実装部分の多い開発中盤の段階で13MBほどあったので、かなり削減できたのではないかと思います。
Kotlin Multiplatform導入の所感
社内でも実プロダクトへの導入の前例がなかったため、KMPには色々と苦労させられました。
しかし実践を通して課題だけでなくメリットも体感できました。
導入により得られたメリット
仕様差異・手戻りが少なくなる
KMPを導入することで、ドメインロジックに関してOS間の仕様差異・手戻りがかなり少なくなりました。
これによりロジックの実装フェーズにムダがなくなり、UI層の作り込みにより時間をかけられるというメリットもありました。
プラットフォーム間でコードに対する共通認識を持てる
iOS/Androidエンジニア間でコードに対する共通認識を持つことができ、作業分担やコードの修正がかなりスムーズになりました。
単にコードが共通化されているからというのもありますが、事前の認識合わせがかなり重要であったように感じています。
実際にP!TNEアプリの開発では、実装着手前に対面で設計に関するディスカッションを行いました。
- 大枠のアーキテクチャ
- KMPを導入する範囲
- プラットフォーム固有のAPIを使用する部分と、その共通インタフェースの設計
- 既存ライブラリが使えそうな部分ついては、その選定を含む
- 実装順序
などを事前に話し合うことで、コードに対しての共通認識を深めました。
(実際の様子です。当初はCompose Multiplatformを想定していましたが、アプリサイズ等の観点から採用を見送りました。)
これにより作業分担や修正がスムーズになりましたが、その他にもメリットがありました。
特に、iOSエンジニアがKMPコードを実装した際に、軽微な内容であればAndroid側のコードも修正できるようになった(その逆も然り)ことは大きいメリットであったように感じます。
設計が共通化されており、それぞれのプラットフォーム固有の事情がなんとなく把握できていたため、
「ここは自分でも修正できそう」「ここは任せた方がよさそう」という判断がしやすくなりました。
見えてきた課題
Swiftとの互換性
KMPのコードはiOS向けにObjective-Cのframeworkとしてビルドされるため、Swiftとの互換性の面でさまざまな問題に直面しました。例えば
-
sealed class
/interface
はSwiftではenum
ではなくclass
とそのサブクラスとして定義されるため、網羅的に処理するにはdefaultケースが必要であること -
Flow
がCombine
やAsyncSequence
などに自動変換されないこと
などです。
ただし、これらはSKIEというプラグインを用いることである程度解消できました。
またP!TNEの開発では大きな問題になりませんでしたが、ジェネリクスの型パラメーターが消失してしまうという問題もあるそうです。
このようにSwiftとの互換性の面で課題を抱えるKMPですが、先日JetBrainsから公開されたKotlin Multiplatform Development Roadmap for 2024の中に「direct Kotlin-to-Swift export」に取り組むとあるため、これらの課題は近い将来解消されるかもしれません。
プラットフォーム固有の知識の重要性
KMPに限った話ではないと思いますが、マルチプラットフォーム開発技術を導入すればプラットフォーム固有の知識が不要になるわけではありません。
前述したように、導入による効果を最大化するには事前のディスカッションが重要であるため、むしろより深い知見が必要だと感じました。
そのため、経験の浅いメンバーだけで運用しようとすると、むしろ生産性を下げてしまうかもしれません。
今後の展望
- iOS/Android双方から使いやすい設計を模索する
- 他プロジェクトへの導入
- 触れられるメンバーを増やす
などなど、より効率的な開発・技術的なチャレンジを推進していきたいと考えています。
終わりに
今回は、P!TNEアプリの開発で取り組んだ技術トピックについて振り返りました。
KMPを用いてApp Clipを開発した事例はなかなかないと思うので、今後この記事が誰かの参考になれば幸いです。
また個人的な所感として、短い開発期間ながら多くの新技術に触れることができ、課題を乗り越えながら大きく成長できたと感じています。
今後も技術的なチャレンジを通して、成長しながら良いプロダクトを開発していきたいと思います。
最後に、レコチョクでは技術的なチャレンジを一緒に推進していく仲間を募集しています。
気になる方は是非採用ページをご覧ください。
明日のレコチョク Advent Calendar 2023は12日目「【API】バックエンド初心者によるCloud Runを使った爆速サーバーレスAPI開発」です。お楽しみに!
この記事はレコチョクのエンジニアブログの記事を転載したものとなります。