# 〖共创稿事节〗HarmonyOS 7 新特性实战(47):把装机门临时提到 26,让 linter 把迁移债一次吐干净

我原本以为这个工程的 API 26 迁移已经做完了。targetSdkVersion 写着 26.0.0,需要按新版分支处理的地方都用 deviceInfo.sdkApiVersion 判断过了,构建一路全绿。直到有一天把 compatibleSdkVersion 也临时改到 26,重新编一遍——31 条警告冒出来,其中 5 条是我完全没意识到的。

问题出在一个容易忽略的地方:linter 按哪个版本检查,取决于 compatibleSdkVersion,而它同时是装机门。

官方文档:应用版本兼容性配置(核对日期 2026-10-29)

一、装机门有两副面孔

compatibleSdkVersion 在构建配置里只是一个字符串,但它同时决定两件毫不相干的事:

它决定什么表现谁在关心
这台设备能不能装低于该版本的设备直接装不上用户、应用市场
linter 按哪个口径检查检查规则集、弃用 API 清单都跟着走编译器

第二件事是我之前没意识到的。compatibleSdkVersion 是 6.0.0(20),linter 就老老实实按 API 20 的规则集检查——那些"API 26 起建议改用 XX"的提示,一条都不会出现,因为按 20 的口径,它们本来就不成立。

这不是 bug,是设计。你告诉编译器"我要兼容到 20",它就不该拿 26 的规矩挑你的刺。

但对迁移来说这是个陷阱:targetSdkVersion 提到 26 只说明"我愿意按 26 的行为跑",不代表代码里没有 26 才废弃的用法。想看到这些债,得让 linter 站到 26 的角度去看。

compatibleSdkVersion 的两副面孔

二、先踩一个格式坑

改之前我随手写了个 "26.0",两段。构建直接拒绝:

> hvigor ERROR: 00306042 Specification Limit Violation
Error Message: In HarmonyOS configuration:
- For API versions 10 to 25, the value of compatibleSdkVersion/targetSdkVersion
  must be a string (for example, '5.0.0(12)').
- For API version 26 and later, the value of compatibleSdkVersion/targetSdkVersion
  must be a string (for example, '26.0.0').

这条报错把规则说全了,而且是两种互斥的写法:

API 区间合法写法例子
10 – 25三段 + 括号里的数字'5.0.0(12)'
26 及以上纯三段,不带括号'26.0.0'

跨过 26 这道坎,写法就得换。这一点靠猜是猜不出来的,"26.0"、"26.0.0(26)" 都会被拒。改成 "26.0.0" 后格式校验通过,linter 这才开始按 26 的口径检查。

三、31 条警告,只有 5 条是债

拿到手的 31 条按文件分布如下:

出警告的模块条数警告正文
音频路由监控21The system capacity of this api 'audio' is not supported on all devices
异兽超分查看器54×'px2vp' has been deprecated + 1×'getMediaContent' has been deprecated
空间音频封装31×module for 'libshanhaiaudio.so' is not verified + 2×Function may throw exceptions
讲解服务1The system capacity of this api 'media' is not supported on all devices
沉浸式外框1This API is unavailable to 2in1

合计 COMPILE RESULT:FAIL {ERROR:1 WARN:31}。31 条看着吓人,但按"要不要动代码"分,是两类完全不同的问题:

  • 5 条 deprecated 是真债。 SDK 26 里有明确的替代 API,现在是警告,将来可能是错误。必须改。
  • 其余 26 条不是"你写错了"。 其中 21 条 audio + 1 条 media 是设备容量提示——这类能力在部分设备上不存在;1 条 2in1 不可用;1 条 napi 模块未验证(SDK 后续版本才启用校验);2 条 Function may throw exceptions 是提醒补异常处理。它们要的答案是能力探测 + 降级——代码里那唯一一处 getDevicesSync 本来就包在 try/catch 里,取不到就返回上一次快照。

把这 26 条当债去"修",只会写出毫无意义的空判断或多余的 try/catch。这一点值得单独说:同一份警告列表里,性质不同的东西混在一起,先分类再动手,比逐条处理快得多。

