前语

关于标题和文章主题

取标题的时候我还在想,我应该写 Compose 跨渠道呢仍是写 Kotlin 跨渠道。

究竟关于我的整体项目而言,确实是 Compose 跨渠道开发,可是关于我这篇文章要说的东西,那其实也触及不到多少 Compose 相关的内容,更多的应该是 Kotlin Multiplatform 相关的内容。

二者取舍不下,干脆都写上得了,所以就有了这个读起来怪怪的标题。

前情回顾

好久好久以前,我运用 Compose 写了一个安卓端的核算器 APP:运用 Jetpack Compose 完结一个核算器APP。

其间有一个形式叫做程序员形式,能够很便利的做不同进制之间的核算,所以实际上我自己也常常运用这个 APP 来算一些东西。

特别是上次在写有关串口校验的内容时,常常需求核算二进制和十六进制的数据,还会触及到位运算。

而众所周知,macOS 自带的核算器十分的 “简洁”,不如 Windows 上的核算器强大。所以我只能运用手机来核算。

明显,这很不便利啊,所以萌生出了将我写的这个核算器移植到桌面端的想法。

十分幸运的,我的核算器运用的是 Compose 来编写 UI 布局,所以简直能够直接无缝搬迁到桌面端上来。

详细搬迁过程能够参考我之前写的搬迁别的一个 APP 的文章: 跟我一同运用 compose 做一个跨渠道的黑白棋游戏(1)整体完结思路、跟我一同运用 compose 做一个跨渠道的黑白棋游戏(4)移植到compose-jb完结跨渠道。

关于移植这个核算器 APP ,需求重点解决的有两个问题:

  1. 原安卓端程序中我运用了 Jetpack ViewModel 来进行状况办理,可是截止目前,Viewmodel 都还没有移植到 Kotlin Multiplatform 中,而且我没记错的话,官方并没有移植的计划。所以咱们需求将本来的 ViewModel 改为运用支撑跨渠道的状况办理方法。
  2. 原安卓端程序运用了 Room 和 Sqlite 来贮存核算历史记录,而 Room 并不支撑跨渠道,别的,我没记错的话,官方也是没有移植的计划。

关于问题 1 ,在我上面说到的文章中现已给出过解决方案了,所以这儿就不再赘述了,咱们这篇文章的主题是关于如何运用支撑跨渠道的数据库 ORM 结构 SQLDelight。

SQLDelight

简介

首要,SQLDelight 是什么东西呢?先来看官方的介绍:

SQLDelight generates typesafe kotlin APIs from your SQL statements. It verifies your schema, statements, and migrations at compile-time and provides IDE features like autocomplete and refactoring which make writing and maintaining SQL simple.

简单说便是一个能让咱们在 Kotlin 中运用 SQL 更便利的一个库。假设仍是不太理解的话,你能够把它当成支撑跨渠道的 Room。

虽然它俩如同就没有多少相似之处,哈哈哈。

而且,不像 Room,供给了几个常用的查询句子(Insert、Update、Delete 等),能够直接运用,SQLDelight 的一切查询句子都需求自己手写。

值得一提的是,这个结构是 Square 旗下公司的产品,没错,便是那个开发了 OKHttp、Glide 的公司。

榜首步,增加依靠

在开端之前先提一嘴,由于我运用的 Gradle 版别和官网文档中的版别不相同,所以我这儿写的增加依靠和官网的不太相同,各位需求依据自己的实际情况来写。

什么?我为什么不用和文档相同的 Gradle 版别?由于官网文档的代码运用的仍是老版别的 Gradle 语法……而我为了运用 Compose 只能用新版别的 Gradle……

首要,在项目根目录下的 build.gradle.kts 文件的 plugins 中增加插件依靠:

plugins {
    // ……
    id("app.cash.sqldelight") version "2.0.0-alpha05" apply false
}

这儿在末尾增加了 apply false 是由于这个项目是 跨渠道 项目,所以插件并不一定一切模块都会用到,所以加上这个表明仅仅界说需求这个插件以及界说需求的版别,可是并不会实际加载并运用。

明显,咱们需求在公共代码模块运用到这个插件,所以转到通用模块(即 common 模块)下的 build.gradle.kts 文件,并在 plugins 中运用插件:

plugins {
    // ……
    id("app.cash.sqldelight")
}

然后,依旧是在通用模块的 build.gradle.kts 文件中增加 SQLDelight 的中心运行库到通用模块源集(sourceSets -> commonMain)中:

    sourceSets {
        val commonMain by getting {
            dependencies {
                // ……
                implementation("app.cash.sqldelight:runtime:2.0.0-alpha05")
            }
        }
      }

最终,需求为不同的渠道增加对应的驱动依靠,依旧是在通用模块的 build.gradle.kts 文件中的渠道对应(androidMain、desktopMain)源集增加:

    sourceSets {
        val androidMain by getting {
            dependencies {
                // ……
                implementation("app.cash.sqldelight:android-driver:2.0.0-alpha05")
            }
        }
        val desktopMain by getting {
            dependencies {
                // ……
                implementation("app.cash.sqldelight:sqlite-driver:2.0.0-alpha05")
            }
        }
      }

需求留意的是,这儿的桌面端驱动需求选择 sqlite-driver 这个驱动。

自此,依靠就全部增加结束。

第二步,编写需求的 SQL 句子

在开端编写 SQL 句子前,咱们需求先在 build.gradle.kts 中为 SQLDelight 插件增加一个装备,用于指定从 SQL 句子中生成的 Kotlin 接口代码的称号以及包名之类的信息。

在通用模块下的 build.gradle.kts 文件中增加以下内容:

sqldelight {
    databases {
        create("HistoryDatabase") {
            packageName.set("com.equationl.common.database")
        }
    }
}

其间的 HistoryDatabase 为 SQLdelight 生成的 Kotlin 接口名,com.equationl.common.database 为它的包途径。

比方我这个装备,编译后主动生成的代码途径和称号为:

Kotlin & Compose Multiplatform 跨平台(Android端、桌面端)开发实践之使用 SQLDelight 将数据储存至数据库

完结装备后接下来便是编写 SQL 文件,这个文件将运用 .sq 作为文件后缀。

为了让 Android Studio 或者 IDEA 支撑 .sq 的代码高亮和代码提示等,咱们能够安装一下 SQLDelight 插件:

Kotlin & Compose Multiplatform 跨平台(Android端、桌面端)开发实践之使用 SQLDelight 将数据储存至数据库

留意这儿说的插件是 IDE 的插件,不是 Gradle 插件,不要搞混了。

.sq 文件默许放在和源代码根目录同级目录的 sqldelight 目录下,而且包途径和文件名与上面刚装备的保持一致:

Kotlin & Compose Multiplatform 跨平台(Android端、桌面端)开发实践之使用 SQLDelight 将数据储存至数据库

HistoryDatabase.sq 文件中写入以下内容:

import com.equationl.common.dataModel.Operator;
import kotlin.Int;
CREATE TABLE History (
   id INTEGER AS Int PRIMARY KEY AUTOINCREMENT,
   show_text TEXT,
   left_number TEXT,
   right_number TEXT,
   operator TEXT AS Operator,
   result TEXT,
   create_time INTEGER
);
getAllHistory:
SELECT * FROM History;
insertHistory:
INSERT INTO History(show_text, left_number, right_number, operator, result, create_time)
VALUES ?;
deleteHistory:
DELETE FROM History WHERE History.id == ?;
deleteAllHistory:
DELETE FROM History;

代码不长,咱们拆开来一段一段看。

榜首部分,假设略懂 SQL 一眼就能看出来,就算不明白 SQL 的也很好理解,便是创建一个名为 History 的表,而且界说了表的字段:

CREATE TABLE History (
   id INTEGER AS Int PRIMARY KEY AUTOINCREMENT,
   show_text TEXT,
   left_number TEXT,
   right_number TEXT,
   operator TEXT AS Operator,
   result TEXT,
   create_time INTEGER
);

其实它便是 SQL 句子,仅仅增加了一些 SQLDelight 特有的语法,一切 SQL 中有的语法它也能用,例如咱们这儿没有运用到的 NOT NULL 用于界说字段不能为空之类的。

这儿需求留意一下 .sq 中支撑的数据类型和 Kotlin 中数据类型的对应关系:

类型 在数据库中的类型 Kotlin中的类型
INTEGER INTEGER Long
REAL REAL Double
TEXT TEXT String
BLOB BLOB ByteArray

在咱们上面的代码中,有两个字段的界说是这样的:

id INTEGER AS Int PRIMARY KEY AUTOINCREMENT,
operator TEXT AS Operator,

咱们把 id 界说为了 INTEGER 类型,它会被转成 kotlin 中的 Long 类型,可是实际上,在咱们这个 APP 中, id 应该是 Int 类型的,所以咱们运用 AS 关键字将其转为了在 kotlin 中的 Int。

同理,operator 本来是 String 类型,这儿咱们转成了一个咱们自界说的枚举类 Operator

对了,别忘了导入这两个类型,不然会编译失利:

import com.equationl.common.dataModel.Operator;
import kotlin.Int;

或许你会疑问,它怎样知道应该怎样转呢?

答案便是它不知道,所以需求咱们自己编写转化函数,这有点类似于 Room 中的 @TypeConverter ,可是这儿咱们暂时先不说怎样写这个转化函数,不过后边咱们会说。

第二部分

getAllHistory:
SELECT * FROM History;

先看第二句,这也是个很简单的 SQL 查询句子,用于查询 History 表中的一切数据。

可是咱们在它前面额外的多加了一个不属于 SQL 的语法,这个是 SQLDelight 特有的语法,表明这段 SQL 句子将被编译成名为 getAllHistory 的函数:

Kotlin & Compose Multiplatform 跨平台(Android端、桌面端)开发实践之使用 SQLDelight 将数据储存至数据库

依据这条 SQL 句子内容,这个函数不需求任何参数,且回来值为 Query<History> (能够经过调用 .executeAsList() 转为 List<History>),也便是榜首段中界说的表结构,它也会被主动编译成一个 kotlin 中的数据类 data class History:

data class History(
  public val id: Int,
  public val show_text: String?,
  public val left_number: String?,
  public val right_number: String?,
  public val operator_: Operator?,
  public val result: String?,
  public val create_time: Long?,
)

留意:其实这儿编译生成的 getAllHistory 还会生成一个带有 mapper 参数的同名函数 ,可是这儿为了便利理解,就先不做讲解

假设咱们需求按条件查询,例如按指定 id 查询,那也能够这样写:

getHistoryById:
SELECT * FROM History WHERE id == ?;

此刻编译生成的 getHistoryById 将会需求传递一个名为 id 的参数:

Kotlin & Compose Multiplatform 跨平台(Android端、桌面端)开发实践之使用 SQLDelight 将数据储存至数据库

这儿的代码中的 ? 能够简单理解为需求传递的参数,SQLDelight 在编译生成 kotlin 代码时,会依照句子主动判别它的参数称号。

再来看第三部分

insertHistory:
INSERT INTO History(show_text, left_number, right_number, operator, result, create_time)
VALUES (?, ?, ?, ?, ?, ?);

这个 insertHistory 作用是将数据刺进到数据库中,编译生成的函数将需求六个参数:

Kotlin & Compose Multiplatform 跨平台(Android端、桌面端)开发实践之使用 SQLDelight 将数据储存至数据库

这么多参数,你或许会说,怎样这么麻烦,就不能像 Room 那样直接刺进数据模型吗?上面不是都说了创建的表会主动生成一个数据类吗?

诶嘿,还真能够,只需这样写:

insertHistory:
INSERT INTO History(show_text, left_number, right_number, operator, result, create_time)
VALUES ?;

生成的函数便是这样的了:

Kotlin & Compose Multiplatform 跨平台(Android端、桌面端)开发实践之使用 SQLDelight 将数据储存至数据库

所以咱们就能够直接传入 History 了。

其实讲到这儿,后边的函数就不需求我再一一讲解了吧,哈哈。

对了,上面说到的 .sq 主动编译生成的 kotlin 文件在 模块目录/build/generated/sqldelight/code/ 目录下,感兴趣的话能够自己打开看看生成的 Kotlin 文件是什么样子的。

第三步,完结不同渠道的驱动

为了运用 SQLDelight ,咱们需求适配不同渠道的驱动程序。

为此,咱们需求用到 kotlin 中的 expectactual 两个关键字。

首要,咱们声明一个用于初始化驱动的 except 函数(或类):

