【共创季稿事节】跨屏演讲台实战:一个 HarmonyOS6.1 跨设备协同应用的完整开发复盘 【共创季稿事节】跨屏演讲台实战一个 HarmonyOS 6.1跨设备协同应用的完整开发复盘一、缘起一个演讲者的真实困境先交代这个项目为什么会存在。做技术分享的人多少都体会过这种尴尬上台演讲时笔记本电脑连着投影稳稳地待在讲台上。而你想走到台中央、离观众近一点却发现自己被一根 HDMI 线拴住了——想翻页得回讲台想看备注得低头盯屏幕想控制时间得偷瞄电脑角落的时钟。人和内容被物理距离割裂了。市面上有专门的翻页笔但它只能翻页看不到备注、控不了时间。而我手里明明有一台鸿蒙手机——它算力充足、屏幕不小为什么不能让它变成我的第二块屏把提词、遥控、计时都接管过来这个想法背后正是 HarmonyOS 一直强调的能力跨设备协同。让手机、平板、PC 这些设备不再各自为战而是围绕同一个任务分工协作。于是我决定动手做一个跨屏演讲台CastDeckPC 作为面向观众的演示大屏手机作为演讲者手中的遥控器兼提词器。这篇文章就是这个应用从零到可运行的完整工程复盘——包括我一开始的架构设想、中途撞上的现实问题以及最终的妥协与取舍。二、需求拆解把协同翻译成技术语言在写第一行代码之前我先把模糊的协同拆成可实现的技术需求。一场演讲的核心状态其实很简单可以抽象成几个字段当前放到第几页currentIndex是否正在计时running计时从何时开始startedAt每一页的内容标题、要点、提词稿、计划用时只要这几个状态能在多个设备间共享且实时同步协同就成立了手机改了 currentIndexPC 大屏就跟着翻页手机点了开始计时两端的计时都动起来。于是需求就清晰了一份可被多端共享的演讲状态数据流转任一端修改其他端实时感知反向控制、任务接续同一套代码在手机竖屏和 PC 大屏上都能良好呈现多端适配演讲内容可编辑、可持久化让它像个真产品而不是写死的 Demo。这四条正好覆盖了跨设备协同的几个关键维度。三、架构设计状态驱动 分层解耦基于上面的拆解我设计了如下分层架构核心设计原则有两个第一状态驱动 UI。所有页面不直接持有数据而是通过AppStorage订阅全局状态。控制器修改状态后统一publish页面用StoragePropWatch响应刷新。这样无论状态是被本地操作改变、还是被远端同步改变UI 的刷新逻辑完全一致——这为后面接入跨设备同步埋下了伏笔。第二同步层可插拔。我把状态怎么在设备间流转这件事单独抽成一层上层业务不关心底层是分布式数据库还是本地存储。这个决策后来救了我一命下文详述。四、数据层让讲稿可编辑、可持久化先看最基础的数据模型。一张幻灯片定义为exportinterfaceSlide{id:string;// 唯一 idtitle:string;// 标题bullets:string[];// 要点script:string;// 提词稿minutes:number;// 计划用时分钟}持久化用鸿蒙的Preferences轻量键值存储封装成DeckStore提供增删改、排序、恢复示例等能力exportclassDeckStore{privatestaticstore:preferences.Preferences|nullnull;staticasyncinit(context:common.Context):Promisevoid{if(DeckStore.storenull){DeckStore.storeawaitpreferences.getPreferences(context,castdeck_store);}}staticasynclist():PromiseSlide[]{if(DeckStore.storenull){returndemoSlides();}constrawawaitDeckStore.store.get(slides,)asstring;if(raw){constseeddemoSlides();// 首次运行写入示例讲稿awaitDeckStore.saveAll(seed);returnseed;}returnJSON.parse(raw)asSlide[];}staticasyncsaveAll(slides:Slide[]):Promisevoid{if(DeckStore.store!null){awaitDeckStore.store.put(slides,JSON.stringify(slides));awaitDeckStore.store.flush();}}// add / update / remove / move / resetToDemo ...}这里有个小细节值得一提首次运行时自动写入一套示例讲稿。这样用户打开应用就有内容可看、可演示而不是面对一个空列表不知所措。这是让 Demo 像产品的一个廉价但有效的手段。五、运行态控制器连接数据与 UIDeckStore管的是持久化的内容而当前放到第几页、有没有在计时这类运行态不需要持久化我放在DeckController里维护并负责把状态推送到AppStorageexportclassDeckController{privatestaticslides:Slide[][];privatestaticcurIndex:number0;privatestaticrunning:booleanfalse;privatestaticstartedAt:number0;staticasyncinit(context:common.Context):Promisevoid{awaitDeckStore.init(context);awaitDeckController.reload();}privatestaticpublish():void{AppStorage.setOrCreate(curIndex,DeckController.curIndex);AppStorage.setOrCreate(totalSlides,DeckController.slides.length);AppStorage.setOrCreate(running,DeckController.running);AppStorage.setOrCreate(startedAt,DeckController.startedAt);AppStorage.setOrCreate(stateTick,Date.now());// 变化信号}staticnext():void{if(DeckController.curIndexDeckController.slides.length-1){DeckController.curIndex;DeckController.publish();}}// prev / start / pause / reset / gotoIndex ...}注意那个stateTick——它是一个每次变化都会更新的时间戳作为状态变了的信号。页面监听它就能在任何一次状态变更后统一刷新而不用为翻页“计时”重置分别写监听。这是一个非常实用的响应式技巧。六、UI 层四块屏各司其职应用采用底部三 Tab外加一个全屏大屏演示模式6.1 演讲台主页这是演讲者的控制中心一屏集成幻灯片预览、演讲计时、遥控翻页、提词稿。其中一个我很满意的细节是超时提醒——为每页设定了计划用时一旦当前页讲超时计时器就变红tick():void{this.clockelapsedText(this.running,this.startedAt,Date.now());if(this.runningthis.startedAt0this.slide!null){constused(Date.now()-this.startedAt)/1000;this.overtimeusedthis.slide.minutes*60;// 超时标红}}演讲最怕失控超时这个红色提醒是个很轻但很有用的设计。6.2 讲稿编辑管理幻灯片列表支持点击编辑、左滑删除、上移下移排序、底部弹层新增。编辑弹层里要点用多行文本每行一条的方式输入保存时切分constbulletsthis.eBullets.split(\n).filter((x:string)x.trim()!);改完立即DeckStore持久化并DeckController.reload()回到演讲台就能看到最新内容。6.3 我的设置演讲统计、提词字号滑块演讲时按与屏幕距离调字号很实用、跨设备协同能力说明、关于。字号用StorageLink双向绑定滑动即时反映到演讲台的提词稿。6.4 全屏大屏演示点演讲台的 大屏演示进入。这是面向观众的纯净画面超大字标题要点进度条右上角小字计时供演讲者瞄。支持点击左右两侧翻页、PC 键盘 ←/→ 翻页、退出返回。七、跨设备同步理想、现实与妥协这是整个项目最曲折、也最有价值的部分我原原本本记下来。7.1 理想方案分布式键值库我最初的方案是用鸿蒙的distributedKVStore分布式键值数据库作为多端共享状态的载体。思路很直接同一华为账号、同一组网内的设备打开同一个 storeId 的库开启autoSync任一端写入自动同步到其他端并通过on(dataChange)回调通知 UI。this.kvStoreawaitthis.kvManager.getKVStore(STORE_ID,{createIfMissing:true,autoSync:true,kvStoreType:distributedKVStore.KVStoreType.SINGLE_VERSION,securityLevel:distributedKVStore.SecurityLevel.S1});this.kvStore.on(dataChange,SubscribeType.SUBSCRIBE_TYPE_ALL,(data){// 解析出 DeckState → 通知 UI 刷新});架构上很优雅上层DeckController完全不知道底层是分布式的只管调SyncBus.put(state)。7.2 现实的耳光真机 模拟器组不成可信组网写完信心满满地跑起来结果——两端不同步。日志显示分布式库明明distributed KV store ready了PC 端 UI 也显示连接但 PC 翻到第 5 页另一端还停在第 1 页。排查后才明白分布式数据同步的前提是多台设备登录同一华为账号、并处于可信互联的组网中。而我手上只有一台鸿蒙 PC 真机 一个 IDE 模拟器——模拟器几乎无法和真机组成可信组网。两端各自打开了本地的同名库autoSync找不到对端于是各写各的互不影响。这不是代码 bug是环境的硬约束。改autoSync参数、手动指定 deviceId 都救不了这个设备组合。7.3 妥协一本地降级兜底好在架构埋的伏笔起了作用。因为同步层是可插拔的我给SyncBus.init加了降级分布式不可用时自动退回AppStorage本地存储。上层业务代码一行都不用改staticasyncput(state:DeckState):Promisevoid{constjsonJSON.stringify(state);if(this.distributedReadythis.kvStore!null){try{awaitthis.kvStore.put(KEY_STATE,json);return;}catch(e){/* 落到本地 */}}AppStorage.setOrCreate(deckStateJson,json);// 本地兜底this.notify(state);}这保证了单机也能完整运行。但它解决不了两台设备真正联动——本地兜底不跨设备。7.4 妥协二重新定位产品形态撞了几次墙后我做了一个关键决策不再把跨真机实时同步作为核心卖点而是把应用重新定位为一个单机自洽、体验完整的演讲辅助工具——三页面 全屏大屏演示模式在一台 PC 上就能讲透。跨设备协同的分布式接口保留在代码里作为进阶能力并在设置页诚实说明完整跨设备联动依赖可信组网环境。这个决策让产品在我的设备条件下真正可用、可演示而不是停在一个跑不通的半成品上。工程实践里承认约束、及时调整定位比死磕一个环境跑不通的理想方案更重要。八、其他踩坑记录坑一大屏退出按钮点不动全屏大屏演示页里我用一个透明的Row铺满全屏做左右两侧点击翻页的热区。结果发现左上角的退出按钮点了没反应。原因是Stack 中后声明的组件在上层——透明翻页热区盖在了退出按钮之上点击被它拦截了。解决办法是调整层级把顶栏退出计时放到 Stack 的最上层Stack { Column { ...内容... } // 底层 Row { ...翻页热区... } // 中层透明 Row { 退出按钮 计时 } // 顶层确保可点 }层级顺序是 ArkUI Stack 里一个容易忽略却很致命的细节。坑二大屏文字对比度不足大屏第一版要点文字用了主题里的textSub灰蓝色 #95A2C4。在手机上还行但一到大屏/投屏深色背景上的灰字几乎看不清。大屏是给观众看的必须高对比——把要点改成近白#EEF2FF并加粗、标题纯白问题解决。大屏 UI 和手机 UI 的对比度标准完全不同不能照搬。坑三构建环境的 NODE_OPTIONS 污染某次构建突然报ERR_WORKER_INVALID_EXEC_ARGV: --report-on-fatalerror is not allowed in NODE_OPTIONS连 DevEco Studio 内部 Sync 都挂了。定位到是NODE_OPTIONS环境变量被外部工具污染而 DevEco 作为 GUI 进程继承了启动时的异常环境。彻底退出、清变量、重启即可。与代码无关但排查耗时记录备忘。九、复盘小结回过头看这个项目给我的最大收获不是某个 API 的用法而是几点工程判断好的架构分层能救命。同步层可插拔的设计让我在分布式跑不通时仅改一层就完成本地降级UI 和业务零改动。要区分代码问题和环境约束。分布式不同步不是我代码错了而是设备组合达不到可信组网的前提。分清这两者才不会在错误的方向上死磕。承认约束及时调整产品定位。与其交付一个跑不通的理想协同不如交付一个单机完整可用、并诚实标注进阶能力的产品。不同设备形态的 UI 标准不同。大屏和手机在对比度、字号、交互热区上的要求差异很大多端适配不只是布局弹性还包括视觉与交互的重新校准。CastDeck 现在是一个三页面、单机自洽、UI 精致、并预留了跨设备接口的完整应用。它没能在我的设备上演示两台真机实时联动但完整走通了从设想到落地的全过程——包括那些必要的妥协。而这些妥协本身恰恰是真实工程最有价值的部分。