【HarmonyOS 7 闪控窗深度实战】02 闪控窗同步应用页面与任务进度
前言
文件整理持续进行时,只有开始和完成两种文字还不足以说明处理到了哪里。主页面增加百分比以后,用户会希望闪控窗也显示同样的进展,并且在中途打开窗口时直接看到当前数值。如果两处各自保存进度,更新遗漏就可能让主页面继续增长而小窗口停在旧值。此时应用需要解决的是同一任务怎样被两个页面持续观察,页面的数量不能改变任务本身的执行过程。
主页面在打开窗口时复制一次百分比,只能让窗口获得那一刻的数值。任务继续执行以后,这份副本不会自动增长。如果闪控页面再建立自己的计时器,两处页面又会按照各自的启动时间计算进度,用户越晚打开窗口,两处数值就越容易出现差异。因此,应用需要先确定由谁更新任务进度,再把两个页面接到这个数据来源。
HarmonyOS 7 允许应用在加载闪控页面时传入共享存储。应用把任务正在使用的存储交给页面以后,页面就能读取当前进度,并继续接收变化。系统不会自动合并两处分别创建的存储,所以开发者需要确认主页面入口和闪控页面加载参数指向同一个对象。
同一份存储还需要配合唯一的任务写入者。用户重复点击开始时,任务对象不能再建立一个计时器;用户关闭并重新打开窗口时,窗口初始化也不能重置进度。应用因此把任务执行与窗口显示分开管理,让显示入口的变化不影响正在进行的整理。
当前整理任务每秒增加一个百分点,进度达到 100% 后结束。任务对象保存状态和计时器,两个页面只读取它发布的结果。这种安排让同一进程中的两个页面持续显示相同进度,没有接入真实文件处理或进程恢复;开发者替换处理逻辑时,仍然可以保留页面与共享存储之间的连接。

一、两个页面需要连接到同一个进度来源
两个页面出现不同进度时,开发者可以先找出所有修改百分比的位置。如果主页面和闪控页面分别保存一个数值,每次任务变化都需要更新两处;窗口中途出现时,还要另外补上此前的进度。应用把百分比集中保存以后,任务每次只更新一个位置,两处页面就可以从同一个结果绘制内容。
应用如果依靠反复加载闪控页面来刷新百分比,旧页面内容会被销毁,新页面随后重新建立。这样的更新会同时影响页面局部状态,而任务增长只需要改变一个数值。因此,当前实现保留已经加载的页面,通过共享存储通知绑定属性更新。
setUIContext() 可以接收 LocalStorage。这个参数把应用创建的存储交给窗口页面,窗口中使用对应装饰器的属性就能跟随数据变化。主页面也绑定同一份存储以后,应用就不需要在两个页面之间相互发送百分比。
export const runtime: TaskRuntime = new TaskRuntime();
@Entry(runtime.storage)
runtime 在当前模块中创建一次,内部保存一个 LocalStorage。主页面通过 @Entry(runtime.storage) 连接这份存储,窗口加载方法也会接收 runtime.storage。两个入口引用的是同一个对象,所以任务写入进度以后,两处页面能够读取相同的结果。
如果两个页面分别执行 new LocalStorage(),即使键名都是 progress,它们仍然是两个独立对象。主页面写入一个对象,窗口读取另一个对象,百分比就无法持续同步。开发者排查时需要沿着存储的创建位置和传参关系检查,确认两个入口拿到的是同一个对象。
任务对象保存一份进度,其他界面从这份进度绘制内容,这种做法通常称为单一状态源。当前采用它,是为了减少两个页面之间重复赋值和遗漏更新的机会。这个对象结构属于应用的实现选择,其他状态管理方式只要能够明确数据归属,也可以解决相同问题。
当前任务对象保留 taskState 和 progress 两个键。状态解释任务正在执行还是已经完成,百分比说明模拟工作量。两者使用明确类型,页面不会把正在整理文件这样的文字写进数值字段,也不会让进度条读取一个含义不确定的对象。

