
从 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 变成 JerryCompose 只会把参与文本测量的那部分状态重算fontSize等与文本内容无关的样式参数不会被重复计算。这个细粒度的重组能力是原来TextView做不到的。代价是如果我们在Text的参数里塞了一个不是稳定的对象比如每次重组都新建一个TextStyleCompose 就会被迫重新测量和布局性能就会退化。所以在 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 参数单位差异字号textSizefontSize都是 sp颜色textColorcolor / style.color无行高lineSpacingExtra / includeFontPaddinglineHeight无 fontPadding字间距letterSpacingletterSpacing都是 em字重textStylefontWeight数值范围一致字体typeface / fontFamilyfontFamily / fontResource自定义方式不同对齐gravitytextAlign语义不同划线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里的SpannableStringForegroundColorSpan、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 { mutableStateOfTextLayoutResult?(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缓存AnnotatedStringval 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 的文本与样式体系表面上是简单的参数调用深入到一定程度后会发现它是一整套文本排版引擎的封装。把上面的这些细节梳理清楚你在日常开发中至少能少走一大半弯路。希望这篇整理能帮你把文本这块从“能用”推进到“用得稳”。