ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenHarmony与React Native:用自定义Hook实现会话级存储useSessionStorage

OpenHarmony与React Native:用自定义Hook实现会话级存储useSessionStorage 1. 先说背景OpenHarmony 上的 React Native 到底缺了什么我一直觉得OpenHarmony React Native 这个组合目前最大的问题不是能不能跑而是跑起来之后缺的东西太多。官方对标 iOS 和 Android 的能力大部分要靠自己用 NativeModule 补。像 openharmony camera、调用电话这类功能网上还能找到不少桥接案例但如果是做业务开发最常遇到的其实是那些不起眼的小需求比如临时把数据跨页面共享一份关掉 App 下次打开就不要了。这就是典型的 sessionStorage 场景。Web 端用window.sessionStorage顺手就写了但 RN 端没有这个 APIOpenHarmony 端也没有直接等价的东西于是就得自己做一个useSessionStorage自定义 Hook。这个需求不是少数人碰到的。比如你做一个多步表单第一步填资料第二步传照片第三步确认用户中途切到别的页面再回来这些临时数据不能丢又不能动不动就往数据库或者持久化存储里写一套。再比如做登录态里的非持久令牌、页面间传递的草稿内容、列表筛选条件都是同一类问题要跨组件共享但生命周期只要跟当前 App 会话走。用组件 state 存页面一跳就没了用 Preferences 持久化App 杀掉再打开还在又得手动清理稍不留意就留下一堆脏数据。这篇文章我会从方案选型、原生模块封装、React Hook 实现到真机验证、踩坑排查完整讲一遍。新手可以照着抄老手可以直接跳到第 5 节的排查速查表。需要说明的是文中代码基于 react-native-openharmonyRNOH的常见接入方式手头 SDK 版本不同时模块注册写法会有细节差异但整体思路是通用的。OpenHarmony 本身的基层语言是 C/C应用侧是 ArkTS/ArkUI跟 RN 打交道的关键就是桥接层理解了这一层这类自定义能力你都能自己做。1.1 OpenHarmony RN 生态的现状和痛点先说清楚现状OpenHarmony 上的 React Native 不是一个官方出品的东西而是社区移植维护的项目所以你不该期待它跟 iOS/Android 上的 RN 一样开箱即用。跑个 Hello World 没问题但一旦涉及设备能力、系统存储、生命周期感知就需要通过原生模块把 ArkTS 的能力暴露给 JS 侧。我最早在香橙派 5 Pro 上跑 OpenHarmony 的时候就明显感觉到这个半成熟状态JS 侧能调的 API 有限很多 Web 开发者的习惯要改。Web 开发者在浏览器里写sessionStorage.setItem(token, x)是条件反射但在 RN 里没有 window在 OpenHarmony 的 ArkTS 里也没有标签页会话这个概念所以必须自己造。这个生态还有一个特点桥接方式很统一。无论你是要摄像头能力、要打电话还是要读写存储本质都是ArkTS 写一个模块暴露给 JS 调用。所以useSessionStorage虽然小但它其实是 OpenHarmony RN 开发里最典型、最值得做一遍的桥接范式。做完了这个后面再做文件读写、剪贴板、传感器之类的原生桥接套路一模一样。1.2 sessionStorage 在移动端为什么不是标配浏览器里的 sessionStorage 有三个特性一是数据在同标签页下共享二是标签页关了数据就没了三是容量比 localStorage 小但够用。这三个特性根植于浏览器的标签页 会话模型移动端 App 没有标签页你也不能拿关闭页面作为清理数据的时机。那移动端 App 的会话应该怎么定义最贴切的其实是 App 进程的一次存活周期App 从启动到被杀掉算一个会话进程被杀、下次冷启动算是新会话。这样一来登录后保存在内存里的临时令牌向导页的进度多步骤表单草稿就有了统一解释这些数据在本次运行期间应该一直可读但 App 一杀最好自动消失别留痕迹。为什么不用已有的存储方案Preferences 和 RelationalStore 都是持久化的适合存下次打开还要用的东西硬拿来做临时会话数据不是不行但你得专门维护一套清理逻辑。组件 state 则完全没有跨页面能力页面销毁状态就没了。所以中间的会话级存储成了空白这就是useSessionStorage存在的意义。1.3 一个自定义 Hook 要解决的三大问题第一数据放哪。我的选择是原生侧内存 Map理由后面详细说。第二怎么跨组件同步。一个页面写了数据另一个已经在栈里的页面要马上感知变化不能靠 每次从 storage 里读一次 这种粗暴方式。第三什么时候清理。要保证冷启动后数据一定不存在否则就不配叫会话存储。这三个问题对应到代码里分别是原生模块设计、事件总线设计、生命周期设计。接下来我按设计、实现、验证、排障的顺序逐步展开。2. 落地方案设计先想清楚再写代码这类自定义桥接最容易犯的错就是一上来写代码写到一半才发现 API 形状不对、存储选型不合适、跨组件同步没考虑。我先花点篇幅讲设计这部分想清楚了后面就是填代码的事。2.1 API 设计向 Web 标准看齐还是按需裁剪我的建议是向 Web 标准看齐。Web 开发者对sessionStorage.getItem、setItem、removeItem、clear这套 API 太熟了RN 团队里如果之前做过前端几乎零学习成本。所以我保留了这四个基础方法另外暴露一个keys()方便调试。Hook 层则对齐社区里useLocalStorage的常见形态const [value, setValue, remove] useSessionStorageT(key, initialValue);value是当前读到的值setValue支持直接传值也支持传函数基于旧值更新remove用来删除当前 key。为什么非要拆成三层而不是一个函数搞定因为原生模块方法要稳定、通用适合做成命令式 APIHook 要贴合 React 组件模型让使用方不用关心订阅、初始化这些细节。命令式 API 和声明式 Hook 各司其职后面排查问题也方便先测原生模块再测 Hook边界很清楚。2.2 存储后端选型纯内存还是 Preferences这是整个方案里最需要想清楚的一步我对比了三套方案方案优点缺点适用场景ArkTS 内存 Map读写快、零磁盘开销、进程死即清空App 杀掉数据没了临时草稿、本次运行内的登录令牌ohos.data.preferences持久化、可跨冷启动需要手动清理容易留脏数据真正的登录状态、用户偏好内存为主 关键字段额外持久化双保险实现复杂清理逻辑容易出错有强制恢复需求的业务我的选择是方案一原生侧搞一个单例 Mapkey 加前缀rn:session:value 统一存序列化后的字符串。原因有几个。第一性能足够好Map 原生查找是 O(1)不需要 I/O不会卡 UI。第二生命周期天然正确OpenHarmony 的进程被杀时内存里的数据跟着蒸发不需要我写任何清理代码。第三隐私安全更好临时数据根本不落盘省去加密和擦除的麻烦。如果你要存的是杀掉 App 再打开还得在的东西比如用户登录态那就不该用这个 Hook应该用自己的 Preferences 封装。这里尤其提醒一句不要把两个概念混在一起。我见过有人在同一个 key 上既做会话存储又做持久化结果用户退出登录后老数据还残留排查半天。会话是会话持久化是持久化分开存放。2.3 数据结构、序列化与幂等性设计原生 Map 的 value 是字符串所以 JS 侧传对象时必须序列化。序列化方案很明确JSON.stringify和JSON.parse。没有比这更通用、更省事的选择了你要是上 protobuf 或者手写二进制纯属过度设计。但序列化有几个坑必须提前堵住。第一个是undefinedJSON.stringify(undefined)返回的不是字符串而是 undefined 本身这会导致原生层收到一个空值。所以我在 Hook 里约定如果新值是undefined视为删除操作直接调removeItem。第二个是NaN和InfinityJSON.stringify(NaN)会变成null读出来就不是原来的值了。解决办法是业务侧尽量避免在会话存储里放非有限数值非放不可就自己包一层校验。第三个是大对象会话数据虽然该死得快但如果业务在一个 key 里塞了几 MB 的东西每次 setValue 都全量序列化主线程会感受到明显卡顿。可以按字段拆分 key也可以引入节流。另外要设计好命名空间。原生侧所有 key 都加上rn:session:前缀避免跟 App 内其他原生存储逻辑冲突。这个前缀在 Keys() 返回时再剥掉JS 侧完全感知不到。2.4 组件间同步机制事件总线还是轮询跨组件同步是这类 Hook 里最容易翻车的地方。你可能会想不就是写一个 storage然后其他组件重新读一遍吗问题在于你怎么知道什么时候该重新读。轮询是最差方案每 500ms 读一次浪费性能不说更新还有延迟。我更倾向于用一个简单事件总线任何组件调用 setValue 或 remove 后发布一个事件订阅了同一个 key 的组件收到事件后重新从原生模块拉最新值再 setState。为什么不用 React Context一个大 App 里的页面不是始终被同一个 Provider 包着的你不可能为了一个临时存储把整个导航树都改造一遍。而事件总线是扁平机制任何组件只要能订阅就行跟组件树一毛钱关系都没有所以它是这类场景的标准解。如果以后遇到多个 RN 实例或者原生侧也会改存储的情况事件总线就不够用了得让原生模块主动发事件JS 侧用NativeEventEmitter接收。这个我在第 5 节会讲怎么升级但大多数 App 场景下JS 事件总线已经足够。3. 核心实现从原生模块到 React Hook 全流程设计定完了开始写码。整个链路分三段ArkTS 原生模块、模块注册、JS 侧 Hook。我按依赖顺序来你先能跑通原生再谈 Hook。3.1 原生侧封装SessionStorageModule 的 ArkTS 实现原生模块用 ArkTS 写核心就是一个带前缀的 Map。这里注意 ArkTS 的语法约束比标准 TypeScript 严格不要用any泛型和空值判断要写得明确。下面是一个可以直接落地的版本// SessionStorageModule.ets import { NativeModule } from react-native-openharmony; const SESSION_PREFIX rn:session:; export class SessionStorageModule extends NativeModule { private sessionData: Mapstring, string new Map(); getItem(key: string): string | null { const fullKey SESSION_PREFIX key; return this.sessionData.get(fullKey) ?? null; } setItem(key: string, value: string): void { if (value null || value undefined) { this.removeItem(key); return; } this.sessionData.set(SESSION_PREFIX key, value); } removeItem(key: string): void { this.sessionData.delete(SESSION_PREFIX key); } clear(): void { this.sessionData.clear(); } keys(): string[] { return Array.from(this.sessionData.keys()) .filter((k) k.startsWith(SESSION_PREFIX)) .map((k) k.substring(SESSION_PREFIX.length)); } }几个实现细节说明一下。getItem返回string | null语义和浏览器一致查不到就返回 null千万别返回空字符串不然 JS 侧没法区分没有这个 key和存了个空值。setItem里对 null 和 undefined 做了兼容处理直接走删除逻辑这样 JS 侧即使误传了空值也不会留下一个undefined字符串在 Map 里。keys()的过滤逻辑保证了你只能看到会话存储自己的 key不会把别的模块塞进来的东西一起带出去。这里还有个容易被忽略的点ArkTS 的 Map 是强类型的Mapstring, string不能往里面塞对象。所以原生侧只管字符串所有对象的序列化必须在 JS 侧完成。这个边界划清楚后面排查类型问题就会容易很多。3.2 模块注册与生命周期绑定写好的模块要注册到 RNOH 的包管理里。RNOH 里一般通过自定义RNPackage来注册原生模块// SessionStoragePackage.ets import { RNPackage, TurboModuleCtx } from react-native-openharmony; import { SessionStorageModule } from ./SessionStorageModule; export class SessionStoragePackage extends RNPackage { createNativeModules(ctx: TurboModuleCtx): unknown[] { return [new SessionStorageModule(ctx)]; } }然后在 App 入口配置里把包加进去。不同版本写法略有差异但核心逻辑就是把SessionStoragePackage实例塞到RNInstances的 package 列表里。这一步做完JS 侧通过NativeModules.SessionStorageModule就能拿到模块实例。有个生命周期提醒这个模块不要做成每次调用都新建实例。RNOH 会为注册过的 NativeModule 维护单例所以它天然是全局共享的这正好符合会话存储整个 App 期间都可用的需求。如果你在 Hook 内部自己new一个模块就会产生多个互相隔离的 Map数据各写各的——这是我实际见过最隐蔽的 bug 之一。3.3 Hook 实现状态初始化、更新与跨组件同步Hook 的核心逻辑分三块初始化、更新、订阅。初始化用惰性初始化组件第一次渲染时才去原生模块读一次数据避免无谓的读取更新走 setValue 的回调形式订阅依赖事件总线。先写事件总线这部分很短// sessionStorageEvents.ts type StorageAction set | remove | clear; type StorageListener (key: string, action: StorageAction) void; const listeners new SetStorageListener(); export const sessionStorageEvents { subscribe(listener: StorageListener): () void { listeners.add(listener); return () { listeners.delete(listener); }; }, publish(key: string, action: StorageAction): void { listeners.forEach((listener) { try { listener(key, action); } catch (e) { // 单个订阅者的异常不能影响其他订阅者 } }); }, };这里subscribe返回的是一个取消订阅函数组件卸载时在useEffect清理函数里调用这是防止内存泄漏的关键。publish里包了 try/catch避免一个组件的渲染异常把事件广播搞崩。3.4 完整的 useSessionStorage 代码下面是完整 Hook。我调过很多版这个结构在状态一致性和代码可读性之间比较平衡// useSessionStorage.ts import { useCallback, useEffect, useState } from react; import { NativeModules } from react-native; import { sessionStorageEvents } from ./sessionStorageEvents; interface SessionStorageModule { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; clear(): void; keys(): string[]; } const { SessionStorageModule } NativeModules as { SessionStorageModule: SessionStorageModule; }; function readValueT(key: string, initialValue: T): T { const raw SessionStorageModule.getItem(key); if (raw null) { return initialValue; } try { return JSON.parse(raw) as T; } catch { return initialValue; } } export function useSessionStorageT(key: string, initialValue: T) { const [value, setValueState] useStateT(() { return readValue(key, initialValue); }); const setValue useCallback( (next: T | ((prev: T) T)) { setValueState((prev) { const resolved typeof next function ? (next as (old: T) T)(prev) : next; if (resolved undefined || resolved null) { SessionStorageModule.removeItem(key); } else { SessionStorageModule.setItem(key, JSON.stringify(resolved)); } sessionStorageEvents.publish(key, set); return resolved; }); }, [key] ); const remove useCallback(() { SessionStorageModule.removeItem(key); sessionStorageEvents.publish(key, remove); setValueState(initialValue); }, [key, initialValue]); useEffect(() { const unsubscribe sessionStorageEvents.subscribe((changedKey, action) { if (changedKey ! key) { return; } if (action remove || action clear) { setValueState(initialValue); return; } const raw SessionStorageModule.getItem(key); if (raw null) { setValueState(initialValue); return; } try { setValueState(JSON.parse(raw) as T); } catch { setValueState(initialValue); } }); return unsubscribe; }, [key, initialValue]); return [value, setValue, remove] as const; }几个代码细节要展开讲讲。第一setValueState内部用函数式更新这是为了保证两次快速连续调用setValue时第二次能基于第一次的结果计算而不是基于可能已经过期的外部闭包。React 官方的 setState 就是这样推荐的在自定义 Hook 里更要注意。第二setValue和订阅回调都会从原生模块重新读数据所以不管数据是谁写的最终展示状态都以原生模块为准。这样即使某个组件状态异常也不会把脏数据广播出去。我这个设计经历过大改最早版本是直接传值给事件总线让别的组件直接 setState 成这个值后来发现对象引用不一致导致判断失灵索性改成收到通知后重新读。虽然多一次原生读取但换来的是单一事实来源值得。第三返回值用了as const保证 TypeScript 能把它推断成 tuple而不是(T | ((prev: T) T) | (() void))[]不然解构出来的类型会很难看。3.5 边界情况处理过期数据、并发写入、大对象订阅逻辑里有个action clear分支当有人调用 clear 清空全部会话时所有监听者都要感知到回到 initialValue。但注意clear事件只带了一个空字符串当 key所以我在事件总线里约定 clear 时不校验 key。当前代码里publish(key, action)的 key 传的是触发写入的那个 keyclear 时传空串即可订阅者看到 action 为 clear 就别管 key 了。并发写入的测试场景两个组件同时对同一个 key 做更新。由于 setValue 内部是函数式更新理论上不会出现丢更新但如果你在一个组件里用循环调 setValue 一百次每次都会触发一次原生写入和一次事件广播性能会很难看。我的建议是高频更新场景不要走这个 Hook或者在外面加一层节流。会话存储本来就不该频繁写如果一个 key 每秒要写几十次那说明数据放错地方了应该放到全局状态管理里去。大对象问题再强调一次。Map 虽然读写快但它一直占着内存直到 App 进程结束。如果你在会话存储里放了一个很大的 base64 图片字符串整个会话期间内存都释放不掉。所以遇到大对象要么拆 key要么明确告诉业务方这里只存轻量数据。4. 实操记录在真机上把它跑起来设计说完了代码也差不多了但这不算完。代码要能在 OpenHarmony 真机上跑通才算落地。下面按我实际做过的流程记录一遍包括环境、代码落位、验证方法和结果观察。4.1 工程环境说明我用的环境是 DevEco Studio OpenHarmony SDK设备是香橙派 5 Pro跑的是标准 OpenHarmony 系统镜像。RNOH 版本这里不固定因为社区更新挺快但接入方式不变。工程主要分两大部分一是 OpenHarmony 原生工程负责 ArkTS 模块和 package 注册二是 RN 侧的 JS Bundle 工程负责 Hook 和业务页面。两者通过 RNOH 的构建链路集成最终 JS 代码会打进 App 里运行在 Hermes 引擎上。4.2 原生模块代码落位第一步把SessionStorageModule.ets放进entry/src/main/ets/下合适的目录比如modules/。注意这个文件的编译单元确保它在ets源目录内DevEco Studio 会自动参与编译。第二步写SessionStoragePackage.ets同样放进原生侧源码目录。第三步在入口的RNInstances配置里注册这个 package。如果你看过 camera 模块或打电话模块的接法会发现这个过程是完全一样的写模块、写 package、注册。所以说穿了useSessionStorage不是多高深的东西它就是一次标准的 RNOH 原生桥接。4.3 RN 侧接入与验证原生侧注册完构建一次。启动 App 后在 JS 调试器里执行# 在 RN 调试工具里验证模块是否注册成功 NativeModules.SessionStorageModule如果打印出来不是 undefined就说明模块已经通了。这时候再手动调几个方法试试写入一个字符串、读取、删除、再读。这个 native 链路先测通再去测 Hook能帮你把问题范围切掉一大半。然后我把useSessionStorage.ts和sessionStorageEvents.ts放进 RN 侧代码目录在页面组件里开始用。我当时的测试页面结构很简单三个页面PageA 写入一个草稿对象PageB 读取并展示PageC 删除。PageA 和 PageB 同时挂载在导航栈里验证跨组件同步。4.4 会话生命周期验证过程生命周期验证要测两个场景。场景一App 从后台恢复到前台按 Home 键退到桌面再点图标切回来会话数据应该还在。因为进程没被杀内存 Map 没变这是最基础的要求。场景二App 被杀掉后冷启动从最近任务里把 App 划掉再点图标启动此时应该读不到任何会话数据。这个验证很重要能确认你的实现确实是会话级而不是偷偷持久化了。如果发现冷启动后数据还在那说明你用的不是纯内存 Map而是有落盘的存储赶紧回去检查选型。我在场景二上真踩过坑。最早一版图省事用 Preferences 做后端杀 App 后再打开数据全在。业务方后来投诉退出登录后草稿没清干净就是因为我把会话数据和登录态数据误用了同一套持久化。后来改成纯内存 Map这个问题从根上消失了。所以设计阶段的选型真的比代码本身更能决定成败。5. 常见问题与排查技巧实录这部分整理的是我自己和同事们在这类桥接 Hook 上踩过的真实问题。每一条都有对应解法按现象-原因-处理来写可以当速查表用。5.1 现象数据写进去了别的组件却读不到最常见的原因是组件没有订阅 sessionStorageEvents。你只在写数据的组件里调了 setValue读数据的组件根本没订阅自然不会重新拉取。解决方法是检查读组件里有没有在useEffect中调sessionStorageEvents.subscribe并且 key 是否一致。另一个隐蔽原因是两个组件用了不同的 key 字符串比如带空格、带大小写差异字符串比对失配事件被吞掉。还有一种情况是组件确实订阅了但订阅回调里有个旧闭包读的是旧 key 或者旧 initialValue。我把订阅回调里所有外部变量都列出来检查了一遍发现问题就在useEffect的依赖数组没写全。RN 的 Hook 校验工具会在调试模式下警告你但生产环境未必提醒所以依赖数组要自己盯紧。5.2 现象存储对象类型乱了数字变成字符串这是序列化问题。JSON.stringify(123)存进去的是字符串123读出来JSON.parse后数字类型还在原则上不会丢类型。会丢类型的是undefined、NaN、Date这类特殊值它们要么被转成 null要么被转成字符串时间。比如你存一个Date对象读出来就是字符串不是 Date 实例。解决方法是业务侧约定会话存储只放可被 JSON 安全表达的数据时间戳用number不要直接塞 Date 对象。如果你控制不了上游数据格式可以在 Hook 外部包一层 schema 校验读出来之后二次转换。5.3 现象Hook 里的 initialValue 一直失灵有朋友遇到过第一次进页面明明存储里没有数据但组件显示的不是 initialValue而是上次的旧数据。排查下来发现是同一个 key 在别的页面残留了数据用户以为已经删了其实只是页面销毁存储删的是另一个 key。另外还有一个反直觉的情况组件从 key A 切换到 key B 时useEffect的依赖数组里只有[key, initialValue]确实会触发重新订阅和重新读取但是useState的初始值只在第一次渲染时生效切换 key 后 state 不会自动重置。所以如果你的组件支持动态改 key必须在订阅回调里处理 key 变化时重新拉取数据。我在最终版代码里已经通过订阅回调覆盖了这个场景每次事件回来都会以原生模块数据为准。5.4 现象应用切后台再回来会话数据没了进程被杀会丢数据这是设计预期。但有一种情况很诡异App 没被杀只是切后台回来发现数据也没了。这多半不是存储问题而是 JS 侧的组件被系统回收了。OpenHarmony 在内存吃紧时会回收后台页面的 JS 实例组件重建后从原生模块重新读如果原生 Map 还在数据应该能恢复。如果读不到就检查是不是原生模块被系统重建了——这跟原生模块的持有方式有关如果你把它注册在了一个会重建的 Page 作用域里而不是全局单例就会出现这个问题。这也是我为什么强调要把 SessionStorageModule 注册到RNPackage层的原因它是跟随 RN 实例存活的而不是跟随页面。5.5 问题速查表与避坑清单现象可能原因处理方式跨组件不同步没有订阅事件或 key 不一致检查订阅逻辑和 key 字符串类型变成字符串/nullDate、NaN、undefined 被 JSON 序列化只存 JSON 安全的数据类型initialValue 不生效key 里残留旧数据手动 remove 或换 key冷启动数据还在使用了持久化存储而非纯内存换回内存 Map 或加清理逻辑杀进程后数据丢失会话存储的预期行为确认业务需求需要持久化请另存频繁 setValue 卡顿高频全量写入 事件风暴加节流或改用全局状态管理再补几条单独的避坑心得。第一不要在生产代码里依赖console.log调试存储状态用keys()方法把当前所有会话 key 打出来更直观。第二订阅返回的取消函数一定要调用不然页面堆叠多了会重复订阅同一事件触发 N 次渲染。第三RNOH 的桥接模块方法默认是异步的所以不要指望 setValue 之后立刻同步读取到结果状态更新还是要走事件通知。6. 写在最后一点个人体会这个东西做完之后我最深的感受是OpenHarmony RN 的开发本质上拼的不是你会不会写业务组件而是你熟不熟悉桥接层那套原生模块注册、JS 侧封装、事件同步的循环。useSessionStorage恰恰把这个循环完整地走了一遍所以它特别适合作为新手接触原生桥接的第一个练手项目。如果你后续想把这个能力做得更完善可以在原生模块里加入过期时间字段setItem 的时候记录写入时间戳getItem 的时候判断是否过期也可以给某些 key 加上 AES 加密避免敏感数据即使误入持久化也不会裸奔还能把事件总线升级成原生侧的 NativeEventEmitter让原生侧代码也能主动通知 JS 侧。每一步都不难但都能让这个 Hook 从够用变成好用。最后分享一个小技巧。调试这类会话存储问题的时候我会在原生模块里额外暴露一个getStats()方法返回当前 Map 的 key 数量和占用内存估算值。线上排查到底是存储丢了还是组件没刷新时这个方法一分钟就能定位问题比反复翻日志高效得多。你也值得给自己留一个这样的调试后门。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进