前言

文件整理正在进行时,用户可能临时需要暂停处理,也可能发现选错了内容,希望结束这一轮任务。闪控窗已经显示百分比以后,操作入口如果仍然全部留在主页面,用户就要为了一个暂停动作重新返回应用。把按钮放到进度旁边可以缩短这条操作路径,但窗口也因此开始参与业务控制,应用需要保证两处入口操作的是同一轮任务。

暂停、恢复和取消具有不同的业务含义。暂停需要留下工作位置,恢复要从这个位置继续,取消则结束本轮处理。如果三个按钮只是修改不同文字,计时器仍可能继续执行,界面就会显示已经暂停而数值不断增长。任务停止增长也不能单独解释当前状态,因为暂停、完成和取消都可能没有活动计时器,它们允许的下一步操作却不同。

HarmonyOS 7 闪控窗的内容区域由应用加载的 ArkUI 页面绘制,因此任务状态旁边可以放置应用自己的控件。系统管理窗口和基础交互区域,应用负责按钮事件与业务方法之间的调用关系。系统不会替文件整理判断当前是否允许恢复,也不会因取消按钮被点击就自动释放任务资源。按钮进入窗口以后,命令条件与执行结果仍然需要在应用中对应起来。

主页面保留操作按钮时,这组条件还需要对两个入口同时生效。如果各页面自行修改状态,一处取消以后,另一处可能仍按旧状态尝试恢复。因此,当前实现把状态判断放在共享任务对象中,页面只提交操作意图,再从共享存储读取结果。界面禁用按钮帮助用户理解可用操作,方法内部的检查则处理其他入口或重复点击,两者各自承担一部分责任。

当前整理任务按秒递增,暂停和取消需要清理的资源是计时器,进度仍然保存在共享存储中。真实文件处理还会涉及文件句柄或工作线程,应用需要等待这些资源实际停止,才能确认取消完成。当前任务先用明确的状态条件区分开始、暂停、恢复与取消,让窗口按钮和主页面遵守相同规则。

一、任务先判断状态,再决定是否接受按钮命令

任务命令的入口需要先读取业务状态。暂停、取消和完成之后都可能没有活动计时器,单看资源是否存在无法判断能否恢复。因此,当前任务对象保留状态文字,并在每个命令方法中检查允许的起点,按钮只负责提交用户意图。

运行中的任务可以暂停,也可以取消。暂停中的任务可以恢复或取消,完成与取消后的任务不能继续恢复。开发者需要把这些条件放到命令执行位置,否则仅在按钮上禁用操作,其他页面仍可能调用到错误分支。

任务状态机描述每种状态允许接受哪些操作,以及操作后会进入什么状态。当前应用使用固定中文字符串判断状态,页面也直接显示这些文字,因此开发者可以把方法条件与界面结果对应起来。表格列出了允许的转换,开发者接入按钮时可以据此检查每条调用是否具有合法起点。

当前状态接受的主要操作处理结果
尚未开始开始进度归零并启动计时器
正在整理文件暂停、取消停止计时,保存当前进度
已暂停恢复、取消原进度继续,或结束本轮
文件整理完成、已取消开始创建新一轮模拟任务

这张表描述当前应用规则,窗口系统不会替应用判断文件任务的状态。应用允许取消后重新开始,却不允许取消后恢复,因为取消已经结束了这一轮任务。界面需要保留这两个动作的区别,用户才能理解原进度会不会继续使用。

取消以后,用户需要从主页面重新开始一轮整理,新一轮进度会归零。恢复则只接受已暂停状态,沿用暂停时保留的数值。应用把两个入口分开,用户就能明确知道自己的操作是在继续原任务,还是重新开始处理。

重复操作也需要有明确结果。已经暂停时再次暂停,当前实现直接返回;完成后点击取消,同样不会覆盖完成结果。这样的无操作处理适用于本地按钮命令,因为重复事件没有新的业务含义,也没有必要把正常重复点击变成错误提示。

主页面保留任务操作,闪控页面按任务状态禁用不适用的按钮。按钮禁用帮助用户理解当前可以执行什么,任务方法内部的条件检查则保证其他入口不能绕过业务规则。开发者增加通知或键盘操作时,也需要调用同一组任务方法,避免各入口自行维护不同的允许状态。

状态判断还需要区分业务含义与资源占用。已暂停、已取消和文件整理完成都可以没有活动计时器,应用却只允许已暂停任务继续恢复。因此,任务方法需要先确认业务状态,再决定是否建立计时器,不能仅凭计时器空闲就开始推进进度。

二、暂停释放计时器,恢复保留原来的工作位置

