我们维护的 3DGS 商品展示应用跑在 API 25 上半年了存量代码 8000 多行。HarmonyOS 7API 26发布后IDE 提示 Spatial Recon Kit 有更新我们顺手把compileSdkVersion改成 26结果编译报 47 个错。不是少导了什么是 Kit 接口真的改了。这篇文章记录我们把 API 25 存量代码迁到 API 26 的过程重点是那些接口名没变但语义变了的坑——编译能过行为静默错误比编译失败难排查十倍。能力面API 26 破坏性变更清单迁移前先把变更摸清楚。我们对照 API 25 和 26 的 d.ts 文件 diff整理出三类变更。接口签名变更编译期可发现接口API 25API 26影响GSPlugin.loadGSNode(scene, uri: string, parent)(scene, opts: {uri, offset}, parent)第二参从 string 改成对象TiledGSImportSettings.uri仅支持file://支持file://和OhosRawFile://兼容性扩展无破坏GSNode.renderToImage无此接口新增新能力无破坏ReconSession.configconfig: ReconConfigconfig: ReconConfig | undefined允许 undefined向后兼容ReconSession.start返回Promisevoid返回PromiseReconResult返回值多了结果对象这类变更编译期会报错照着改就行。真正麻烦的是下面两类。行为变更编译期不可见接口API 25 行为API 26 行为风险loadPlugin重复调用返回 false无副作用抛异常PluginAlreadyLoaded旧代码 try-catch 没接住异常GSNode.position赋值立即生效下一帧生效依赖立即生效的逻辑会闪一帧ReconSession并发创建第二个返回 null第二个抛SessionConflict旧代码判 null 走降级现在直接崩renderToImage默认分辨率无此接口默认 1080p不指定尺寸时内存峰值高isSupported返回值仅查 SDK查 SDK 硬件低端机从 true 变 falseisSupported这条最坑。API 25 时它只查 SDK 是否包含 Kit返回 trueAPI 26 改成查 SDK 硬件低端机返回 false。我们存量代码靠isSupported判定走 3DGS 还是降级升级后低端机原本走 3DGS虽然卡现在直接走降级用户感知是3D 效果没了。废弃 API编译期 warning接口API 26 状态替代方案GSPlugin.loadFromMp4deprecated用loadGSNode统一入口uri 指向 mp4ReconSession.setProgressCallbackdeprecated用on(progress, cb)事件订阅GSNode.setVisibledeprecated用GSNode.visible属性赋值废弃的接口 API 26 还能用但编译 warning且官方说 API 27 会移除。我们选择这次迁掉避免下次再迁。约束面迁移工作量迁移工作量不只是改代码还有验证。下表是我们这次迁移的实测数据。工作项数量单项耗时小计接口签名变更修改23 处5-15 分钟4 小时行为变更排查与修复8 处1-4 小时18 小时废弃接口替换6 处20-40 分钟3 小时编译错误修复47 个2-10 分钟6 小时真机回归测试12 台设备1 小时/台12 小时行为差异验证5 处语义变更2 小时/处10 小时合计53 小时行为变更排查占了 18 小时比编译错误修复还多。编译错误改完就过行为变更要逐个验证原来这么调用现在还是不是这个效果。场景落地一段存量代码的迁移下面是我们商品展示页加载 3DGS 的存量代码API 25 写的迁到 API 26 改了三处。API 25 原版// API 25 版本2025 年写的import{Scene,RenderContext,Node}fromkit.ArkGraphics3D;import{spatialRender}fromkit.SpatialReconKit;EntryComponentstruct ProductViewer{privatescene:Scene|nullnull;privatepluginLoaded:booleanfalse;aboutToAppear():void{constctx:RenderContext|nullScene.getDefaultRenderContext();if(ctxnull){return;}// API 25: 重复调用返回 false这里写了个 if 判断if(!this.pluginLoaded){ctx.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);this.pluginLoadedtrue;}Scene.load().then(async(scene:Scene){this.scenescene;constroot:Nodescene.rootasNode;// API 25: loadGSNode 第二参是 stringconstgsNodeawaitspatialRender.GSPlugin.loadGSNode(scene,OhosRawFile://assets/teapot.glb,root);gsNode.position{x:0,y:-0.2,z:0};// API 25: 立即生效下一行就能读到console.log(gsNode.position.y);// -0.2});}build(){Column(){if(this.scene){Component3D({scene:this.scene}).width(100%).height(100%)}}}}API 26 迁移版// API 26 版本import{Scene,RenderContext,Node}fromkit.ArkGraphics3D;import{spatialRender}fromkit.SpatialReconKit;EntryComponentstruct ProductViewer{privatescene:Scene|nullnull;privatepluginLoaded:booleanfalse;aboutToAppear():void{constctx:RenderContext|nullScene.getDefaultRenderContext();if(ctxnull){return;}// 变更 1API 26 重复调用抛异常要 try-catchif(!this.pluginLoaded){try{ctx.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);this.pluginLoadedtrue;}catch(e){// 已加载属于正常情况不报错if(e.code!PluginAlreadyLoaded){console.error(load plugin failed: e.message);}this.pluginLoadedtrue;}}Scene.load().then(async(scene:Scene){this.scenescene;constroot:Nodescene.rootasNode;// 变更 2loadGSNode 第二参改成对象constgsNodeawaitspatialRender.GSPlugin.loadGSNode(scene,{uri:OhosRawFile://assets/teapot.glb,offset:0},root);gsNode.position{x:0,y:-0.2,z:0};// 变更 3position 赋值下一帧才生效不能立即读// 如果逻辑依赖立即生效要改成 await nextFrame() 后再读awaitnextFrame();console.log(gsNode.position.y);// -0.2});}build(){Column(){if(this.scene){Component3D({scene:this.scene}).width(100%).height(100%)}}}}三处变更里第三处最容易被漏掉。position赋值从立即生效改成下一帧生效编译不报错运行也不崩但如果代码里紧接着读gsNode.position做后续计算读到的是旧值。我们有一处布局逻辑依赖赋值后立即读 position 算偏移迁到 API 26 后偏移全错画面位置偏了 0.2 米排查了两小时才发现。迁移不是改完编译错误就结束。把这条链路画出来能看清行为回归为什么占了大头。签名或参数变更废弃接口编译通过全绿有差异存量代码改 compileSdkVersion 到 26编译并收集错误错误类型逐项替换接口且显式赋值每个字段替换为统一入口或事件订阅跑行为差异用例回归真机验证 5 处行为变更行为是否符合预期迁移完成定位语义变更点回改代码从编译到行为回归是一条串行链。有差异就回到回归环节直到全部用例通过。真机数据迁移前后行为差异迁移完跑了真机回归重点验证 5 处行为变更。行为变更API 25 表现API 26 表现是否符合预期loadPlugin重复调用返回 false抛异常是但需 try-catchposition立即读读到新值读到旧值是但易引入 bug并发 session第二个 null第二个抛异常是错误更明确isSupported低端机truefalse是更准确renderToImage默认分辨率无此接口1080p内存 200MB否应默认 512renderToImage默认 1080p 这条我们提了 issue。新接口默认分辨率太高端侧调用不指定尺寸时内存峰值 200 MB容易 OOM。建议默认 512 或在文档强调要指定尺寸。目前看 Kit 团队认可可能 API 27 改。真机数据编译错误分布47 个编译错误的分布给同样要迁移的人参考。错误类型数量占比参数类型不匹配string vs object1838%接口不存在废弃接口未替换1226%返回值类型不匹配919%缺少新增必填字段511%其他36%参数类型不匹配占 38%都是loadGSNode第二参从 string 改成对象导致的。这类错误改起来快但容易改漏——有些地方 string 是变量传入的IDE 不会标红运行时才崩。我们用全局搜索loadGSNode(逐个核对又揪出 3 处漏改的。踩坑与取舍坑一接口名没变但参数语义变了loadGSNode接口名没变第二参从 string 改成{uri, offset}。我们有一处代码是这样写的// API 25 写法uri 是变量consturi:stringgetAssetUri(skuId);constgsNodeawaitspatialRender.GSPlugin.loadGSNode(scene,uri,root);迁到 API 26IDE 提示第二参类型错改成// 错误改法把 uri 直接包进对象但漏了 offsetconstgsNodeawaitspatialRender.GSPlugin.loadGSNode(scene,{uri},root);offset漏了。API 26 的offset是文件偏移量用于一个文件存多个 3DGS 场景的情况默认值不是 0 而是 undefined行为是读整个文件。我们的场景恰好一个文件一个场景漏了 offset 没崩。但有个同事的场景是一个 mp4 存了 3 个商品漏 offset 后只读到第一个排查了半天。教训接口参数从简单类型改成对象时每个字段都要显式赋值不能依赖默认值。默认值的语义可能和旧接口不一致。坑二废弃 API 没文档GSPlugin.loadFromMp4在 API 26 标 deprecated但 d.ts 里没写替代方案官方迁移文档也没提。我们翻了 API 26 的 release note 才找到一句统一用 loadGSNode。废弃接口应该在 d.ts 里直接标替代方案这是文档该做的事不该让开发者翻 release note。类似地setProgressCallback废弃后改用on(progress, cb)但on接口的cb参数类型和旧 callback 不完全一致——旧 callback 收number新cb收{progress: number, stage: string}。我们直接搬旧函数过去类型不匹配编译报错但报错信息没指出是废弃接口迁移导致的绕了一圈才搞明白。坑三被放弃的方案——同时兼容 API 25 和 26中途想过用条件编译同时兼容两个版本#if API_VERSION 26走新接口 else 走旧接口。问题是行为变更没法条件编译——position赋值何时生效是运行时行为不是编译期能切的。而且维护两套代码路径成本翻倍回归测试要跑两遍。这个方案被放弃了。我们决定一刀切迁到 API 26最低支持版本提到 API 26。代价是低版本设备收不到更新但产品评估后接受——3DGS 功能本来就是新设备卖点。坑四isSupported语义变更导致降级链失效前面提到isSupported在低端机从 true 变 false。我们的降级链见第 35 篇依赖isSupported判定升级后低端机原本能跑 3DGS虽然卡现在直接走 Mesh 降级。这事本身是好事——低端机跑 3DGS 体验确实差走 Mesh 更合理。但用户感知是3D 效果没了有用户投诉。我们的处理是在 changelog 里写明低端设备 3D 展示优化为更流畅的方案并在首次进入时弹一次提示。接口语义变更的影响要传导到用户沟通不能只在代码层面改完就完事。迁移检查清单迁移完用这个清单逐项核对避免漏改。// entry/src/main/ets/migration/MigrationChecklist.ets// 迁移自检清单逐项确认exportclassMigrationChecklist{// 1. loadGSNode 第二参是否都改成对象// 全局搜索 loadGSNode(逐个核对第二参是 {uri, offset} 不是 stringstaticcheckLoadGSNode():boolean{// 用 grep 搜 loadGSNode( 调用点人工核对returntrue;// 已确认 23 处全部改完}// 2. loadPlugin 重复调用是否都 try-catchstaticcheckLoadPlugin():boolean{// 搜 loadPlugin(确认外层有 try-catch PluginAlreadyLoadedreturntrue;}// 3. position/rotation/scale 赋值后是否避免立即读staticcheckPositionRead():boolean{// 搜 .position 确认后续没有同步读 .positionreturntrue;}// 4. 并发 session 创建是否改成 catch SessionConflictstaticcheckSessionConflict():boolean{// 搜 new ReconSession确认 catch 了 SessionConflictreturntrue;}// 5. renderToImage 是否都指定了 width/heightstaticcheckRenderToImage():boolean{// 搜 renderToImage(确认传了 {width: 512, height: 512} 之类returntrue;}// 6. 废弃接口是否都替换staticcheckDeprecated():boolean{// 搜 loadFromMp4 / setProgressCallback / setVisible应为 0 处returntrue;}}这份清单看着蠢但迁移第 3 天我们靠它发现了 2 处漏改。人肉改 8000 行代码不可能不漏清单是兜底。注意一下下哦接口参数从简单类型改成对象时每个字段显式赋值不依赖默认值默认值语义可能和旧接口不一致position赋值在 API 26 下一帧生效赋值后不能同步读要await nextFrame()loadPlugin重复调用 API 26 抛异常要 try-catchPluginAlreadyLoaded不能只判返回值并发 session 创建 API 26 抛SessionConflict不能只判 null否则直接崩renderToImage必须指定 width/height默认 1080p 内存峰值 200 MB端侧易 OOMisSupported语义从查 SDK 改成查 SDK 硬件低端机返回值会变降级链要重新验证废弃接口在 API 26 还能用但 API 27 会移除这次迁掉避免下次再迁行为变更的影响要传导到用户沟通不能只在代码层面改用户感知到的变化要写 changelog