任务存储需要保留当前结果,让后来打开的页面也能读取最新状态。事件只能说明某次增加已经发生,新打开的窗口还需要知道此前累计到了哪里。因此当前存储保留可直接读取的进度数值,窗口首次进入就能获得现状,不需要重放已经发生的全部定时回调。
进度条长度和百分比文字都能从 progress 计算,任务对象就不需要再保存条形宽度和显示字符串。百分比变化时,页面根据同一个数值更新进度条与文字,避免出现文字已经变化而条形长度仍然保留旧结果的情况。开发者增加其他展示形式时,也可以先判断它是否能够从已有数据推导,减少需要同步维护的副本。
任务状态和百分比需要在业务方法中配合更新。定时器达到结束条件时,任务对象先保存最终进度,再写入完成状态,两个页面分别响应这些变化。当前实现没有保证多个属性在同一显示帧完成更新,因此业务结束条件仍然由任务方法判断,页面只呈现结果。
二、开始入口先保护计时器,再更新任务状态
同一份共享存储解决了两个页面读取哪里的问题,但它不会阻止应用创建两个写入者。重复点击如果建立了两个计时器,同一个存储也会被推进得过快。因此,开始方法把运行检查放在归零之前,先确认这一轮任务是否已经存在,才能决定是否初始化新的进度。
用户连续点击开始整理时,按钮事件会连续触发。界面暂时禁用按钮能够减少误操作,但任务对象仍应检查自己是否已经运行,因为其他入口也可能调用同一个方法。当前代码使用计时器编号判断运行状态,已有计时器时直接返回。
start(): void {
if (this.timer !== -1) { return; }
this.storage.set<number>('progress', 0);
this.storage.set<string>('taskState', '正在整理文件');
运行检查位于进度归零之前,因此重复调用 start() 不会重置正在处理的任务。只有计时器处于空闲状态时,方法才会初始化新一轮进度。这个检查保护的是当前应用的开始语义:用户重复请求处理同一项工作,已经完成的部分应该保留。
如果开始按钮以后允许每次提交不同的文件集合,应用就需要区分重复点击和新的任务请求。前者可以直接返回,后者则需要保存新的任务信息。当前按钮没有提交不同的数据,所以任务方法可以用计时器编号阻止重复开始,不需要让两个页面分别判断。
计时器每次从存储读取进度,再把增加后的数值写回。当前实现每秒增加一个百分点,并使用 Math.min() 把结果限制在 100 以内。进度条与文字读取相同数值,任务就不会因为两处页面采用不同计算方式而出现偏差。
const progress = Math.min(100, (this.storage.get<number>('progress') ?? 0) + 1);
this.storage.set<number>('progress', progress);
console.info('FloatTask02 progress=' + progress);
任务进度达到 100% 后,TaskRuntime 写入文件整理完成,并释放当前计时器。清理方法同时把计时器编号恢复成空闲标记,用户再次开始时才能建立新的计时器。如果方法只调用 clearInterval(),却没有恢复编号,下一次开始仍然会被判断为任务正在运行。
当前进度只适用于定时器模拟。接入真实文件处理时,任务对象应当根据已完成字节数、条目数或业务阶段计算进度,再把真实结果写入同一个状态入口。页面读取关系可以保留,定时推进逻辑需要替换,不能把 UI 的演示百分比用于表示真实处理结果。
setInterval() 也不保证每个回调都严格在整秒发生。系统调度、应用状态和负载会影响执行时间,因此当前百分比代表已经执行的模拟步骤,不代表精确剩余秒数。界面没有显示倒计时,就是为了避免把调度间隔误写成完成时间承诺。
TaskRuntime 目前与主页面保存在同一个文件中,但它独立于 Index 组件实例存在。窗口加载需要的是这个任务对象持有的存储,不需要取得主页面组件。这个区别让主页面和闪控页面可以各自绘制,而任务对象仍然统一管理进度。
进度归零只发生在新一轮任务开始时。显示闪控窗的方法不调用 start(),因此用户中途查看进度不会重新执行整理。开发者接入窗口入口时,需要保留这两条调用路径的区别,避免复用初始化方法时覆盖正在处理的进度。
三、两个页面连接共享存储,进度变化直接更新内容
任务已经能够独立推进以后,闪控页面需要连接到它正在使用的存储。窗口加载参数决定页面首次读取哪里,也决定页面以后接收谁的变化。应用因此把 runtime.storage 直接传入控制器,避免在窗口创建方法中再初始化任务状态。
窗口创建仍然需要满足 Stage 模型、设备能力、ohos.permission.FLOAT_VIEW 权限和主窗口位于前台等条件,控制器也需要有效的 UIAbilityContext。这些条件通过以后,应用在启动窗口前加载 pages/FloatViewPage,路径必须与 main_pages.json 中的配置一致。加载时同时传入任务存储,窗口就能直接读取已经产生的进度。
await controller.setUIContext('pages/FloatViewPage', runtime.storage);
闪控页面使用 @Entry({ useSharedStorage: true }) 取得传入的共享存储,再以 @LocalStorageLink('progress') 声明数值属性。主页面使用相同键名和类型,因此文字与进度条都由相同数据驱动。当前页面没有在出现时启动业务计时器,这一点保证了打开窗口不会加快任务。
如果任务已经运行,而窗口始终保留默认文字,开发者可以沿着数据进入页面的顺序排查:先确认构造函数创建了 taskState 和 progress,再确认加载参数确实是 runtime.storage,随后检查共享入口与属性绑定。任何一处对象或键名不一致,都会使页面无法接收预期的数据。数据连接已经正确以后,开发者再检查进度条与文字是否使用了绑定属性。
@LocalStorageLink 允许页面与存储双向连接,但当前两个页面只展示任务结果。进度写入集中在 TaskRuntime 的开始方法和计时回调中,窗口没有自行修改百分比。这个分工需要由应用代码保持,装饰器本身不会限制业务写入权限。
主页面与闪控页面会分别响应共享属性的变化。两个页面因此显示同一份任务结果,但各自的绘制时机仍由界面更新过程决定。开发者不能把数据相同理解为严格的逐帧同步,也不需要为短暂的显示先后再增加一套进度补偿逻辑。
反复调用 setUIContext() 不能替代状态更新。这个接口会重新加载内容,重复调用还会销毁旧 UIContent,进度频繁变化时没有必要付出这种重建成本。应用只在创建窗口时加载页面,后续通过存储更新文字与进度条,页面才能保留自己的显示过程。
计时回调读取 progress 时使用空值默认值零,避免缺少键时继续计算出无效数值。构造函数已经初始化这个键,所以正常执行应当读取到已保存的进度。如果任务意外归零,开发者仍然需要检查存储是否被替换、键是否被删除,默认值不能代替这些错误的排查。
主页面的 Progress 与文字读取同一个属性,进度条的总量设置为一百。当前数值因此可以直接作为百分比显示,不需要再除以任务数量。真实业务如果保存的是已处理条目数,页面需要根据总条目计算比例,不能继续把条目数直接附加百分号。数据单位必须在任务对象与显示层之间约定清楚,否则两处页面可能同步显示同一个错误数值。
四、窗口重新创建以后继续读取当前进度
共享存储保留的是任务现状,因此窗口关闭后不需要保存一份额外的百分比。用户再次请求显示时,应用重新创建控制器和页面,再传入原来的 runtime.storage。新页面先读取当前结果,然后接收后续变化,任务没有必要重放之前发生的全部进度更新。
用户关闭闪控窗时,停止方法只处理窗口。任务对象没有放在 FloatViewPage 中,所以窗口页面销毁不会直接释放业务计时器。应用收到 STOPPED 后释放旧控制器引用,再次显示时创建新控制器;任务对象及其存储仍然保留,新的页面就有连续的数据可以读取。
任务能够跨越一次窗口重建继续存在,仍然受到应用自身生命周期限制。主页面销毁时会调用 runtime.dispose(),进程被回收后,内存中的存储也会丢失。当前实现没有将任务写入持久化存储,因此重新启动应用不能恢复上一进程的处理位置。
主页面退到后台与主页面组件销毁也不能混同。普通前后台变化不一定销毁页面,但真实后台执行仍受系统调度约束。闪控窗显示能力不等于持续任务保活能力,实际产品需要按任务类型申请相应后台执行机制,并保存必要的恢复信息。
如果每次打开窗口都重新显示零,开发者可以先检查显示方法是否创建了新存储,再检查页面出现时是否调用了任务初始化。新页面确实需要重新建立属性绑定,但它应该连接已有状态。只有用户明确开始新一轮整理,任务方法才需要重置百分比。
如果主页面持续增长,而闪控页面长时间停留在旧进度,开发者应检查闪控页面绑定的对象与键名。如果两处都增长得异常快,排查方向则是任务对象是否建立了多个计时器。两类问题虽然都表现为进度不符合预期,却分别来自数据连接和任务执行,修改进度条动画不能解决它们。
整理任务开始以后,主页面和闪控窗都会读取同一份进度状态。用户中途打开窗口,可以直接看到当前百分比;用户重复点击开始,运行检查会保留原来的进度和计时器。窗口的显示时机因此不会改变这一轮任务已经完成的工作。

页面已经共用状态以后,新增交互也应当进入同一个任务对象。暂停与恢复需要保留已有进度,取消需要结束当前执行,它们都不能简单复用会归零的开始方法。应用可以继续在 TaskRuntime 中定义这些命令及其允许状态,让两个页面提交操作后仍然读取同一份结果。
总结
当前实现已经让主页面和闪控窗读取同一份任务进度,业务计时器由模块中的任务对象统一管理。重复开始会先经过运行检查,显示窗口也不会启动新的计时器,因此页面数量没有改变任务执行次数。进度差异可以沿着任务写入、存储传递和属性绑定定位,每个环节都有明确的检查对象。
这套结构解决了同一进程中重复保存进度和重复启动计时器的问题。窗口重新创建以后仍然读取原存储,应用无需为它补播历史变化。闪控窗增加暂停或取消按钮时,业务命令也需要由这个任务对象统一处理,才能继续保持两个页面之间的关系。
我目前手里还没有可以测试 HarmonyOS 7 的真机,所以相关内容现阶段主要通过 HarmonyOS 7 模拟器进行验证,真机上的系统表现、设备差异和实际体验,后面有条件再继续补测,最终还是以实际设备运行结果为准。
完整代码
FloatViewPage.ets
@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%')
}.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) { return; }
this.storage.set<number>('progress', 0);
this.storage.set<string>('taskState', '正在整理文件');
this.timer = setInterval(() => {
const progress = Math.min(100, (this.storage.get<number>('progress') ?? 0) + 1);
this.storage.set<number>('progress', progress);
console.info('FloatTask02 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('FloatTask02 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('FloatTask02 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('FloatTask02 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('FloatTask02 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('FloatTask02 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('FloatTask02 stop=' + (error as BusinessError).message);
}
}
aboutToDisappear(): void {
runtime.dispose();
}
build() {
Column({ space: 20 }) {
Text('文件整理').fontSize(30).fontWeight(FontWeight.Bold)
Text('阶段 02 · 实时进度同步').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)
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": "在闪控窗中查看本地任务状态"
}
]
}
更多推荐

所有评论(0)