暂停需要同时停止进度增长并更新状态文字。如果方法只把文字改成已暂停,计时回调仍然会增加百分比,两个页面就会显示互相矛盾的信息。pause() 因此先检查任务是否正在运行,再释放计时器,随后写入暂停状态。

  pause(): void {
    if (this.storage.get<string>('taskState') !== '正在整理文件') { return; }
    this.dispose();
    this.storage.set<string>('taskState', '已暂停');
    console.info('FloatTask03 action=pause progress=' + this.storage.get<number>('progress'));
  }

dispose() 清理计时器并恢复空闲编号,不修改进度。暂停方法可以复用这个资源清理动作,恢复时仍然读到原数值。资源清理与状态变化分开后,同一个清理方法也可以用于任务完成和取消,但每个入口写入的业务结果不同。

恢复方法只接受已暂停状态。它把状态改回正在整理文件,再调用 startTimer(),没有调用会把进度归零的 start()。这条调用路径很重要,直接复用开始方法会把继续处理误写成重新处理。

  resume(): void {
    if (this.storage.get<string>('taskState') !== '已暂停') { return; }
    this.storage.set<string>('taskState', '正在整理文件');
    this.startTimer();
  }

startTimer() 内部再次检查计时器编号,已有计时器就不重复建立。状态检查和计时器检查共同保护当前实现,但它们没有处理真实多线程并发。当前按钮回调与计时器在应用的这条执行路径中更新状态,后续如果应用加入后台工作线程,开发者需要额外设计消息和任务身份。

用户恢复任务以后,进度应当从暂停位置继续增加。如果进度回到零,开发者需要检查恢复是否错误调用了 start();如果进度增长加快,则需要检查旧计时器是否仍然存在。这两类结果分别对应入口选择和资源清理,应用不能只根据状态文字已经恢复运行就认定处理正确。

当前实现保留已经完成的百分点,没有保存一个计时间隔内已经经过的时长。用户暂停半秒后恢复,新的计时器仍然按照完整间隔推进下一步。这个选择适用于本地模拟进度;真实字节处理或精确计时需要保存实际工作位置,再从那个位置继续。

开始方法会把进度归零,恢复方法则保留当前进度,所以两个按钮不能共用任务初始化逻辑。当前应用没有任务历史,新一轮开始以后,页面会显示新结果。用户需要先确认上一轮处理状态,再从主页面主动开始,取消方法不会直接发起下一轮整理。

三、两个页面发送相同命令,取消结果继续留在窗口

任务方法已经能够检查状态并管理计时器,窗口按钮就可以直接调用这些方法。主页面也使用同一个任务对象,两个入口无需互相调用组件方法。每次命令更新共享存储以后,两处界面各自读取结果,闪控页面的按钮可用性也随任务状态变化。

闪控页面加载时仍使用同一个 LocalStorage,进度和状态通过装饰器更新。交互命令则调用从 Index.ets 导出的 runtime。模块中的任务对象只创建一次,窗口页面导入这个对象不会为按钮另建一套计时器。

        Button('暂停').enabled(this.taskState === '正在整理文件').onClick(() => runtime.pause())
        Button('恢复').enabled(this.taskState === '已暂停').onClick(() => runtime.resume())
        Button('取消').enabled(this.taskState === '正在整理文件' || this.taskState === '已暂停')
          .onClick(() => runtime.cancel())

按钮只表达操作意图,状态修改继续由任务方法执行。pause() 更新存储以后,主页面和闪控页面各自收到变化,不需要窗口按钮再额外通知主页面。两处界面因此能够显示相同状态。闪控页面再按该状态调整按钮可用性,主页面操作仍由任务方法检查当前状态是否允许执行。

任务对象与主页面暂时放在一个文件里,是当前文件数量与职责之间的取舍。窗口页面因此依赖主页面模块导出的业务对象,但没有访问主页面组件实例。任务复杂度增加以后,可以把这个对象移到独立运行时文件,两个页面的调用方式无需改变。

开发者排查按钮没有响应时,需要先确认按钮是否因状态被禁用。按钮允许点击以后,任务方法仍可能因为状态已经变化而拒绝命令;如果方法根本没有收到调用,则需要检查控件是否位于系统保留区域。当前三种情况分别涉及界面条件、业务判断和事件接收,处理位置也各不相同。

主页面调用任务方法以后,不需要再修改闪控页面按钮的 enabled 值。按钮条件读取的是任务状态,任务方法完成写入后,界面重新计算可用性。开发者如果在点击回调中手动设置另一组按钮布尔值,就会产生状态文字已取消、恢复按钮却仍然可用的组合。当前直接从状态推导按钮条件,减少了需要保持同步的字段。

当前三个按钮分别调用 pause()resume()cancel(),每个入口的含义保持固定。窗口页面不根据点击次数猜测下一次操作,业务方法会重新检查当前状态。如果产品改成一个随状态变化的操作按钮,方法内部仍然需要保留检查,因为其他入口也可能在两次点击之间改变任务。