// 该段内容坐落 common 模块下 commonMain 包中
expect fun createDriver(): SqlDriver

然后在不同的渠道代码模块中写上对应的 actual 完结:

// 这段代码坐落 common 模块 androdMain 包中
actual fun createDriver(): SqlDriver {
    return AndroidSqliteDriver(HistoryDatabase.Schema, ActivityUtils.getTopActivity(), "history.db")
}
// 这段代码坐落 common 模块 desktopMain 包中
actual fun createDriver(): SqlDriver {
    val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
    HistoryDatabase.Schema.create(driver)
    return driver
}

留意这儿官网和其他教程给出的初始化驱动的代码都是经过一个类来初始化的,由于不同渠道或许需求为其供给不同的参数:

// in src/commonMain/kotlin
expect class DriverFactory {
  expect fun createDriver(): SqlDriver
}
// in src/androidMain/kotlin
actual class DriverFactory(private val context: Context) {
  actual fun createDriver(): SqlDriver {
    return AndroidSqliteDriver(Database.Schema, context, "test.db") 
  }
}
// in src/nativeMain/kotlin
actual class DriverFactory {
  actual fun createDriver(): SqlDriver {
    return NativeSqliteDriver(Database.Schema, "test.db")
  }
}

例如在安卓渠道中需求供给安卓的上下文 Context ,所以在初始化时需求供给 context 参数,换句话说,触及到数据库交互的当地或许无法很好的完全完结多个渠道通用代码,由于在通用模块中无法拿到安卓上下文,也就无法初始化 SQLDelight 驱动。

可是这儿咱们这个项目中的事务逻辑简直全部都写在了通用模块中,关于数据库相关的逻辑我当然也期望能够继续写在通用模块中,好在我的项目能够经过第三方结构拿到安卓的 Context

AndroidSqliteDriver(
   HistoryDatabase.Schema, 
   ActivityUtils.getTopActivity(),  // Context
   "history.db"
)

所以就不存在上面说的这个问题,因此我也就没有以类的形式在获取驱动,而是直接写了一个函数。

最终,留意一下初始化驱动的渠道代码,在安卓中回来的是:AndroidSqliteDriver(HistoryDatabase.Schema, ActivityUtils.getTopActivity(), "history.db") 其间第二个参数是 Context,第三个参数是要运用的数据库文件名.

在桌面端则略有不同:

val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
HistoryDatabase.Schema.create(driver)
return driver

从代码也能看出,在桌面端咱们并没有将数据库写入到文件中,而是将其放到内存中了。这就意味着,当程序退出时数据库中的数据将丢掉。

不过由于咱们的核算器的逻辑是仿照的微软核算器,而微软核算器的处理逻辑也是历史记录仅在打开程序时有用,关闭程序后记录会被主动铲除。

这个逻辑恰好和这儿相同了,所以我就直接运用了官网文档中的这种将数据库写入内存的做法。

假设你们想要将数据库写入指定文件的话,能够参考这儿:Desktop Compose File Saving using SqlDelight。

最终,在桌面端运用数据库还有一点需求留意,假设咱们直接这样运行的话,关于 debug 包确实没有问题,可是假设运行或发布 distributions 包的话会闪退,这是由于在 distributions 包中没有包含所需的数据库支撑。

所以咱们需求在 desktop 模块下的 build.gradle.kts 文件中的 application -> nativeDistributions 块增加以下代码:

compose.desktop {
    application {
        nativeDistributions {
              // ……
            modules("java.sql")
              // ……
        }
    }
}

第四步,开端运用

在完结了上面的前置准备工作之后,咱们就能够开端运用了。

可是为了运用起来更加便利,咱们能够自己在 common 模块的 commonMain 包中封装一个帮助类 DataBase。

首要,在这个类中初始化需求运用到的实例:

private val database = HistoryDatabase(
    createDriver(),
    HistoryAdapter = History.Adapter(
        idAdapter = longOfIntAdapter,
        operator_Adapter = stringOfOperatorAdapter
    )
)
private val dbQuery = database.historyDatabaseQueries

在咱们后续运用时直接调用 dbQuery.xxx 即可,其间的 xxx 便是咱们自己在 .sq 文件中写的那些函数名。

