【HarmonyOS学习笔记】2026-07-19 | 开发踩坑实录


date: 2026-07-19
tags: [HarmonyOS, Stack布局, 透明度, 权限请求, 异步陷阱, PanGesture, constructor]
type: 踩坑实录

记录项目开发中遇到的技术问题、排查过程和结论。

一、constructor vs static of() — 类实例创建方式

问题

模型类使用了 static of() 工厂方法创建实例,而不是 constructor。这两种方式在内存占用上有没有差异?

分析

ArkTS 禁止 constructor 参数属性简写(arkts-no-ctor-prop-decls),即不允许 constructor(public name: string) 这种写法。但普通 constructor 完全合法:

class MyModel {
  id: string = ''
  timestamp: number = 0

  constructor(id: string, timestamp: number) {
    this.id = id
    this.timestamp = timestamp
  }
}

static of()constructor 做的事情本质相同——在堆上分配内存、给字段赋值。区别在于赋值次数:

// constructor:1次赋值
constructor(id: string, timestamp: number) {
  this.id = id
  this.timestamp = timestamp
}

// static of():2次赋值(先默认值,再覆盖)
static of(id: string, timestamp: number): MyModel {
  const item = new MyModel()   // 无参构造,字段赋默认值
  item.id = id                 // 覆盖赋值
  item.timestamp = timestamp   // 覆盖赋值
  return item
}

结论

维度 constructor static of()
实例内存 一样 一样
赋值次数 1次 2次(默认值+覆盖)
性能差异 微小优势 微小劣势(可忽略)

内存占用几乎无差异。static of() 的唯一优势是绕过"对象字面量必须列出全部字段"的限制。如果只用 new ClassName() 创建实例,用 constructor 更直观。


二、Stack 叠加布局点击无响应

问题

使用 Stack 布局实现侧边面板,展开后点击遮罩层无法关闭面板。

原因

Stack 中后渲染的组件在上层。代码渲染顺序:

Stack() {
  Column() { ... }     // 1. 主内容区(底层)

  if (this.panelOpen) {
    Column()           // 2. 半透明遮罩(中层)
      .onClick(() => { this.panelOpen = false })
  }

  if (this.panelOpen) {
    Row() { ... }      // 3. 侧边面板(顶层,盖住遮罩)
  }
}

侧边面板渲染在遮罩之后,盖住了遮罩层。点击落在面板上而不是遮罩上,面板自身又没有关闭按钮,导致无法关闭。

解决方案

换用 SideBarContainer(Embed 模式):

SideBarContainer(SideBarContainerType.Embed) {
  // sideBar:侧边面板
  Column() { ... }

  // mainContent:主内容区
  Column() { ... }
}
.showSideBar(this.panelOpen)
.autoHide(true)
.showControlButton(true)
.sideBarWidth(320)

或者保持 Stack 但调整渲染顺序(把遮罩写在面板后面),缺点是面板也无法操作了。

结论

Stack 叠加布局不适合做固定侧边栏,但适合做悬浮层叠加。SideBarContainer 是 ArkUI 专门为侧边栏场景设计的容器,内置展开/收起动画、控制按钮和 autoHide。


三、自定义悬浮面板实现

问题

SideBarContainer 的 controlButton 只能自定义 width/height/icons,无法自定义位置,也不支持沿边缘拖拽。对于可拖拽贴边的悬浮面板需求,SideBarContainer 无法满足。

方案

自定义组件 + Stack 叠加 + PanGesture:

三态切换

状态 外观 交互
button 56×56 圆形按钮 点击展开为 mini
mini 半透明小窗口,紧凑列表 "展开"→full, "收起"→button
full 宽面板全功能 "缩小"→mini, "关闭"→button

拖拽贴边

  • 使用 PanGesture({ direction: PanDirection.Vertical }) + .translate({ y: offset })
  • onActionUpdate 实时计算 offsetY,限制在屏幕范围内
  • onActionEnd 将最终偏移写入 positionY(下一次拖拽的起点)
  • 屏幕高度通过 display.getDefaultDisplaySync().height / densityPixels 获取

关键代码结构

@ComponentV2
export struct FloatingPanel {
  @Local panelState: PanelState = 'button'
  @Local offsetY: number = 0
  @Local positionY: number = 0

