本文译自「StyledString: A Better Pattern for Rich Text in Jetpack Compose」,原文链接proandroiddev.com/styledstrin...,由Eury Pérez Beltré发布于2025年7月14日。

在 Jetpack Compose 中设置文本样式看似简单......但其实不然。在本文中,我们将探讨 AnnotatedString 的局限性,以及 StyledString 如何让富文本更易于管理。让我们来详细分析一下。👇
📚 目录
- 引言:一个粗体字、一个链接,以及一大堆麻烦
- AnnotatedString:样式过多,简洁性不足
- StyledString 简介:一个 API 即可设置所有样式
- StyledString 底层原理:API 背后的引擎
- 结语
引言:一个加粗的单词、一个链接,以及一大堆麻烦
一开始,你拥有了 AnnotatedString 和一个 SpanStyle ,一切看起来都很顺畅。你想加粗一个单词?很简单✅。给某个东西加下划线?没问题。这甚至感觉有点有趣,尤其是在你像官方文档中那样手动构建整个字符串的时候。
但问题是:🧠
当你完全控制字符串时,这种方法非常有效。但当你处理实际内容:动态副本、本地化文本、从其他地方传入的段落,而你只需要设置其中一部分的样式时?
事情很快就变得很糟糕。
突然间,你需要跟踪子字符串、计算索引、应用样式,并连接点击监听器。只需对文本进行一次更改,你的逻辑就会像纸牌屋一样崩溃。🃏
你原本想要的只是加粗一个单词并让链接可点击。现在你深陷于样板代码中,祈祷一切都不会改变。
在这篇文章中,我将解释为什么 AnnotatedString 在实际 UI 中无法很好地扩展,并介绍一个我为了解决这个问题而构建的微型抽象。它叫做 StyledString,它的功能非常强大:💡 它确实做到了:
让Compose 中的文本样式再次变得简单。
AnnotatedString:样式太多,简洁性不足
首先,让我们称赞一下 AnnotatedString。它是一款强大的工具💪.
你可以使用一个 Text 可组合项来创建带样式、可点击、可交互的文本。想要让一个单词加粗,另一个单词像链接一样显示?完全可以。该 API 灵活、底层,并且由 Compose 本身的富文本引擎支持。
问题是,它只有在手动构建整个字符串时才能发挥最佳效果。
文档中的大多数示例如下所示:
Kotlin
buildAnnotatedString {
append("Hello ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append("world")
}
}
看起来不错,对吧?但棘手的地方就在这里。👀
如果你有一整段动态文本,比如一段本地化的字符串或一个从其他地方拉取的句子,而你只想为其中的部分内容添加样式,该怎么办?
现在你需要处理:
- 查找要添加样式的子字符串
- 计算起始和结束索引
- 手动添加样式或注释
- 希望文本永远不变,否则一切都会崩溃。
如果你需要多种样式,比如粗体单词、可点击的电子邮件和带下划线的 URL,动态地,事情很快就会变得混乱。🔥 这时,buildAnnotatedString 就会变成一堆脆弱的索引数学运算、重复的逻辑和样板代码,难以阅读,更难以维护。
当然,AnnotatedString 功能强大。但当你的文本是动态的,而你只想为其中的部分内容添加样式时?它很快就会变得索然无味。
StyledString 隆重出场:一个 API 即可实现所有样式
在与 AnnotatedString 纠结了无数次之后,我决定构建一个更好的东西。它并非一个庞大的库,也不是一个完整的样式框架。而是一个简单、兼容 Compose 的抽象,用于解决一个非常具体的问题。
StyledString 来啦!👋
它的目标很简单:让你定义字符串的哪些部分应该被设置样式或可点击,而无需担心 indexOf 、 addStyle 或 AnnotatedString.Builder 。你只需编写文本,告诉它需要设置哪些单词的样式,以及点击后该执行的操作。
它的实际效果如下:
Kotlin
// This list can be built in the ViewModel
val styledStrings = persistentListOf(
StyledString.ClickableEmail(
highlightedText = "support@example.com",
email = "support@example.com",
style = SpanStyle(
color = Color.Blue,
textDecoration = TextDecoration.Underline
)
),
StyledString.ClickableUrl(
highlightedText = "website",
url = "https://euryperez.dev",
style = SpanStyle(
color = Color.Blue,
textDecoration = TextDecoration.Underline
)
)
)
// In your Compose Screen
StyledText(
fullText = "Contact us at support@example.com or visit our website",
styledStrings = styledStrings,
style = MaterialTheme.typography.label,
onClick = { styled ->
when (styled) {
is ClickableEmail -> openEmailClient(styled.email)
is ClickableUrl -> openUrl(styled.url)
}
}
)
就是这样。无需手动构建文本,无需索引计算,无需样板代码。只需简洁、易读、声明式的样式,即可与真实文本兼容。
由于 StyledString 支持 Simple 、 ClickableEmail 和 ClickableUrl 等类型,因此它易于在你的应用中扩展和复用。你可以获得可点击的、带样式的文本,而无需牺牲其合理性和可维护性。🙏
StyledString 的底层:API 背后的引擎
让我们揭开它的面纱,逐步了解 StyledString 的工作原理。🪄
当你在 UI 中使用 StyledText 时,🧠它可能感觉像魔法一样神奇,但在幕后,它只是一个简洁、易于组合的架构,旨在减少样式设计的痛苦,而不会增加不必要的复杂性。
本节涵盖了 StyledString 系统的每个部分,从样式的描述方式,到样式的查找、应用和在屏幕上渲染。
🧱 1. 数据模型:StyledString 和 ClickableStyleString
整个实用程序的核心是一个名为 StyledString 的密封接口。我们通过它来对需要以某种方式设置样式的文本片段进行建模。
Kotlin
@Immutable
sealed interface StyledString {
val highlightedText: String
val style: SpanStyle
// Add `Simple` type
// Add `ClickableEmail` type
// Add `ClickableUrl` type
}
每个 StyledString 都需要两条信息:
- highlightedText:需要设置样式的文本的确切部分
- style:定义其外观的 SpanStyle(颜色、下划线、字体粗细等)。
然后,我们定义一些实现此接口的特定类型:
Kotlin
@Immutable
data class Simple(
override val highlightedText: String,
override val style: SpanStyle,
) : StyledString
这个是纯视觉效果的,它会改变文本的外观,但不响应点击。
然后我们介绍交互类型:
Kotlin
@Immutable
data class ClickableEmail(
override val highlightedText: String,
val email: String,
override val style: SpanStyle,
) : StyledString, ClickableStyleString
@Immutable
data class ClickableUrl(
override val highlightedText: String,
val url: String,
override val style: SpanStyle
) : StyledString, ClickableStyleString
它们执行相同的样式设置工作,但还携带额外的数据(例如点击时应打开的 URL 或电子邮件)。更重要的是,它们实现了第二个接口:ClickableStyleString。
Kotlin
sealed interface ClickableStyleString
这个小接口意义重大,它让我们能够区分纯视觉样式和应该响应点击的样式。这使得我们的点击处理逻辑简洁且类型安全。💡
你可以轻松添加更多变体,例如 @mentions、#hashtags 或电话号码,只需创建另一个数据类并选择性地实现ClickableStyleString 即可。
🎯 2. 样式和链接:applyStyle
一旦我们知道了哪些文本需要样式,我们就需要一种将这些样式应用于实际的 AnnotatedString 的方法。这就是 applyStyle() 的作用,它是一个简单的扩展函数,它根据 StyledString 的类型应用样式(和点击监听器)。
Kotlin
private fun AnnotatedString.Builder.applyStyle(
styledString: StyledString,
startIndex: Int,
endIndex: Int,
onClick: (ClickableStyleString) -> Unit
) {
when (styledString) {
is StyledString.ClickableUrl -> TODO()
is StyledString.ClickableEmail -> TODO()
is StyledString.Simple -> TODO()
}
}
每次匹配每个 StyledString 时,都会调用一次此函数。现在让我们看看它做了什么:
Kotlin
is StyledString.ClickableUrl -> {
val linkAnnotation = LinkAnnotation.Url(
url = styledString.url,
styles = TextLinkStyles(style = styledString.style),
linkInteractionListener = { onClick(styledString) }
)
addLink(linkAnnotation, startIndex, endIndex)
}
如果是 URL,我们会创建一个 LinkAnnotation.Url 对象,附加样式,并为其添加一个点击监听器。addLink 负责将其附加到正确的文本范围。
我们执行的操作类似,但针对电子邮件使用的是 LinkAnnotation.Clickable :
Kotlin
is StyledString.ClickableEmail -> {
val linkAnnotation = LinkAnnotation.Clickable(
tag = styledString.highlightedText,
styles = TextLinkStyles(style = styledString.style),
linkInteractionListener = { onClick(styledString) }
)
addLink(linkAnnotation, startIndex, endIndex)
}
如果样式只是视觉上的(不可点击),我们会应用常规跨度:
Kotlin
is StyledString.Simple -> {
addStyle(
style = styledString.style,
start = startIndex,
end = endIndex
)
}
这种分离将所有样式应用逻辑集中在一处。如果你想要支持新的链接类型或行为,只需更新此函数即可。
🔍 3. 匹配文本:findAllOccurrences
在应用样式之前,我们需要找到文本中所有出现指定highlightedText 的位置。这就是此函数的用途。
Kotlin
/**
* Find all occurrences of a substring in a string, optionally ignoring case.
*
* @param substring The substring to search for.
* @param ignoreCase Whether to perform a case-insensitive search.
* @return A list of indices where the substring was found.
*/
private fun String.findAllOccurrences(
substring: String,
ignoreCase: Boolean = false
): List<Int>
这将获取全文,并返回给定子字符串的每个匹配项的起始索引列表。
工作原理如下:
Kotlin
if (substring.isEmpty()) return emptyList()
对于空子字符串,快速提前退出。避免奇怪的边缘情况。然后,我们准备进行不区分大小写的搜索(如果需要):
Kotlin
val indices = mutableListOf<Int>()
val searchString = if (ignoreCase) this.lowercase() else this
val searchSubstring = if (ignoreCase) substring.lowercase() else substring
现在我们遍历字符串,找到所有匹配项:
Kotlin
var startIndex = 0
val maxStartIndex = length - substring.length
while (startIndex <= maxStartIndex) {
val index = searchString.indexOf(searchSubstring, startIndex)
if (index == -1) break
indices.add(index)
startIndex = index + 1
}
我们最终返回结果:
Kotlin
return indices.toList()
这使得我们的样式逻辑保持灵活性和弹性,无论我们设计的单词出现一次还是十几次。
🧠 4. 构建 AnnotatedString:rememberStyledAnnotationString
以下函数将所有内容整合在一起。它接收完整文本和你的StyledString 列表,并返回一个应用了所有样式的 AnnotatedString。
Kotlin
@Composable
fun rememberStyledAnnotationString(
fullText: String,
styledStrings: ImmutableList<StyledString>,
ignoreCase: Boolean = false,
onClick: (ClickableStyleString) -> Unit
): AnnotatedString
我们确保使用 rememberUpdatedState() 来保持点击监听器的最新状态:
Kotlin
val currentOnClick by rememberUpdatedState(onClick)
然后我们使用记住来缓存工作,除非输入发生变化:
Kotlin
return remember(fullText, styledStrings, ignoreCase) {
// TODO: build annotated string
}
我们首先附加完整的未样式化的文本。然后,对于每个 StyledString ,我们找到所有匹配的位置并应用样式:
Kotlin
buildAnnotatedString {
append(fullText)
styledStrings.fastForEach { styledStringInfo ->
val indices =
fullText.findAllOccurrences(styledStringInfo.highlightedText, ignoreCase)
indices.fastForEach { startIndex ->
val endIndex = startIndex + styledStringInfo.highlightedText.length
applyStyle(styledStringInfo, startIndex, endIndex, currentOnClick)
}
}
}
这个循环使得样式设置能够动态且多目标化。你可以将任何本地化或运行时生成的字符串作为 fullText 传递,它仍然能够正确应用样式。
🧩 5. 可组合项:StyledText
最后,StyledText 可组合项将所有内容连接在一起。
Kotlin
@Composable
fun StyledText(
fullText: String,
styledStrings: ImmutableList<StyledString>,
style: TextStyle,
modifier: Modifier = Modifier,
onClick: (ClickableStyleString) -> Unit = {},
ignoreCase: Boolean = false,
) {
// TODO: Implementation
}
你传入全文、样式以及可选的点击处理程序。它的内部功能如下:
Kotlin
val annotatedString = rememberStyledAnnotationString(
fullText = fullText,
styledStrings = styledStrings,
ignoreCase = ignoreCase,
onClick = onClick
)
这调用了我们刚刚讲过的逻辑。它返回一个带样式的 AnnotatedString 。然后我们渲染它:
Kotlin
Text(
modifier = modifier,
text = annotatedString,
style = style
)
它只是一个普通的 Compose Text 。但所有样式逻辑都已预先烘焙。现在,你的 UI 代码保持简洁且声明式。🌚
⚡️ 6. StyledText 实践
现在,让我们来看看 StyledText 的实践,为此,我整理了一个预览,你可以自己测试一下:
Kotlin
@PreviewLightDark
@Composable
private fun StyledTextPreview() {
MyTheme {
Box(
modifier = Modifier
.background(color = MaterialTheme.colors.background)
.padding(16.dp)
) {
// This list can be built in the ViewModel
val styledStrings = persistentListOf(
StyledString.ClickableEmail(
highlightedText = "support@example.com",
email = "support@example.com",
style = SpanStyle(
color = Color.Gray,
textDecoration = TextDecoration.Underline
)
),
StyledString.ClickableUrl(
highlightedText = "website",
url = "https://euryperez.dev",
style = SpanStyle(
color = Color.Gray,
textDecoration = TextDecoration.Underline
)
)
)
// In your Compose Screen
StyledText(
fullText = "Contact us at support@example.com or visit our website",
styledStrings = styledStrings,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
onClick = { styled ->
when (styled) {
is StyledString.ClickableEmail -> TODO()
is StyledString.ClickableUrl -> TODO()
}
}
)
}
}
}
你将在预览中看到以下内容:
✅ 总结:一个输出简洁的简单引擎
总而言之,我们构建了一个完全可复用的 Compose 实用程序,它:
- 使用 StyledString 以声明式方式描述样式
- 安全地区分可视样式和可点击样式
- 使用 applyStyle 应用 span 和 link
- 使用 findAllOccurrences 查找多个匹配项
- 以 Compose 稳定的方式组装所有内容
- 封装在一个简洁的 API 中:StyledText
无需 indexOf ,无需复杂的范围逻辑,也无需复制粘贴 buildAnnotatedString样板代码。
点击此处 获取完整解决方案。
结语🎯
Jetpack Compose 赋予我们强大的功能,但并非总是最符合人体工程学的开箱即用工具。AnnotatedString 对于一次性需求来说非常棒,但一旦你的 UI 需要多种样式、复用模式或动态点击处理,它就会很快变得冗长。
这就是 StyledString 的用武之地。
它并非取代 AnnotatedString,而是对其进行包装,为你提供一种更安全、更清晰的方式来描述意图:
- → "将此单词加粗"
- → "将此短语设为链接"
- → "为该字符串的每个实例设置样式"
你无需再考虑文本偏移量和跨度范围,而是开始思考含义。结果:代码更简洁、样板更少,开发者体验更佳💆
🧩 易于采用
你无需重构整个应用即可使用 StyledString 。
只需将一两个 Text() 元素替换为 StyledText() 即可。将内联的 buildAnnotatedString { ... } 块替换为 StyledString.Simple 或 ClickableUrl 的简单列表即可。
就这样,你就成功了。✨
🛠️ 易于扩展
还有其他用例吗?
- 为 #hashtags 设置样式?
- 处理 @mentions?
- 自动检测电话号码?
- 添加图标或背景高亮?
只需创建一个实现 StyledString 的新数据类,并在 applyStyle() 中处理它即可。系统的其余部分保持不变。
这种分离使你的文本逻辑模块化、可测试,并能够适应未来的设计或业务需求。
如果你有什么有趣的想法,别忘了在评论区分享。😉
🫱 轮到你了
现在你已经了解了它的工作原理(以及它实际需要的代码量有多小),那就在下一个 Compose 屏幕中尝试一下吧。不再需要繁琐的 AnnotatedString.Builder 代码。不再需要重复的 span 逻辑。只需描述你想要的内容,剩下的交给 StyledText 处理。
让 Compose 中的文本样式再次变得简单。😎
🤝 感谢阅读
如果你最终在项目中使用了 StyledString,请告诉我!看到这些微型模式在现实世界中落地总是很酷。👀
感谢阅读!如果你觉得这篇文章有用,请考虑分享给其他开发者,点赞或留言。这很有帮助。✌️
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!