8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ZOZOAdvent Calendar 2024

Day 2

iOS & Android アプリを作る!Compose Multiplatform での expect actual の使い所

Last updated at Posted at 2024-12-02

はじめに

どうもねもと申します。僕は普段 iOS & Android アプリの個人開発をしているのですが、その技術スタックとして Compose Multiplatform (以下 CMP と記載) を利用しています。2024年12月現在においてはかなりの希少種だと思います。

さて、CMP や KMP 開発における特徴といえば expect actual パターンがあります。OS 毎におなじ使い勝手のメソッドやクラスを実装し分けることができるという優れものです。そんな優れものの expect actual パターンではあるのですが、 CMP や KMP を触りたてだといつ使えば良いのか分からないなどの悩みがあると思います。特にまだ新しい CMP においてはなおさらいつ使えば良いのだろうという疑問があると思います。

本記事ではそのような疑問を解消するべく、実際の CMP & KMP 開発における expect actual パターンを使い所を紹介していこうと思います。ところどころ iOS や Android 開発に出てくる用語が説明なしに出てきたりするのですが、長くなってしまうので説明を割愛しています。ご了承ください🙏

Google Map を表示

現状 Maps SDK は CMP 対応されていないので、共通コードで実装することができません。そのため Android と iOS それぞれの Maps SDK を使用して別個に実装する必要があります。そこで expect actual パターンが生きてきます。

こちらは実際に自分が個人開発をしている旅行アプリの地図画面です。例えばこの画像のようにマップ上の必要なところにカメラを移動させて、ピンを立てる要件だとします。

まず、共通部分のコードで GoogleMaps Composable を定義します。

@Composable
expect fun GoogleMaps(
    state: DetailTemplateState.VisitedPlacesState,
    modifier: Modifier = Modifier,
)

次に、iOS 部分のコードです。このように KMP 開発では iOS 側の Kotlin コードにおいて実際の iOS のコードを呼び出すことができます。これを利用して、 iOS 側の Kotlin コードで Maps SDK for iOS を呼び出します。

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.UIKitInteropInteractionMode
import androidx.compose.ui.viewinterop.UIKitInteropProperties
import androidx.compose.ui.viewinterop.UIKitView
import cocoapods.GoogleMaps.GMSCameraPosition
import cocoapods.GoogleMaps.GMSCameraUpdate
import cocoapods.GoogleMaps.GMSMapView
import cocoapods.GoogleMaps.GMSMarker
import kotlinx.cinterop.ExperimentalForeignApi

@OptIn(ExperimentalForeignApi::class, ExperimentalComposeUiApi::class)
@Composable
actual fun GoogleMaps(
    state: DetailTemplateState.VisitedPlacesState,
    modifier: Modifier,
) {
    val googleMapView = remember { GMSMapView() }

    LaunchedEffect(state.cameraPosition) {
        val cameraPosition = GMSCameraPosition.cameraWithLatitude(
            latitude = state.cameraPosition.latitude,
            longitude = state.cameraPosition.longitude,
            zoom = state.zoomLevel,
        )
        googleMapView.moveCamera(GMSCameraUpdate.setCamera(cameraPosition))
    }

    LaunchedEffect(state.places) {
        state.places.forEach { place ->
            val marker = GMSMarker()
            marker.position = platform.CoreLocation.CLLocationCoordinate2DMake(
                place.latitude,
                place.longitude
            )
            marker.title = place.name
            marker.map = googleMapView
        }
    }

    UIKitView(
        factory = { googleMapView },
        modifier = modifier,
        properties = UIKitInteropProperties(
            interactionMode = UIKitInteropInteractionMode.NonCooperative
        )
    )
}

1つ目の LaunchedEffect ブロックの内部でカメラを動かし、2つ目の LaunchedEffect ブロックの内部でマップにピンを立てています。

続いて Android 側のコードも載せておきます。こちらでは Jetpack Compose 向けの Maps SDK を利用しています。

@Composable
actual fun GoogleMaps(
    state: DetailTemplateState.VisitedPlacesState,
    modifier: Modifier,
) {
    val newCameraPosition = LatLng(
        state.cameraPosition.latitude,
        state.cameraPosition.longitude,
    )

    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(
            newCameraPosition,
            state.zoomLevel,
        )
    }

    GoogleMap(
        modifier = modifier,
        cameraPositionState = cameraPositionState,
        uiSettings = MapUiSettings(
            zoomControlsEnabled = false,
        ),
        onMapLoaded = {
            if (cameraPositionState.position.target != newCameraPosition) {
                cameraPositionState.move(
                    CameraUpdateFactory.newLatLngZoom(
                        newCameraPosition,
                        state.zoomLevel,
                    )
                )
            }
        },
        content =  {
            state.places.forEach { place ->
                Marker(
                    state = MarkerState(position = LatLng(place.latitude, place.longitude)),
                    title = place.name,
                )
            }
        }
    )
}

