news 2026/9/20 11:58:41

Cherry Studio Preference 系统实战指南:usePreference 钩子与 PreferenceService 的完整用法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cherry Studio Preference 系统实战指南:usePreference 钩子与 PreferenceService 的完整用法

Cherry Studio Preference 系统实战指南:usePreference 钩子与 PreferenceService 的完整用法

【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio

本文以 Cherry Studio 仓库中的 Preference Usage Guide 为核心骨架,系统讲解 React 侧usePreference/useMultiplePreferences两个 Hook、渲染进程单例preferenceService与主进程生命周期服务PreferenceService的完整调用方式,并结合 源码、渲染进程服务 与 主进程服务 深入剖析乐观更新、回滚、跨窗口同步等底层机制。读完本文,你将能够在 Cherry Studio 的任何功能模块中正确读写主题、语言、字号、功能开关等用户设置,并理解何时选择乐观或悲观更新策略。

一、Preference 是什么:定位与边界

Cherry Studio 的数据体系分为四套系统(详见 Data System Reference 的决策表):BootConfigService(进程级早期配置)、CacheService(可再生临时数据)、PreferenceService(用户设置)与DataApiService(业务数据)。Preference 的定位是:

  • 存储小而固定键位的用户设置,必须在多个窗口间持久化并保持一致;
  • 适用于主题、语言、字号、功能开关、快捷键等场景;
  • 不适用于用户创建的记录、大型集合或可再生的 UI 状态。

从架构看(参见 Preference Overview),preference表以(scope, key)为联合主键,值以 JSON 存储,当前运行时 scope 恒为default,其表结构定义在 db/schemas/preference.ts:

export const preferenceTable = sqliteTable( 'preference', { scope: text().notNull().default('default'), // scope 预留扩展,当前仅支持 'default' key: text().notNull(), value: text({ mode: 'json' }), ...createUpdateTimestamps }, (t) => [primaryKey({ columns: [t.scope, t.key] })] )

src/shared/data/preference/preferenceTypes.ts定义了完整的类型体系:PreferenceKeyType仅覆盖 SQLite 后端键;UnifiedPreferenceKeyType额外包含带BootConfig.前缀的公开 BootConfig 键;PreferenceUpdateOptions则是唯一的更新策略选项:

export type PreferenceUpdateOptions = { optimistic: boolean }

每个键都有生成好的默认值,因此调用方即使在渲染进程缓存尚未加载完成时,也能立刻观察到一个合理值,而不是undefined

二、React Hooks:usePreference 与 useMultiplePreferences

React 组件中推荐始终使用 Hook,由 Hook 自动管理订阅生命周期。实现位于 src/renderer/data/hooks/usePreference.ts,底层基于 React 18 的useSyncExternalStore,能获得实时的跨窗口同步。

2.1 usePreference:单个键

usePreference接收一个合法键,返回[value, setValue]。value 会套用生成的默认值(绝不会是undefined),setter 返回 Promise:

import { usePreference } from '@data/hooks/usePreference' const [theme, setTheme] = usePreference('ui.theme_mode') await setTheme('dark')

从源码看(usePreference.ts),Hook 内部:

  1. useSyncExternalStore订阅该键的变更(preferenceService.subscribeChange(key));
  2. 首次渲染时若缓存未命中(rawValue === undefined),通过useEffect异步发起preferenceService.get(key)拉取;
  3. 对外暴露的值rawValue !== undefined ? rawValue : getDefaultValue(key),即默认值兜底,绝不返回undefined
  4. setter 内部调用preferenceService.set(key, newValue, options),失败时记录日志并重新抛出,由调用方决定如何提示用户。

2.2 悲观更新:等待持久化确认

更新默认是乐观的(DEFAULT_PREFERENCE_OPTIONS = { optimistic: true })。当 UI 必须等到持久化确认后才能呈现"已保存"状态时,传入{ optimistic: false }

const [developerMode, setDeveloperMode] = usePreference('app.developer_mode.enabled', { optimistic: false }) await setDeveloperMode(true)

典型的悲观场景包括密码、WebDAV 凭据等关键设置,例如源码注释中给出的data.backup.webdav.pass;而主题、字号等纯 UI 偏好适合乐观更新以换取即时反馈。

2.3 useMultiplePreferences:批量读取与批量更新

当需要一组相关设置时,用useMultiplePreferences。它接收一个「本地名 → 偏好键」的映射对象,返回[values, updateValues]

import { useMultiplePreferences } from '@data/hooks/usePreference' const [settings, updateSettings] = useMultiplePreferences({ theme: 'ui.theme_mode', language: 'app.language', fontSize: 'chat.message.font_size' }) await updateSettings({ theme: 'system', language: 'en-US' })

源码实现要点(usePreference.ts):

  • 单次订阅聚合:内部将映射值转为 keyList,为每个键调用一次subscribeChange并聚合退订函数;相比多个usePreference更高效;
  • 快照去重:通过lastSnapshotRef缓存最近快照,仅当任一值真正变化时才产生新对象,避免useSyncExternalStore无限循环;
  • 初始加载:对未缓存键调用getMultipleRaw一次性批量拉取;
  • 局部更新updateSettings只更新传入的键,未传的键保持原值;
  • 默认值兜底exposedValues同样对每个键应用getDefaultValue

2.4 键映射对象的稳定性要求

key-map 对象必须保持引用稳定(模块常量或useMemo),因为它既是 Hook 的依赖,也是订阅定义。若由动态输入构造,务必用useMemo包裹,否则每次渲染都会重建订阅。

三、渲染进程 Service:非 React 代码的直接访问

非 React 的渲染进程代码(如事件监听、工具函数、服务层)可以直接使用单例preferenceService(src/renderer/data/PreferenceService.ts 导出)。

3.1 基本读写

import { preferenceService } from '@data/PreferenceService' const theme = await preferenceService.get('ui.theme_mode') const settings = await preferenceService.getMultiple({ language: 'app.language', fontSize: 'chat.message.font_size' }) await preferenceService.set('ui.theme_mode', 'dark') await preferenceService.setMultiple({ 'app.language': 'en-US', 'chat.message.font_size': 16 })

注意两处命名细节:

  • getMultiple()接收本地名到键的对象,返回以本地名命名的结果;
  • getMultipleRaw(keys)接收键数组,返回以 Preference 键本身命名的对象——仅在结果必须按键索引时使用。

getMultipleRaw的缓存逻辑(PreferenceService.ts):先筛出已缓存键,对未缓存键批量走一次 IPCgetMultipleRaw拉取,失败时用getDefaultValue填充默认值兜底,随后对全部请求键执行一次批量子订阅(内部自动去重)。

3.2 同步缓存读取与订阅

渲染进程服务内部维护cache对象,提供同步读取接口getCachedValue(key)isCached(key)subscribeChange(key)柯里化的:先传键、再传回调,返回退订函数:

const unsubscribe = preferenceService.subscribeChange('ui.theme_mode')(() => { const theme = preferenceService.getCachedValue('ui.theme_mode') logger.info('Theme changed', { theme }) })

务必在持有者销毁时调用返回的退订函数;React 代码应使用 Hook,Hook 会自动管理该生命周期(useSyncExternalStore会在组件卸载时调用退订)。

3.3 乐观更新的底层实现

渲染进程服务是乐观更新机制的真正执行者,其核心状态包括optimisticValues(跟踪乐观值、原始值、时间戳与 requestId)与requestQueues(同键并发更新队列)。整体流程(PreferenceService.ts):

  1. setOptimistic生成唯一requestId并入队(同键并发请求串行处理,防止竞态);
  2. executeOptimisticUpdate立即更新本地 cache 并通知监听者,UI 瞬间响应;
  3. 调用window.api.preference.set(key, value)持久化到主进程;
  4. 成功则confirmOptimistic清除乐观状态并处理下一个排队请求;失败则rollbackOptimistic恢复首次请求记录的原始值并重新通知,然后抛出异常。

批量的setMultipleOptimistic采用同一策略(PreferenceService.ts):为每个键生成batchRequestId_key形式的独立 requestId,任何一个键失败都会回滚整个批次中所有受影响键的原始值。此外,onChanged监听器用isEqual深比较过滤掉自己写入产生的 IPC 回显,避免无谓重渲染。

四、主进程 Service:生命周期托管与同步读

主进程代码通过application容器获取生命周期托管的服务实例(实现见 src/main/data/PreferenceService.ts):

import { application } from '@application' const preferences = application.get('PreferenceService') const theme = preferences.get('ui.theme_mode') const { language, fontSize } = preferences.getMultiple({ language: 'app.language', fontSize: 'chat.message.font_size' }) await preferences.set('ui.theme_mode', 'dark')

4.1 同步读、异步写的设计

主进程get()/getMultiple()同步的内存缓存读取(服务初始化时一次性从 SQLite 加载全部scope = 'default'的键到内存,见onInit,PreferenceService.ts)。写入返回 Promise 的原因在于:better-sqlite3 的写入本身是同步的,但写入后需要跨进程广播变更通知给所有已订阅的渲染窗口notifyChange,见 PreferenceService.ts),这部分是异步语义。

服务声明(PreferenceService.ts)展示了其在生命周期中的位置:

@Injectable('PreferenceService') @ServicePhase(Phase.BeforeReady) @DependsOn(['DbService']) export class PreferenceService extends BaseService {
  • Phase.BeforeReady:在应用 Ready 之前完成初始化,保证窗口创建后立即可读;
  • @DependsOn(['DbService']):依赖数据库服务先行就绪。

4.2 统一键路由:resolveKey

主进程是统一偏好 API 的唯一入口闸门。resolveKey()(PreferenceService.ts)把每个键路由到对应存储:

存储
普通生成键,如ui.theme_modeSQLite preference 行
公开BootConfig.app.*文件后端bootConfigService
内部BootConfig.temp.*在统一 Preference 边界直接拒绝

路由规则实现在 src/shared/data/preference/preferenceUtils.ts:isBootConfigKey检测BootConfig.前缀;isPublicBootConfigKey依据DefaultBootConfig自动派生的白名单过滤掉temp.*内部状态与未知键;getDefaultValue则同时覆盖 DB 键与 BootConfig 键的默认值查询。

setMultiple会先解析并校验所有键再执行任何写入(PreferenceService.ts):批次中若混有内部键会被原子性拒绝;随后 BootConfig 写入与 SQLite 事务是两套独立存储,因此并非一次跨存储的原子提交。另外,主进程对未变化的键会跳过数据库写入(isEqual比较),并只在成功后发布通知。

4.3 主进程订阅与资源释放

主进程订阅签名与渲染进程不同:subscribeChange(key, callback)。生命周期服务必须把返回的 disposable 注册到自身生命周期,以便服务停止时自动释放:

this.registerDisposable( preferences.subscribeChange('ui.theme_mode', (theme) => { logger.info('Theme changed', { theme }) }) )

主进程还维护了窗口级订阅表windowSubscriptions: Map<windowId, Set<keys>>notifyChange会精确推送给订阅了该键的窗口;对已销毁的窗口自动清理订阅(setupWindowCleanup每 5 分钟巡检一次,见 PreferenceService.ts)。广播刻意不排除写入方窗口——写入方通过渲染侧onChanged的深比较去重,从而在多窗口并发写竞争下保证各窗口缓存与数据库最终一致。

五、失败语义(Failure Semantics)

综合两个进程的实现,Preference 的失败行为可归纳为四条明确规则:

  • 乐观写入:渲染进程立即更新本地缓存并通知 React;若主进程拒绝写入,则回滚到受保护的原始值;
  • 悲观写入:主进程确认持久化之前,旧缓存值保持可见,UI 不会呈现未确认的"已保存"状态;
  • 批量乐观回滚:批次内任一键失败,恢复该批次中每一个受影响键的原始值;
  • Hook setter 重新抛出异常usePreferenceuseMultiplePreferences的 setter 捕获错误并throw,由调用方决定如何向用户提示。

六、新增一个 Preference 键的正确姿势

绝不直接编辑生成文件src/shared/data/preference/preferenceSchemas.tsPreferenceSchemas接口与DefaultPreferences对象均为自动生成)或DefaultPreferences。正确流程是修改生成器输入并重新生成,详见 Preference Schema Guide:

  1. v2-refactor-temp/tools/data-classify/data/target-key-definitions.json(新 v2 设置)或classification.json(简单 v1→v2 映射)中添加条目,status必须为"classified"
  2. 键名遵循namespace.category.key_name规范(至少两段小写点分、下划线分词,由data-schema-key/valid-keylint 规则强制);
  3. 共享的联合类型、枚举、品牌类型放入preferenceTypes.ts,生成器输入中以PreferenceTypes.X引用;
  4. v2-refactor-temp/tools/data-classify目录运行生成管线:
cd v2-refactor-temp/tools/data-classify npm run generate

该命令会同步重新生成四个耦合产物:preferenceSchemas.tsbootConfigSchemas.tsPreferencesMappings.tsBootConfigMappings.ts。之后即可通过正常 API 消费新键:

import { usePreference } from '@data/hooks/usePreference' const [enabled, setEnabled] = usePreference('feature.my_feature.enabled')

修改键后运行pnpm lint,它会检查生成类型、键命名、格式以及所有 Preference 调用点。

七、调试与安全

  • 统计接口:主进程getStats(details?)报告键数与订阅数(见 PreferenceService.ts)。摘要形式含总键数、主进程订阅数、窗口订阅数与活动窗口数;details: true的详细形式包含逐键订阅数据,适合诊断场景,且源码注明该接口资源开销较大、建议仅在开发环境使用;
  • IPC 安全:所有Preference_*IPC 入口(Preference_GetPreference_SetPreference_GetMultipleRawPreference_SetMultiplePreference_GetAllPreference_Subscribe)都经assertTrustedSendervalidateSender源信任校验(PreferenceService.ts),拒绝不受信任发送者;渲染进程代码应始终使用服务与 Hook,而不是直接调用这些通道;
  • 渲染侧调试:服务暴露getPendingOptimisticUpdates()查看所有未确认的乐观更新,以及preloadAll()/isFullyCached()用于启动预热与状态查询。

八、小结

Cherry Studio 的 Preference 系统在三个层次提供了自洽的访问方式:React 组件用 Hook(自动管理订阅与默认值)、非 React 渲染代码用单例 service(支持乐观/悲观策略与柯里化订阅)、主进程用生命周期服务(同步读、异步写、跨窗口广播)。理解resolveKey的 BootConfig 路由与乐观更新的回滚机制,能帮助你在接入主题、语言、字号、功能开关等设置时写出既响应迅速又行为正确的代码。进一步阅读可参考 Preference Overview、Preference Schema Guide 与 Data System Reference。

【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 11:58:36

FPGA与DSP专用低噪声LDO供电设计指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 11:58:12

OpenClaw:本地AI服务中枢,打通Discord/Telegram与Qwen2

1. 项目本质与真实价值再定义&#xff1a;这不是“替代ChatGPT”&#xff0c;而是构建你自己的AI服务中枢OpenClaw这个名字最近在技术圈里传得挺快&#xff0c;但很多人一看到标题里写着“零成本私有化”“永久免费替代ChatGPT”&#xff0c;就下意识以为这是个能一键装上、马上…

作者头像 李华
网站建设 2026/9/20 11:57:45

QQ空间历史说说备份指南:GetQzonehistory 三步导出到本地

QQ空间历史说说备份指南&#xff1a;GetQzonehistory 三步导出到本地 【免费下载链接】GetQzonehistory 获取QQ空间发布的历史说说 项目地址: https://gitcode.com/GitHub_Trending/ge/GetQzonehistory 深夜想翻三年前旅行时发的一张照片&#xff0c;空间时间线却越刷越…

作者头像 李华
网站建设 2026/9/20 11:57:31

LLM代码执行安全沙箱agentguard:架构设计与工程实践

1. 为什么LLM代码执行必须要有沙箱1.1 从一次真实的翻车现场说起去年下半年我参与了一个内部工具链项目&#xff0c;核心逻辑是让大语言模型根据用户的自然语言描述自动生成数据处理脚本&#xff0c;然后直接在服务器上跑出结果。听起来很美好对吧&#xff1f;用户说一句“帮我…

作者头像 李华
网站建设 2026/9/20 11:55:35

AI降重工具对比:千笔与SpeedAI的技术解析与应用

1. 项目背景与核心价值在当今内容创作领域&#xff0c;AI辅助工具已经深度渗透到写作、设计、编程等各个环节。但随之而来的问题是&#xff0c;过度依赖AI生成的内容往往缺乏个性化和专业深度&#xff0c;特别是在学术、技术文档等需要体现个人专业能力的场景中。这就是"降…

作者头像 李华