news 2026/7/12 1:38:01

【共创季稿事节】跨屏演讲台实战:一个 HarmonyOS6.1 跨设备协同应用的完整开发复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【共创季稿事节】跨屏演讲台实战:一个 HarmonyOS6.1 跨设备协同应用的完整开发复盘

【共创季稿事节】跨屏演讲台实战:一个 HarmonyOS 6.1跨设备协同应用的完整开发复盘

一、缘起:一个演讲者的真实困境

先交代这个项目为什么会存在。

做技术分享的人多少都体会过这种尴尬:上台演讲时,笔记本电脑连着投影,稳稳地待在讲台上。而你想走到台中央、离观众近一点,却发现自己被一根 HDMI 线拴住了——想翻页得回讲台,想看备注得低头盯屏幕,想控制时间得偷瞄电脑角落的时钟。人和内容,被物理距离割裂了。

市面上有专门的翻页笔,但它只能翻页,看不到备注、控不了时间。而我手里明明有一台鸿蒙手机——它算力充足、屏幕不小,为什么不能让它变成我的"第二块屏",把提词、遥控、计时都接管过来?

这个想法背后,正是 HarmonyOS 一直强调的能力:跨设备协同。让手机、平板、PC 这些设备不再各自为战,而是围绕同一个任务分工协作。于是我决定动手做一个跨屏演讲台(CastDeck):PC 作为面向观众的演示大屏,手机作为演讲者手中的遥控器兼提词器。

这篇文章,就是这个应用从零到可运行的完整工程复盘——包括我一开始的架构设想、中途撞上的现实问题,以及最终的妥协与取舍。

二、需求拆解:把"协同"翻译成技术语言

在写第一行代码之前,我先把模糊的"协同"拆成可实现的技术需求。

一场演讲的核心状态其实很简单,可以抽象成几个字段:

  • 当前放到第几页(currentIndex)
  • 是否正在计时(running)
  • 计时从何时开始(startedAt)
  • 每一页的内容(标题、要点、提词稿、计划用时)

只要这几个状态能在多个设备间共享且实时同步,协同就成立了:手机改了 currentIndex,PC 大屏就跟着翻页;手机点了开始计时,两端的计时都动起来。

于是需求就清晰了:

  1. 一份可被多端共享的演讲状态(数据流转);
  2. 任一端修改,其他端实时感知(反向控制、任务接续);
  3. 同一套代码在手机竖屏和 PC 大屏上都能良好呈现(多端适配);
  4. 演讲内容可编辑、可持久化(让它像个真产品,而不是写死的 Demo)。

这四条,正好覆盖了跨设备协同的几个关键维度。

三、架构设计:状态驱动 + 分层解耦

基于上面的拆解,我设计了如下分层架构:

核心设计原则有两个:

第一,状态驱动 UI。所有页面不直接持有数据,而是通过AppStorage订阅全局状态。控制器修改状态后统一publish,页面用@StorageProp+@Watch响应刷新。这样无论状态是被本地操作改变、还是被远端同步改变,UI 的刷新逻辑完全一致——这为后面接入跨设备同步埋下了伏笔。

第二,同步层可插拔。我把"状态怎么在设备间流转"这件事单独抽成一层,上层业务不关心底层是分布式数据库还是本地存储。这个决策后来救了我一命,下文详述。

四、数据层:让讲稿可编辑、可持久化

先看最基础的数据模型。一张幻灯片定义为:

