乐趣区

关于android:Android插件化中资源错乱的解决方案

本文介绍了 Android 插件化框架中,插件应用宿主资源时资源错乱的问题,以及错乱的起因、业界通用解决方案、咱们提出的优化计划。

本文将依照如下程序,循序渐进地进行解说:

  • 简略介绍 Android 插件化中资源局部的动态化。
  • 简略介绍 Android 中资源的一些基础知识、应用形式及其编译原理。
  • 介绍插件化场景下呈现的资源错乱问题及业界通用的解决方案。
  • 介绍一种新的计划——免资源固定计划,用于解决资源错乱问题。
  • 独自介绍一下免资源固定计划中的一个技术点:批改 apk 中的资源文件。

一、Android 插件化中资源的动态化

Android 倒退了这么多年,市面上涌现出许多插件化 / 热修复框架,无论是插件化还是热修复,都是为了实现对主 apk 以外内容的动态化,这些内容包含 dex(class)、res(资源)、so(动静库)等。对于每一种内容,业界都有许多实现计划,只管计划各不相同,但底层原理都差不多,网上也有许多文章和开源我的项目能够学习参考。

名词解释:
宿主 :间接装置到用户手机上的 App,宿主中的代码在宿主装置到用户手机上的那一刻就定死了,不能再扭转了(热修复也只是让谬误的逻辑不走而已,并没有扭转原有的代码)。
插件:独立于宿主之外的一个文件。须要被宿主动静加载的 class、res、so 等的汇合。(热修复中这部分通常称为 patch,这里为了不便,就叫插件吧)
java 代码:为了形容不便,apk 中的 dex 在编译前一律称为 java 代码,编译后一律称为 dex(这个说法不精确,不要被我误导了,个别为 java/kotlin->class->dex)

说到 Android 资源的动态化,思路都大同小异:

  • 为每个插件创立一个 Resources 或者把插件的资源门路增加到宿主 AssetManager,从而能够顺利的加载到插件资源。
  • 插件编译时通过配置 aapt2 参数对插件中资源 id 的 packageId 局部进行批改,保障插件与宿主资源 id 不抵触。
  • 对于插件中应用到的宿主资源,利用 aapt2 参数进行资源固定,保障宿主降级后插件应用到的宿主资源 id 不变。

aapt2 的呈现使资源固定、packageId 批改变得容易了很多!

只管 Android 资源的动态化技术曾经非常成熟,然而在实际过程中还是有许多有余,比方“资源固定”就常常被业务同学吐槽。

二、Android 中的资源介绍

在介绍资源固定之前,首先简略介绍一下 Android 中资源相干的基础知识。

2.1 Android 中的资源 id

Android 代码在编译成 apk 之后,每个资源都对应一个惟一的资源 id,资源 id 是一个 8 位的 16 进制 int 值 0xPPTTEEEE:

  • PP:前两位是 PackageId 字段,系统资源是 01,宿主资源 id 是 7f,其余如厂商自定义的皮肤包、webview 插件资源包会占用 02、03……,因而 App 资源和系统资源永远不会抵触。市面上的插件框架为了保障插件和宿主资源不抵触,通常会把插件资源的 PP 改为其余值,如 7e、7d。
  • TT:两头两位是 TypeId 字段,示意资源的类型,如 anim、drawable、string 等,这块没有严格的对应关系,通常是依照字母程序调配 type 值。
  • EEEE:最初四位是 EntryId 字段,用于辨别同一个 PackageId、同一个 TypeId 下不同 name 的资源,通常也是依照字母程序进行调配的。

留神:

  • 资源 id 的调配默认是按资源的字母排序进行的,也就是说,当新增一个 name 为 a 的资源,从新编译之后,a 前面的同类型的资源 id 值都会被扭转。
  • aapt2 中提供了参数能够对资源 id 调配形式进行干涉,aapt2 会优先依照参数中配置的对应关系调配 id,这个技术咱们称之为资源固定,也是目前插件化框架在解决资源错乱问题中用的最多的技术。

2.2 Android 中的资源应用形式

Android 中应用资源通常有两种形式:

在 java 代码中通过 R 的外部类进行拜访,具体语法为:

[<package_name>].R.<resource_type>.<resource_name>

在 xml 中通过符号应用,具体语法为:

@[<package_name>:]<resource_type>/<resource_name>

