第1周:别小看 `TextView`,它其实是 Android 页面里最常被低估的组件

很多人刚学 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":超出之后在末尾显示省略号

  • textSizetextColor:决定阅读层级,不只是"看着差不多"

很多初学者会把 maxLinesellipsize 当成小技巧,我现在更愿意把它叫做排版治理。因为一旦进入列表页、卡片流、搜索结果页,文本如果没有边界,后面的测量、布局、点击区间、滚动节奏都会跟着乱。

富文本为什么值得早点学:一个 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 周学的是文字组件。

但更深一层看,这周真正建立起来的是一种做事顺序:

  • 先把能力做全

  • 再把坑点补齐

  • 再把工程成本看见

  • 最后做成可验收、可讲解、可输出的成果

这套顺序一旦立住,后面学 EditTextRecyclerViewViewModelRoomWorkManager,你都会稳很多。因为你已经不是在"记组件",而是在训练自己把一个能力从 Demo 写到可交付。

下一步怎么走

第 2 周会进入 EditText 全功能和输入体验优化。

如果说 TextView 解决的是"怎么把文字展示出来",那 EditText 处理的就是"怎么把输入这件事做舒服、做稳定、做可控"。你会接触输入类型限制、TextWatcherInputFilter、密码显隐、软键盘控制这些内容。

但在进入第 2 周之前,我建议你先自己顺一遍下面这些问题:

  • SpannableStringBuilder 为什么能减少多 TextView 嵌套?

  • 跑马灯为什么经常写了不生效?

  • ClickableSpan 为什么必须配 LinkMovementMethod

  • maxLinesellipsize 为什么属于排版治理?

  • Typeface 缓存、StaticLayout、对象池分别在解决什么问题?

如果这些问题你已经能比较顺地讲出来,那第 1 周就不是"看过了",而是真的开始进脑子了。

相关推荐
随遇丿而安1 小时前
Android全功能终极创作
android
summerkissyou19875 小时前
Android-基础-SystemClock.elapsedRealtime和System.currentTimeMillis区别
android
ian4u5 小时前
车载 Android C++ 完整技能路线:从基础到进阶
android·开发语言·c++
学习使我健康7 小时前
Android 中 Service 用法
android·kotlin
2601_949816687 小时前
MySQL 数据库连接池爆满问题排查与解决
android·数据库·mysql
Tangsong4048 小时前
以Termius的方式进行安卓设备调试?试试【easyadb】| 多功能可视化adb工具
android·adb
码农的小菜园10 小时前
Android的Locale学习笔记
android·笔记·学习
帅次10 小时前
链路到端上:HTTPS 之后安全题还在考什么
android·okhttp·glide·zygote·retrofit