Appearance
用户数据导入导出设计
v4 版本新增用户数据的导入与导出功能,允许用户将输入历史、收藏列表等个人数据导出为文件备份,以及从文件中导入恢复。目的是保护用户数据安全,方便换机迁移和灾难恢复。
1. UserDataService
kotlin
/**
* 用户数据导入导出服务。
*
* 负责将用户数据序列化为 JSON 文件以及从 JSON 文件反序列化恢复数据。
* 所有操作均为协程化的异步操作,避免阻塞主线程。
*/
class UserDataService(
private val userInputDao: UserInputDao,
private val favoriteDao: FavoriteDao,
private val configStore: ConfigDataStore,
private val json: Json,
private val scope: CoroutineScope,
) {
/**
* 导出用户数据到指定 URI。
*
* @param context Android Context,用于访问 ContentResolver
* @param uri 目标文件 URI(由系统文件选择器返回)
* @return 导出结果
*/
suspend fun exportTo(context: Context, uri: Uri): ExportResult {
val userInputData = userInputDao.getAll()
val favoriteData = favoriteDao.getAll()
val configData = configStore.config.first()
val backup = UserBackup(
version = BACKUP_FORMAT_VERSION,
appVersion = BuildConfig.VERSION_NAME,
exportedAt = Clock.System.now().toString(),
data = BackupData(
userInput = userInputData.map { it.toBackupEntry() },
favorites = favoriteData.map { it.toBackupEntry() },
config = configData.toBackupEntry(),
),
)
val jsonString = json.encodeToString(backup)
return withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openOutputStream(uri)?.use { output ->
output.write(jsonString.toByteArray(Charsets.UTF_8))
} ?: error("Failed to open output stream for URI: $uri")
}.fold(
onSuccess = { ExportResult.Success(itemCount = userInputData.size + favoriteData.size) },
onFailure = { ExportResult.Failure(it.message ?: "Unknown error") },
)
}
}
/**
* 从指定 URI 导入用户数据。
*
* @param context Android Context
* @param uri 源文件 URI
* @param strategy 导入策略
* @return 导入结果
*/
suspend fun importFrom(
context: Context,
uri: Uri,
strategy: ImportStrategy = ImportStrategy.Replace,
): ImportResult {
return withContext(Dispatchers.IO) {
val jsonString = runCatching {
context.contentResolver.openInputStream(uri)?.use { input ->
input.readBytes().toString(Charsets.UTF_8)
} ?: error("Failed to open input stream for URI: $uri")
}.getOrElse {
return@withContext ImportResult.Failure(it.message ?: "Failed to read file")
}
val backup = runCatching {
json.decodeFromString<UserBackup>(jsonString)
}.getOrElse {
return@withContext ImportResult.Failure("Invalid backup file format: ${it.message}")
}
// 版本兼容性检查
if (backup.version > BACKUP_FORMAT_VERSION) {
return@withContext ImportResult.Failure(
"Backup format version ${backup.version} is not supported (max: $BACKUP_FORMAT_VERSION)"
)
}
// 按策略执行导入
when (strategy) {
ImportStrategy.Replace -> importReplace(backup)
ImportStrategy.Merge -> importMerge(backup)
}
}
}
private suspend fun importReplace(backup: UserBackup): ImportResult {
// 先保存当前数据用于回滚
val currentInputData = userInputDao.getAll()
val currentFavorites = favoriteDao.getAll()
return runCatching {
userInputDao.clearAll()
favoriteDao.clearAll()
backup.data.userInput.forEach { entry ->
userInputDao.upsert(entry.toEntity())
}
backup.data.favorites.forEach { entry ->
favoriteDao.upsert(entry.toEntity())
}
backup.data.config?.let { config ->
configStore.updateConfig { config.restoreFromBackup(it) }
}
ImportResult.Success(
importedCount = backup.data.userInput.size + backup.data.favorites.size,
skippedCount = 0,
conflictCount = 0,
)
}.getOrElse { error ->
// 回滚
runCatching {
userInputDao.clearAll()
favoriteDao.clearAll()
currentInputData.forEach { userInputDao.upsert(it) }
currentFavorites.forEach { favoriteDao.upsert(it) }
}
ImportResult.Failure("Import failed, rolled back: ${error.message}")
}
}
private suspend fun importMerge(backup: UserBackup): ImportResult {
var importedCount = 0
var conflictCount = 0
backup.data.userInput.forEach { entry ->
val existing = userInputDao.getByTextAndType(entry.text, entry.type)
if (existing != null) {
conflictCount++
// 取较高频率和较新时间
val merged = existing.copy(
freq = maxOf(existing.freq, entry.freq),
lastUsed = maxOf(existing.lastUsed, entry.lastUsed),
)
userInputDao.upsert(merged)
} else {
userInputDao.upsert(entry.toEntity())
importedCount++
}
}
backup.data.favorites.forEach { entry ->
val existing = favoriteDao.getByText(entry.text)
if (existing != null) {
conflictCount++
val merged = existing.copy(
usageCount = maxOf(existing.usageCount, entry.usageCount),
)
favoriteDao.upsert(merged)
} else {
favoriteDao.upsert(entry.toEntity())
importedCount++
}
}
backup.data.config?.let { config ->
configStore.updateConfig { config.restoreFromBackup(it) }
}
return ImportResult.Success(
importedCount = importedCount,
skippedCount = 0,
conflictCount = conflictCount,
)
}
companion object {
private const val BACKUP_FORMAT_VERSION = 1
}
}2. 数据模型
2.1 UserBackup / BackupData
kotlin
@Serializable
data class UserBackup(
val version: Int,
val appVersion: String,
val exportedAt: String,
val data: BackupData,
)
@Serializable
data class BackupData(
val userInput: List<UserInputBackupEntry>,
val favorites: List<FavoriteBackupEntry>,
val config: ConfigBackupEntry? = null,
)
@Serializable
data class UserInputBackupEntry(
val text: String,
val type: String,
val freq: Int,
val last_used: Long,
)
@Serializable
data class FavoriteBackupEntry(
val text: String,
val type: String? = null,
val usage_count: Int,
val created_at: Long,
)
@Serializable
data class ConfigBackupEntry(
// 引擎配置(对应 ImeConfig.EngineConfig)
val engine: EngineConfigBackupEntry? = null,
// UI 配置(对应 ImeConfig.UiConfig)
val ui: UiConfigBackupEntry? = null,
)
@Serializable
data class EngineConfigBackupEntry(
// 输入体验
val keyboard_type: String? = null,
val hand_mode: String? = null,
val candidate_prediction_enabled: Boolean? = null,
val single_line_input: Boolean? = null,
// 功能集单独处理(features 为枚举集合,不在此列出)
)
@Serializable
data class UiConfigBackupEntry(
// 外观
val theme_type: String? = null,
val x_pad_enabled: Boolean? = null,
// 输入体验
val latin_use_pinyin_keys_in_x_pad: Boolean? = null,
val adapt_desktop_swipe_up_gesture: Boolean? = null,
val candidate_variant_first_enabled: Boolean? = null,
// 隐私
val user_input_data_enabled: Boolean? = null,
// 反馈控制
val audio_feedback_enabled: Boolean? = null,
val haptic_feedback_enabled: Boolean? = null,
val key_animation_enabled: Boolean? = null,
val key_popup_tips_enabled: Boolean? = null,
val gesture_slipping_trail_enabled: Boolean? = null,
val clip_popup_tips_enabled: Boolean? = null,
val candidates_paging_audio_enabled: Boolean? = null,
val clip_popup_tips_timeout: Int? = null,
// 日志与诊断
val log_level: String? = null,
val log_storage_path: String? = null,
// 输入练习演示
val practice_playback_speed: Float? = null,
val practice_show_finger_overlay: Boolean? = null,
val practice_show_swipe_trail: Boolean? = null,
)2.2 导出文件格式
导出文件格式为 JSON,便于人工检视和跨版本兼容:
json
{
"version": 1,
"app_version": "4.0.0",
"exported_at": "2026-05-12T10:30:00+08:00",
"data": {
"user_input": [
{ "text": "你好", "type": "pinyin", "freq": 42, "last_used": 1715472000000 },
{ "text": "hello", "type": "latin", "freq": 15, "last_used": 1715471000000 }
],
"favorites": [
{ "text": "example@email.com", "type": "Email", "usage_count": 3, "created_at": 1715470000000 }
],
"config": {
"engine": {
"hand_mode": "Right"
},
"ui": {
"theme_type": "FollowSystem",
"x_pad_enabled": true,
"candidate_variant_first_enabled": false,
"user_input_data_enabled": true,
"audio_feedback_enabled": true,
"key_animation_enabled": true,
"candidates_paging_audio_enabled": true,
"key_popup_tips_enabled": true,
"gesture_slipping_trail_enabled": true,
"clip_popup_tips_enabled": true,
"clip_popup_tips_timeout": 15
}
}
}
}设计说明:
version为数据格式版本号,便于后续格式变更时做兼容处理app_version记录导出时的应用版本,用于排查兼容性问题- 拼音字典数据为内置数据,不纳入导出范围
config中的字段结构对应ImeConfig的嵌套结构(engine和ui两个子对象),仅导出与默认值不同的配置项- 导入时,备份文件中不存在的可选字段使用
null默认值,不影响现有配置
3. 结果类型
kotlin
sealed class ExportResult {
data class Success(val itemCount: Int) : ExportResult()
data class Failure(val message: String) : ExportResult()
}
sealed class ImportResult {
data class Success(
val importedCount: Int,
val skippedCount: Int,
val conflictCount: Int,
) : ImportResult()
data class Failure(val message: String) : ImportResult()
}
enum class ImportStrategy {
Replace, // 替换:清除现有数据后导入
Merge, // 合并:与现有数据合并
}4. UI 设计
4.1 设置页面中的入口
kotlin
@Composable
fun DataManagementSection(
onExport: () -> Unit,
onImport: () -> Unit,
) {
SettingsSectionHeader("数据管理")
ClickablePreference(
title = "导出用户数据",
description = "将输入历史、收藏和设置导出为备份文件",
onClick = onExport,
)
ClickablePreference(
title = "导入用户数据",
description = "从备份文件恢复输入历史、收藏和设置",
onClick = onImport,
)
}4.2 文件选择
kotlin
// 导出:使用 Activity Result API 创建文件
val createFileLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { viewModel.handleIntent(ImeIntent.ExportUserData(it)) }
}
// 导入:使用 Activity Result API 打开文件
val openFileLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(arrayOf("application/json"))
) { uri ->
uri?.let {
// 显示导入策略选择对话框
showImportStrategyDialog = true
selectedImportUri = it
}
}4.3 导入策略选择对话框
kotlin
@Composable
fun ImportStrategyDialog(
onStrategySelected: (ImportStrategy) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("导入策略") },
text = {
Column {
Text("请选择数据导入方式:")
Spacer(modifier = Modifier.height(16.dp))
// 替换选项
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = true, onClick = { /* ... */ })
Column {
Text("替换现有数据", fontWeight = FontWeight.Bold)
Text("清除当前所有数据后导入备份数据", style = bodySmall)
}
}
// 合并选项
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = false, onClick = { /* ... */ })
Column {
Text("与现有数据合并", fontWeight = FontWeight.Bold)
Text("保留现有数据,相同条目取较高频率", style = bodySmall)
}
}
}
},
confirmButton = {
TextButton(onClick = { onStrategySelected(ImportStrategy.Replace) }) {
Text("确认导入")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
},
)
}5. 权限与安全
5.1 权限
- 不需要
WRITE_EXTERNAL_STORAGE或READ_EXTERNAL_STORAGE权限 - 使用 SAF(Storage Access Framework)的
CreateDocument和OpenDocument,系统自动处理权限
5.2 数据安全
- 导出文件为明文 JSON,不包含加密
- 导出文件中不包含拼音字典数据(为应用内置数据)
- 导入时验证文件格式版本,不兼容的版本拒绝导入
- 导入失败自动回滚,确保数据完整性
6. Intent 扩展
用户数据导入导出新增以下 ImeIntent 子类:
kotlin
// 新增于 ImeIntent
data class ExportUserData(val uri: Uri) : ImeIntent()
data class ImportUserData(val uri: Uri, val strategy: ImportStrategy) : ImeIntent()