AnycaでAppClip対応をした話

iOS14の新機能として大きな注目を浴びたAppClipですが、同時に発表されたWidgetに比べると実際に利用されているところを見る機会が少ないと思います。

[Anyca](https://anyca.net/)では公開されているクルマの情報を閲覧機能を提供するAppClipを10月にリリースしました。本記事ではAnycaにおけるAppClipのリリースでつまずいた点や、それを回避する手法などを紹介していきたいと思います。

なお、この記事は[DeNA Advent Calendar 2020](https://qiita.com/advent-calendar/2020/dena)の16日目の記事です。

## AppClip とは

AppClipはAppStoreからアプリをインストールさせることなく、アプリの一部機能を提供することのできるiOS14から利用できる新機能です。特定のURLやNFCタグ、QRコードなどを起点にしてAppClipカードと呼ばれるUIを表示・起動することができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4640/c0877af4-cc04-d31e-6aef-f1894c6e9c59.png" width="50%" />

もしiOS14のインストールされたiOS端末をお持ちであれば、以下のQRコードを読み取ってみてください。AnycaのAppClipカードが表示され、表示ボタンを押下するとアプリがインストールされていなくてもAnycaのAppClipが起動し、クルマの詳細情報画面が表示されると思います。

<img src="https://chart.googleapis.com/chart?cht=qr&chs=300x300&chl=https%3A%2F%2Fanyca.net%2Fapp_redirect%3Fpath%3Dcar_detail%26car_id%3D37238&choe=UTF-8" />

このようにユーザに対して、スムーズにアプリの機能を提供することができるため、ユーザにアプリを利用してもらうための敷居を大幅に下げることができます。

## AppClipの10MB制限

このように便利なAppClipですが、通常のアプリに比べると一部のフレームワークが利用できなかったり、プライバシー情報の取得が制限されるといったいくつかの制限が存在します。このなかで最も大きな制限となるのはバイナリサイズに関するものです。

AppClipを素早く起動させるために、AppClipのバイナリサイズは10MBに制限しています。この10MBという制約はipa形式のような圧縮した状態でのアプリサイズに対してではなく、未圧縮状態でのサイズに対して課されており、非常に厳しいものになっています。

例えばAnycaのアプリはipa形式のサイズでは65MB程度ですが、未圧縮状態では140MB程のサイズになります。アプリの一部機能をAppClipとして提供するには、機能を提供するのに必要なものだけを切り出し、1/10程度のサイズに抑え込まなければなりません。

### AppClipバイナリのサイズの確認方法

作成したAppClipは10MBを超えていても、開発中は普通に利用することができます。しかし、いざアプリにAppClipを含めて申請しようとすると、以下のようなエラーが出てしまいます。


> ITMS-90865: Thinned app clip size is too large - The universal variant app clip /Payload/xxx.app exceeds the maximum allowable size of 10MB. For details about app thinning, see: https://help.apple.com/xcode/mac/current/#/devbbdc5ce4f.

AppClipのサイズを申請前に確認するには、AppClipを含むアプリをArchiveし、AdHocでAppClipをBitcodeとApp Thinningを有効にしてエクスポートします。エクスポートが完了すると、`App Thinning Size Report.txt`というファイルがipaとともに生成されます。このファイルの内容を確認することでAppClipのサイズを確認できます。

```

App Thinning Size Report for All Variants of anyca-Release

:

Variant: Clip.ipa
Supported variant descriptors: Universal
App + On Demand Resources size: 4 MB compressed, 10.3 MB uncompressed
App size: 4 MB compressed, 10.3 MB uncompressed
On Demand Resources size: Zero KB compressed, Zero KB uncompressed
```

このなかの`App size: xx.x MB compressed, xx.x MB uncompressed`の部分の`xx.x MB uncompressed`が10MB未満であればAppClipのサイズ制限をパスできたことになります。

### 必要なファイル・リソース・ライブラリを絞り込む

AppClipで利用する機能を提供するのに現在のアプリで利用しているコードやライブラリ、リソースをすべて利用する必要はありません。新しくAppClip用のターゲットを作成するにあたって、利用しないビューや機能に関するコードやリソース、ライブラリは全てAppClipのターゲットから外します。

コード内にはAppClipでは利用しないため、一部ビルドの対象から外したい箇所などが出てくる場合があります。こういった場合、AppClipターゲットではコンパイラフラグとして`APP_CLIP`を定義しておき、プリプロセッサを利用して、AppClip向けのビルドでは一部のコードをビルドから除外するといった対応を行います。

```swift
#if !APP_CLIP
  // AppClipでは不要な処理
#endif
```

基本的にはこのターゲットとなるファイルの絞り込みを行うことで、大体の場合、サイズ制限を回避できるのではないかと思います。しかし、Anycaの場合これだけでは10MBを下回ることができず、他にもいくつかの対応を行う必要がありました。

### リソースを圧縮する

Anycaでは一部UIを独自のレイアウトファイルで定義している部分があり、このレイアウトファイルはJSONの形式でリソースに含まれています。また、一部デザインでカスタムフォントを利用しており、これもリソースに含める必要がありました。

これらのファイルは1つ1つはそれほど大きなファイルではなく、ipaにしてしまえば圧縮が効くので、通常のアプリ開発では気にする必要はないのですが、AppClipにおいては未圧縮状態でのファイルサイズがサイズ制限に影響してくるため、こういったファイルが多いほど制限に引っかかりやすくなってしまいます。

Anycaではビルド時にこれらのファイルをzipで圧縮することで、AppClipのサイズを削減することにしました。

まず、AppClipのビルドフェーズにスクリプトを追加し、リソースのコピーが完了した時点で対象のファイルをzipで圧縮し、元のデータを削除しています。

```sh
cd "${TARGET_BUILD_DIR}/${EXECUTABLE_FOLDER_PATH}"
zip fonts.zip *.ttf
zip layouts.zip layout_*.json
rm -f *.ttf
rm -f layout_*.json
```

そしてAppClipの起動時にzipファイルを展開し、展開したリソースを利用するようにします。

```swift
func decompressLayoutFiles(destinationPath: String) {
    let bundlePath = Bundle(for: type(of: self)).bundlePath
    guard let contents = try? FileManager.default.contentsOfDirectory(atPath: bundlePath) else { return }
    
    // レイアウト圧縮ファイルがあった場合、展開する
    if let compressedFile = contents.first(where: { $0 == "layouts.zip" }) {
        SSZipArchive.unzipFile(atPath: "\(bundlePath)/\(compressedFile)", toDestination: destinationPath)
    }
}
```

```swift
func registerCompressed(bundle: Bundle? = .main) {
    guard let compressedFile = bundle?.path(forResource: "fonts", ofType: "zip"),
          let cacheDirectory = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first else { return }

    do {
        let fontDirectory = "\(cacheDirectory)/Fonts"
        if !FileManager.default.fileExists(atPath: fontDirectory) {
            try FileManager.default.createDirectory(atPath: fontDirectory, withIntermediateDirectories: false)
        }
        
        SSZipArchive.unzipFile(atPath: compressedFile, toDestination: fontDirectory)
        
        let contents = try FileManager.default.contentsOfDirectory(atPath: fontDirectory)
        for content in contents where content.hasSuffix("ttf") {
            guard let fontData = NSData(contentsOfFile: "\(fontDirectory)/\(content)"),
                  let dataProvider = CGDataProvider(data: fontData),
                  let cgFont = CGFont(dataProvider) else { continue }

            var errorRef: Unmanaged<CFError>? = nil
            CTFontManagerRegisterGraphicsFont(cgFont, &errorRef)
        }
    } catch {
        // do nothing
    }
}
```

このようにして自前でファイルを圧縮・展開することで、数MBのサイズ削減を行うことができました。

### Swiftの最適化をサイズ優先にする

上記の内容でだいぶスリムになると思いますが、あともう一押しサイズを小さくしたい場合、Swiftコンパイラの最適化レベルをサイズ優先指定にします。

```
SWIFT_OPTIMIZATION_LEVEL = -Osize
```

これによって通常の最適化レベル`-O`に比べて、20%程度サイズが縮小されました。

## AppClipの動作確認
通常のアプリと同じように、開発中のAppClipはXcodeからデバッグ実行を行うことができます。この際、実行時の環境変数`_XCAppClipURL`に起動URLを指定することで、AppClipがこのURLをもとに起動された状態をテストすることが可能です。

実際にQRコードやNFCタグからURLを読み取り、AppClipを起動するには開発端末の開発者設定から、AppClipのLocal Experienceを追加することで、設定したURLを含むQRコードやNFCタグを読み取った際にAppClipを起動させることができます。

TestFlight経由でテスターに向けてAppClipのテストを提供することも可能ですが、起動はTestFlightアプリからのみとなり、最大3つまでの起動URLしか指定することはできません。

## AppClip Experienceの登録
AppClipには`Default AppClip Experience`と`Advanced AppClip Experience`の2種類があり、AppClipの起動時に表示されるAppClipカードの内容をそれぞれAppStore Connectから設定することができます。

QRコードやNFCタグを利用したAppClip起動は`Advanced AppClip Experience`でしか利用できないため、これらをトリガーに起動を行いたい場合は、両方の設定を行う必要があります。

## まとめ
AppClipはまだまだ提供しているアプリが少なく、その利用方法もまだ手探りの状態です。サイズ制限が厳しいものの、AppStore経由でのアプリのインストールに比べれば非常にスムーズにユーザにアプリに触れてもらえるため、うまく組み込むことができれば非常に楽しみな機能だと考えています。

現在、有楽町にあるb8taでAnycaの展示を行なっており、こちらの設置してあるQRコードからもAppClipを試すことができます。お近くにお寄りの際は是非体験してみてください。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4640/965843e7-433b-b45d-7c10-3a40cdc5c24e.png" width="80%" />

また、DeNA公式のTwitterアカウント [@DeNAxTech](https://twitter.com/DeNAxTech) では、 Blog記事だけでなく色々な勉強会での登壇資料も発信しています。興味があれば、ぜひフォローしてください。