  build() {
    Stack({ alignContent: Alignment.TopEnd }) {
      if (this.panelState === 'button') {
        this.buttonView()
      } else if (this.panelState === 'mini') {
        this.miniView()
      } else {
        this.fullView()
      }
    }
    .translate({ y: this.offsetY })
    .margin({ top: 80, right: 12 })
    .gesture(PanGesture(...))
  }
}

页面中使用:

Stack({ alignContent: Alignment.TopEnd }) {
  Column() { ... }  // 主内容区(全屏,不被挤压)
  FloatingPanel({ ... })
}

结论

  • SideBarContainer 适合固定侧边栏,不适合可拖拽悬浮面板
  • 自定义组件 + Stack 叠加 + PanGesture 是实现悬浮面板的标准方案
  • translate 移动不影响布局流,拖拽体验流畅

四、8位 hex 透明度不生效

问题

半透明背景需要能看到底层内容。尝试以下方式:

  1. color.json 中写 "value": "#FFFFFF80" — 颜色变了但透明度没变
  2. 代码直接写 backgroundColor('#FFFFFF80') — 同样结果
  3. 代码写 backgroundColor($r('app.color.floating_mini_bg')) 配合 color.json 的 8位hex — 同样

分析

ArkUI 的 backgroundColor 接受 8位 hex 字符串时,RGB 部分生效,alpha 部分被忽略。颜色确实会变(#FFFFFF80#FFFFFF),因为 alpha 字节被当作颜色的一部分错误解析了。但透明度不会生效,背景仍然不透明。

这不是 color.json 的问题,而是 ArkUI 渲染层的问题。在代码中直接写 8位 hex 也是同样结果。

rgba 字符串是可行的:

.backgroundColor('rgba(255, 255, 255, 0.85)')

结论

方式 颜色 透明度 用途
#RRGGBB (6位hex) ❌ 无 不透明背景
#RRGGBBAA (8位hex) ⚠️ 变色 ❌ 不生效 不要用
rgba(R,G,B,A) 字符串 半透明背景用这个
.opacity(0.5) ✅ 整体透明 可用但文字也会变淡

五、requestPermissionsFromUser Promise 永远不 resolve — 三重陷阱

问题

@ComponentV2 页面组件中请求权限,requestPermissionsFromUser 的 Promise 永远不 resolve 也不 reject,导致状态永远不重置,后续调用全部被守卫条件跳过。

排查过程

现象:日志只有请求调用,没有权限结果输出。

尝试1 — getHostContext() 方案

  • 替换 getContext(this)this.getUIContext().getHostContext()
  • 结果:同样挂起。getHostContext() 返回基类 Context | undefined,而 requestPermissionsFromUser 运行时只接受 UIAbilityContextUIExtensionContext
  • 传入基类 Context 时框架内部校验类型失败,但不抛异常也不 reject——Promise 直接挂起

尝试2 — AppStorage 传递 UIAbilityContext

  • EntryAbility.onCreate 中存入 AppStorage
  • 页面中获取 context 成功,但请求仍然失败

突破 — 添加逐步日志后发现bundleInfo.appInfo.accessTokenId 这行在 try 块外面抛出了 TypeError 异常,catch 捕获不到!

根因 — GET_BUNDLE_INFO_DEFAULT 不含 appInfo

SDK 文档明确说明:

GET_BUNDLE_INFO_DEFAULT (0x00000000) — “The obtained bundleInfo does not contain information of signatureInfo, applicationInfo, hapModuleInfo, ability, extensionAbility and permission.”

所以 bundleInfo.appInfoundefined,访问 .accessTokenId 抛 TypeError。这个异常在 try 外面,不被 catch,整个 async 函数断裂。

三重陷阱总结

# 陷阱 表现 修复
1 getHostContext() 返回基类 Context requestPermissionsFromUser 静默挂起 改用 AppStorage 传递真正的 UIAbilityContext
2 GET_BUNDLE_INFO_DEFAULT 不含 appInfo bundleInfo.appInfo.accessTokenId 抛 TypeError,try 外不捕获 改用 getSelfPermissionStatus() 同步方法,不需要 bundleInfo
3 try 外的异常导致 async 函数断裂 状态永远不重置 所有可能抛异常的代码移入 try 块

最终方案

// EntryAbility.onCreate
AppStorage.setOrCreate<common.UIAbilityContext>('uiAbilityContext', this.context)

// 页面中请求权限
private async requestPermission(): Promise<boolean> {
  const context = AppStorage.get<common.UIAbilityContext>('uiAbilityContext')
  if (!context) { return false }
  try {
    const atManager = abilityAccessCtrl.createAtManager()
    const status = atManager.getSelfPermissionStatus('ohos.permission.MICROPHONE')
    if (status === abilityAccessCtrl.PermissionStatus.GRANTED) { return true }
    const result = await atManager.requestPermissionsFromUser(context, ['ohos.permission.MICROPHONE'])
    if (result.authResults.length > 0) { return result.authResults[0] === 0 }
    return false
  } catch (e) {
    return false
  }
}

结论

  • getContext(this) 在 @ComponentV2 中已 deprecated (since API 18),不要用
  • getHostContext() 返回基类 Context,不适合传给 requestPermissionsFromUser
  • AppStorage 传递 UIAbilityContext 是正确方案
  • GET_BUNDLE_INFO_DEFAULT 不含 appInfo,要用 GET_BUNDLE_INFO_WITH_APPLICATION 或改用 getSelfPermissionStatus()
  • getSelfPermissionStatus() (since 20) 是最佳方案——同步方法,不需要 tokenId/bundleInfo/Context
  • try 外的异常在 async 函数中会导致整个 Promise 链断裂,务必将所有可能抛异常的代码移入 try 块

六、异步操作 + 状态管理踩坑教训

问题

异步操作(如采集器停止)可能挂起导致后续同步操作永远不执行;回调中只有最终结果没有中间结果导致数据丢失;资源对象未释放导致泄漏。

教训一:await 可能挂起时,同步操作不要放在后面

// ❌ 如果 capturer.stop() 挂起,finish() 永远不执行
async stop(): Promise<void> {
  await this.capturer.stop()
  this.engine.finish(...)   // 永远到不了
}

// ✅ 同步操作先执行,异步释放不 await
stop(): void {
  this.engine.finish(...)                        // 同步,立即执行
  const capturer = this.capturer
  this.capturer = undefined
  capturer.stop().then(() => capturer.release()).catch(() => {})  // fire-and-forget
}

核心:同步操作不要依赖可能挂起的 await。如果某个操作必须执行,放在 await 前面。异步释放资源用 fire-and-forget 模式(不 await,只 catch 错误)。

教训二:中间结果需要缓存

回调流中可能有多次 isFinal=false 的中间结果,最终 isFinal=true 的结果依赖主动调用 finish() 触发。如果不调 finish(),就永远收不到最终结果。

// ✅ 缓存中间结果,停止时主动发送
if (result.result.trim() !== '') {
  this.lastText = result.result.trim()
}
if (result.isFinal && this.lastText !== '') {
  this.sendResult(this.lastText)
  this.lastText = ''
}

// 停止时也发送缓存的结果
stop(): void {
  this.engine.finish(...)
  if (this.lastText !== '') {
    this.sendResult(this.lastText)
    this.lastText = ''
  }
}

教训三:资源对象用完要 shutdown

每次创建新引擎/采集器时,旧的如果不在 onComplete/onError 回调中 shutdown(),就会泄漏。

onComplete(): void {
  if (this.engine !== undefined) {
    this.engine.shutdown()
    this.engine = undefined
  }
}

教训四:状态管理要精简

UI 层只需要反映用户关心的状态。内部实现步骤(如权限请求中、引擎创建中)不应该暴露为 UI 状态。用户按下按钮就是"激活",松开就是"结束",内部步骤只是过程。

教训五:异步操作期间检查用户是否已取消

多步异步操作中,用户可能在任意步骤间改变主意。每个 await 后检查状态,如果已取消则中止并清理。


学习小结:六个踩坑问题覆盖了类设计、布局、颜色、权限请求、异步操作和状态管理。最深的坑是权限请求的三重陷阱——类型不匹配时 Promise 静默挂起、SDK flag 含义与预期不符、try 外异常导致 async 函数断裂。异步操作的核心教训是:同步关键操作不要放在可能挂起的 await 后面,资源用完要释放,状态管理要精简。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