# 〖共创稿事节〗HarmonyOS 7 新特性实战(49):球收窗展——闪控球与闪控窗绑成一条生命周期

在异兽详情页点「闪控速览」,按钮转一下,然后应用里什么都没发生。切到桌面才发现:一颗带白泽插画的悬浮球停在屏幕上,点球展开成一张系统小窗,窗里有插画、名字、简介和一个「回应用看详情」的按钮;再按一下窗的标题栏,又收回成球。用户看到的是球收窗展,应用侧要做的是把两套窗口接口接成一条生命周期。

之前写系统媒体卡那篇(第 46 篇)时,我在 SDK 里没找到闪控球的公开 API,把切片改成了 AVSession。这次重新翻 d.ts 才发现两套接口都在:@ohos.window.floatingBall(API 20 起)管球,@ohos.window.floatView(API 26 起)管窗,中间还有一个 bind 把两者绑在一起。绑起来之后的行为,和分开看这两个模块时的直觉不太一样——两个窗口都创建了,但同时只显示一个;任何一方的 stop 都会把另一个带走。

官方 API 参考:@ohos.window.floatingBall · @ohos.window.floatView(核对日期 2026-10-30)

一、两套接口,一条生命周期

先把分工写清楚。球是那个缩在屏幕边缘的圆点,窗是点开后展开的小卡片,两者是独立的控制器、独立的窗口,靠 bind 产生关系:

floatingBall(球)floatView(窗)
起版API 20API 26
创建floatingBall.create({ context })floatView.create({ context, templateType })
上屏startFloatingBall(params)start() 或被球带起
内容FloatingBallParams:模板 + 标题 + 内容 + 图标setUIContext 指定一个页面路径
模板EMPHATIC(图标+标题+内容)ROUNDED_RECTANGLE(系统定高约 190vp)
销毁stopFloatingBall()stop() 或被球带走

不 bind 也能各自独立跑,但那样点球不会展开成窗,窗也和球没有关系。bind 的语义从实测日志看很具体:调用之后两个窗口都会被创建,但同时只显示一个,显示哪个取决于先 start 谁。所以正常顺序是 bind 之后只 start 球——窗被创建出来就收回成球(floatView state=5,IN_FLOATING_BALL),等用户点球才展开。

还有一个容易漏的约束:setUIContext 指定的页面必须先注册进 main_pages.json,否则窗展开时内容页加载不出来。这是纯静态资源的硬前提,运行时没有任何报错提示。

二、开工之前:三层探测与一次 user_grant

闪控能力依赖 SystemCapability.Window.SessionManager,旧 API 版本的设备上这些接口根本不存在。所以入口先做三层探测,任何一层不满足就整个功能静默不可用——不弹 toast,因为对一个附加功能来说,「没有」比「失败」更体面。

/** 设备不支持闪控(旧 API 版本或无 SessionManager 能力)时整个功能静默不可用。 */
static probe(): boolean {
  try {
    return canIUse('SystemCapability.Window.SessionManager') &&
      floatingBall.isFloatingBallEnabled() &&
      floatView.isFloatViewEnabled();
  } catch (error) {
    hilog.warn(DOMAIN, TAG, 'degrade: probe failed %{public}s', JSON.stringify(error));
    return false;
  }
}

权限是两个,且性质不同:

权限授予方式谁来申请
ohos.permission.USE_FLOAT_BALLsystem_grant模块配置文件里声明即得
ohos.permission.FLOAT_VIEWuser_grant运行时 requestPermissionsFromUser,首次挂球弹一次

FLOAT_VIEW 声明时必须带 reason,指向 string.json 里一句用户能看懂的话,usedScene 标 inuse——挂球这个动作发生时用户就在屏幕前,弹窗理由写的是「用于在屏幕上展示闪控球速览」。用户拒绝授权返回 false,详情页没有任何变化,这次点击等于没发生过。

三、create→bind→setUIContext→start:顺序就是语义

