実際にリリースするiOSアプリ開発で困ったこと

鈴木大貴氏:それでは『iOSアプリにKMMを導入するtips』の発表を始めます。簡単に自己紹介をします。株式会社AbemaTVでAbemaのアプリを開発しています、鈴木大貴と申します。iOSアプリを担当しています。

今日のアジェンダですが、大きく分けて4項目です。KMM(Kotlin Multiplatform Mobile)のフレームワークを取り込んだアプリをQAを通してリリースブランチに乗せていて、これは来週以降(※取材当時)実際にリリースされるものです。

KMMを導入するにあたって詰まった部分などをまとめています。1つ目は「Suspend functionをiOSでキャンセルできるようにする」というものです。2つ目は「テストのときだけ任意のモジュールをimportしたい」というもので、3つ目が「KMMのフレームワークをプライベートリポジトリのReleasesにアップロードして、CocoaPods経由でダウンロードできるようにする」です。最後は「Kotlinで実装されたNSObjectのサブクラスをSwizzleするとクラッシュする問題を解決する」というものです。

「Suspend functionをiOSでキャンセルする」方法

さっそく「Suspend functionをiOSでキャンセルする」について話します。以前 「Qiita」に簡単にまとめたこともあったんですが、それをより詳しく話していきます。

これはKotlinのコードです。例えばビデオのランキングに関するAPIを1つのインターフェイスとしてまとめたときに、ジャンルごとのランキングを取得するSuspend functionを定義した場合、以下のようになると思います。

これをSwift側で呼び出すと、インターフェイス的にはcompletionHandlerとしてコールバックを渡して、そこからレスポンスを受け取るかたちになります。ただこの場合だと、メソッド自体の返り値はVoid型になるので、Swift側からキャンセルする術がありません。

なので、サブスクライブして、最終的に返ってきたjobのオブジェクトからcancelを呼び出せるように変更していきます。このやり方は、Working with Kotlin Coroutines and RxSwiftという記事でも紹介していて、それを参考にしています。

1つ目の作業なんですが、Suspend function自体をラップするクラスを作ります。名前はSuspendWrapperで置いています。

コンストラクターの引数としてLambdaでsuspend functionを取得して、AndroidからSuspend functionをそのまま呼び出す用のメソッドを定義しつつ、iOSでsubscribeをして、最終的にjobでキャンセルできるインターフェイスにするものを定義します。

subscribeでは成功したときのハンドラーと失敗したときのハンドラーを定義しています。

実際にインターフェイスに定義すると、先ほどは返り値がそのままGenreRankingResponseになっていたと思うんですが、そこをただSuspendWrapperというクラスで括って、ジェネリックなものとして返してあげます。

ほかにもDeferredというKotlin側で実際に定義されているインターフェイスを使う方法もあるんですが、kotlin-nativeの仕様でObjective-Cのprotocolに変換されます。

Objective-Cのprotocol自体がGeneric Argumentをもたない仕様になっているので、Tと定義しても実際に利用するとAnyになってしまうという問題があります。なので、今回は1つ型を用意してラップするというかたちにしています。

Swiftで実際に以下のように呼び出せるようになります。これはiOS向けにcancelを呼び出せるインターフェイスです。Android側で本来不要なはずのsuspendというfunctionをいったん呼び出さなくてはいけないというデメリットがあります。

また、いろいろとレイヤーなどを分けてモジュールを追加していくと、いろいろなところでSuspendWrapperをインポートしないといけません。

「Koru」を使って「SuspendWrapper」をさらに活用する

そこで、このSuspendWrapperをさらに活用していきます。GitHubでKoruというものが公開されていて、これを使うと自動的に先ほどのSuspendWrapperを生やすことができます。このライブラリも先ほど紹介したブログを参考に作られているようです。

実際に自動生成されるクラスがどういうふうになるかというと、先ほど定義したランキング用のAPIを1個ラップしたNativeというサフィックスが付いたクラスが生成されます。

SuspendWrapperを返り値として、内部でsuspend functionをMainScopeで呼び出す実装をしたメソッドとそのクラスが自動生成されます。

こちらは実際に自動生成されたものです。Kotlin側でどういうふうに実装するかというと、@ToNativeClassでアノテーションを追加するだけです。

そのアノテーションにどのCoroutineScopeで動かすかを指定する必要があるので、MainScopeを指定しつつ、インターフェイス上はSuspendWrapperではなくてGenreRankingResponseというただのクラスを返すので、もともとAndroid側でも利用したかった通常のSuspend functionの状態で定義できます。

こちらで使っているMainScopeProviderがどういうものかというと、CoroutineScopeとしてMainScopeを提供するクラスとして定義しています。@ExportedScopeProviderの アノテーションを指定すると、先ほどの@ToNativeClassというアノテーションに指定できるScopeProviderとなります。

