dely tech blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

KMMでiOS・Android
を共通化しよう

ogp

こんにちは! dely開発部でiOSエンジニアをしている@yochidrosです。

この記事は「dely #2 Advent Calendar 2020」の11日目の記事です。

adventar.org

adventar.org

昨日は@_kobuuukataさんの開発者向けのオンラインイベントを開催してわかった7つのポイントでした

普段,業務上はiOSアプリをゴリゴリ開発していますが、過去にAndroid・iOS両方を開発してたこともあり 一度に両OSを開発できたら良いと思っていました。 FlutterReact Nativeなどマルチプラットフォーム開発できるツールがありますがそれぞれで メリット・デメリットがありなかなか導入に至るまでにはいかないケースがあると思います。

そこで今回は2020年の9月についにalpha版になったKMMについて調べてみました。

KMMとは?

Kotlin Multiplatform Mobileの略でiOS・Androidで共通のビジネスロジックを一つのコードで実現できるSDKです。 Kotlinで記述したものが各プラットフォームにネイティブなバイナリコードに変換されるので他のライブラリとも併用できます。

KMM-release-scheme_Blogpost

詳しくは公式サイトにも紹介があります。

blog.jetbrains.com

KMPとKMMの違いは?

Kotlin Multi PlatformとKotlin Multiplatform Mobileの違いは簡単に言えばモバイルに特化しているという点です。 KMPはかくデスクトップのOSにコンパイルされるのに対してKMMはモバイルのOSにコンパイルすることができます。 KMPでもiOS/Androidに変換することができますがモバイルだけならKMMを利用すれば良いということになります。  

使ってみよう

説明はさておき、実際に触ってみましょう。 実際に動かしたコードはこちらにあります。

github.com

以下、こちらの環境で動作を確認してます

  • Android Studio 4.2 Preview
  • Kotlin 1.4.20-release-Studio4.2-1
  • Xcode 12.2

新規プロジェクトを作成する前にプラグインを導入します。 Android StudioでPreference -> Pluginskotlin multiplatformと調べるとKMMのプラグインがでてきますのでインストールします。 インストールしたらAndroid Studioを再起動しましょう。 再起動が完了したら新規プロジェクトのテンプレートにKMM Applicationが増えているので選択します。

KMMプラグインを追加する

プロジェクトの名前を決めます。 今回はサンプルなので共通コードはSharedとしています。

プロジェクトの名前を決める

FINISHを押すと共通コードのmoduleと各OSのプロジェクトが作成されます。

iOS・Android・共通ロジックが生成される

早速各OSで動かしてみましょう。

動かしてみた

ビルドが通ってHello, #{OSのバージョン}が表示されました!

※注意

エミュレーター・シュミレーターでの動作は確認できましたがiOSの実機での動作確認では注意が必要です。 iOSの場合はDeviceの選択がAndroidと同様に設定ができないです。 ConfigurationEdit Configurationを選択します。 iOSのconfigを選択してExecution Targetを確認したい実機を選択します。

f:id:yochidrop:20201207095227j:plain これで動くかと思いきやiOSだと開発者の証明書がないと実機に入れることができません。 証明書を設定せず動かそうとするとエラーが起きます。

 error: Signing for "KmmiOSApp" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'KmmiOSApp' from project 'KmmiOSApp')

証明書の設定はXcodeでiOSのプロジェクトを開いて設定します。(サンプルではKmmiOSApp.xcodeproj) 証明書の設定には Apple Developerアカウントが必要なのでない人は作っておきましょう。

developer.apple.com

f:id:yochidrop:20201207095224p:plain
[iOS]証明書を設定する

これで再度Android Studioでビルド&Runすると実機のiOSで動作が確認できます。 もし以下のようなエラーが起きた時はClean Projectをしてからビルドしてみましょう。

 error: Building for iOS, but the linked and embedded framework 'Shared.framework' was built for iOS Simulator. (in target 'KmmiOSApp' from project 'KmmiOSApp')

通信処理を共通化してみる

両OSとも動作確認ができたので次に共通の処理を書いていきます。 共通化できる部分は - 通信処理 - ログ送信 - etc.. が挙げられると思います。 その中で通信部分のところを共通化してみましょう。

今回は例としてGithubのREST APIを使って 自分のレポジトリを取得するAPIクライアントを書いてみます。

まず、通信やJSONパーサーのライブラリを導入します。

  • Shared/build.gradle.kts
val commonMain by getting {
  dependencies {
    implementation("io.ktor:ktor-client-core:1.4.1")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1")
    implementation("io.ktor:ktor-client-json:1.4.1")
    implementation("io.ktor:ktor-client-serialization:1.4.1")

    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1-native-mt") {
      version {
        strictly("1.4.1-native-mt")
      }
    }
  }
}

