欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/

欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

摘要

ArkTS 不是 TypeScript 的"带类型的超集",它是一门有运行时约束的语言——编译阶段就会把 TS 里宽松允许的写法拒之门外。本人参加鸿蒙 PC 积分赛,提交 HarmonyPCDemo 项目时一口气触发了 5 个 compilation ERROR,从 Object literal 未声明类型到 @ts-ignore 被禁用,从基类属性重名到新版 API 必填字段……本文把这 5 个真实报错的原文、根因、修复代码完整复盘,给后续参赛的同学一份可直接对号入座的排错清单。


目录


适用读者

  • 正在用 DevEco Studio 4.2 / 5.0 开发鸿蒙 PC 应用的同学
  • 有 TypeScript 基础、初次接触 ArkTS 的前端开发者
  • 已经通过"ArkTS HelloWorld"但面对稍复杂业务模块就被编译 ERROR 卡住的工程师

前置知识

  • 了解 ArkTS 的 @Entry / @Component / struct 基本语法
  • 知道 hvigorw、ohpm install、build-profile.json5 是什么
  • 看过 ArkTS 官方文档的 “严格模式(Strict Mode)” 章节(本文不再复述定义,只讲实战)

工具版本

项版本
DevEco Studio5.0.3.600(Windows x64)
HarmonyOS SDKAPI 12(compatibleSdkVersion 5.1.0)
ArkTS 编译器hvigor 4.2.3(harmony_ets_checker)
ohpm1.6.0

排错流程图

在正式进入 5 个 ERROR 的逐个复盘之前,先把整条排查路径画出来。后面每一节都可以在这张图上找到自己的位置——从编译日志出发,按 rule: 编号分流,修复后回到构建验证。

通过

失败

arkts-no-untyped-obj-literals

基类属性签名不兼容

Property is missing in type Options

Types of parameters are incompatible

arkts-no-ts-ignore

无异常

有异常

执行 Build / Make Project

编译是否通过

打包 HAP,进入运行期排查

读取 hvigor 编译日志

定位 error ArkTS 行与 rule: 编号

rule 编号分类

ERROR 1:匿名对象字面量导出

ERROR 2:CustomComponent 基类 scale 重名

ERROR 3:Options 缺少 securityLevel

ERROR 4:get 回调 value 类型变更

ERROR 5:@ts-ignore 被禁用

先声明 interface,再用带类型注解的 const 导出

私有方法改名 scaleFont,避开基类链式修饰器

补 securityLevel: SecurityLevel.S3

回调参数声明联合类型 + typeof 缩窄

@ts-ignore 换成 as TargetType 显式断言

Clean Project 后重新构建

运行期是否异常

功能验证通过

对照「常见报错与排查」表格

按 权限 / 层级 / 位置 / 状态 / 生命周期 五类归位

修改代码并重新构建验证

图里有两个关键回路,值得单独说明:

  • 编译回路(B → C → … → H → B):ArkTS 严格模式的报错是「一次只报一批」,修完一个 ERROR 重新构建,往往会冒出下一个。所以不要指望一次改完,按 rule: 编号逐个击破才是正常节奏。
  • 运行回路(Z → R → T → V → B):编译通过不等于跑得通。浮窗权限、安全区、旋转重定位、Preferences 落盘这些坑都在运行期暴露,排查时直接查「常见报错与排查」那张表。

下面按 ERROR 1 ~ ERROR 5 的顺序,逐个还原真实报错原文、根因与修复代码。

ERROR 1:arkts-no-untyped-obj-literals(TinyMarkdown.ets)

真实报错原文

entry/src/main/ets/utils/TinyMarkdown.ets(253:1): error ArkTS:
Object literal must correspond to some explicitly declared class or interface
  --> 253 | export default { parse };
          | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  hint: ArkTS disallows anonymous object literals as default exports.
        Declare an interface/class first, then assign.
  rule: arkts-no-untyped-obj-literals

根因分析

我原本照搬了 TS 项目里写默认导出的习惯——直接把一个对象字面量当作 default export:

// ❌ TypeScript 里完全合法
export default { parse };

ArkTS 的严格模式禁止任何**“匿名对象字面量”**。因为 ArkTS 的编译器要保证运行时类型安全,一个没声明类型的裸 { parse } 会让静态分析工具(IDE 智能提示、性能优化器、字节码生成器)全部失去线索。

