Compose Multiplatform 之旅 — 数据存储(sqldelight、multiplatform-settings)

502 阅读3分钟

在app 开发过程中,数据存储是比较常用的需求,那Compose Multiplatform项目中,我们该如何进行数据存储呢?今天我们就来聊一聊CMP项目中的数据存储。

想更多了解Compose Multiplatform,也可以看看其他文章

找到合适的框架

Kotlin Mutiplatform 已经有了很多现成的基建了,我们还是通过直接推荐的 klibs.io 去进行搜索,看看有哪些现成的框架。

Pasted image 20250109203723.png

搜索storage关键字,找到常见的2种场景(1.kv 存储 2.数据库存储) 找到了2个star比较高的仓库,都支持Android、iOS、Web、桌面端。

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 存储的时候,继续计时

20250118-151547.gif

数据库存储-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文件,否则排查半天发现就是不能生成)。根据前面声明的包名,创建好对应的目录。

Pasted image 20250118152451.png

在目录中,创建你需要的数据类型,这里我把上面的计时功能,增加一个历史数据存储。定义了一个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 是根据上面写的一些操作,生成的帮助类

Pasted image 20250118153550.png

数据库初始化

在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 配置

img_v3_02ik_2221ac17-84fc-447c-b833-8b0ee595e02g.jpg

效果

在上面的kv 页面的基础上,我们新增了一个页面展示记录的详情,详情数据就是使用的数据库中的数据。

img_v3_02il_ce5e046b-c9c9-461f-a1ee-aed44e04c64g.gif

结语

利用上面的两个库,已经可以处理跨平台开发过程中常见的数据存储了。有了这些基建,在跨平台开发过程中,岂不是美滋滋。有什么其他想了解跨平台的特性或者三方库,大家可以提出来,我们一起学习,共同进步。