从 View 体系切到 Jetpack Compose 之后,我花了很多时间研究整个声明式 UI 里最经常出现、也最容易被低估的组件:文本。每次内部评审或帮同事复盘时,我都习惯问一个问题——Compose 的 Text 和 TextView 到底差在哪。能答得清楚的开发者,基本对声明式 UI 已经有比较深的体感。这篇文章不是 API 手册,而是把我实际开发中关于 Compose 文本与样式的大量细节、取舍和踩坑记录整理成一份可复用的参考,不管是刚入门 Compose 的新手,还是已经写了几个模块、正在为样式细节头疼的朋友,都可以直接对照着用。
1. 为什么说 Compose 的 Text 不是换了个名字的 TextView
很多从 View 体系迁过来的同学,第一反应是把TextView的用法往Text上套。表面看确实像:设置文字、设置大小、设置颜色、设置字体,功能都差不多。但底层的渲染逻辑、状态管理方式、样式合并机制完全换了一套玩法,如果不理解这个底层差异,后面碰上奇怪的布局问题和性能问题就会很被动。
1.1 声明式重组对文本渲染的改变
在 View 体系里,TextView是一个持有内部状态的对象,你调用setText()、setTextSize()、setTextColor(),它内部会触发invalidate(),然后走一次 measure、layout、draw 流程。这个模式是命令式的:你想让文本变成什么样,就主动去调对应的方法,UI 的最终状态取决于你上一次调了什么方法。
Compose 的Text是一个@Composable函数。它不持有可变状态,而是接收参数,根据参数生成对应的TextLayoutResult,最终交给绘制层去渲染。每次参数变化,Compose 会按需重组,但不会把整个Text内部的绘制状态全部重置,它只更新跟参数关联的那一部分。举个例子:
@Composable fun Greeting(name: String) { Text( text = "Hello $name", fontSize = 18.sp ) }当name从 "Tom" 变成 "Jerry",Compose 只会把参与文本测量的那部分状态重算,fontSize等与文本内容无关的样式参数不会被重复计算。这个细粒度的重组能力,是原来TextView做不到的。代价是:如果我们在Text的参数里塞了一个不是稳定的对象,比如每次重组都新建一个TextStyle,Compose 就会被迫重新测量和布局,性能就会退化。
所以在 Compose 里写文本,要时刻提醒自己一件事:不要在主线程的 composition 里做大量的字符串拼接或者对象创建,尽量把数据层面的文本内容用State持有,让重组只发生在真正需要的层级。
1.2 Text 的可组合项拆分与基础用法
Text的完整参数远不止一个text和style。我看过很多项目的代码,常见的是写了几十个参数堆在一个Text上。实际上,Compose 官方把文本相关的功能拆成了多个入口,选错了入口不仅是风格问题,有时还会影响性能。
一般来说,我们常用的是这几个:
Text:纯静态文本,适合固定内容或从外部读取的普通字符串。AnnotatedString版本的Text:富文本场景,局部颜色、字体、点击事件都靠它。TextField/BasicTextField:输入框场景,跟纯文本展示是两个体系。ClickableText:带局部点击区域的文本。SelectionContainer:支持用户选择文本内容的容器。
基础用法很直观:
@Composable fun BasicTextDemo() { Text( text = "Compose 文本与样式", color = Color(0xFF333333), fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 24.sp, maxLines = 2, overflow = TextOverflow.Ellipsis ) }这里有一个很容易被忽略的点:Text直接设置颜色、字号,本质上是在帮你构造一个TextStyle,再传给内部实现。所以如果同一个页面里有多个文本的样式是重复的,更合理的方式是把它抽成一个TextStyle常量或者放进MaterialTheme.typography,而不是在每个Text上重复写参数。这样后续统一调整字号、颜色时会方便很多,也能减少无意义的对象分配。
2. 文本样式参数:从 setTextSize 到 fontSize 的心智切换
Compose 的文本样式参数看起来比TextView更规整,很多名字一眼就能看懂,但实际用起来,有些单位、默认值和边界行为跟 View 时代差别很大。我见过不少人在这一步踩坑,所以要单独拎出来讲。
2.1 字号、行高、字间距的那点换算关系
先说字号。Text的fontSize默认单位是sp,这个跟TextView的setTextSize()一致,会自动跟随系统的字体缩放。但 Compose 内部其实不用sp做计算,它会通过Density把sp转换成像素值参与测量。如果在自定义绘制的场景里拿到的是像素值,不要直接把它当成sp回填给Text,要先除以density.fontScale,否则用户在系统里调大字体后,你的布局会乱掉。
行高是最容易出问题的地方。TextView有includeFontPadding这个属性,默认是 true,会在文字上下额外加一点内边距,这是为了避免某些字体绘制时裁掉笔画。Compose 里lineHeight的行为跟TextView不一样:它直接指定每行文本所占的高度,不会再额外加fontPadding。对于同样字号的中文文本,Compose 默认渲染出来的行高通常比 View 体系下更紧凑,如果你在从老项目迁移 UI,经常要对lineHeight做一次手动校准。
字间距letterSpacing的单位是em,不是px,也不是sp。1em等于当前字号的大小,所以letterSpacing = 0.5.em表示半个字符宽度的间距。很多人拿 View 时代的letterSpacing(单位也是 em)来套,数字是一致的,但要小心负值,比如标题做紧凑效果时,负的 letterSpacing 可以收紧字符间距,在 Compose 里同样支持。
我把常用参数在两个体系里的对应关系整理成了表格:
| 作用 | TextView 参数 | Compose 参数 | 单位差异 |
|---|---|---|---|
| 字号 | textSize | fontSize | 都是 sp |
| 颜色 | textColor | color / style.color | 无 |
| 行高 | lineSpacingExtra / includeFontPadding | lineHeight | 无 fontPadding |
| 字间距 | letterSpacing | letterSpacing | 都是 em |
| 字重 | textStyle | fontWeight | 数值范围一致 |
| 字体 | typeface / fontFamily | fontFamily / fontResource | 自定义方式不同 |
| 对齐 | gravity | textAlign | 语义不同 |
| 划线 | paintFlags / getPaint() | textDecoration | 更直接 |
2.2 字体族与字重:系统字体和你自定义字体的博弈
fontFamily这个参数,看似简单,实际坑不少。Compose 内置了FontFamily.SansSerif、FontFamily.Serif、FontFamily.Monospace、FontFamily.Cursive和FontFamily.Default。默认情况下,Android 系统的SansSerif在不同厂商 ROM 上可能指向不同的字体文件,所以如果你的 UI 对字体样式要求很严格,不要依赖系统默认值。
另一个常见坑是字重不生效。很多中文字体或者开源字体,整个文件只提供一种字重,即使你在fontWeight = FontWeight.Bold设置了加粗,系统也没法把它变成真正的粗体。这时候系统会做一步 fake bold,也就是把字形往外描一圈。在 View 体系里,TextView的setTypeface会做类似处理,但 Compose 的行为是:如果字体文件本身不支持当前字重,它会尝试最近的可用字重,找不到就合成。这会导致两个问题:一是加粗效果在不同机型上看起来不一样;二是文本宽度变了,可能影响其他布局。
自定义字体时,要把多个字重分别放进res/font目录,然后用FontFamily把它们组织起来:
val myFontFamily = FontFamily( Font(R.font.myfont_regular, FontWeight.Normal), Font(R.font.myfont_medium, FontWeight.Medium), Font(R.font.myfont_bold, FontWeight.Bold) )这样设置fontWeight时,系统会优先选择对应的字体文件,而不是靠合成。
2.3 文字对齐与方向:textAlign、textDirection 的真实行为
textAlign是大家很熟悉的对齐属性,但 Compose 里的行为在一些边界条件下跟直觉不一致。比如,对于多行文本,TextAlign.End只影响段落的末尾行,不是让整个文本块右对齐那么简单的理解。更常见的是:当文本只有一行时,textAlign默认值是TextAlign.Start,它会跟着textDirection走;而如果你把textAlign设置成Center,但文本没有占满整行,它会在行的范围内居中。
真正让人头疼的是,textAlign本身只对段内每一行生效,不会改变段落整体在一个宽Box中的位置。如果你想让整个文本块居中,正确的做法是给Text设置textAlign = TextAlign.Center,同时让Text的宽度撑满父容器,比如用Modifier.fillMaxWidth()。只设置textAlign,不设置宽度,文本的宽度是由内容决定的,对齐效果就体现不出来。
textDirection是另一个容易被忽略的参数。对于纯数字、英文文本,默认的Ltr方向通常没问题,但遇到混合文本或某些地区的阿拉伯语、希伯来语时,不设置textDirection会导致顺序错乱。建议在涉及多语言的项目里,根据Locale显式指定:
Text( text = localizedText, textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr )3. 富文本能力:AnnotatedString 才是样式层的重头戏
普通字符串加一个统一的TextStyle,只能满足最简单的展示需求。真实项目里,“一段文字里关键词标红”“协议内容前几句可点击”“标题里某个字用特殊字体”,这些需求比比皆是。Compose 处理这些场景的核心就是AnnotatedString。
3.1 SpanStyle 与 ParagraphStyle 的分工
AnnotatedString由三部分组成:text是原始字符串,spanStyles是作用在字符串某个区间上的行内样式,paragraphStyles是作用在段落上的样式(如文本对齐、行高、段间距等)。
SpanStyle管的是行内样式,类似TextView里的SpannableString+ForegroundColorSpan、StyleSpan的合集。它可以设置颜色、字体、字重、字号、文字装饰、阴影等。
ParagraphStyle管的是段落级样式,类似LeadingMarginSpan、LineHeightSpan的效果。它支持:
ParagraphStyle( textAlign = TextAlign.Center, lineHeight = 24.sp, textIndent = TextIndent(firstLine = 12.sp) )这两者的核心区别是作用粒度:SpanStyle可以精确到几个字符,ParagraphStyle只作用于完整段落。如果想把一个段落的某个词居中,这是做不到的,因为居中是一个段落行为,你只能把那个词单独拆成一段。
3.2 点击局部文本与动态拼接的实现思路
富文本的典型场景是“用户协议里,前一句是普通文字,后一句‘点击查看协议详情’可以点击”。用AnnotatedString实现这个很直接:
val annotatedString = buildAnnotatedString { append("我已阅读并同意") withStyle(SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline)) { append("《用户协议》") } } ClickableText( text = annotatedString, onClick = { offset -> val annotation = annotatedString.getStringAnnotations( tag = "PRIVACY", start = offset, end = offset ).firstOrNull() if (annotation != null) { // 点击的是协议部分 } } )注意我在这里用了ClickableText,不是普通的Text。ClickableText会把onClick回调的偏移量传给代码,这样你就可以判断用户到底点在了哪里。如果直接用Text加Modifier.clickable,你只能知道整个文本被点击了,拿不到具体是哪个字符。
还有一种动态拼接的场景:数据从后端返回,里面包含多个需要不同样式的片段。这时候用buildAnnotatedString配合循环去构造,注意保留 tag 或自定义SpanStyle,方便后续判断。如果片段很多,千万不要在 composition 里反复调buildAnnotatedString,而是把它放到remember中,只在数据变化时重新构建。
3.3 文本样式合并的优先级规则
当AnnotatedString里的SpanStyle与外层Text的TextStyle同时存在时,到底谁生效?这个规则很多人搞不清楚。简单说:AnnotatedString里的SpanStyle优先级更高,未在SpanStyle中显式设置的属性,会回退到外层Text的TextStyle。
举例说明:
Text( text = buildAnnotatedString { append("普通文本") withStyle(SpanStyle(fontSize = 20.sp)) { append("变大") } }, color = Color.Red, fontSize = 14.sp )这段代码里,所有文字的颜色都是红色;但“变大”两个字字号是 20.sp,普通部分字号是 14.sp。因为SpanStyle只设了字体大小,颜色属性没有覆盖,所以沿用了外层Text的颜色。反过来,如果SpanStyle里显式写了color,那这个局部颜色会覆盖外层颜色。
实际开发中还有一个坑:多个SpanStyle区间重叠时,系统会要求区间不能有歧义。你如果给同一段文字设置两次区间重叠的SpanStyle,编译器不会报错,但运行时可能出现样式异常。所以在构造富文本时,尽量保证区间不重叠,或者用addStyle时注意顺序。
4. 自定义字体:字体文件、Google Fonts 与可变字体
文本样式做到一定程度,就会开始折腾字体。系统默认字体虽然稳定,但品牌感不足,很多产品的标题需要一个更有辨识度的字体。Compose 对自定义字体的支持比 View 体系理解起来更顺,但也有一些细节值得注意。
4.1 res/font 与 FontFamily 的定义方式
把字体文件放到app/src/main/res/font目录下,然后通过FontFamily引用:
val AppFontFamily = FontFamily( Font(R.font.app_regular, FontWeight.Normal), Font(R.font.app_medium, FontWeight.Medium), Font(R.font.app_bold, FontWeight.Bold), Font(R.font.app_italic, FontWeight.Normal, FontStyle.Italic) )然后在MaterialTheme中统一设置:
MaterialTheme( typography = Typography( titleLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 22.sp), bodyMedium = TextStyle(fontFamily = AppFontFamily, fontSize = 14.sp) ) )这里有个小技巧:如果同一个字体文件对应多个字重,但你只有一个文件,可以给同一个Font声明多个字重,让它走系统合成加粗。但前面说过,这样效果不稳定,最好的方式还是找设计团队提供对应的regular、medium、bold字重文件。
另外,字体资源文件名只能用小写字母、数字和下划线,不能有大写字母。我见过有人把MyFont.ttf放进去,编译直接报错,查了半天才发现是命名问题。
4.2 可变字体(Variable Font)的初体验
相比传统的多个字重文件,可变字体(Variable Font)在安卓生态里越来越常见。一个文件内包含字重、宽度、斜体等多个轴,可以在运行时动态调整。
Compose 通过FontVariation.Settings来设置可变字体的轴:
val variableFont = Font( resId = R.font.my_variable_font, variationSettings = FontVariation.Settings( FontVariation.weight(600), FontVariation.width(90) ) )这种方式省去了多个文件占用的体积,而且调整字重非常平滑。不过要注意:可变字体需要 API 26 以上才能支持轴设置,低版本需要做降级处理。国内应用如果 minSdk 还在 24 以下,建议先用传统方案,等 minSdk 抬高后再上可变字体。
4.3 下载字体与网络字体的取舍
Google Fonts 提供了在线下载字体的方案,com.google.android.gms:play-services-fonts配合FontFamily可以直接用:
val fontFamily = FontFamily( Font( googleFont = GoogleFont("Roboto"), fontProvider = GoogleFont.Provider(...) ) )但国内网络环境对 Google 服务的可用性一直是老大难问题,而且下载字体是异步的,会出现先显示默认字体、后跳变到目标字体的情况,用户感知比较明显。我建议:如果能通过产品设计确认字体是固定的,直接打资源包;只有在字体会动态变化、且资源包过大的场景才考虑下载字体。离线优先永远是最稳妥的方案。
5. 文本测量的坑与性能实践
文本展示不是简单地画几个字符,测量阶段如果出了问题,视觉上会比崩溃更让人头疼。下面这几个问题我是在真实项目里反复踩到的,特意记录在这里。
5.1 maxLines、overflow 和软键盘弹出后的布局问题
maxLines和overflow是处理长文本的标配。maxLines限制显示行数,overflow决定超出部分怎么处理。
常见写法:
Text( text = longText, maxLines = 2, overflow = TextOverflow.Ellipsis )但这里有个隐藏问题:TextOverflow.Ellipsis只在maxLines生效时起作用。如果你只设置overflow = TextOverflow.Ellipsis但没有设置maxLines,文本不会被截断,也不会出现省略号。原因是未限制行数时,文本会尽量显示完整,溢出处理策略没有意义。
另一个场景:底部输入框弹出软键盘后,文本区域的可用高度变小,如果文本设置了maxLines = 3,它会被截断成 3 行。但如果文本高度被压缩到不足 3 行,系统可能会把原来的 3 行压缩成 2 行,这时省略号出现的位置就变得不可预测。解决方案是给文本容器设置一个稳定的高度,或者在布局变化时重新计算maxLines。
5.2 组合本地文本与位置计算
有些需求拿到Text内部某些字符的坐标,比如在文本上画一个高亮背景,或者定位某个关键词的点击区域。Text的onTextLayout回调会返回TextLayoutResult,里面有详细的行、字符合法信息和坐标信息:
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) } Text( text = text, onTextLayout = { layoutResult = it } ) // 获取某个偏移量所在的行和字符坐标 val offset = 10 val lineIndex = layoutResult?.getLineForOffset(offset) ?: 0 val x = layoutResult?.getHorizontalPosition(offset, usePrimaryDirection = true) val y = layoutResult?.getLineTop(lineIndex)用这个能力可以做关键词高亮、文本阅读位置追踪、甚至简单的代码编辑器光标定位。需要注意:onTextLayout只在文本内容或样式变化时回调,不要在回调里做耗时操作,否则会拖慢重组。
5.3 性能:避免不必要的重组与文本缓存
Compose 文本性能问题,大部分不是绘制慢,而是重组范围太大。常见错误是:父组件状态频繁变化,子组件里的文本跟着频繁重建。解决办法是给Text所在的Composable加key()或者把文本数据提升到上层,尽量让文本层保持稳定。
对于长文本,比如详情页大段内容,可以配合remember缓存AnnotatedString:
val cachedAnnotatedText = remember(data) { buildAnnotatedString { ... } }这样只有data变化时才会重建富文本对象,否则每次重组都是复用同一个实例。另外,TextStyle最好也定义为val或放进Companion,避免每次重组都 new 一个。
6. 实测总结:我在文本样式上踩过的几个具体坑
最后这部分,把我实际开发中遇到次数最多、最容易被忽视的几个坑集中列一下,每个都是拿真机验证过的。
6.1 行高不一致问题
中文和英文混排时,同一段文字里英文的上下留白和中文不一样,导致多行文本看起来行距不均匀。我在一个商品详情页遇到过:标题用中文字体,里面含有个别英文品牌名,结果在部分 Android 10 机型上英文部分被轻微裁掉,看不到英文的下降部(比如字母 g、y)。排查后发现是自定义字体的lineHeight没有覆盖全字符集导致的。
解决方式:统一显式设置lineHeight,并且可以把PlatformTextStyle(includeFontPadding = false)加上,减少默认字体内边距的影响。如果使用高度自定义字体,还要检查Font的lineHeight是否有对应的字体度量。
6.2 粗体导致布局宽度变化
有一个悬浮按钮,文字是“确认支付”,在普通状态和加载状态之间切换,加载状态文案变成“支付中”,同时业务要求加粗。结果按钮宽度一变,旁边图标被挤开,整个布局抖动了一下。问题根源是fontWeight变化后,文本测量宽度变化了,而父容器没有固定宽度。
解决方式有两种:一是给文本设置固定宽度;二是使用Modifier.weight让文本在可用空间内自适应,避免撑破布局。更稳妥的是在切换状态前先测量两个文本的宽度,取最大值作为容器最小宽度。
6.3 文字不垂直居中
这是新手最容易困惑的一点。在 Compose 里,文字在Box中不垂直居中,大部分时候不是布局问题,而是行高和字体基线导致的视觉偏差。Text的高度是行高决定的,行高里包含上间距和下间距,中文字体在不同字符组合下,基线位置并不完全一致。
处理方式:先用Text的style里设置lineHeight,再通过Modifier.wrapContentHeight(align = Alignment.CenterVertically)让它居中。如果仍然偏差,需要检查是否自定义字体带来了额外的 ascent/descent 偏移。对于要求严格居中的场景,可以用onTextLayout拿firstBaseline和lastBaseline做手动微调,虽然麻烦,但效果可控。
我在实际项目里见过不少团队因为文本垂直居中问题,被迫把文本包进一个固定高度的Box里,再用Modifier.offset硬调位置。这样做一旦字号或字体变化,位置全乱。建议从一开始就养成统一设置lineHeight的习惯,不要完全依赖默认值。
Compose 的文本与样式体系,表面上是简单的参数调用,深入到一定程度后会发现它是一整套文本排版引擎的封装。把上面的这些细节梳理清楚,你在日常开发中至少能少走一大半弯路。希望这篇整理能帮你把文本这块从“能用”推进到“用得稳”。