ITPub博客

首页 > 移动开发 > ANDROID > 一次Android下载优化,CDN消耗占比从50+%到1%

一次Android下载优化,CDN消耗占比从50+%到1%

原创 ANDROID 作者:山有木xi 时间:2025-07-31 19:10:32 0 删除 编辑

Android 的功能开发中,难免会遇到功能需求或者 UI 设计,需要使用单独的字体显示,而这种需求多了之后,如果所有的字体文件都通过内嵌的方式携带会导致整体的包体非常肿大。那么统一的做法应该就是通过服务端下发字体文件。

 

而在我们应用中,大量的使用到了很多不同的字体,在大约 1 年多前,我们上线了统一下载和管理字体的业务逻辑,但是当时负责这个功能的研发因为未知的原因,采用了启动应用下载所有字体的方案。最近我们服务端的老大跑来吐槽我们说整个公司的 CDN 费用一大半花在我们的这个字体下载上面了。


有问题的代码逻辑:

具体步骤如下:

1 、启动应用,判断 1 个小时内是否下载过。

2 、 1 个小时内没有下载过新的字体的情况下,获取服务端关于字体的 json 文件,里面记录了字体的名称和 cdn 地址一次性下载所有字体。

3 、对于需要使用下载字体的 View ,判断本地是否有字体文件,有则立即使用,没有则采用默认系统字体的方式。

 

这样的步骤有下面一些问题:

1、 一次性下载所有字体,但是我们的实际项目中, 90% 的用户只会使用到其中 1-5 个字体,但是需要承担下载几十上百甚至未来几百个字体的流量和存储,而我们的 CDN 费用也会居高不下,按需加载对我们和用户来说都是最合理的方案。

2、 缓存时间设置为 1 个小时,但是我们的字体在上线的 1 年多时间内,其实并没有发生改动,也就是说,用户只需要用到同一个没有发生过改变的字体的情况下,需要承担无数次全部字体的下载,而我们的 CDN 资源的消耗的最大的来源也是这一块。

3、 对于下载成功后的字体没有立即使用,而是依赖于下一次触发刷新 View 来使用字体,如果用户没有触发到下一次刷新,就没有办法看到字体的效果,对于产品测的预期来说其实是有偏差的。

4、下载的字体并没有有效的管理机制,对于未发生改变的字体不应该重复的下载,对于字体集合应该能有效控制哪些字体需要重新下载,哪些字体可以直接采用已经下载过的。

 

优化方案

1、取消一次性下载所有字体,封装字体下载相关的逻辑,按需申请下载资源:

interface FontAwareView {
    fun getRequiredFontNames(): List<String>
    fun onFontAvailable(fontName: String, typeface: Typeface)
}
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、为每个字体增加版本管理体系,最简单、快速、安全的实现是增加版本号判断:

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 举例子:

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 即可

    override fun getRequiredFontNames(): List<String> {
        return listOf("font1","font2")
    }

 

4、 对于存储所有字体名字和链接的 json 增加版本号控制

以日期控制,例如 20250731 ,其实用什么都行,喜欢从 0 开始也可以,更多的理由都是偏虚的,只是我个人习惯版本以日期计算。

 

未来的规划

上面的逻辑已经能够解决目前的问题,但是人总要成长的,也要为后续继续的优化留下伏笔,如果这个功能让我继续优化,我可能会从下面几个点进行下手:

1、 增加断点续传,避免需要大量字体文件的时候用户退出导致字体重复下载。目前我们的字体文件都不大,以现在的网速来说其实极小的概率会遇到这种情况。

2、 更新字体时应保留旧的字体,等待新的字体下载成功后进行替换,避免新的字体下载失败。而旧的字体被覆盖导致没有字体使用的问题。目前采用的是之前的下载逻辑,以最小改动原则解决占用问题即可,所以并没有对这个问题进行处理,同时我们的字体其实很久没有改变过了。

3、 增加字体 md5 校验,防止字体被篡改。 这个点没有在本次优化中体现,是因为我们的字体是放在应用目录中,对于大部分用户来说不太会涉及改动这里面的文件,而且我们的功能是为了用户的需求而设置的字体,如果用户自己认为有更合适的字体需要替换,我们也保留不拒绝的原则。

 

思考

如果是以前的我,肯定会在解决这个问题时一边蛐蛐之前的研发写的这种代码实在不“雅观”,但是现在我在想,每一个项目中不可能所有功能都精雕细琢,很多功能是需要在有需求 / 想法的时候,满足目标快速上线,来看看用户的反应。

 

一个项目需要有能快速上线并且快速看到用户反馈的能力,也就应该有能够快速定位和解决问题的能力。而如果拥有随时可以重构代码的能力,那么其实不应该对“有问题的代码”产生焦虑。

 

完美代码 肯定 是 一种 奢望,持续可维护才是目标。我们应该允许 “ 今天上线的是妥协方案 ” ,但不能没有 “ 明天随时改进的能力 ” 。

 

而同样在我们项目中的这个功能,过度的消耗资源并不是最大的问题,我认为最大的问题应该是对于各个环节出现了没有完全把控的情况 。这是系统失控的信号,我可能会问问自己以及我们的团队:

1 、是否缺乏功能生命周期管理,功能上线后 存在没有持续评估、演化路径不清晰的问题。

2 、在其他地方是否还有类似的技术债务和妥协导致的风险或者问题 。

3 、功能设计之初是如何通过需求评审的,是否设立了「使用边界」与「增长策略」,当时是因为 功能设计 缺乏前瞻性,还是设计之初只涉及到了1-2 个字体的下载并且急需快速上线,而后续的 扩张忽略了 相应的升级和适配?

 

 


来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/69917874/viewspace-3092519/ ,如需转载,请注明出处,否则将追究法律责任。

请登录后发表评论 登录
全部评论
Oracle OCA(Java),IBM高级工程师认证,中国软件行业人才(高级c语言),高级Android工程师,对数据库并发与性能调优也有一定了解

注册时间:2019-04-25

  • 博文量
    147
  • 访问量
    390750