在app 开发过程中,数据存储是比较常用的需求,那Compose Multiplatform项目中,我们该如何进行数据存储呢?今天我们就来聊一聊CMP项目中的数据存储。
想更多了解Compose Multiplatform,也可以看看其他文章
- Compose Multiplatform 之旅 — 启程
- Compose Multiplatform 之旅 — 项目初探
- Compose Multiplatform 之旅 —做一个自己的项目(别踩白块)
- Compose Multiplatform 之旅—看看大佬在做啥
- Compose Multiplatform 之旅—为什么可以跨平台
- Compose Multiplatform 之旅—声明式UI
- Compose Multiplatform 之旅—跳转、导航(Voyager)
- Compose Multiplatform 之旅 — 数据存储(multiplatform-settings、sqldelight)
- Compose Multiplatform 之旅 — 网络请求(Ktor)
- Compose Multiplatform 之旅 — 图标、图片展示(coil)
找到合适的框架
Kotlin Mutiplatform 已经有了很多现成的基建了,我们还是通过直接推荐的 klibs.io 去进行搜索,看看有哪些现成的框架。
搜索storage关键字,找到常见的2种场景(1.kv 存储 2.数据库存储) 找到了2个star比较高的仓库,都支持Android、iOS、Web、桌面端。
- kv 存储: multiplatform-settings
- 数据库存储: sqldelight
KV 存储-multiplatform-settings
该库可以根据具体平台自动适配常见存储方式:
- Android:使用 SharedPreferences。
- iOS:使用 NSUserDefaults。
- JS/Native/Desktop:使用文件系统模拟存储。
依赖引入
//在build.gradle.kts 的commonMain引入相关依赖
commonMain.dependencies {
...
implementation(libs.multiplatform.settings)
}
//libs.versions.toml 中定义版本号
multiplatformSettings = "1.2.0"
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
初始化
我们使用之前提到过的expect/actual 的机制来初始化 Settings 对象。
commonMain
//公共模块定义好接口
expect fun createSettings(): Settings
androidMain
actual fun createSettings(): Settings {
//appContext 是在Application 声明的一个变量
val delegate: SharedPreferences = appContext.getSharedPreferences("MyPrefs", Context.MODE_PRIVATE)
return SharedPreferencesSettings(delegate)
}
iosMain
actual fun createSettings(): Settings {
val userDefaults = NSUserDefaults.standardUserDefaults
return NSUserDefaultsSettings(userDefaults)
}
jvmMain
val delegate: Preferences = Preferences.userRoot().node("com.example.myapp")
return PreferencesSettings(delegate)
使用
简单封装一个存储字符串的方法,存储和获取数据,当然其他常见的基础数据类型都是支持的。
object SettingsUtils {
private val settings = createSettings()
fun save(key:String,value: Long){
settings.putLong(key,value)
}
fun get(key: String,defaultValue: Long):Long{
return settings.getLong(key,defaultValue)
}
}
在页面上,记录上次点击的时间,输出上次与此刻相差的时间
class NowScreen : Screen {
@Composable
override fun Content() {
var time by rememberSaveable { mutableStateOf(0L) }
var lastTime by rememberSaveable { mutableStateOf(0L) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
lastTime = SettingsUtils.get("time", 0L)
}
while (true) {
time = if (lastTime > 0) {
getCurrentTimestamp() - lastTime
} else {
0
}
delay(1000)
}
}
Column(
modifier = Modifier.fillMaxSize().background(Color.Yellow),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "距离任务开始已经${time} 秒了",
fontSize = 20.sp,
color = Color.Black
)
Button(onClick = {
time = 0
lastTime = getCurrentTimestamp()
SettingsUtils.save("time", lastTime)
}) {
Text("重新开始")
}
}
}
}
fun getCurrentTimestamp(): Long {
//需要引入datetime库,获取当前时间
val instant: Instant = Clock.System.now()
return instant.epochSeconds
}
效果
可以看到杀死app之后,下次再次进入会根据kv 存储的时候,继续计时
数据库存储-sqldelight
依赖引入
1.需要引入插件生成sql类 2.每个平台不同实现,需要引入对应的依赖
//libs.versions.toml 中定义版本号
sqldelight = "2.0.2"
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
//在工程根目录引入插件
plugins {
//...
alias(libs.plugins.sqldelight) apply false
}
//在shared 的build.gradle.kts 的commonMain引入插件和相关依赖
plugins {
//...
alias(libs.plugins.sqldelight)
}
kotlin {
sourceSets {
//每个模块引入对的平台依赖
commonMain.dependencies {
implementation("app.cash.sqldelight:runtime:2.0.2")
}
androidMain.dependencies {
implementation("app.cash.sqldelight:android-driver:2.0.2")
}
iosMain.dependencies {
implementation("app.cash.sqldelight:native-driver:2.0.2")
}
jvmMain.dependencies {
implementation("app.cash.sqldelight:sqlite-driver:2.0.2")
}
}
}
sqldelight {
databases{
//定义数据库名字
create("AppDatabase") {
//对于包名
packageName.set("com.example.database")
}
}
}
定义数据库
在commonMain 中,新增一个sqldelight的目录,(注意:不是在kotlin目录中,否则生成数据库代码时,找不到对应的.sq文件,否则排查半天发现就是不能生成)。根据前面声明的包名,创建好对应的目录。
在目录中,创建你需要的数据类型,这里我把上面的计时功能,增加一个历史数据存储。定义了一个TimeRecord.sq 的数据表
-- TimeRecord.sq
-- 创建时间记录表
CREATE TABLE time_record (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, -- 唯一标识
event_name TEXT NOT NULL, -- 事件名称
start_time INTEGER NOT NULL, -- 开始时间(时间戳,单位为秒)
end_time INTEGER NOT NULL -- 结束时间(时间戳,单位为秒)
);
-- 插入新记录
insertTimeRecord:
INSERT INTO time_record (event_name, start_time, end_time)
VALUES (:event_name, :start_time, :end_time);
-- 查询所有记录,按开始时间升序排序
selectAllTimeRecordsSortedByStartTime:
SELECT * FROM time_record
ORDER BY start_time ASC;
-- 更新记录的开始时间和结束时间
updateTimeRecord:
UPDATE time_record
SET start_time = :start_time, end_time = :end_time
WHERE id = :id;
-- 删除记录
deleteTimeRecordById:
DELETE FROM time_record WHERE id = :id;
根据创建的表生成对应的类
gradle sync 一下,或者执行 gradle task 里面的 sqldelight>generateSqlDelightInterface 任务 就会生成对应的类。
生成的类在build>generated>sqldelight 目录下。 这里生成的AppDatabase、AppDatabaseImpl 就是处理数据库相关的初始化、迁移逻辑。 Time_record 是上面声明表的基础类型 TimeRecordQueries 是根据上面写的一些操作,生成的帮助类
数据库初始化
在commonMain 中创建一个DatabaseHelper
// commonMain
// 每个平台使用不同的driver,通过expect让每个端自行实现
expect class DatabaseDriverFactory() {
fun createDriver(): SqlDriver
}
object DatabaseHelper {
private val driver = DatabaseDriverFactory().createDriver()
//后续就可以直接用这个数据库进行处理了,这里的AppDatabase类就是前面自动创建的
val database = AppDatabase(driver)
}
//androidMain
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, appContext, "app.db")
}
}
//iosMain
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
}
//jvmMain
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
val databaseFile = File("app.db")
val driver = JdbcSqliteDriver("jdbc:sqlite:app.db")
if (!databaseFile.exists()) {
// 数据库文件不存在时,创建表,否则会出现未创建或者重复创建表的错误
AppDatabase.Schema.create(driver)
}
return driver
}
}
数据库使用
在前面记录时间的页面,我们把每次计时结束,我们都把数据存储到数据库中
val timeRecordQueries = DatabaseHelper.database.timeRecordQueries
//往表中插入数据
timeRecordQueries.insertTimeRecord("专注",lastTime, getCurrentTimestamp())
在新的页面,我们进行列表展示数据库的数据
class PastScreen : Screen {
@Composable
override fun Content() {
val timeRecordQueries = DatabaseHelper.database.timeRecordQueries
//获取到数据库里面的数据
val timeRecords by produceState<List<Time_record>>(initialValue = emptyList(), timeRecordQueries) {
withContext(Dispatchers.IO) {
value = timeRecordQueries
.selectAllTimeRecordsSortedByStartTime()
.executeAsList()
}
}
Column (
modifier = Modifier.fillMaxSize().background(Color.Green),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
){
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(10.dp)
) {
//列表展示数据库的每条数据
items(timeRecords) { timeRecord ->
TimeRecordItem(timeRecord)
}
}
}
}
}
@Composable
fun TimeRecordItem(timeRecord: Time_record) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = timeRecord.event_name)
Text(
text = "开始时间: ${timeRecord.start_time}"
)
Text(
text = "结束时间: ${timeRecord.end_time}"
)
}
}
}
iOS Undefined symbols for architecture arm64问题
Android 和 桌面端运行不会存在问题,但是在ios 上运行sqldelight有一个Undefined symbols for architecture arm64的错误。 在github 上找到了对应的修复方案:github.com/sqldelight/…
需要在xcode 上,other linker flags 的最后增加一个 -lsqlite3 配置
效果
在上面的kv 页面的基础上,我们新增了一个页面展示记录的详情,详情数据就是使用的数据库中的数据。
结语
利用上面的两个库,已经可以处理跨平台开发过程中常见的数据存储了。有了这些基建,在跨平台开发过程中,岂不是美滋滋。有什么其他想了解跨平台的特性或者三方库,大家可以提出来,我们一起学习,共同进步。