xml 中也能够通过? 代替 @的模式属性。也能够引入自定义属性,如 android:layout_width。这两种用法不影响下文的介绍。

那么这两种形式有什么区别呢?

从代码书写的角度来说,都是通过一个资源名称(resource_name)来拜访资源。咱们反编译一下 apk,看看编译后是什么样的。

别离在我的项目 app module、library module、xml 中编写如下代码:

咱们反编译一下 apk,看看这三种代码在 apk 中是如何体现的。

能够发现 appTest 办法和 xml 中的资源变成了数字(0x7f0e0069),libTest 办法中的资源仍旧是通过 Lcom/bytedance/lib/R$string;->test 拜访的

论断:

  • 主 module 中援用的资源被编译成了数值;
  • 子 module、aar 中通过 R 的外部类间接援用数值;
  • xml 中的资源 id 全副编译成了数值。(看上图中 xml 的属性——layout_width 等仍旧是字符串,其实它背地也是资源 id 数值,这块的字符串其实是没有用的,甚至在一些包体积优化中能够间接去掉,后续抖音会发一篇相干的文章介绍该技术)

那么为什么 libTest 办法中是通过 field 援用,而 appTest 中就变成数字了呢?

2.3 Android 中资源编译的简略流程

假如有一个工程,只有一个 app module,通过 maven 仓库依赖若干三方 aar,我的项目编译时的简化流程如下图:

⒈下载三方 aar。

⒉将 app module 和三方 aar 中的资源通过 aapt2 进行编译、链接,最终生成 R.jar 和 ap_。

  • R.jar 蕴含了最终打入 apk 的所有 R.class,每个依赖对应一个。aapt2 也会默认依照字母排序为每个资源分配惟一的 id 值。留神:新增删除一个资源都会导致它前面的资源 id 扭转。aapt2 容许通过配置干涉 id 的调配。
  • ap_文件中蕴含了所有编译好的资源文件。
  • App module 的 java 文件与 R.jar 一起被 javac 编译。因为 R.jar 中的 field 都是 final,因而 app module 中通过 R 援用的资源全副被内敛成了数值。而三方 aar 中因为曾经是 class,无需进行编译,因而仍旧是通过 R 援用来应用资源。

⒊最初把 app module 编译进去的.class、三方 aar 中的.class 转成 dex,与 ap_一起压缩到 apk 中。

⒋因而就很容易了解为啥 libTest 中仍旧是通过 R 来应用资源,而 appTest 中通过数值间接援用(被内联)。

因而就很容易了解为啥 libTest 中仍旧是通过 R 来应用资源,而 appTest 中通过数值间接援用(被内联)。

libTest module 尽管被 app module 通过源码依赖,然而在资源编译这块其实是相似的,这里不开展介绍。

2.4 总结

Android 中的资源的无论是通过 java 代码应用还是 xml 应用,最终都是通过资源 id 值进行查找的。

把 apk 拖到 as 中,查看 resources.arsc 文件,能够看到它外面蕴含了 apk 中所有资源的 id 索引,以及该资源名对应的真正资源或值。很容易想到,App 运行起来也是通过资源 id 值通过这个资源表来查找真正的资源内容。

三、插件应用宿主资源

3.1 插件如何应用宿主资源

设想一下,咱们想要把 App 的直播性能做成一个插件动静下发,直播性能所须要的大部分资源都在直播插件中,然而总有一些资源来自宿主,如一些通用的 UI 组件中蕴含的资源(support/androidx 库)等。

那么,假如宿主中有一张图片名为 icon,直播插件中的 xml 通过 @drawable/icon 援用了这张图片,同时也在代码中通过 R.drawable.icon 援用了它,理论直播插件中是没有 icon 这张图片的,它存在于宿主中。宿主编译完后,依照后面的知识点,宿主中的 icon 对应的数值被编译成 0x7f010001。

插件自身也是一个 apk,依据后面介绍的知识点,插件编译实现后,xml 中的 @drawable/icon 会编成一个数值(0x7f010001),java 代码中的 R.drawable.icon 也会间接或间接编成一个数值(0x7f010001)。当这个插件运行在宿主上,依照后面的介绍,插件会去查找 0x7f010001,发现能够找到,这样就正确的应用了宿主资源。

插件编译时咱们会做一些解决,使插件中能够援用到宿主 id。

