素材说明:本文配套封面、DevEco Studio、模拟器、手机界面及 HiLog 全为人工制作的技术演示,未在真实 HarmonyOS SDK 下编译、真机执行或完成审核测试。任务与数值均为可复核的示例,不是外部服务的真实返回值。
一、排在第一位的任务,未必应该永远先走
一次处理离线报表导出队列时,我发现用户真正抱怨的并不是任务慢,而是“早提交的任务一直没有轮到”。给每种导出类型设置一个固定优先级当然很简单:紧急任务总是排在普通任务前面。但只要高优先级任务源源不断进来,一个最早排队的低优先级任务就可能永远没有执行机会。这不是数组排序写错,而是调度策略本身没有描述等待成本。
本篇采用FairExportDemo,页面为ExportQueuePage,演示任务FQ-3412。它只有五个离线导出请求,足够把规则讲清楚:当前时钟不是系统毫秒时间,而是应用产生的一次次逻辑轮次;在演示的tick=8时按每等待三轮增加一档的办法计算有效优先级,最高封顶五档。取消T-02后,队头应当变成T-05,状态记为NEXT_T05。这样设计的目的,就是让图、代码和可手算的数据可以相互校验。
如果直接修改任务本身的basePriority,下一轮会再拿修改后的数值老化,相当于等待成本被重复计入。我更倾向于保留提交时的基础权重和入队轮次,把有效优先级当作当下计算出来的派生值。这样排序失败也不会损坏原始任务,页面切走后重新进入,仍然能够解释为什么某个任务排到了前面。
二、先定字段含义,再谈 Array.sort 的结果
对于小批量离线任务,一个普通Array就能容纳待调度快照;Map可以维护按 ID 查找的业务索引。这里没有调用 HarmonyOS 特有的任务调度服务,采用的是 ArkTS 可表达的应用层策略,所以不能把它包装成系统自带“公平队列 SDK”。示例字段只保留id、base、arrivedTick、sequence与status。sequence是同一轮到达时仍然可以稳定排序的入队序号,不能借助当前数组下标代替。
这段代码解决基础权重被重复改写的问题:有效分数由当前轮次重算,老化只在显示或决策时发生;Math.max防止时钟倒退导致负等待,Math.min保证最高五档。业务需要先保证nowTick是单调推进的有效整数,外部时间戳不能随意混入。
interfaceExportJob{id:string;base:number;arrivedTick:number;sequence:number;status:'PENDING'|'CANCELLED';}functioneffectivePriority(job:ExportJob,nowTick:number):number{constwaited=Math.max(0,nowTick-job.arrivedTick);returnMath.min(5,job.base+Math.floor(waited/3));}constinitialJobs:ExportJob[]=[{id:'T-01',base:1,arrivedTick:0,sequence:1,status:'PENDING'},{id:'T-02',base:3,arrivedTick:4,sequence:2,status:'PENDING'},{id:'T-03',base:2,arrivedTick:3,sequence:3,status:'PENDING'},{id:'T-04',base:1,arrivedTick:1,sequence:4,status:'PENDING'},{id:'T-05',base:3,arrivedTick:5,sequence:5,status:'PENDING'}];逐个算就能得到结论。在第八轮,T-01是三档、T-02是四档、T-03是三档、T-04是三档、T-05是四档。还没有取消时,T-02和T-05同为四档,较早入队的T-02应先执行。用户主动撤销后,T-02不能再参与候选比较,此时T-05理应站到最前。这里不使用百分比进度来掩盖结果:五条原始任务里一条撤销,剩余四条待调度。
三、取消不是删除数组元素,更不是对正在执行的任务施法
我会把取消分成两种语义:等待中撤销,标记后从下一次候选快照里排除;运行中取消,则必须通知真实执行器并等待明确的终止结果。本文只覆盖第一种状态,避免写出一个“改成 CANCELLED 就能终止文件写入”的错误示例。离线队列要留下撤销记录,方便排查用户为什么看不到这份导出结果,而不是在内存里把它彻底抹掉。
这段代码解决撤销后的重排:先按 ID 制作不可变的新快照,然后只从PENDING中挑候选。比较器顺序是有效优先级降序、到达轮次升序、稳定序号升序;即便引擎的稳定排序行为不同,也有确定的最终比较次序。
functioncancelWaitingJob(jobs:ExportJob[],targetId:string):ExportJob[]{returnjobs.map((job:ExportJob):ExportJob=>{if(job.id!==targetId||job.status!=='PENDING')returnjob;return{id:job.id,base:job.base,arrivedTick:job.arrivedTick,sequence:job.sequence,status:'CANCELLED'};});}functiondispatchOrder(jobs:ExportJob[],nowTick:number):ExportJob[]{constcandidates=jobs.filter((job:ExportJob)=>job.status==='PENDING');returncandidates.sort((a:ExportJob,b:ExportJob):number=>{constbyScore=effectivePriority(b,nowTick)-effectivePriority(a,nowTick);if(byScore!==0)returnbyScore;if(a.arrivedTick!==b.arrivedTick)returna.arrivedTick-b.arrivedTick;returna.sequence-b.sequence;});}由于排序发生在filter产生的候选数组上,原始队列的审计顺序并没有被改写。如果后续加入“执行中”“失败待重试”状态,只需要在状态机层决定哪些状态可以参与选取,不能直接把所有非取消状态都视为待调度。还要警惕重试把arrivedTick清零:那会让曾经等待很久的任务被当成新任务。应由产品明确重试继承原等待成本还是进入新的服务等级,再决定规则。
四、为什么我不在每次渲染时直接重排原数组
ArkUI 页面的状态变更会触发显示更新,但渲染时如果顺手给同一个数组做原地排序,就容易发生显示顺序、审计顺序和业务索引一起变化。这个 Demo 把dispatchOrder定义成纯函数:状态变化时输入确定的任务快照和tick=8,返回一个独立有序列表。显示层展示前四个等待任务,点击取消T-02之后重新计算队头,图片只展示被取消后的结果。
这段代码解决页面读取与显示分离:示例使用Map计算稳定 ID 索引,并给界面提供一个可重复计算的快照。它不会自动执行文件导出,也不会假称已经关联系统服务;任何耗时导出都应由真实队列执行器另行管理。
interfaceQueueView{nextId:string;pending:number;cancelled:number;state:string;}functionbuildQueueView(jobs:ExportJob[],nowTick:number):QueueView{constbyId=newMap<string,ExportJob>();jobs.forEach((job:ExportJob)=>byId.set(job.id,job));if(byId.size!==jobs.length){return{nextId:'',pending:0,cancelled:0,state:'DUPLICATE_ID'};}constordered=dispatchOrder(jobs,nowTick);constcancelled=jobs.filter((j:ExportJob)=>j.status==='CANCELLED').length;constnextId=ordered.length>0?ordered[0].id:'';return{nextId,pending:ordered.length,cancelled,state:nextId.length>0?`NEXT_${nextId.replace('-','')}`:'EMPTY'};}constafterCancel=cancelWaitingJob(initialJobs,'T-02');constview=buildQueueView(afterCancel,8);// T-05 / 4 / 1 / NEXT_T05示例Map的意义是身份一致性检查,而不是为了炫耀容器。真实项目中,收到两条相同任务 ID 时不能默默以后者覆盖前者。这里只在构建视图时识别重复并返回DUPLICATE_ID,而持久化、去重和入队确认仍是外围系统负责的事。页面被销毁时没有定时器或网络句柄需要释放;如果未来每秒自动推动逻辑时钟,应当在页面离开时停止前台定时器,并将调度器放到独立生命周期中。
配套 DevEco Studio 演示图中,项目目录是FairExport/entry/src/main/ets/pages/ExportQueuePage.ets,中间显示effectivePriority和dispatchOrder相关逻辑,右侧模拟器给出队列排序,底部 HiLog 的示例行是FQ-3412 tick=8 total=5 cancelled=1 pending=4 next=T-05 state=NEXT_T05。数字和代码可手算对应,图中不包含虚构的文件系统读写结果,也没有“导出成功”之类的断言。
五、所谓公平,是可解释的取舍,不是绝对数学保证
为什么老化阈值取三轮、分数封顶五档?因为它们让这组数据的排序变化容易看见,绝不表示这两个参数适用于全部离线队列。真实产品首先要回答什么叫“等待一轮”:一次调度决策、一个固定时间窗,还是一个执行完成事件。若任务耗时相差巨大,只按决策次数增加权重可能仍然不公平;若高优先级任务存在强时效承诺,盲目老化又可能让低优先级任务抢走关键资源。
纯手机演示图固定显示FQ-3412、tick=8、原始任务五条、取消一条、待调度四条。队头T-05的有效分数为四,后面的T-01、T-04、T-03都是三。一个容易忽略的视觉细节是:被取消的T-02不应在“等待队列”中继续占一个名次,但可以在底部审计提示中出现。这样使用者既不会误认为它还要执行,也能找到撤销痕迹。
六、验收时我会特意塞进三个坏数据
第一组坏数据是时钟倒退,例如入队轮次为九而当前轮次为八。上面的分数函数不会出现负等待,但实际调度层应进一步拒绝不合规时钟来源。第二组是重复任务 ID,示例直接进入DUPLICATE_ID,禁止向“可执行下一项”的流程放行。第三组是全部取消:队列排序应该返回空数组、下一任务 ID 是空字符串、状态为EMPTY;页面必须显示“暂无待调度任务”,而不是让旧卡片残留在列表顶部。
还有一个边界更像架构问题:两个页面都在调用“计算下一项”,不等于两边可以同时启动任务。当前例子只做确定性候选选择,没有并发领取协议。真的进入生产后,需要执行器具备原子领取、失败恢复、持久化账本和取消确认;否则一张正确的排序截图,也无法证明不会重复导出同一份文件。
最终这次练习并没有造出多么复杂的队列框架。它留下的是一条实用界线:基础优先级属于任务事实,老化分数属于某个时刻的派生判断,取消记录属于审计事实。把三者拆开以后,页面无需猜测任务的历史,也能按相同输入重复画出同一份结果。本文所有界面和日志均为设计演示,尚无目标 SDK 编译与真实导出吞吐测试。
七、官方文档与验证边界
ArkTS 官方简介:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-overview ,其中介绍 ArkTS 的基本类型与容器能力。状态驱动与组件渲染可进一步阅读 ArkUI 概述:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkui-overview 。本篇的老化打分、取消语义与排序规则为纯应用层代码,不涉及华为官方提供的导出调度 API。示例应在目标 DevEco Studio、目标 API 版本下重新编译,并针对真实业务补充持久化与并发验收。