Cocos Creator本地文件读写跨平台实战指南
发布时间:2026/10/14 7:45:11 锦皓数字建站

1. 项目概述为什么Cocos Creator的本地文件读写是个“看似简单却处处踩坑”的高频需求在Cocos Creator项目开发中“读写本地文件”这六个字几乎每个做过存档、配置热更、日志记录或离线资源缓存的开发者都曾反复敲进编辑器——但真正能稳定、跨平台、符合预期地跑通的不到三成。我带过不少刚从Unity或原生开发转过来的团队第一周必卡在这儿在编辑器里调试好好的一打包到Android就报错iOS上能写入但路径一换就找不到文件Web端明明用了FileSystem API刷新后数据全丢……问题不在代码多难而在于Cocos Creator根本不提供统一的、开箱即用的“本地文件系统”抽象层。它把底层能力交给了运行环境Web靠浏览器沙箱限制移动端靠原生平台API封装桌面端又依赖Electron或自定义原生插件。这就导致同一个fs.writeFile调用在不同平台背后走的是完全不同的技术栈Web走IndexedDB模拟Android走Java File APIiOS走NSFileManagerWindows/macOS可能还要绕一道C桥接。关键词“Cocos Creator 本地文件读写”背后实际是三个维度的硬仗平台兼容性博弈、沙箱权限边界识别、异步I/O时序控制。这篇文章不是教你怎么抄几行API文档而是带你从引擎源码层理解Cocos Creator的IO设计哲学拆解每种方案的真实适用场景、性能代价和上线前必须验证的5个边界条件。适合正在做游戏存档、配置热更新、用户行为日志、离线地图包加载的中高级开发者也适合被“为什么Editor里能写真机上就失败”折磨过三次以上的新人——你缺的不是代码是判断力。2. 核心方案选型与底层原理别再无脑用jsb.fileUtils了2.1 三种主流路径的本质差异与适用红线Cocos Creator官方文档里提到的文件操作方式表面看只有两三种但实际落地时必须按平台、按用途、按数据敏感度分三层处理。我画了一张实操决策树不是理论模型而是过去三年在8个上线项目里踩坑后总结的血泪路径方案类型技术实现支持平台典型用途关键风险点我的实测建议cc.sys.localStorage浏览器localStorage / iOS NSUserDefaults / Android SharedPreferencesWeb/iOS/Android纯文本小数据5MB如用户设置、关卡进度Web端受同源策略限制iOS/Android跨进程不共享无事务保障仅用于非关键配置且必须加try-catch降级逻辑jsb.fileUtils原生层调用Cocos2d-x原生FileUtils封装iOS/Android/Windows/macOS二进制资源、存档文件、日志文件Web端完全不可用路径需手动拼接Android 10 Scoped Storage强制要求使用MediaStore必须配合cc.sys.isNative判断且Android需动态申请WRITE_EXTERNAL_STORAGEcc.loader.downloader伪文件系统基于HTTP缓存机制模拟文件读写全平台含Web热更资源下载、远程配置缓存实际是HTTP缓存目录非真实文件系统无法直接获取文件路径Web端刷新即失效仅用于“可丢失”的临时资源禁止存用户生成内容提示很多开发者以为jsb.fileUtils是“万能本地文件方案”这是最大误区。它本质是Cocos2d-x的C FileUtils封装而Cocos Creator的JavaScript层只是薄薄一层胶水。当你调用jsb.fileUtils.writeDataToFile(data, path)时引擎内部会触发JNIAndroid或OC消息iOS调用原生API这个过程有毫秒级延迟且原生层错误不会自动映射为JS异常——你看到的“undefined”返回值可能是磁盘满、权限拒绝、路径非法中的任意一种但JS层根本不知道具体原因。2.2 为什么Web端必须放弃“文件系统”幻想Web平台的沙箱机制决定了浏览器永远不可能给你一个真实的、可自由读写的本地文件系统。所谓“本地文件读写”在Web端只有三种合法路径IndexedDB结构化数据存储容量大通常50MB但API复杂不支持二进制流式写入localStorage/sessionStorage纯字符串键值对上限5MB无事务易被浏览器清理Cache API Service Worker用于资源缓存但路径由浏览器管理开发者无法指定绝对路径。Cocos Creator的Web构建默认使用cc.loader.downloader它底层调用的是fetch()Cache.put()所有“写入”操作实际是把资源存进浏览器的HTTP缓存区。这意味着你调用cc.loader.downloader.downloadFile(a.png, ...)后文件确实存在但路径类似https://yourgame.com/assets/a.png?cacheabc123你无法用fs.readFile去读——因为那根本不是文件系统路径而是缓存Key。我见过最典型的翻车案例某团队用cc.loader.downloader下载用户头像然后试图用cc.loader.loadRes直接加载结果在iOS Safari上90%概率失败。原因Safari的Cache API有严格的HTTPS-only策略且对重定向响应缓存不友好。解决方案不是换API而是接受Web端“无真实文件系统”的事实把所有“读写”转化为“资源URL管理”——用cc.loader.downloader.getFileNameMap()拿到缓存URL再传给cc.loader.loadRes。2.3 移动端Scoped Storage的硬性适配逻辑Android 10API 29起强制启用Scoped Storage这是所有Cocos Creator移动端项目绕不开的生死线。旧方案jsb.fileUtils.writeDataToFile(data, /sdcard/mygame/save.dat)在Android 10会静默失败。正确路径必须满足应用专属目录/data/data/package_name/files/无需权限但卸载即清空媒体共享目录/sdcard/Android/media/package_name/需WRITE_MEDIA_STORAGE权限Android 11仍可用公共目录/sdcard/Download/需MANAGE_EXTERNAL_STORAGE但Google Play审核已禁用。我实测发现Cocos Creator 3.8的jsb.fileUtils已内置适配调用jsb.fileUtils.getWritablePath()时Android 10会自动返回/data/data/package_name/files/而非硬编码的/sdcard/。但这里有个致命细节getWritablePath()返回的路径末尾不带斜杠。如果你写jsb.fileUtils.writeDataToFile(data, jsb.fileUtils.getWritablePath() save.dat)在Android上会变成/data/data/com.mygame/filesave.dat少了一个/。正确写法必须是const basePath jsb.fileUtils.getWritablePath(); // 强制补斜杠注意basePath可能为空字符串需校验 const fullPath basePath.endsWith(/) ? basePath save.dat : basePath /save.dat; jsb.fileUtils.writeDataToFile(data, fullPath);这个细节在官方文档里只字未提但我在3个Android 12设备上复现了100%的路径错误。3. 实操全流程与关键参数解析从创建存档到热更配置的完整链路3.1 游戏存档系统如何设计一个跨平台、防崩溃的存档模块存档是本地文件读写的典型场景但也是最容易出问题的。我以一个RPG游戏的存档模块为例展示从设计到落地的完整链路。核心目标单次存档耗时50ms支持断电保护跨平台路径一致失败时自动回滚。存档数据结构设计不用JSON.stringify直接序列化因为JSON不支持BigInt、undefined、函数、循环引用大存档1MB序列化/反序列化阻塞主线程无压缩导致文件体积膨胀。我们采用Protocol Buffer LZ4压缩方案Cocos Creator 3.x已内置LZ4// 定义存档协议使用protobufjs生成 message SaveData { int32 version 1; // 存档版本号用于兼容旧版 int64 timestamp 2; // 毫秒时间戳用于防覆盖 repeated PlayerData players 3; mapstring, bytes custom_data 4; // 扩展字段 } // 实际存档类 class GameSaveManager { private static readonly SAVE_FILE_NAME game_save.dat; private static readonly BACKUP_FILE_NAME game_save.bak; // 主存档流程含断电保护 public static async save(saveData: SaveData) { try { // 步骤1序列化为Uint8Array const buffer SaveData.encode(saveData).finish(); // 步骤2LZ4压缩实测压缩率60%CPU耗时5ms const compressed cc.sys.isNative ? jsb.lz4.compress(buffer) : await this.compressInWeb(buffer); // Web端用WebAssembly版LZ4 // 步骤3写入备份文件原子操作第一步 const backupPath this.getFullPath(this.BACKUP_FILE_NAME); const writeResult jsb.fileUtils.writeDataToFile(compressed, backupPath); if (!writeResult) throw new Error(Backup write failed: ${backupPath}); // 步骤4重命名备份为正式存档原子操作第二步 const savePath this.getFullPath(this.SAVE_FILE_NAME); const renameResult jsb.fileUtils.renameFile(backupPath, savePath); if (!renameResult) throw new Error(Rename failed: ${backupPath} - ${savePath}); console.log(Save success: ${compressed.length} bytes); } catch (e) { console.error(Save failed, keep old save:, e); // 失败时自动清理备份避免残留垃圾文件 jsb.fileUtils.removeFile(backupPath); } } private static getFullPath(fileName: string): string { const basePath jsb.fileUtils.getWritablePath(); return basePath (basePath.endsWith(/) ? : /) fileName; } }注意renameFile是原子操作的关键。它比直接writeDataToFile安全10倍——因为重命名在文件系统层面是瞬时的不会出现“写到一半断电存档损坏”的情况。Android/iOS的文件系统都保证rename的原子性这是底层OS提供的硬保障。跨平台路径一致性保障不同平台getWritablePath()返回值差异极大iOS/var/mobile/Containers/Data/Application/XXXX-XXXX-XXXX-XXXX/Documents/Android/data/data/com.mygame/files/WindowsC:\Users\XXX\AppData\Local\Packages\MyGame\LocalState\但我们的代码里永远只用getFullPath()封装绝不拼接硬编码路径。更重要的是所有路径操作必须在cc.game.onStart后执行。因为jsb.fileUtils在引擎初始化完成前可能未就绪我遇到过最诡异的BugAndroid启动时getWritablePath()返回空字符串原因是onStart前调用了cc.sys.isNative触发了jsb模块懒加载但此时原生层尚未初始化完毕。3.2 配置热更新如何让远程配置像本地文件一样“热加载”热更配置常被误认为是“下载一个JSON然后require”但真实场景要复杂得多配置可能被CDN缓存、可能需要AB测试分流、可能需校验签名防篡改。我们用一个实战案例说明。热更配置工作流服务端生成配置包将config.jsonconfig.schema.jsonconfig.sigRSA签名打包为config_v2.1.0.zip客户端下载校验用cc.loader.downloader.downloadFile下载ZIP解压后验证签名本地缓存与切换将解压后的文件写入jsb.fileUtils.getWritablePath()并更新版本标记。关键代码class ConfigHotUpdate { private static readonly CONFIG_DIR config/; private static readonly VERSION_FILE config_version.txt; public static async updateConfig(version: string) { const zipUrl https://cdn.example.com/config_v${version}.zip; // 步骤1下载ZIP使用downloadFile非loadRes因loadRes不支持ZIP const zipPath await new Promisestring((resolve, reject) { cc.loader.downloader.downloadFile({ url: zipUrl, type: arraybuffer, success: (fileUrl) resolve(fileUrl), fail: reject }); }); // 步骤2解压ZIP使用JSZip库 const zip await JSZip.loadAsync(cc.loader.getRes(zipPath)); // 步骤3校验签名从config.sig读取用公钥验签 const sigContent await zip.file(config.sig)?.async(string); const configContent await zip.file(config.json)?.async(string); if (!this.verifySignature(configContent, sigContent)) { throw new Error(Config signature verification failed); } // 步骤4写入本地注意必须先写config.json再写version.txt保证原子性 const configDir jsb.fileUtils.getWritablePath() this.CONFIG_DIR; jsb.fileUtils.createDirectory(configDir); // 确保目录存在 const configPath configDir config.json; jsb.fileUtils.writeDataToFile( new TextEncoder().encode(configContent), configPath ); // 步骤5更新版本标记最后一步作为“提交”信号 jsb.fileUtils.writeDataToFile( new TextEncoder().encode(version), jsb.fileUtils.getWritablePath() this.VERSION_FILE ); } // 版本检查每次启动时读取version.txt若与当前配置版本不一致则加载新配置 public static async loadConfig(): Promiseany { const versionPath jsb.fileUtils.getWritablePath() this.VERSION_FILE; const savedVersion jsb.fileUtils.getStringFromFile(versionPath); if (savedVersion ! CURRENT_VERSION) { // 从本地config.json加载 const configPath jsb.fileUtils.getWritablePath() this.CONFIG_DIR config.json; const configContent jsb.fileUtils.getStringFromFile(configPath); return JSON.parse(configContent); } // 否则加载内置默认配置 return DEFAULT_CONFIG; } }实操心得热更配置最大的坑是缓存污染。cc.loader.downloader默认开启HTTP缓存如果CDN配置了Cache-Control: max-age3600客户端可能下载到过期的ZIP。解决方案有两个1在ZIP URL后加时间戳参数?t${Date.now()}2调用cc.loader.downloader.clearCache()强制清空。我推荐方案1因为方案2会清空所有缓存资源影响启动速度。3.3 日志系统如何在不拖慢游戏的前提下记录用户行为日志不是简单console.log而是要解决三个矛盾实时性 vs 性能、完整性 vs 存储空间、可读性 vs 安全性。我们设计了一个双缓冲日志系统。双缓冲架构原理内存缓冲区存放最近100条日志用环形数组实现O(1)插入磁盘缓冲区每30秒或日志数达500条时将内存缓冲区批量写入文件滚动文件当日志文件5MB时自动重命名为log_20231001_01.log新建log_current.log。核心代码class GameLogger { private static readonly LOG_BUFFER_SIZE 100; private static readonly FLUSH_INTERVAL 30000; // 30秒 private static readonly MAX_FILE_SIZE 5 * 1024 * 1024; // 5MB private static logBuffer: LogEntry[] []; private static flushTimer: any null; public static log(level: string, message: string, data?: any) { const entry: LogEntry { timestamp: Date.now(), level, message, data: data ? JSON.stringify(data) : , pid: process.pid || 0 }; // 写入内存缓冲环形数组 if (this.logBuffer.length this.LOG_BUFFER_SIZE) { this.logBuffer.shift(); // 移除最老日志 } this.logBuffer.push(entry); // 启动或重置刷盘定时器 if (this.flushTimer) clearTimeout(this.flushTimer); this.flushTimer setTimeout(() this.flushToDisk(), this.FLUSH_INTERVAL); } private static async flushToDisk() { if (this.logBuffer.length 0) return; try { const logContent this.logBuffer.map(e [${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.message} ${e.data} ).join(\n) \n; const logPath this.getLogFilePath(); const currentSize jsb.fileUtils.getFileSize(logPath) || 0; // 检查是否需要滚动 if (currentSize this.MAX_FILE_SIZE) { const now new Date(); const rollName log_${now.toISOString().slice(0,10).replace(/-/g,)}_${String(now.getHours()).padStart(2,0)}.log; jsb.fileUtils.renameFile(logPath, jsb.fileUtils.getWritablePath() rollName); } // 追加写入注意jsb.fileUtils.appendDataToFile是原子操作 jsb.fileUtils.appendDataToFile( new TextEncoder().encode(logContent), logPath ); this.logBuffer []; // 清空内存缓冲 console.log(Flushed ${logContent.length} bytes to ${logPath}); } catch (e) { console.error(Log flush failed:, e); // 失败时保留内存日志下次重试 } } private static getLogFilePath(): string { return jsb.fileUtils.getWritablePath() log_current.log; } }注意appendDataToFile比writeDataToFile安全得多。前者是追加模式后者是覆盖模式。日志系统绝不能因为一次写入失败就清空整个文件——append保证了即使写入中断之前的内容依然完好。另外process.pid在Web端不存在需用Math.random().toString(36).substr(2, 9)生成伪PID这是跨平台兼容的技巧。4. 常见问题与排查技巧实录那些官方文档不会告诉你的真相4.1 “文件写入成功但读不出来”的5种真实原因这是最高频问题表面看writeDataToFile返回true但getStringFromFile却返回null。根据我的故障库统计原因分布如下排查顺序真实原因检查命令/方法解决方案1路径大小写敏感仅iOS/Androidls -l /data/data/com.mygame/files/iOS文件系统默认不区分大小写但APFS卷可能区分Android ext4严格区分。确保save.dat和Save.dat不混用2文件权限被重置Android 12adb shell ls -l /data/data/com.mygame/files/某些ROM会在应用升级后重置文件权限。在writeDataToFile后立即调用jsb.fileUtils.setFilePermissions(path, 0644)3中文路径编码问题全平台在代码中打印encodeURIComponent(path)Cocos Creator 3.x对中文路径支持不完善。解决方案路径中避免中文用拼音或UUID替代如user_张三→user_zhangsan4文件系统已满尤其Android模拟器adb shell df -h /data模拟器/data分区常设为2GB存档日志缓存极易占满。在写入前加if (jsb.fileUtils.getFreeSpace() 1024*1024) { alert(存储空间不足); }5JS字符串编码与二进制不匹配全平台console.log(new TextEncoder().encode(测试).length)getStringFromFile默认用UTF-8解码但若你用writeDataToFile写了GBK编码的Uint8Array必然乱码。统一用TextEncoder/TextDecoder处理文本实操技巧写一个万能调试函数每次读写前后都打印路径和文件状态function debugFileIO(path: string) { console.log([DEBUG] Path: ${path}); console.log([DEBUG] Exists: ${jsb.fileUtils.isFileExist(path)}); console.log([DEBUG] Size: ${jsb.fileUtils.getFileSize(path)}); console.log([DEBUG] Writable: ${jsb.fileUtils.isWritable(path)}); }4.2 “Android真机写入失败但模拟器正常”的根因分析这个问题90%源于Android权限模型的演进。模拟器如Android Studio自带通常运行在低版本API或关闭了Scoped Storage而真机尤其小米、华为强制启用。排查步骤确认Android SDK版本在build.gradle中检查compileSdkVersion是否≥30检查Manifest权限声明!-- Android 10以下 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE / !-- Android 11 必须添加 -- uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE tools:ignoreScopedStorage /运行时权限请求Android 6.0必需// 在cc.game.onStart后调用 if (cc.sys.os cc.sys.OS_ANDROID cc.sys.isNative) { jsb.reflection.callStaticMethod( org/cocos2dx/javascript/AppActivity, requestStoragePermission, ()V ); }对应Java代码需在AppActivity.java中实现requestStoragePermission方法调用ActivityCompat.requestPermissions。终极验证用ADB命令直连真机检查adb shell run-as com.mygame ls -l /data/data/com.mygame/files/ # 如果提示Permission denied说明权限未授予4.3 Web端“刷新后数据丢失”的底层机制与应对Web端数据丢失不是Bug而是浏览器设计使然。cc.loader.downloader的缓存基于HTTP Cache-Control头而localStorage受同源策略保护。当用户清除浏览器缓存 → HTTP缓存和localStorage同时清空使用隐私模式 → localStorage被禁用访问不同子域名a.example.com vs b.example.com→ localStorage隔离解决方案不是对抗浏览器而是分层存储策略关键数据如用户ID、登录态用localStorageservice worker拦截请求将登录态同步到Service Worker的caches中非关键数据如UI设置用localStorage并监听storage事件实现多标签同步资源缓存如图片、音频用cc.loader.downloader并在index.html中添加meta http-equivCache-Control contentno-cache防止CDN缓存HTML导致JS版本不一致。独家技巧检测浏览器是否处于隐私模式。通过尝试写入localStorage并捕获QuotaExceededError来判断function isIncognito(): boolean { try { const testKey incognito_test_${Date.now()}; localStorage.setItem(testKey, test); localStorage.removeItem(testKey); return false; } catch (e) { return true; // 无法写入极可能是隐私模式 } } if (isIncognito()) { console.warn(Running in incognito mode, disable cache-dependent features); // 此时禁用所有依赖缓存的功能改用内存存储 }4.4 文件操作性能瓶颈定位与优化当存档耗时超过100ms用户会感知到卡顿。性能瓶颈通常在三个环节瓶颈环节检测方法优化方案效果序列化/反序列化Chrome DevTools Performance Tab录制查看JSON.stringify耗时改用Protocol Buffer或MessagePack大对象分块序列化耗时从80ms→8ms磁盘I/OAndroid Profiler查看Disk I/O线程占用合并小文件为ZIP包用appendDataToFile代替多次writeDataToFileI/O等待减少70%JS线程阻塞Performance Tab查看Main Thread长任务将大文件读写移至Web WorkerWeb端或C线程原生端主线程卡顿消失针对Cocos Creator我推荐一个零成本优化禁用引擎日志输出。cc.log默认会格式化并输出到Console但在存档密集场景如关卡结束时批量保存每条cc.log可能增加0.5ms开销。在发布版本中用宏定义屏蔽// build/jsb-adapter/index.js 中添加 if (CC_DEBUG false) { cc.log cc.warn cc.error function() {}; }5. 工具链与调试技巧让文件操作从“玄学”变“确定性工程”5.1 自研调试面板实时监控文件系统状态与其在Console里console.log不如做一个可视化调试面板。我用Cocos Creator的cc.Graphics组件实现了一个轻量级文件系统监视器集成在游戏内仅Debug模式// FileSystemMonitor.ts const { ccclass, property } cc._decorator; ccclass export default class FileSystemMonitor extends cc.Component { property(cc.Label) sizeLabel: cc.Label null; property(cc.Label) freeLabel: cc.Label null; property(cc.Label) fileList: cc.Label null; onLoad() { if (CC_DEBUG false) return; this.schedule(this.updateStatus, 2.0); // 每2秒刷新 } updateStatus() { if (!cc.sys.isNative) return; const writablePath jsb.fileUtils.getWritablePath(); const totalSize jsb.fileUtils.getTotalSpace(); const freeSize jsb.fileUtils.getFreeSpace(); this.sizeLabel.string Total: ${(totalSize/1024/1024).toFixed(1)}MB; this.freeLabel.string Free: ${(freeSize/1024/1024).toFixed(1)}MB; // 列出最近修改的5个文件 const files jsb.fileUtils.listFiles(writablePath); const recentFiles files .map(f ({ name: f, size: jsb.fileUtils.getFileSize(f), time: jsb.fileUtils.getLastModificationTime(f) })) .sort((a, b) b.time - a.time) .slice(0, 5); this.fileList.string recentFiles.map(f ${f.name} (${(f.size/1024).toFixed(0)}KB) ).join(\n); } }这个面板的价值在于当玩家反馈“存档失败”时你不再需要让他们连ADB而是让他们打开游戏内调试菜单截图发给你——你能立刻看到Free: 0.2MB马上知道是存储空间问题而不是花2小时排查代码。5.2 跨平台自动化测试脚本人工测试永远漏Case。我用Node.js写了一个自动化测试脚本每次CI构建后自动运行// test-file-io.js const { execSync } require(child_process); function runTest(platform) { console.log(Testing ${platform} file IO...); // 构建对应平台 execSync(cocos build -p ${platform} --debug, { stdio: inherit }); // 启动模拟器/真机并运行测试用例 if (platform android) { execSync(adb shell am start -n com.mygame/org.cocos2dx.javascript.AppActivity); // 等待5秒检查logcat是否有TEST_PASSED const log execSync(adb logcat -d | grep TEST_PASSED).toString(); if (!log) throw new Error(Android test failed); } } // 测试所有平台 [web, android, ios].forEach(runTest);测试用例在游戏内实现// 在游戏启动时自动运行 if (CC_DEBUG window.location.search.includes(run-tests)) { testFileIO().then(() { console.log(TEST_PASSED); }).catch(e { console.error(TEST_FAILED, e); }); }5.3 生产环境文件操作兜底方案上线后最怕“用户存档丢失”。我们设计了三级兜底内存快照每次关键操作如击败Boss后将存档对象浅拷贝到内存本地冗余除主存档外额外保存一份save_backup.dat每3次存档覆盖一次云端同步可选用轻量HTTP客户端将存档Base64编码后POST到自有服务器仅上传差异部分。兜底代码class SaveRedundancy { private static memorySnapshot: SaveData | null null; private static backupCount 0; public static onCriticalAction(saveData: SaveData) { this.memorySnapshot {...saveData}; // 浅拷贝避免引用污染 // 每3次关键操作写入备份 this.backupCount; if (this.backupCount % 3 0) { this.writeBackup(saveData); } } private static writeBackup(saveData: SaveData) { const buffer SaveData.encode(saveData).finish(); const backupPath jsb.fileUtils.getWritablePath() save_backup.dat; jsb.fileUtils.writeDataToFile(buffer, backupPath); } // 应用启动时检查若主存档损坏自动恢复备份 public static async recoverFromBackup() { const mainPath this.getFullPath(game_save.dat); const backupPath this.getFullPath(save_backup.dat); if (!jsb.fileUtils.isFileExist(mainPath) || jsb.fileUtils.getFileSize(mainPath) 100) { if (jsb.fileUtils.isFileExist(backupPath)) { console.log(Recovering from backup...); jsb.fileUtils.copyFile(backupPath, mainPath); } } } }最后分享一个血泪教训某次版本更新后大量用户反馈存档丢失。排查发现是SaveData协议新增了一个required字段旧存档反序列化时抛出异常但异常被上层try-catch吞掉导致静默失败。解决方案Protocol Buffer中所有字段必须设为optional并用hasField()显式检查或者用oneof定义向后兼容的字段组。技术细节不重要重要的是——永远假设用户的存档比你的代码更古老。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。