任务对象在完成与取消后都允许重新开始,但开始按钮仍然属于主页面。窗口用户看到已取消结果后,需要回到应用发起新一轮整理;当前窗口没有隐含重试入口。这个安排减少了小窗口中的操作数量,也避免取消附近出现容易误触的重新开始。产品如果希望直接重试,可以增加明确按钮并调用开始方法,同时让用户知道旧进度将被重置。

当前取消方法接受运行和暂停两种状态,其他状态直接返回。方法清理计时器,再写入已取消,保留取消时的百分比。用户能够看到任务在什么位置结束,也不会误以为进度已经达到 100%。

取消任务不调用闪控窗的 stop()。窗口保留已取消结果,方便用户确认自己的操作;窗口关闭则由停止闪控窗按钮或系统关闭入口处理。这项选择来自当前交互需求,其他产品可以在确认结果后关闭窗口,但需要明确完成提示和窗口退出之间的关系。

闪控页面的恢复按钮在已取消状态被禁用,主页面直接调用恢复方法也会被状态条件拒绝。应用重新开始时才会归零并创建新计时器。这样可以防止用户误把取消理解成无限期暂停,也让新的任务拥有清楚起点。

实际文件处理的取消通常更复杂。应用可能需要通知工作线程、关闭文件句柄、清理不完整产物,并确认底层操作已经停止。当前代码没有这些资源,所以不能将计时器取消结果推广为真实文件写入可以立即终止。接入实际处理逻辑时,任务状态可能还需要增加正在取消,等待底层确认以后再显示已取消。

窗口异常只说明显示入口遇到了问题,不能直接证明整理失败。当前窗口操作没有把任务状态改成已取消,主页面仍然可以读取已有结果。开发者需要把窗口错误与业务取消分别处理,避免一个显示问题结束用户没有要求停止的工作。

取消与完成接近发生时,任务方法按照执行时读到的状态判断。计时器先达到 100% 并写入完成状态,随后执行的取消方法就会直接返回;取消先执行时,方法会清理计时器并写入已取消。真实后台处理如果涉及多个线程,应用还需要使用任务标识和执行回执确认结果,当前本地计时器没有覆盖这种并发处理。

异常操作的反馈也应当保持克制。用户快速点击两次暂停,第二次没有新的工作需要完成,任务方法返回即可;如果系统窗口接口返回错误,应用才需要记录对应窗口问题。把每次被状态条件拒绝的点击都变成弹窗,会干扰用户确认任务是否暂停。闪控页面使用按钮禁用表达允许范围,日志保留已经执行的命令,页面没有为无意义的重复点击制造额外错误。

四、状态已经改变,操作仍需落到可点击的区域

用户在闪控窗暂停任务以后,两处页面都会显示已暂停,百分比保留在暂停位置。恢复命令让原进度继续增长,取消命令则结束本轮并保留结束位置。当前按钮与任务方法已经建立这组关系,页面布局仍然需要保证用户能够实际触发这些命令。

窗口已经显示暂停状态,但百分比仍然增长时,开发者应检查计时器清理。如果只有一个页面更新,开发者则需要检查两个入口是否使用同一份存储。业务状态与数值都正确以后,仍然无法触发按钮的问题才需要继续定位到控件布局和系统交互区域。

按钮接近系统导航区域时,点击可能被系统交互接收,业务方法就没有机会处理暂停或取消。应用需要把操作控件放在可接收事件的内容区域,不能仅根据按钮已经绘制出来判断它可用。这个空间条件会直接影响任务控制,因此窗口布局需要与业务方法一起考虑。

当前三个按钮采用固定横向排列,窗口使用模板允许的最大尺寸。应用缩小窗口以后,原有排列未必还能完整显示,也可能把操作挤到系统保留区域。开发者调整窗口形态时,需要同时检查按钮排列与可用内容空间,保留一个能够完成完整操作的入口。

总结

当前窗口已经能够控制同一任务的暂停、恢复与取消,主页面不需要另行接收组件通知就能显示变化。合法状态判断放在任务方法内部,按钮禁用帮助用户理解操作范围,业务方法继续拦截不适用的命令。暂停与取消都清理计时器,但保留的业务结果不同,因此恢复方法只接受已暂停状态。

窗口增加操作按钮以后,任务状态与控件空间共同决定用户能否完成控制。当前方案让暂停保留进度、恢复继续执行、取消留下结果,窗口关闭仍然单独处理。应用继续缩小窗口或增加任务信息时,需要重新安排操作区,让这些命令仍然可以被明确找到并触发。

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

完整代码

FloatViewPage.ets

import { runtime } from './Index';

