iPhone Duo 的消息传了很久,这回基本坐实了。作为从 Android 碎片化适配一路折腾过来的开发者,我对“新形态设备”这四个字真是又爱又恨——爱的是技术想象空间被撑开,恨的是它意味着又一轮铺天盖地的适配需求。双屏、折叠、铰链、悬停,这些词很快会从厂商发布会 PPT 变成你工单系统里的真实需求:运动 App 首页跨屏怎么摆、视频播放器展开后怎么排、图表在第二屏怎么联动,光想想就头大。
腾讯开源的 Kuikly 这个跨端框架,我关注它其实有一阵子了。它不是传统的 WebView 套壳,也不是简单地把 JS 逻辑映射到原生控件,而是用 Kotlin 写逻辑、自绘引擎渲染的一套东西。这回 iPhone Duo 要来,正好拿它做一轮双屏适配演练,把布局切换、铰链避让、双屏联动这些硬骨头挨个啃一遍。这篇文章就是我这次适配实践的全记录,适合正在做跨端应用、或者预研折叠屏/双屏适配方案的移动端开发同学参考,思路和踩坑部分对原生开发同样有借鉴价值。
1. iPhone Duo 的形态变化,究竟动了 App 的哪块蛋糕
1.1 双屏不是“更大的一块屏”,而是多了一个物理空间
很多朋友第一反应是:双屏嘛,不就是把屏幕变大了,适配逻辑按大屏平板处理就行。这个想法会害死人。平板是把一块屏幕的面积放大,内容还是铺在同一平面上的,而 iPhone Duo 这类双屏设备是两块独立的物理屏幕通过铰链连接,中间有明确的物理分界线。
这意味着你的 App 可能同时出现在两块屏幕上,也可能跨在两块屏幕上,还可能只出现在其中一块上。用户的握持方式、观看距离、操作习惯全部变了。举个例子,运动 App 在单屏状态下,训练计时器通常在屏幕中央偏下位置,方便拇指点按;展开成双屏后,如果还按原来的布局跑,计时器可能正好落在铰链区域,一按压就触发屏幕联动抖动,体验直接崩盘。
所以第一件事就是把思维从“适配一块大屏”切换成“设计多个屏空间”。双屏设备上的界面不是单一画布,而是由 Screen A、Screen B、以及它们之间的连接区域组成的组合体。这也是 Kuikly 这类跨端框架能发挥价值的地方——它的布局引擎天然支持多容器管理,你可以把两块屏当成两个独立的根容器去编排。
1.2 折痕、铰链、悬停:双屏设备带来的三类新交互
我拿到模拟环境后的第一感受是,双屏并不只是“多块屏幕”,它给交互层引入了三个全新的约束条件。
第一是折痕与铰链避让。铰链区域虽然在展开状态下可以做到接近无缝,但物理上它依然是一个不可用区域,触摸识别、显示精度都会打折。所有关键操作按钮、输入框、轮播图指示器都不能落入这个区域。第二是悬停模式。设备可以像笔记本一样立在桌面上,上半屏显示内容、下半屏变成触控板。这时候 App 需要响应这种姿态变化,比如视频会议 App 可以在上半屏显示视频画面、下半屏显示参会者列表和控制按钮。第三是双屏跨屏操作。用户可能把一张图片从 A 屏拖到 B 屏,也可能在一屏打开列表、另一屏打开详情。
这三类交互对 UI 框架提出了硬性要求:布局要能感知设备形态、组件要能响应跨屏事件、样式系统要能处理安全区变化。传统 Native 开发当然也能做,但工作量在于你要分别维护 iOS 和 Android 两套逻辑;而 Kuikly 的逻辑层是 Kotlin 单写的,天然省掉一半适配成本。
1.3 总结一下:iPhone Duo 对 App 的三个硬性要求
我把这次的适配需求收敛成三句话,后面所有工作都围绕这三条展开。
- 空间感知:App 必须能实时知道当前处于单屏、双屏展开、还是悬停状态,并据此切换布局。
- 安全区自适应:铰链区域、圆角区域、系统手势区域都要纳入安全区计算,不能靠写死 padding 解决。
- 跨屏联动能力:页面状态需要在两块屏幕之间同步、迁移,比如主屏列表、副屏详情,或者主屏视频、副屏弹幕。
这三条并不是 iPhone Duo 独有的,折叠屏 Android 设备早就提过类似需求。但 iPhone Duo 的特殊之处在于它把“双屏”从尝鲜带向了主流市场,App 再不适配就说不过去了。好消息是,Kuikly 在这三块都有对应的解决方案,下面细说。
2. 为什么我选 Kuikly 来应对这场适配战争
2.1 Kuikly 到底是什么,它凭什么能处理双屏
先给还不熟悉 Kuikly 的朋友补个背景。Kuikly 是腾讯开源的一套跨端开发框架,核心思路是用 Kotlin 作为统一逻辑语言,开发者写一份 Kotlin 代码,可以同时编译到 Android、iOS,甚至鸿蒙等平台。UI 层它不走 WebView、也不直接映射原生控件,而是通过自绘引擎(Skia)把界面画出来。
这个技术路线带来的直接好处是:UI 表现一致性强。我们团队以前用 WebView 方案做适配,最头疼的就是不同 iOS 版本上 CSS 渲染的细微差异,偶尔还会出现 GPU 缓存导致的白屏;换到 Kuikly 后,自绘引擎在所有平台都走同一套渲染管线,双屏这种特殊形态至少不会被 WebView 的奇葩兼容性问题二次折磨。
另一个好处是 Kotlin 语言的表达能力。Kotlin 的协程、密封类、扩展函数,在写形态判断和布局切换这类逻辑时非常顺手。你可以把“单屏态”“展开态”“悬停态”定义成密封类,然后通过 when 表达式穷举处理,编译器还能帮你检查漏掉的分支。这在双屏适配里太重要了,因为状态分支本身就多,少写一个分支就是线上事故。
2.2 逻辑像素与响应式布局:适配的底层地基
双屏适配最容易犯的错误是直接用物理像素或简单的宽高百分比去布局。真正的第一步是建立逻辑像素体系。Kuikly 的布局系统支持类似 dp 的逻辑单位,在不同屏幕密度下自动缩放,配合 Flexbox 风格的弹性布局,可以让界面元素在单屏宽度和双屏展开宽度之间平滑过渡。
我来解释一下为什么这很关键。展开态下单屏宽度大概 400 逻辑像素,展开后有 800 多逻辑像素的宽度。如果用固定 width 写死控件尺寸,展开后页面会出现大片空白;如果只用百分比,控件会被拉伸到难看的地步。正确做法是用弹性布局(Flex)加约束条件:列表项可以占满单屏宽度,但最大宽度限制在 240 逻辑像素以内;详情区域使用 FlexGrow 吸收剩余空间。Kuikly 的布局引擎完整实现了这套规则,写起来和 Flutter 的 Flex 很像,上手几乎没有成本。
顺带一提,这套逻辑像素体系在应对 Android 平板、大屏电视、甚至车机屏幕时同样适用。我们团队之前做过一轮大屏适配方案调研,当时是打算自己写一套栅格系统,现在直接用 Kuikly 的响应式布局就把问题解决了。
2.3 一次开发、多端复用:双屏适配顺手把生态做厚了
选 Kuikly 还有一个很实际的考量:团队资源有限。公司不可能为了 iPhone Duo 单独成立一个 iOS 适配组,而 Kuikly 让我们用一套代码同时覆盖 iOS、Android、以及后续可能的折叠屏鸿蒙设备。人力成本几乎是减半的,这对中小团队来说是决定性的优势。
实际开发中还有一个隐性收益:Kuikly 的热重载能力。做布局适配时,我需要反复调整元件的尺寸、间距、对齐方式,如果走原生 iOS 编译,一次完整构建少说几十秒,一天下来光等编译就浪费大量时间。Kuikly 支持热更新式的实时预览,改完代码马上看到效果,效率完全不在一个量级。
另外,Kuikly 在无障碍适配方面也有不错的支持。双屏设备对读屏软件的需求更复杂,用户可能跨屏滑动浏览。Kuikly 提供了文本缩放、屏幕阅读器事件派发等基础能力,后面我会专门讲无障碍适配的一些坑。
3. 实操全记录:用 Kuikly 给 App 做一份双屏适配方案
3.1 工程准备与基础依赖配置
我建议先别急着写代码,把工程环境和调试工具准备好,这部分能省掉后面非常多麻烦。
第一步是安装 Kuikly 的最新命令行工具,创建工程。以运动 App 为例,我会先初始化一个基础跨端工程,确保在 iPhone 模拟器和 Android 模拟器上都能跑通默认模板。
# 创建工程(示意命令,以官网最新文档为准) kuikly create DuoFitApp cd DuoFitApp kuikly run ios跑通默认模板后,一定要接入双屏模拟器。iPhone Duo 的模拟器通常支持形态切换快捷键,不同 Xcode 版本位置不一样,但核心能力就是在单屏、展开、悬停三种形态之间切换。把快捷键背熟,后面调试布局时会高频使用。
工程层面还有一些基础配置要做:开启安全区支持,让布局自动避开状态栏、手势区域和铰链区域;注册屏幕状态变化监听,这是双屏布局切换的入口。我习惯把形态判断封装成一个全局单例,任何页面都可以直接查询当前处于什么形态。
// 双屏形态定义(示意代码,API 以官方文档为准) enum class DeviceForm { SINGLE_SCREEN, // 单屏折叠态 DUAL_EXPANDED, // 双屏展开态 FOLDED_HOVER // 悬停/帐篷态 } object DeviceFormManager { private val listeners = mutableListOf<(DeviceForm) -> Unit>() fun register(listener: (DeviceForm) -> Unit) { listeners.add(listener) // 监听系统屏幕状态回调... } }这段代码的用意是把“设备形态”从系统层抽象成业务层可以统一消费的状态。后面写布局、写交互逻辑都不用再关心系统 API 的差异,直接依赖这个全局状态就行。
3.2 双屏形态识别与布局切换:核心实现
接下来是这次适配的重头戏:形态识别和布局切换。我先定义一个页面描述模型,把“首页”在三种形态下的布局规则显式声明出来。运动 App 首页包含三块核心内容:运动数据卡片、训练计划列表、本月运动统计图表。
在单屏形态下,我采用单列滚动布局——数据卡片在顶部,训练计划列表在中间,统计图表在底部,一屏展示不完就滚动。在双屏展开形态下,布局切换为两栏:左屏放数据卡片和训练计划列表,右屏放统计图表和本周趋势。在悬停形态下,上半屏显示数据卡片,下半屏显示训练控制按钮。
// 首页的响应式布局声明(示意代码) class HomePage : KuiklyComponent() { override fun build(context: KuiklyContext) { val form = DeviceFormManager.currentForm() when (form) { DeviceForm.SINGLE_SCREEN -> buildSingleColumn() DeviceForm.DUAL_EXPANDED -> buildDualColumn() DeviceForm.FOLDED_HOVER -> buildHoverMode() } } private fun buildDualColumn() { FlexLayout( direction = Row, children = listOf( FlexItem(flex = 1) { buildLeftPanel() }, FlexItem(flex = 1) { buildRightPanel() } ) ) } }这个结构看着简单,但有两个细节必须处理到位。第一是状态保存。形态切换时页面会重新 build,如果用户在训练计划列表里滚到第 20 项,切到展开形态后滚动位置不能丢。解决办法是给可滚动组件绑定 key,Kuikly 会基于 key 保留滚动状态。第二是布局切换动画。形态切换直接跳变会很生硬,我给 FlexLayout 的间距、子项尺寸加了过渡动画,让两栏布局在 300 毫秒内平滑展开。用户体感上会觉得 App 是“主动适应”设备,而不是“被动拉伸”。
3.3 铰链区域与安全区避让:最容易翻车的地方
如果说布局切换决定了适配的上限,那铰链区的处理就决定了适配的下限。我踩过最惨的坑是:在模拟器上看布局完美,但真机上一落铰链区域,按钮就点击失灵,因为铰链区域的触控层是物理中断的。
正确的做法是把铰链区域纳入安全区计算体系。Kuikly 提供了类似 SafeArea 的机制,你可以声明一个 DirectionalSafeArea,指定顶部、底部、左侧、右侧各自的安全距离。在双屏展开形态下,左右两块屏相接的位置就是铰链安全区,必须预留足够的 padding。
// 铰链区域避让示例(示意代码) FlexLayout( direction = Row, padding = Padding( start = if (form == DUAL_EXPANDED) hingeSafeWidth else 0.dp, end = if (form == DUAL_EXPANDED) hingeSafeWidth else 0.dp ) ) { // 子组件... }这里有个关键细节:铰链安全区的宽度不能写死。不同机型的铰链宽度不同,有的 4 毫米,有的 8 毫米,加上保护壳的边缘遮挡,实际可用区域差异很大。正确的做法是从系统读取铰链区域的几何数据,Kuikly 的窗口信息接口会返回这块区域的坐标和尺寸,然后动态计算安全区。
我在这块给团队定了一条硬性规范:所有可点击元件(按钮、标签、列表项)的中心点必须距离铰链区域至少 12 逻辑像素。这条规则我写成了代码检查脚本,CI 阶段自动扫描布局,防止以后有人不小心把按钮塞进铰链区。
3.4 双屏联动场景的实践:以运动 App 为例
布局和安全区解决之后,就是双屏设备的灵魂功能——跨屏联动。直接说我的实现方案:在双屏展开形态下,左侧屏是训练计划的列表,右侧屏是选中计划的动作详情和倒计时。用户点击左侧某个训练动作,右侧立刻更新对应内容。
实现思路是状态提升。左右两栏不能各自维护数据,必须把当前选中的训练计划放到父容器统一管理,然后向下传递给左右两栏。
class TrainingDetailPage : KuiklyComponent() { // 当前选中的训练计划 id var selectedPlanId: String by mutableStateOf("") override fun build(context: KuiklyContext) { if (DeviceFormManager.currentForm() == DeviceForm.DUAL_EXPANDED) { Row { PlanList( selectedId = selectedPlanId, onSelect = { id -> selectedPlanId = id } ) PlanDetail(planId = selectedPlanId) } } else { // 单屏形态:点击列表项后跳转详情页面 PlanList( selectedId = "", onSelect = { id -> navigateToDetail(id) } ) } } }这一个模式基本覆盖了绝大多数双屏联动场景,比如邮件 App 左侧列表右侧正文、股票 App 左侧自选股右侧分时图。单屏形态退回传统的跳转导航,展开形态自动升级为主从布局,用户不需要学习新的操作习惯。
我额外做了一个小优化:把联动状态持久化到本地存储。用户上次展开时选中了训练计划 A,下次再展开时自动恢复选中状态,不用重新点击。这在跨屏场景里非常提升好感,实现的成本也就是在状态变化时写一次缓存。
4. 排坑实录:这些双屏适配问题,我踩过你都别再踩
4.1 形态切换导致页面状态丢失
第一个坑是形态切换后页面状态丢失。现象很典型:展开态右屏显示着跑步记录详情,用户折叠回单屏,再重新展开,右屏变成了空白默认状态。排查后发现问题不在数据层,而是布局切换时组件被销毁重建了,组件内部的局部状态没保留。
解决思路分两步走。第一步,给所有需要保留状态的页面组件设置稳定的 key,让 Kuikly 在布局重建时复用同一个组件实例;第二步,把跨屏共享的 UI 状态(比如选中的训练计划 ID、列表滚动位置)提升到父容器或者全局状态层,不要放在组件内部。
4.2 悬停模式下的触摸区域错位
第二个坑出现在悬停模式。设备像帐篷一样立在桌面上,上下半屏各自独立触控。我把竖屏下的按钮坐标直接沿用了,结果下半屏的按钮点击区域和显示区域错位,点了没反应。原因是悬停态下系统可能会对触控区域做坐标变换,尤其是当设备有一定倾斜角度时。
解决这个问题的重点是不要依赖绝对坐标做点击判定,而是完全交给布局系统。所有可交互元件都用 Kuikly 的布局约束定位,不要手动计算 frame。另外在悬停态下,我把按钮的点击热区额外扩大了 8 逻辑像素,因为设备立在桌面上,手指点按的精确度会下降,扩大热区能明显提升操作成功率。
4.3 无障碍适配与字体缩放被遗漏
双屏设备对无障碍的需求更突出,因为用户可能一边拿着设备,一边依赖读屏软件跨屏操作。我们最初的适配方案完全没有考虑无障碍,结果读屏模式下,系统把左右两屏的内容当成两个独立的页面播报,用户根本搞不清上下文的联系。
修正方案是给跨屏的容器组件设置正确的语义分组。Kuikly 支持把左屏列表和右屏详情合并成一个语义单元,读屏时先播报列表内容,再播报详情内容,并且通过 live region 属性标记详情变化,让读屏软件在用户切换训练项时自动播报新内容。
字体缩放也是容易被忽视的点。双屏展开后屏幕变大,很多用户会顺手把系统字体调大。我用固定像素写图表标签的字体大小,大字体模式下文字直接溢出卡片。后来全部改成逻辑像素加最大行数限制,同时允许图表在溢出时显示省略号,总算把问题解决掉。
4.4 双屏适配常见问题速查表
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
| 展开后按钮点击失灵 | 按钮落入铰链触控盲区 | 将铰链区域纳入安全区体系,动态计算避让宽度 |
| 布局切换后滚动位置丢失 | 组件重建导致局部状态销毁 | 设置稳定 key,滚动状态提升到父组件 |
| 悬停模式点击错位 | 使用了绝对坐标计算点击区域 | 改用布局系统定位,扩大按钮热区 |
| 读屏跨屏上下文混乱 | 缺少语义分组 | 左右屏组件合并语义单元,标记 live region |
| 大字体模式下文字溢出 | 使用了固定像素字号 | 改用逻辑像素,加最大行数限制和省略号 |
| 双屏联动不同步 | 左右栏各自维护业务状态 | 状态提升到父容器,单向数据流传递 |
| 形态切换动画卡顿 | 布局重建过程中频繁测量 | 给 Flex 子项加过渡动画,避免强制同步布局 |
4.5 一个值得重视的性能问题
最后提一个容易埋雷的性能问题。双屏展开后,可见内容量大约翻倍,如果列表里的图片组件还沿用单屏时的加载策略,首帧渲染会出现明显卡顿。我的做法是在双屏形态下启用图片预加载,提前把虚拟列表范围内的图片缓存到内存。
另外,自绘引擎在双屏上的渲染压力比单屏大很多,建议把高频更新的区域(比如秒表计时器)和低频更新区域分离开,不要因为一秒刷新一次计时器,就把整个页面全部标记为 dirty。Kuikly 提供了局部重绘的机制,这项优化做好了,帧率稳定性会有质的提升。
5. 做完这次适配,我的一点体会
iPhone Duo 的到来其实不是第一个双屏设备,但很可能是第一次把双屏适配推进大众视野的设备。我在做这次适配时,最大的体会是:适配的本质不是让界面“适应屏幕”,而是重新理解用户在不同设备形态下的行为模式。同一份健身数据,用户在单屏折叠态下可能只是快速看一眼消耗;在展开态下可能希望同时查看训练计划和动作演示;在悬停态下可能希望把手机立在桌上跟着视频做动作。每一层都是新的交互设计机会。
从工程角度讲,Kuikly 帮我省掉了大量重复劳动。响应式布局、形态判断、安全区计算、跨屏状态同步,这些能力都内建在框架里,团队只需要专注业务逻辑。相比我们之前用原生代码维护两套适配方案,这次的开发效率确实提升明显。
最后分享一个小技巧:适配工作一定要从第一天就把形态变化列入测试用例。不要等界面全部写完再集中适配,而是每开发一个页面,就在单屏、展开、悬停三种形态下都过一遍,顺手把问题修复掉。这样看起来每一次开发都多花了一点时间,但整体周期反而是最短的。
双屏是大屏适配的进一步延伸,未来可能还有更多屏幕形态出现,但底层的适配方法论是相通的:感知形态、区分空间、状态同步、人性化交互。把这套基本功练好,无论后面出什么新形态设备,你都不会慌。