3.2 插件应用宿主资源有什么问题

前文介绍过,新增或删除一个资源都可能导致其余许多资源的 id 被扭转。

咱们的宿主编译进去后 icon 为 0x7f010001,基于已有的宿主编译出一个插件后,插件中援用的 icon 也是 0x7f010001,此时没什么问题。

宿主迭代后,新增了一个新的资源 aicon,依照后面介绍的资源 id 调配规定,新版本的宿主中 aicon 的 id 值为 0x7f010001,icon 的 id 值被调配为 0x7f010002。老版本的插件下发到新版本的宿主上时依旧会通过 0x7f010001 去宿主中找 icon,天然就找错了。运气好一点可能只是图片展现异样,运气不好点可能就间接 crash 了。

3.3 如何解决这类问题

为了解决这个问题,业界目前有一个通用、稳固的计划——资源固定。宿主编译时通过 aapt2 提供的参数对插件应用到的资源进行固定,使宿主每次打包时这些资源的值永远不产生扭转。

资源固定计划的弊病:

⒈一个插件对应一个宿主的状况:

  • 必须把宿主的所有资源都进行固定。如果只固定插件应用的资源,当一个宿主有两个插件时,两个插件各自给宿主固定本人须要的资源,在代码合并时,很容易引发抵触,因为资源固定的值是不容许反复的。
  • 当宿主接入多个波及到资源固定的框架,如:插件化、资源热修复、游戏重打包框架等,这些框架之间进行资源固定时也须要思考对立固定,这个老本是很高的。
  • 资源固定进步了宿主接入框架的老本

⒉一个插件运行在多个宿主的状况

  • 当一个插件想要运行在多个宿主上,就须要每个宿主针对该插件的资源应用状况进行资源固定。一旦某个宿主曾经对某个资源进行了固定,导致其与该插件要求的资源固定产生抵触,插件就须要对该宿主进行斗争,依据该宿主已有的资源固定从新生成固定规定。这样就无奈实现一个插件在多个宿主上运行。咱们目前有一个需要:同一个插件须要在上千个宿主上运行,如果不能解决这个问题,可能须要打成千盈百个插件进去,很显著是不合理的。
  • 资源固定进步了宿主接入框架的老本

为了解决上述的问题,咱们钻研了一套新的计划解决资源错乱问题。

四、免资源固定计划

同一个版本的插件运行在不同版本甚至不同的 App 上时,插件的代码是固定的,而宿主中的资源 id 是会扭转的,为了解决资源错乱问题,以后的思路是保障宿主每次出新版本时资源 id 不变。那么有没有方法在不束缚宿主的状况下,让插件始终跟宿主的资源 id 保持一致呢?

因为插件打包时,宿主是未知的,并且对于一个插件跑在多个宿主的状况,宿主也是多样的。所以没法指定让插件把 id 打成满足宿主的样子,而前文也介绍过,插件中援用宿主 id 的中央都是常量。那怎么办呢?

是否能够在插件运行到宿主上时,动静批改插件中的内容,实现插件与宿主 id 值匹配的成果。

比方插件中应用了宿主的资源 icon,对应的 id 值为 0x7f010001。当该插件运行在一个 icon 为 0x7f010002 的宿主上时,因为运行时资源查找都是通过 id 值进行的,此时咱们只能晓得插件是在找一个 id 为 0x7f010001 的资源。通过某些伎俩,如果咱们能够把 0x7f010001 映射成 icon 这个字符串,而后利用 Android 零碎提供的 Resources#getIdentifier 办法,动静获取到以后宿主中 icon 对应的资源 id,即可保障插件加载到正确的资源。

这个工作须要在插件编译时、运行时别离做一些工作配合实现实现。

4.1 插件编译时工作

本大节内容基于 agp4.1 介绍,各个版本有些许差别,但总体思路大同小异。

后面介绍了,插件应用宿主资源次要有两种状况:1. 通过 java 代码 2. 通过 xml。

4.1.1 解决 java 代码中援用宿主的资源

java 代码在编译成 class 之后,对于援用宿主资源 id 的代码,有的会编译成数值,有的仍旧是通过 R 援用。对于后者,咱们能够很容易找进去,对于前者就有些艰难了,因为单纯去扫描 class 中 0x7f 结尾的数字,很容易误判,把一个无意义的数字也当作资源 id 解决。

