Android 包体积资源优化实践

news2024/10/6 16:18:25

1 插件优化

插件优化资源在得物App最新版本上收益12MB。插件优化的日志在包体积平台有具体的展示,也是为了提供一个资源问题追溯的能力。

1.1 插件环境配置

插件首先会初始化环境配置,如果机器上未安装运行环境则会去oss下载对应的可执行文件。

1.2 图片压缩

在开发阶段,开发同学首先会通过TinyPNG等工具主动对图片进行压缩,而对于三方库和一些业务遗漏处理的图片则会在打包的时候通过gradle插件进行压缩。

图片压缩插件使用 cwebp 对图片进行webp转换,使用 guetzli 对JPEG进行压缩,使用pngquant对PNG 进行压缩,使用 gifsicle 对gif进行压缩。在实施对过程中,对于 res 目录下的文件优先使用 webp 处理,对assets 目录下的文件则进行同格式压缩。下面先介绍下资源压缩插件的工作模式和原理。

1.2.1 Res图片压缩

  • 第一步,找到并遍历 ap_ 文件

这里对 ap_ 文件进行一下简单介绍,ap_ 文件是由 AAPT2 生成的,AAPT2(Android 资源打包工具)是一种构建工具,Android Studio 和 Android Gradle 插件使用它来编译和打包应用的资源。AAPT2 会解析资源、为资源编制索引,并将资源编译为针对 Android 平台进行过优化的二进制格式。

AAPT2这个工具在打包过程中主要做了下列工作:把"assets"和"res/raw"目录下的所有资源进行打包(会根据不同的文件后缀选择压缩或不压缩),而"res/“目录下的其他资源进行编译或者其他处理(具体处理方式视文件后缀不同而不同,例如:”.xml"会编译成二进制文件,".png"文件会进行优化等等)后才进行打包;会对除了assets资源之外所有的资源赋予一个资源ID常量,并且会生成一个资源索引表resources.arsc;编译AndroidManifest.xml成二进制的XML文件;把上面3个步骤中生成结果保存在一个*.ap_文件,并把各个资源ID常量定义在一个 R.java\ R.txt中;

  • 第二步,解压 ap_ 文件,找到 res/drawable 、res/mipmap 、res/raw 目录下的图片进行压缩
fun compressImg(imgFile: File): Long {
    if (ImageUtil.isJPG(imgFile) || ImageUtil.isGIF(imgFile) || ImageUtil.isPNG(imgFile)) {
        val lastIndexOf = imgFile.path.lastIndexOf(".")
        if (lastIndexOf < 0) {
            println("compressImg ignore ${imgFile.path}")
            return 0
        }
        val tempFilePath =
                "${imgFile.path.substring(0, lastIndexOf)}_temp${imgFile.path.substring(lastIndexOf)}"

        if (ImageUtil.isJPG(imgFile)) {
            Tools.cmd("guetzli", "--quality 85 ${imgFile.path} $tempFilePath")
        } else if (ImageUtil.isGIF(imgFile)) {
            Tools.cmd("gifsicle", "-O3 --lossy=25 ${imgFile.path} -o $tempFilePath")
        } else if (ImageUtil.isPNG(imgFile)) {
            Tools.cmd(
                    "pngquant",
                    "--skip-if-larger --speed 1 --nofs --strip --force  --quality=75  ${imgFile.path} --output $tempFilePath"
            )
        }
        val oldSize = imgFile.length()
        val tempFile = File(tempFilePath)
        val newSize = tempFile.length()
        return if (newSize in 1 until oldSize) {
            val imgFileName: String = imgFile.path
            if (imgFile.exists()) {
                imgFile.delete()
            }
            tempFile.renameTo(File(imgFileName))
            oldSize - newSize
        } else {
            if (tempFile.exists()) {
                tempFile.delete()
            }
            0L
        }
    }
    return 0
}

图片的压缩收益最大,且实施简单,风险最低,是资源优化的首选。

1.2.2 Assets图片压缩

