【iOS】Kotlin MultiplatformでApp Clip対応アプリを2か月で作ったので振り返る

この記事は[レコチョク Advent Calendar 2023](https://qiita.com/advent-calendar/2023/recochoku)の11日目の記事となります。

## はじめに

こんにちは、永田です。
株式会社レコチョクでiOSアプリ開発をしています。

今年の私的音楽トピックは

- 周年ライブ開催により、もう15年も凛として時雨を追っていることに気付く
- NewJeans・LE SSERAFIMを皮切りにK-POPにハマる
- [USB DAC](https://www.fiio.jp/products/btr7/)デビューで逆に有線イヤホンに回帰する

の3本でした。

---

さて、レコチョクでは「新しい音楽体験の創出」に向けた取り組みを多数行っています。

その一環として、[P!TNE](https://pitne.jp)というアプリを開発しました。
対応商品に内蔵されたNFCタグをスキャンすることでアプリが起動し、商品に紐づく音源を再生できるアプリです。


![P!TNEの紹介画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175525/d861f604-db31-2dc3-dc1a-74f699f0cd77.gif)


このアプリの開発に取り組んだ2か月強の期間の中で、技術的にも新しい取り組みを行ってきました。その中でも特に注力した

- Kotlin Multiplatform
- App Clip

について、乗り越えた難所や所感についてご紹介します。

## Kotlin Multiplatformとは

[Kotlin Multiplatform](https://www.jetbrains.com/ja-jp/kotlin-multiplatform/)（以下KMP）とは、
Kotlinで書いたコードを複数のプラットフォーム（Android, iOS, Webなど）で共有するための技術です。

プラットフォーム間でのビジネスロジックの実装差異をなくすことができ、開発効率の向上が期待できるなどのメリットがあります。

![Kotlin Multiplatform](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175525/6f52a499-693f-d0cb-37ba-c2e47f26dd61.png)

### 導入の経緯

P!TNEアプリでは、開発期間の短さ（2か月）やアプリ開発者が少数精鋭であること（iOS/Androidそれぞれ1名ずつ）から、非常に効率的な開発が求められていました。

そこで、技術投資の一環として部門で検証を進めていた

- Flutter
- Kotlin Multiplatform
- Compose Multiplatform

の3つのマルチプラットフォーム開発技術からどれかを採用することで、これを実現しようと考えました。

その中でも

- アプリチーム全体として学習コストの低さ
- ネイティブの体験を損なうことがない
- App Clipの実装のため、アプリ自体のサイズを小さくする必要がある

などの理由からKMPが最適であると判断し、導入を決定しました。

## App Clipとは

[App Clip](https://developer.apple.com/jp/app-clips/)とは、アプリの一部機能をコンパクトに提供するための手段です。

NFCタグやQRコードの読み取りをトリガーとして、端末にオンデマンドにインストールされます。

<img alt="App Clip Card" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175525/4f3c2c98-203b-221b-428e-d7f8fe312919.jpeg" width="50%">

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とアプリ本体でデータを共有しています。

一般的なデータ共有の実装に関しては、[公式のドキュメント](https://developer.apple.com/documentation/app_clips/sharing_data_between_your_app_clip_and_your_full_app)で解説されています。

KMPを用いている場合でも、まずApp Groupsを作成した上でデータ共有のためのコードを書く必要があります。

#### `UserDefaults`

`UserDefaults`のデータを共有する場合、普段よく使うであろう`UserDefaults.standard`は使いません。
[`UserDefaults.init(suiteName:)`](https://developer.apple.com/documentation/foundation/userdefaults/1409957-init)にApp GroupsのIdentifierを渡して生成したインスタンスを使用します。

P!TNEでは`UserDefaults`と`SharedPreferences`の抽象化に[russhwolf/multiplatform-settings](https://github.com/russhwolf/multiplatform-settings)を採用していたため、iosMainで以下のようなコードを実装し、`UserDefaults`のデータ共有を実現しました。

```kotlin
internal actual class SettingsFactory {
    actual fun create(): Settings {
        return NSUserDefaultsSettings.Factory().create(
            name = Identifiers.APP_GROUP_IDENTIFIER
        )
    }
}
```

https://github.com/russhwolf/multiplatform-settings

#### SQLite

P!TNEではSQLite操作の抽象化に[cashapp/sqldelight](https://github.com/cashapp/sqldelight)を使用しています。

https://github.com/cashapp/sqldelight

これを用いてSQLiteのデータ共通を行うために、[こちらのIssue](https://github.com/cashapp/sqldelight/issues/1787)の内容を参考に、iosMainで以下のようなコードを実装しました。

[`FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`](https://developer.apple.com/documentation/foundation/filemanager/1412643-containerurl)を用いてApp Groups間の共通領域を参照しています。

```kotlin
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`に渡すこともできます。

```ruby
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]`](https://github.com/JetBrains/kotlin/blob/88bbe8d92d9d3cba920eb467f6fe733d241538c0/kotlin-native/konan/konan.properties#L237)なので、サイズに対してより積極的に最適化を行いたい場合は`NativeBinary.freeCompilerArgs`を上書きします。

```kotlin
@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`になる](https://kotlinlang.org/docs/visibility-modifiers.html)らしく、KMPのFrameworkで大量のヘッダー定義が生成されてしまいました。

これに対し、iOS/Androidから参照する必要のないKMPのクラス・メソッド等に`internal`を付与することで、ヘッダー定義の生成量を抑えました。
これによりアプリのサイズが1.4MBほど削減できました。

#### Swift Packageでのマルチモジュール構成にする

社内の多くのアプリでは、`project.pbxproj`のコンフリクト回避のためにXcodeGenを導入しています。
P!TNEアプリでも同様にXcodeGenを導入し、Embedded Frameworkによるマルチモジュール開発をしていました。

しかし、アプリサイズ削減の余地を探している中、この構成をやめてみたらどうか？とアドバイスをもらいました。

そこでアプリの構成変更を検討していた際、Swift Packageを用いたマルチモジュール構成についての資料を読み直しました。

https://speakerdeck.com/d_date/swift-package-centered-project-build-and-practice

https://tech.timee.co.jp/entry/2021/08/24/160205

いくつかのアプリでサイズ削減の効果が出ている事実を確認し、P!TNEでも同様の構成に変更しました。
これにより、開発途中の段階でアプリサイズを3.2MBほど削減できました。

![Swift Packageの導入結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175525/cf1b9d8b-8dd9-7b08-7c55-ee0c64eb3f68.png)


また、Swift PackageからKMPのコードを参照する際は、KMPコードをXCFrameworkとして書き出し、`binaryTarget`としてアプリに導入しています。

#### CIでApp Clipの容量を監視する

CIを用いてApp Clipのサイズを定期的に計測できるようにしました。

具体的には、Pull Requestの提出時にCI(Bitrise)上でアプリをビルドし、DangerでApp Clipのサイズを計算してコメントしています。

![App Clipのサイズ計測](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175525/076c083d-aede-9cd6-93ce-ec24bfdf4892.png)


本当はApp Thinningを有効にした際に生成される`App Thinning Size Report.txt`から情報を抜き出したかったのですが、[fastlane, xcodebuildコマンド等からビルドすると当該ファイルが生成されないバグ](https://github.com/fastlane/fastlane/issues/21058)が存在したため、App Clipバンドル内のファイルサイズを合算して参考値を出しています。

```ruby
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ほどあったので、かなり削減できたのではないかと思います。

![App Clipのサイズ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175525/2b5702da-364c-af23-8fd0-3a7f7d97107c.png)


## Kotlin Multiplatform導入の所感

社内でも実プロダクトへの導入の前例がなかったため、KMPには色々と苦労させられました。
しかし実践を通して課題だけでなくメリットも体感できました。

### 導入により得られたメリット

#### 仕様差異・手戻りが少なくなる

KMPを導入することで、ドメインロジックに関してOS間の仕様差異・手戻りがかなり少なくなりました。

これによりロジックの実装フェーズにムダがなくなり、UI層の作り込みにより時間をかけられるというメリットもありました。

#### プラットフォーム間でコードに対する共通認識を持てる

iOS/Androidエンジニア間でコードに対する共通認識を持つことができ、作業分担やコードの修正がかなりスムーズになりました。

単にコードが共通化されているからというのもありますが、事前の認識合わせがかなり重要であったように感じています。

実際にP!TNEアプリの開発では、実装着手前に対面で設計に関するディスカッションを行いました。

- 大枠のアーキテクチャ
- KMPを導入する範囲
- プラットフォーム固有のAPIを使用する部分と、その共通インタフェースの設計
  - 既存ライブラリが使えそうな部分ついては、その選定を含む
- 実装順序

などを事前に話し合うことで、コードに対しての共通認識を深めました。

（実際の様子です。当初はCompose Multiplatformを想定していましたが、アプリサイズ等の観点から採用を見送りました。）
<img alt="設計の検討" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175525/5f55a966-b975-87bf-6c09-62ef1e48d3d1.jpeg" width="50%">


これにより作業分担や修正がスムーズになりましたが、その他にもメリットがありました。

特に、iOSエンジニアがKMPコードを実装した際に、軽微な内容であればAndroid側のコードも修正できるようになった（その逆も然り）ことは大きいメリットであったように感じます。

設計が共通化されており、それぞれのプラットフォーム固有の事情がなんとなく把握できていたため、
「ここは自分でも修正できそう」「ここは任せた方がよさそう」という判断がしやすくなりました。

### 見えてきた課題

#### Swiftとの互換性

KMPのコードはiOS向けにObjective-Cのframeworkとしてビルドされるため、Swiftとの互換性の面でさまざまな問題に直面しました。例えば

- `sealed class`/`interface`はSwiftでは`enum`ではなく`class`とそのサブクラスとして定義されるため、網羅的に処理するにはdefaultケースが必要であること
- `Flow`が`Combine`や`AsyncSequence`などに自動変換されないこと

などです。
ただし、これらは[SKIE](https://github.com/touchlab/SKIE)というプラグインを用いることである程度解消できました。

https://github.com/touchlab/SKIE

またP!TNEの開発では大きな問題になりませんでしたが、ジェネリクスの型パラメーターが消失してしまうという問題もあるそうです。

このようにSwiftとの互換性の面で課題を抱えるKMPですが、先日JetBrainsから公開された[Kotlin Multiplatform Development Roadmap for 2024](https://blog.jetbrains.com/kotlin/2023/11/kotlin-multiplatform-development-roadmap-for-2024/)の中に「direct Kotlin-to-Swift export」に取り組むとあるため、これらの課題は近い将来解消されるかもしれません。

#### プラットフォーム固有の知識の重要性

KMPに限った話ではないと思いますが、マルチプラットフォーム開発技術を導入すればプラットフォーム固有の知識が不要になるわけではありません。

前述したように、導入による効果を最大化するには事前のディスカッションが重要であるため、むしろより深い知見が必要だと感じました。

そのため、経験の浅いメンバーだけで運用しようとすると、むしろ生産性を下げてしまうかもしれません。

### 今後の展望

- iOS/Android双方から使いやすい設計を模索する
- 他プロジェクトへの導入
- 触れられるメンバーを増やす

などなど、より効率的な開発・技術的なチャレンジを推進していきたいと考えています。

## 終わりに

今回は、P!TNEアプリの開発で取り組んだ技術トピックについて振り返りました。
KMPを用いてApp Clipを開発した事例はなかなかないと思うので、今後この記事が誰かの参考になれば幸いです。

また個人的な所感として、短い開発期間ながら多くの新技術に触れることができ、課題を乗り越えながら大きく成長できたと感じています。
今後も技術的なチャレンジを通して、成長しながら良いプロダクトを開発していきたいと思います。

最後に、レコチョクでは技術的なチャレンジを一緒に推進していく仲間を募集しています。
気になる方は是非[採用ページ](https://recruit.recochoku.jp/)をご覧ください。

https://recruit.recochoku.jp/

明日の[レコチョク Advent Calendar 2023](https://qiita.com/advent-calendar/2023/recochoku)は12日目「【API】バックエンド初心者によるCloud Runを使った爆速サーバーレスAPI開発」です。お楽しみに!

---

この記事はレコチョクのエンジニアブログの記事を転載したものとなります。 
 
https://techblog.recochoku.jp/9877