Firebase Storage の利用

Firebase Storage については非公式ではあるものの、CMP 向けの Firebase SDK が公開されています。そのため共通コードのみで処理を実装することもできます。しかし本アプリの都合で iOS 側では写真ファイルをドキュメントディレクトリに保持していて、Firebase Storage にアップロードする前に、そのパスを取得する必要があります。この処理が iOS 特有のものです。そのためここでも CMP 用の Firebase SDK を使用しつつも expect actual パターンを利用して Firebase Storage への画像のアップロードおよび削除処理を実装しました。

共通部分のコード。expectでメソッドを定義していきます。

expect class FirebaseStorage {
    suspend fun uploadImage(localImage: String, userId: String): String
    suspend fun deleteImage(url: String)
}

iOS 側のコード。uploadImageメソッド3行目で画像が保存されているドキュメントディレクトリのパスを取得しています。

import cocoapods.FirebaseStorage.FIRStorage
import cocoapods.FirebaseStorage.FIRStorageMetadata
import com.nemo.decake.data.util.getMimeContentType
import com.nemo.decake.ui.util.deleteDocumentFile
import com.nemo.decake.ui.util.resolveDocumentFileUrl
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import platform.Foundation.NSURL
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

@OptIn(ExperimentalForeignApi::class)
actual class FirebaseStorage {
    actual suspend fun uploadImage(localImage: String, userId: String): String =
        suspendCancellableCoroutine { continuation ->
                val resolvedLocalImage =
                    resolveDocumentFileUrl(localImage) ?: throw IllegalStateException()
                val imageName = resolvedLocalImage.split("/").lastOrNull().orEmpty()
                val storageRef = FIRStorage.storage().reference().child("hogehoge")
                val fileUrl =
                    NSURL.fileURLWithPath(resolvedLocalImage.removePrefix("file://"))
                val contentType =
                    fileUrl.getMimeContentType() ?: throw IllegalStateException()
                val firStorageMetadata = FIRStorageMetadata(
                    mapOf(
                        "name" to imageName,
                        "contentType" to contentType
                    )
                )
                storageRef.putFile(fileUrl, firStorageMetadata) { _, putFileError ->
                    if (putFileError == null) {
                        storageRef.downloadURLWithCompletion { url, error ->
                            if (error == null) {
                                deleteDocumentFile(resolvedLocalImage)
                                continuation.resume(url?.absoluteString.orEmpty())
                            } else {
                                continuation.resumeWithException(IllegalStateException(error.toString()))
                            }
                        }
                    } else {
                        continuation.resumeWithException(IllegalStateException(putFileError.toString()))
                    }
                }
            }

    actual suspend fun deleteImage(url: String) {
        val storageRef = FIRStorage.storage().referenceForURL(url)
        storageRef.deleteWithCompletion { error ->
            if (error != null) {
                throw IllegalStateException(error.toString())
            }
        }
    }
}

Android 側のコードも載せておきます。こちらは特に特殊な処理をすることなく、シンプルに

