我们维护的 3DGS 商品展示应用跑在 API 25 上半年了,存量代码 8000 多行。HarmonyOS 7(API 26)发布后,IDE 提示 Spatial Recon Kit 有更新,我们顺手把compileSdkVersion改成 26,结果编译报 47 个错。不是少导了什么,是 Kit 接口真的改了。这篇文章记录我们把 API 25 存量代码迁到 API 26 的过程,重点是那些接口名没变但语义变了的坑——编译能过,行为静默错误,比编译失败难排查十倍。
能力面:API 26 破坏性变更清单
迁移前先把变更摸清楚。我们对照 API 25 和 26 的 d.ts 文件 diff,整理出三类变更。
接口签名变更(编译期可发现)
| 接口 | API 25 | API 26 | 影响 |
|---|---|---|---|
GSPlugin.loadGSNode | (scene, uri: string, parent) | (scene, opts: {uri, offset}, parent) | 第二参从 string 改成对象 |
TiledGSImportSettings.uri | 仅支持file:// | 支持file://和OhosRawFile:// | 兼容性扩展,无破坏 |
GSNode.renderToImage | 无此接口 | 新增 | 新能力,无破坏 |
ReconSession.config | config: ReconConfig | config: ReconConfig | undefined | 允许 undefined,向后兼容 |
ReconSession.start | 返回Promise<void> | 返回Promise<ReconResult> | 返回值多了结果对象 |
这类变更编译期会报错,照着改就行。真正麻烦的是下面两类。
行为变更(编译期不可见)
| 接口 | API 25 行为 | API 26 行为 | 风险 |
|---|---|---|---|
loadPlugin重复调用 | 返回 false,无副作用 | 抛异常PluginAlreadyLoaded | 旧代码 try-catch 没接住异常 |
GSNode.position赋值 | 立即生效 | 下一帧生效 | 依赖立即生效的逻辑会闪一帧 |
ReconSession并发创建 | 第二个返回 null | 第二个抛SessionConflict | 旧代码判 null 走降级,现在直接崩 |
renderToImage默认分辨率 | 无此接口 | 默认 1080p | 不指定尺寸时内存峰值高 |
isSupported返回值 | 仅查 SDK | 查 SDK + 硬件 | 低端机从 true 变 false |
isSupported这条最坑。API 25 时它只查 SDK 是否包含 Kit,返回 true;API 26 改成查 SDK + 硬件,低端机返回 false。我们存量代码靠isSupported判定走 3DGS 还是降级,升级后低端机原本走 3DGS(虽然卡)现在直接走降级,用户感知是"3D 效果没了"。
废弃 API(编译期 warning)
| 接口 | API 26 状态 | 替代方案 |
|---|---|---|
GSPlugin.loadFromMp4 | deprecated | 用loadGSNode统一入口,uri 指向 mp4 |
ReconSession.setProgressCallback | deprecated | 用on('progress', cb)事件订阅 |
GSNode.setVisible | deprecated | 用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}from'@kit.ArkGraphics3D';import{spatialRender}from'@kit.SpatialReconKit';@Entry@Componentstruct ProductViewer{privatescene:Scene|null=null;privatepluginLoaded:boolean=false;aboutToAppear():void{constctx:RenderContext|null=Scene.getDefaultRenderContext();if(ctx===null){return;}// API 25: 重复调用返回 false,这里写了个 if 判断if(!this.pluginLoaded){ctx.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);this.pluginLoaded=true;}Scene.load().then(async(scene:Scene)=>{this.scene=scene;constroot:Node=scene.rootasNode;// API 25: loadGSNode 第二参是 stringconstgsNode=awaitspatialRender.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}from'@kit.ArkGraphics3D';import{spatialRender}from'@kit.SpatialReconKit';@Entry@Componentstruct ProductViewer{privatescene:Scene|null=null;privatepluginLoaded:boolean=false;aboutToAppear():void{constctx:RenderContext|null=Scene.getDefaultRenderContext();if(ctx===null){return;}// 变更 1:API 26 重复调用抛异常,要 try-catchif(!this.pluginLoaded){try{ctx.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);this.pluginLoaded=true;}catch(e){// 已加载属于正常情况,不报错if(e.code!=='PluginAlreadyLoaded'){console.error('load plugin failed: '+e.message);}this.pluginLoaded=true;}}Scene.load().then(async(scene:Scene)=>{this.scene=scene;constroot:Node=scene.rootasNode;// 变更 2:loadGSNode 第二参改成对象constgsNode=awaitspatialRender.GSPlugin.loadGSNode(scene,{uri:'OhosRawFile://assets/teapot.glb',offset:0},root);gsNode.position={x:0,y:-0.2,z:0};// 变更 3:position 赋值下一帧才生效,不能立即读// 如果逻辑依赖立即生效,要改成 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 米,排查了两小时才发现。
迁移不是改完编译错误就结束。把这条链路画出来,能看清行为回归为什么占了大头。
从编译到行为回归是一条串行链。有差异就回到回归环节,直到全部用例通过。
真机数据:迁移前后行为差异
迁移完跑了真机回归,重点验证 5 处行为变更。
| 行为变更 | API 25 表现 | API 26 表现 | 是否符合预期 |
|---|---|---|---|
loadPlugin重复调用 | 返回 false | 抛异常 | 是,但需 try-catch |
position立即读 | 读到新值 | 读到旧值 | 是,但易引入 bug |
| 并发 session | 第二个 null | 第二个抛异常 | 是,错误更明确 |
isSupported低端机 | true | false | 是,更准确 |
renderToImage默认分辨率 | 无此接口 | 1080p,内存 200MB | 否,应默认 512 |
renderToImage默认 1080p 这条我们提了 issue。新接口默认分辨率太高,端侧调用不指定尺寸时内存峰值 200 MB,容易 OOM。建议默认 512 或在文档强调要指定尺寸。目前看 Kit 团队认可,可能 API 27 改。
真机数据:编译错误分布
47 个编译错误的分布,给同样要迁移的人参考。
| 错误类型 | 数量 | 占比 |
|---|---|---|
| 参数类型不匹配(string vs object) | 18 | 38% |
| 接口不存在(废弃接口未替换) | 12 | 26% |
| 返回值类型不匹配 | 9 | 19% |
| 缺少新增必填字段 | 5 | 11% |
| 其他 | 3 | 6% |
参数类型不匹配占 38%,都是loadGSNode第二参从 string 改成对象导致的。这类错误改起来快,但容易改漏——有些地方 string 是变量传入的,IDE 不会标红,运行时才崩。我们用全局搜索loadGSNode(逐个核对,又揪出 3 处漏改的。
踩坑与取舍
坑一:接口名没变但参数语义变了
loadGSNode接口名没变,第二参从 string 改成{uri, offset}。我们有一处代码是这样写的:
// API 25 写法,uri 是变量consturi:string=getAssetUri(skuId);constgsNode=awaitspatialRender.GSPlugin.loadGSNode(scene,uri,root);迁到 API 26,IDE 提示第二参类型错,改成:
// 错误改法:把 uri 直接包进对象,但漏了 offsetconstgsNode=awaitspatialRender.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