整条启动链路六步,顺序换不得:

const floatController: floatView.FloatViewController = await floatView.create({
  context: context,
  templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE
});
const ballController: floatingBall.FloatingBallController = await floatingBall.create({ context: context });
const ballParams: floatingBall.FloatingBallParams =
  ShanhaiQuickViewController.buildBallParams(target, icon);
// bind 之后 start 任一控制器都会同时创建两个窗口,先注册回调再启动,避免漏掉最早的状态事件。
ShanhaiQuickViewController.registerCallbacks(floatController, ballController);
await floatView.bind(floatController, ballController, ballParams);
await floatController.setUIContext(QUICK_VIEW_PAGE_PATH);
await ballController.startFloatingBall(ballParams);

三个「为什么」值得单独说:

为什么先注册回调再 bind? bind 之后 start 球,两个窗口同步创建,最早的状态事件在 startFloatingBall 的 promise resolve 之前就可能发出。先 on 再 start,一条都不漏。

为什么 bind 之后只 start 球? 上面说过,start 谁谁上屏。业务语义是「球先出现,用户点球才看窗」,所以只 start 球。如果反过来先 start 窗,桌面上直接铺开一张小卡片,球反而收起来——不是不能做,但那就不是「速览入口」而是「弹窗广告」了。

为什么球的内容用 EMPHATIC 模板? FloatingBallParams 的 template、title、content 三个字段一起决定球的形态:title 给异兽名,content 给地区名,icon 是解码后的插画 PixelMap。图标配不上时这两个字段照样生效,球退化为纯文字形态——降级路径在第四节展开。

一次挂球要过的六道工序

四、球的图标:一个返回 undefined 的重载

球上的白泽插画是当前异兽的资源图,解码这段出了一天里最哑巴的一个坑:

const raw: Uint8Array =
  context.resourceManager.getMediaContentSync(shanhaiBeastImage(beastId));
const source: image.ImageSource = image.createImageSource(raw.buffer as ArrayBuffer);

第一版直接把 Uint8Array 喂给 createImageSource——它只有 uri、fd、ArrayBuffer 三个重载,Uint8Array 传进去不报错,返回 undefined,到下一行 createPixelMap 才炸出 Cannot read property createPixelMap of undefined。报错位置和出错位置隔了一行,第一眼看像是 PixelMap API 用错了。修法就是取 raw.buffer as ArrayBuffer,和第 46 篇媒体卡封面的处理是同一条惯例——同一个坑隔了两周换个接口又踩一遍,说明它值得写进封装里。

解码按系统建议的 128px 出图,按异兽 id 缓存在 Map 里,换异兽时不再重解。失败路径单独处理:

} catch (error) {
  // BusinessError 的 code/message 不可枚举,JSON.stringify 会得到 {},必须显式取字段。
  const err = error as BusinessError;
  hilog.warn(DOMAIN, TAG, 'degrade: ball icon decode failed code=%{public}d msg=%{public}s',
    err.code, err.message);
  return undefined;
}

这里有两层教训。一层是功能上的:图标解码失败返回 undefined,buildBallParams 就不塞 icon 字段,球以纯文字形态照常挂起——第一轮旧包在模拟器上跑的就是这个降级形态,icon=missing,球能挂、能展开、能回跳,什么都能用,只是没图。另一层是日志上的:BusinessError 的 code 和 message 不可枚举,JSON.stringify(error) 得到的是 {},第一轮日志里 code= msg= 后面空空如也就是这个原因。错误对象必须显式取字段,这句教训在 ArkTS 上比在 TS 里更常见,因为很多系统错误根本不是标准 Error。

五、窗里画什么:189vp 的定高里排一张海报

闪控窗的尺寸是系统模板给的,应用改不了。模拟器 1320px 宽、3x 密度,窗体总高 567px——折算正好约 189vp,这还是含系统标题栏的总高,内容区实际更矮。我第一版窗内容按普通页面写:大插画、标题、地区、别名、四行简介、两个按钮,摞起来约 350vp——底部三分之一被直接裁掉,而且窗内不可滚,多出来的部分既看不见也点不到。