...

val androidMain by getting {
  dependencies {
    implementation("io.ktor:ktor-client-android:1.4.1")
    implementation("com.google.android.material:material:1.2.1")
  }
}

...

val iosMain by getting {
  dependencies {
    implementation("io.ktor:ktor-client-ios:1.4.1")
  }
}

導入が完了したらコードを書いていきます。 最初に共通化したい処理を宣言します。

  • Shared/../commonMain/../GithubAPIClient.kt
expect class GithubAPIClient() {
  val client: HttpClient
  val dispatcher: CoroutineDispatcher
}

あくまでこれは抽象的に宣言しているので実際に各プラットフォームに適したコードを定義します。

  • Shared/../androidMain/../GithubAPIClient.kt
actual class GithubAPIClient actual constructor() {
  actual val client: HttpClient = HttpClient(Android) {
    install(JsonFeature) {
      serializer = KotlinxSerializer(json = kotlinx.serialization.json.Json {
          isLenient = false
          ignoreUnknownKeys = true
          allowSpecialFloatingPointValues = true
          useArrayPolymorphism = false
      })
    }
  }
  actual val dispatcher: CoroutineDispatcher = Dispatchers.Default
}
  • Shared/../iosMain/../GithubAPIClient.kt
actual class GithubAPIClient actual constructor() {
  actual val client: HttpClient = HttpClient(Ios) {
    install(JsonFeature) {
      serializer = KotlinxSerializer(json = kotlinx.serialization.json.Json {
          isLenient = false
          ignoreUnknownKeys = true
          allowSpecialFloatingPointValues = true
          useArrayPolymorphism = false
      })
    }
  }

  actual val dispatcher: CoroutineDispatcher = Dispacher(dispatch_get_main_queue())
}

iOSの場合はそのままではコルーチンが使えないので以下のようにdispatch_queueを使って対応します。

class Dispacher(private val dispatchQueue: dispatch_queue_t) : CoroutineDispatcher() {
    override fun dispatch(context: CoroutineContext, block: Runnable) {
        dispatch_async(dispatchQueue) {
            block.run()
        }
    }
}

あとは実際に通信する処理を書いていきます。 レスポンスはレポジトリ名とそのリンクだけを定義してます。

  • Shared/../commonMain/../GithubAPI.kt
@Serializable
data class Repository(
    val name: String,
    @SerialName("html_url") val url: String,
)

class GithubAPI {
    val apiClient: GithubAPIClient = GithubAPIClient()
    companion object {
        val BASEURL = "https://api.github.com/users/${自分のgithubのユーザーID}/repos"
    }
     fun fetchRepos(callback: (List<Repository>) -> Unit) {
        GlobalScope.apply {
            launch(apiClient.dispatcher) {
                val result = apiClient.client.get<List<Repository>>(BASEURL)
                callback(result)
            }
        }
    }
}

これで共通部分の実装が完了しました。さくっとできましたね!

各OS毎でビルドをするとアプリから利用できるようになります。

  • iOS
import UIKit
import Shared

class ViewController: UIViewController {
    @IBOutlet weak var textView: UITextView!

    override func viewDidLoad() {
        super.viewDidLoad()

        GithubAPI().fetchRepos { (repos) in
            DispatchQueue.main.async {
                self.textView.text = repos.map { $0.name }.joined(separator: "\n")
            }
        }
    }

}
  • Android
import com.yochidros.kmmsample.Shared.GithubAPI

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        GithubAPI().fetchRepos {
            runOnUiThread {
                val tv: TextView = findViewById(R.id.text_view)
                tv.text = it.map { it.name }.joinToString("\n")
            }
        }
    }
}

動かしてみると実際に画面に自分のレポジトリの名前がでていますね!

f:id:yochidrop:20201207095211p:plainf:id:yochidrop:20201207095218p:plain
実際に自分のgithubのレポジトリを取得した時の画像

最後に

いかがでしたでしょうか? KMMを使って共通のロジックとなりうる部分を一つのコードで処理することができたと思います。 iOSの場合はCocoaPodsにも対応しているので 共通ロジックとアプリケーションでレポジトリを分けて開発することもできると思います。

まだ、Alpha版なのでいきなり導入することはできないかと思いますが、少しでも共通できるものは共通化しておけば 相互の仕様の齟齬が少なくなると思います。

明日は @yyamanoi1222Cloud Runで手軽にサーバーレス・SSR(サーバーサイドレンダリング)です!お楽しみに!

また、delyではエンジニアを絶賛募集中です!ご興味があればこちらのリンクからお気軽にエントリーください!

join-us.dely.jp

さらに定期的にTechTalkというイベントも開催しているので、delyについて詳しく知りたい方は是非参加してみてください!

bethesun.connpass.com