actual class FirebaseStorage(
    private val context: Context
) {
    actual suspend fun uploadImage(localImage: String, userId: String): String {
        val fileUri = Uri.parse(localImage)
        val contentResolver = context.contentResolver
        val imageName =
            contentResolver.getFileName(uri = fileUri) ?: throw IllegalStateException()
        val imageContentType = contentResolver.getType(fileUri)
        val storageRef = Firebase.storage.android.reference.child("hogehoge")
        val imageUrl = storageRef.putFile(
                fileUri,
                storageMetadata {
                    contentType = imageContentType
                },
            ).await()
            storageRef.downloadUrl.await().toString()
        return imageUrl
    }

    actual suspend fun deleteImage(url: String) {
        val storageRef = Firebase.storage.android.getReferenceFromUrl(url)
        storageRef.delete()
    }

    private fun ContentResolver.getFileName(uri: Uri): String {
        var fileName: String? = null

        // contentResolver からファイル名を取得するためのクエリを実行
        val cursor = this.query(
            uri,
            arrayOf(OpenableColumns.DISPLAY_NAME), // ファイル名の列を指定
            null,
            null,
            null
        )

        cursor?.use {
            if (it.moveToFirst()) {
                // DISPLAY_NAME 列からファイル名を取得
                fileName = it.getString(it.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
            }
        }

        val contentType = fileName?.split(".")?.lastOrNull()
        return randomUUID() + "." + contentType
    }
}

写真ピッカーの使用

端末などに保存されている写真を扱うために OS の写真ピッカーを使用したい時があると思います。これについては CMP 対応している写真ピッカーライブラリ Filekit を使用しています。こちらについても本アプリの仕様として iOS アプリ側では写真ピッカーから取得した画像をドキュメントディレクトリに移動させなければなりません。そこで OS ごとに処理が異なってしまうので expect actual パターンを使用しています。

共通部分のコード。

@Composable
expect fun rememberMediaPickerLauncher(
    type: PickerType,
    mode: PickerMode<List<PlatformFile>>,
    title: String?,
    onResult: (List<String>) -> Unit
): PickerResultLauncher

iOS 側のコード。moveImageToDocumentDirという処理が先ほど説明したドキュメントディレクトリへの画像の移動処理です。それが成功した後onResultコールバックで結果を返しています。

import androidx.compose.runtime.Composable
import io.github.vinceglb.filekit.compose.PickerResultLauncher
import io.github.vinceglb.filekit.compose.rememberFilePickerLauncher
import io.github.vinceglb.filekit.core.PickerMode
import io.github.vinceglb.filekit.core.PickerType
import io.github.vinceglb.filekit.core.PlatformFile

@Composable
actual fun rememberMediaPickerLauncher(
    type: PickerType,
    mode: PickerMode<List<PlatformFile>>,
    title: String?,
    onResult: (List<String>) -> Unit
): PickerResultLauncher {
    return rememberFilePickerLauncher(
        type = type,
        mode = mode,
        onResult = {
            val uris = it?.map { platformFile ->
                val nsUrl = platformFile.nsUrl.toString()
                moveImageToDocumentDir(imageUri = nsUrl)
            }.orEmpty().filterNotNull()

            onResult(uris)
        }
    )
}

Android 側のコード。Androidでは写真ピッカーから取得した画像への永続的なアクセス権を取得する必要があります。そのためonResultコールバックで結果を返す前にFLAG_GRANT_READ_URI_PERMISSIONのパーミッションを取得しています。これによりアプリを再起動した後などにも続けて同じ画像にアクセスし続けることが可能になります。

@Composable
actual fun rememberMediaPickerLauncher(
    type: PickerType,
    mode: PickerMode<List<PlatformFile>>,
    title: String?,
    onResult: (List<String>) -> Unit
): PickerResultLauncher {
    val context = LocalContext.current
    val contentResolver: ContentResolver = context.contentResolver

    return rememberFilePickerLauncher(
        type = type,
        mode = mode,
        title = title,
        onResult = {
            it?.forEach {
                contentResolver.takePersistableUriPermission(it.uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
            }
            onResult(it?.map { it.uri.toString() }.orEmpty())
        }
    )
}

OS 特有のコードを DI

こちらは CMP というよりも KMP よりの話になります。現在の KMP 開発では DI コンテナとして Koin がよく使われていると思います。KMP 開発しているとあるコンポーネントについて、それぞれの OS で別々の実装を用意したいことがよくあります。そのような時にも expect actual パターンが効いてきます。以下のように共通部分で定義したplatformDataModuleという Koin Module で OS 毎に別々の処理を DI するようにしています。

共通部分のコード

expect val platformDataModule: Module

iOS 側のコード

actual val platformDataModule: Module = module {
    single<HttpClient> {
        createHttpClient(Darwin.create())
    }

    single<DecakeDatabase> {
        DatabaseGetter().getDecakeDatabase()
    }

    single<FirebaseStorage> {
        FirebaseStorage()
    }
}

Android 側のコード。Android 側では↓のように Database のインスタンス取得等に Context が必要だったりします。OS 毎に別コードで実装ができると、このような時にも自然と Android 側でだけ Context を扱うコードを書けたりするのでとてもありがたいです。

actual val platformDataModule: Module = module {
    single<HttpClient> {
        createHttpClient(OkHttp.create())
    }

    single<DecakeDatabase> {
        DatabaseGetter(androidContext()).getDecakeDatabase()
    }

    single<FirebaseStorage> {
        FirebaseStorage(androidContext())
    }
}

あとは以上の Koin Module を渡しつつ iOS, Android それぞれのアプリケーションのエントリーポイントの部分で startKoinしてやれば、 OS 毎に適切な方の実装を DI してやるということが実現可能になります。

まとめ

このように CMP & KMP 開発の色々な部分で便利に expect actual パターンを使用することができます。無理に共通化し過ぎるのではなく、必要なところはそれぞれ別々の実装を織り交ぜていこうという CMP & KMP の考え方が個人的にとても好きです。まだ CMP & KMP 開発やったことない人がいれば、ぜひこの機会に自身で体験してみてほしいなと思います。

また本記事に登場したサンプルコードは全て Decake という私の個人開発アプリで動いている実際にコードです。App Store & Google Play の両方で公開中なのでもし少しでも興味があればぜひインストールして触ってみてください。

結論:Kotlin 最強マジ卍

参考記事

8
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?