31 条警告分成 5 条真债与 26 条能力提示

四、替代项不要猜,去读 d.ts

5 条 deprecated 集中在同一个文件。我没有查文档,直接翻了 SDK 的 .d.ts——被弃用的方法上面都有 @useinstead 字段,写着该换成什么。这一步值得强调:编译器的建议和它想让你用的替代品是同一份数据,查文档拿到的可能是过时描述,读 d.ts 拿到的是编译器真正据以判定的那条。

px2vp 那 4 处在一段避让区计算里,四行连着出现:

private loadSafeInsets(): void {
  try {
    const hostWindow: window.Window = this.getUIContext().getHostContext() as window.Window;
    const types: window.AvoidAreaType[] = [
      window.AvoidAreaType.TYPE_SYSTEM,
      window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR
    ];
    let top: number = 0;
    let bottom: number = 0;
    let left: number = 0;
    let right: number = 0;
    for (const type of types) {
      const area: window.AvoidArea = hostWindow.getWindowAvoidArea(type);
      // 改前是全局 px2vp(...),SDK 26 linter 标 deprecated
      top = Math.max(top, this.getUIContext().px2vp(area.topRect.height));
      bottom = Math.max(bottom, this.getUIContext().px2vp(area.bottomRect.height));
      left = Math.max(left, this.getUIContext().px2vp(area.leftRect.width));
      right = Math.max(right, this.getUIContext().px2vp(area.rightRect.width));
      hilog.info(SR_LOG_DOMAIN, 'ShanhaiSR',
        'avoid type=%{public}d t=%{public}d b=%{public}d l=%{public}d r=%{public}d',
        type, area.topRect.height, area.bottomRect.height,
        area.leftRect.width, area.rightRect.width);
    }
    this.safeTop = top;
    this.safeBottom = bottom;
    this.safeLeft = left;
    this.safeRight = right;
  } catch (error) {
    const failure = error as BusinessError;
    hilog.warn(SR_LOG_DOMAIN, 'ShanhaiSR', 'avoid area unavailable code=%{public}d', failure.code);
  }
}

px2vp 这类全局函数的问题在于它拿不到 UIContext——它只能按某种进程级的默认密度换算。在多窗口、多实例场景下,同一个数值在不同窗口里应该得到不同的 vp 结果,而全局函数给不出这个区分。改成 UIContext 的实例方法后,密度来源就明确了:是这个界面实例所在的窗口在换算。

getMediaContent 那处在解码原图的方法里,改完连带把整条解码链看得更清楚:

// 解码期就降到低清(长边 SR_INPUT_LONG_SIDE),按原始长宽比缩放,不做拉伸变形。
private async loadLowRes(): Promise<image.PixelMap | undefined> {
  try {
    const context = this.getUIContext().getHostContext();
    if (context === undefined) {
      this.note = '无法获取应用上下文,请稍后再试。';
      this.phase = ShanhaiSuperResolutionPhase.FAILED;
      return undefined;
    }
    // 改前:await context.resourceManager.getMediaContent(...)
    // 改后:同步版,SDK 26 linter 建议的替代
    const raw = context.resourceManager.getMediaContentSync(shanhaiBeastImage(this.beastId));
    const source = image.createImageSource(raw.buffer as ArrayBuffer);
    const info = await source.getImageInfo();
    this.srcW = info.size.width;
    this.srcH = info.size.height;
    // 拿到原图宽高就定屏幕方向,不等 4 倍重建跑完 —— 否则用户会先看一小条图
    this.syncOrientation(info.size.width, info.size.height);
    const longSide = Math.max(info.size.width, info.size.height);
    const ratio = SR_INPUT_LONG_SIDE / longSide;
    const options: image.DecodingOptions = {
      desiredSize: {
        width: Math.max(1, Math.round(info.size.width * ratio)),
        height: Math.max(1, Math.round(info.size.height * ratio))
      }
    };
    const pixelMap = await source.createPixelMap(options);
    await source.release();
    this.lowRes = pixelMap;
    this.pane = SR_PANE_LOW;
    return pixelMap;
  } catch (error) {
    const failure = error as BusinessError;
    console.error('[shanhai-sr] decode failed code=' + failure.code + ' message=' + failure.message);
    this.note = '这张插画暂时没能打开,请稍后再试。';
    this.phase = ShanhaiSuperResolutionPhase.FAILED;
    return undefined;
  }
}

