Android的功能开发中,难免会遇到功能需求或者UI设计,需要使用单独的字体显示,而这种需求多了之后,如果所有的字体文件都通过内嵌的方式携带会导致整体的包体非常肿大。那么统一的做法应该就是通过服务端下发字体文件。
而在我们应用中,大量的使用到了很多不同的字体,在大约1年多前,我们上线了统一下载和管理字体的业务逻辑,但是当时负责这个功能的研发因为未知的原因,采用了启动应用下载所有字体的方案。最近我们服务端的老大跑来吐槽我们说整个公司的CDN费用一大半花在我们的这个字体下载上面了。
有问题的代码逻辑
具体步骤如下:
- 启动应用,判断1个小时内是否下载过。
- 1个小时内没有下载过新的字体的情况下,获取服务端关于字体的json文件,里面记录了字体的名称和cdn地址一次性下载所有字体。
- 对于需要使用下载字体的View,判断本地是否有字体文件,有则立即使用,没有则采用默认系统字体的方式。
这样的步骤有下面一些问题:
- 一次性下载所有字体,但是我们的实际项目中,90%的用户只会使用到其中1-5个字体,但是需要承担下载几十上百甚至未来几百个字体的流量和存储,而我们的CDN费用也会居高不下,按需加载对我们和用户来说都是最合理的方案。
- 缓存时间设置为1个小时,但是我们的字体在上线的1年多时间内,其实并没有发生改动,也就是说,用户只需要用到同一个没有发生过改变的字体的情况下,需要承担无数次全部字体的下载,而我们的CDN资源的消耗的最大的来源也是这一块。
- 对于下载成功后的字体没有立即使用,而是依赖于下一次触发刷新View来使用字体,如果用户没有触发到下一次刷新,就没有办法看到字体的效果,对于产品测的预期来说其实是有偏差的。
- 下载的字体并没有有效的管理机制,对于未发生改变的字体不应该重复的下载,对于字体集合应该能有效控制哪些字体需要重新下载,哪些字体可以直接采用已经下载过的。