Assets 图片压缩的处理方式与 res 下差不多,区别仅仅在于挂载的 task 与 压缩模式不同,Assets 下单资源由于是通过 AssetsManager 按照名称获取的,且使用场景不可控,无法明确感知业务使用对格式是否有要求的前提下,同格式压缩是相对稳妥的方案。

val mergeAssets = project.tasks.getByName("merge${variantName}Assets")
mergeAssets.doLast { task ->
    (task as MergeSourceSetFolders).outputDir.asFileTree.files.filter {
        val originalPath = it.absolutePath.replace(task.outputDir.get().toString() + "/", "")
        val filter = context.compressAssetsExtension.whiteList.contains(originalPath)
        if (filter) {
            println("Assets compress ignore:$originalPath")
        }
        !filter
    }.forEach { file ->
        val originalPath = file.absolutePath.replace(task.outputDir.get().toString() + "/", "")
        val reduceSize = CompressUtil.compressImg(file)
        if (reduceSize > 0) {
            assetsShrinkLength += reduceSize
            assetsList.add("$originalPath => reduce[${byteToSize(reduceSize)}]")
        }
    }
    println("assets optimized:${byteToSize(assetsShrinkLength)}")
}

1.3 资源去重

相较于压缩,资源的去重需要对arsc文件格式有一点了解。为了便于理解,这里先对arsc二进制文件进行一点简单的介绍。resource.arsc文件是Apk打包过程中的产生的一个资源索引文件,它是一个二进制文件,源码ResourceTypes.h 定义了其数据结构。通过学习resource.arsc文件结构,可以帮助我们深入了解apk包体积优化中使用到的 重复资源删除、资源文件名混淆 技术。

将apk使用AS 打开也能看到resource.arsc中存储的信息

说回到资源去重,去重打原理很简单,找到资源文件目录下相同的文件,然后删除掉重复的文件,最后到 arsc 中修改记录,将删除的文件索引名称进行替换。

由于删除重复资源在 arsc 中只是对常量池中路径替换,并没有删除 arsc 中的记录,也没有修改PackageChunk 中的常量池内容,也就是对应上图中的 Name 字段,故而重复资源的删除安全性比较高。

下面介绍下具体实施方案:

  • 第一步遍历ap文件,通过 crc32 算法找出相同文件。之所以选择 crc32 是因为 gralde 的 entry file 自带 crc32 值,不需要进行额外计算,但是 crc32 是有冲突风险的,故而又对 crc32 的重复结果进行 md5 二次校验。
  • 第二步则是对原始重复文件的删除
  • 第三步修改 ResourceTableChunk 常量池内容,进行资源重定向

// 查询重复资源
val groupResources = ZipFile(apFile).groupsResources()
// 获取
val resourcesFile = File(unZipDir, "resources.arsc")
val md5Map = HashMap<String, HashSet<ZipEntry>>()
val newResouce = FileInputStream(resourcesFile).use { stream ->
    val resouce = ResourceFile.fromInputStream(stream)
    groupResources.asSequence()
        .filter { it.value.size > 1 }
        .map { entry ->
            entry.value.forEach { zipEntry ->
                if (whiteList.isEmpty() || !whiteList.contains(zipEntry.name)) {
                    val file = File(unZipDir, zipEntry.name)
                    MD5Util.computeMD5(file).takeIf { it.isNotEmpty() }?.let {
                        val set = md5Map.getOrDefault(it, HashSet())
                        set.add(zipEntry)
                        md5Map[it] = set
                    }
                }
            }
            md5Map.values
        }
        .filter { it.size > 1 }
        .forEach { collection ->
            // 删除多余资源
            collection.forEach { it ->
                val zips = it.toTypedArray()
                // 所有的重复资源都指定到这个第一个文件上
                val coreResources = zips[0]
                for (index in 1 until zips.size) {
                    // 重复的资源
                    val repeatZipFile = zips[index]
                    result?.add("${repeatZipFile.name} => ${coreResources.name}    reduce[${byteToSize(repeatZipFile.size)}]")
                    // 删除解压的路径的重复文件
                    File(unZipDir, repeatZipFile.name).delete()
                    // 将这些重复的资源都重定向到同一个文件上
                    resouce
                        .chunks
                        .filterIsInstance<ResourceTableChunk>()
                        .forEach { chunk ->
                            val stringPoolChunk = chunk.stringPool
                            val index = stringPoolChunk.indexOf(repeatZipFile.name)
                            if (index != -1) {
                                // 进行剔除重复资源
                                stringPoolChunk.setString(index, coreResources.name)
                            }
                        }
                }
            }
        }

    resouce
}

