多设备协同 UI 为什么总在切换那一刻“掉链子”?——把多端布局、屏幕切换、数据续接一次打透!
本文从实战角度探讨鸿蒙多设备协同开发的三大核心问题:多端布局适配、状态迁移和远程交互。作者提出"尺寸分级"策略(Compact/Medium/Expanded),强调通过骨架组件ResponsiveScaffold实现布局结构化复用。在状态迁移方面,建议打包"最小可重建状态"(如路由、滚动位置等关键数据),区分Continuation(适合编辑类应用)和Re
我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~
前言
先抛个不太礼貌但很真实的灵魂拷问:你做“多设备协同”时,有没有遇到过这种尴尬——手机上好好的,切到平板一开,布局散了、状态丢了、交互还卡半拍?别急着背锅,是时候把多端布局 → 屏幕切换 → 数据共享与续接这条链路,一段一段地“缝”到位。
本文不讲空话,直接围绕 ArkUI/ArkTS 的组件化思路与 ContinuationAbility / RemoteUI 协同机制,搭一套能落地的经验谱:什么该放在本地、什么该通过分布式同步、什么时候“迁移”、什么时候“投屏/远控”。我会给出完整的组件骨架与示例代码,再把那些“看似小事、实则决定体验生死”的细节逐个按下去。
说白了——协同不是魔法,是管好“状态 + 渲染 + 连接”的三项纪律。来,开整。😎
1. 多端布局:别追求“同款界面”,要追求“同质体验”
1.1 尺寸分级与布局策略
多设备协同的第一步,不是写一堆 if (width > xxx),而是确立“尺寸分级”:
- Compact(窄屏/手机竖屏)
- Medium(大屏手机横屏/小平板/折叠外屏)
- Expanded(平板/桌面)
UI 结构随分级变化:
- Compact:单列、底部导航、浮层弹窗;
- Medium:双列(列表 + 详情)、侧边导航;
- Expanded:三列(导航/列表/详情)、工具侧栏、悬浮面板。
一个尺寸分级小工具(示意):
// /common/sizeClass.ts
export type SizeClass = 'compact' | 'medium' | 'expanded'
export function toSizeClass(width: number): SizeClass {
if (width < 600) return 'compact'
if (width < 1024) return 'medium'
return 'expanded'
}
记住:结构先行,样式其次。把“分级 → 结构”绑定住,才能让远端设备一接力,页面不“散架”。
1.2 响应式骨架组件:Scaffold + 插槽是王道
别把响应式逻辑塞满每个页面。写一个 ResponsiveScaffold,把导航栏、内容区、工具栏做成插槽(@BuilderParam),让页面只关心填什么,而不是怎么排。
// /components/ResponsiveScaffold.ets
@Component
export default struct ResponsiveScaffold {
width: number = 360
@BuilderParam nav?: () => void
@BuilderParam content?: () => void
@BuilderParam secondary?: () => void
@BuilderParam toolbar?: () => void
build() {
const cls = toSizeClass(this.width)
if (cls === 'compact') {
Column() {
if (this.toolbar) this.toolbar!()
if (this.content) this.content!()
}
} else if (cls === 'medium') {
Row() {
if (this.nav) { Column(){ this.nav!() }.width('28%') }
if (this.content) { Column(){ this.content!() }.width('72%') }
}
} else { // expanded
Row() {
if (this.nav) { Column(){ this.nav!() }.width('20%') }
if (this.content) { Column(){ this.content!() }.width('50%') }
if (this.secondary) { Column(){ this.secondary!() }.width('30%') }
}
}
}
}
页面侧使用:
// /pages/NotesHome.ets (便签主页示例)
@Entry
@Component
struct NotesHome {
@State w: number = 360
@Builder navPane() { /* 分组/标签 */ }
@Builder listPane() { /* 便签列表 */ }
@Builder detailPane() { /* 详情或空态 */ }
@Builder toolsBar() { /* 搜索/新建/筛选 */ }
build() {
ResponsiveScaffold({
width: this.w,
nav: this.navPane,
content: this.listPane,
secondary: this.detailPane,
toolbar: this.toolsBar
}).height('100%').width('100%')
}
}
插槽一开,适配就不再是复制 N 份布局,而是“替换骨架 + 复用内容”。这对后面的屏幕切换极其重要——因为接力时只要选对骨架,内容自然站稳。
1.3 输入形态与姿态:别把平板当“大手机”
协同时常见“输入错位”:手机用手指,平板/桌面用鼠标键盘。经验法则:
- 命中面积:鼠标可精确,触控要≥44dp;
- 悬浮态:桌面/平板允许 hover 提示,手机则禁用;
- 键盘习惯:桌面要支持快捷键(Ctrl/Cmd + S 保存,Esc 关闭等);
- 横竖屏与分屏:监听窗口尺寸变化,不要把旋转当“重新进入页面”,而是刷新骨架即可。
2. 屏幕切换:迁移(Continuation)和镜像/远控(RemoteUI),不是“二选一”
2.1 何时“迁移”,何时“镜像/远控”?
- Continuation(迁移):把会话/状态转移到目标设备“本地运行”。编辑类、生产力类强烈建议用迁移——延迟小、本地能力全。
- RemoteUI(远程 UI):把渲染与输入通过会话传到目标设备。展示/演示/临时协作很合适——比如手机做遥控器,平板全屏显示。
- 二者组合:先 RemoteUI 快速镜像窗口(即刻“上大屏”),后台悄悄完成 Continuation 迁移,平滑切换到目标设备本地运行。这招体验上几乎“无缝”。
2.2 ContinuationAbility:迁移的“最小闭包”怎么装箱?
核心认知:迁移不是“把全部内存倒过去”,而是打包“最小可重建状态”(Minimal Restoration Set)。
- UI 层:当前路由、滚动位置、编辑光标、暂存草稿 ID;
- 数据层:只带 Key(如 noteId),内容本地/云端再拉;
- 运行层:谁是“主控”(runningDevice),避免多端并发写。
状态建模(示意):
// /session/ContinuationPayload.ts
export interface ContinuationPayload {
route: string // 当前页面
params?: Record<string, any>
scrollOffset?: number
caret?: { selectionStart: number; selectionEnd: number }
noteId?: string
runningDevice?: string // 当前主控设备ID
ts: number
}
迁移客户端:
// /continuation/ContinuationClient.ets(伪示意,按你SDK接口适配)
export class ContinuationClient {
async selectDevice(): Promise<string> {
// 打开系统设备选择器,返回 deviceId
// return continuationManager.selectDevice()
return ''
}
async continueTo(deviceId: string, payload: ContinuationPayload) {
// 1) onSave: 序列化最小状态
const data = JSON.stringify(payload)
// 2) 发起迁移(系统将调用对端 Ability 的恢复钩子)
// await continuationManager.continueAbility({ deviceId, data })
}
}
目标端(恢复)大致流程:
// /entryability/EntryAbility.ets(示意)
import UIAbility from '@ohos.app.ability.UIAbility'
export default class EntryAbility extends UIAbility {
// 某些版本为 onContinue / onRestore 回调,具体以 SDK 为准
onCreate(want, launchParam) {
const payloadStr = launchParam?.parameters?.payload
if (payloadStr) {
const payload: ContinuationPayload = JSON.parse(payloadStr)
this.restoreFrom(payload)
}
}
private restoreFrom(p: ContinuationPayload) {
// 1) 恢复路由:跳到 p.route 并传入 p.params
// 2) 拉取 noteId 对应数据(本地/云)
// 3) 安排 UI:滚动到 scrollOffset、恢复光标 caret
// 4) 标记本机为主控(runningDevice = myId)
}
}
经验:迁移前先把“可视状态”序列化(滚动/光标),迁移后再“补数据”。千万别试图把大块内容直接装进 payload,把它当“索引”就好。
2.3 RemoteUI:会话、渲染、输入桥
RemoteUI 视角下我们要解决两件事:
- 会话:源端(Host)与目标端(Client)建立一个 UI 会话(类似投屏/远控)。
- 输入桥:目标端的输入(鼠标/键盘/触控)回传给源端驱动更新。
抽象个小容器(示意):
// /remote/RemoteSession.ts(抽象)
export interface RemoteSession {
open(deviceId: string): Promise<void>
close(): void
sendEvent(evt: any): void // Client -> Host 输入
sendFrame(snapshot: ImageSource): void // Host -> Client 渲染帧(或结构化更新)
}
Host 侧“投屏”:
// /remote/RemoteHost.ets(示意)
@Component
export default struct RemoteHost {
session!: RemoteSession
@State running: boolean = false
aboutToAppear() {
// 每帧或差量变化时,把“可视区域”快照/结构化变化发给 session
}
build() {
Column() {
Text('Projecting to remote device…')
Button('Stop').onClick(()=> { this.session.close(); this.running = false })
}
}
}
Client 侧“承载”:
// /remote/RemoteClient.ets(示意)
@Component
export default struct RemoteClient {
session!: RemoteSession
@State lastFrame?: ImageSource
aboutToAppear() {
// 订阅 session 帧数据,lastFrame = frame
}
build() {
Column() {
if (this.lastFrame) {
// 用 Image/Canvas 显示
} else {
Text('Waiting for host…')
}
}
.onTouch((e)=> this.session.sendEvent(e))
// 键盘/鼠标事件同理
}
}
组合拳:实际体验里常用“RemoteUI 一键上大屏”,同时后台启动 Continuation 迁移,几秒后静默切换为目标端本地运行。用户几乎察觉不到“换脑袋”的那一刻。
3. 数据共享与续接:同步“关键状态”,别同步“一切”
3.1 该同步什么?这四类足够了
- 会话关键:
runningDevice(当前主控)、noteId、route、scrollOffset、caret。 - 协作信号:是否有人在编辑、对方的光标影子、只读/占用。
- 轻量偏好:主题、字号、布局模式(单栏/多栏),让接力更“像自己”。
- 断点锚点:最近编辑时间戳、最近历史版本 ID,方便回滚。
不该实时同步:大块业务数据(富文本内容、附件大文件)——走本地数据库/云端拉取,允许稍后到达;实时同步只做“索引 + 位置 + 信号”。
3.2 会话与冲突:主控唯一,旁观可见
简单但有效的约束:
- 一次只允许一个主控(扣秒/写入的一方);
- 其他设备旁观可见(看到位置/高亮),但默认只读;
- “接管”需要显式操作,变更
runningDevice; - 写入冲突遵循“后写覆盖 + 合并策略”,并记录冲突快照。
分布式 KV 小客户端(示例):
// /data/KvClient.ts(简化示意)
export class KvClient {
private observers = new Map<string, (v:any)=>void>()
async put(key: string, val: any) { /* 分布式KV写入 */ }
async get<T=any>(key: string): Promise<T|undefined> { /* 读取 */ }
observe<T=any>(key: string, cb: (v:T)=>void) {
this.observers.set(key, cb)
}
}
关键键位建议:
session:runningDevice -> string
session:route -> string
session:cursor -> { noteId, caret, ts }
session:scroll -> { route, offset, ts }
session:presence -> { deviceId, nickname, ts }
3.3 断点续接:UI 栈、滚动、光标三件套
- UI 栈:
route+params序列化; - 滚动位置:记录
scrollOffset,进入目标端后延迟恢复(等数据/布局稳定); - 光标:编辑器需暴露
setSelection(start,end),迁移/接管后调用; - 富文本/Markdown:只同步
noteId与selection,正文按版本号从本地/云拉。
4. 一锅端示例:便签编辑器(Notes)多设备协同
目标体验
- 手机在地铁上写便签,进办公室把内容“上平板继续”;
- 切过去 UI 不散(多端布局骨架),数据不断(续接),输入顺(键鼠);
- 可以先“镜像投过来”(RemoteUI),2~3 秒后自动“本地运行”(Continuation)。
4.1 页面骨架与布局
// /pages/NoteEditor.ets
import { toSizeClass } from '../common/sizeClass'
import { KvClient } from '../data/KvClient'
@Entry
@Component
struct NoteEditor {
@State width: number = 360
@State noteId: string = ''
@State content: string = ''
@State caret = { selectionStart: 0, selectionEnd: 0 }
private kv = new KvClient()
aboutToAppear() {
// 监听滚动/光标共享
this.kv.observe('session:cursor', (v)=> { /* 显示远端光标影子 */ })
}
build() {
const cls = toSizeClass(this.width)
if (cls === 'compact') {
Column() { this.toolbar(); this.editor() }
} else if (cls === 'medium') {
Row() { this.outline(); this.editor() }
} else {
Row() { this.nav(); this.editor(); this.meta() }
}
}
@Builder toolbar() { /* 搜索、切换、协同指示灯 */ }
@Builder outline() { /* 大纲/历史 */ }
@Builder nav() { /* 便签列表/分组 */ }
@Builder meta() { /* 版本/评论/附件 */ }
@Builder editor() {
// 你的富文本控件/自研编辑器
TextInput({ text: this.content, placeholder: 'Type…' })
.onChange(t => { this.content = t }) // 数据写入本地/节流同步
.onSelect((s: number, e: number) => {
this.caret = { selectionStart: s, selectionEnd: e }
this.kv.put('session:cursor', { noteId: this.noteId, caret: this.caret, ts: Date.now() })
})
}
}
4.2 Continuation:迁移打包与恢复
发起迁移:
// /continuation/continueNote.ts
import { ContinuationClient } from './ContinuationClient'
import type { ContinuationPayload } from '../session/ContinuationPayload'
export async function continueOnBigScreen(noteId: string, route: string, caret: {selectionStart:number;selectionEnd:number}, scroll: number) {
const client = new ContinuationClient()
const deviceId = await client.selectDevice()
const payload: ContinuationPayload = {
route, noteId, caret, scrollOffset: scroll, runningDevice: 'target@auto', ts: Date.now()
}
await client.continueTo(deviceId, payload)
}
目标端恢复:
// /entryability/EntryAbility.ets 片段
private async restoreFrom(p: ContinuationPayload) {
// 1) 跳转路由到 NoteEditor,并携带 noteId
// 2) 从本地/云端拉取 noteId 内容 -> setState(content)
// 3) 等编辑器装载完 -> 恢复滚动/光标
// 4) 标记本机为主控:kv.put('session:runningDevice', myDeviceId)
}
4.3 RemoteUI:先镜像,后迁移
一键镜像按钮:
Button('Project to Pad')
.onClick(async ()=>{
// 选择设备 -> 打开 RemoteUI 会话
// RemoteHost(session).start()
// 同时启动 continueOnBigScreen(),完成后静默切换
})
静默切换策略:
-
RemoteUI 会话持续渲染;
-
Continuation 完成并确认目标端“本地运行已就绪”后:
- RemoteUI 弹个“已转为本地运行”的轻提示;
- 3 秒内自动关闭 RemoteUI 会话(给用户反悔时间)。
5. 工程化清单:把体验做成“可维护的制度”
权限与配置(按你 SDK 版本核对)
-
网络、分布式数据、通知/振动(如需要):
ohos.permission.INTERNETohos.permission.DISTRIBUTED_DATASYNC(或同类跨设备数据权限)ohos.permission.POST_NOTIFICATIONS/ohos.permission.VIBRATE
-
module.json5中为各 HAP(entry/feature/remote)单独最小化申请。 -
Continuation/RemoteUI 相关扩展能力在
abilities中声明入口与导出策略。
埋点与可观测
- 记录“迁移发起/成功/耗时/失败原因”;
- 记录“RemoteUI 打开/帧率/输入延迟”;
- 续接后 30 秒的崩溃率与停留时长单独看。
灰度与回滚
- Continuation/RemoteUI 的入口都挂在远端配置开关;
- 异常高于阈值立即切回本地单设备模式;
- 迁移失败兜底:在目标端自动打开“只读镜像模式”(RemoteUI),保证“能看能展示”。
一致性策略
- 所有“关键状态”写 KV 时带
ts,目标端只接收更晚的; - 主控切换时广播
runningDevice并设置 5 秒“抖动保护”(避免多次来回); - 富文本内容采用版本号 + 增量补丁(或服务器统一时序),落地时幂等。
6. 坑位地图:不踩会显得你“像开挂的一样稳”
- 把迁移当“复制内存”:错。请只带“索引 + 位置”,数据二次拉。
- 多端并发写:没有主控/租约就敢放开写,后患无穷。记住“唯一写入者”。
- 旋转/分屏触发重建:把窗口变化当“重启页面”,状态闪烁。改用骨架响应。
- RemoteUI 不限帧:你以为越多越丝滑,其实越多越烫。自适应限帧/差量更新。
- 滚动/光标恢复过早:数据/布局未稳就 setScroll,必丢。等下一帧 + 布局稳定信号。
- KV 同步粒度过细:每个字符都同步,网络风暴。节流/合并,200~400ms 一次足矣。
- 权限一股脑全开:审核红灯。按 HAP 最小化。
- 把“镜像”当成“协作”:RemoteUI 本质是“单人主控 + 他端观看/输入”,多人协作需另起协作层(CRDT/OT)。
7. 一页纸 Checklist(交付前自查)
- 有 ResponsiveScaffold,多端只换骨架不换内容
- Continuation 打包最小状态(route/noteId/scroll/caret),目标端可重建
- RemoteUI 会话稳定,输入桥工作正常,并支持“镜像→迁移”的静默切换
- 分布式 KV 只同步关键状态,主控唯一、版本有序
- 断点续接体验到位:UI 栈、滚动、光标均可恢复
- 埋点齐全,可灰度、可回滚
- 权限最小化,
module.json5与能力声明清晰 - 旋转/分屏不重建,布局自适配
- 节流/去抖,限帧/差量,功耗把控
收个尾:协同的“惊艳”,来自克制与秩序
多设备协同并不神秘,它只是把布局(看得见)、迁移/镜像(换得快)、**状态(接得上)**这三件事,按秩序一一治理。
当你用 Scaffold + 插槽收住多端布局,用 Continuation 只带“最小闭包”、用 RemoteUI 承接“即时上屏”,再用 KV + 版本稳住共享状态——恭喜你,切屏那一刻就不会再“掉链子”。
…
(未完待续)
更多推荐





所有评论(0)