白板从 2.16 开始支持多视图模式,开发者可以自由创建和管理多个白板视图,从而实现比单个白板更复杂的业务场景,下面就来看看。
为了展现多视图模式的强大,我们开发了 Fastboard 项目。它基本上是一块主要白板和一堆窗口,这些窗口里是开发者自由编写的小应用 Netless App。在此基础上,我们又开发了 Flat 来展现白板和 RTC 结合使用的场景。
如果你也想试试这种窗口化的场景,可以直接接入 Fastboard。
Fastboard 终究只是针对一个特定场景设计实现的,它不可避免的会有一些功能上的取舍:
room
可以切换不同文件夹的功能被去掉了如果你的业务需求并非窗口化 App,或者你有一些非对等同步设计(例如主播和观众看到的内容不同),那么以下是你可以用到的白板同步服务基建:
上述功能大都照顾了本地应用的编写体验,例如广播消息默认在本地是零时延地被触发,状态更新也会在本地几乎同步地收到更新回调。虽说如此,在特定情况下你可能仍然需要感知到同步的发生,下面就针对高阶用法进行一些介绍。
在《自定义事件》中,我们介绍了白板可写用户可以向全体用户广播消息:
room.dispatchMagixEvent("eventName", "message")
room.addMagixEventListener("eventName", (ev) => {
if (ev.authorId === room.observerId) {
// 此事件是由自己发出的
console.log(ev.event) // "eventName"
console.log(ev.payload) // "message"
}
})
如果你需要等待消息经过服务器同步后再触发回调,可以在监听时增加 fireSelfEventAfterCommit:
room.addMagixEventListener("eventName", (ev) => {
// 事件已经被同步服务器处理
}, { fireSelfEventAfterCommit: true })
由于白板同步服务共享一个 WebSocket 信道,通过这个机制就可以确保之前的所有同步修改都正确发到了服务器:
为什么需要确保?因为即使接口的表现再接近本地应用,白板服务说到底还是一个同步应用,如果在事件还没发送之前就结束房间或者切到只读状态,那么这些同步事件就被丢弃了,那些本地修改就有可能会被撤回。
const nextFrame = () => new Promise(resolve => {
const on_sync = () => {
resolve()
room.removeMagixEventListener("__sync__", on_sync)
}
room.addMagixEventListener("__sync__", on_sync, { fireSelfEventAfterCommit: true })
try {
room.dispatchMagixEvent("__sync__", null) // 如果你没有可写权限,这个接口会直接报错
} catch (err) {
console.error(err)
on_sync()
}
})
await nextFrame()
对于简单的、一维的业务,我们提供了 Global State 来实时同步状态。但是业务场景可能会更加复杂,此时通过《Invisible Plugin》,你可以借助白板的同步信道来实现对任意数据的持久化和同步。
虽说是任意,但是白板为了支持回放,所有中途的数据都会存储,所以请尽量精简设计应用状态,不要存太多把房间搞炸了。
class CustomPlugin extends InvisiblePlugin {
static kind = "Custom"
increment() {
this.setAttributes({ count: (this.attributes.count || 0) + 1 })
// 可以直接更新一个深层的状态,这样就不止一维了
this.updateAttributes(["deep", "state"], 123)
}
onAttributesUpdate(attributes) {
$('button').text(attributes.count)
}
}
let plugin = room.getInvisiblePlugin("Custom")
if (plugin == null) {
try {
plugin = await room.createInvisiblePlugin(CustomPlugin, {})
} catch {
// 如果有多个用户同时调用 createInvisiblePlugin,除了第一个外别的都会报错
await new Promise(resolve => setTimeout(resolve, 200))
plugin = room.getInvisiblePlugin("Custom")
}
}
如果你使用 TypeScript,可以这么写:
class CustomPlugin extends InvisiblePlugin<{}, {}> { public static readonly kind = "Custom" }
为了简化接入成本和概念,我们事先编写了一个插件 SyncedStore,封装了包括上面的 nextFrame()
等功能,可以直接使用。
通过在初始化时添加 useMobXState,白板里的大部分接口会返回 Proxy 对象,然后你可以使用 MobX 的接口对其进行监听:
import { WhiteWebSdk, autorun } from 'white-web-sdk'
const sdk = new WhiteWebSdk({ ...sdkConfig, useMobXState: true })
const room = await sdk.joinRoom({ ...roomConfig })
room.state.sceneState // Proxy(Object) {...}
autorun(() => console.log(JSON.stringify(room.state.sceneState)))
// {"sceneName":"init","scenePath":"/init","contextPath":"/","scenes":[{"name":"init"}],"index":0}
room.putScenes('/', [{}])
// {"sceneName":"init","scenePath":"/init","contextPath":"/","scenes":[{"name":"init"},{"name":"14ebae006d5d11ee8d8c7dc71a962c0f"}],"index":0}
room.setSceneIndex(1)
// {"sceneName":"14ebae006d5d11ee8d8c7dc71a962c0f","scenePath":"/14ebae006d5d11ee8d8c7dc71a962c0f","contextPath":"/","scenes":[{"name":"init"},{"name":"14ebae006d5d11ee8d8c7dc71a962c0f"}],"index":1}
这种监听粒度比 onRoomStateChanged
更细,可以更方便地完成一些复杂的业务逻辑。
类似的,InvisiblePlugin 内的 attributes 也会变成 Proxy 对象。但是请注意 onAttributesUpdate
将不会被触发,因为这意味着你选择了用更细粒度的监听方式。原本为了维护 onAttributesUpdate
SDK 要在内部不停地复制 Proxy 的原始数据,而选用 MobX 模式后 SDK 就不再浪费这层开销了。
为什么
autorun
来自 SDK 而不是mobx
这个包呢?因为用户可以自己安装不同版本的 MobX,这些 MobX 之间不能互相监听,所以需要从 SDK 内引用,这样保证和 SDK 里的 Proxy 对象用的是同一份 MobX。
如果你想编写实时性要求较高的应用,例如同步播放器,同步倒计时等等,很快你会遇到一个问题:
种种不可控外部因素下使得这种同步变得十分困难。此时客户可以采用更低时延的服务(如使用 RTC 直接传输视频流,类似直播体验)、重新设计同步策略和渲染策略(如许多竞技类游戏会采用严格的帧同步策略,也就是由服务器结算伤害值等等,本地可以提前渲染击中动画但是数值的变动需要等服务器)等等。
但是上述方案增加了成本,对于轻度场景(例如视频播放器的话,即使偶尔卡上两秒也是正常情况,用户只是需要大部分时间内所有端的时间对齐),白板还提供了一个简单的同步时间戳服务,它类似于许多操作系统里自带的时间同步服务,只不过这里是和白板服务器的时间同步。
room.calibrationTimestamp // 类似 Date.now() 在服务器上的返回值
// 在回放时你可以通过这行代码获取“当时”的时间戳
player.beginTimestamp + player.progressTime
有了这个基准时间,你就可以实现上述应用场景了。你可以看看 Netless App 里的同步播放器的设计思路和具体实现。
白板自 2.16 起提供了一些 View
相关接口,可以把它理解为一块白板,它可以自由地设置渲染的目标元素、更改渲染场景、更改视角、拥有独立的撤销重做等。你需要在加入房间时添加 useMultiViews 来启用它:
const room = await sdk.joinRoom({ ...roomConfig, useMultiViews: true })
const view = room.views.createView()
// 渲染到元素里
view.divElement = $('#whiteboard')
// 更改场景
view.focusScenePath = '/init'
需要注意的是,由于不存在“主白板”概念,以往的一些 room
上的接口会无法使用,需要调用 view 上的同名接口:
room.XXX() -> view.XXX()
// 渲染一帧到某个元素,注意这些渲染出来的是一个 DOM 树,不是一张图
scenePreview(scenePath, div, width, height, engine)
// 获取某个场景的背景图并输出 base64 数据
generateScreenshot(scenePath, width, height)
// 和 scenePreview 一样
fillSceneSnapshot(scenePath, div, width, height, engine)
// 渲染某个场景到一个 canvas 上
screenshotToCanvas(context, scenePath, width, height, camera, ratio)
// 获取某个场景所有元素的包围盒
getBoundingRect(scenePath)
// 外面采集到的像素坐标(clientX, clientY)转换成白板坐标系里的点
convertToPointInWorld({ x, y })
// 白板坐标系里的点转换成外部像素坐标,可以用来制作外部教具
convertToPointOnScreen(x, y)
// 禁止非 API 调用以外对视角的修改
disableCameraTransform
// 设置非 API 调用以外对视角的修改的限制
setCameraBound(bound)
// 移动视角
moveCamera({ centerX, centerY, scale, animationMode })
// 移动视角到包含某个区域
moveCameraToContain({ originX, originY, width, height, animationMode })
// 设置教具等个人状态
setMemberState({ ...memberState })
// 清除场景
cleanCurrentScene()
// 插入图片
insertImage({ uuid, centerX, centerY, width, height })
completeImageUpload(uuid, url)
lockImage(uuid, locked)
// 插入旧版插件
insertPlugin(kind, attributes)
// 插入文字
insertText(x, y, text)
updateText(id, text)
updateSelectedText(format)
// 复制粘贴删除
duplicate()
copy()
paste()
delete()
// 撤销重做
canUndoSteps
canRedoSteps
undo()
redo()
// 移动选中元素
moveSelectedComponentsToTop()
moveSelectedComponentsToBottom()
// 绑定元素
room.bindHtmlElement(div) -> view.divElement = div
// 修改渲染场景
// 注意:view 不会同步这个修改,实际上这两者都是有用的,具体见下文
room.setScenePath('/init') -> view.focusScenePath = '/init'
// 禁止某个按键被 view 捕获和处理
view.disableKey = ' '
// 销毁 view
view.release()
以下接口没有对应的 view 接口:
// 支持按住某个按键时切到抓手工具
room.handToolActive = true
room.handToolKey = ' '
虽然看起来很多,大部分都是直接把 room.
换成 view.
即可。下面是一些需要注意的点:
因为不同端的 view 可以拥有各自的大小,这里为了给予开发者最大的自由度,view 本身不再含有任何场景视角同步的逻辑,这和 room.putScenes()
room.setScenePath()
等接口不冲突。开发者应当自己知晓各端展示的细节,然后调用 view.focusScenePath = '/scene-path'
view.moveCamera()
来实现同步。
room.putScenes()
和 room.setScenePath()
和原先一样会触发 onRoomStateChanged
,但是由于没有主白板,这个信息现在只是一个信息。如果你的业务设计里有类似主白板的概念,可以利用 room.state.sceneState
和一块自定义 view 重新实现这个效果。
不可否认的是,任何需要联网的服务都不会完全像本地软件一样拥有完全可靠的状态,白板也不例外。虽然上面的接口已经在极力地模拟本地应用的体验,但是开发者仍然需要自行处理一些特殊的情况,例如:
在重连时,白板会丢弃之前所有的 Proxy 对象,重新创建一个新的状态树,此时之前的 autorun()
(参考上文「进阶同步状态:使用 MobX 风格的状态监听」部分)就会失效。你需要重新执行挂载监听:
let phase_ = room.phase
room.callbacks.on("onPhaseChanged", (phase) => {
if (phase === 'connected' && phase_ === 'reconnecting') {
reset_all_listeners()
}
phase_ = phase
})
在 seek 结束时,你会收到一个包含了超多变动的状态更新回调。此时如果你的应用不能及时处理这种「非线性状态变更」,或者你的应用依赖于特定的状态变化和事件发送顺序,那么就会出现问题。
你有两个策略来处理这个问题:
精心设计应用状态,确保每个可能的状态都是有定义的。即使状态陷入到一个不可能的情况也要给出正常且一致的渲染效果(白板保证所有端看到的状态都是一致的,但一致不代表正确,正确是由具体业务决定的)。
例如,倘若设计一个树形(类似 Figma)的状态,如何保证不出现以下两种异常情况?
// bad
attributes = {
root: {
children: [
{ name: 'child', children: [] }
]
}
}
思考一下,如果你的「移动元素」实现为:
那么就会出现上面说的第一个问题。我们可以对数据结构稍作修改即可规避这个问题:
// good
attributes = {
node1: { parent: null },
node2: { parent: 'node1' }
}
如果每个元素不记录 children,而是只记录 parent,那么所谓的移动节点只是一次:
this.updateAttributes(['node1', 'parent'], 'node2')
那么你在回放的每个时间点都会是有良好定义的状态。
在 seek 结束时自己重新初始化应用,约等于从中途加入房间。
await player.seek(time)
await reset_apps()