异步改同步,一开始我有点犹豫——不会卡住 UI 线程吗?实际这处读的是一张压缩过的图片资源,几十 KB 的量级,同步读完的开销比走一次 Promise 往返还小;而且紧接着的 createPixelMap 和 4 倍重建本身就是异步的,这一处省掉的往返不会改变整体的响应形态。SDK 把它标成 deprecated since 20 并推同步版,本身就是基于这个判断。

改的过程中还有个容易看错的地方:紧跟着的 syncOrientation(...) 里的 sync 和这次改动没有关系,它指的是「让屏幕方向和图片长宽比同步」,不是「同步调用」。同一个词在同一段代码里出现两种含义,我在改到这里时才意识到名字会误导人。

改完再编一次:BUILD SUCCESSFUL,ArkTS:WARN 计数 0,deprecated 计数 0。

五、改完必须还原,而且得证明还原了

这一步是最容易忘的。compatibleSdkVersion 是装机门,提到 26 之后所有低于 26 的设备都装不上;更隐蔽的是,代码里那些 deviceInfo.sdkApiVersion < 26 的判断会全部变成死代码——分支还在,但永远走不到。

还原之后我做了逐字节比对,和改之前的备份 diff 完全一致。这不是形式主义:做实验改的文件,必须能证明它回到了原样,否则"临时改一下"会变成一次没人发现的静默变更。

有个细节帮了大忙:这个文件在本机一直在 git 的 skip-worktree 里(git ls-files -v 前缀是 S),因为它要写本机签名路径,不能提交。这意味着改动不会污染仓库,git status 也看不见它——好处是不用担心误提交,代价是必须自己负责确认还原,git 不会提醒你。

六、换一条渠道再查一遍

linter 报完 31 条,我手上的信息是"规则认识的弃用项都清了"。但我不知道规则不认识的东西还有没有。

于是换了另一条路:用对话式 Agent 读一遍代码。用的是 DevEco Code 的 plan 模式——只读,不改文件。选 plan 而不是默认模式有个现实原因:当时另一个 agent 正在同一个仓库里写代码,不能有两个写者。

问的问题就是把这次迁移说清楚:compatibleSdkVersion 从 6.0.0(20) 提到 26.0.0,主模块的 ArkTS 源码里有哪些 deprecated API 要迁移,给文件 + 行号 + 替代写法。

它的回答分两半,第一半让我愣了一下:

它说 px2vp 和 getMediaContent 不用改。理由是这两处已经是 this.getUIContext().px2vp(...) 和 getMediaContentSync(...)。它说的没错——因为它看到的是修完之后的代码。它跑的时候,第四节那 5 处已经改完了。这是纯读码分析的一个固有局限:它不知道历史,只能看到当前快照。

第二半是 linter 一条都没报的:两处 @ohos.deviceInfo 旧导入路径。

出现位置行原写法改为
首页入口3import deviceInfo from '@ohos.deviceInfo'import { deviceInfo } from '@kit.BasicServicesKit'
异兽超分查看器6同上同上(该文件第 3 行已从 @kit.BasicServicesKit 导 BusinessError,合并为一行)

这个改动看着简单,实际要处理一个细节:超分查看器那个文件的顶部本来就有一行从 @kit.BasicServicesKit 导入 BusinessError,新加的 deviceInfo 必须并进同一行,不能另起一行重复导入同一个模块——重复导入在 ArkTS 里是编译错误,不是警告。改完的导入区是这样:

import { image } from '@kit.ImageKit';
import { imageSuperResolution, visionBase } from '@kit.CoreVisionKit';
// 原先这里是 import { BusinessError } from '@kit.BasicServicesKit';
// 外加一行 import deviceInfo from '@ohos.deviceInfo'; 合并成一行
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
// 下面还有一行导入工程自己的异兽插画资源表,与本次迁移无关,略

