[控场AI]
· 11 分钟阅读· 5,662 字

Compose文本编辑新范式:TextFieldState取代旧API

Compose文本编辑新范式:TextFieldState取代旧API

Compose 1.13 弃用旧文本输入回调 API,TextFieldState 以同步快照系统解决异步输入问题,并带来输入过滤、输出变换、状态提升与格式化文本编辑新方案。

Jetpack Compose 团队宣布旧版 value/onValueChange 文本输入 API 将于 1.13 弃用,推荐迁移至 TextFieldState。旧 API 在与 StateFlow 等异步流结合时,因无法同步响应 IME 而导致字符丢失、重复和光标跳动。TextFieldState 在用户输入时同步更新,内部由快照系统支撑,使 BasicTextField 可在布局/绘制阶段才读取状态,消除不必要重组。编程式修改通过 edit 函数与 TextFieldBuffer 完成;InputTransformation 在提交前拦截过滤并支持变更列表精确拒绝输入;OutputTransformation 自动处理视觉格式化的双向偏移映射。文章给出四种状态提升迁移模式:局部保留、提升至共同祖先、状态持有者对象、提升至 ViewModel,并特别提醒勿将可变 TextFieldState 放入不可变 UI 数据类。Compose 1.12 起还提供格式化文本编辑 API,包括展开策略与 TrackedRange 实时句柄,支撑所见即所得、模式化样式和搜索高亮。团队正在开发 Markdown 增量解析库。

在Android开发中,文本输入框是登录页、搜索栏、聊天应用等几乎所有交互场景的核心。然而,传统基于value和onValueChange回调的API在处理异步数据流时,常出现字符丢失、重复输入和光标跳动等问题。Jetpack Compose团队在近期技术分享中系统介绍了新的TextFieldState API,并宣布旧回调式API将于Compose 1.13起弃用。

旧API的困境:异步输入与IME的冲突

过去,Compose提供两种文本输入API:一种接收value和onValueChange回调,另一种接收TextFieldState对象。前者在同步输入场景下表现良好,但一旦与StateFlow等异步流结合,就会与输入法编辑器(IME,即软键盘)产生时序冲突。

旧API与键盘的冲突场景

这种冲突表现为用户输入字符被丢弃、重复,或光标意外跳转。根本原因在于,onValueChange回调需要等待异步往返完成后才更新UI状态,而IME期望立即同步响应。

输入法编辑器(IME)与文本输入时序

IME是操作系统提供的软键盘服务,它通过输入连接(InputConnection)与编辑框通信。当用户按键时,IME会立即向应用发送编辑操作(如插入字符、删除字符、更新 composing text),并期望应用在同一个帧内同步返回更新后的文本和光标位置,以便渲染 composing region、候选词和光标。如果应用延迟响应(例如等待异步回调返回),IME 会认为编辑未成功,可能导致重复发送、丢弃输入或光标位置错乱。Compose 旧版 value/onValueChange API 正是受制于此:UI 状态更新要经过异步往返,无法满足 IME 的同步期望,从而产生字符丢失、重复和光标跳动。理解这一时序约束,有助于认识 TextFieldState 同步更新的必要性。

TextFieldState:同步更新与快照系统

TextFieldState是Compose 1.7起稳定提供的解决方案。它在用户输入时同步更新,无需等待异步往返,恰好满足IME的同步需求。除了只读的文本属性外,TextFieldState还封装了活动编辑会话、选择范围、撤销/重做状态,以及用于格式化文本的span样式和段落样式。

其内部属性由Compose快照系统支撑。当在Compose中读取文本或选择范围时,读取作用域会自动订阅更新。这使基础BasicTextField得以在布局或绘制阶段才读取状态,从而消除用户输入时不必要的重组,显著提升性能。

编程式修改:TextFieldBuffer与edit函数

由于text是只读属性,编程式修改需通过TextFieldState.edit函数进行。该函数提供一个TextFieldBuffer,暴露插入、替换、删除等操作。一个实用技巧是:避免连续多次调用edit,而应将所有操作合并到单次edit调用中,这样Compose只创建一次缓冲区实例,并同步一次IME和快照系统。对于常见的批量操作,Compose还提供了扩展函数,例如设置文本并将光标置于末尾。

