一套 SwiftUI 代码,三端不同的手——Shellby 的多会话适配
Shellby 复盘:命令广播、分屏、⌘W——一套代码要同时伺候 Mac 的键盘、iPad 的多任务、iPhone 的单手。
Shellby 用一套 SwiftUI 代码同时跑 iPhone、iPad、macOS。「一套代码三端」听起来是 SwiftUI 的卖点,但真到多会话、分屏、快捷键这些交互密集的地方,三端的差异会逼你在同一份代码里分出好几条路径。这篇讲三个具体的适配:命令广播、分屏、还有一个看似简单的 ⌘W。
命令广播:把广播下沉到输入路径
先说个设计上的小决定。命令广播——一次输入同时发往多个会话,批量在多台机器上执行——最直觉的写法是在 UI 层「遍历所有面板,逐个把输入发过去」。
Shellby 没这么做,而是把广播下沉到会话对象自己的输入路径里。会话的 sendInput 方法先把输入写给自己,再看有没有激活的广播组,有就转发给组里的其他成员。
为什么这样更好?因为所有输入来源都过 sendInput 这一个口子——SwiftTerm 的键入、iOS 软键盘的特殊键、符号栏,全都走它。广播逻辑放在这个唯一入口里,就天然对所有输入源生效,不用在每个 UI 组件里各接一遍广播。数据结构也极简:一个广播组只持有「是否激活」和「成员列表」,会话反向弱引用回组。
广播刻意不做任何状态对齐——不管各会话当前 cwd 是不是一致、是不是同一个 shell 状态,就是纯字节广播。这是个取舍:想做「智能对齐」会引入无穷的边界情况,而运维场景里「对多台同构机器发同一条命令」本就假设它们状态相近。防误操作不靠对齐,靠强可见性:广播开关高亮橙色,右侧常驻一个橙色「广播中」标签,你不可能没注意到自己正在往多台机器上发命令。
分屏:被 reflow 坑掉的可拖拽分隔条
Mac 上的专业用户要多个终端同屏——分屏。第一版用的是可拖拽的 HSplitView/VSplitView,就是那种中间有条可以拖的分隔线、拖动实时调整两侧宽度。
这个方案被一个终端渲染的坑逼退了。
问题在于 SwiftUI 的 SplitView 布局时,会对里面的视图做多趟试探性布局——它要试好几个尺寸才定下最终的分割位置。而每试一趟,终端视图(SwiftTerm)就会按那个中间尺寸 reflow 一次(重排文字),而且这些中间态的重排会残留进 scrollback(滚动历史)。结果就是:上排面板的内容被这些试探性 reflow 搞坏了。
修法是放弃可拖拽,改成等分的 VStack/HStack——一趟就定尺寸,每个面板只 reflow 一次。代价是失去了拖动调整比例的能力,换来的是内容不损坏。为了弥补,做了个自动排布:在所有可能的列数里,选一个让每格宽高比最接近 1.3(终端略宽更好读)、且空格子最少的方案。面板之间可以拖动重排顺序(只有标题栏是拖拽手柄,避免和终端里选词冲突),重排只改数组顺序、不动会话实例,所以内容不丢。
「理论上更好的交互」(可拖拽分隔条)输给了「底层组件的现实」(reflow 破坏缓冲)——工程里这种事不少,能接受退化方案、把损失降到最小,比硬撑一个会坏内容的漂亮交互重要。
⌘W:一个快捷键,三端三种写法
最能说明「一套代码三端」有多不省心的,是 ⌘W——「关闭」。它在会话语境下有歧义:是关闭当前会话标签,还是关闭整个窗口?三个平台,三条不同的处理路径:
- macOS:SwiftUI 的菜单命令抢不到 ⌘W——系统的 key equivalent 优先级更高,会直接触发「关窗口」。解法是用
NSEvent.addLocalMonitorForEvents装一个本地按键监听,拦下 ⌘W:有活动会话就关会话并吞掉事件,没有才放行去关窗口。 - 真 iPad:反过来,得在菜单构建时把系统的 close 命令
remove掉,把 ⌘W 让给「关闭会话」,否则和系统 close 重复会触发 UIKeyCommand 去重崩溃。 - iOS-on-Mac(「Designed for iPad」跑在 Mac 上):close 由 Mac 桥接层注入、移除不可靠,只好把「关闭会话」改挂到 ⇧⌘W。
同一个「按 ⌘W 关掉当前会话」的意图,因为三端的系统菜单机制不同,写了三套。这不是设计不够统一,是平台各自的键盘/菜单模型本就不一样,一套代码想在三端都「感觉对」,就得在这些地方分叉。
还有些多窗口的适配是「白拿」的:每个窗口各持一个独立的会话管理器实现窗口隔离,⌘N 新窗口、iPad 的多 Scene / Stage Manager 由 WindowGroup 自动提供。但也有要主动关的——macOS 上刻意关掉系统的自动窗口标签页(allowsAutomaticWindowTabbing = false),因为系统标签页会新开独立窗口(各自的会话管理器),和「窗口内的会话标签」语义重叠,一混用户就懵。
复用终端视图,不绑 SwiftUI 身份
还有个贯穿始终的设计值得单独提:终端视图由会话对象持有并复用,不绑定 SwiftUI 的视图身份。
SwiftTerm 的终端视图是 UIKit/AppKit 的,比 Swift 并发还老。如果按 SwiftUI 常规做法让它跟着视图身份走,切标签、进出分屏时视图会被销毁重建,终端内容就没了。所以反过来:让会话对象持有那个终端视图,切标签/分屏时把同一个视图换个挂载点(先从旧父视图摘下、再挂到新的),内容始终在。单会话多标签之间的切换更是用 ZStack + opacity 显隐——所有终端常驻、全尺寸,只改透明度,不卸载、不改尺寸,也就不触发 reflow、内容不错位。
小结
「一套代码三端」是真的省了很多,但它省的是「大部分」,不是「全部」。几条经验:
- 共享逻辑要放在唯一入口:广播下沉到
sendInput,一处生效于所有输入源,而不是在每个 UI 组件里各接一遍; - 理论最优的交互要让位于底层现实:可拖拽分隔条输给了 SwiftTerm 的 reflow 坑,退到等分栅格 + 自动排布,认了损失换正确性;
- 平台差异集中在交互层:⌘W 三端三写,不是不统一,是键盘/菜单模型本就不同——把差异框在这些点上,核心逻辑仍是一套;
- 别让重活跟着 SwiftUI 身份走:终端视图由会话持有、复用,切换只换挂载点,内容不丢。
一套代码三端,真正的功夫不在共享的那 90%,在于把剩下 10% 的平台差异干净地分叉出去,让每一端都「感觉是原生的」。
留言