改法是按「插画 + 是谁 + 回跳」三件事取舍:

// 闪控窗由系统模板给定尺寸(约 190vp 高),内容必须按「插画 + 是谁 + 回跳」取舍,
// 放不下的部分用 Scroll 兜底——小窗里信息要让位给主按钮。
build() {
  Scroll() {
    Column({ space: 8 }) {
      Image(shanhaiBeastImage(this.beastId))
        .width('100%').height(76).objectFit(ImageFit.Cover).borderRadius(10)
      Row({ space: 8 }) {
        Text(this.beastName).fontSize(18).fontWeight(FontWeight.Bold)
        if (this.regionName.length > 0) {
          Text(this.regionName).fontSize(13)
        }
      }
      Text(this.summary).fontSize(13).lineHeight(19).maxLines(2)
      Button('回应用看详情') // 金色主按钮 36vp
      Button('停止速览')     // 次按钮 32vp,滑出后才可见
    }
  }
  .scrollBar(BarState.Off)
}

插画从 120vp 压到 76vp、名称与地区并到一行、简介限两行、主按钮压到 36vp,一屏正好放得下;「停止速览」是次要操作,留在 Scroll 兜底范围内,滑一下才露出来(scrollBar(Off) 让兜底滚动不显滚动条)。顺手删掉了初版顶部的「山海速览」Text——系统标题栏已经写着应用名,再写一遍就是重复。

还有一个和页面模型有关的决定:窗内容页的数据不走 AppStorage,而是从服务的进程内快照读。闪控窗和主窗口同进程,但 UI 实例独立,主窗口的页面状态在这里不保证可见。快照在挂球时由服务持有,窗展开、页面 aboutToAppear 时读走,一字不差:

aboutToAppear(): void {
  const snapshot: QuickViewSnapshot | undefined = ShanhaiQuickViewController.snapshot();
  if (snapshot === undefined) {
    hilog.warn(DOMAIN, TAG, 'degrade: snapshot missing, quick view shows fallback');
    return;
  }
  this.beastId = snapshot.beastId;
  this.beastName = snapshot.beastName;
  this.regionName = snapshot.regionName;
  this.summary = snapshot.summary;
}

189vp 的定高里排一张海报

六、点球与收球:状态回调里的数字

球和窗各自有状态回调,实测五段动作对应的状态是这样的(模拟器 Pura X View / HarmonyOS 7.0.0.107,真机 HBN-AL80 / HarmonyOS 7.0.0.111 复测,下同):

动作球的日志窗的日志说明
挂球floatingBall state=1floatView state=5窗随 bind 创建,立即收回为球
点球展开—floatView state=1窗 STARTED,内容页读快照
回应看详情—窗保持悬浮主窗口 onNewWant 直达异兽详情
停止速览floatingBall state=2floatView state=3 stopReason=FLOATING_BALL_STOP(真机 APP_STOP)窗的销毁原因由系统写

最有信息量的是最后一行的 stopReason。应用侧只调了一句 stopFloatingBall(),球先 STOPPED(state=2),不到一秒窗跟着 state=3,stopReason 是系统写的销毁原因,不是应用传的。有意思的是这个字符串在两台设备上不一样:模拟器写 FLOATING_BALL_STOP(「窗之所以停,是因为球被停」),真机写 APP_STOP(「应用主动停的」)——同一句 stopFloatingBall(),两套系统版本给了不同归因。

真机上把球拖进回收站,又给了第三个数据点:应用一行代码都没调,系统自己销毁这对窗口,日志里先窗后球——floatView state=3 stopReason=FLOATING_BALL_STOP,2 毫秒后 floatingBall state=2。销毁顺序和归因字符串都跟发起方走。所以别把销毁原因字符串当协议用,连「球在前窗在后」的顺序也不能当协议用;bind 焊死生命周期的字据是「两个状态成对出现、一个都少不了」——这一点在两台设备、两种销毁路径上完全一致。