1.4 资源混淆

资源混淆则是在资源去重打基础上更进一步,与代码混淆的思路一致,用长路径替换短路径,一来减小文件名大小,二来降低arsc中常量池中二进制文件大小。

长路径替换短路径修改 ResourceTableChunk 即可,与重复资源处理如出一辙。

同时我们发现 PackageChunk 中常量池中字段还是原来的内容,但是并不影响apk的运行。因为通过getDrawable(R.drawable.xxx)方式加载的资源在编译后对应的是getDrawable(0x7f08xxxx)这种16进制的内容,其实就是与 arsc 中的 ID 对应,用不上 Name 字段。而通过getResources().getIdentifier()方式调用的我们通过白名单keep住了,Name 字段在这里也是可以移除的。


        val resourcesFile = File(unZipDir, "resources.arsc")
        val newResouce = FileInputStream(resourcesFile).use { inputStream ->
            val resouce = ResourceFile.fromInputStream(inputStream)
            resouce
                .chunks
                .filterIsInstance<ResourceTableChunk>()
                .forEach { chunk ->
                    val stringPoolChunk = chunk.stringPool
                    // 获取所有的路径
                    val strings = stringPoolChunk.getStrings() ?: return@forEach

                    for (index in 0 until stringPoolChunk.stringCount) {
                        val v = strings[index]

                        if (v.startsWith("res")) {
                            if (ignore(v, context.proguardResourcesExtension.whiteList)) {
                                println("resProguard  ignore  $v ")
                                // 把文件移到新的目录
                                val newPath = v.replaceFirst("res", whiteTempRes)
                                val parent = File("$unZipDir${File.separator}$newPath").parentFile
                                if (!parent.exists()) {
                                    parent.mkdirs()
                                }
                                keeps.add(newPath)
                                // 移动文件
                                File("$unZipDir${File.separator}$v").renameTo(File("$unZipDir${File.separator}$newPath"))
                                continue
                            }
                            // 判断是否有相同的
                            val newPath = if (mappings[v] == null) {
                                val newPath = createProcessPath(v, builder)
                                // 创建路径
                                val parent = File("$unZipDir${File.separator}$newPath").parentFile
                                if (!parent.exists()) {
                                    parent.mkdirs()
                                }
                                // 移动文件
                                val isOk =
                                    File("$unZipDir${File.separator}$v").renameTo(File("$unZipDir${File.separator}$newPath"))
                                if (isOk) {
                                    mappings[v] = newPath
                                    newPath
                                } else {
                                    mappings[v] = v
                                    v
                                }
                            } else {
                                mappings[v]
                            }
                            strings[index] = newPath!!
                        }
                    }

                    val str2 = mappings.map {
                        val startIndex = it.key.lastIndexOf("/") + 1
                        var endIndex = it.key.lastIndexOf(".")

                        if (endIndex < 0) {
                            endIndex = it.key.length
                        }
                        if (endIndex < startIndex) {
                            it.key to it.value
                        } else {
//                            val vStartIndex = it.value.lastIndexOf("/") + 1
//                            var vEndIndex = it.value.lastIndexOf(".")
//                            if (vEndIndex < 0) {
//                                vEndIndex = it.value.length
//                            }
//                            val result = it.value.substring(vStartIndex, vEndIndex)
                            // 使用相同的字符串,以减小体积
                            it.key.substring(startIndex, endIndex) to "du"
                        }
                    }.toMap()

                    // 修改 arsc PackageChunk 字段
                    chunk.chunks.values.filterIsInstance<PackageChunk>()
                        .flatMap { it.chunks.values }
                        .filterIsInstance<StringPoolChunk>()
                        .forEach {
                            for (index in 0 until it.stringCount) {
                                it.getStrings()?.forEachIndexed { index, s ->
                                    str2[s]?.let { result ->
                                        it.setString(index, result)
                                    }
                                }
                            }
                        }

                    // 将 mapping 映射成 指定格式文件,供给反混淆服务使用
                    val mMappingWriter: Writer = BufferedWriter(FileWriter(file, false))
                    val packageName = context.proguardResourcesExtension.packageName
                    val pathMappings = mutableMapOf<String, String>()
                    val idMappings = mutableMapOf<String, String>()
                    mappings.filter { (t, u) -> t != u }.forEach { (t, u) ->
                        result?.add(" $t => $u")
                        compress[t]?.let {
                            compress[u] = it
                            compress.remove(t)
                        }
                        val pathKey = t.substring(0, t.lastIndexOf("/"))
                        pathMappings[pathKey] = u.substring(0, u.lastIndexOf("/"))
                        val typename = t.split("/")[1].split("-")[0]
                        val path1 = t.substring(t.lastIndexOf("/") + 1, t.indexOf("."))
                        val path2 = u.substring(u.lastIndexOf("/") + 1, u.indexOf("."))
                        val path = "$packageName.R.$typename.$path1"
                        val pathV = "$packageName.R.$typename.$path2"
                        if (idMappings[path].isNullOrEmpty()) {
                            idMappings[path] = pathV
                        }
                    }
                    generalFileResMapping(mMappingWriter, pathMappings)
                    generalResIDMapping(mMappingWriter, idMappings)
                }

            // 删除res下的文件
            FileOperation.deleteDir(File("$unZipDir${File.separator}res"))
            // 将白名单的文件移回res
            keeps.forEach {
                val newPath = it.replaceFirst(whiteTempRes, "res")
                val parent = File("$unZipDir${File.separator}$newPath").parentFile
                if (!parent.exists()) {
                    parent.mkdirs()
                }
                File("$unZipDir${File.separator}$it").renameTo(File("$unZipDir${File.separator}$newPath"))
            }
            // 收尾删除 res2
            FileOperation.deleteDir(File("$unZipDir${File.separator}$whiteTempRes"))
            resouce
        }
  • 白名单配置必不可少,保证反射调用资源不参与混淆

  • createProcessPath 用于将长路径修改为短路径

  • 修改 PackageChunk 中的常量池,用于极致的包体裁剪,未压缩前减小包体300kb,arsc压缩后降低包体70kb

  • 生成资源混淆mapping文件,提供给包体积服务进行资源名称还原使用