后面讲了为什么 class 中的资源 id 会内联成数值,那咱们不让它内联不就好了吗?只须要在编译过程中解决 R.jar,移除 class 中所有的 final 字段,就能够保障插件中援用宿主的资源 id 全副通过 R 进行援用。

这块须要对 agp 的工作流程、gradle plugin 的开发有肯定的理解,用到了 asm 字节码批改技术和 agp 提供的 transform api,不理解的同学能够独自查一下,这块就不具体介绍了。
简略来说就是通过这两项技术,能够在编译 apk 时,对 class 文件进行批改。

开始实际

⒈因为 R.jar 是在 processResourcesTask 中生成的,因而能够写一个 gradle plugin,在 processResourcesTask 的 doLast 中获取到 R.jar,批改 R.jar 中的字节码,将 field 中的 id 为 0x7f 结尾的字段的 final 修饰符全副移除。这样就能够保障插件 class 中所有援用宿主资源的中央都不会被内联成数值。

⒉通过第一步的解决,插件中援用的宿主资源全副通过 R.xx.xx 来援用,但插件 R 中的数值仍旧是无奈与宿主对应的。因而咱们持续写一个 transform,扫描出插件中通过 R 援用资源的中央,利用 asm 将其从原来的 R 援用批改为办法调用。插件运行时,本来相似 R.drawable.test 的代码不再是获取一个常量数值,而是调用一个办法,外部动静计算以后宿主中对应的值。

总结:

以上,通过编译时的一些解决,即可解决插件 java 代码中援用宿主资源时免资源固定的问题。

  • 长处:无需资源固定。
  • 毛病:1. 插件中的局部资源不进行内联,会使包体积有十分渺小的减少,然而问题不大。2. 插件援用宿主资源由原来的常量变成了办法调用,执行效率升高,不过这块能够通过缓存来解决。同时插件化自身就是一项黑科技技术,有时候就义一些性能,解决一个问题还是十分值得的。

4.1.2 解决 xml 代码中援用宿主的资源

xml 中援用宿主资源的问题仅靠编译时是无奈解决的,因为 xml 不像 java 代码一样能够执行逻辑,后面介绍了,xml 在编译完结后,资源全副编成了数值,而咱们在编译时又无奈晓得将来运行在哪个宿主,值为多少。所以批改 xml 中资源 id 的工作只能搬到运行时去搞。当然也须要在编译时做一些事件,辅助运行时的批改操作。

运行时咱们须要批改 apk 的 xml 中 0x7f 结尾的资源,将其数值改为对应以后宿主的正确数值,而通过 xml,咱们只能拿到一个数值,因而咱们能够在插件编译时收集插件 xml 中应用的宿主资源所在的 xml 文件以及它们所对应的资源 name,运行时借助前文提到的 mapRes 办法即可获取到须要被批改后的值。

开始实际

前文介绍过,aapt2 编译 / 链接后会生成一个 ap_文件,这个文件中蕴含了最终会进入插件中的所有编译后的资源(包含各种 xml、resources.arsc、AndroidManifest.xml),咱们只须要剖析这些文件中援用的 0x7f 结尾的资源,依据 R.txt(aapt2 生成的一个文件)找到对应的资源名,将资源名、id 值、所在文件记录到一个文件中,一并打包进插件 apk 中。

至于如何扫描这些文件中 0x7f 的资源,咱们在不同阶段应用了不同形式,大家能够自行抉择:1. 应用 aapt2 命令 dump 文件信息,剖析 dump 后的文本内容(咱们编译时是这么做的,简略粗犷、性能较差、不够优雅)2. 依据文件格式剖析对文件内容进行解析,找到 0x7f 结尾的资源(比拟优雅,效率也高,咱们运行时是这样做的)

总结:

以上,便生成了一个文件,外部存储了插件 xml 中应用到的宿主资源的信息。大略长上面这样:

前文始终在说 xml 中应用的宿主资源,看下面这个配置文件发现 fileNames 中怎么会有 resoureces.arsc?它明明不是 xml 文件?
其实 Android 资源编译之后,values 相干的一些资源文件都不存在了,会间接进入到 resources.arsc 中,layout 这类文件还存在,resoureces.arsc 中 layout 指向的正是各种 layout.xml,而 string 等 value 类型的资源指向的是一个实在的内容。感兴趣的同学能够通过 Android Studio 关上 apk,察看一下 resources.arsc 中的构造。