@Entry({ useSharedStorage: true })
@Component
struct FloatViewPage {
  @LocalStorageLink('taskState') taskState: string = '尚未开始';
  @LocalStorageLink('progress') progress: number = 0;
  build() {
    Column({ space: 16 }) {
      Text('文件整理').fontSize(22).fontWeight(FontWeight.Bold)
      Text(this.taskState).fontSize(18)
      Text(this.progress + '%').fontSize(28)
      Progress({ value: this.progress, total: 100 }).width('80%')
      Row({ space: 8 }) {
        Button('暂停').enabled(this.taskState === '正在整理文件').onClick(() => runtime.pause())
        Button('恢复').enabled(this.taskState === '已暂停').onClick(() => runtime.resume())
        Button('取消').enabled(this.taskState === '正在整理文件' || this.taskState === '已暂停')
          .onClick(() => runtime.cancel())
      }
    }.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';

class TaskRuntime {
  readonly storage: LocalStorage = new LocalStorage();
  private timer: number = -1;
  constructor() {
    this.storage.setOrCreate<string>('taskState', '尚未开始');
    this.storage.setOrCreate<number>('progress', 0);
  }
  start(): void {
    if (this.timer !== -1 || this.storage.get<string>('taskState') === '已暂停') { return; }
    this.storage.set<number>('progress', 0);
    this.storage.set<string>('taskState', '正在整理文件');
    this.startTimer();
  }
  pause(): void {
    if (this.storage.get<string>('taskState') !== '正在整理文件') { return; }
    this.dispose();
    this.storage.set<string>('taskState', '已暂停');
    console.info('FloatTask03 action=pause progress=' + this.storage.get<number>('progress'));
  }
  resume(): void {
    if (this.storage.get<string>('taskState') !== '已暂停') { return; }
    this.storage.set<string>('taskState', '正在整理文件');
    this.startTimer();
  }
  cancel(): void {
    const state = this.storage.get<string>('taskState');
    if (state !== '正在整理文件' && state !== '已暂停') { return; }
    this.dispose();
    this.storage.set<string>('taskState', '已取消');
    console.info('FloatTask03 action=cancel progress=' + this.storage.get<number>('progress'));
  }
  private startTimer(): void {
    if (this.timer !== -1) { return; }
    this.timer = setInterval(() => {
      const progress = Math.min(100, (this.storage.get<number>('progress') ?? 0) + 1);
      this.storage.set<number>('progress', progress);
      console.info('FloatTask03 progress=' + progress);
      if (progress === 100) {
        this.storage.set<string>('taskState', '文件整理完成');
        this.dispose();
      }
    }, 1000);
  }
  dispose(): void {
    if (this.timer !== -1) { clearInterval(this.timer); this.timer = -1; }
  }
}
export const runtime: TaskRuntime = new TaskRuntime();

@Entry(runtime.storage)
@Component
struct Index {
  @LocalStorageLink('taskState') taskState: string = '尚未开始';
  @LocalStorageLink('progress') progress: number = 0;
  @State windowState: string = '尚未启动';
  @State supported: boolean = false;
  @State busy: boolean = false;
  private controller: floatView.FloatViewController | undefined = undefined;


  aboutToAppear(): void {
    this.supported = canIUse('SystemCapability.Window.SessionManager') && floatView.isFloatViewEnabled();
    console.info('FloatTask03 capability=' + this.supported);
  }

  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('FloatTask03 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('FloatTask03 state=' + JSON.stringify(info));
        if (info.state === floatView.FloatViewState.STOPPED) {
          controller.offStateChange();
          if (this.controller === controller) { this.controller = undefined; }
        }
      });
      await controller.setUIContext('pages/FloatViewPage', runtime.storage);
      const limits = floatView.getFloatViewLimits(floatView.FloatViewTemplateType.ROUNDED_RECTANGLE);
      console.info('FloatTask03 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('FloatTask03 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('FloatTask03 stop=' + (error as BusinessError).message);
    }
  }

  aboutToDisappear(): void {
    runtime.dispose();
  }

  build() {
    Column({ space: 12 }) {
      Text('文件整理').fontSize(30).fontWeight(FontWeight.Bold)
      Text('阶段 03 · 任务交互控制').fontSize(18)
      Text(this.taskState).fontSize(24)
      Text(this.progress + '%').fontSize(32)
      Progress({ value: this.progress, total: 100 }).width(260)
      Text('闪控窗能力:' + this.supported).fontSize(18)
      Text(this.windowState).fontSize(18)
      Row({ space: 12 }) {
        Button('暂停任务').onClick(() => runtime.pause())
        Button('恢复任务').onClick(() => runtime.resume())
        Button('取消任务').onClick(() => runtime.cancel())
      }
      Button('开始整理').onClick(() => runtime.start())
      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"
          }
        ],
      }
    ]
  }
}

main_pages.json

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

string.json

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

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

更多推荐