ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【时光清单|09】HarmonyOS ArkTS 缓存管理实战:控制过期、清理与读取失败兜底

【时光清单|09】HarmonyOS ArkTS 缓存管理实战:控制过期、清理与读取失败兜底 【时光清单09】HarmonyOS ArkTS 缓存管理实战控制过期、清理与读取失败兜底缓存管理最危险的误区是把“能从 Preferences 读写值”直接等同于“已经有缓存系统”。真正的缓存至少要回答四个问题值属于谁、何时失效、读取失败返回什么、用户清理后会不会误删业务数据。如果缓存和持久化数据共用一个存储实例又没有命名空间与过期策略最终很容易把旧结果当作真数据或者在“清理缓存”时把用户纪念日一起清掉。时光清单的真实源码提供了一个独立CacheManager它使用名为app_cache的 Preferences封装字符串、数字、布尔值的读取与写入并提供remove()和clear()。读取异常或尚未初始化时返回调用方给定的默认值写入后执行flush()。但从当前项目引用关系看这个类还没有接入业务页面源码也没有 TTL、过期时间、容量控制或缓存键规范。因此本文将严格区分现状与改造先复核当前封装的初始化、类型接口、默认值和清理行为再在不改变其轻量定位的前提下设计 TTL 包装、懒清理、批量清理、错误可观测性和缓存/业务数据边界。重点不是造一个复杂框架而是让缓存“旧了能失效、坏了能降级、删了不伤数据”。本文将完成这些可复核分析说明app_cache与timelist_data为什么必须分开。还原getString/getNumber/getBoolean的失败兜底。分析flush()、初始化和静默失败的真实影响。明确当前源码没有 TTL也没有业务接入。设计带版本和过期时间的缓存信封。给出清理、并发、测试和发布审核边界。本文唯一标记CSDN-SERIES:ALL-163207895一、缓存与持久化不是同一个概念项目中有两个基于 Preferences 的封装CacheManager store name: app_cache 目标可丢弃的字符串、数字、布尔缓存 DataStore store name: timelist_data 目标纪念日、愿望、日记、习惯等持久化业务数据两者底层技术相同但数据承诺不同。维度缓存业务持久化是否可删除可以通常不可以读取失败回源或默认值应提示或保护数据是否需要过期通常需要由业务生命周期决定清理入口可以提供“一键清理”需要独立确认数据真源否是CacheManager使用独立的app_cache这是很重要的安全边界。未来实现“清理缓存”时只清空这个 Preferences不会直接触碰timelist_data。二、初始化未就绪时选择默认值当前类保存一个可空 Preferences 引用export class CacheManager { private store: preferences.Preferences | null null; async init(context: Context): Promisevoid { try { this.store await preferences.getPreferences( context, app_cache ); } catch (e) { hilog.error( 0x0000, TAG, init failed: ${JSON.stringify(e)} ); } } }初始化失败不会抛给调用方而是保留store null。随后所有读取返回默认值写入直接返回。这符合“缓存失败不阻断核心功能”的原则但也带来一个要求业务不能依赖缓存作为唯一数据源。例如首页欢迎语可以缓存缓存失效后重新计算用户写下的纪念日不能只放在缓存中否则初始化失败会让数据表现为丢失。三、三个类型接口的真实行为字符串读取async getString( key: string, defaultVal: string ): Promisestring { if (!this.store) return defaultVal; try { const val await this.store.get(key, defaultVal); return val as string; } catch (_) { return defaultVal; } }数字和布尔接口结构相同async getNumber( key: string, defaultVal: number 0 ): Promisenumber { if (!this.store) return defaultVal; try { const val await this.store.get(key, defaultVal); return val as number; } catch (_) { return defaultVal; } } async getBoolean( key: string, defaultVal: boolean false ): Promiseboolean { if (!this.store) return defaultVal; try { const val await this.store.get(key, defaultVal); return val as boolean; } catch (_) { return defaultVal; } }这些方法提供两个兜底点存储尚未初始化立即返回默认值。Preferences 读取抛出异常捕获后返回默认值。调用方无需为非关键缓存显示错误页面但默认值必须具有明确语义。0可能表示“缓存值确实是零”也可能表示“没有缓存”。需要区分时应返回可空类型或结果对象而不是依靠默认值猜测。四、类型断言并不验证实际类型return val as number只做静态断言。如果同一个 key 曾写入字符串读取为数字时未必得到预期结果。应在运行时检查const val await this.store.get(key, defaultVal); return typeof val number ? val : defaultVal;字符串和布尔值同理。缓存键一旦改变类型最好提升 key 版本class CacheKeys { static readonly HOME_STATS_V1 home_stats_v1; static readonly LAST_QUOTE_ID_V2 last_quote_id_v2; }键名版本比“尝试把旧字符串转成新对象”更简单旧缓存可以在后台自然淘汰。五、写入put 后立即 flush字符串写入async setString( key: string, value: string ): Promisevoid { if (!this.store) return; try { await this.store.put(key, value); await this.store.flush(); } catch (e) { hilog.warn( 0x0000, TAG, setString failed: ${JSON.stringify(e)} ); } }每次put()后都flush()能够让值尽快落盘但高频场景会增加 I/O。缓存如果只在页面退出、数据请求完成或设置变化时写入当前方式足够直接如果滚动、输入或动画过程中频繁更新就应该合并写入。例如搜索联想结果不应每输入一个字符就同步 flush。可以只缓存最终查询或者做短延时合并。性能优化的前提是实际写入频率而不是看到flush()就盲目去掉。当前三个写入方法都返回Promisevoid调用方只能知道异步方法已经结束无法从返回类型区分“成功落盘”“store 尚未初始化所以直接返回”与“put 或 flush 失败后被捕获”。对缓存来说写失败通常不应阻断业务但仍需要一个可观测结果否则设置页无法决定是否显示清理成功服务层也无法统计缓存长期失效。建议让底层返回written、not_ready或storage_error并由上层决定是否忽略不要为了追求强一致性把缓存写失败升级成业务数据保存失败。还要区分put()完成与flush()完成的含义。前者修改 Preferences 中的值后者请求持久化当前实现把两步放在同一个try中只要任一步抛错都会记录同一条警告无法判断失败阶段。若诊断确有必要可以分别捕获或在错误结果中记录阶段但日志仍只应包含键名、操作和错误类别不应输出缓存正文。本文没有执行故障注入不能推断哪一步在当前设备上更容易失败。批量写入也不应简单改成“永远延迟 flush”。应用进入后台、进程被系统回收或用户刚完成关键设置时尚未提交的缓存可能丢失。更稳的做法是为高频非关键缓存提供短窗口合并为明确的用户动作保留立即提交并在生命周期结束前尽力冲刷等待队列。是否值得实现要由写入频率、缓存重建成本和测量数据决定当前源码没有批处理队列这里只给出演进边界。六、remove 与 clear 的不同风险单键删除async remove(key: string): Promisevoid { if (!this.store) return; try { await this.store.delete(key); await this.store.flush(); } catch (_) {} }全量清理async clear(): Promisevoid { if (!this.store) return; try { await this.store.clear(); await this.store.flush(); } catch (_) {} }remove()适合某个数据域失效例如纪念日变化后删除首页统计缓存clear()适合用户明确选择“清理缓存”或应用 schema 大版本升级。全量清理不能被普通页面生命周期自动调用。否则每次进入页面都清空缓存缓存层形同虚设。也不能把clear()用作退出登录的全部清理逻辑因为账号凭据、用户数据和缓存通常属于不同存储域。当前调用关系与历史证据必须分开本轮对entry/src/main/ets做了聚焦引用搜索除CacheManager.ets自身外没有找到导入、实例化或方法调用。EntryAbility启动时初始化的是DataStoreAnniversaryRepository持久化时调用的也是DataStore.putJson()这两个路径使用timelist_data并不经过app_cache。因此可以确认工具类存在也可以确认它具备原始类型读写与清理接口但不能把它描述成已经为首页、纪念日或语录提供缓存加速。源码中的“可用类”与产品中的“已接入能力”是两个不同层次的事实。仓库错误记录提供了另一组历史证据。2026-05-20的记录提到情绪背景异步读取时可能先显示默认值启动回填与默认初始化也可能造成视觉上的恢复默认当时的修复包括在设置页优先同步读取、在EntryAbility同步初始化DataStore并回填MOOD_BACKGROUND记录中还保留了一次历史assembleHap成功。当前源码确实能看到这些同步优先与启动回填代码所以“修复思路仍在源码中”可以证明但那次构建属于历史记录本轮没有重新构建、安装或真机验证不能写成当前版本已经通过。这段历史也解释了为什么缓存就绪状态不能靠调用顺序暗示。若页面在初始化完成前读取默认值可能只是暂时结果若页面把暂时结果当成真实设置再写回就会把持久值覆盖掉。建议由应用服务统一持有初始化 Promise页面得到明确的 ready、failed 或 degraded 状态再决定展示占位、继续回源还是接受默认值。七、当前源码没有 TTL文章标题中的“控制过期”是工程目标不是对现状的虚构。当前CacheManager保存的是裸值key - string key - number key - boolean没有保存写入时间过期时间schema 版本数据来源是否允许陈旧读取。所以只要 key 仍存在它就会一直返回。要支持 TTL需要为值增加缓存信封。八、设计通用缓存信封可以把复杂缓存序列化为 JSONinterface CacheEnvelopeT { version: number; createdAt: number; expiresAt: number; value: T; }写入时明确 TTLasync setJsonT( key: string, value: T, ttlMs: number ): Promisevoid { const now Date.now(); const entry: CacheEnvelopeT { version: 1, createdAt: now, expiresAt: now ttlMs, value }; await this.setString( key, JSON.stringify(entry) ); }ttlMs必须校验为有限正数。永不过期数据不应该借用一个极大数字最好单独定义策略type CachePolicy | { mode: ttl; ttlMs: number } | { mode: session } | { mode: manual };这样调用点能表达意图而不是留下无法解释的86400000。九、读取时做“懒过期”读取 JSON 缓存时先解析、再验证async getJsonT( key: string ): PromiseT | null { const raw await this.getString(key, ); if (raw.length 0) return null; try { const entry JSON.parse(raw) as CacheEnvelopeT; if (!this.isEnvelope(entry)) { await this.remove(key); return null; } if (Date.now() entry.expiresAt) { await this.remove(key); return null; } return entry.value; } catch (_) { await this.remove(key); return null; } }这叫懒过期只有读取到某个 key 时才检查并删除。对小型本地缓存它比启动时扫描全部键更简单也不会为了清理几个小值拖慢首帧。十、缓存未命中后的回源完整读取链路应该是async getOrLoadT( key: string, ttlMs: number, loader: () PromiseT ): PromiseT { const cached await this.getJsonT(key); if (cached ! null) return cached; const fresh await loader(); await this.setJson(key, fresh, ttlMs); return fresh; }但这里要区分错误缓存读取失败忽略并回源。回源失败由业务决定显示旧值、默认值还是错误态。缓存写入失败仍可返回刚刚获得的新值。缓存层不能把回源错误吞掉否则页面只看到空列表不知道真实数据加载失败。十一、读取失败兜底不能掩盖长期故障当前读取方法catch (_) { return defaultVal; }很安静。偶发缓存错误不应打断业务但持续失败需要可观测。推荐采用低噪声日志catch (e) { hilog.warn( 0x0000, TAG, get cache failed, key%{public}s, key ); return defaultVal; }不要把缓存值写入日志值可能包含用户偏好或业务摘要。也不要在每次未命中都记错误“不存在”是正常状态只有 API 异常、解析失败和类型错误才需要诊断。一个默认值当前合并了多种完全不同的状态以getNumber(count, 0)为例返回0至少可能表示五件事缓存真实保存了零、key 从未存在、CacheManager尚未初始化、初始化已经失败、Preferences 读取抛出异常。若未来增加 JSON 信封还会多出解析失败、版本不支持、类型不匹配和已过期。把这些情况全部压成一个数字虽然便于展示却让调用方无法决定是否回源、是否删除坏值、是否提示错误以及是否记录诊断。更稳的接口可以返回判别联合type CacheReadResultT | { state: hit; value: T } | { state: miss } | { state: expired } | { state: not_ready } | { state: invalid } | { state: storage_error };页面不必直接处理所有状态服务层可以把miss、expired和invalid统一转成回源把not_ready转成等待初始化把storage_error转成“继续使用刚取得的新数据但不落缓存”。关键是底层先保留事实上层再按业务语义折叠而不是在最底层过早丢失原因。当前源码仍返回默认值以上结果对象是建议实现。十二、初始化竞态当前调用方必须先await cache.init(context)否则读取立即返回默认值、写入直接丢弃init 尚未完成 - setString() - store 仍为 null - 直接 return - 本次缓存写入丢失可以像项目DataStore一样保存初始化 Promiseprivate initPromise: Promisevoid | null null; init(context: Context): void { if (this.initPromise ! null) return; this.initPromise this.doInit(context); } private async ensureReady(): Promisevoid { if (this.initPromise ! null) { await this.initPromise; } }每个公开方法先await ensureReady()。这样调用方不必精确协调启动时序。若初始化最终失败读取仍降级、写入仍不阻断核心功能。十三、并发回源与请求合并两个页面同时读取同一个过期 key 时可能同时执行 loader造成重复计算或重复请求。可维护进行中的 Promiseprivate pending: Mapstring, PromiseObject new Map();第一次未命中时注册 loader Promise后续相同 key 等待它。完成后删除 pending。这个优化只适用于回源成本明显的场景当前项目是本地离线应用CacheManager也尚未接入业务不需要提前引入复杂泛型并发层。文章给出它是为了说明扩展边界不是宣称源码已有请求合并。十四、缓存键必须集中管理散落字符串容易冲突await cache.setString( home_data, value );不同模块可能都使用home_data类型和 TTL 却不同。建议集中定义export class CacheKeys { static readonly HOME_SUMMARY_V1 home.summary.v1; static readonly DAILY_QUOTE_V1 quote.daily.v1; static readonly THEME_PREVIEW_V1 theme.preview.v1; }键名包含领域和版本清理时也能按数据域管理。Preferences 是否支持枚举与批量删除要以当前 SDK API 为准如果不便前缀清理可维护一个缓存索引。十五、不同数据需要不同 TTLTTL 应根据“陈旧的代价”和“重建成本”选择数据建议策略原因今日语录选择结果到次日边界日期变化后应更新首页统计数据变更时主动删除不应依赖固定时间主题预览计算手动或版本失效源数据很少变化临时搜索结果短 TTL查询变化快用户纪念日不应作为缓存真源必须进入 Repository“数据变化时主动删除”比给首页统计设置五分钟 TTL 更准确。纪念日新增、删除或置顶成功后调用remove(HOME_SUMMARY_V1)下次进入首页再重新计算。过期时间依赖设备时钟边界必须写清楚Date.now()适合表达本机墙上时钟但用户手动改时间、系统校时或跨时区使用都会改变它与expiresAt的比较结果。对于“缓存十分钟”这类短时长策略时钟回拨可能让条目存活更久时钟前跳可能让它立刻过期对于“每日语录到次日失效”则需要按产品采用的本地日历边界重新计算下一次零点而不是固定加二十四小时。文章不能只写一个时间戳就假设所有时间语义相同。边界建议统一为now expiresAt即过期避免同一毫秒在不同方法中得到相反结果。写入时还要拒绝NaN、无穷大、零和负数 TTL防止生成永不命中或永不清理的异常条目。如果设备时间变化对业务影响较大可以把Clock注入缓存策略并在测试中模拟前跳、回拨和跨日这仍是建议测试方法不代表当前工具已经处理系统时钟变化。十六、陈旧可用策略部分场景可以接受短暂旧值。可以扩展信封interface CacheEnvelopeT { version: number; createdAt: number; expiresAt: number; staleUntil: number; value: T; }读取结果区分type CacheReadResultT | { state: fresh; value: T } | { state: stale; value: T } | { state: miss };页面可先展示 stale 值再后台回源。对于倒计时数字或用户刚编辑的统计这种策略可能产生误导不应使用。缓存策略必须由业务正确性决定。十七、容量控制与清理Preferences 适合轻量键值不适合大图片、长列表或无限增长的响应缓存。当前接口只支持三种标量这是合理限制。扩展 JSON 后需要设置边界单项序列化长度上限总 key 数量预算不缓存图片二进制不缓存完整相册schema 升级清理旧版本用户清理后展示释放结果。如果需要缓存文件应使用cacheDir或专门文件目录并建立文件索引、大小统计和淘汰策略而不是把大内容塞进 Preferences。十八、clear 之后要刷新内存状态当前CacheManager本身没有内存缓存因此clear()后 Preferences 就是唯一变化。未来若增加内存 Map清理必须同时清除async clear(): Promisevoid { this.memory.clear(); await this.store?.clear(); await this.store?.flush(); }页面如果正在展示缓存派生结果也应收到失效信号。可以复用专门的缓存版本号或由对应 ViewModel 重载。不要用业务DATA_VERSION表示所有缓存变化除非清理确实影响业务页面数据。十九、敏感信息不应进入普通缓存缓存是可清理数据不代表可以随意放任何内容。以下内容不应使用普通 Preferences 明文缓存登录凭据支付信息身份证件私密密钥可恢复完整用户数据的令牌。时光清单当前是本地应用CacheManager也没有业务调用。未来缓存关系数据摘要时应坚持最小化原则不把完整情侣留言或私人备注复制到缓存。二十、测试矩阵当前接口初始化成功后可写入并读取三种类型。key 不存在返回调用方默认值。未初始化时读取返回默认值。写入后重建实例仍可读取。remove()只删除目标 key。clear()清空app_cache不影响timelist_data。TTL 扩展未过期返回 fresh。边界now expiresAt判定过期。过期值被懒删除。JSON 损坏返回 miss 并删除。schema 版本不支持时返回 miss。系统时间变化时行为可解释。错误与性能初始化失败不阻断主流程。flush 失败不覆盖新鲜业务结果。高频写入经过合并。大对象被上限拒绝。日志不包含缓存内容。可通过注入时钟测试 TTL而不是在测试里真实等待interface Clock { now(): number; }生产实现返回Date.now()测试实现可以精确推进到过期边界。二十一、发布审核与用户文案清理缓存页面必须准确“清理缓存”不能删除纪念日、愿望、日记等业务数据。清理前可展示缓存大小没有真实统计就不要伪造。清理成功后不要显示虚假释放容量。失败时允许重试不要卡住设置页。离线应用不能因为缓存封装引入隐藏网络请求。隐私政策应与实际存储和清理行为一致。当前CacheManager独立使用app_cache为安全清理提供了良好基础但在业务未接入前界面也不应宣称已有智能缓存加速或自动过期。二十二、渐进改造路线基于现有代码建议将 CacheManager 建为单例或由应用服务统一持有。增加initPromise和ensureReady()。为三种读取加入运行时类型检查。集中定义缓存键和版本。新增CacheEnvelopeT与 JSON API。支持 TTL 与懒删除。在明确业务场景中接入而不是全量缓存。为高频写入增加合并策略。为清理操作增加真实统计和结果状态。保持app_cache与timelist_data隔离。二十三、总结当前CacheManager的真实能力是初始化独立 app_cache Preferences - 读写 string / number / boolean - 读取失败回默认值 - 写入后 flush - 支持单键删除与全量清理它是一个清晰的轻量起点但还不是完整缓存策略层没有 TTL、类型运行时校验、初始化等待、容量控制也尚未接入项目业务。可靠扩展的关键不是增加更多getXxx()而是建立缓存信封、键版本、过期语义和回源边界。对 HarmonyOS 本地应用而言缓存管理的合格标准可以浓缩为一句话缓存永远可丢过期后不冒充新数据读取失败能安全回源清理操作绝不触碰用户业务真源。本文基于时光清单项目的CacheManager.ets与DataStore.ets真实源码复核整理。文中明确标注当前实现与建议扩展未将 TTL、业务接入或容量统计描述为已实现能力。AI 辅助声明本文在真实源码核验、结构梳理和文字编辑过程中使用了 AI 辅助关键接口、引用关系和工程结论均以项目源码为依据进行人工复核。
返回列表