输入过滤:InputTransformation在提交前拦截

旧API中,过滤逻辑在onValueChange中执行,此时文本已提交,拒绝或修改输入会与键盘“打架”,且开发者只能拿到最终字符串,需做全量差异比对。新API引入InputTransformation,在用户输入到达TextFieldState之前进行拦截,并提供相同的TextFieldBuffer API。

输入变换与状态持有者

其关键能力是变更列表(change list):开发者可检查精确的编辑范围,就地修改或整体拒绝输入,Compose会自动保持键盘和光标同步。例如实现仅允许数字的过滤时,可检查变更中是否含非数字字符,若有则调用revertAllChanges拒绝输入。

输出变换:自动处理双向偏移映射

对于信用卡号、电话号码等视觉格式化需求,旧VisualTransformation API要求开发者手动实现原始输入到视觉输出的双向映射,极易出错并引发运行时异常。新的OutputTransformation同样基于TextFieldBuffer,开发者只需在指定索引调用插入函数,系统会自动计算双向偏移映射,大幅减少手动计算和崩溃。

Compose 快照系统与读取作用域

快照系统是 Compose 实现细粒度状态观察的基础设施。它维护多份状态快照,允许在不同快照中读写状态而不互相干扰。当 Composable 在组合、布局或绘制阶段读取某个状态对象时,当前读取作用域(如重组作用域、布局作用域)会自动订阅该状态。状态变更时,只有订阅了该状态的作用域会被通知,从而只触发必要的重组、布局或绘制。TextFieldState 的内部属性由快照系统支撑,因此 BasicTextField 可以在布局/绘制阶段才读取文本和选择范围,避免每次击键都触发组合阶段的重组,显著提升性能。这也解释了为何通过祖先 Composable 传递 TextFieldState 不会导致父级重组——只有实际读取文本的位置才会更新。

状态提升的四种迁移模式

实践中,几乎每个用例都归属于以下模式之一,这些示例也可指导迁移。

模式一:局部保留

最简单的场景是自包含输入框,如促销码输入,仅在提交时读取文本。此时在Composable内调用rememberTextFieldState即可,它还能帮助进程死亡后的状态恢复。过滤通过InputTransformation完成,提交时从状态对象取文本。

模式二:提升至共同祖先

当兄弟UI元素(如字符计数器或提交按钮)需要读取或修改文本时,将TextFieldState提升到共同祖先Composable。由于快照系统支撑,通过祖先传递不会触发父级重组,只有实际读取文本的位置会更新。

模式三:状态持有者对象

复杂UI场景下,若输入变换和输出变换需要协调内部状态(如内联话题标签),应将它们与TextFieldState一起打包进状态持有者对象。例如标签编辑器状态可复用标签范围等中间计算结果,再整体传给Composable。

模式四:提升至ViewModel

当业务逻辑需要访问文本(如搜索即输或复杂校验),可将TextFieldState提升至ViewModel。关键注意点:不要将可变文本对象放入不可变数据类的UI状态中,因为copy方法会在每次变更时发出新实例,而TextFieldState的引用永不改变,StateFlow甚至感知不到用户输入;更糟的是,若新建UI状态时分配新状态实例,输入框会丢失选择、光标和历史。正确做法是将其作为ViewModel的直接属性。TextFieldState不引用Android视图、Context或Compose生命周期,概念上与StateFlow无异。但ViewModel没有rememberSaveable,需手动为TextFieldState配置Saver以在进程死亡后恢复。

若团队有严格模块规则(ViewModel仅存在于纯Kotlin模块),或迁移复杂遗留屏幕时无法立即重构UI状态,可采用兼容方案:在Composable中保留TextFieldState作为单一事实来源,用launchEffect配合snapshotFlow将文本变更推回现有ViewModel。需注意发射首个事件,避免重复搜索查询或用初始值覆盖ViewModel。若逻辑需反馈变更(如提交后清空文本),不能直接向ViewModel设空字符串,而应发射清空文本事件,由持有状态对象的Composable调用清空。

为什么 TextFieldState 引用不变会让 StateFlow 失效

