前言

文件整理、内容导出和资料压缩都可能需要一段等待时间。任务开始以后,主页面可以显示处理状态,但用户往往还要切换页面查找资料,或者回到桌面处理其他事情。如果状态只出现在原来的业务页面,用户就需要反复返回,才能确认处理有没有结束。应用因此需要一个独立的状态入口,让查看进展这件事不再完全依赖主页面的位置。

这个入口还需要与任务本身保持联系。用户可能先开始整理,过一会儿才打开状态窗口,也可能暂时关闭窗口后继续等待结果。如果显示入口顺带重新启动任务,原来的处理过程就会被打断;如果窗口只保存打开时的文字,任务完成以后又无法及时反映结果。因此,接入小窗口时需要同时考虑窗口怎样出现,以及任务变化怎样进入窗口,单独绘制一张卡片还不足以回答这两个问题。

HarmonyOS 7 的闪控窗提供了在独立小窗口中持续展示应用内容的能力,应用主窗口退到后台后,闪控窗仍可以在前台显示。系统管理窗口外部结构和基础窗口行为,应用在系统提供的内容区域加载自己的 ArkUI 页面。这个分工让文件整理状态可以拥有独立的展示位置,但系统不会替应用生成业务结果。页面显示什么、状态由谁保存、完成以后何时更新,仍然需要应用建立明确的数据路径。

系统窗口也带来了普通页面之外的接入条件。当前设备需要支持闪控窗,应用需要处理用户授权,还要把窗口关联到有效的主窗口上下文。控制器取得以后,应用才能继续加载内容和设置尺寸;启动请求发出后,窗口是否真正显示也需要确认。这些条件发生在不同阶段,排查空白页面或启动失败时,工程需要能够区分访问条件、内容加载与显示状态。

我选择一分钟的本地定时器作为整理任务载体,是为了留下开始、等待和完成的观察时间。应用不读取个人文件,也不生成真实压缩包,因此首次接入可以集中检查窗口和状态之间的关系,避免把文件处理错误混入显示问题。当前范围先覆盖第一次完整创建、开始与完成文字更新以及停止窗口,连续百分比和任务操作暂时不加入。工程从启动前的能力与权限条件开始,随后才能为任务状态建立可用的显示入口。

一、启动闪控窗以前先完成能力与权限确认

闪控窗入口接入主页面以后,工程需要先确认 SDK 接口与运行设备两个条件。前一个条件决定代码能否使用这组接口,后一个条件决定安装后是否存在可用的窗口功能。由于两者发生在不同阶段,编译通过以后仍然需要保留运行时判断,主页面也需要呈现当前设备的检查结果。

运行时判断还需要区分通用窗口能力和闪控窗功能。窗口会话管理能力存在时,应用才能继续查询闪控窗是否可用,不能把通用能力检查直接当成最终结果。当前代码用短路判断连接这两个条件,只有两项都满足才进入启动流程。

    this.supported = canIUse('SystemCapability.Window.SessionManager') && floatView.isFloatViewEnabled();

SystemCapability.Window.SessionManager 表示窗口会话管理能力。isFloatViewEnabled() 进一步判断设备是否支持闪控窗,两者解决的问题范围不同。页面把判断结果直接显示出来,开发者就能区分按钮没有创建窗口,是能力条件不满足,还是后续操作失败。

闪控窗相关接口从 API 26.0.0 开始提供,并且使用 Stage 模型。工程中的 compatibleSdkVersiontargetSdkVersion 都设置为 26.0.0,当前代码不承担低版本安装适配。产品将来需要覆盖更早系统时,还要重新设计安装基线和接口调用边界,单独增加一个布尔判断无法完成全部兼容工作。

能力检查通过以后,创建流程仍然需要等待用户授权,因为系统能力不会替用户作出选择。当前应用采用按显示操作申请所需权限的方式,让申请原因与用户眼前的功能对应。申请范围只包含当前窗口需要的权限,拒绝之后的反馈也可以直接说明哪项显示功能未能开启。

能力检测通过以后,应用还需要获得 ohos.permission.FLOAT_VIEW 权限。这个权限由用户授权,开发者既要在模块配置中声明,也要在实际使用前申请。当前模块把申请原因写成在闪控窗中查看本地任务状态,让授权提示对应用户刚刚点击的显示操作。

权限声明包含权限名称、原因资源,以及使用场景中的 EntryAbility。原因资源必须存在于字符串资源文件,不能只在配置中留下一个没有定义的资源引用。窗口页面也需要加入 main_pages.json,因为权限声明解决访问资格,页面清单解决内容入口,两者不能互相替代。

