KC Blog

Compose Multiplatform 實戰:CMP中跨平台Android、iOS程式碼的進入點

6 min read
CrossPlatform#CMP#Kotlin

前言

Compose Multiplatform (簡稱CMP)

昨天我們大致瞭解了一下CMP的專案結構

我們從昨天的CMP的專案結構理解與編譯配置

中的專案結構可以知道

CMP專案可以在

commonMain下寫共用邏輯

androidMain 下寫Android平台的邏輯

iosMain 下寫iOS平台的邏輯

desktopMain 下寫Desktop平台的邏輯

YourProjectName
├── composeApp
│   ├── ...
│   ├── src
│   │   ├── commonMain
│   │   ├── commonTest
│   │   ├── iosMain
│   │   └── desktopMain
│   └── ...
├── ...
├── ...
└── ...

接下來我們可以開始一步一步來理解

CMP的程式碼進入點

因為涉及跨平台實作

所以總覺得需要好好理解下

程式碼是怎麼運作以及怎麼進到各自平台的

所以今天將詳細解說下CMP在跨平台的程式進入點

理解CMP程式的進入點

我們先簡單了解一下 Compose MultiplatformKotlin Multiplatform

  • 當然在CMP創建好的時候就已經幫你建立好這些進入點

    這邊僅需理解概念即可

  • 在CMP的commonMain下共用的程式碼進入點

    預期 在androidMain、iOSMain..等跨平台程式碼

    都會去呼叫這個共用函示

    來達到共用程式碼的目的

  • 這裡建立了一個共用的App()函式

    其中包含了

    1.自定義的通用UI Theme

    2.使用了koin 注入viewmodel

    3.自定義Compose UI的進入點

    (這個後面章節會解釋如何自定義UI Theme、使用koin、自定義Compose UI...等主題)

// in ../commonMain/App.kt
@Composable
@Preview
fun App() {

    ElegantAccessComposeTheme {
        val viewModel = koinViewModel<MainViewModel>()
        ElegantAccessApp(viewModel)
    }

}
Android App 程式進入點
  • Android實際呼叫commonMain中共用的App()函式
// in ../androidMain/App.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val androidModule = module {
            single<Activity> { this@MainActivity }
            single<Context> { this@MainActivity.applicationContext }
        }

        startKoin {
            modules(appModule() + androidModule)
        }

        setContent {
            // 呼叫剛剛實作共用的App()函式
            App()
        }
    }
}
  • 其中Android的Android Manifest.xml中會宣告這個MainActivity<activity>tag

    以及開啟App的初始頁面<intent-filter>

    <?xml version="1.0" encoding="utf-8"?>
      <manifest xmlns:android="http://schemas.android.com/apk/res/android">
          <application
              ...>
              <activity
                  ...
                  android:name=".MainActivity">
                  <intent-filter>
                      <action android:name="android.intent.action.MAIN" />
                      <category android:name="android.intent.category.LAUNCHER" />
                  </intent-filter>
              </activity>
          </application>
      </manifest>
    
iOS App 程式進入點
  • iosMain實際呼叫commonMain中共用的App()函式
// in ../commonIos/MainViewController.kt

fun MainViewController() = ComposeUIViewController {

    val uiViewController = LocalUIViewController.current
    val iosModule = module {
        single<UIViewControllerWrapper> { UIViewControllerWrapperImpl(uiViewController) }
    }

    KoinApplication(application = {
        modules(appModule() + iosModule)
    }) {
        App()
    }
}
  • 實際在iOS會去呼叫上面MainViewController.kt內的函式MainViewController() Cover
Desktop 程式進入點
  • desktopMain中實際呼叫commonMain中共用的App()函式

    其中透過compose裡面的 application 函式 搭配Window 來完成desktop application

// in ../desktopMain/main.kt
fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "App",
    ) {
        App()
    }
}
  • CMP中的desktop也是透過JVM去編譯

    如果你要把他Build出來

    則在環境中使用下方gradle cmd 即可

./gradlew desktopRun -DmainClass=MainKt --quiet
  • 或者可以透過IDE直接把這個Gradle task加入到Run Configuration內 <img src="/images/compose/046.png" alt="Cover" style="width: 90%"/ class="prose-img">

開發共用邏輯

  • 理解完上方進入點後

    我們可以開始開發共用邏輯

    來達到只用一份Code製作多個平台的應用程式

  • 由下方圖片可看到

    我們大部分的時間會花在./commonMain

    主要的邏輯開發都在此

    除了有些依賴各自平台的內容,例如:檔案系統、檔案選擇器...等等

    才會透過expectautual 來實作

    (後面章節會也會再講怎麼用expect跟autual)

<img src="/images/compose/047.png" alt="Cover" style="width: 80%"/ class="prose-img">

  • 不過到目前為止

    即便是desktop平台iOS平台 有自己的檔案系統

    導致在commonMain共用邏輯中需要自己實作

    但在KMMCMP

    都已經有支援透過kotlin程式碼

    來寫這些跨平台內容的Library了

    你只需要在Gradle中配置即可

例如:透過Kotlin來實作desktop的檔案相關操作:

// ../desktop/PlatformFile.desktop.kt
actual class PlatformFile actual constructor(private val path: String) {
    private val file = java.io.File(path)

    actual fun exists() = file.exists()
    actual fun createFile() = file.createNewFile()
    actual fun writeBytes(bytes: ByteArray) = file.writeBytes(bytes)
    actual fun delete() = file.delete()
    actual fun isDirectory(): Boolean = file.isDirectory
    actual fun copyTo(destination: PlatformFile, overwrite: Boolean) {
        file.copyTo(java.io.File(destination.path), overwrite)
    }
    actual fun mkdirs() {
        file.mkdirs()
    }
}

actual class PlatformZip actual constructor() {
    actual fun unzip(zipFilePath: String, destinationDir: String) {
        java.util.zip.ZipFile(zipFilePath).use { zip ->
            zip.entries().asSequence().forEach { entry ->
                val file = java.io.File(destinationDir, entry.name)
                if (entry.isDirectory) {
                    file.mkdirs()
                } else {
                    file.parentFile.mkdirs()
                    zip.getInputStream(entry).use { input ->
                        file.outputStream().use { output ->
                            input.copyTo(output)
                        }
                    }
                }
            }
        }
    }
}