多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Android自定义带拼音音调TextView:从测量到绘制的实践

Android自定义带拼音音调TextView:从测量到绘制的实践 简介这份安卓自定义带拼音音调文本控件资料主要面向需要实现汉字与拼音对照显示功能的安卓开发者和语言学习类应用开发者。资料以PDF文档形式系统讲解控件从设计到落地的完整流程包含拼音数组与汉字数组的存储设计、onDraw方法重写原理、TextPaint设置字体颜色与大小的方法、基于测量宽度自动换行与拼音汉字居中对齐的排版逻辑以及缓存和异步加载等性能优化手段。文中还给出了SpellTextView类核心代码示例标注了关键实现步骤便于直接参考改造。资源包共1个文件为单个PDF文档压缩包大小仅35KB内容紧凑、便于快速通读。该资源已有419人学习浏览适合正在搭建汉语教学、生词卡阅读或语言学习类应用并希望实现友好文本展示的Android开发者。1. 带拼音音调的 TextView 到底解决什么问题接到一个儿童阅读器的需求给生字、古诗配带声调注音。第一版我图省事直接用系统TextView拼接文本再压一个字体文件上去结果“窗”的chuāng和三声“雨”的yǔ在汉字上方忽左忽右行尾注音经常被甩到下一行。后来把显示层替换成一个自绘的Android自定义带拼音音调Textview这些问题才真正收敛。这个控件的核心作用很简单把带声调的拼音画在对应汉字上方按字对齐且它在参与换行、点击、滚动时不会像字符串拼接那样“散架”。适合给绘本、词典、识字 App、语言学习界面做注音层也适合需要在拼音上做交互比如点击发音、区分声调颜色的场景。这里先说一个反直觉的结论带声调拼音好不好看关键不在字体文件而在排版策略。2. 为什么默认 TextView 做不了拼音显示的三个硬约束2.1 声调排版不是“加个符号”那么简单带声调的拼音由拉丁字母和附加符号组成比如ā、á、ǎ、à字符本身就在 Unicode 的 Latin Extended-B 区块里系统字体十有八九认识但你没法把它们“放”到汉字正上方。TextView的排版单位是“一行文字”它不知道也不关心哪个拼音属于哪个汉字。用SpannableString配合ReplacementSpan理论上能画但ReplacementSpan的getSize返回宽度时你既要算汉字宽又要算拼音宽换行时系统拿这个宽度去切行行尾经常溢出或切出半个拼音滚动时 span 的回收和复用也会带来额外开销。我试过这条路维护成本比自绘高不少。2.2 汉字与拼音的对齐依赖测量策略汉字是方块字同一个字号下每个汉字的 advance 宽度基本一致拼音是拉丁字母chuāng和c的 width 差了四五倍。如果按“汉字宽度”对齐拼音短的字比如é会在汉字上方居中视觉舒服拼音长到超过汉字宽度时比如zhuang要么压缩拼音要么让拼音溢出到邻字上方这两种策略必须在绘制前就定好。更麻烦的是换行系统按汉字的 advance 切行拼音实际需要的宽度更大行尾的汉字配一个长拼音时拼音会被画出边界。这正是默认方案无法解决的第三层约束换行必须把“拼音宽度”纳入计算。2.3 选型拼音数据源、字体与原生方案对比做这个控件前先想清楚两件事拼音从哪来字体用哪套。拼音数据源我建议用轻量的开源拼音转换库它把汉字转成类似nǐ hǎo的带声调字符串这类库通常提供“简洁模式”和“多音字模式”前者包体积小、转换快后者带词组上下文判断包体积会大 1~2 MB。生产环境我给儿童内容用多音字模式给普通列表用简洁模式。字体方面部分国产 ROM 的系统默认字体不包含āǚ这些字形结果就是屏幕上出现方框所以我会在应用内放一个覆盖 Latin Extended 区块的字体文件在控件里直接用Paint.setTypeface加载。三个原生方案对比下来自绘 View 虽然代码量最多但对齐和换行完全可控性能也最稳定。方案对齐控制换行滚动性能适配成本系统字体 文本拼接无系统切行拼音错位好低但效果不可用SpannableString ReplacementSpan可但宽度难统一行尾溢出频发一般span 回收开销高自绘 View完全可控自管换行与测量高绘制逻辑简单中一次性投入3. 基础版实现自定义 View 的测量与绘制核心流程3.1 自定义属性拼音字号、颜色、间距怎么设一个注音控件最常用的参数就是拼音字号、汉字字号、拼音颜色、汉字颜色以及“拼音和汉字之间的额外间距”。我把它做成 XML 可配的自定义属性比写死在代码里方便太多。先建attrs.xmlresources declare-styleable namePinyinTextView attr namepinyinTextSize formatdimension / attr namehanziTextSize formatdimension / attr namepinyinTextColor formatcolor / attr namehanziTextColor formatcolor / attr namepinyinHanziGap formatdimension / /declare-styleable /resources然后在构造器里解析并初始化两支画笔public PinyinTextView(Context context, AttributeSet attrs) { super(context, attrs); TypedArray a context.obtainStyledAttributes(attrs, R.styleable.PinyinTextView); float pinyinSize a.getDimension(R.styleable.PinyinTextView_pinyinTextSize, sp2px(12)); float hanziSize a.getDimension(R.styleable.PinyinTextView_hanziTextSize, sp2px(16)); pinyinPaint new Paint(Paint.ANTI_ALIAS_FLAG); pinyinPaint.setTextSize(pinyinSize); pinyinPaint.setTextColor(a.getColor(R.styleable.PinyinTextView_pinyinTextColor, 0xFF888888)); pinyinPaint.setTypeface(pinyinTypeface); // 内置字体避免声调字符变方框 hanziPaint new Paint(Paint.ANTI_ALIAS_FLAG); hanziPaint.setTextSize(hanziSize); hanziPaint.setTextColor(a.getColor(R.styleable.PinyinTextView_hanziTextColor, 0xFF333333)); a.recycle(); }这里有几个参数值得细说。拼音字号我一般比汉字字号小 4dp视觉上既不抢戏又能看清楚声调拼音颜色用灰色而不是纯黑因为注音是辅助信息灰色字重更轻不会和正文抢层次。间距pinyinHanziGap默认给 2dp 就行太大会让行与行之间看起来松散太小拼音会和汉字贴在一起阅读时容易串行。3.2 onMeasure按汉字宽度换行给拼音留出行高测量是整个控件的地基。我先把文本拆成“行”每行保存字符列表和行宽信息onMeasure只需要遍历这些行算出期望宽高。注意换行时要把拼音宽度也考虑进去不能只看汉字宽度否则行尾拼音会溢出到控件外面。Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode MeasureSpec.getMode(widthMeasureSpec); int maxWidth MeasureSpec.getSize(widthMeasureSpec); int usableWidth maxWidth - getPaddingLeft() - getPaddingRight(); lines LayoutHelper.buildLines(charItems, usableWidth, hanziPaint, pinyinPaint); int desiredWidth 0; int desiredHeight getPaddingTop() getPaddingBottom(); for (LineInfo line : lines) { desiredWidth Math.max(desiredWidth, (int) line.width); desiredHeight line.height; } int width (widthMode MeasureSpec.EXACTLY) ? maxWidth : desiredWidth getPaddingLeft() getPaddingRight(); int height resolveSize(desiredHeight, heightMeasureSpec); setMeasuredDimension(width, height); }LayoutHelper.buildLines的逻辑核心是逐字累加 advance超过可用宽度就换行。这里的 advance 取max(hanziWidth, pinyinWidth)这是我和拼音排版“打架”多次之后定下来的经验值——按汉字宽度切行拼音会溢出按拼音宽度切行拼音短的字会留出一大块空白。取最大值两边都不吃亏。行高也要分成三段拼音行高、间距、汉字行高分别用两支画笔的FontMetrics计算。public static ListLineInfo buildLines(ListCharItem items, int maxWidth, Paint hanziPaint, Paint pinyinPaint) { ListLineInfo lines new ArrayList(); LineInfo current new LineInfo(); float currentWidth 0f; for (CharItem item : items) { item.hanziWidth hanziPaint.measureText(String.valueOf(item.hanzi)); item.pinyinWidth item.pinyin null ? 0f : pinyinPaint.measureText(item.pinyin); item.advance Math.max(item.hanziWidth, item.pinyinWidth); if (currentWidth item.advance maxWidth current.items.size() 0) { lines.add(current); current new LineInfo(); currentWidth 0f; } current.items.add(item); currentWidth item.advance; } if (!current.items.isEmpty()) { lines.add(current); } return lines; }换行时有个细节current.items.size() 0这个条件不能省否则当某个字本身宽度超过maxWidth时会死循环——新的空行放不下一个字又换一行永远执行不完。这种“单字超宽”在拼音场景里很少见但如果用户把拼音字号设得很大就会触发。3.3 onDraw先画拼音再画汉字两个基线别搞混绘制顺序上我习惯先画拼音再画汉字。因为拼音在上方、汉字在下方先画下面的汉字不影响上方的拼音反过来拼音先画也不会被汉字盖住但遇到拼音和汉字重叠的极端字体时后画的会盖住先画的汉字应该永远在最上层。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (lines null || lines.isEmpty()) return; float y getPaddingTop(); for (LineInfo line : lines) { float cursorX getPaddingLeft(); for (CharItem item : line.items) { if (item.pinyin ! null) { float pinyinWidth pinyinPaint.measureText(item.pinyin); float pinyinX cursorX (item.advance - pinyinWidth) / 2f; canvas.drawText(item.pinyin, pinyinX, y line.pinyinBaseline, pinyinPaint); } canvas.drawText(String.valueOf(item.hanzi), cursorX, y line.pinyinHeight line.gap line.hanziBaseline, hanziPaint); cursorX item.advance; } y line.height; } }两个基线偏移在buildLines阶段就算好存进LineInfo避免onDraw里重复计算。拼音基线是pinyinPaint.ascent()的负值取整汉字基线是hanziPaint.ascent()的负值取整因为ascent是负数负负得正后就是“从行顶到基线的距离”。这个阶段有个容易搞混的点FontMetrics.top和ascent不一样top是字体能画到的最高点ascent是标准行高起点用ascent做基线计算才不会有莫名的上下跳动。拼音居中时如果拼音宽度接近甚至超过advance居中算法会自动把拼音向左推让它从汉字左边缘开始画视觉上虽然有点挤但不会溢出到下一格。这就是初级版的全部核心逻辑接下来要考虑数据层的多音字和复杂文本。4. 数据与多音字从字符串到可注音结构4.1 把文本拆成汉字、拼音、音调三要素绘制层面的对齐依赖把一段字符串拆成“字符 拼音 按钮宽度”的结构。我定义两个数据类CharItem描述单个字符LineInfo描述换行后的一行。这里的核心思想是所有计算量放在setText阶段完成绘制阶段只做坐标平移和drawText把绘制开销压到最低。class CharItem { char hanzi; // 原始字符 String pinyin; // 带声调拼音如 chūn非汉字时为 null int tone; // 声调 1~4轻声为 5非汉字为 0 float hanziWidth; // 汉字宽度 float pinyinWidth;// 拼音宽度 float advance; // 参与换行的宽度取 max(hanziWidth, pinyinWidth) RectF drawRect; // 绘制位置供点击热区使用 } class LineInfo { ListCharItem items new ArrayList(); float width; float height; float pinyinHeight; float gap; float pinyinBaseline; float hanziBaseline; }文本拆解的逻辑我一般这样处理遍历text.charAt(i)遇到汉字就查拼音库遇到标点或空格直接复制不生成拼音。之所以不用substring去切是因为拼音的长度和汉字不是一一对应的逐个字符处理才能保证后面的坐标计算不出错。private ListCharItem buildCharItems(String text) { ListCharItem result new ArrayList(); for (int i 0; i text.length(); i) { char c text.charAt(i); CharItem item new CharItem(); item.hanzi c; if (isHanzi(c)) { String py pinyinConverter.toPinyin(c); if (correction ! null) { String override correction.get(String.valueOf(c)); if (override ! null) { py override; } } item.pinyin py; item.tone extractTone(py); } result.add(item); } return result; }extractTone是从拼音字符串末尾的数字或符号里提取声调比如从chūn里识别 1 声。这个tone字段现在可能用不到但等产品想给不同声调配不同颜色时它就能直接派上用场不用再回头遍历字符串解析。我吃过这个亏第一版没存tone后来加需求时所有数据处理流程都要重跑一遍。4.2 多音字上下文推断有限留手动纠错入口拼音库再强多音字也不可能全对。“行”在“银行”里读háng在“行走”里读xíng“了”做助词时读le做动词时读liǎo。词库型的转换库能解决一部分但人名、古文、方言场景还是会翻车。我一般会留一个轻量的纠错接口让业务层把易错词传进来控件在转换时做覆盖。注意手写覆盖的优先级一定要高于词库这样产品运营发现错误时不需要发版走配置下发的通道就能修。public void setCorrection(MapString, String correctionMap) { this.correction correctionMap; if (charItems ! null) { charItems buildCharItems(text); requestLayout(); invalidate(); } }覆盖逻辑这里有个坑词和字的优先级不同。直接用String.valueOf(c)做 key 只能覆盖单字如果传 “银行” 这种词需要先在buildCharItems里做最长匹配——先查两个字的词再查单字否则“银行”这个词永远命中不了。我在第 5 章的回收坑里还会提到纠错表在列表场景下要保证线程安全最好只读多个 item 共享一份实例。4.3 更新流程setText 之后如何触发重排setText不能直接调invalidate()了事。换行宽度变了视图尺寸可能也要变必须让父布局重新测量。我的习惯是先重新构建行数据再调requestLayout()触发onMeasure最后invalidate()重绘顺序不能反。public void setText(String text) { this.text text; this.charItems buildCharItems(text); requestLayout(); invalidate(); }如果文本来自网络或数据库转换在多线程做这里给你一个异步版本的骨架。核心是“版本号”机制每次setTextAsync都会拿到一个新版本号回调回来时只认最新的避免旧数据覆盖新数据。这个机制在 RecyclerView 列表滚动时尤其重要否则快速滑动时拼音会错位。public void setTextAsync(final String newText) { final int version generation; executor.execute(() - { final ListCharItem newItems buildCharItems(newText); post(() - { if (version ! generation) return; // 旧任务丢弃 this.text newText; this.charItems newItems; requestLayout(); invalidate(); }); }); }buildCharItems是纯计算不持有 View 引用放到后台线程不会引发 UI 问题回调通过post回到主线程再更新数据并重绘。线程池我会直接用应用里现成的避免为了一个控件单独起线程。这里还有一点要提醒charItems这个引用被后台线程持有回调解锁后主线程重新赋值只要保证“只替换引用、不修改内容”就不会有并发修改异常。5. 换行、字体与回收避坑拼音 TextView 的 5 个典型翻车现场5.1 换行时拼音和汉字被切到两行现象文本在控件里排版后某一行末尾的汉字显示在本行它的拼音却画在下一行开头看起来好像上一行字“丢了拼音”。原因换行时没有把拼音宽度计入 advance行尾的汉字放得下但拼音放不下绘制时只能顺着坐标流画到了下一行。解决换行时取max(hanziWidth, pinyinWidth)作为该字符的前进宽度绘制和换行共用同一个宽度两个阶段就不可能出现错位。我后来把buildLines里算宽度和onDraw里画字用的cursorX统一成item.advance这个坑再没出现过。5.2 修改文字后控件尺寸没刷新现象setText之后文字变短了但控件在布局里还是占着原来的宽度或者文字变长后被截断。原因只调用了invalidate()这个方法是“重绘”不会触发onMeasure如果父布局是wrap_content或者控件自身是wrap_content尺寸完全没机会重新计算。解决setText里先requestLayout()再invalidate()顺序不能颠倒。requestLayout会从控件向上传递给父布局让整条测量链重新跑一遍。我的经验是TextView用它自己的内部逻辑处理了这件事但自绘控件没有系统帮忙每次文本变化都必须手动把两个方法都调了。5.3 标点符号让整行宽度算错现象文本里有英文标点或半角空格时换行位置和绘制位置对不上行尾偶尔多出一截空白或者拼音画到边界外。原因英文标点的宽度和中文标点差距很大同一个字符advance计算时用汉字宽度标点没被特殊处理宽度混用导致累加误差。解决把所有中文标点统一转全角把英文标点、数字、空格也按汉字宽度处理保证advance的一致性。具体做法是在buildCharItems里判断字符不是汉字也不是拼音字母时直接把advance设为hanziWidth不要用pinyinPaint.measureText去量标点。if (!isHanzi(c) !isLatinChar(c)) { item.hanziWidth hanziPaint.measureText(String.valueOf(c)); item.pinyinWidth 0f; item.advance item.hanziWidth; }5.4 列表复用后拼音错位或显示成上个条目的内容现象RecyclerView 快速滑动时某些 item 的拼音和汉字对不上甚至直接显示成上一个位置的注音文本。原因异步转换拼音时回调晚于 View 复用旧的 callback 回来之后直接setText把上一个位置的结果写进了当前复用的 View。解决用第 4.3 节的版本号机制。每个异步任务拿一个递增的 token回调时确认 token 还是当前 View 持有的才允许写入。列表场景里也可以配合holder.getBindingAdapterPosition做二次校验但版本号机制已经能覆盖绝大多数情况。踩过一次这个坑之后我把所有自绘控件的异步更新都统一走这个模式避免每次都得重新排查“是不是 View 被回收了”。5.5 部分设备上声调字母变成方框现象á、ǚ这些字符在部分国产 ROM 上显示为方框换了系统字体也没用。原因系统默认字体不一定包含 Latin Extended 区块的字形尤其是一些精简过字体包的 ROM。带声调拼音如果依赖系统字体永远是黑匣子。解决在控件里内置一个覆盖 Latin Extended 的字体文件初始化时pinyinPaint.setTypeface(Typeface.createFromAsset(getContext().getAssets(), fonts/pinyin.ttf))。注意汉字字体和拼音字体可以分开汉字继续用系统默认字体保持原样拼音用内置字体两支画笔互不影响。上线前拿一台老版本系统的机器和一台新机器各跑一次专门看声调字形这一步能救你于水火。6. 进阶技巧把拼音数据缓存与热区交互做成可复用组件6.1 拼音数据缓存避免列表滚动时重复转换拼音转换是 CPU 密集操作列表滚动时每帧转几十个字必然掉帧。我加了一层基于LruCache的缓存key 是原始文本value 是转换后的ListCharItem。这样同一个文本进入控件视野时直接命中缓存不再走转换逻辑。private static final LruCacheString, ListCharItem CACHE new LruCache(512); public void setText(String text) { ListCharItem cached CACHE.get(text); if (cached ! null) { this.charItems cached; } else { this.charItems buildCharItems(text); CACHE.put(text, this.charItems); } requestLayout(); invalidate(); }缓存大小我建议给 200 到 500 条之间。给太少列表滑动时频繁逐出重建给太多占用的内存对一个辅助控件来说不划算。文本是长句子时key 太长会影响 HashMap 性能我会对长度超过 20 的文本做一次简单 hash用 hash 做 key碰撞概率低到可以忽略。6.2 拼音热区点击命中检测与高亮注音控件最常见的交互是点击某个汉字触发它的拼音发音。做法是在绘制阶段把每个字符的绘制矩形存进CharItem.drawRectonTouchEvent里做矩形命中检测。Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() MotionEvent.ACTION_DOWN) { for (LineInfo line : lines) { for (CharItem item : line.items) { if (item.drawRect ! null item.drawRect.contains(event.getX(), event.getY())) { notifyPinyinClick(item); // 回调业务层播放发音等 return true; } } } } return super.onTouchEvent(event); }drawRect的赋值在onDraw里完成但onDraw每帧都会跑我用一个needsUpdateRect标志位控制只在行数据变化时更新矩形避免重复new RectF产生内存抖动。命中检测时只判断ACTION_DOWN不要在MOVE里反复遍历所有字符省电也省计算。6.3 上线前的验证清单我每次改完这类控件都会跑一遍下面的验证清单才送测验证项方法达标标准换行正确性用 20 个不同长度拼音的汉字拼成文本在 320dp 宽度控件里滚动检查每行拼音与汉字同行无溢出无断行字体兼容在 5.0 和 12 系统模拟器各跑一次特殊声调字符不显示方框滚动性能RecyclerView 里放 100 条注音文本快速滑动无明显掉帧内存不增长多音字覆盖输入“银行”“行走”“了却”等词纠错表生效读正确异步更新网络加载文本并连续刷新多次最终显示内容与最新提交一致这五件事里滚动性能和字体兼容最容易“骗”自己。我在单条文本上测性能从来看不出问题必须塞进列表里滚起来才能暴露掉帧字体兼容则必须在真机上测模拟器用的字体补全程度和真机差太多。现在我做拼音注音组件固定先写一个“多音字 长文本 列表”的样例全部跑通再往业务里接。这个习惯帮我避免了不少线上返工。希望这些弯路记录能帮你在做Android自定义带拼音音调Textview时少踩几个坑。本文还有配套的精品资源点击获取
返回列表