【共创稿事节】做饭时少回几次 App:HarmonyOS 7 闪控窗解读与实战
面条刚下锅,青菜还在炒,另一边的米饭也快好了。手机里开着计时器,手上却不可能一直停在那个页面:要查一下菜谱,也可能要回一条消息。每次切回来,只是为了看一眼还剩多久。
这个片刻很能说明一种常见矛盾:任务仍在进行,用户的注意力已经去了别处。应用主页面承载了完整功能,眼下需要的却只有剩余时间、暂停和继续。把完整应用重新打开,显得有些重。
HarmonyOS 7 的闪控窗给了应用一种更轻的呈现方式。它能把少量关键内容与简易操作留在当前界面之上。真正值得设计的,是哪些任务需要这样的存在感,以及小窗应该怎样与完整页面保持一致。
一、先决定什么值得一直出现在眼前
闪控窗可以悬浮在桌面或其他应用界面上,展示内容并提供快捷操作。该能力从 API26 开始支持,适用于 Stage 模型。官方开发指导
它适合的任务通常同时具备几个特点:正在进行,需要偶尔查看,当前操作不复杂,而且用户有合理理由离开主页面。备餐倒计时符合这样的条件——人离开计时器页面后,锅里的事情并没有停止。
但把主页面缩小塞进窗口,未必能让体验变轻。菜谱全文、全部设置和历史记录都常驻眼前,会让小窗变成另一块拥挤界面。本例只保留当前任务、倒计时、暂停/继续、下一项和返回备餐,把管理与阅读留给完整页面。
同样的思路可以用于一段正在进行的练习、需要短暂关注的等待任务。是否采用小窗,应由任务的连续性和操作频率决定;用户也需要能随时关闭它,重新获得屏幕空间。
二、把“灶边”放进一次备餐过程
“灶边”有三项任务:番茄鸡蛋面、清炒时蔬和焖饭。主页面负责一起开始和查看全部任务,菜谱页负责阅读做法,小窗负责眼前最急的一件事。
先在设置中选择分钟计时,回到备餐页点击“一起开始备餐”,再点击“开启闪控窗”。首次使用需要按系统提示授权。窗口出现后,返回桌面,仍能看到当前任务与剩余时间。
这里的变化不在于计时器变小了,而在于用户不必为一次短暂确认离开手上的事情。任务仍有可见的入口,完整页面也仍然承担原来的职责。
假如面条暂时不用继续计时,在小窗点“暂停”,等待几秒,数字应保持不动。点“继续”后再推进;点“下一项”关注青菜;最后返回备餐页,检查三项任务是否接上刚才的状态。
这轮真机记录里,暂停前后读取的剩余时间都是157646ms,页面显示02:38;切换任务后返回,三项截止时间与原来一致。它验证的是同一个任务经过不同入口操作后,仍然有同一份状态。
再试一次系统标题栏右上角的关闭按钮,回到备餐页重新开启。窗口可以关闭,任务不必因此重置;用户关闭的是眼前的呈现,是否结束计时应由清楚的业务操作决定。
以上来自 Mate 60 Pro、HarmonyOS 7.0.0.109 / API26 的实际闪控窗运行。窗口出现与关闭分别由 STARTED、STOPPED 回调核对,操作标注添加在采集画面之外。
三、小窗最容易出问题的地方,是出现了第二套任务
如果主页面自己倒数,小窗也再开一个倒计时,两个数字暂时相同并不难。但小窗暂停、主页面恢复、应用暂时失去刷新机会后,两个入口就可能逐渐分开。
本例把“任务”和“窗口”分开管理。任务统一保存在 CookingSession 中,两个页面订阅同一个快照;窗口控制器只负责授权、创建、系统状态与显示。小窗里的暂停按钮也调用同一个会话方法。
剩余时间按截止时间推导,而不是每刷新一次就减一秒。这样,刷新晚了一次,不会把任务时间也一起拖慢。会话中的实际实现位于 entry/src/main/ets/CookingSession.ets:
static refresh(): void {
let completed: boolean = false;
for (const task of CookingSession.state.tasks) {
if (task.status !== 'running') { continue; }
task.remaining = Math.max(0, task.deadline - Date.now());
if (task.remaining === 0) {
task.status = 'done'; completed = true; CookingSession.log('clock', 'complete', task.id, '倒计时结束');
}
}
if (completed) { CookingSession.save(); }
}
static publish(): void { cookStorage.setOrCreate('snapshot', JSON.stringify(CookingSession.state)); }
暂停保存当前剩余时间;恢复时,用当前时间加上剩余时间重新建立截止时间。这个方法同时服务于主页面与小窗:
static toggle(id: number, source: string): void {
CookingSession.refresh(); const task = CookingSession.state.tasks[id]; if (!task) { return; }
CookingSession.state.focus = id;
if (task.status === 'running') {
task.status = 'paused'; CookingSession.log(source, 'pause', id, String(task.remaining));
} else {
if (task.status === 'done') { task.remaining = task.duration; }
task.deadline = Date.now() + task.remaining; task.status = 'running';
CookingSession.log(source, 'resume', id, String(task.deadline));
}
CookingSession.publish(); CookingSession.save();
}
按钮来自哪个入口,只影响操作记录,不应改变计时规则。这个安排也便于扩展:菜谱页要增加“开始这一步”时,仍然操作已有任务,不必另建一份计时逻辑。
四、接入时,先接共享状态,再接系统窗口
从文末仓库导入工程并完成个人签名后,可以按三个文件往下读:CookingSession.ets 保存任务,FloatTimerController.ets 对接系统窗口,pages/FloatTimer.ets 呈现当前任务和按钮。
1. 配齐权限、页面和主窗口入口
使用 DevEco Studio 打开仓库根目录,同步依赖,配置包含 ohos.permission.FLOAT_VIEW 的有效个人调试 Profile,再安装到支持闪控窗的 API26 真机。应用内授权与签名中的受限权限覆盖都需要完成。
在 entry/src/main/module.json5 的 module 对象内声明权限:
"requestPermissions": [{
"name": "ohos.permission.FLOAT_VIEW",
"reason": "$string:float_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}]
entry/src/main/resources/base/profile/main_pages.json 注册主页面和闪控窗页面:
{"src": ["pages/Index", "pages/FloatTimer"]}
entry/src/main/ets/entryability/EntryAbility.ets 在窗口创建时初始化会话并传入共享存储,实际入口如下:
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { CookingSession, cookStorage } from '../CookingSession';
import { FloatTimerController } from '../FloatTimerController';
export default class EntryAbility extends UIAbility {
async onWindowStageCreate(stage: window.WindowStage): Promise<void> {
await CookingSession.init(this.context);
await stage.loadContent('pages/Index', cookStorage);
}
onWindowStageDestroy(): void { FloatTimerController.release(); CookingSession.dispose(); }
}
2. 让两个页面订阅同一份任务
主窗口与闪控窗使用同一个 cookStorage。在 EntryAbility 中初始化任务并把它传给主页面,创建小窗时再通过 setUIContext 传给小窗页面:
export const cookStorage: LocalStorage = new LocalStorage();
// EntryAbility:主窗口
await CookingSession.init(this.context);
await stage.loadContent('pages/Index', cookStorage);
// FloatTimerController:闪控窗
await controller.setUIContext('pages/FloatTimer', cookStorage);
@Entry(cookStorage)
@Component
struct FloatTimer {
@LocalStorageLink('snapshot') @Watch('refresh') snapshot: string = '';
@State state: CookSnapshot = new CookSnapshot();
private refresh(): void {
if (this.snapshot) {
this.state = JSON.parse(this.snapshot) as CookSnapshot;
}
}
// build() 展示同一份任务状态
}
snapshot 变化后刷新页面,主页面与小窗按钮继续调用同一个 CookingSession。小窗按钮的实际绑定如下:
Button(taskAction(this.state.tasks[this.state.focus].status))
.onClick(() => {
CookingSession.toggle(CookingSession.state.focus, 'float');
})
Button('下一项').onClick(() => { CookingSession.next(); })
3. 创建并启动系统闪控窗
窗口侧的调用顺序是能力检查、请求授权、创建控制器、注册状态与尺寸回调、绑定页面、启动。不要把 start() 返回直接解释成窗口已经显示;应等待系统 STARTED 回调,再更新页面的开启状态。
下面给出 entry/src/main/ets/FloatTimerController.ets 的控制器实现,包含授权、回调、尺寸、避让区、返回和释放。主页面“开启闪控窗”的回调调用 FloatTimerController.open(this.ctx()),其中 ctx() 取当前页面的 UIAbilityContext。
import { abilityAccessCtrl, common } from '@kit.AbilityKit';
import { floatView, display, window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { CookingSession, cookStorage } from './CookingSession';
export class FloatTimerController {
private static controller: floatView.FloatViewController | undefined = undefined;
private static starting: boolean = false;
private static started: boolean = false;
private static density: number = 1;
private static stateCallback = (info: floatView.FloatViewStateChangeInfo): void => {
const names: string[] = ['', '已开启', '已隐藏', '已关闭', '已收起侧边栏', '已转闪控球', '窗口异常'];
CookingSession.windowEvent(names[info.state] ?? '未知状态', JSON.stringify(info));
if (info.state === floatView.FloatViewState.STARTED) { FloatTimerController.starting = false; FloatTimerController.started = true; CookingSession.message(''); FloatTimerController.measure(); }
if (info.state === floatView.FloatViewState.ERROR) { FloatTimerController.starting = false; FloatTimerController.started = false; }
if (info.state === floatView.FloatViewState.STOPPED) { FloatTimerController.starting = false; FloatTimerController.release(); }
};
private static rectCallback = (info: floatView.FloatViewRectChangeInfo): void => {
CookingSession.log('window', 'rect', -1, JSON.stringify(info)); FloatTimerController.measure();
};
private static limitsCallback = (limits: floatView.FloatViewLimits): void => {
CookingSession.log('window', 'limits', -1, JSON.stringify(limits));
FloatTimerController.resize(limits).catch((error: BusinessError) => { CookingSession.message('窗口尺寸调整失败:' + error.code); });
};
private static measure(): void {
if (!FloatTimerController.controller || !FloatTimerController.started) { return; }
try {
const properties = FloatTimerController.controller.getWindowProperties();
CookingSession.state.topInset = Math.max(12, properties.avoidArea.topRect.height / FloatTimerController.density);
CookingSession.state.bottomInset = Math.max(12, properties.avoidArea.bottomRect.height / FloatTimerController.density);
CookingSession.log('window', 'properties', -1, JSON.stringify(properties)); CookingSession.publish(); CookingSession.save();
} catch (error) {
const code = (error as BusinessError).code;
if (code === 1300031) { CookingSession.log('window', 'propertiesUnavailable', -1, String(code)); return; }
CookingSession.message('读取窗口区域失败:' + code);
}
}
private static async resize(limits: floatView.FloatViewLimits): Promise<void> {
let width: number = Math.round(Math.max(limits.minSize.width, Math.min(limits.maxSize.width, 320 * FloatTimerController.density)));
let height: number = Math.round(Math.max(limits.minSize.height, Math.min(limits.maxSize.height, 320 * FloatTimerController.density)));
if (limits.ratioLimits.length > 0) {
const desired: number = width / height;
const range = limits.ratioLimits.reduce((best: floatView.RatioLimit, candidate: floatView.RatioLimit): floatView.RatioLimit => {
const bestDistance = Math.abs(desired - Math.max(best.minRatio, Math.min(best.maxRatio, desired)));
const distance = Math.abs(desired - Math.max(candidate.minRatio, Math.min(candidate.maxRatio, desired)));
return distance < bestDistance ? candidate : best;
});
const ratio = Math.max(range.minRatio, Math.min(range.maxRatio, desired));
height = Math.round(Math.max(limits.minSize.height, Math.min(limits.maxSize.height, width / ratio)));
width = Math.round(Math.max(limits.minSize.width, Math.min(limits.maxSize.width, height * ratio)));
}
const size: window.Size = { width, height };
CookingSession.log('window', 'requestedSize', -1, JSON.stringify(size));
await FloatTimerController.controller?.setWindowSize(size);
}
static async open(context: common.UIAbilityContext): Promise<void> {
if (FloatTimerController.starting) { CookingSession.message('闪控窗正在开启,请稍候。'); return; }
FloatTimerController.starting = true;
try {
if (!floatView.isFloatViewEnabled()) { throw new Error('当前设备未开放闪控窗能力'); }
const permission = await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context, ['ohos.permission.FLOAT_VIEW']);
if (permission.authResults[0] !== 0) { throw new Error('需要授权后才能开启闪控窗'); }
if (FloatTimerController.controller) {
await FloatTimerController.controller.setFloatViewVisibilityInApp(true); FloatTimerController.starting = false; return;
}
FloatTimerController.density = display.getDefaultDisplaySync().densityPixels;
FloatTimerController.controller = await floatView.create({ context, templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE });
FloatTimerController.controller.onStateChange(FloatTimerController.stateCallback); FloatTimerController.controller.onRectChange(FloatTimerController.rectCallback);
FloatTimerController.controller.onLimitsChange(FloatTimerController.limitsCallback);
await FloatTimerController.controller.setUIContext('pages/FloatTimer', cookStorage);
await FloatTimerController.resize(floatView.getFloatViewLimits(floatView.FloatViewTemplateType.ROUNDED_RECTANGLE));
CookingSession.windowEvent('正在开启', 'start request');
await FloatTimerController.controller.start();
// Promise 完成只表示调用完成;成功状态以 STARTED 回调为准。
} catch (error) {
FloatTimerController.starting = false; FloatTimerController.release();
const cause = error as BusinessError;
CookingSession.windowEvent('开启失败', String(cause.code) + ': ' + cause.message);
CookingSession.message(cause.message + (cause.code ? '(' + cause.code + ')' : ''));
}
}
static async restore(): Promise<void> {
const parameters: Record<string, Object> = {};
parameters['taskId'] = CookingSession.state.focus;
try { await FloatTimerController.controller?.restoreMainWindow(parameters); }
catch (error) { CookingSession.message('返回备餐失败:' + (error as BusinessError).code); }
}
static async stop(): Promise<void> {
try { await FloatTimerController.controller?.stop(); }
catch (error) { CookingSession.message('关闭失败:' + (error as BusinessError).code); }
}
static release(): void {
FloatTimerController.started = false;
FloatTimerController.controller?.offStateChange(FloatTimerController.stateCallback); FloatTimerController.controller?.offRectChange(FloatTimerController.rectCallback);
FloatTimerController.controller?.offLimitsChange(FloatTimerController.limitsCallback); FloatTimerController.controller = undefined;
}
}
运行后依次检查:点击开启并授权,小窗出现;回桌面后仍显示当前任务;暂停后等几秒数字不变;继续、下一项、返回备餐后三项任务保持一致;点击系统关闭,再开启时不重置任务。restoreMainWindow 只由用户点击“返回备餐”触发。
ohos.permission.FLOAT_VIEW 还涉及签名中的受限权限覆盖。仅在 module.json5 写上权限,不等于已经具备使用条件。本例录制时使用的是包含该权限的5天试用调试 Profile;复现时需要配置自己当前有效的权限与签名,不能沿用录制日期的试用状态。
完整授权、窗口创建、尺寸与避让区处理,以及页面按钮接线,见配套工程与 README和仓库 README。应用应在前台启动闪控窗,窗口数量和与其他小窗形态的冲突也要按系统约束处理。
五、窗口能出现之后,还要回答三个问题
第一个问题是“用户关闭它以后会怎样”。这轮实测收到 STOPPED 后保留任务,再开启时收到新的 STARTED,没有把截止时间重置。窗口生命周期与任务生命周期被分别处理了。
第二个问题是“窗口变小,操作还在不在”。尺寸、宽高比和系统避让区会影响可用空间,本例从系统读取实际限制,按钮放在避让区之外,内容过高时可以滚动。下面是实际深色窗口,文字与按钮也需要在这种背景下检查。
第三个问题是“应用以后还会不会提醒”。小窗能显示,不代表进程结束后仍会准时触发提醒。若要把备餐示例做成长期使用的提醒工具,还需要结合通知和合适的后台任务机制;任务持久化也只能恢复记录,不能替代系统调度。
这些问题共同决定了它能否从一个窗口演示变成可靠功能。窗口展示、业务状态、后台提醒有各自的职责,把边界分清,用户才不会在关键时刻得到意外的行为。
六、让任务保持可见,也要让用户能轻松离开
回到厨房里,需要的其实是随时知道哪一锅更急。小窗只承担这个小而持续的需求,其余时候让用户继续查菜谱、回消息,或者放下手机。
闪控窗的价值因此与“显示更多东西”没有必然关系。值得留下来的,是用户正在关心的状态和少量即时操作。任务在多个入口之间保持一致、关闭与返回符合预期,才会让这块小窗口成为帮助。
配套工程与资料
GitCode:灶边闪控窗 Demo。README 包含工程导入、权限与调试签名、启动和三组操作步骤。厨房图片与视觉参考由 image2 生成,菜谱文字为示例自编。
更多推荐




所有评论(0)