iOSとAndroidでより多くのロジックを共有する
このチュートリアルではIntelliJ IDEAを使用していますが、Android Studioでも同じように進めることができます。どちらのIDEも同じコア機能とKotlin Multiplatformサポートを共有しています。
これは、「共有ロジックとネイティブUIを備えたKotlin Multiplatformアプリを作成する」チュートリアルの第4部です。先に進む前に、前のステップを完了していることを確認してください。
外部依存関係を使用して共通ロジックを実装したので、より複雑なロジックを追加し始めることができます。ネットワークリクエストとデータシリアライズは、Kotlin Multiplatformを使用してコードを共有する最も一般的なユースケースです。このオンボーディングジャーニーを完了した後に将来のプロジェクトでそれらを使用できるように、最初のアプリケーションでそれらを実装する方法を学びましょう。
更新されたアプリは、SpaceX APIからインターネット経由でデータを取得し、SpaceXロケットの最後の成功した打ち上げ日を表示します。
プロジェクトの最終状態は、異なるコルーチンソリューションを持つGitHubリポジトリの2つのブランチで確認できます。
依存関係を追加する
プロジェクトに以下のマルチプラットフォームライブラリを追加する必要があります。
kotlinx.coroutines
:同時操作を可能にする非同期コードにコルーチンを使用するため。kotlinx.serialization
:JSONレスポンスを、ネットワーク操作の処理に使用されるエンティティクラスのオブジェクトにデシリアライズするため。- Ktor:インターネット経由でデータを取得するためのHTTPクライアントを作成するためのフレームワーク。
kotlinx.coroutines
kotlinx.coroutines
をプロジェクトに追加するには、共通ソースセットで依存関係を指定します。これを行うには、共有モジュールのbuild.gradle.kts
ファイルに次の行を追加します。
kotlin {
// ...
sourceSets {
commonMain.dependencies {
// ...
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}
}
}
Multiplatform Gradleプラグインは、kotlinx.coroutines
のプラットフォーム固有(iOSおよびAndroid)の部分に自動的に依存関係を追加します。
kotlinx.serialization
kotlinx.serialization
ライブラリを使用するには、対応するGradleプラグインを設定します。 これを行うには、共有モジュールのbuild.gradle.kts
ファイルの冒頭にある既存のplugins {}
ブロックに次の行を追加します。
plugins {
// ...
kotlin("plugin.serialization") version "2.2.0"
}
Ktor
共有モジュールの共通ソースセットにコア依存関係(ktor-client-core
)を追加する必要があります。 さらに、サポートする依存関係も追加する必要があります。
- 特定の形式でコンテンツをシリアライズおよびデシリアライズできる
ContentNegotiation
機能(ktor-client-content-negotiation
)を追加します。 - KtorにJSON形式と
kotlinx.serialization
をシリアライズライブラリとして使用するように指示するために、ktor-serialization-kotlinx-json
依存関係を追加します。KtorはJSONデータを期待し、応答を受信したときにそれをデータクラスにデシリアライズします。 - プラットフォームソースセット(
ktor-client-android
、ktor-client-darwin
)の対応するアーティファクトに依存関係を追加することで、プラットフォームエンジンを提供します。
kotlin {
// ...
val ktorVersion = "3.2.3"
sourceSets {
commonMain.dependencies {
// ...
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-android:$ktorVersion")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:$ktorVersion")
}
}
}
Sync Gradle Changesボタンをクリックして、Gradleファイルを同期します。
APIリクエストを作成する
データを取得するためにSpaceX APIを使用し、v4/launchesエンドポイントからすべての打ち上げのリストを取得するための単一のメソッドを使用します。
データモデルを追加する
shared/src/commonMain/kotlin/.../greetingkmp
ディレクトリに新しいRocketLaunch.kt
ファイルを作成し、SpaceX APIからデータを格納するデータクラスを追加します。
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class RocketLaunch (
@SerialName("flight_number")
val flightNumber: Int,
@SerialName("name")
val missionName: String,
@SerialName("date_utc")
val launchDateUTC: String,
@SerialName("success")
val launchSuccess: Boolean?,
)
RocketLaunch
クラスには@Serializable
アノテーションが付けられているため、kotlinx.serialization
プラグインは自動的にデフォルトのシリアライザーを生成できます。@SerialName
アノテーションを使用すると、フィールド名を再定義できるため、データクラスでプロパティをより読みやすい名前で宣言できます。
HTTPクライアントを接続する
shared/src/commonMain/kotlin/.../greetingkmp
ディレクトリに新しいRocketComponent
クラスを作成します。HTTP GETリクエストを通じてロケット打ち上げ情報を取得するための
httpClient
プロパティを追加します。kotlinimport io.ktor.client.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json class RocketComponent { private val httpClient = HttpClient { install(ContentNegotiation) { json(Json { prettyPrint = true isLenient = true ignoreUnknownKeys = true }) } } }
- ContentNegotiation KtorプラグインとJSONシリアライザーは、GETリクエストの結果をデシリアライズします。
- ここでのJSONシリアライザーは、
prettyPrint
プロパティによりJSONをより読みやすい形式で出力するように設定されています。isLenient
により不正な形式のJSONを読み取る際に柔軟性が高まり、ignoreUnknownKeys
によりロケット打ち上げモデルで宣言されていないキーを無視します。
RocketComponent
にgetDateOfLastSuccessfulLaunch()
サスペンド関数を追加します。kotlinclass RocketComponent { // ... private suspend fun getDateOfLastSuccessfulLaunch(): String { } }
httpClient.get()
関数を呼び出して、ロケット打ち上げ情報を取得します。kotlinimport io.ktor.client.request.* import io.ktor.client.call.* class RocketComponent { // ... private suspend fun getDateOfLastSuccessfulLaunch(): String { val rockets: List<RocketLaunch> = httpClient.get("https://api.spacexdata.com/v4/launches").body() } }
httpClient.get()
もサスペンド関数です。これは、スレッドをブロックせずにネットワーク経由で非同期にデータを取得する必要があるためです。- サスペンド関数は、コルーチンまたは他のサスペンド関数からのみ呼び出すことができます。これが
getDateOfLastSuccessfulLaunch()
がsuspend
キーワードでマークされた理由です。ネットワークリクエストはHTTPクライアントのスレッドプールで実行されます。
関数を再度更新して、リスト内の最後の成功した打ち上げを見つけます。
kotlinclass RocketComponent { // ... private suspend fun getDateOfLastSuccessfulLaunch(): String { val rockets: List<RocketLaunch> = httpClient.get("https://api.spacexdata.com/v4/launches").body() val lastSuccessLaunch = rockets.last { it.launchSuccess == true } } }
ロケット打ち上げのリストは、古いものから新しいものへと日付順にソートされています。
打ち上げ日をUTCからローカル日時に変換し、出力をフォーマットします。
kotlinimport kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant class RocketComponent { // ... private suspend fun getDateOfLastSuccessfulLaunch(): String { val rockets: List<RocketLaunch> = httpClient.get("https://api.spacexdata.com/v4/launches").body() val lastSuccessLaunch = rockets.last { it.launchSuccess == true } val date = Instant.parse(lastSuccessLaunch.launchDateUTC) .toLocalDateTime(TimeZone.currentSystemDefault()) return "${date.month} ${date.day}, ${date.year}" } }
日付は「MMMM DD, YYYY」形式になります(例:OCTOBER 5, 2022)。
getDateOfLastSuccessfulLaunch()
関数を使用してメッセージを作成する、もう1つのサスペンド関数launchPhrase()
を追加します。kotlinclass RocketComponent { // ... suspend fun launchPhrase(): String = try { "The last successful launch was on ${getDateOfLastSuccessfulLaunch()} 🚀" } catch (e: Exception) { println("Exception during getting the date of the last successful launch $e") "Error occurred" } }
Flowを作成する
サスペンド関数の代わりにFlowを使用できます。これらは、サスペンド関数が返す単一の値ではなく、値のシーケンスを発行します。
shared/src/commonMain/kotlin
ディレクトリにあるGreeting.kt
ファイルを開きます。Greeting
クラスにrocketComponent
プロパティを追加します。このプロパティには、最後の成功した打ち上げ日を含むメッセージが格納されます。kotlinprivate val rocketComponent = RocketComponent()
greet()
関数がFlow
を返すように変更します。kotlinimport kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlin.time.Duration.Companion.seconds class Greeting { // ... fun greet(): Flow<String> = flow { emit(if (Random.nextBoolean()) "Hi!" else "Hello!") delay(1.seconds) emit("Guess what this is! > ${platform.name.reversed()}") delay(1.seconds) emit(daysPhrase()) emit(rocketComponent.launchPhrase()) } }
Flow
は、すべてのステートメントをラップするflow()
ビルダー関数でここに作成されます。Flow
は、各発行間に1秒の遅延を伴って文字列を発行します。最後の要素は、ネットワーク応答が返された後にのみ発行されるため、正確な遅延はネットワークによって異なります。
インターネットアクセス権限を追加する
インターネットにアクセスするには、Androidアプリケーションに適切な権限が必要です。すべてのネットワークリクエストは共有モジュールから行われるため、そのマニフェストにインターネットアクセス権限を追加するのが理にかなっています。
composeApp/src/androidMain/AndroidManifest.xml
ファイルをアクセス権限で更新します。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
...
</manifest>
greet()
関数の戻り値の型をFlow
に変更することで、共有モジュールのAPIはすでに更新されています。 次に、greet()
関数呼び出しの結果を適切に処理できるように、プロジェクトのネイティブ部分を更新する必要があります。
ネイティブAndroid UIを更新する
共有モジュールとAndroidアプリケーションの両方がKotlinで記述されているため、Androidから共有コードを使用するのは簡単です。
ビューモデルを導入する
アプリケーションがより複雑になるにつれて、UIを実装するApp()
関数を呼び出すAndroidアクティビティであるMainActivity
にビューモデルを導入する時が来ました。 ビューモデルはアクティビティからのデータを管理し、アクティビティがライフサイクル変更を受けても消滅しません。
composeApp/build.gradle.kts
ファイルに以下の依存関係を追加します。kotlinandroidMain.dependencies { // ... implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") }
composeApp/src/androidMain/kotlin/com/jetbrains/greeting/greetingkmp
ディレクトリに、新しいMainViewModel
Kotlinクラスを作成します。kotlinimport androidx.lifecycle.ViewModel class MainViewModel : ViewModel() { // ... }
このクラスはAndroidの
ViewModel
クラスを拡張しており、ライフサイクルと設定変更に関して正しい動作を保証します。StateFlow型の
greetingList
値と、そのバッキングプロパティを作成します。kotlinimport kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class MainViewModel : ViewModel() { private val _greetingList = MutableStateFlow<List<String>>(listOf()) val greetingList: StateFlow<List<String>> get() = _greetingList }
- ここでの
StateFlow
はFlow
インターフェースを拡張していますが、単一の値または状態を持ちます。 - プライベートなバッキングプロパティ
_greetingList
は、このクラスのクライアントのみが読み取り専用のgreetingList
プロパティにアクセスできることを保証します。
- ここでの
View Modelの
init
関数で、Greeting().greet()
フローからすべての文字列を収集します。kotlinimport androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch class MainViewModel : ViewModel() { private val _greetingList = MutableStateFlow<List<String>>(listOf()) val greetingList: StateFlow<List<String>> get() = _greetingList init { viewModelScope.launch { Greeting().greet().collect { phrase -> //... } } } }
collect()
関数はサスペンドされるため、ビューモデルのスコープ内でlaunch
コルーチンが使用されます。 これは、launch
コルーチンがビューモデルのライフサイクルの正しいフェーズ中のみ実行されることを意味します。collect
の後続ラムダ内で、収集されたphrase
をlist
内のフレーズのリストに追加するように_greetingList
の値を更新します。kotlinimport kotlinx.coroutines.flow.update class MainViewModel : ViewModel() { //... init { viewModelScope.launch { Greeting().greet().collect { phrase -> _greetingList.update { list -> list + phrase } } } } }
update()
関数は値を自動的に更新します。
ビューモデルのFlowを使用する
composeApp/src/androidMain/kotlin
にあるApp.kt
ファイルを開き、以前の実装を置き換えるように更新します。kotlinimport androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.lifecycle.viewmodel.compose.viewModel @Composable fun App(mainViewModel: MainViewModel = viewModel()) { MaterialTheme { val greetings by mainViewModel.greetingList.collectAsStateWithLifecycle() Column( modifier = Modifier .safeContentPadding() .fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { greetings.forEach { greeting -> Text(greeting) HorizontalDivider() } } } }
greetingList
に対するcollectAsStateWithLifecycle()
関数呼び出しは、ViewModelのFlowから値を収集し、ライフサイクルを意識した方法でそれをコンポーザブルステートとして表現します。- 新しいFlowが作成されると、コンポーズの状態が変更され、区切り線で区切られたグリーティングフレーズが垂直に配置されたスクロール可能な
Column
が表示されます。
結果を確認するには、composeApp構成を再実行します。
ネイティブiOS UIを更新する
プロジェクトのiOS部分では、ビジネスロジックをすべて含む共有モジュールにUIを接続するために、Model–View–ViewModel (MVVM)パターンを再び利用します。
モジュールはContentView.swift
ファイルにimport Shared
宣言で既にインポートされています。
ViewModelを導入する
iosApp/ContentView.swift
で、ContentView
のViewModel
クラスを作成し、それのためのデータを準備および管理します。 並行処理をサポートするために、startObserving()
関数をtask()
呼び出し内で呼び出します。
import SwiftUI
import Shared
struct ContentView: View {
@ObservedObject private(set) var viewModel: ViewModel
var body: some View {
ListView(phrases: viewModel.greetings)
.task { await self.viewModel.startObserving() }
}
}
extension ContentView {
@MainActor
class ViewModel: ObservableObject {
@Published var greetings: Array<String> = []
func startObserving() {
// ...
}
}
}
struct ListView: View {
let phrases: Array<String>
var body: some View {
List(phrases, id: \.self) {
Text($0)
}
}
}
ViewModel
はContentView
の拡張として宣言されており、密接に関連しています。ViewModel
には、String
フレーズの配列であるgreetings
プロパティがあります。 SwiftUIはViewModel(ContentView.ViewModel
)をビュー(ContentView
)に接続します。ContentView.ViewModel
はObservableObject
として宣言されています。@Published
ラッパーはgreetings
プロパティに使用されます。@ObservedObject
プロパティラッパーはViewModelを購読するために使用されます。
このViewModelは、このプロパティが変更されるたびにシグナルを発行します。 次に、Flowを消費するためにstartObserving()
関数を実装する必要があります。
iOSからFlowを消費するためのライブラリを選択する
このチュートリアルでは、iOSでFlowを操作するのに役立つSKIEまたはKMP-NativeCoroutinesライブラリを使用できます。 どちらもオープンソースソリューションであり、Kotlin/Nativeコンパイラがまだデフォルトで提供していないFlowによるキャンセルとジェネリクスをサポートしています。
- SKIEライブラリは、Kotlinコンパイラによって生成されたObjective-C APIを拡張します。SKIEはFlowをSwiftの
AsyncSequence
と同等のものに変換します。SKIEは、スレッド制限なしで、自動的な双方向キャンセルを伴うSwiftのasync
/await
を直接サポートします(CombineとRxSwiftにはアダプターが必要です)。SKIEは、さまざまなKotlin型をSwiftの同等型にブリッジすることを含め、KotlinからSwiftフレンドリーなAPIを生成するための他の機能も提供します。また、iOSプロジェクトに追加の依存関係を追加する必要もありません。 - KMP-NativeCoroutinesライブラリは、必要なラッパーを生成することで、iOSからサスペンド関数とFlowを消費するのに役立ちます。 KMP-NativeCoroutinesは、Swiftの
async
/await
機能、Combine、RxSwiftをサポートしています。 KMP-NativeCoroutinesを使用するには、iOSプロジェクトにSPMまたはCocoaPodの依存関係を追加する必要があります。
オプション1. KMP-NativeCoroutinesを構成する
ライブラリの最新バージョンを使用することをお勧めします。 プラグインの新しいバージョンが利用可能かどうかは、KMP-NativeCoroutinesリポジトリで確認してください。
プロジェクトのルート
build.gradle.kts
ファイル(shared/build.gradle.kts
ファイルではない)のplugins {}
ブロックにKSP (Kotlin Symbol Processor)とKMP-NativeCoroutinesプラグインを追加します。kotlinplugins { // ... id("com.google.devtools.ksp").version("2.2.0-2.0.2").apply(false) id("com.rickclephas.kmp.nativecoroutines").version("1.0.0-ALPHA-45").apply(false) }
shared/build.gradle.kts
ファイルにKMP-NativeCoroutinesプラグインを追加します。kotlinplugins { // ... id("com.google.devtools.ksp") id("com.rickclephas.kmp.nativecoroutines") }
同じく
shared/build.gradle.kts
ファイルで、実験的な@ObjCName
アノテーションをオプトインします。kotlinkotlin { // ... sourceSets{ all { languageSettings { optIn("kotlin.experimental.ExperimentalObjCName") optIn("kotlin.time.ExperimentalTime") } } // ... } }
Sync Gradle ChangesボタンをクリックしてGradleファイルを同期します。
KMP-NativeCoroutinesでFlowをマークする
shared/src/commonMain/kotlin
ディレクトリのGreeting.kt
ファイルを開きます。greet()
関数に@NativeCoroutines
アノテーションを追加します。これにより、プラグインがiOSでの正しいFlow処理をサポートするための適切なコードを生成することを保証します。kotlinimport com.rickclephas.kmp.nativecoroutines.NativeCoroutines class Greeting { // ... @NativeCoroutines fun greet(): Flow<String> = flow { // ... } }
XcodeでSPMを使用してライブラリをインポートする
File | Open Project in Xcode に移動します。
Xcodeで、左側のメニューにある
iosApp
プロジェクトを右クリックし、Add Package Dependenciesを選択します。検索バーにパッケージ名を入力します。
nonehttps://github.com/rickclephas/KMP-NativeCoroutines.git
- Dependency RuleドロップダウンでExact Version項目を選択し、隣接するフィールドに
1.0.0-ALPHA-45
バージョンを入力します。 - Add Packageボタンをクリックします。XcodeはGitHubからパッケージをフェッチし、別のウィンドウを開いてパッケージプロダクトを選択します。
- 表示されているように、「KMPNativeCoroutinesAsync」と「KMPNativeCoroutinesCore」をアプリに追加し、Add Packageをクリックします。
これにより、async/await
メカニズムを操作するために必要なKMP-NativeCoroutinesパッケージの一部がインストールされます。
KMP-NativeCoroutinesライブラリを使用してFlowを消費する
iosApp/ContentView.swift
で、KMP-NativeCoroutinesのasyncSequence()
関数を使用してGreeting().greet()
関数にFlowを消費するようにstartObserving()
関数を更新します。Swiftfunc startObserving() async { do { let sequence = asyncSequence(for: Greeting().greet()) for try await phrase in sequence { self.greetings.append(phrase) } } catch { print("Failed with error: \(error)") } }
ここでのループと
await
メカニズムは、Flowを反復処理し、Flowが値を放出するたびにgreetings
プロパティを更新するために使用されます。ViewModel
が@MainActor
アノテーションでマークされていることを確認します。このアノテーションは、ViewModel
内のすべての非同期操作がKotlin/Nativeの要件に準拠するためにメインスレッドで実行されることを保証します。Swift// ... import KMPNativeCoroutinesAsync import KMPNativeCoroutinesCore // ... extension ContentView { @MainActor class ViewModel: ObservableObject { @Published var greetings: Array<String> = [] func startObserving() async { do { let sequence = asyncSequence(for: Greeting().greet()) for try await phrase in sequence { self.greetings.append(phrase) } } catch { print("Failed with error: \(error)") } } } }
オプション2. SKIEを構成する
ライブラリを設定するには、shared/build.gradle.kts
にSKIEプラグインを指定し、Sync Gradle Changesボタンをクリックします。
plugins {
id("co.touchlab.skie") version "0.10.4"
}
SKIEを使用してFlowを消費する
Greeting().greet()
Flowを反復処理し、Flowが値を放出するたびにgreetings
プロパティを更新するために、ループとawait
メカニズムを使用します。
ViewModel
が@MainActor
アノテーションでマークされていることを確認します。 このアノテーションは、ViewModel
内のすべての非同期操作がKotlin/Nativeの要件に準拠するためにメインスレッドで実行されることを保証します。
// ...
extension ContentView {
@MainActor
class ViewModel: ObservableObject {
@Published var greetings: [String] = []
func startObserving() async {
for await phrase in Greeting().greet() {
self.greetings.append(phrase)
}
}
}
}
ViewModelを消費し、iOSアプリを実行する
iosApp/iOSApp.swift
で、アプリのエントリポイントを更新します。
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView(viewModel: ContentView.ViewModel())
}
}
}
IntelliJ IDEAからiosApp構成を実行して、アプリのロジックが同期されていることを確認します。
プロジェクトの最終状態は、異なるコルーチンソリューションを持つGitHubリポジトリの2つのブランチで確認できます。
次のステップ
チュートリアルの最終部では、プロジェクトを締めくくり、次に取るべきステップを確認します。
参照
- サスペンド関数の構成の様々なアプローチを探る。
- Objective-Cフレームワークとライブラリとの相互運用性について詳しく学ぶ。
- ネットワークとデータストレージに関するこのチュートリアルを完了する。
ヘルプを得る
- Kotlin Slack。招待を受けるには、#multiplatformチャンネルに参加してください。
- Kotlin課題トラッカー。新しい課題を報告する。