它反过来约束了写法:主动停止只能调 stopFloatingBall()。球和窗既然一停俱停,再单独调窗的 stop() 没有意义;回调里任何一方报 STOPPED/ERROR,统一走同一个清理函数把控制器引用、快照、started 标记全部清掉,下一次点按钮从头走完整链路。

floatController.onStateChange((info: floatView.FloatViewStateChangeInfo) => {
  hilog.info(DOMAIN, TAG, 'system->app floatView state=%{public}d stopReason=%{public}s',
    info.state, info.stopReason);
  if (info.state === floatView.FloatViewState.STOPPED || info.state === floatView.FloatViewState.ERROR) {
    ShanhaiQuickViewController.resetAfterStop();
  }
});
ballController.on('stateChange', (state: floatingBall.FloatingBallState) => {
  hilog.info(DOMAIN, TAG, 'system->app floatingBall state=%{public}d', state);
  if (state === floatingBall.FloatingBallState.STOPPED) {
    ShanhaiQuickViewController.resetAfterStop();
  }
});

五段日志里的状态配对

展开后的窗长这样(模拟器取证截图,真机同布局):

展开的闪控窗:五块内容各来自哪里

七、回应用看详情:不新造协议,复用启动白名单

窗里的主按钮做的是跨窗口回跳:把主窗口拉回前台,直达当前异兽的详情。最省事的写法是空参数拉起主 Ability,用户回来落在首页,得自己再点进图鉴找到那只异兽——这条路径在第 46 篇写媒体卡时就判过不合格。这次同样复用桌面卡片的启动白名单管线,只是参数改由闪控窗侧来带:

const wantParameters: Record<string, Object> = {};
wantParameters.source = SHANHAI_LAUNCH_SOURCE_DESKTOP_FORM;
wantParameters.schemaVersion = SHANHAI_LAUNCH_SCHEMA_VERSION;
wantParameters.action = 'OPEN_BEAST';
if (target !== undefined) {
  wantParameters.beastId = target.beastId;
}
floatController.restoreMainWindow(wantParameters)
参数值接收端拿到什么
source桌面卡片来源常量标识参数来自白名单管线,进同一套校验
schemaVersion当前协议版本防旧参数混入
actionOPEN_BEAST走异兽详情分支
beastId当前速览的异兽直达 No.001 白泽详情

主窗口侧什么都不用改:onNewWant 收到 want,交给同一个 publishLaunchRequest 写进 AppStorage,页面消费跳转。冷启动进程已死时走 onCreate,也是同一套解析。实测日志里应用层打 restoreMainWindow ok 的同时,系统侧打 WMS startSceneFromOther to: EntryAbility/com.shanhai.spirits——主窗口是被闪控窗拉起来的,这次跳转跨的是两个窗口、一个进程。

d.ts 里有一个必须记住的前置条件:restoreMainWindow 必须在用户点过闪控窗之后调用,否则系统报 1300032。球还在、窗没收开过,主按钮就不可能被点到,所以业务上天然满足;但假如以后把回跳入口搬到球上(比如长按球回详情),这个错误码就会立刻出现。

八、换一只异兽:update 不重建

球已经挂着的时候,用户回到列表页换九尾狐再点「闪控速览」,不该把球销毁重建——闪一下,位置也可能变。服务里用 ballStarted 标记区分两条路径:已挂球就只调 updateFloatingBall,换标题、地区和图标:

11:53:13.973 I ball icon decoded beast=beast_jiuweihu size=128
11:53:13.976 I app->system quick view updated beast=beast_jiuweihu region=青丘

