ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HarmonyOS 7 精准碰一碰 SlideDrop 实战 04:投递状态 + 冲突恢复解决连续碰一碰异常场景【鸿蒙心迹】

HarmonyOS 7 精准碰一碰 SlideDrop 实战 04:投递状态 + 冲突恢复解决连续碰一碰异常场景【鸿蒙心迹】 前三篇我一直在做一件事先把 SlideDrop 的主链路搭起来再把触碰坐标、槽位映射、素材类型和插入规则一点点补完整。到第四篇我关注的重点明显变了。现在我更关心的不再是“能不能把素材投进去”而是“连续碰一碰会不会乱、目标位置已有内容时怎么收、用户取消或超时以后页面怎么反馈、失败后到底有没有恢复能力”。如果前三篇解决的是“精准落位”那第四篇解决的就是“异常情况下还能不能稳住”。这也是 Demo 从“看起来能跑”走向“有工程味道”的分水岭。一、第四篇为什么一定要讲状态、冲突和恢复我做这套 SlideDrop 五连载时一开始心里其实有个担心如果一直沿着“投进去就算成功”的思路写前两篇、第三篇还会有新鲜感到了第四篇很容易开始飘。读者会看到很多效果图但会逐渐怀疑一件事这东西是不是只适合做演示不适合认真讨论工程实现。而真正决定一个跨设备协作 Demo 成色的往往不是那一瞬间的“成功动效”而是下面这些更难受的问题用户点了发送以后系统当前到底处于哪个阶段目标设备已经识别了但用户迟迟没有碰一下会不会一直挂着对方页面的目标槽位已经有内容了系统怎么决定是替换、取消还是稍后重试连续两次甚至三次碰一碰时前一个会话没结束后一个任务是不是会把状态冲掉当用户离开页面、切到后台、或者目标端一度失联时当前任务能不能留下可诊断的痕迹。这些问题有个共同点它们都不是“主链路 happy path”的问题但它们决定了读者会不会把这套 Demo 当成一个真正能继续往下迭代的工程项目。所以第四篇我给自己定下来的目标很明确用一套清晰的状态定义把投递过程说清楚用状态中心页面把这些状态对用户讲清楚用冲突恢复模块把“目标位置已有内容”这类边界情况收起来用重试队列让失败不只是“结束”而是“还有下一步”让代码、日志、页面和配图说的是同一件事。二、第四篇我真正要补的是“一条完整的状态链”如果把前三篇看成“建主桥”那第四篇更像是在给桥两边加护栏、加路标、加应急车道。它不一定最炫但它会让这套 Demo 突然稳很多。我最后把第四篇的主线收成一句话不是加一个新页面而是补齐一条完整的状态链。这条链条至少要覆盖这些阶段IDLE空闲尚未发起投递PREPARING准备中校验文件、创建会话、装配上下文WAITING_TOUCH等待碰一碰说明目标设备已就绪但还未真正开始传输TRANSFERRING投递中正在发送文件或等待目标落位SUCCESS成功目标端已经完成接收与放置CONFLICT冲突目标槽位已有内容、存在同名资源或当前会话被阻塞CANCELLED取消用户主动终止TIMEOUT超时等待碰一碰或传输过程超过设定时限FAILED失败最终没有恢复成功。这些状态看起来不少但它们并不是“为了专业而专业”。相反它们是为了避免后面每一层代码都在各自发明一套状态词。页面说“准备中”日志写“ready”会话管理器又写“pending”读者一看就会乱。工程真正麻烦的地方从来不是你状态少而是你状态词不统一。所以第四篇一上来我没有先做 UI也没有先画流程图而是先把状态语言统一下来。后面的状态中心、冲突恢复、重试队列、日志打印全都围绕这个前提往下走。三、目录结构必须再往前走一步不然后面一定写乱第四篇之前SlideDrop 的项目结构已经开始成形但还偏 Demo。也就是说它可以说明问题但还不够适合接纳越来越多的异常逻辑。尤其是当我要开始处理冲突恢复、会话取消、重试队列时如果还继续把逻辑堆在页面层后面几乎一定会难以维护。所以这篇我先整理了一次目录SlideDropDemo ├─ entry │ ├─ src/main/ets │ │ ├─ pages │ │ │ ├─ Index.ets │ │ │ ├─ SlideDropPage.ets │ │ │ └─ StatusCenterPage.ets │ │ ├─ components │ │ │ ├─ ReplaceDialog.ets │ │ │ ├─ TransferProgressCard.ets │ │ │ ├─ RecentResultCard.ets │ │ │ └─ StateStepList.ets │ │ ├─ manager │ │ │ ├─ TransferStateMachine.ets │ │ │ ├─ ConflictRecoveryManager.ets │ │ │ ├─ RetryQueue.ets │ │ │ └─ TransferSessionManager.ets │ │ ├─ model │ │ │ ├─ TransferMission.ets │ │ │ ├─ TransferState.ets │ │ │ ├─ SlotType.ets │ │ │ └─ RecentResult.ets │ │ ├─ store │ │ │ └─ TransferStore.ets │ │ ├─ common │ │ │ ├─ Logger.ets │ │ │ └─ TimeFormatter.ets │ │ └─ utils │ │ ├─ FileValidator.ets │ │ └─ DelayTask.ets │ └─ resources └─ module.json5我为什么特别愿意在文章里把目录写出来因为它其实代表了我对第四篇边界的判断TransferStateMachine只管状态流转ConflictRecoveryManager只管冲突分支怎么走RetryQueue只管待重试任务TransferStore只管给页面喂数据页面层只负责表达不去吞掉所有业务逻辑。到这里SlideDrop 就不再只是“一个页面把所有事做完”的 Demo 了而开始像一个能讲工程结构的项目。四、先把状态机立住因为 UI、日志、恢复逻辑都要依附在它上面很多人写 Demo 喜欢先做页面这很正常因为页面有即时反馈。但我自己写第四篇的时候非常明确状态机必须先出来。原因很简单。没有状态机的时候页面层和日志层往往会各自长出一套规则页面可能用isLoading、hasConflict、progress来判断日志可能打印start、retry、done会话管理器又可能内部有一套pending / active / closed。这些词一多很快就会出现“代码能跑但解释不清”的情况。文章一旦要讲清楚过程就会非常吃力。所以第四篇我直接用enum 状态机的方式把语言收紧。文件位置entry/src/main/ets/manager/TransferStateMachine.ets用途统一管理投递状态流转驱动 UI 更新、日志输出和异常入口。export enum TransferState { IDLE IDLE, PREPARING PREPARING, WAITING_TOUCH WAITING_TOUCH, TRANSFERRING TRANSFERRING, SUCCESS SUCCESS, CONFLICT CONFLICT, CANCELLED CANCELLED, TIMEOUT TIMEOUT, FAILED FAILED } export class TransferStateMachine { private state: TransferState TransferState.IDLE private listeners: Array(from: TransferState, to: TransferState, msg?: string) void [] private timeoutTask: number | null null private sessionId: string beginTransfer(sessionId: string, fileName: string, slot: string): void { this.sessionId sessionId this.updateState(TransferState.PREPARING, file${fileName}, slot${slot}) setTimeout(() { this.updateState(TransferState.WAITING_TOUCH, 等待目标设备靠近) this.startTimeoutTimer() }, 280) } onTouchConfirmed(deviceName: string): void { this.updateState(TransferState.TRANSFERRING, target${deviceName}) } onConflict(slot: string): void { this.clearTimeoutTimer() this.updateState(TransferState.CONFLICT, slot${slot}) } onSuccess(resultPath: string): void { this.clearTimeoutTimer() this.updateState(TransferState.SUCCESS, result${resultPath}) } onCancelled(reason: string): void { this.clearTimeoutTimer() this.updateState(TransferState.CANCELLED, reason) } onTimeout(): void { this.updateState(TransferState.TIMEOUT, touch-or-transfer-timeout) } onFailed(message: string): void { this.clearTimeoutTimer() this.updateState(TransferState.FAILED, message) } subscribe(listener: (from: TransferState, to: TransferState, msg?: string) void) { this.listeners.push(listener) } private updateState(to: TransferState, msg?: string): void { const from this.state this.state to Logger.info([State] ${from} - ${to}${msg ? | msg : }) this.listeners.forEach(listener listener(from, to, msg)) } private startTimeoutTimer() { this.timeoutTask setTimeout(() this.onTimeout(), 12000) as unknown as number } private clearTimeoutTimer() { if (this.timeoutTask) { clearTimeout(this.timeoutTask) this.timeoutTask null } } }这段代码真正解决的问题不只是“状态能跑起来”而是它给整个项目建立了一条骨架页面知道自己应该展示什么日志知道自己应该怎么记录冲突恢复知道自己从哪个状态进入重试队列知道自己应该把任务送回哪个阶段。第四篇的很多东西看起来像在写页面实际上底层全是靠这个状态机在撑着。五、页面层要变轻第四篇新增状态中心但它只负责“表达”状态机出来以后页面层就该瘦身了。不然你很容易陷入一种老问题为了做一个状态页又在页面里堆回一堆业务判断。所以我这篇的原则很简单页面只负责表达不直接包办业务。状态中心页主要做四件事展示当前投递任务展示当前状态步骤在发生冲突时给出替换 / 取消 / 重试入口展示最近一次或最近几次结果。这些信息都不应该在页面层临时拼出来而是来自TransferStore和状态机订阅结果。文件位置entry/src/main/ets/pages/StatusCenterPage.ets用途监听状态变更把投递状态、冲突恢复和最近结果展示给用户。Entry Component struct StatusCenterPage { State private currentState: TransferState TransferState.IDLE State private progressValue: number 0 State private taskTitle: string 第 4 章 产品设计方案 State private fileName: string SlideDrop 演示文稿.pptx private store: TransferStore AppStorage.getTransferStore(transferStore)! private machine: TransferStateMachine AppStorage.getTransferStateMachine(stateMachine)! aboutToAppear() { this.machine.subscribe((from, to, msg) { this.currentState to this.store.appendStateLog({ from, to, msg, time: Date.now() }) if (to TransferState.PREPARING) { this.progressValue 12 } else if (to TransferState.WAITING_TOUCH) { this.progressValue 24 } else if (to TransferState.TRANSFERRING) { this.progressValue 68 } else if (to TransferState.SUCCESS) { this.progressValue 100 this.store.pushRecentResult({ title: this.taskTitle, state: success, fileName: this.fileName, timeText: 今天 10:24, extraText: 耗时 12.3 秒 }) } else if (to TransferState.CANCELLED) { this.store.pushRecentResult({ title: 第 3 章 市场分析, state: cancelled, fileName: 分析补充稿.pptx, timeText: 今天 10:18, extraText: 用户已取消 }) } }) } }这个结构看着不复杂但我觉得它很重要因为从这一刻开始页面终于不再是“什么都能做什么都写一点”的状态而是明确变成了“拿结果去表达”。六、真正难的不是弹一个替换框而是把冲突之后的每一步收清楚到第四篇最像真实工程的一层我觉得不是状态机而是冲突恢复。因为“目标位置已经有内容”这件事表面上只是一个弹窗实际上它后面会连出很多分支用户确认替换系统是不是要先备份旧内容用户取消当前会话是不是立刻结束用户暂时不处理这个任务能不能进入重试队列设备恢复以后之前的任务要不要自动恢复同一个槽位连续收到两个新任务时后来的任务是不是需要排队。如果这些逻辑全写在页面里那页面一定会变得很重而且代码会越来越难读。所以我把它们统一收进ConflictRecoveryManager。文件位置entry/src/main/ets/manager/ConflictRecoveryManager.ets用途处理冲突时的替换确认、取消结束、加入重试队列和超时恢复。export class ConflictRecoveryManager { constructor(private retryQueue: RetryQueue) {} async handleConflict(transfer: TransferMission, slot: SlotType): Promiseboolean { Logger.info([Conflict] slot${slot} occupiedtrue, id${transfer.id}) const confirmed await this.confirmReplace(transfer, slot) if (confirmed) { Logger.info([Dialog] user confirms replace, id${transfer.id}) await this.backupOldResource(slot) await this.replaceCurrentContent(transfer, slot) this.enqueueRetry(transfer, replace-confirmed, 600) return true } Logger.info([Dialog] user cancels replace, id${transfer.id}) this.cancelTransfer(transfer.id, user_cancel) return false } enqueueRetry(transfer: TransferMission, reason: string, delay: number 2000): void { Logger.info([Retry] enqueue mission id${transfer.id}, reason${reason}) this.retryQueue.enqueue(transfer, delay, reason) } cancelTransfer(id: string, reason: string): void { Logger.info([State] CANCELLED id${id}, reason${reason}) this.retryQueue.cancel(id) } handleTimeout(id: string): void { Logger.warn([Retry] timeout id${id}) this.retryQueue.remove(id) } private async confirmReplace(transfer: TransferMission, slot: SlotType): Promiseboolean { return await promptAction.showDialog({ title: 该位置已存在内容, message: 位置 ${slot} 已有内容是否替换为 ${transfer.fileName}, confirmText: 替换, cancelText: 取消 }) } private async backupOldResource(slot: SlotType): Promisevoid { Logger.info([Backup] backup old resource in slot${slot}) } private async replaceCurrentContent(transfer: TransferMission, slot: SlotType): Promisevoid { Logger.info([Replace] apply new resource ${transfer.fileName} - slot${slot}) } }这段代码写完以后我最大的感受不是“冲突问题解决了”而是冲突终于从一个 UI 提示变成了一个完整过程。它有前因、有分支、有结论最重要的是——它可追踪。你在日志里能看到它在页面上能看到它在配图里也能展示它。这种“可追踪感”我觉得就是第四篇最值钱的地方。七、重试队列不是炫技而是让失败拥有第二次机会我见过很多 Demo一遇到失败就结束。它们通常会给一个很轻的提示网络异常、设备不可用、请稍后重试。看起来没毛病但如果这个 Demo 是跨设备协作场景这种处理就太薄了。因为这类失败很多时候不是“根本不成立”而只是“当前条件不满足”。比如对端刚好切页面槽位还没释放用户碰完又挪开了设备目标端一度被中断但很快就恢复用户连续发起两次任务后一个其实完全可以稍后继续。这些情况都不适合“一刀切直接失败”。所以第四篇里我专门加了RetryQueue。这套队列我没有往复杂设计走而是有意保持小而清楚收下待重试任务记录失败原因记录已重试次数在合适的时机重新发起超过上限后再真正判定结束。文件位置entry/src/main/ets/manager/RetryQueue.ets用途保存待重试任务并在恢复时重新执行。interface RetryMission { transfer: TransferMission delay: number reason: string retryCount: number createdAt: number } export class RetryQueue { private queue: RetryMission[] [] private maxRetryCount: number 3 enqueue(transfer: TransferMission, delay: number, reason: string) { this.queue.push({ transfer, delay, reason, retryCount: 0, createdAt: Date.now() }) } async flush(handler: (mission: RetryMission) Promiseboolean) { for (const mission of this.queue) { mission.retryCount 1 await DelayTask.sleep(mission.delay) const success await handler(mission) if (success) { Logger.info([Retry] success id${mission.transfer.id}, cost${mission.retryCount}) this.remove(mission.transfer.id) } else if (mission.retryCount this.maxRetryCount) { Logger.warn([Retry] give up id${mission.transfer.id}) this.remove(mission.transfer.id) } } } cancel(id: string) { this.queue this.queue.filter(item item.transfer.id ! id) } remove(id: string) { this.queue this.queue.filter(item item.transfer.id ! id) } getPendingList(): RetryMission[] { return [...this.queue] } }你会发现这里并没有什么“高并发调度算法”但它已经足够说明第四篇想表达的核心失败以后SlideDrop 不再只是“退出”而是开始学会“恢复”。八、状态中心页面不是装饰它其实承担了“把内部过程翻译成人话”的责任写到这里第四篇其实已经有了完整的底层逻辑状态机、冲突恢复、重试队列、日志出口。但如果这些都只留在代码里读者和用户依然会觉得它“黑盒”。所以我才坚持把“状态中心”单独做出来。它不是为了凑一个页面数量而是为了做一件很实际的事把工程内部过程翻译成人能理解的界面。这件事说起来很轻实际上很难。因为你不能把所有底层术语原样扔给用户。比如WAITING_TOUCH在界面上就应该写成“等待碰一碰”CONFLICT不能只显示“冲突”而是要告诉用户“该位置已有内容”RETRY ENQUEUED这种内部词不该直接出现而应该翻译成“已加入重试队列”CANCELLED也要区分到底是“用户取消”还是“目标设备超时导致终止”。这一步其实很像技术写作本身把工程里的专业状态翻译成外界能理解的语言。SlideDrop 第四篇我觉得最有意思的地方就在这里——代码和文章做的是同一件事。九、类型二这张综合讲解图第四篇我最想强调的是“异常也有秩序”前面几篇你已经让我固定了两种视觉语言一个是简约封面一个是类型二综合讲解图。我现在越来越认同这个组合因为它很适合技术文章。尤其是第四篇类型二这张图的价值比前面更大。因为第四篇讲的不是单点能力而是一段比较复杂的链路。单独一张 DevEco 截图说明不了全貌单独一张手机图也不够所以我特别希望类型二图里同时出现这几类信息DevEco 里的状态机或恢复代码手机端的状态中心界面中间的状态流转图下方或侧边的实机碰一碰效果。当这些元素放在一起的时候读者会非常直观地感受到这篇文章不是只在讲 UI也不是只在讲某个 API它讲的是一条完整的工程链路以及这条链路在异常场景下如何继续成立。所以第四篇的类型二图我最看重的不是视觉炫技而是它能不能把“异常也有秩序”这件事讲清楚。十、第四篇我怎么验收看的是“失败以后还能不能回来”前三篇的验收更偏功能证明而第四篇的验收明显更偏工程稳定性。我这次基本按下面几条来验收。1正常状态链是否完整我会先走一条正常投递链路确保至少能覆盖PREPARINGWAITING_TOUCHTRANSFERRINGSUCCESS而且不仅状态要走到页面上的步骤、日志里的打印也要跟着同步变化。2冲突检测是否准确我会故意找一个已有内容的目标槽位再次发起投递看系统能不能稳定触发冲突状态而不是直接覆盖过去也不是静默失败。3替换确认是否闭环冲突出现以后用户确认替换时日志里应该能看到冲突、确认、替换、重试、成功这一整串过程用户取消时则应该能明确落在CANCELLED而不是停在一个不清不楚的中间态。4重试是否真的有效我会人工制造一次短暂中断比如延迟目标端响应再看任务是否被加入重试队列恢复以后是否能继续往前推进而不是每次都要重新从头来一遍。5连续碰一碰是否会互相污染这是第四篇非常关键的一点。我会连续发起多次投递观察新任务是否覆盖旧任务状态已结束任务是否会污染最近结果区重试中的任务是否会和新任务抢页面状态。如果这些地方不稳第四篇就还不算完成。6用户能不能看懂虽然这套 Demo 本质上还是一个演示项目但第四篇之后我已经开始用“用户能不能一眼看明白”来做验收了。用户至少要知道现在是不是还在投递为什么发生冲突应该替换、取消还是再试一次最近一次结果到底是什么。如果这些都能说清楚我就认为第四篇是真的把异常路径纳进来了。十一、这篇写完以后我对第五篇反而更有底了有意思的是第四篇虽然写的是“异常”和“恢复”但它写完以后我反而觉得第五篇会轻松很多。原因很简单。之前你会一直担心会话会不会收不住页面会不会越来越重日志会不会越来越散一旦出错Demo 会不会没法继续演示。而第四篇其实已经把这些问题拆得差不多了。它做的不是最后一步工程收口但它把工程收口最难啃的前提铺好了。到第五篇时我就能更从容地去做下面这些事生命周期和页面间状态同步资源释放与清理图片、文件和会话缓存收尾可复用 Demo 目录模板最终演示验收与文章收官。换句话说第四篇的作用不是“给第四篇自己加分”而是把整个五连载往最终成型的方向推了一大步。十二、本文小记如果让我用一句话总结第四篇我会说第四篇最重要的价值不是多了一个状态中心页面而是 SlideDrop 第一次认真面对“失败以后怎么办”。前三篇解决的是怎么把东西精准投进去。第四篇开始解决的是投递过程怎么被定义冲突出现以后怎么恢复用户取消、等待超时、重试成功这些状态怎么被表达代码、日志、页面和配图怎么形成一个统一的叙事。这一步没有第三篇那种“规则命中了”的爽感但它更像真实工程。因为真正的项目从来不只是看成功路径而是看你在异常路径上还能不能站得住。也正因为如此我觉得第四篇可能会是这个五连载里最“有工程味”的一篇。它没有试图炫很多新能力它只是认真把一件很关键的事做好了让 Demo 在异常场景下也能讲清楚自己。十三、这篇里我刻意保留的几个“工程味”细节为了让第四篇不只是停留在概念层我在实现和写作上都刻意保留了几个细节。它们看起来不大但我觉得很能代表这篇文章的气质。1状态词尽量稳定不频繁换说法文章里写等待碰一碰代码里就让它落到WAITING_TOUCH文章里写投递中页面里就保持同样语义。这样做的好处是读者在看配图、看代码、看解释时不会不断做“翻译工作”。技术文章最怕的就是同一件事换了三种名字读起来会很累。2日志一定保留“前后状态”而不是只打结果我这篇特别在意日志是否能看出“从哪到哪”。比如IDLE - PREPARING、WAITING_TOUCH - TRANSFERRING、CONFLICT - SUCCESS。因为只看单点结果后面很难定位问题看状态流转才更接近真实排查思路。这也是为什么配图里我总想把日志区保留下来。3冲突恢复不直接做成大红色危险按钮很多教程图一碰到冲突就喜欢做得很吓人但实际产品里并不是每次冲突都意味着严重错误。它更像是一个需要用户确认的分支。所以我在这篇里更偏向“克制表达”提醒用户当前槽位已有内容同时给出替换、取消、重试三个明确动作。这样更像一个能落地的交互而不是单纯追求视觉戏剧性。4实机图里保留了轻微办公环境感这一点其实是为了让文章更像“真实开发记录”。如果所有图都过于干净反而像概念设计稿。我保留了桌面、电脑、手机同框少量红色标注只用来解释关键点而不是每张图都满屏箭头。这样一来文章整体会更像开发过程复盘而不是宣传海报。5代码不追求写满而是尽量只放能解释问题的那一段第四篇里状态机、冲突恢复、重试队列这三段代码就是这个思路。它们不一定覆盖项目全部实现但都刚好能解释“为什么要这样拆”“异常是怎么被收住的”“页面为什么能跟着变”。我越来越觉得技术文章里的代码不一定越多越好关键是要让每一段都承担清晰职责。十四、写在最后第四篇不是炫功能而是在替第五篇铺路如果前三篇让我更有“做出东西”的成就感那第四篇给我的感觉更像“把东西做稳”。它不一定最热闹但它会让整个 SlideDrop 系列一下子站住。因为从这一篇开始我终于可以比较有底气地说这不是一个只能顺着演示路径跑一遍的 Demo 了。它开始有状态、有恢复、有日志、有重试也开始有一点真实工程面对异常时的克制和秩序感。而第五篇能不能把整个系列顺利收口其实很大程度上就取决于第四篇有没有把这些地基打稳。现在我反而挺期待第五篇的因为状态、冲突、恢复这些最容易发散的部分已经在这一篇里被规整得差不多了。剩下的就是把这套 Demo 真的收成一个可以复用、可以展示、也可以写进完整项目复盘的作品。
返回列表