exportinterfaceSlide{id:string;// 唯一 idtitle:string;// 标题bullets:string[];// 要点script:string;// 提词稿minutes:number;// 计划用时(分钟)}

持久化用鸿蒙的Preferences(轻量键值存储),封装成DeckStore,提供增删改、排序、恢复示例等能力:

exportclassDeckStore{privatestaticstore:preferences.Preferences|null=null;staticasyncinit(context:common.Context):Promise<void>{if(DeckStore.store===null){DeckStore.store=awaitpreferences.getPreferences(context,'castdeck_store');}}staticasynclist():Promise<Slide[]>{if(DeckStore.store===null){returndemoSlides();}constraw=awaitDeckStore.store.get('slides','')asstring;if(raw===''){constseed=demoSlides();// 首次运行写入示例讲稿awaitDeckStore.saveAll(seed);returnseed;}returnJSON.parse(raw)asSlide[];}staticasyncsaveAll(slides:Slide[]):Promise<void>{if(DeckStore.store!==null){awaitDeckStore.store.put('slides',JSON.stringify(slides));awaitDeckStore.store.flush();}}// add / update / remove / move / resetToDemo ...}

这里有个小细节值得一提:首次运行时自动写入一套示例讲稿。这样用户打开应用就有内容可看、可演示,而不是面对一个空列表不知所措。这是"让 Demo 像产品"的一个廉价但有效的手段。

五、运行态控制器:连接数据与 UI

DeckStore管的是"持久化的内容",而"当前放到第几页、有没有在计时"这类运行态不需要持久化,我放在DeckController里维护,并负责把状态推送到AppStorage

exportclassDeckController{privatestaticslides:Slide[]=[];privatestaticcurIndex:number=0;privatestaticrunning:boolean=false;privatestaticstartedAt:number=0;staticasyncinit(context:common.Context):Promise<void>{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.curIndex<DeckController.slides.length-1){DeckController.curIndex++;DeckController.publish();}}// prev / start / pause / reset / gotoIndex ...}

注意那个stateTick——它是一个每次变化都会更新的时间戳,作为"状态变了"的信号。页面监听它,就能在任何一次状态变更后统一刷新,而不用为"翻页"“计时”"重置"分别写监听。这是一个非常实用的响应式技巧。

六、UI 层:四块屏,各司其职

应用采用底部三 Tab,外加一个全屏大屏演示模式:

6.1 演讲台(主页)

这是演讲者的"控制中心",一屏集成:幻灯片预览、演讲计时、遥控翻页、提词稿。其中一个我很满意的细节是超时提醒——为每页设定了计划用时,一旦当前页讲超时,计时器就变红:

tick():void{this.clock=elapsedText(this.running,this.startedAt,Date.now());if(this.running&&this.startedAt>0&&this.slide!==null){constused=(Date.now()-this.startedAt)/1000;this.overtime=used>this.slide.minutes*60;// 超时标红}}

演讲最怕失控超时,这个红色提醒是个很轻但很有用的设计。

6.2 讲稿(编辑管理)

幻灯片列表,支持点击编辑、左滑删除、上移下移排序、底部弹层新增。编辑弹层里,要点用多行文本"每行一条"的方式输入,保存时切分:

constbullets=this.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.kvStore=awaitthis.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):Promise<void>{constjson=JSON.stringify(state);if(this.distributedReady&&this.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 的用法,而是几点工程判断:

  1. 好的架构分层能救命。同步层可插拔的设计,让我在分布式跑不通时,仅改一层就完成本地降级,UI 和业务零改动。
  2. 要区分"代码问题"和"环境约束"。分布式不同步不是我代码错了,而是设备组合达不到可信组网的前提。分清这两者,才不会在错误的方向上死磕。
  3. 承认约束,及时调整产品定位。与其交付一个跑不通的"理想协同",不如交付一个单机完整可用、并诚实标注进阶能力的产品。
  4. 不同设备形态的 UI 标准不同。大屏和手机在对比度、字号、交互热区上的要求差异很大,多端适配不只是布局弹性,还包括视觉与交互的重新校准。

CastDeck 现在是一个三页面、单机自洽、UI 精致、并预留了跨设备接口的完整应用。它没能在我的设备上演示"两台真机实时联动",但完整走通了从设想到落地的全过程——包括那些必要的妥协。而这些妥协本身,恰恰是真实工程最有价值的部分。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/12 1:37:00

AI 率一直降不下来怎么办?换个方法从 70% 降到 12%

AI 率一直降不下来怎么办&#xff1f;换个方法从 70% 降到 12% 你现在大概是这个状态&#xff1a;报告已经改了一整晚&#xff0c;一句一句地换词、调语序&#xff0c;检测结果一刷新&#xff0c;AI 率还是卡在七八十不动&#xff0c;甚至比刚开始还高。你开始怀疑是不是自己写…

作者头像 李华
网站建设 2026/7/12 1:35:57

3天精通!FanControl如何彻底改变你的电脑散热体验?

3天精通&#xff01;FanControl如何彻底改变你的电脑散热体验&#xff1f; 【免费下载链接】FanControl.Releases This is the release repository for Fan Control, a highly customizable fan controlling software for Windows. 项目地址: https://gitcode.com/GitHub_Tre…

作者头像 李华
网站建设 2026/7/12 1:34:58

5分钟配置LX Music聚合音源:全网音乐资源一站式获取终极指南

5分钟配置LX Music聚合音源&#xff1a;全网音乐资源一站式获取终极指南 【免费下载链接】lxmusic- lxmusic(洛雪音乐)全网最新最全音源 项目地址: https://gitcode.com/gh_mirrors/lx/lxmusic- 在当前数字音乐版权分散的时代&#xff0c;用户常常需要在多个平台间切换才…

作者头像 李华
网站建设 2026/7/12 1:34:17

邻接矩阵 vs 邻接表:图数据结构 C++ 实现与 3 大性能场景对比

邻接矩阵 vs 邻接表&#xff1a;图数据结构 C 实现与 3 大性能场景对比在计算机科学中&#xff0c;图&#xff08;Graph&#xff09;是一种非常重要的非线性数据结构&#xff0c;它由一组顶点&#xff08;Vertex&#xff09;和连接这些顶点的边&#xff08;Edge&#xff09;组成…

作者头像 李华
网站建设 2026/7/12 1:32:59

Keil AStyle 参数深度解析:5种代码风格与10个关键参数实战对比

Keil AStyle 参数深度解析&#xff1a;5种代码风格与10个关键参数实战对比 在嵌入式开发领域&#xff0c;代码的可读性和一致性直接影响团队协作效率和后期维护成本。当你在Keil MDK环境中面对杂乱无章的代码时&#xff0c;是否渴望像Visual Studio或Eclipse那样一键完成专业级…

作者头像 李华