很多人刚学 Android 的时候,看到 TextView 会本能地觉得:这不就是"显示文字"吗?
我一开始也是这么想的。直到真正开始写页面、写卡片、写列表、写协议页、写活动页,我才慢慢意识到,TextView 根本不是一个"学完就过去"的小组件。标题、价格、角标、说明文案、按钮上的字、协议里的局部链接,背后都离不开它。
所以第 1 周把重点放在 TextView,一点都不轻。它看起来像基础,实际上是在给后面很多能力打地基。只是这次我不想再把它写成一篇"纯概念文章",因为只靠嘴讲,很容易把 TextView 讲虚。真正能让人记住的,还是代码。
这周到底要解决什么问题
第 1 周的主题是:TextView 全功能 + 文字渲染优化。
这一周至少要把两件事做扎实:
-
功能层面 :把
TextView最常见、最实用的能力都跑一遍 -
工程层面:开始理解文本测量、字体缓存、富文本对象复用这些问题
如果只学功能,不管性能和复用,你写出来的东西很容易停留在"会抄 Demo";如果只谈优化,不把基础能力一项一项落地,那优化又会显得悬空。所以这周最好的方式,不是记 API 清单,而是直接做出一页能点、能看、能讲、能验收的 Demo。

先看最基础的一段代码:文字边界先立住
很多人一上来就想玩富文本、渐变、点击态,但真正最容易把页面搞乱的,反而是最普通的正文排版。比如一条推荐文案太长,把卡片高度直接撑爆,这种问题在真实业务里非常常见。
bash
<TextView
android:id="@+id/tv_ellipsize"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="#1F2937"
android:textSize="15sp"/>
这段 XML 很普通,但它解决的是一个很现实的问题:文案必须有边界。
-
maxLines="2":最多显示两行 -
ellipsize="end":超出之后在末尾显示省略号 -
textSize和textColor:决定阅读层级,不只是"看着差不多"
很多初学者会把 maxLines 和 ellipsize 当成小技巧,我现在更愿意把它叫做排版治理。因为一旦进入列表页、卡片流、搜索结果页,文本如果没有边界,后面的测量、布局、点击区间、滚动节奏都会跟着乱。
富文本为什么值得早点学:一个 TextView 里装的不只是字
现实业务里,很少所有文字都长得一样。价格需要高亮,活动标题要放大,警告语要变色,重点词可能要加粗。如果这些都靠多个 TextView 叠起来写,布局会很碎,维护成本也会越来越高。
这时候就要上 SpannableStringBuilder(用于在同一段文本里做局部样式控制)。这不是花活,它是工程上很实用的一种文本组织方式。
bash
privatefunbuildRichText(): CharSequence=SpannableStringBuilder().apply {
append("富文本:")
appendStyled(
"粗体",
StyleSpan(Typeface.BOLD),
ForegroundColorSpan(Color.parseColor("#5B4CFF"))
)
append(" + ")
appendStyled(
"高亮",
BackgroundColorSpan(Color.parseColor("#FFF3C4")),
ForegroundColorSpan(Color.parseColor("#8A4B00"))
)
append(" + ")
appendStyled("放大", RelativeSizeSpan(1.25f))
}

这段代码最关键的点,不是"颜色好不好看",而是你开始理解 Span 的粒度控制了:
-
StyleSpan(Typeface.BOLD):控制局部字重 -
ForegroundColorSpan(...):控制局部文字颜色 -
BackgroundColorSpan(...):控制局部背景高亮 -
RelativeSizeSpan(1.25f):控制局部字号比例
如果你是刚学到这里,先别急着背所有 Span 名字,先抓住一个最重要的结论:富文本的意义,是在一个 TextView 里处理复杂表达,减少不必要的 View 嵌套。 这对卡片、商品价格、活动文案、协议文本都很有用。
跑马灯为什么总有人写了却不动
TextView 的跑马灯是一个典型坑点。很多人只记得一句:
ellipsize = TextUtils.TruncateAt.MARQUEE
然后发现完全不滚。问题不是 Android 在故意刁难人,而是跑马灯生效本来就依赖几个条件同时成立。
bash
binding.tvMarquee.apply {
text="跑马灯演示:这是一个超长公告标题,只有在单行、选中状态和 MARQUEE 模式同时满足时,文字才会持续滚动。"
ellipsize=TextUtils.TruncateAt.MARQUEE
marqueeRepeatLimit=-1
isSingleLine=true
isSelected=true
}
这段代码里少一行都可能让效果失效:
-
少了
ellipsize = MARQUEE:不会进入跑马灯模式 -
少了
isSingleLine = true:多行文本不会按你期待的方式滚动 -
少了
isSelected = true:它往往就是静止的 -
marqueeRepeatLimit = -1:表示无限循环,不然滚几次就停了
第一次踩这个坑时,人很容易怀疑自己 XML 写错了,或者怀疑模拟器有问题。后来你会明白,这其实是在提醒你:Android 很多能力不是单个属性决定的,而是一组前提共同成立才会生效。
渐变和阴影不是炫技,而是文字绘制能力
做久了页面你会发现,文字不总是只承担"信息展示"这一件事。活动页、品牌页、会员页、运营 Banner 上的标题,很多时候还承担了一部分视觉表达。
这时候就会用到 Shader(着色器)和 setShadowLayer()。在这周的 Demo 里,我把这两个能力放进了一个标题示例里。
bash
privatefunapplyGradient(textView: TextView) {
if (textView.width<=0) return
textView.paint.shader=LinearGradient(
0f,
0f,
textView.width.toFloat(),
0f,
intArrayOf(
Color.parseColor("#5B4CFF"),
Color.parseColor("#1DA1F2"),
Color.parseColor("#22C55E")
),
null,
Shader.TileMode.CLAMP
)
textView.invalidate()
}
binding.tvGradient.setShadowLayer(12f, 0f, 6f, Color.parseColor("#332B2D42"))