能够看到,在初始化 HistoryDatabase 时,咱们供给了两个参数:

  1. 驱动实例,这儿的驱动便是上一节中咱们所编撰的驱动

  2. 类型转化适配器,文章稍早前有说过,咱们把 id 从 Long 转成了 Int 类型,operator 从 String 转成了 Operator 类型,而 SQLDelight 是不知道应该怎样转的,需求咱们自己界说转化函数,此刻这个参数便是用来界说咱们 的转化函数的。

    别的留意,假设咱们在 .sq 中没有写需求转化类型的字段的话,这儿就没有第二个参数,只需求榜首个参数即可。

这两个转化函数是这样界说的,这儿以 idAdapter 为例:

private val longOfIntAdapter = object : ColumnAdapter<Int, Long> {
    override fun decode(databaseValue: Long): Int {
        return databaseValue.toInt()
    }
    override fun encode(value: Int): Long {
        return value.toLong()
    }
}

其实也很简单,便是一个匿名函数 ColumnAdapter ,有两个泛型,榜首个表明要转化成的类型,第二个表明本来的类型。然后重载 decodeencode 办法,在其间完结类型转化即可。

初始化好数据库实例后,接下里便是写几个办法用于调用数据库查询:

首要是删除行

internal fun delete(historyData: HistoryData?) {
    if (historyData == null) {
        dbQuery.deleteAllHistory()
    }
    else {
        dbQuery.deleteHistory(historyData.id)
    }
}

这儿的 dbQuery.deleteAllHistory()dbQuery.deleteHistory(historyData.id) 对应的便是咱们前面在 .sq 中写的:

deleteHistory:
DELETE FROM History WHERE History.id == ?;
deleteAllHistory:
DELETE FROM History;

然后是刺进数据:

internal fun insert(item: HistoryData) {
    item.run {
        dbQuery.insertHistory(
            History(id, showText, lastInputText, inputText, operator, result, createTime)
        )
    }
}

在这儿,其实这个函数的参数能够直接运用 History 类型,这样咱们就只需求直接 dbQuery.insertHistory(item) 即可,可是由于我这个项目是搬迁自安卓端的,不是新写的,而在安卓端本来的数据模型运用的是自己界说的一个数据类 HistoryData,假设我改用 SQLDelight 生成的 History 的话,就需求改很多当地,所以这儿我干脆直接在进行数据库查询时转一下得了。

最终,是查询一切记录:

internal fun getAll(): List<HistoryData> {
    return dbQuery.getAllHistory(::mapHistoryList).executeAsList()
}

与上面刺进数据函数相同问题,这儿 dbQuery.getAllHistory 回来的是 List<History> 数据,而咱们需求的是 List<HistoryData> 数据,所以咱们需求转一下。

随堂测验,各位还记得上面咱们说过的,SQLDelight 生成的 getAllHistory 函数是没有参数的吗?

那么,问题来了,这儿传入的参数是什么东西?其实这儿的 getAllHistory 不仅有无参的函数,还有一个带有类型为高阶函数的参数的同名函数,且这个高阶函数的参数是一切查询字段的参数。那么这个高阶函数是用来干嘛的呢?当然便是拿来给咱们做数据转化或其他处理用的了。

这儿的 mapHistoryList 内容如下:

private fun mapHistoryList(
    id: Int,
    show_text: String?,
    left_number: String?,
    right_number: String?,
    operator_: Operator?,
    result: String?,
    create_time: Long?,
): HistoryData {
    return HistoryData(
        id = id,
        showText = show_text ?: "",
        lastInputText = left_number ?: "",
        inputText = right_number ?: "",
        operator = operator_ ?: Operator.NUll,
        result = result ?: "",
        createTime = create_time ?: 0
    )
}

自此,咱们的数据库帮助类简直全部完结了,最终再加一个单例实例便利调用,也避免重复初始化:

companion object {
    val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
        DataBase()
    }
}

完整的 DataBase 类内容如下:

package com.equationl.common.database
import app.cash.sqldelight.ColumnAdapter
import com.equationl.common.dataModel.HistoryData
import com.equationl.common.dataModel.Operator
import com.equationl.common.platform.createDriver
internal class DataBase {
    companion object {
        val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
            DataBase()
        }
    }
    private val longOfIntAdapter = object : ColumnAdapter<Int, Long> {
        override fun decode(databaseValue: Long): Int {
            return databaseValue.toInt()
        }
        override fun encode(value: Int): Long {
            return value.toLong()
        }
    }
    private val stringOfOperatorAdapter = object : ColumnAdapter<Operator, String> {
        override fun decode(databaseValue: String): Operator {
            return try {
                Operator.valueOf(databaseValue)
            } catch (e: IllegalArgumentException) {
                Operator.NUll
            }
        }
        override fun encode(value: Operator): String {
            return value.name
        }
    }
    private val database = HistoryDatabase(
        createDriver(),
        HistoryAdapter = History.Adapter(
            idAdapter = longOfIntAdapter,
            operator_Adapter = stringOfOperatorAdapter
        )
    )
    private val dbQuery = database.historyDatabaseQueries
    internal fun delete(historyData: HistoryData?) {
        if (historyData == null) {
            dbQuery.deleteAllHistory()
        }
        else {
            dbQuery.deleteHistory(historyData.id)
        }
    }
    internal fun getAll(): List<HistoryData> {
        return dbQuery.getAllHistory(::mapHistoryList).executeAsList()
    }
    internal fun insert(item: HistoryData) {
        item.run {
            dbQuery.insertHistory(
                History(id, showText, lastInputText, inputText, operator, result, createTime)
            )
        }
    }
    private fun mapHistoryList(
        id: Int,
        show_text: String?,
        left_number: String?,
        right_number: String?,
        operator_: Operator?,
        result: String?,
        create_time: Long?,
    ): HistoryData {
        return HistoryData(
            id = id,
            showText = show_text ?: "",
            lastInputText = left_number ?: "",
            inputText = right_number ?: "",
            operator = operator_ ?: Operator.NUll,
            result = result ?: "",
            createTime = create_time ?: 0
        )
    }
}

最终,在咱们实际需求运用到的当地调用即可。

例如,在我这个项目中,我会在点击历史记录图标后从数据库读取历史记录数据并更新到列表中,所以在 ViewModel (留意,这儿的 ViewModel 不是 Jetpack ViewModel 结构,仅仅一个姓名)中的 toggleHistory 函数有这么一段代码:

private val dataBase = DataBase.instance
// ……
private fun toggleHistory(forceClose: Boolean, viewStates: MutableState<StandardState>) {
// ……
        CoroutineScope(Dispatchers.IO).launch {
            var list = dataBase.getAll()
            if (list.isEmpty()) {
                list = listOf(
                    HistoryData(-1, showText = "", "null", "null", Operator.NUll, "没有历史记录")
                )
            }
            viewStates.value = viewStates.value.copy(historyList = list)
        }
// ……
}

对了,在这儿的代码中,我自己启动了一个协程用于履行数据库查询操作,这是由于关于我这个项目,能够支撑我这么做。

而其实 SQLDelight 官方就有支撑运用协程履行查询的扩展库,引荐各位仍是运用官方的方法来:

val players: Flow<List<HockeyPlayer>> =
  playerQueries.selectAll()
    .asFlow()
    .mapToList()

依靠: implementation("app.cash.sqldelight:coroutines-extensions:2.0.0-alpha05")

总结

完整项目源码地址:calculator-Compose-MultiPlatform

好了,现在咱们现已完全完结了将本来在安卓端运用 Room 完结的数据库贮存搬迁到了运用 SQLDelight 完结的跨渠道数据库贮存。

能够看到,其实运用 SQLDelight 也是十分的便利,相较于 Room ,或许也便是前期装备稍微麻烦了那么一点点(究竟要手写 SQL)。

最最重要的是,SQLDelight 支撑跨渠道,无论是移动端的 安卓 和 iOS 仍是桌面端的 Windows、macOS、Linux,它都支撑。

仅仅在官网教程和其他大佬写的博客中,大多数都是介绍 SQLdelight 在单一渠道运用或者移动端跨渠道运用,我没看到有介绍跨安卓和桌面端的文章,所以这儿我就写了这篇文章。

参考资料

  1. SQLDelight Multiplatform
  2. 【翻译】运用 Ktor 和 SQLDelight 构建跨渠道APP教程

本文正在参与「金石计划」