球的图标按异兽缓存,九尾狐第一次解码后进 Map,来回切换不再重复解码。真机复测时这条路径暴露了一个模拟器上没看见的坑:updateFloatingBall 只更新球,不更新窗。点球展开后,窗面显示的还是白泽——闪控窗内容页是 setUIContext 装载一次的,aboutToAppear 里读快照只读一回,之后窗收起再展开,页面实例不会重载;而翻遍 FloatViewController 的 d.ts,没有任何更新窗内容的接口,球有 updateFloatingBall,窗没有对应物。更要命的是 currentTarget 已经换成九尾狐,这时在显示白泽的窗上点「回应用看详情」,主窗口直达的却是九尾狐——球和窗各说各话。

系统接口没给路,就在应用层把更新推过去:服务里留一个静态通知口,update 成功后把新快照推给窗内容页,页面订阅后刷新自己的 @State:

// 服务侧:update 成功后推送
const listener: ((snapshot: QuickViewSnapshot) => void) | undefined =
  ShanhaiQuickViewController.onTargetUpdated;
const snapshot: QuickViewSnapshot | undefined = ShanhaiQuickViewController.snapshot();
if (listener !== undefined && snapshot !== undefined) {
  listener(snapshot);
}

// 页面侧:aboutToAppear 里注册,aboutToDisappear 里注销
ShanhaiQuickViewController.onTargetUpdated = (updated: QuickViewSnapshot): void => {
  this.beastId = updated.beastId;
  this.beastName = updated.beastName;
  this.regionName = updated.regionName;
  this.summary = updated.summary;
};

修复后真机日志成对出现,点球展开窗面跟着换成「九尾狐 青丘」,回跳落地也是九尾狐:

12:05:57.546 I app->system quick view updated beast=beast_jiuweihu region=青丘
12:05:57.547 I quick view page refreshed beast=beast_jiuweihu region=青丘

update 也可能被系统拒绝(比如球被用户拖进了回收站),这时把引用清干净返回 false,让下一次点击走完整启动链路——降级的终点永远是「回到能用的状态」,而不是把错误抛给用户。

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

  • 真机已复测,两处差异已回写:真机 HBN-AL80(HarmonyOS 7.0.0.111)复跑了五段链路,行为全部对上;差异是 stopReason 归因字符串(APP_STOP)和第八节那个「update 不刷窗」的坑(已修)。模板窗在真机 1260×2844 屏上实测约 1148×676px,对照模板标称高度折算密度约 3.5,与模拟器量到的 189vp 一致——「布局自量 + Scroll 兜底」的写法在两台设备上都没出问题。
  • 拖动与回收站:两条路径都已在真机取证——拖动换位球跟手、松手自动吸边、不触发展开;拖入回收站全程无应用侧调用,系统自行销毁球窗对(窗先停,2 毫秒后球停,stopReason=FLOATING_BALL_STOP),前面「update 被拒清引用」的兜底正是为这条路准备的。
  • 模板尺寸随设备变化:189vp 是这台模拟器上量出来的,d.ts 没有承诺统一值,布局自量 + Scroll 兜底的写法不能省。
  • floatView 独立使用:不 bind、直接 start() 窗的用法没有覆盖,它更像「从主窗口弹一张小卡片」,和速览入口是两个产品语义。

十、收个尾

这篇的最小闭环是:详情页点「闪控速览」→ 球挂上桌面(跨应用悬浮)→ 点球展开系统小窗 → 窗里回应用看详情 → 主窗口直达异兽详情 → 停止速览球窗一起消失。五段日志链把每一步行为都钉在了时间线上:挂球、展开、回跳、停止、换异兽,每段都有球和窗两侧的状态数字可以对照。

回头看,这个功能最容易做错的地方不在任何一行代码,而在对 bind 的想象:它不是一个「关联」,是真正的合并——两个窗口一起创建、只显示一个、一起销毁。写的时候把它当成两个独立窗口去管理,状态就会对不上;把它当成一条生命周期去写,代码反而短了。

Logo

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

更多推荐