第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 周就不是"看过了",而是真的开始进脑子了。

相关推荐
AFinalStone27 分钟前
Android 7系统异常问题排查(四)Framework层(上)—System Server Watchdog
android·系统异常
宝杰X72 小时前
Android Room3 多平台数据库
android·数据库
huibin1478523695 小时前
热缓解学习记录
android·学习
小田的博客8 小时前
SAP MM 供应商银行主数据更新报错!message R1228!
android·java·服务器
一笑的小酒馆9 小时前
Android智能猫砂盆视频加载慢卡顿问题分析
android
只会cv的小前端10 小时前
七巧低代码服务端脚本使用方法
android·低代码·rxjava
10 小时前
Android 自定义 View 实战:从零打造工业级全向摇杆控件(OmniJoystickView)
android·kotlin·自定义view·摇杆控件
雨白11 小时前
深入理解 Kotlin 协程 (十一):以逸待劳,探秘 select 多路复用与并发安全策略
android·kotlin
hunterandroid13 小时前
[Android 从零到一] Android 深度链接与 App Links:从 URI Scheme 到可验证的应用跳转
android
我命由我1234515 小时前
Jetpack Compose - Material Design 断点范围、WindowSizeClass、针对不同屏幕尺寸创建预览、四类导航栏
android·java·开发语言·java-ee·kotlin·android jetpack·android runtime