调用处一行都不用动——deviceInfo.sdkApiVersion 这种写法两种导入方式都支持,改的只是 deviceInfo 这个标识符从哪儿来。这也是这类迁移不难却容易漏的原因:它在调用处没有任何痕迹,只有导入区那一行能看出来。

它还给了个我没注意到的旁证:同一个工程里另外两个文件早就是 @kit.BasicServicesKit 的新写法,只有这两处漏了。这个旁证的价值在于——如果全仓库都是旧写法,那是"时代问题";只有两处漏了,那是"漏改",后者才值得改。

这个发现比"多改两行"更有价值的地方在于:它说明了同一份代码里两种写法并存。新的 @kit.* 聚合导入是后来的推荐写法,旧的 @ohos.* 分散导入并没有被标 deprecated,所以 linter 完全不管。规则管不到这种"风格漂移",但读整个仓库能看出来。顺带一提,@kit.* 这种聚合式导入是 API 12 之后主推的形态,它把同一套能力收进一个 Kit 命名空间,代价是从旧文档抄来的导入语句不会报错、只是不会更新。

两条渠道的对照

检查项linter(编译期事实)对话式 Agent(读码分析)
px2vp ×4✅ 5 条 deprecated❌ 已修完,看不到
@ohos.deviceInfo ×2❌ 一条不报✅ 抓到,并给了同仓库旁证
设备容量(audio / media)✅ 26 条未提及
2in1 不可用接口✅ 1 条未提及

两边都只看到一半,而且是不重叠的那一半。linter 靠规则命中,报得准、不漏它认识的项,但只覆盖有规则的;Agent 读码靠语义理解,能发现"不一致"这种规则表达不出来的问题,但看不见改动历史,也拿不到编译器级别的设备能力清单。

两条渠道的覆盖对照

七、改完的验证

导入改完,全量搜一遍 @ohos. 前缀,只剩一条注释里提到 @ohos.measure,实际导入路径清零。

  • 构建:BUILD SUCCESSFUL in 21 s 822 ms,0 ERROR
  • 门禁:项目里 50 个 verify-*.mjs 全部通过
  • 模拟器(phone 实例,API 26):安装成功 → 启动成功 → 日志 446 行中 0 条 jscrash / Unhandled,ForegroundLifecycle EntryAbility 与 UIContent OnNewWant 都在位,进程稳定 3 分钟未重启

模拟器画面停在了锁屏,没取到应用界面截图。查了日志,是模拟器实例的 UpdateWindowVisible: 0,不是应用没起来——ForegroundLifecycle 和 OnNewWant 都在,进程存活,能证明的是"装得上、起得来、不崩"。界面截图这一环我没有硬凑。

八、这套做法没覆盖的部分

  • 只查了主模块。主模块之外的代码没进这次扫描范围,如果工程里还有别的模块,迁移债统计不完整。
  • os.* 前缀的新 API 一个都没用。API 26 引入的新能力这次没碰,这篇只处理"旧用法要不要换",不涉及"新能力要不要用"。
  • 对话式 Agent 的结论没有全部核实。它说"px2vp 已迁移"是对的(当时确实改完了),但我没法假设它的每一条判断都准确——这次只采纳了它给的 @ohos.deviceInfo 那两处,并且逐条核对过行号和写法。AI 给的清单要当线索用,不能当结论用。
  • 模拟器版本 vs 真机。模拟器是 phone 实例 API 26,真机上"低于 26 的会不会走到降级分支"这件事,靠模拟器验证不了。

九、收个尾

compatibleSdkVersion 写多少,决定了 linter 用哪一套规矩审你。装机门和检查口径是同一个字段的两副面孔——想看清 26 的债,就得让检查站到 26 的位置上,看完再把它放回去,并且能证明放回去了。

下一篇把这条线索往回接:上架包怎么做到可追溯——签名、APP 构建、预检和版本回滚。

Logo

更多推荐