4.2 插件装置时的工作

后面介绍了在插件编译时,给 java 代码中插入了一些逻辑,实现了插件动静依据宿主环境获取资源 id 的成果。然而 xml 编译完之后,资源 id 都间接编译成了数字,xml 中也无奈插入逻辑,因而咱们只能在插件运行前,依据宿主环境进行批改。

插件在宿主中运行前都有一个插件装置的过程,相似于 apk 在 Android 零碎中的装置,因而只须要在每次插件装置前,或者宿主降级后,依据编译时生成的配置文件,联合 mapRes 办法,对插件中的 xml、resources.arsc 文件进行批改即可。

确定了批改机会和批改内容,接下来就要具体介绍怎么批改这些文件了。

五、批改 apk 中的资源文件

5.1 如何批改 xml、arsc 文件

Android 中的 layout、drawable、AndroidManifest 等文件在编译成 apk 后,不再是惯例的 xml 文件了,而是新的一种文件格式 Android Binary XML,咱们这里称之为 axml。那么如何批改 axml 文件呢?

所有的文件都有本人的文件格式,程序在读取文件时都是读的 byte 数组,而后依据文件格式解析 byte 数组中每一个元素的含意。因而咱们只须要理解了 axml 的文件格式,依照标准解析这个文件,在 byte 数组中找到其中示意资源 id 的地位,将本来的资源 id 依据 resMap 办法映射出新的值,而后批改 byte 数组中对应的局部。(十分侥幸,咱们这里批改的只是 axml 文件中的一个 8 位 16 进制数,这个批改不会导致文件中内容的长度、偏移等信息扭转,因而间接替换对应局部的 byte 数组即可。)

resources.arsc 是 apk 的资源索引表,外面记录了 apk 中所有的资源,对于 values 类型的资源,资源对应的内容会全副进入到 resources.arsc 中,因而咱们也须要对这个文件进行批改(如一个 style 的 parent 是宿主资源,咱们就须要批改它)。批改的办法和 xml 相似,只须要依照标准解析 byte 数组,找到要批改内容的偏移量,替换即可。

对于 axml、arsc 的文件格式,网上有很多文章介绍,这里就不具体叙述了。

Apktool(Apktool – A tool for reverse engineering 3rd party, closed, binary Android apps.)是一款弱小、开源的逆向工具,它能够把 apk 反编译成源码,那它必定也有读取 apk 中 axml、arsc 的代码,不然怎么输入一个能够编辑的 xml 源码文件?所以咱们能够间接去扒 apktool 中读取 axml、arsc 的代码,当读取到 axml 中属于宿主的 id 时,记录一下 byte 数组的偏移量,间接替换对应地位的 byte 子数组。

aapt2 为咱们提供了 dump 资源内容的能力,能够帮忙咱们间接用“肉眼”去看 axml、arsc 的内容,借助这个工具能够让咱们很不便的确认批改内容,验证批改是否失效。以 30.0 版本的 build-tools 中的 aapt2 为例,它的命令为aapt2 dump apk 门路 --file 资源门路。前面不跟--file 资源门路,会间接 dump arsc。

以下是 dump 进去的 arsc,能够看到最初一个 style 的 parent 是一个 0x7f 结尾的宿主资源。

以下是 dump 进去的 activity_plugin1.xml,能够看到 TextView 中援用了一个宿主中的资源作为 backgroud。

5.2 批改 apk 中的 xml/arsc 文件

以上咱们晓得了如何批改一个 axml、arsc 文件。插件装置时咱们拿到的是 apk 文件,那么如何批改 apk 中的 axml、arsc 文件呢?

5.2.1 重压缩形式批改

Apk 其实就是一个 zip 文件,批改 apk 中的文件内容,首先想到的最简略的办法就是读取 zipFile 外面的文件,批改之后重压缩。

java 为咱们提供了一套操作 zipFile 的 api,咱们能够轻松的将 zip 文件中的内容读取到内存,在内存中批改之后利用 ZipOutputStream 从新写入到新的 zipFile 中。

代码实现非常简单。批改胜利后,测试发现是可行的,那咱们的第一步就算是胜利了,阐明运行时动静批改插件的路子是行的通的。