这里最值得理解的不是参数本身,而是绘制过程:
-
textView.paint.shader:把线性渐变绑定到文字绘制用的TextPaint -
LinearGradient(...):定义渐变的起点、终点和颜色数组 -
setShadowLayer(...):给文字加阴影层次
但我要提醒一句真实判断:这种效果更适合标题,不适合大段正文。 因为正文的核心任务是可读性,过强的视觉特效反而会影响阅读效率。
ClickableSpan 真正难的地方,不是 onClick()
局部点击是协议页、评论区、富文本说明里非常常见的一种交互。比如"我已阅读并同意《用户协议》",你肯定不想为了这一小段可点击文字再拆出一整排 View。
这时候 ClickableSpan(局部点击 Span)非常合适,但很多人只写了 onClick(),最后发现两个问题:要么点不动,要么样式丑得像系统默认链接。
bash
privatefunbuildActionText(): CharSequence=SpannableStringBuilder().apply {
append("文字点击:")
valrichStart=length
append("查看富文本卡片")
setSpan(
createFeatureSpan(
featureKey=FeatureKey.RICH,
toastText="已切换到富文本卡片",
color=Color.parseColor("#2563EB")
),
richStart,
length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
binding.tvClickable.text=actionText
binding.tvClickable.movementMethod=LinkMovementMethod.getInstance()
binding.tvClickable.highlightColor=Color.TRANSPARENT
再看 ClickableSpan 里的样式控制:
bash
overridefunupdateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.color=Color.parseColor("#2563EB")
ds.isUnderlineText=false
ds.typeface=TextRenderOptimizer.typeface("sans-serif-medium", Typeface.BOLD)
}
这里的重点有三个:
-
LinkMovementMethod.getInstance():没有它,很多时候就是看起来能点,实际点不动 -
highlightColor = Color.TRANSPARENT:去掉系统默认的高亮背景,让点击态更可控 -
updateDrawState():决定这段局部文本最终长什么样
所以 ClickableSpan 真正难的地方,不是你能不能写出 onClick(),而是你能不能把点击行为、视觉样式、用户预期一起处理对。
讲完功能,还得开始碰优化:不然后面会越来越吃力
如果这周只停在"把样式做出来",其实还不够。因为一旦文字多了、富文本多了、滚动频率高了,问题就会慢慢从"好不好看"变成:
-
会不会重复测量
-
会不会不停创建对象
-
会不会让长列表滑动卡顿
-
会不会让复杂文本布局越来越难控制
所以这周 Demo 里,我顺手把几个关键优化点也落了下来。
1. Typeface 缓存:同一种字体别反复创建
bash
privatevaltypefaceCache=mutableMapOf<String, Typeface>()
funtypeface(name: String, style: Int): Typeface {
valcacheKey="$name-$style"
returntypefaceCache.getOrPut(cacheKey) {
Typeface.create(name, style)
}
}
Typeface 是字体对象。你当然可以每次都 Typeface.create(),但如果页面里反复创建相同字体对象,本质上就是在做重复劳动。这个缓存不复杂,却很适合帮你建立一个工程意识:相同资源能复用,就不要一遍遍新建。
2. 文本宽度缓存 + StaticLayout:把测量成本看见
bash
privatevalwidthCache=LruCache<String, Int>(64)
funmeasureTextWidth(paint: TextPaint, text: CharSequence): Int {
valcacheKey=buildString {
append(paint.textSize)
append('|')
append(paint.typeface?.style?: Typeface.NORMAL)
append('|')
append(text)
}
widthCache.get(cacheKey)?.let { returnit }
valmeasuredWidth=paint.measureText(text.toString()).toInt()
widthCache.put(cacheKey, measuredWidth)
returnmeasuredWidth
}
funmeasureStaticLayoutHeight(text: CharSequence, paint: TextPaint, widthPx: Int): Int {
vallayout=StaticLayout.Builder
.obtain(text, 0, text.length, paint, max(widthPx, 1))
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(false)
.build()
returnlayout.height
}
这两段代码放在一起看,意义就很清楚了:
-
measureTextWidth(...):缓存相同配置下的文本宽度,避免重复测量 -
StaticLayout.Builder.obtain(...):在更底层控制复杂文本排版和高度计算
第 1 周不要求你把 StaticLayout 学到源码级,但你至少要知道:当普通 TextView 的默认测量已经不能满足需求时,它是一个很关键的底层工具。
3. SpannableStringBuilder 对象池:富文本不是零成本
bash
privatevalspanBuilderPool=ArrayDeque<SpannableStringBuilder>()
privatefunobtainSpanBuilder(): SpannableStringBuilder {
if (spanBuilderPool.isEmpty()) {
returnSpannableStringBuilder()
}
returnspanBuilderPool.removeLast().apply {
clear()
clearSpans()
}
}
privatefunrecycleSpanBuilder(builder: SpannableStringBuilder) {
builder.clear()
builder.clearSpans()
if (spanBuilderPool.size<8) {
spanBuilderPool.addLast(builder)
}
}
很多人第一次接触富文本时,会觉得不过是"给文字换个颜色"。但只要进入列表、标签流、运营卡片、高频刷新场景,你就会发现 SpannableStringBuilder 的创建和回收也有成本。对象池(Object Pool)的思路,不一定一开始就要上线,但越早知道,后面做复杂页面越不容易慌。
为什么这次第 1 周必须做成验收页
我这次没有把第 1 周写成几个散碎 Demo,而是把它做成了"卡片化功能面板 + 优化指标区 + 验收页"。原因很实际:零散代码片段只能让你知道"有这个 API",但完整页面才能让你真正理解"这个能力怎么落在真实界面里"。
当 7 个能力点都被拆成独立卡片后,会有三个非常直接的好处:
-
学习更稳:每次只看一个点,不会把所有知识搅成一团
-
验收更清楚:你能明确看到自己到底做成了什么
-
输出更省力:每张卡片都可以直接扩成一小节文章或面试讲解
这件事比表面看上去更重要。因为真正浪费时间的,往往不是写代码,而是你写完以后没有组织好,过几天自己都讲不清。
这一周最容易踩的几个坑
1. 以为会抄就等于会用
跑马灯、富文本、局部点击,这几个点都很容易出现一种错觉:代码跑通了,就以为自己懂了。实际上你只要问一句"为什么少这一行就不行",很多人马上就卡住。
2. 只顾功能,不顾结构
你当然可以把所有示例堆在一个页面里,但如果没有卡片分组、没有当前讲解区、没有验收页,这个页面很快就会沦为一次性练习品。它能跑,但难复盘、难输出、难拿去讲。
3. 把优化当成以后再说
这个想法有一半对,一半不对。对的地方是:你不需要第一周就把所有性能问题都做到极致。不对的地方是:你不能因此完全没有优化意识。早点认识 Typeface 缓存、StaticLayout、对象池、预计算文本,后面学长列表和复杂界面时会轻松很多。
这一周真正学到的,不只是 TextView
如果只看表面,第 1 周学的是文字组件。
但更深一层看,这周真正建立起来的是一种做事顺序:
-
先把能力做全
-
再把坑点补齐
-
再把工程成本看见
-
最后做成可验收、可讲解、可输出的成果
这套顺序一旦立住,后面学 EditText、RecyclerView、ViewModel、Room、WorkManager,你都会稳很多。因为你已经不是在"记组件",而是在训练自己把一个能力从 Demo 写到可交付。
下一步怎么走
第 2 周会进入 EditText 全功能和输入体验优化。
如果说 TextView 解决的是"怎么把文字展示出来",那 EditText 处理的就是"怎么把输入这件事做舒服、做稳定、做可控"。你会接触输入类型限制、TextWatcher、InputFilter、密码显隐、软键盘控制这些内容。
但在进入第 2 周之前,我建议你先自己顺一遍下面这些问题:
-
SpannableStringBuilder为什么能减少多TextView嵌套? -
跑马灯为什么经常写了不生效?
-
ClickableSpan为什么必须配LinkMovementMethod? -
maxLines和ellipsize为什么属于排版治理? -
Typeface缓存、StaticLayout、对象池分别在解决什么问题?
如果这些问题你已经能比较顺地讲出来,那第 1 周就不是"看过了",而是真的开始进脑子了。