TextFieldState 是可变的单一事实来源对象,其引用在创建后永不改变;用户输入改变的是其内部属性(如 text 字段),而不是替换整个对象。StateFlow 通过比较新旧值(使用 equals)来决定是否发射新值。如果将一个不可变 UI 数据类放入 StateFlow,并在每次文本变更时用 copy 生成新实例,则 StateFlow 会发射新值——但这会重建 UI 状态,并可能意外创建新的 TextFieldState 实例,导致选择、光标和历史丢失。反之,若将 TextFieldState 本身作为 ViewModel 的直接属性,由于引用不变,StateFlow 感知不到内部文本变化,因此不能依赖 StateFlow 来观察文本。正确做法是:将 TextFieldState 作为 ViewModel 的直接属性,在需要观察文本时使用 snapshotFlow { textFieldState.text } 或直接在 Composable 中读取状态。若必须将文本放入 UI 状态流,应避免在每次变更时分配新的 TextFieldState 实例。

格式化文本编辑:样式跟踪与展开策略

上述TextFieldState、TextFieldBuffer、输入/输出变换不仅是纯文本输入的基础,也支撑了最新的格式化文本编辑API(Compose 1.12起可用)。

静态文本通常用AnnotatedString配合addStyle实现样式,但其不可变特性在编辑场景下需为每次击键分配新对象及其大量范围对象,开销巨大。

每次击键的样式范围重定位

新API在TextFieldBuffer中提供类似的addStyle,并引入展开策略(ExpandPolicy)参数,控制样式边界处输入文本的行为:InsideOnly不扩展样式;AtStart在范围起始处扩展;AtEnd在范围末尾扩展(编辑器最常用);AtBoth两端均扩展。样式范围会随编辑自动更新——在样式前插入文本时范围前移,内部插入时扩展,删除时收缩,全部删除时样式折叠并自动移除。

查询与TrackedRange:实时样式句柄

TextFieldState提供只读的getSpanStyles和getParagraphStyles查询,返回不可变的AnnotatedString.Range列表,且顺序感知,支持叠加样式覆盖。TextFieldBuffer的getSpanStyles则返回TrackedRange对象,可视为缓冲区中样式范围的实时句柄,支持就地更新范围、展开策略或样式本身,并通过isValid属性检查范围是否仍存在。

TrackedRange的有效性检查

三个真实用例

所见即所得编辑器中,点击加粗按钮时先查询选区重叠样式,判断是否已全部加粗;若未加粗则添加样式并使用AtEnd策略,便于继续输入;若已加粗且选区位于更长加粗范围内,可用TrackedRange就地调整范围末尾,而非删除重建。

基于模式(pattern-based)的样式中,输入双星号包裹单词时,InputTransformation查找匹配模式、提取内容、移除星号并调用addStyle应用加粗,样式持久写入状态。

搜索高亮场景则适合放在OutputTransformation中:查找所有匹配并调用addStyle绘制黄色高亮,查询本身是可变状态,快照系统会在其更新时自动重新应用输出变换,而原始文本状态不被修改。

AnnotatedString 与样式范围的开销

AnnotatedString 是 Compose 中表示带样式文本的不可变数据类,它由一个纯文本字符串和一组 Range(标注范围)组成,每个范围记录起始索引、结束索引和样式项(如 SpanStyle 或 ParagraphStyle)。在静态展示场景下,这种不可变结构高效且线程安全。但在文本编辑场景中,每次击键都会改变文本长度,导致所有后续样式范围的索引需要重新计算;同时,不可变特性迫使为每次击键分配新的 AnnotatedString 及大量新的 Range 对象,造成频繁的内存分配和垃圾回收压力。这正是新格式化文本编辑 API 在 TextFieldBuffer 中提供可变 addStyle 和 TrackedRange 实时句柄的动机:通过就地更新样式范围,避免每次击键重建整个带样式文本结构。

展望:Markdown增量解析库

团队透露正在开发面向Jetpack Compose的标记文本库,支持Markdown等富文本格式,核心能力是增量解析与增量渲染,非常适合实时编辑或Markdown流式场景,目前处于原型阶段并欢迎开发者反馈用例。

对于仍在使用旧回调API的团队,官方已提供迁移指南,并正在开发额外API以简化迁移和状态变更观察。

#Jetpack Compose#TextFieldState#Android文本输入#Compose文本编辑#InputTransformation#格式化文本编辑#Compose迁移
分享:

相关推荐