资源混淆的落地过程必须要谨慎,对存量代码,在得物app中我们先通过字节码扫描找出所有反射调用资源的地方,配置keep文件。对于后续业务开发中新增的反射调用则通过测试流程及早发现问题。

1.5 ARSC压缩

Arsc 压缩降低的体积非常可观,压缩后的arsc 700kb,未压缩的约 7MB。实施起来通过 7zip对 arsc文件压缩即可。

但是 Target Sdk 在30以上 arsc 压缩被禁了。压缩 resources.arsc 虽然能带来包体上的收益,但也有弊端,它将带来内存和运行速度上的劣势。不压缩的resources.arsc系统可以使用mmap来节约内存的使用(一个app的资源至少被3个进程所持有:自己, launcher, system),而压缩的resources.arsc会存在于每个进程中。

2 资源下发

Apk 中的存量大资源在打包后包体积平台检测出来,针对问题资源排期处理。动态下发和无用删除则是处理存量资源的常用手段,同时通过 CI 前置管控新增资源过大的情况。

资源下发的主体主要是 so 文件和图片,对下发的资源的管控则需可以通过平台化管理。堵不如疏,能下发的资源就下发是包体优化的一大利器。

下发的资源通过动态资源管理平台进行处理

3 无用资源删除

无用资源的检测结合bytex的 resCheck 编译期 与 matrix-apk-canary smail 扫描的结果,将业务可以处理的部分在平台上展示,版本迭代过程中边迭代边治理,能够有效防止无用资源的持续恶化。

4 总结