先ほどと同じなんですが、Suspend functionをSwift側でそのまま呼び出すと、completionHandlerのクロージャになります。

これを自動生成されたVideoRankingApiNativeに渡すと、APIはSuspendWrapperで自動的に生成されたインターフェイスにアクセスできるようになって、subscribeを呼ぶことができます。返り値としてjobが返ってくるので、キャンセルも可です。

subscribeとjobが返ってくる関係をiOSで使っているRxSwiftで表現すると、単一実行したものが結果として1回だけ返ってくるという状態なので、RxSwiftのSingleに置き換えられる状態になります。

こちらに置き換えた状態で、例えばRepositoryでランキングを取得するものを実際に作るとすると、引数にはインターフェイスとして定義しているVideoRankingApiを取りつつ、中で利用するときにはSuspendWrapperとして自動生成されたApiNativeのインスタンスを保持します。それを実際に利用して、RxSwiftのSingleに変換して外側に返します。

「SuspendWrapper」を利用したクラスをテストする方法

SuspendWrapperを利用したクラスをテストしたくなることがあると思うんですが、先ほどMainScopeProviderで使っていたMainScope自体はCoroutineScopeの中でDispatchers.Mainを使っているので、iOS上のXCテストで実行したときには内部的に非同期で実行されます。

上から順番にfakeを作って、mockを生成して、実際にテスト対象となるRepositoryをインスタンス化したあとに、非同期のものを待つためにexpectationでexpectを生成して、実際にsubscribeを呼び出します。

ここではまだ呼び出しは発生していなくて、呼び出しを受け取るという実装をするだけですね。

そのあとにいったんDispatchQueue.main.asyncで非同期に実行させる実装だけをして、expectを0.1秒間待つという処理を書きます。次に番号①でWaitの部分の定義をして、②ではDispatchQueue.main.asyncでRunLoop先で実行させるものを1個定義します。

③で待つ処理に入って、④で実際に値として想定しているものをレスポンスとしてSuspendWrapperのmockに渡すとsubscribeのコールバックが呼ばれて、⑤でexpectのfulfillが呼ばれてテストが成功します。

ただこれは内部の値が適切に渡せているかを確認しただけなので、非同期なテストを書く必要は実際はありません。ここで同期的に書く場合、先ほどのDispatchers.Mainの部分をimmediateに変えれば同期的にメインスレッドから呼ばれてdispatchされるはずです。ただ、現状PullRequestがあがっていて、immediateで実装されていても同期的に実行されずにdispatchが呼ばれて非同期になってしまうという問題があります。

なのでiOS側で同期的に呼び出しを可能にするためには、ImmediateScopeを定義します。CoroutineDispatcher自体を実装して、dispatchのメソッドの中でただblockをrunするだけのもので、同一スレッドで即時実行されるScopeです。

先ほどのMainScopeProviderの中にImmediateScopeをiOS側から別なScopeとしてセットできるsetterを追加して、実際にImmediateScopeで動く実装を追加します。そうするとMainScopeProvider自体がSingleton扱いになるので、そこから呼び出しを行って実際にscopeをsetします。

Suspend functionを自身で初期化する場合はScopeProviderが必要になるので、これもiOS側の実装としてImmediateScopeProviderを定義して、ImmediateScopeを返せるようにします。

実際にそれをSuspend functionのmockとして値を返せる状態にするのですが、KotlinSuspendfunction0というprotocolがいて、これはkotlin-native側で自動的に吐き出されたSuspend functionを意味しています。

これがインターフェイスとして定義された場合、必ず実装しないといけないものが、invoke(completionHandler:)です。これはSuspend functionが実行されたときに呼び出されるメソッドになっています。

completionHandlerの引数を受け取ったら、Suspend functionのmockでcompletionHandlerをほかのクロージャ内で保持をして、respondの部分で成功値か失敗値を返せるようにします。

それをSuspendWrapperとして返せるように、asSuspendWrapperという関数としてImmediateScopeProviderを渡します。

そうすると先ほど非同期で書いていたテストが、上からImmediateScopeをsetして、fakeのAPIを生成したあとにmockのSuspend functionを定義して、responseとしてSuspendWrapperを渡します。

そのあとに実際のテストのターゲットになるRepositoryを初期化して、responseを待つ用にBehaviorRelayで変数を1個置きます。そこでGenreのsubscribeをして、responseに対してbindできるようにしたあとに、最後に実際に値として渡したいものをsuccessというかたちでrespondで渡すと、そのままテストが通過できます。

これが実際に疎通ができているかのテストです。

(次回へつづく)