:球收窗展——闪控球与闪控窗绑成一条生命周期)
# 〖共创稿事节〗HarmonyOS 7 新特性实战49球收窗展——闪控球与闪控窗绑成一条生命周期在异兽详情页点「闪控速览」按钮转一下然后应用里什么都没发生。切到桌面才发现一颗带白泽插画的悬浮球停在屏幕上点球展开成一张系统小窗窗里有插画、名字、简介和一个「回应用看详情」的按钮再按一下窗的标题栏又收回成球。用户看到的是球收窗展应用侧要做的是把两套窗口接口接成一条生命周期。之前写系统媒体卡那篇第 46 篇时我在 SDK 里没找到闪控球的公开 API把切片改成了 AVSession。这次重新翻 d.ts 才发现两套接口都在ohos.window.floatingBallAPI 20 起管球ohos.window.floatViewAPI 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 state5IN_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);三个「为什么」值得单独说为什么先注册回调再 bindbind 之后 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; }这里有两层教训。一层是功能上的图标解码失败返回 undefinedbuildBallParams就不塞icon字段球以纯文字形态照常挂起——第一轮旧包在模拟器上跑的就是这个降级形态iconmissing球能挂、能展开、能回跳什么都能用只是没图。另一层是日志上的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; }六、点球与收球状态回调里的数字球和窗各自有状态回调实测五段动作对应的状态是这样的模拟器 Pura X View / HarmonyOS 7.0.0.107真机 HBN-AL80 / HarmonyOS 7.0.0.111 复测下同动作球的日志窗的日志说明挂球floatingBall state1floatView state5窗随 bind 创建立即收回为球点球展开—floatView state1窗 STARTED内容页读快照回应看详情—窗保持悬浮主窗口 onNewWant 直达异兽详情停止速览floatingBall state2floatView state3 stopReasonFLOATING_BALL_STOP真机APP_STOP窗的销毁原因由系统写最有信息量的是最后一行的stopReason。应用侧只调了一句stopFloatingBall()球先 STOPPEDstate2不到一秒窗跟着 state3stopReason是系统写的销毁原因不是应用传的。有意思的是这个字符串在两台设备上不一样模拟器写FLOATING_BALL_STOP「窗之所以停是因为球被停」真机写APP_STOP「应用主动停的」——同一句stopFloatingBall()两套系统版本给了不同归因。真机上把球拖进回收站又给了第三个数据点应用一行代码都没调系统自己销毁这对窗口日志里先窗后球——floatView state3 stopReasonFLOATING_BALL_STOP2 毫秒后floatingBall state2。销毁顺序和归因字符串都跟发起方走。所以别把销毁原因字符串当协议用连「球在前窗在后」的顺序也不能当协议用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: Recordstring, 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 beastbeast_jiuweihu size128 11:53:13.976 I app-system quick view updated beastbeast_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 beastbeast_jiuweihu region青丘 12:05:57.547 I quick view page refreshed beastbeast_jiuweihu region青丘update 也可能被系统拒绝比如球被用户拖进了回收站这时把引用清干净返回 false让下一次点击走完整启动链路——降级的终点永远是「回到能用的状态」而不是把错误抛给用户。九、这套做法没覆盖的部分真机已复测两处差异已回写真机 HBN-AL80HarmonyOS 7.0.0.111复跑了五段链路行为全部对上差异是stopReason归因字符串APP_STOP和第八节那个「update 不刷窗」的坑已修。模板窗在真机 1260×2844 屏上实测约 1148×676px对照模板标称高度折算密度约 3.5与模拟器量到的 189vp 一致——「布局自量 Scroll 兜底」的写法在两台设备上都没出问题。拖动与回收站两条路径都已在真机取证——拖动换位球跟手、松手自动吸边、不触发展开拖入回收站全程无应用侧调用系统自行销毁球窗对窗先停2 毫秒后球停stopReasonFLOATING_BALL_STOP前面「update 被拒清引用」的兜底正是为这条路准备的。模板尺寸随设备变化189vp 是这台模拟器上量出来的d.ts 没有承诺统一值布局自量 Scroll 兜底的写法不能省。floatView 独立使用不 bind、直接start()窗的用法没有覆盖它更像「从主窗口弹一张小卡片」和速览入口是两个产品语义。十、收个尾这篇的最小闭环是详情页点「闪控速览」→ 球挂上桌面跨应用悬浮→ 点球展开系统小窗 → 窗里回应用看详情 → 主窗口直达异兽详情 → 停止速览球窗一起消失。五段日志链把每一步行为都钉在了时间线上挂球、展开、回跳、停止、换异兽每段都有球和窗两侧的状态数字可以对照。回头看这个功能最容易做错的地方不在任何一行代码而在对 bind 的想象它不是一个「关联」是真正的合并——两个窗口一起创建、只显示一个、一起销毁。写的时候把它当成两个独立窗口去管理状态就会对不上把它当成一条生命周期去写代码反而短了。