15
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Compose Multiplatformの仕組みを探る(iOS編)

Last updated at Posted at 2023-07-03

この記事の目的

前回の記事 でとにかくCompose Multiplatformを触れるようになるところまでを書きました。
その次となる本記事では、Compose Multiplatform がどのようにして iOS の UI を Kotlin で表現しているのかを探り、「なんとなく仕組みは分かった」と言えるようになるまでを目的とします。

前提

Compose Multiplatform for iOS はこの記事執筆時点(2023/06/29)でAlpha版です。
仕組みが大きく変更される可能性もありうるので、あくまでこの時点ではこんな仕組みなんだ(だったんだ)、という程度でお読みください。

アプリケーションとライブラリのコードを見る

まずは前回の記事でも利用した、Compose Multiplatformアプリケーションのテンプレートプロジェクトのコードを見てみます。

Androidアプリのルートとなるファイルの中身はこんな感じ。

shared/src/androidMain/kotlin/main.android.kt
import androidx.compose.runtime.Composable

actual fun getPlatformName(): String = "Android"

@Composable fun MainView() = App()

本題からずれてしまうので簡単な紹介に留めますが、App()というのはテンプレートとして用意されている Hello, World! なアプリケーションのルート画面です。

App()の簡単な中身はこちらを展開
shared/src/commonMain/kotlin/App.kt
@OptIn(ExperimentalResourceApi::class)
@Composable
fun App() {
    MaterialTheme {
        var greetingText by remember { mutableStateOf("Hello, World!") }
        var showImage by remember { mutableStateOf(false) }
        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Button(onClick = {
                greetingText = "Hello, ${getPlatformName()}"
                showImage = !showImage
            }) {
                Text(greetingText)
            }
            AnimatedVisibility(showImage) {
                Image(
                    painterResource("compose-multiplatform.xml"),
                    null
                )
            }
        }
    }
}

expect fun getPlatformName(): String

ちなみに、expectactual というキーワードは Multiplatform 専用のインターフェイスとして使われています。少し違いますが、abstractoverride のような関係だと理解しています。

DesktopもAndroidと同様。

shared/src/desktopMain/kotlin/main.desktop.kt
import androidx.compose.runtime.Composable

actual fun getPlatformName(): String = "Desktop"

@Composable fun MainView() = App()

最後に本題のiOSですが、App()が何者かに包まれています。

shared/src/iosMain/kotlin/main.ios.kt
import androidx.compose.ui.window.ComposeUIViewController

actual fun getPlatformName(): String = "iOS"

fun MainViewController() = ComposeUIViewController { App() }

キーワードだけ見るとSwiftなんじゃないかと思うようなコードですね。
ComposeUIViewControllerが何者かというと、compose-multiplatform-core に定義されていました。

実体としてはComposeWindowというクラスが利用されており、skiaLayerをレイヤーに持つSkikoUIView上にcontent()、すなわちテンプレートアプリケーションから渡したApp()が描画されるようです。

compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeWindow.uikit.kt
// コードは大幅に省略しています

fun ComposeUIViewController(content: @Composable () -> Unit): UIViewController =
    ComposeWindow().apply {
        setContent(content)
    }

@OptIn(InternalComposeApi::class)
@ExportObjCClass
internal actual class ComposeWindow : UIViewController {

    private lateinit var layer: ComposeLayer
    private lateinit var content: @Composable () -> Unit
    
    override fun loadView() {
        val skiaLayer = createSkiaLayer()
        val skikoUIView = SkikoUIView(
            skiaLayer = skiaLayer,
            pointInside = { point, _ ->
                !layer.hitInteropView(point, isTouchEvent = true)
            },
        ).load()
        val rootView = UIView() // rootView needs to interop with UIKit
        rootView.backgroundColor = UIColor.whiteColor

        skikoUIView.translatesAutoresizingMaskIntoConstraints = false
        rootView.addSubview(skikoUIView)

        //...

        layer = ComposeLayer(
            layer = skiaLayer,
            platform = uiKitPlatform,
            getTopLeftOffset = ::getTopLeftOffset,
            input = uiKitTextInputService.skikoInput,
        )
        layer.setContent(content = {
            CompositionLocalProvider(
                LocalLayerContainer provides rootView,
                LocalUIViewController provides this,
                LocalKeyboardOverlapHeightState provides keyboardOverlapHeightState,
                LocalSafeAreaState provides safeAreaState,
                LocalLayoutMarginsState provides layoutMarginsState,
                LocalInterfaceOrientationState provides interfaceOrientationState,
            ) {
                content()
            }
        })
    }

    //...

    actual fun setContent(
        content: @Composable () -> Unit
    ) {
        this.content = content
    }
}

ここまで来れば、あとはSkiaSkikoが何者かが分かれば当初の

「なんとなく仕組みは分かった」と言えるようになる

という目的は果たせそうです。

SkiaとSkiko

SkiaはGoogleが開発している、C++製のクロスプラットフォームな2Dレンダリングライブラリです。多くのFlutterアプリでも利用されているライブラリで、SkikoSkiaをKotlinで扱えるようにしてくれるライブラリです。
そのため、(少なくとも現時点では)Compose Multiplatform for iOS は Flutter と同様の描画システムを持っている、と言えます。

Skia がやってくれていることについては、端的に言ってしまうとHTMLのCanvas APIのような形で画面上に座標ベースで画面要素をレンダリングします。
座標ベースでどんな形でも自由に描画することができるため、これを利用して「ボタンの形」や「テキストフィールドの形」を画面上に画面要素として描画します。
Skia showcase
https://skia.org/docs/#showcase

ちなみに、Skia には致命的なパフォーマンス上の欠陥があり、Flutter は Skia の利用をやめて Impeller という別のレンダリングエンジンを利用するようにしていく方針のようです。
Compose Multiplatform for iOS も Impeller に乗り換えるのか、それとも Skia のままで正式リリースまで進むのか、今後の動向が気になりますね。

UIKitView

ここまでのUI共通化の流れと外れるのであまり触れませんでしたが、Alpha 版の Compose Multiplatform for iOS では UIKitView というインターフェースで UIKitSwiftUI との相互運用もサポートしています。

MKMapView などの複雑な画面要素を Compose で実装された画面に組み込んだり、逆にSwiftUI で実装された画面に Compose で実装された画面要素を組み込んだりすることも可能です。

参考にさせていただいたサイト

15
7
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
15
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?