当前页面在用户点击显示闪控窗以后申请权限。用户拒绝时,页面显示未授权信息,并且退出窗口创建流程。文件整理按钮独立存在,拒绝悬浮显示不会阻止用户留在应用内处理任务。这是当前业务的产品选择,因为窗口只是额外的查看入口,文件整理本身没有依赖悬浮显示的理由。

      const result = await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context,
        ['ohos.permission.FLOAT_VIEW']);

应用需要检查返回的授权结果,不能只确认 Promise 没有抛错。申请调用成功表示流程完成,用户仍然可能选择不允许。当前代码只申请一个权限,因此检查 authResults[0];以后一次申请多个权限时,应用必须按对应关系分别判断。

授权结果已经明确以后,应用还需要确认启动时机。系统窗口会影响其他界面的可见区域,因此启动位置和同应用实例数量都受到约束。当前把创建接到主页面点击事件,让用户意图、前台条件与错误提示落在同一次操作中,失败信息也能返回这个入口。

启动操作还有前台条件。应用需要在主窗口位于前台时启动闪控窗,同一应用只能启动一个闪控窗,已经启动闪控球或画中画时也会产生冲突。因此当前实现把入口放在主页面按钮上,并用 busy 阻止异步创建期间重复点击。已有控制器时再次点击也会直接返回,避免连续发出创建请求。

这些检查不能保证后续调用永远成功。权限可能变化,窗口服务也可能返回错误,所以创建、加载和启动仍然位于异常处理范围内。页面展示错误码,日志保留错误消息,开发者才能根据失败位置继续判断原因。

权限原因也需要与实际行为一致。当前窗口只显示应用自己生成的任务状态,应用没有读取其他应用的屏幕内容,也没有提供跨应用点击能力。授权说明如果写成文件访问或者后台处理,用户就无法从提示中判断为什么显示一个任务窗口需要这项权限。开发者准备权限配置时,应该先确认窗口承担的功能,再填写能解释这个功能的原因。

运行入口使用用户主动点击,还能把失败反馈放回明确位置。用户刚刚请求显示窗口,页面紧接着显示未授权或者启动失败,操作与结果容易对应。如果应用在启动时自动请求多个无关权限,用户拒绝其中一项以后,开发者很难从界面行为判断哪一步受到了影响。当前只围绕闪控窗申请一项权限,便于把接入问题逐项定位。

二、控制器依次加载页面、设置尺寸并启动窗口

权限确认完成以后,创建流程需要取得有效宿主并指定内容页面。控制器操作系统窗口,业务组件在指定的 ArkUI 页面中绘制,两者通过页面加载过程建立联系。宿主上下文如果无效,创建阶段就可能失败;页面入口错误则需要在后续内容加载阶段定位,工程因此分别保留这两处检查。

用户完成授权以后,应用从当前组件的 UIContext 获取宿主上下文,并把它作为 UIAbilityContext 使用。这个上下文把闪控窗与所属应用主窗口联系起来,不能随意传入一个脱离当前页面的对象。后续主窗口被销毁时,闪控窗也会受到所属窗口生命周期影响。

当前代码从组件读取宿主上下文,没有把上下文保存成与页面无关的全局常量。窗口关联的是当前主窗口,页面和主窗的寿命会影响后续操作,因此调用位置需要提供有效对象。以后将创建方法移入工具类时,调用方仍然需要传入有效上下文,类型断言只能表达类型,不能使失效宿主恢复可用。

floatView.create() 返回 FloatViewController。控制器提供加载内容、设置大小和启动等操作,业务状态仍由应用保存。当前实现选择 ROUNDED_RECTANGLE 圆角矩形模板,因为任务名称和状态两行文字需要一块普通内容区域,不需要额外设计条状布局。

控制器创建以后,应用先注册状态监听,再加载 pages/FloatViewPage。监听必须早于启动,否则开发者可能错过用于确认显示完成的状态变化。setUIContext() 使用页面清单中的完整路径名称,当前文件对应 entry/src/main/ets/pages/FloatViewPage.ets,清单中则填写 pages/FloatViewPage

      await controller.setUIContext('pages/FloatViewPage', this.storage);
      const limits = floatView.getFloatViewLimits(floatView.FloatViewTemplateType.ROUNDED_RECTANGLE);
      console.info('FloatTask01 limits=' + JSON.stringify(limits));
      await controller.setWindowSize(limits.maxSize);
      await controller.start();