本文主要介绍了得物APP资源优化做了的一些动作,其中对资源优化插件的工作模式进行了重点介绍。当然,对于资源依旧有不少手段可以完善,比如提供高效简单的 9 图下发方案,包体积平台增加图片相似度检测能力、把一些次级的资源通过插件包下发都是之后可以尝试的地方。

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/784166.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Windows 在VMware16.x安装Win11系统详细教程

文章目录 一、准备二、创建虚拟机1. 创建新的虚拟机2. 选择虚拟机硬件兼容性3. 安装客户机操作系统4. 选择客户机操作系统5. 命名虚拟机6. 固件类型7. 处理器配置8. 此虚拟机内存9. 网络类型10. 选择I/O控制器类型11. 选择磁盘类型12. 选择磁盘13. 指定磁盘容量14. 指定磁盘文件…

【深度学习】日常笔记15

训练集和测试集并不来⾃同⼀个分布。这就是所谓的分布偏移。 真实⻛险是从真实分布中抽取的所有数据的总体损失的预期&#xff0c;然⽽&#xff0c;这个数据总体通常是⽆法获得的。计算真实风险公式如下&#xff1a; 为概率密度函数 经验⻛险是训练数据的平均损失&#xff0c;⽤…

python机器学习(四)线性代数回顾、多元线性回归、多项式回归、标准方程法求解、线性回归案例

回顾线性代数 矩阵 矩阵可以理解为二维数组的另一种表现形式。A矩阵为三行两列的矩阵&#xff0c;B矩阵为两行三列的矩阵&#xff0c;可以通过下标来获取矩阵的元素&#xff0c;下标默认都是从0开始的。 A i j : A_{ij}: Aij​:表示第 i i i行&#xff0c;第 j j j列的元素。…

N位分频器的实现

N位分频器的实现 一、 目的 使用verilog实现n位的分频器&#xff0c;可以是偶数&#xff0c;也可以是奇数 二、 原理 FPGA中n位分频器的工作原理可以简要概括为: 分频器的作用是将输入时钟频率分频,输出低于输入时钟频率的时钟信号。n位分频器可以将输入时钟频率分频2^n倍…

linux进阶-I.MX 6ULL

目录 启动模式&#xff08;8引脚设置启动模式&#xff09; 对应原理图 boot ROM程序 空偏移 映像向量表&#xff08;Image vector table&#xff0c;IVT&#xff09; IVT结构体 Boot data DCD&#xff08;外设寄存器配置信息&#xff0c;初始化关键外设&#xff09; NXP…

如何使用 After Effects 导出摄像机跟踪数据到 3ds Max

推荐&#xff1a; NSDT场景编辑器助你快速搭建可二次开发的3D应用场景 在本教程中&#xff0c;我将展示如何在After Effects中跟踪实景场景&#xff0c;然后将相机数据导出到3ds Max。 1. 项目设置 步骤 1 打开“后效”。 打开后效果 步骤 2 转到合成>新合成以创建新合…

Docker Compose(九)

一、背景&#xff1a; 对于现代应用来说&#xff0c;大多数都是通过很多的微服务互相协同组成一个完整的应用。例如&#xff0c;订单管理、用户管理、品类管理、缓存服务、数据库服务等&#xff0c;他们构成了一个电商平台的应用。而部署和管理大量的服务容器是一件非常繁琐的事…

【图像处理】使用自动编码器进行图像降噪(改进版)

阿里雷扎凯沙瓦尔兹 一、说明 自动编码器是一种学习压缩和重建输入数据的神经网络。它由一个将数据压缩为低维表示的编码器和一个从压缩表示中重建原始数据的解码器组成。该模型使用无监督学习进行训练&#xff0c;旨在最小化输入和重建输出之间的差异。自动编码器可用于降维、…

宋浩线性代数笔记(二)矩阵及其性质

更新线性代数第二章——矩阵&#xff0c;本章为线代学科最核心的一章&#xff0c;知识点多而杂碎&#xff0c;务必仔细学习。 重难点在于&#xff1a; 1.矩阵的乘法运算 2.逆矩阵、伴随矩阵的求解 3.矩阵的初等变换 4.矩阵的秩 &#xff08;去年写的字&#xff0c;属实有点ugl…

Android 之 Canvas API 详解 (Part 2) 剪切方法合集

