我是兰瓶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 该同步什么?这四类足够了

  1. 会话关键runningDevice(当前主控)、noteIdroutescrollOffsetcaret
  2. 协作信号:是否有人在编辑、对方的光标影子、只读/占用。
  3. 轻量偏好:主题、字号、布局模式(单栏/多栏),让接力更“像自己”。
  4. 断点锚点:最近编辑时间戳、最近历史版本 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:只同步 noteIdselection,正文按版本号从本地/云拉。

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.INTERNET
    • ohos.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. 坑位地图:不踩会显得你“像开挂的一样稳”

  1. 把迁移当“复制内存”:错。请只带“索引 + 位置”,数据二次拉。
  2. 多端并发写:没有主控/租约就敢放开写,后患无穷。记住“唯一写入者”。
  3. 旋转/分屏触发重建:把窗口变化当“重启页面”,状态闪烁。改用骨架响应
  4. RemoteUI 不限帧:你以为越多越丝滑,其实越多越烫。自适应限帧/差量更新
  5. 滚动/光标恢复过早:数据/布局未稳就 setScroll,必丢。等下一帧 + 布局稳定信号
  6. KV 同步粒度过细:每个字符都同步,网络风暴。节流/合并,200~400ms 一次足矣。
  7. 权限一股脑全开:审核红灯。按 HAP 最小化
  8. 把“镜像”当成“协作”:RemoteUI 本质是“单人主控 + 他端观看/输入”,多人协作需另起协作层(CRDT/OT)。

7. 一页纸 Checklist(交付前自查)

  • ResponsiveScaffold,多端只换骨架不换内容
  • Continuation 打包最小状态(route/noteId/scroll/caret),目标端可重建
  • RemoteUI 会话稳定,输入桥工作正常,并支持“镜像→迁移”的静默切换
  • 分布式 KV 只同步关键状态,主控唯一、版本有序
  • 断点续接体验到位:UI 栈、滚动、光标均可恢复
  • 埋点齐全,可灰度、可回滚
  • 权限最小化,module.json5 与能力声明清晰
  • 旋转/分屏不重建,布局自适配
  • 节流/去抖,限帧/差量,功耗把控

收个尾:协同的“惊艳”,来自克制与秩序

多设备协同并不神秘,它只是把布局(看得见)迁移/镜像(换得快)、**状态(接得上)**这三件事,按秩序一一治理。
  当你用 Scaffold + 插槽收住多端布局,用 Continuation 只带“最小闭包”、用 RemoteUI 承接“即时上屏”,再用 KV + 版本稳住共享状态——恭喜你,切屏那一刻就不会再“掉链子”

(未完待续)

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