窃喜之于,发现批改过程非常耗时。以公司的直播插件为例(直播插件大概 30MB,属于比拟大的插件了),在 9.0 及其以上的设施上耗时约 8s,在 7~8 的设施耗时大概 20~40s,在 7.x 以下设施大概耗时 10~20s。只管插件装置是在后盾进行,适当的减少一些工夫是能够承受的,然而几十秒的耗时很显著不能够承受。那咱们只能想别的方法了。

对于各个版本的耗时差别

Android7.0 开始,官网应用 ZLIB 来提供 Deflater、Inflater 的实现,优化理解压压缩算法速度(能够查看 Deflater.java、Inflater.java 的正文)。然而 7.x/8.x 的 ZipFileInputStream 在读取数据时有一个 8192 的 BUFSIZE 限度(8.x 之后移除了这个限度),导致在读取数据时循环次数增多,效率反而降落。
7.0 开始,ZipFileInpugStream 在读取数据时是通过 native 办法 ZipFile_read 进行的。以下是 android8.0 和 android9.0 中 ZipFile_read 的局部代码。

5.2.2 间接批改 apk 的 byte 数组

Apk 其实就是一个 zip 文件,对于 zip 文件的介绍能够参考官网文档。

简略总结一下,zip 文件是由数据区、地方目录记录区、地方目录尾部区组成(高版本的 zip 文件减少了新的内容)。

  • 地方目录尾部区:通过尾部区咱们能够晓得 zip 包中文件的数目、地方目录记录区的地位等信息。
  • 地方目录记录区:通过尾部区咱们能够疾速找到地方目录记录区中的每一条文件记录,这些记录次要形容了 zip 包中文件的根本属性如文件名、文件大小、是否压缩、压缩后的大小、文件在数据区中的偏移等。
  • 数据区:数据区用来寄存文件实在的内容,依据地方目录记录区记录的内容,能够疾速在数据区找到对应的文件元数据以及文件的实在数据(如果压缩,则是压缩后的数据)。

开始干活

理解了 zip 文件的格局后,咱们只须要依照文件格式协定,在 apk 中找到咱们须要批改的文件数据在 apk 中的偏移量,而后联合后面批改 axml/arsc 文件的形式,间接批改对应的 byte 数组即可。借助 java 为咱们提供的 RandomAccessFile 工具,咱们能够疾速的文件的任意地位进行读取 / 写入。

批改过程中发现,apk 中的 xml 文件大部分是被压缩的(res/xml 目录下的个别不压缩),这就导致咱们从 apk 中拿进去的 byte 数组是 axml 被压缩后的数据,咱们要对这段数据进行批改,须要先利用 Deflate 算法对它进行解压(zip 文件中个别都是用的 Deflate 算法),而后进行批改再压缩,然而通过咱们批改后,可能从新压缩进去的数据就与批改前的数据长度不匹配了,如果是缩短还好,批改一下文件元数据即可,如果文件长度变长可能会导致前面文件的偏移量都要扭转,牵一发而动全身。

好在插件的打包过程咱们是能够侵入的,后面介绍“插件编译时工作”时,咱们在编译时拿到了须要批改的文件,因而咱们只须要管制 apk 打包时不要对这些文件进行压缩(事实上 Android Target30 也要求 arsc 文件不进行压缩)。这样就很简略的解决了问题,当然会导致插件包体积的减少。

最终测试在直播插件中,开启这个性能会导致包体积减少 20kb,对于靠近 30mb 体积的直播插件来说,这个增量是能够承受的,而且也不会影响宿主包体积。(这个增量取决于插件有多少 xml 应用了宿主资源,个别插件的增量应该都是小于直播插件的。)

革新实现后,经测试,直播插件在各个版本手机上批改时长大概在 300~700ms 之间,批改速度晋升了 10~90 倍。大部分插件也比直播插件小,耗时能够保障在 100ms 之内。同时这个批改过程仅在插件第一次装置或者宿主降级时做,并且是在后盾实现,所以是齐全能够承受的。

我整顿了一些学习材料,外面包含 Java 根底、Android 进阶、架构设计、NDK、音视频开发、跨平台、底层源码等技术,还有 2022 年一线大厂最新面试题集锦,都分享给大家,助大家学习路上乘风破浪~ 能力失去晋升,思维失去宽阔~ 有须要的能够 点击下方链接收费获取

链接:https://shimo.im/docs/R13j85m…

退出移动版