本节引言&#xff1a; 本节继续带来Android绘图系列详解之Canvas API详解(Part 2)&#xff0c;今天要讲解的是Canvas 中的ClipXxx方法族&#xff01;我们可以看到文档中给我们提供的Clip方法有三种类型&#xff1a; clipPath( )&#xff0c;clipRect( )&#xff0c;clipRegion(…

Mybatis的基本操作--增删改查

目录 查看数据 无参数 一个参数 多个参数 添加数据 修改数据 删除数据 查看数据 分三种情况&#xff1a;无参&#xff0c;有一个参数&#xff0c;有多个参数的情况。 &#xff08;这里的详细操作步骤是博主的上一篇博客写的&#xff1a;初识Mybatis&#xff0c;并创建第…

2023年VSCode插件最新推荐(54款)

本文介绍前端开发领域常用的一些VSCode插件&#xff0c;插件是VSCode最重要的组成部分之一&#xff0c;本文列出了我自己在以往工作经验中积累的54款插件&#xff0c;个人觉得这些插件是有用或有趣的&#xff0c;根据它们的作用&#xff0c;我粗略的把它们分成了代码管理、文本…

css实现纹理条纹,波点背景效果

css实现纹理条纹&#xff0c;波点背景效果 本文目录 css实现纹理条纹&#xff0c;波点背景效果效果一&#xff1a;水平条纹效果二&#xff1a;竖向条纹效果三&#xff1a;斜条纹效果四&#xff1a;网格效果五&#xff1a;象棋盘1效果六&#xff1a;象棋盘2效果七&#xff1a;红…

代码重构的时机与方法

&#x1f431; 个人主页&#xff1a;不叫猫先生&#xff0c;公众号&#xff1a;前端舵手 &#x1f64b;‍♂️ 作者简介&#xff1a;2022年度博客之星前端领域TOP 2&#xff0c;前端领域优质作者、阿里云专家博主&#xff0c;专注于前端各领域技术&#xff0c;共同学习共同进步…

机器人制作开源方案 | 智能垃圾桶

1. 功能说明 智能垃圾桶是一种利用物联网技术和智能感知能力的智能设备&#xff0c;旨在提高垃圾分类和处理的效率。通常具备以下特点和功能&#xff1a; ① 智能感知&#xff1a;智能垃圾桶配备各种传感器&#xff0c;如压力传感器、红外线传感器等&#xff0c;可以实时感知…

Flutter实现点击头像更新头像的功能,本地相册选取和调用相机两种方式的实现

文章目录 需求实现的效果如图代码实现代码分析用InkWell包住了我们的头像&#xff0c;这样来监听点击头像的事件用showDialog弹出提示框让用户选择是从相册里选择头像还是使用相机来拍照用image_picker实现从设备的相册或相机中选择图片或拍照 需求 Flutter实现点击头像更新头…

数字孪生管控系统,智慧园区楼宇合集

智慧园区是指将物联网、大数据、人工智能等技术应用于传统建筑和基础设施&#xff0c;以实现对园区的全面监控、管理和服务的一种建筑形态。通过将园区内设备、设施和系统联网&#xff0c;实现数据的传输、共享和响应&#xff0c;提高园区的管理效率和运营效益&#xff0c;为居…

【java】【基础8】入门结业-ATM系统实战

目录 一、ATM项目技术 二、能达成的能力 三、开始编码 3.1 系统架构搭建、欢迎页设计 3.1.1 Account 3.1.2 ATM 3.1.3 Test 3.2 开户功能实现 3.2.1 修改AccountgetUserName() 3.2.2 ATM 开户操作 3.2.3 ATM为新用户生成一个随机卡号​编辑 3.3 登录功能实现 3.4 操作页…

登录和注册页面 - 验证码功能的实现

目录 1. 生成验证码 2. 将本地验证码发布成 URL 3. 后端返回验证码的 URL 给前端 4. 前端将用户输入的验证码传给后端 5. 后端验证验证码 1. 生成验证码 使用hutool 工具生成验证码. 1.1 添加 hutool 验证码依赖 <!-- 验证码 --> <dependency><groupId…