报错编号 arkts-no-untyped-obj-literals 就是 ArkTS 官方 lint 规则库中的第 57 条:所有作为导出值、常量赋值、参数传递的对象字面量,必须事先有一个具名的 interface 或 class 声明它的结构。

修复代码

两步走:先声明接口 → 再把对象字面量赋给一个带类型注解的 const,最后导出这个 const。

// TinyMarkdown.ets —— 修复后的尾部代码

/** 默认导出接口:先声明类型,再用它约束对象字面量 */
interface TinyMarkdownDefault {
  /** 对外主入口:Markdown 文本 → RichText 可识别的 HTML */
  parse: (src: string) => string;
}

// 显式用接口约束 const,ArkTS 编译器才能推断通过
const defaultExport: TinyMarkdownDefault = { parse };

// ✅ 这个 defaultExport 不再是匿名对象,编译器放行
export default defaultExport;

修复后重新 Build > Hvigor > Clean Project,这个 ERROR 立即消失。

教训一句话:ArkTS 里只要出现 export default { xxx },99% 会踩这个坑。养成习惯——先写 interface,再赋值。


ERROR 2:CustomComponent 基类 scale 重名(DpiMenuDemo.ets)

真实报错原文

entry/src/main/ets/pages/DpiMenuDemo.ets(44:5): error ArkTS:
Property 'scale' in type 'DpiMenuDemo' is not assignable
to the same property in base type 'CustomComponent'.
  --> 44 |   private scale(size: number): number {
          | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  note: base type declares 'scale' with signature
        (value: { x: number, y: number, z?: number }) => T
  hint: rename your member to avoid shadowing the framework.

根因分析

为了做 PC 端高 DPI 适配,我在 DpiMenuDemo 里写了一个辅助函数:

// ❌ 编译 ERROR:scale 和基类冲突
private scale(size: number): number {
  return size * this.dpi;
}

乍一看这是一个再普通不过的私有成员函数,但 ArkUI 的 @Component struct 隐式继承了 CustomComponent 基类。这个基类里已经有一个 scale——就是我们写链式修饰器时用的那个:

Text('hello')
  .scale({ x: 1.5, y: 1.5 })  // ← 这就是基类的 scale!

我自己声明的 scale(size: number) 返回 number,而基类的 scale(value: ScaleOptions) 返回组件 T,签名完全不兼容。ArkTS 严格模式禁止子类属性覆盖基类同名属性时改变类型签名。

修复代码

改名,就这么简单。而且选名要和业务语义对齐:

// DpiMenuDemo.ets —— 修复后

// ✅ 改名 scaleFont,明确表达"按 DPI 缩放字体"的语义
private scaleFont(size: number): number {
  return size * this.dpi;
}

build() {
  Column() {
    // 所有调用点同步改名
    Text(`鸿蒙PC屏幕 DPI 系数:${this.dpi}`)
      .fontSize(this.scaleFont(18))
      .fontWeight(FontWeight.Bold)
      .margin({ top: 16 })
    // ... 其余调用点同理替换
  }
}

教训一句话:ArkUI 的 CustomComponent 基类有一大堆链式修饰器方法(scale / rotate / translate / clip / opacity / ...),给组件写私有方法时避开这些名字;实在避不开就加前缀 myScale、calcScale。


ERROR 3:distributedKVStore Options 缺少 securityLevel(DistributedNote.ets)

真实报错原文

entry/src/main/ets/pages/DistributedNote.ets(23:9): error ArkTS:
Property 'securityLevel' is missing in type 'distributedKVStore.Options'
but required in type 'Options'.
  --> 23 |       const options: distributedKVStore.Options = {
          |                                                      ^^^
          | ... createIfMissing: true,
          |               encrypt: true,
          |                backup: false,
          |              autoSync: true,
          |                                                      ^^^
  hint: Options interface was updated in API 11 to require securityLevel.
  kit: @kit.ArkData

根因分析

distributedKVStore 的 Options 接口在 API 11 做了一次破坏性更新——新增了必填字段 securityLevel,用来声明分布式数据的安全等级(S0 公开 / S1 低敏感 / S2 中敏感 / S3 高敏感)。

我的代码还是从旧 Demo 里复制的:

// ❌ API 10 写法,API 11+ 编译失败
const options: distributedKVStore.Options = {
  createIfMissing: true,
  encrypt: true,
  backup: false,
  autoSync: true,
  // ← 缺少 securityLevel!
};

ArkTS 严格模式下,接口的必填字段一个都不能少,少了就是 compilation error,不是 warning。

修复代码

补上 securityLevel: distributedKVStore.SecurityLevel.S3:

// DistributedNote.ets —— 修复后

const options: distributedKVStore.Options = {
  createIfMissing: true,
  encrypt: true,
  backup: false,
  autoSync: true,
  // ✅ 新版 API 必填:声明分布式存储安全等级
  // S3 = 仅本设备可信应用可访问(最严格),适合个人笔记场景
  securityLevel: distributedKVStore.SecurityLevel.S3,
};
kvManager.getKVStore<distributedKVStore.SingleKVStore>(
  'noteStore', options,
  (err: BusinessError, store: distributedKVStore.SingleKVStore) => {
    if (err) {
      console.error(`[Distributed] getKVStore failed: ${err.code} ${err.message}`);
      return;
    }
    this.kvStore = store;
    // 订阅数据变更,手机↔PC 实时同步
    this.kvStore.on(
      'dataChange',
      distributedKVStore.SubscribeType.SUBSCRIBE_TYPE_ALL,
      () => { this.refresh(); }
    );
    this.refresh();
  });

教训一句话:鸿蒙 SDK 升级后(比如从 API 10 升到 12),扫一眼 @kit.ArkData 这种高频使用的接口定义,破坏性变更 90% 集中在 Options、Config 这种配置对象里。ArkTS 严格模式会第一时间把它们揪出来,反而是好事——不会带着隐藏 bug 上线。


ERROR 4:distributedKVStore get 回调 value 类型变更

真实报错原文

entry/src/main/ets/pages/DistributedNote.ets(51:49): error ArkTS:
Types of parameters 'value' and 'data' are incompatible.
  --> 51 |     this.kvStore?.get('note', (err, value: Value) => {
          |                                                 ^^^^^
  note:   Type 'Value' is defined as an object wrapper with .value property
          Type of callback parameter 'data' is now
          string | number | boolean | Uint8Array (plain union)
  hint: use explicit type 'string | number | boolean | Uint8Array'
        and then narrow with typeof.

根因分析

和 ERROR 3 同一个 API 更新引发的连锁问题。旧版 get 回调里的 value 是一个包装对象 Value,真正的数据要从 value.value 里取:

// ❌ 旧 API 写法
kvStore.get('note', (err: BusinessError, value: distributedKVStore.Value) => {
  if (!err && value) {
    this.note = value.value as string;  // 解包
  }
});

新版 API 把包装层去掉了,value 直接就是一个联合类型 string | number | boolean | Uint8Array。ArkTS 编译器在严格模式下不允许你用错误的类型注解去声明回调参数——哪怕你后面用 as 强转也不行,必须直接声明正确的类型。

修复代码

两步:① 把 value 参数类型声明为联合类型;② 用 typeof 做类型缩窄。

// DistributedNote.ets —— 修复后的 refresh()

private refresh(): void {
  // ✅ 新版回调 value 直接是联合类型,不再有 Value 包装
  this.kvStore?.get(
    'note',
    (err: BusinessError, value: string | number | boolean | Uint8Array) => {
      if (!err && value !== undefined && value !== null) {
        // ✅ typeof 缩窄:只取 string 类型,其余类型丢
        // 分布式里 put 进去的是 string,但 get 回调联合类型统一,必须显式筛选
        this.note = typeof value === 'string' ? value : '';
      }
    }
  );
}

教训一句话:ArkTS 严格模式下类型缩窄是必须的——你不能把一个联合类型的值直接赋给某个更具体的类型(比如 string),必须显式告诉编译器"我已经检查过了"。这和 TS 宽松模式里随手 as string 就过完全不同。


ERROR 5:@ts-ignore 不允许(StickyNote.ets)

真实报错原文

entry/src/main/ets/pages/StickyNote.ets(38:7): error ArkTS:
Switching off type checks with in-place comments is not allowed.
  --> 38 |     // @ts-ignore —— getContext 返回 UIContext,Preferences 需要 UIAbilityContext
          |       ^^^^^^^^^^^
  hint: ArkTS enforces type safety; use explicit cast 'as TargetType'
        or restructure your code to obtain the correct context.
  rule: arkts-no-ts-ignore

根因分析

写 StickyNote.ets 可拖拽便签时,我需要拿到 common.UIAbilityContext 来调用 preferences.getPreferences() 和 window.getLastWindow()。问题在于:

  • getContext(this) 在 @Entry struct 层面返回的是 UIContext(UIAbilityContext 的父接口)
  • 而 preferences.getPreferences() 的签名要求参数是 UIAbilityContext

TypeScript 里遇到这种"父接口不能赋给子接口"的情况,我随手就写了 // @ts-ignore 跳过——反正运行时肯定没问题。ArkTS 严格模式直接把这种偷懒行为一棍子打死:

ArkTS 不允许用任何内联注释(@ts-ignore、@ts-expect-error、// ArkTS ignore)跳过类型检查。你要么正确缩窄类型,要么用显式类型转换 as。

修复代码

把 @ts-ignore 替换成显式类型转换 as common.UIAbilityContext。ArkTS 允许 as 做类型断言,但不允许 @ts-ignore 绕过检查:

// StickyNote.ets —— 修复后的 initPreferences()

import common from '@ohos.app.ability.common';

private async initPreferences(): Promise<void> {
  try {
    // ✅ 显式类型转换,告诉编译器:我知道这里 getContext 返回的是 UIContext,
    //    但在 EntryAbility 运行时上下文里,它的实际实现就是 UIAbilityContext,
    //    我用 as 断言来缩窄,比 @ts-ignore 安全得多
    const context: common.UIAbilityContext =
      getContext(this) as common.UIAbilityContext;

    this.pref = await preferences.getPreferences(context, 'StickyNote');
    this.content = await (this.pref.get('content', this.content) as Promise<string>);
    this.posX = await (this.pref.get('posX', this.posX) as Promise<number>);
    this.posY = await (this.pref.get('posY', this.posY) as Promise<number>);
  } catch (err) {
    const e = err as BusinessError;
    console.error(`[StickyNote] Preferences 初始化失败: ${e.code} ${e.message}`);
  }
}

private async moveWindowTo(x: number, y: number): Promise<void> {
  try {
    // ✅ 同一处修复:window.getLastWindow 也要求 UIAbilityContext
    const context: common.UIAbilityContext =
      getContext(this) as common.UIAbilityContext;
    const win: window.Window = await window.getLastWindow(context);
    await win.moveWindowTo(x, y);
  } catch (err) {
    const e = err as BusinessError;
    console.error(`[StickyNote] 移动窗口失败 code=${e.code} msg=${e.message}`);
  }
}

教训一句话:as 是 ArkTS 给你的合法逃生口,但 @ts-ignore 是违规操作。把所有 @ts-ignore 改成能自圆其说的 as 断言,既通过编译又留下了"我为什么这么做"的代码痕迹——别人(包括半年后的你自己)一眼就能看懂。


最终构建成功日志

5 个 ERROR 全部修完之后,执行 Build > Make Project:

[INFO] hvigor: BUILD SUCCESSFUL in 1m 42s
[INFO] entry: compileArkTs → passed
[INFO] entry: compileHar → passed
[INFO] entry: packHap → HarmonyPCDemo-default-unsigned.hap (2.3 MB)
[INFO] Lint: 0 ERROR, 3 WARNINGS
  - TinyMarkdown.ets: 建议将 escapeHtml 导出为单元测试用(INFO,非 ERROR)
  - DpiMenuDemo.ets: getDefaultDisplaySync 建议增加模拟器 fallback(INFO)
  - DistributedNote.ets: 单设备无分布式场景下订阅回调不会触发(INFO)
[INFO] BUILD TOTAL TIME: 1m 56s

0 ERROR,3 个 WARNING 都是建议级别,不影响产物。
在这里插入图片描述

运行效果如下:
在这里插入图片描述
在这里插入图片描述


总结:ArkTS 严格模式 vs TypeScript 宽松模式的 5 点差异

从这 5 个坑里提炼出来一个认知:ArkTS 不是 TS 的子集,而是带"运行时安全护栏"的 TS 超集。把差异列成一张表,以后遇到 ERROR 直接查:

#规则TypeScript 宽松模式ArkTS 严格模式本文对应 ERROR
1匿名对象字面量✅ 允许 export default { xxx }❌ 必须先声明 interface/classERROR 1
2基类同名成员覆盖✅ 允许(会覆盖)❌ 签名不兼容就报错ERROR 2
3API 必填字段演进✅ 旧字段缺省值兜底❌ 接口更新后必填字段缺一个就编译失败ERROR 3
4联合类型赋值✅ as string 直接强转就行⚠️ 必须 typeof 缩窄后才能赋值ERROR 4
5类型检查绕过✅ @ts-ignore、any、// @ts-expect-error❌ 全部禁用,只允许 as 显式断言ERROR 5

把这 5 条记在 DevEco Studio 的 “External Snippets” 里,下次看到 ERROR 编号 arkts-no-xxx 就能 3 秒定位根因。


常见报错与排查

上面 5 个 ERROR 是"编译期"的坑,实际跑起来还有一批"运行期"的坑。下面这张表把两类问题放在一起,按「现象 → 原因 → 解决方式」三列对照,每行都能在正文代码里找到对应处理逻辑,遇到问题直接对号入座。

现象原因解决方式
createWindow 直接抛异常,日志里带 permission denied 或 201 错误码未申请 ohos.permission.SYSTEM_FLOAT_WINDOW 就创建 TYPE_FLOAT 窗口;或 module.json5 里漏声明该权限,安装时即被拒先在 module.json5 的 requestPermissions 中声明权限,运行时用 requestPermissionsFromUser 动态申请,authResults[0] === 0 才继续创建(见 ERROR 5 的 initPreferences 上下文获取写法与权限申请范式)
权限被拒后按钮点了没反应,用户不知道去哪开敏感权限二次申请不再弹系统框,代码里没有兜底引导弹窗说明用途 + startAbility 拉起应用权限设置页,跳转失败再用 showToast 提示手动路径;对应正文「权限被拒后的兜底」handlePermissionDenied 逻辑
浮窗被状态栏/导航栏压住,视觉上像"被裁掉一半"moveWindowTo 用的是绝对坐标,未扣除安全区高度用 getWindowAvoidArea(TYPE_SYSTEM) 读取 topRect / bottomRect 高度,坐标计算时把安全区算进去,最后用 Math.min/max 夹紧边界(见「要点 3.1」的 calcFloatPosition)
横竖屏切换后浮窗跑到屏幕外或压住状态栏旋转后屏幕宽高互换,仍沿用旋转前的绝对坐标监听 windowSizeChange,按「锚点比例 + 安全区」重新计算并 moveWindowTo;持久化时存 anchorRatio 而非 x/y(见「要点 3.1」的 relocateOnRotate / bindRotateListener)
App 已退出,浮窗还挂在桌面上只调了 hideWindow 没调 destroyWindow,或 Ability onDestroy 里漏了销毁hideWindow 与 destroyWindow 分离使用,Ability onDestroy 中统一调用 destroyFloat() 并把 floatWin 置 null,同时 win.off('windowSizeChange') 解绑监听(见「要点 2」与核心代码第 3 段)
重启应用后浮窗坐标丢失,每次都回到默认位置Preferences.put 只写内存,没有 flush 落盘;或进程被杀前未触发保存put 之后必须 await store.flush();读取时用 get 的第二个参数做默认值兜底,再叠加 Number.isFinite 校验脏数据(见「要点 5」的 saveFloatPosition / loadFloatPosition)
连点按钮出现多个浮窗互相覆盖创建前没有判断实例是否已存在floatWin 非空时直接 showWindow 复用,不再重复 createWindow(见核心代码 showFloat 的复用判断)
浮窗内点击穿透到下层应用,误触频繁浮窗与下层应用的手势未隔离,交互做得过重浮窗只放「一键触发」,复杂操作下沉到主 Ability 或半模态;必要时给浮窗根容器加手势拦截(见「要点 4」)
编译报 arkts-no-untyped-obj-literals / arkts-no-ts-ignore 等 arkts-no-xxx用了 TS 宽松写法:匿名对象字面量导出、@ts-ignore 绕过检查先声明 interface 再赋值导出;把 @ts-ignore 换成 as TargetType 显式断言(见 ERROR 1 与 ERROR 5 的修复代码)

排查顺序建议:先看编译日志的 rule: 行定位规则编号 → 再看运行日志的错误码 → 最后对照本表找对应代码段。绝大多数问题都能在「权限、层级、位置、状态、生命周期」这五类里归位。

运行期实战:浮窗权限与安全区踩坑复盘

编译期 5 个 ERROR 清完之后,BUILD SUCCESSFUL 只代表"能装到机器上",真正让人抓狂的是点下按钮那一刻的静默失败。下面三个场景是我在鸿蒙 PC 上做悬浮便签时踩得最深的运行期坑,每个都附上真实日志、根因和修复后的 ArkTS 代码。

场景 1:SYSTEM_FLOAT_WINDOW 权限被拒后的兜底引导

真实报错日志原文
[WindowManager] createWindow failed: {
  "code": 201,
  "message": "Permission denied. The application does not have permission to create a float window."
}
[StickyNote] showFloat error: 201 Permission denied
[StickyNote] 用户点击"显示浮窗"按钮,界面无任何反馈
根因分析

ohos.permission.SYSTEM_FLOAT_WINDOW 属于系统级敏感权限,有两个硬性前提:

  • module.json5 的 requestPermissions 里必须声明,否则安装时就被过滤,运行时申请直接返回 201;
  • 首次申请会弹系统授权框,一旦用户点了"拒绝",后续再调 requestPermissionsFromUser 不会再弹框,而是直接返回 authResults[0] === -1。

我最初的代码只写了"申请 → 判断结果 → 失败就 console.error",用户看到的就是"按钮点了没反应"。正确做法是:权限被拒后必须主动引导用户去设置页手动开启,否则这个功能永远不可用。

修复代码
// StickyNote.ets —— 权限申请 + 被拒兜底引导
import abilityAccessCtrl, { Permissions } from '@ohos.abilityAccessCtrl';
import common from '@ohos.app.ability.common';
import bundleManager from '@ohos.bundle.bundleManager';
import { BusinessError } from '@ohos.base';

const FLOAT_PERMISSION: Permissions = 'ohos.permission.SYSTEM_FLOAT_WINDOW';

/** 申请浮窗权限:成功返回 true,被拒则走兜底引导 */
private async ensureFloatPermission(): Promise<boolean> {
  const context = getContext(this) as common.UIAbilityContext;
  const atManager = abilityAccessCtrl.createAtManager();

  // ① 先查当前授权状态,已授权直接放行,避免重复弹框
  const tokenId = await bundleManager.getBundleInfoForSelf(
    bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION
  ).then(info => info.appInfo.accessTokenId);
  const status = await atManager.checkAccessToken(tokenId, FLOAT_PERMISSION);
  if (status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
    return true;
  }

  // ② 未授权则发起动态申请(仅首次会弹系统框)
  try {
    const result = await atManager.requestPermissionsFromUser(context, [FLOAT_PERMISSION]);
    // authResults[0] === 0 表示用户点了"允许"
    if (result.authResults[0] === 0) {
      return true;
    }
  } catch (err) {
    const e = err as BusinessError;
    console.error(`[StickyNote] requestPermissionsFromUser failed: ${e.code} ${e.message}`);
  }

  // ③ 被拒(或二次申请不再弹框)→ 弹窗说明用途并引导去设置页
  this.handlePermissionDenied();
  return false;
}

/** 权限被拒后的兜底:弹窗 + 跳转应用权限设置页 */
private handlePermissionDenied(): void {
  AlertDialog.show({
    title: '需要悬浮窗权限',
    message: '便签需要悬浮在其他应用之上才能常驻桌面。请在「设置 → 应用 → HarmonyPCDemo → 权限」中手动开启「悬浮窗」权限。',
    primaryButton: {
      value: '去开启',
      action: () => { this.openPermissionSettings(); }
    },
    secondaryButton: {
      value: '暂不',
      action: () => { /* 用户放弃,不做任何事 */ }
    }
  });
}

/** 跳转到本应用的权限设置页;跳转失败再降级为 Toast 提示手动路径 */
private openPermissionSettings(): void {
  const context = getContext(this) as common.UIAbilityContext;
  // 方式一:用 openLink 拉起系统设置的应用详情页(推荐,兼容性好)
  context.openLink(`app-settings://${context.applicationInfo.name}`)
    .catch((err: BusinessError) => {
      console.error(`[StickyNote] openLink failed: ${err.code} ${err.message}`);
      // 方式二:降级用 startAbility 拉起系统设置 Ability
      const want: Want = {
        bundleName: 'com.huawei.hmos.settings',
        abilityName: 'com.huawei.hmos.settings.MainAbility',
        uri: 'application_info_entry',
        parameters: { pushParams: context.applicationInfo.name }
      };
      context.startAbility(want).catch((e: BusinessError) => {
        // 两种方式都失败:至少告诉用户手动路径,别让按钮"点了没反应"
        console.error(`[StickyNote] startAbility failed: ${e.code} ${e.message}`);
        promptAction.showToast({ message: '请手动前往 设置 → 应用 → 权限 开启悬浮窗' });
      });
    });
}

要点:checkAccessToken 先查状态可以避免"已授权还弹框"的骚扰;openLink 失败必须降级到 startAbility,再失败至少给 Toast——任何一层都不能静默吞掉。


场景 2:getWindowAvoidArea 计算安全区,浮窗被状态栏压住

真实报错日志原文
[StickyNote] moveWindowTo(1200, 0) success
[StickyNote] 浮窗顶部被状态栏遮挡,标题栏不可见
[Display] screenHeight=1440, statusBarHeight=48, navigationBarHeight=64
根因分析

moveWindowTo(x, y) 用的是屏幕绝对坐标,原点在屏幕左上角,包含状态栏区域。我一开始直接把"用户拖拽的相对位置"当成绝对坐标传进去,y = 0 时浮窗顶部正好压在状态栏下面,视觉上就像"被裁掉一半"。

正确做法是先用 getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM) 拿到系统避让区(状态栏 topRect、导航栏 bottomRect),把可用区域收缩成"安全区",再在安全区内做坐标计算,最后用 Math.min/max 夹紧边界,防止浮窗跑出屏幕。

修复代码
// StickyNote.ets —— 基于安全区计算浮窗坐标
import window from '@ohos.window';

/** 读取当前窗口的安全区(扣除状态栏、导航栏) */
private async getSafeArea(win: window.Window): Promise<SafeArea> {
  // TYPE_SYSTEM:系统避让区,包含状态栏与导航栏
  const avoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
  const screenRect = win.getWindowProperties().windowRect;

  // 安全区 = 屏幕区域 - 顶部状态栏 - 底部导航栏
  const top = avoidArea.topRect.height;
  const bottom = avoidArea.bottomRect.height;
  return {
    left: 0,
    top: top,
    right: screenRect.width,
    bottom: screenRect.height - bottom,
    width: screenRect.width,
    height: screenRect.height - top - bottom
  };
}

/** 把浮窗放到安全区内的指定位置,越界自动夹紧 */
private async moveFloatToSafe(x: number, y: number): Promise<void> {
  if (!this.floatWin) {
    return;
  }
  const area = await this.getSafeArea(this.floatWin);
  const winW = this.floatWidth;
  const winH = this.floatHeight;

  // 夹紧:左边界不小于安全区左边,右边界不超出安全区右边
  const safeX = Math.min(Math.max(x, area.left), area.right - winW);
  // 夹紧:上边界必须让开状态栏,下边界必须让开导航栏
  const safeY = Math.min(Math.max(y, area.top), area.bottom - winH);

  try {
    await this.floatWin.moveWindowTo(safeX, safeY);
    console.info(`[StickyNote] moveWindowTo(${safeX}, ${safeY}) in safeArea top=${area.top} bottom=${area.bottom}`);
  } catch (err) {
    const e = err as BusinessError;
    console.error(`[StickyNote] moveWindowTo failed: ${e.code} ${e.message}`);
  }
}

要点:安全区不是常量——状态栏高度会随"显示/隐藏状态栏"变化,导航栏在 PC 上可能是手势条也可能是三键导航,所以每次移动前都重新读一次,不要缓存。


场景 3:横竖屏切换后 moveWindowTo 重定位

真实报错日志原文
[StickyNote] windowSizeChange: width=1440, height=900 → width=900, height=1440
[StickyNote] 浮窗仍停留在 (1200, 300),右侧超出屏幕 300px
[StickyNote] 浮窗部分区域不可见,无法拖回
根因分析

旋转后屏幕宽高互换(1440×900 → 900×1440),但我保存的是旋转前的绝对坐标 (1200, 300)。新屏幕宽度只有 900,x = 1200 直接跑到屏幕外,浮窗"消失"了。

根因有两层:

  1. 没有监听旋转事件——windowSizeChange 触发了,但代码里没绑定回调;
  2. 持久化的是绝对坐标——绝对坐标和屏幕尺寸强耦合,换分辨率、换方向就失效。

修复思路:监听 windowSizeChange,旋转后按锚点比例(浮窗中心点占屏幕的百分比)重新换算坐标,再走场景 2 的安全区夹紧逻辑。

修复代码
// StickyNote.ets —— 监听旋转 + 按锚点比例重定位
import window from '@ohos.window';

/** 绑定屏幕尺寸变化监听(旋转、分屏、分辨率切换都会触发) */
private bindRotateListener(win: window.Window): void {
  win.on('windowSizeChange', (size: window.Size) => {
    console.info(`[StickyNote] windowSizeChange: ${size.width}x${size.height}`);
    // 旋转后重新计算位置,异步执行避免阻塞回调
    this.relocateOnRotate(size).catch((err: BusinessError) => {
      console.error(`[StickyNote] relocateOnRotate failed: ${err.code} ${err.message}`);
    });
  });
}

/** 旋转后按锚点比例重新定位浮窗 */
private async relocateOnRotate(size: window.Size): Promise<void> {
  if (!this.floatWin) {
    return;
  }
  // ① 用旋转前的锚点比例(浮窗中心占屏幕的百分比)换算新坐标
  //    anchorRatioX / anchorRatioY 在拖拽结束时保存,取值 0~1
  const centerX = this.anchorRatioX * size.width;
  const centerY = this.anchorRatioY * size.height;

  // ② 由中心点反推左上角坐标
  const targetX = centerX - this.floatWidth / 2;
  const targetY = centerY - this.floatHeight / 2;

  // ③ 复用安全区夹紧逻辑,保证旋转后不压状态栏、不越界
  await this.moveFloatToSafe(targetX, targetY);

  // ④ 旋转后坐标已变,重新落盘,避免下次启动用旧坐标
  await this.saveFloatPosition();
}

/** 拖拽结束时记录锚点比例(替代直接存 x/y) */
private updateAnchorRatio(x: number, y: number): void {
  const screen = display.getDefaultDisplaySync();
  // 记录浮窗中心点相对屏幕的比例,与分辨率解耦
  this.anchorRatioX = (x + this.floatWidth / 2) / screen.width;
  this.anchorRatioY = (y + this.floatHeight / 2) / screen.height;
}

要点:持久化时存 anchorRatioX / anchorRatioY 而不是 x / y,这样换设备、换分辨率、横竖屏切换都能还原到"视觉上差不多"的位置;旋转回调里必须重新 saveFloatPosition(),否则下次启动还是旧坐标。


三个场景串起来就是一条完整的运行期链路:申请权限(失败则引导)→ 计算安全区 → 定位并夹紧 → 监听旋转重定位 → 落盘。编译期靠 rule: 编号定位,运行期靠错误码 + 日志定位,两套动作练熟,鸿蒙 PC 的浮窗功能基本不会再"点了没反应"。

AtomGit 仓库链接

本文五个 ERROR 对应的源码已按编号单独归档(与完整工程最新提交逐字节一致):

👉 https://atomgit.com/wdracky/harmony-pc-arkts-strict

仓库 README 里有「ERROR 1~5 → 文件」对照表:

文章小节文件(仓库内路径)
ERROR 1 arkts-no-untyped-obj-literalsentry/src/main/ets/utils/TinyMarkdown.ets
ERROR 2 基类 scale 重名entry/src/main/ets/pages/DpiMenuDemo.ets
ERROR 3 / ERROR 4 distributedKVStoreentry/src/main/ets/pages/DistributedNote.ets
ERROR 5 禁用 @ts-ignoreentry/src/main/ets/pages/StickyNote.ets
演示页(Home 按钮 ⑤)entry/src/main/ets/pages/Index.ets + entry/src/main/ets/kmp/KmpDateTime.ets

配套的 hypium 用例 TinyMarkdown.test.ets 也在仓库里,用于守住 ERROR 1 的修复不回归。

https://atomgit.com/wdracky/harmony-pc-arkts-strict


结语与后续计划

5 个 ERROR 修完、构建日志变成 BUILD SUCCESSFUL 的那一刻,最大的收获不是"改对了哪一行代码",而是建立起了一套面对 ArkTS 报错的固定动作:

  1. 先读 rule: 行——arkts-no-xxx 编号就是官方 lint 规则名,直接搜官方文档能拿到规则原文,比盲猜快十倍。
  2. 再判断是"写法问题"还是"API 演进问题"——前者改代码结构(声明 interface、改名、换 as),后者查 SDK 变更日志(Options 必填字段、回调签名)。
  3. 最后回到构建验证——ArkTS 一次只报一批,改完必须重新构建,别指望一次全清。

后续我还会继续补充两类内容:

  • 运行期实战:浮窗权限申请、安全区计算、横竖屏重定位、Preferences 落盘这些"编译能过但跑起来会翻车"的坑,会单独成篇。
  • ArkTS 严格模式规则速查表:把 arkts-no-xxx 系列规则按"对象字面量 / 类型断言 / 继承覆盖 / API 兼容"分类整理,做成一张可打印的对照表。

如果你在鸿蒙 PC 开发中也踩到了本文没覆盖的 ERROR,欢迎在评论区贴出报错原文和 rule: 编号,我会挑典型的补进后续版本。排错这件事,一个人踩坑是事故,一群人共享就是文档。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