优化方案
1、取消一次性下载所有字体,封装字体下载相关的逻辑,按需申请下载资源:
js
object FontManager {
private val fontCache = mutableMapOf<String, Typeface>()
private val waitingViews = mutableMapOf<String, MutableList<FontAwareView>>()
fun requestFont(view: FontAwareView, context: Context) {
val requiredFonts = view.getRequiredFontNames()
for (fontName in requiredFonts) {
// 缓存中有直接回调
fontCache[fontName]?.let {
view.onFontAvailable(fontName, it)
return@for
}
// 加入等待列表
val views = waitingViews.getOrPut(fontName) { mutableListOf() }
if (!views.contains(view)) views.add(view)
// 启动下载
downloadFontIfNeeded(context, fontName)
}
}
private fun downloadFontIfNeeded(context: Context, fontName: String) {
if (fontCache.containsKey(fontName)) return // 已有,无需重复下载
val fontFile = File(context.filesDir, "fonts/$fontName.ttf")
if (fontFile.exists()) {
val typeface = Typeface.createFromFile(fontFile)
fontCache[fontName] = typeface
notifyViews(fontName, typeface)
} else {
// 下载逻辑
downloadFontFromServer(fontName, fontFile) { success ->
if (success) {
val typeface = Typeface.createFromFile(fontFile)
fontCache[fontName] = typeface
notifyViews(fontName, typeface)
} else {
// 可以记录失败日志或重试
}
}
}
}
private fun notifyViews(fontName: String, typeface: Typeface) {
waitingViews.remove(fontName)?.forEach { it.onFontAvailable(fontName, typeface) }
}
}
2、为每个字体增加版本管理体系,最简单、快速、安全的实现是增加版本号判断:
js
data class FontMeta(val version: Int, val url: String)
private val fontInfoMap = mutableMapOf<String, FontMeta>() // 需提前拉取并设置
fun setFontInfoMap(fontInfo: Map<String, FontMeta>) {
fontInfoMap.clear()
fontInfoMap.putAll(fontInfo)
}
fun requestFont(view: FontAwareView, context: Context) {
val requiredFonts = view.getRequiredFontNames()
for (fontName in requiredFonts) {
fontCache[fontName]?.let {
view.onFontAvailable(fontName, it)
continue
}
val views = waitingViews.getOrPut(fontName) { mutableListOf() }
if (!views.contains(view)) views.add(view)
downloadFontIfNeeded(context, fontName)
}
}
private fun downloadFontIfNeeded(context: Context, fontName: String) {
val meta = fontInfoMap[fontName] ?: return
val localVersion = mmkv.decodeInt("font_version_$fontName", -1)
val fontFile = File(context.filesDir, "fonts/$fontName.ttf")
if (fontFile.exists()) {
val typeface = Typeface.createFromFile(fontFile)
fontCache[fontName] = typeface
notifyViews(fontName, typeface)
// 如果版本一致则无需下载
if (meta.version == localVersion) return
}
// 下载新版本字体
downloadFontFromServer(meta.url, fontFile) { success ->
if (success) {
val newTypeface = Typeface.createFromFile(fontFile)
fontCache[fontName] = newTypeface
mmkv.encode("font_version_$fontName", meta.version)
notifyViews(fontName, newTypeface)
} else {
// 可记录失败日志或上报
}
}
}
3、对于使用到的View进行立即刷新处理,在我们的项目中因为大量使用到了自定义view,所以通过自定义view举例子:
js
class ViewBase@JvmOverloads constructor(...) : ConstraintLayout(...), FontAwareView {
override fun getRequiredFontNames(): List<String> {
return emptyList()
}
override fun onFontAvailable(fontName: String, typeface: Typeface) {
getRequiredFontNames.firstOrNull {
it == fontName
}?.let {
invalidate()
}
}
}
在继承的子view中,重写getRequiredFontNames方法,传递字体名称的list即可
js
override fun getRequiredFontNames(): List<String> {
return listOf("font1","font2")
}
4、对于存储所有字体名字和链接的json增加版本号控制
以日期控制,例如20250731,其实用什么都行,喜欢从0开始也可以,更多的理由都是偏虚的,只是我个人习惯版本以日期计算。
未来的规划
上面的逻辑已经能够解决目前的问题,但是人总要成长的,也要为后续继续的优化留下伏笔,如果这个功能让我继续优化,我可能会从下面几个点进行下手:
1、增加断点续传,避免需要大量字体文件的时候用户退出导致字体重复下载。 目前我们的字体文件都不大,以现在的网速来说其实极小的概率会遇到这种情况。
2、更新字体时应保留旧的字体,等待新的字体下载成功后进行替换,避免新的字体下载失败。 而旧的字体被覆盖导致没有字体使用的问题。目前采用的是之前的下载逻辑,以最小改动原则解决占用问题即可,所以并没有对这个问题进行处理,同时我们的字体其实很久没有改变过了。
3、增加字体md5校验,防止字体被篡改。 这个点没有在本次优化中体现,是因为我们的字体是放在应用目录中,对于大部分用户来说不太会涉及改动这里面的文件,而且我们的功能是为了用户的需求而设置的字体,如果用户自己认为有更合适的字体需要替换,我们也保留不拒绝的原则。
思考
如果是以前的我,肯定会在解决这个问题时一边蛐蛐之前的研发写的这种代码实在不"雅观",但是现在我在想,每一个项目中不可能所有功能都精雕细琢,很多功能是需要在有需求/想法的时候,满足目标快速上线,来看看用户的反应。
一个项目需要有能快速上线并且快速看到用户反馈的能力,也就应该有能够快速定位和解决问题的能力。而如果拥有随时可以重构代码的能力,那么其实不应该对"有问题的代码"产生焦虑。
完美代码肯定是一种奢望,持续可维护才是目标。我们应该允许"今天上线的是妥协方案",但不能没有"明天随时改进的能力"。
而同样在我们项目中的这个功能,过度的消耗资源并不是最大的问题,我认为最大的问题应该是对于各个环节出现了没有完全把控的情况。这是系统失控的信号,我可能会问问自己以及我们的团队:
- 是否缺乏功能生命周期管理,功能上线后存在没有持续评估、演化路径不清晰的问题。
- 在其他地方是否还有类似的技术债务和妥协导致的风险或者问题。
- 功能设计之初是如何通过需求评审的,是否设立了「使用边界」与「增长策略」,当时是因为功能设计缺乏前瞻性,还是设计之初只涉及到了1-2个字体的下载并且急需快速上线,而后续的扩张忽略了相应的升级和适配?