页面加载、尺寸设置与窗口启动存在前后依赖。窗口需要先知道显示哪个页面,应用随后读取模板尺寸限制并设置大小,再请求启动。当前采用返回的最大尺寸,目的是给两行状态留下足够显示区域,便于确认页面加载;这个选择不代表所有任务窗口都应该占用最大面积。

尺寸限制使用像素,不能直接把布局中常见的 vp 数值原样当作相同长度。当前代码直接传递接口返回的 maxSize,没有额外进行单位换算,因此避免了把两种单位混用。需要使用设计稿宽度时,应用必须结合实际密度换算,并检查宽高比与最终显示结果。

调用 start() 后,应用还需要等待 STARTED 状态回调。Promise 返回只说明启动接口已经完成当前调用,不等于屏幕内容已经显示。当前页面使用回调中的状态更新窗口信息,日志也记录状态值,这样调用返回与窗口显示不会混成一项结果。

日志需要分别观察控制器创建、页面加载和 STARTED 三个阶段。前两个结果说明对应调用已经完成,第三个结果用于确认窗口进入显示状态。如果日志缺少启动状态,文字字号还没有成为首要排查对象;应用需要先定位窗口停在哪个调用阶段,再检查该阶段要求的条件。

如果收到权限错误,开发者先核对授权结果和权限声明;如果页面路径报错,检查页面清单及文件名称;如果启动报错,再检查前台条件和同应用其他悬浮窗口。按调用发生的位置排查,可以减少无关的布局修改。白色窗口中的文字没有出现,也需要单独检查页面内容,不能立即认定窗口没有启动。

页面路径与宿主上下文还承担不同职责。路径决定窗口加载哪份 ArkUI 内容,上下文决定这份窗口属于哪个主窗口;路径正确但上下文不满足要求时,修改文字布局没有帮助。开发者可以先确认创建是否返回控制器,再确认内容加载是否完成,把这两个阶段的错误分别记录。只有系统已经接受页面入口后,文字颜色、留白和字号才成为当前需要检查的对象。

三、任务状态通过共享存储进入闪控页面

窗口能够显示以后,内容更新需要找到明确的数据入口。主页面组件与闪控页面分别建立自己的界面,普通成员不会因为窗口属于同一应用就自动共享。当前实现把任务状态放进显式传入的存储,因此排查文字未更新时,可以从任务赋值追踪到存储参数,再检查闪控页面的绑定。

主页面与闪控页面属于两个内容入口,闪控页面不能直接读取主页面组件的普通成员。当前实现创建一个 LocalStorage,保存 taskState,再通过 setUIContext() 的第二个参数传给闪控页面。闪控页面使用共享存储入口和 @LocalStorageLink 读取这个键,状态变化才能驱动文字刷新。

@Entry({ useSharedStorage: true })
@Component
struct FloatViewPage {
  @LocalStorageLink('taskState') taskState: string = '尚未开始';

普通变量赋值可以说明这里为什么需要共享存储。主页面把自己的成员改成完成,只会影响读取这个成员的界面;闪控页面没有访问该组件实例,所以不会自动获得变化。传入同一份存储以后,两处内容才有共同的数据位置,存储参数和键名也因此必须保持对应。

共享存储需要先创建对应键。主页面出现时使用 setOrCreate() 初始化状态,用户开始整理时写入正在整理文件,定时器完成时再写入文件整理完成。闪控页面只展示状态,不启动第二个定时器,因此打开窗口不会额外产生一次整理任务。

当前主页面保留自己的状态文字,并在任务变化时同步写入存储。对于只有开始与完成两个变化点的实现,这种写法能够明确展示数据从哪里进入窗口。进度高频更新或多个操作入口加入以后,维护两处赋值会增加遗漏风险,应用就需要把任务状态集中到同一位置维护。

一分钟定时器模拟的是等待过程。定时器编号用于防止重复开始,任务完成后恢复为空闲标记,用户才能再次执行。当前页面销毁时会清理定时器,避免一个已经失去界面的组件继续保留回调;这个实现没有提供进程重建后的任务恢复。

我把业务开始方法与窗口显示方法分开,是因为用户可能在任务运行一段时间后才打开窗口。此时窗口应该读取已有状态,加载页面不应重新开始整理。检查显示按钮的调用路径时,任务初始化和窗口初始化需要分别追踪;如果显示方法中出现进度归零或者新建业务计时器,就需要核对它是否超出了用户请求的动作。

开发者还需要区分窗口停止和文件整理停止。用户点击停止闪控窗时,应用只调用控制器的 stop(),没有清除任务定时器。窗口只是暂时不显示,任务仍然可能完成。当前页面还没有取消任务按钮,用户不能把关闭窗口理解为取消文件处理。

STOPPED 回调到达以后,应用取消状态监听并释放控制器引用。下次用户点击显示闪控窗,应用会重新创建控制器和加载页面。释放引用发生在停止状态到达以后,能够避免把停止请求刚刚发出写成窗口已经完全关闭。

实际接入还要考虑应用退到后台后的执行限制。闪控窗可以在应用主窗口退到后台后继续显示,但一个普通定时器不因此获得可靠的长期后台执行保证。真实文件任务需要按业务选择后台任务机制、持久化进度和恢复方案;当前一分钟模拟处理只说明页面与窗口的状态关系。

四、回调确认接入结果

代码关系已经建立以后,验证需要把调用返回与实际画面对应起来。

任务完成需要继续观察状态变化。约一分钟后,日志记录完成回调,窗口文字变为文件整理完成。这里可以确认存储更新已经传到闪控页面。

状态更新需要同时核对任务侧和窗口侧。任务侧记录完成,说明定时回调已经到达;窗口文字随后变化,才能确认更新沿着存储传到了页面。如果日志已经完成而文字未变,排查范围就缩小到存储写入和页面绑定;如果完成回调尚未出现,则需要先检查任务执行,不能将两种现象都归为窗口失效。

随后通过主页面停止按钮关闭窗口,页面显示停止状态,日志记录应用主动停止。开发者复现时可以先移动窗口,避免它遮住主页面按钮,再执行停止操作。点击系统标题栏关闭按钮也涉及停止,但不同入口的停止原因需要分别记录,不能用一次按钮测试覆盖所有退出方式。

复现时还需要把开始整理与显示闪控窗视为两个独立按钮。先开始任务再显示窗口,可以检查加载时是否读到正在运行的状态;先显示窗口再开始任务,可以检查已显示页面能否接收后续更新。两种顺序使用相同存储,却分别覆盖初始读取和变化传播,开发者遇到只在某种顺序下空白的问题时,可以沿着这两个位置检查。当前截图和日志记录了已执行的操作顺序,其他顺序需要重新操作后再记录结果。

完成状态也需要与计时器清理一起观察。文字变成完成后,如果应用仍然保留旧计时器引用,用户下一次点击可能无法开始;如果清理时同时重置状态,窗口又会立即丢失完成提示。当前实现保留完成文字,只释放本轮计时编号,使用户有时间确认结果。真实文件任务接入以后,也应让完成提示对应实际处理结果,再决定什么时候开始下一轮。

总结

当前应用已经能够完成闪控窗的第一次创建、内容加载和任务状态更新。能力与权限通过以后,控制器加载页面并请求启动;任务随后把开始和完成状态写入传入的存储,窗口才有内容可以持续显示。主页面保留独立开始任务的入口,因此关闭闪控窗不会主动取消这轮整理。

任务只有开始和完成两个变化点时,主页面同步写入状态还比较容易检查。如果业务继续增加连续百分比,两处显示就需要始终读取同一进度,分散赋值会增加遗漏风险。因此,当前工程继续推进时,需要把任务状态和计时器集中起来,让页面变化不再影响任务执行次数。

我目前手里还没有可以测试 HarmonyOS 7 的真机,所以相关内容现阶段主要通过 HarmonyOS 7 模拟器进行验证,真机上的系统表现、设备差异和实际体验,后面有条件再继续补测,最终还是以实际设备运行结果为准。

完整代码

FloatViewPage.ets

@Entry({ useSharedStorage: true })
@Component
struct FloatViewPage {
  @LocalStorageLink('taskState') taskState: string = '尚未开始';
  build() {
    Column({ space: 16 }) {
      Text('文件整理').fontSize(22).fontWeight(FontWeight.Bold)
      Text(this.taskState).fontSize(18)
    }.width('100%').height('100%').padding(32).justifyContent(FlexAlign.Center)
  }
}

Index.ets

import { floatView } from '@kit.ArkUI';
import { abilityAccessCtrl, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct Index {
  @State taskState: string = '尚未开始';
  @State windowState: string = '尚未启动';
  @State supported: boolean = false;
  @State busy: boolean = false;
  private controller: floatView.FloatViewController | undefined = undefined;
  private taskTimer: number = -1;
  private storage: LocalStorage = new LocalStorage();

  aboutToAppear(): void {
    this.storage.setOrCreate<string>('taskState', this.taskState);
    this.supported = canIUse('SystemCapability.Window.SessionManager') && floatView.isFloatViewEnabled();
    console.info('FloatTask01 capability=' + this.supported);
  }

  startTask(): void {
    if (this.taskTimer !== -1) { return; }
    this.taskState = '正在整理文件';
    this.storage.set<string>('taskState', this.taskState);
    this.taskTimer = setTimeout(() => {
      this.taskState = '文件整理完成';
      this.storage.set<string>('taskState', this.taskState);
      this.taskTimer = -1;
      console.info('FloatTask01 task=completed');
    }, 60000);
  }

  async openFloat(): Promise<void> {
    if (this.busy || this.controller) { return; }
    if (!this.supported) { this.windowState = '当前设备不支持闪控窗'; return; }
    this.busy = true;
    try {
      const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
      const result = await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context,
        ['ohos.permission.FLOAT_VIEW']);
      console.info('FloatTask01 permission=' + JSON.stringify(result.authResults));
      if (result.authResults[0] !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        this.windowState = '闪控窗权限未授权'; return;
      }
      const controller = await floatView.create({ context: context,
        templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE });
      this.controller = controller;
      controller.onStateChange((info: floatView.FloatViewStateChangeInfo) => {
        this.windowState = '窗口状态:' + info.state;
        console.info('FloatTask01 state=' + JSON.stringify(info));
        if (info.state === floatView.FloatViewState.STOPPED) {
          controller.offStateChange();
          if (this.controller === controller) { this.controller = undefined; }
        }
      });
      await controller.setUIContext('pages/FloatViewPage', this.storage);
      const limits = floatView.getFloatViewLimits(floatView.FloatViewTemplateType.ROUNDED_RECTANGLE);
      console.info('FloatTask01 limits=' + JSON.stringify(limits));
      await controller.setWindowSize(limits.maxSize);
      await controller.start();
    } catch (error) {
      const err = error as BusinessError;
      this.windowState = '启动失败:' + err.code;
      console.error('FloatTask01 error=' + err.code + ':' + err.message);
      this.controller?.offStateChange();
      this.controller = undefined;
    } finally { this.busy = false; }
  }

  async closeFloat(): Promise<void> {
    if (!this.controller || this.busy) { return; }
    try { await this.controller.stop(); }
    catch (error) {
      console.error('FloatTask01 stop=' + (error as BusinessError).message);
    }
  }

  aboutToDisappear(): void {
    if (this.taskTimer !== -1) { clearTimeout(this.taskTimer); this.taskTimer = -1; }
  }

  build() {
    Column({ space: 20 }) {
      Text('文件整理').fontSize(30).fontWeight(FontWeight.Bold)
      Text('阶段 01 · 闪控窗状态显示').fontSize(18)
      Text(this.taskState).fontSize(24)
      Text('闪控窗能力:' + this.supported).fontSize(18)
      Text(this.windowState).fontSize(18)
      Button('开始整理').onClick(() => this.startTask())
      Button('显示闪控窗').enabled(!this.busy).onClick(() => this.openFloat())
      Button('停止闪控窗').onClick(() => this.closeFloat())
    }.width('100%').height('100%').padding(32).justifyContent(FlexAlign.Center)
  }
}

module.json5

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "requestPermissions": [{"name":"ohos.permission.FLOAT_VIEW","reason":"$string:float_reason","usedScene":{"abilities":["EntryAbility"],"when":"inuse"}}],
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "ohos.want.action.home"
            ]
          }
        ]
      }
    ],
    "extensionAbilities": [
      {
        "name": "EntryBackupAbility",
        "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
        "type": "backup",
        "exported": false,
        "metadata": [
          {
            "name": "ohos.extension.backup",
            "resource": "$profile:backup_config"
          }
        ],
      }
    ]
  }
}

entry/src/main/resources/base/profile/main_pages.json

{"src":["pages/Index","pages/FloatViewPage"]}

entry/src/main/resources/base/element/string.json

{
  "string": [
    {
      "name": "module_desc",
      "value": "module description"
    },
    {
      "name": "EntryAbility_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_label",
      "value": "label"
    },
    {
      "name": "float_reason",
      "value": "在闪控窗中查看本地任务状态"
    }
  ]
}
Logo

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

更多推荐