likes
comments
collection
share

【万字总结】Android 内存优化知识盘点

作者站长头像
站长
· 阅读数 2
【万字总结】Android 内存优化知识盘点

在人生最艰难的日子里,不要想太过遥远的未来,认真过好当下每一天,就好了。—— 杨绛

内存优化的意义

Android性能优化三把板斧:稳定性、内存、启动速度、包体积。这几个方向的知识其实是相互关联的网状结构,例如内存控制得好,就不容易出现OOM导致的稳定性问题。同样,经过精简的包体积,也会大大提升应用启动速度。

【万字总结】Android 内存优化知识盘点

相比于C/C++,JVM很大的一个跨越是实现了内存的自动分配与回收。然而,这并不意味着作为开发者可以肆无忌惮地使用内存,作为一种有限的资源,内存再大也有耗尽的时候。我们讲“内存优化”,主要是出于稳定性、流畅度、进程存活率三个维度的考虑。

  1. 稳定性:减少OOM,提高应用稳定性
  2. 流畅度:减少卡顿,提高应用流畅度
  3. 进程存活率:减少内存占用,提高应用后台运行时的存活率

内存理论知识

这一部分讲解内存优化相关的理论知识,为实践环节提供理论支持。首先从虚拟机配置角度说明Android系统里应用可用的内存上限;然后分析在这个上限之内,应用程序内存主要分配为哪几个部分;最后用比较多的篇幅讲解开发中关系最紧密的对象内存分配与回收

应用可用内存:dalvik.vm配置

现在市面上主流旗舰机型的内存已经达到了16G的级别,比起笔记本电脑也不遑多让,然而在极限的使用条件下,仍然会不可避免地出现卡顿。对于应用程序而言,它能够使用的最大内存是在虚拟机配置里写死的,可以通过命令adb shell getprop dalvik.vm.heapgrowthlimit来查看,在我的手机(vivo X Note)上是256m

> adb shell getprop dalvik.vm.heapgrowthlimit
256m

此外虚拟机配置中还有heapstartsizeheapsize等参数,它们都是定义在/system/build.prop文件当中的,含义说明如下:

参数含义vivo X Note
heapstartsize堆分配的初始大小,影响操作系统对RAM的分配和首次使用应用的流畅性8m
heapgrowthlimit单个应用可用的最大内存,若超出则OOM256m
heapsize单个进程可以用最大内存,仅当声明android:largeHeap=true时生效,此时会覆盖heapgrowthlimit的值512m

Native虚拟内存

我们知道Android 8.0以后,Bitmap不再存放于Java堆内存,而是位于Native的堆内存,它的上限也就是Native虚拟内存的上限,只与CPU架构相关。Native堆内存不受JVM管控。

CPU架构Native堆上限
32位3G(4G减去1G内核空间)
64位128T

【万字总结】Android 内存优化知识盘点

进程内存指标:USS, PSS, RSS, VSS

dumpsys meminfo可以看到整个系统中不同进程所占据的内存情况,最重要的有以下4个缩写指标,它们之间的关系是:VSS >= RSS >= PSS >= USS。在Android系统中推荐使用PSS曲线来衡量应用的物理内存占用情况。

内存指标英文全称含义等价
USSUnique Set Size物理内存进程独占的内存
PSSProportional Set Size物理内存PSS = USS + 按比例包含共享库
RSSResident Set Size物理内存RSS= USS+ 包含共享库
VSSVirtual Set Size虚拟内存VSS= RSS+ 未分配实际物理内存

可以通过dumpsys命令查看以上内存信息。

dumpsys meminfo <pid> // 指定pid
dumpsys meminfo --package <packagename> // 指定包名,可能存在多个进程
dumpsys meminfo // 系统中所有进程的内存占用,有排序

按照范围从大到小,Android系统管理内存可以分为进程、对象、变量三个层级。

进程内存分配与回收

进程内存分配和回收受到Linux内核的管控,Android将进程分为5个优先级,当进程空间紧张时,按照优先级从低到高的顺序进行回收。

【万字总结】Android 内存优化知识盘点

对象/变量内存分配与回收

【万字总结】Android 内存优化知识盘点

对象/变量内存分配和回收受到JVM虚拟机的管控,在JVM中,内存按区域分配如下,其中方法区、堆区是供所有线程共享的。

  • 方法区:被虚拟机加载的类信息、常量、静态变量;存活于程序运行整个周期,不回收
  • 堆区:存储Java对象实例,在线程区-Java函数栈帧会定义实例指针(or引用),指向堆内的实例;GC时判断是否回收
  • 线程区:每一个线程单独的区域
    • 程序计数器:当前线程运行到的指令位置行数
    • Java函数栈帧:存储方法执行时的局部变量、操作数;执行结束后释放
    • Native函数栈帧:为Native函数服务;执行结束后释放

Java对象生命周期

ClassLoader装载.class文件开始,一个Java对象的生命周期包含以下阶段:

【万字总结】Android 内存优化知识盘点

JVM内存分代

Java虚拟机采用分代策略管理堆内存中的对象,分代的目的是优化GC性能,将具有不同生命周期的对象归属于不同的年代,采取最适合它们的内存回收方式

JVM运行时内存可以分为堆(Heap)非堆(Non-heap) 两大部分。堆在JVM启动时创建,是运行时数据区域,所有类实例数组内存从堆分配。堆以外的内存称为非堆内存,方法区、类结构等数据保存在非堆内存。简单说,堆是开发者可以触及的内存部分,非堆是JVM自留的部分

【万字总结】Android 内存优化知识盘点

对象分代的角度,JVM内部将对象分为3类:

  • New Generation,新生代,位于堆内存,内部分为3块,其中EdenSurvivor的默认比例为8:1
    • Eden,新创建的对象都位于该分区,满时执行GC,并将仍存活的对象复制到From,GC后此区域被清空
    • From Survivor (S1),GC时,将仍存活的对象复制到To
    • To Survivor (S2),满时,将仍存活的对象复制到Old。GC之后会交换FromTo区,从而使新的To(也就是老的From)永远是空的
  • Old Generation,老年代,位于堆内存
  • Permanent Generation,永生代,位于非堆内存

GC Roots

定义:通过一系列名为GCRoots的对象作为起始点,从这个节点向下搜索,搜索走过的路径称为ReferenceChain,当一个对象到GCRoots没有任何ReferenceChain相连时,(图论:这个对象不可到达),则证明这个对象不可用。

【万字总结】Android 内存优化知识盘点

共有4GC Roots:

  • 方法区
    • 静态引用的对象
    • 常量引用的对象
  • 线程区
    • Java函数栈帧中引用的对象
    • Native函数栈帧中JNI引用的对象

垃圾回收算法

算法解释特点应用场景
复制将内存分为2份,使用其中一份进行存储,每次执行清除时将仍然存活的对象复制到另一半,然后清除原来的内存舍弃空间,换取时间上的高效率对象存活率低的场景——新生代
标记-清除先扫描一遍,将不存活的对象打上标记。第二遍扫描时清理掉这些对象会产生不连续的内存碎片对象存活率高的场景——老年代
标记-整理先扫描一遍,将不存活的对象打上标记。第二遍扫描时清理掉这些对象,并将内存向一侧聚集收拢防止产生内存碎片对象存活率高的场景——老年代

内存问题归类

理解了JVM内存的分配与回收原理,我们看一下在Android开发中,发生内存问题的三个表现,它们分别是内存抖动内存泄漏内存溢出。其中内存抖动与泄漏是引发内存溢出的重要因素。

内存抖动

什么是内存抖动

在一段时间内,频繁地发生内存的分配和释放,在曲线图上展现为锯齿状的内存使用曲线。

【万字总结】Android 内存优化知识盘点

内存抖动的原因

  1. 选择了不恰当的数据类型:如使用加号+进行字符串拼接,正确的做法是用StringBuilder
  2. 在循环里面创建对象:则循环执行时会频繁地创建销毁
  3. onDraw中创建对象:一次绘制会多次调用onDraw

如何避免内存抖动

  1. 选择恰当的数据类型(String -> StringBuilder
  2. 在循环体、onDraw函数外创建对象并复用,减少不合理的对象创建
  3. 使用对象池进行缓存复用

内存泄漏

直接原因是长生命周期的对象引用了短生命周期的对象,导致短生命周期的对象无法回收。主要是指Activity的泄漏,即持有Activity的变量生命周期长于Activity的生命周期,导致Activity虽然结束但其对象并未释放,其内部的成员变量也由于被持有而无法释放。反复进入退出页面,会看到内存曲线不断上升,即使手动GC也无济于事,继续下去会导致OOM。

【万字总结】Android 内存优化知识盘点

常见泄漏场景

在Android开发中经常会有将Activity作为Context类型参数传递的场景,用到的时候必须谨慎,防止泄漏。

1.非静态内部类

非静态内部类会持有外部类对象的引用,典型的有Handler、Runnable等。内部类在编译后,会生成一个名为this$0的成员变量,就是外部类实例。

1.1 Handler等待运行

在使用Handler处理消息时,会构造一个Message对象并调用Handler.post(),此时Message对象中的target成员变量指向该Handler。由于Message会在MessageQueue中按时序处理,在处理到它之前,是无法释放Handler的。也就导致了Handler所持有的外部类(通常是Activity)发生泄漏。

要解决这个问题,需要从两点入手:

  1. 使用Handler静态内部类(或者直接新建一个类文件),Handler通过弱引用持有Activity
  2. Activity.onDestroy()中清空消息队列
1.2 Runnable长时间运行

与非静态内部类相类似,通过new Runnable() {...}创建一个匿名内部类的对象时,也会持有外部对象的引用。如果这个Runnable在线程池中排队等待处理,同样会导致Activity无法释放。解决方法与Handler类似,由于Activity与任务队列不强相关,此处不建议清空线程池中的任务队列。

  1. 创建一个实现了Runnable的静态内部类,内部通过弱引用持有Activity,使用Activity时判空
// Runnable错误用法
private Runnable mRunnable = new Runnable() {
    @Override
    public void run() {

    }
};

// Runnable正确用法
private static class MyRunnable implements Runnable {
    WeakReference<MainActivity> reference;
    public MyRunnable(MainActivity activity){
        reference = new WeakReference<>(activity);
    }
    @Override
    public void run() {
        MainActivity activity = reference.get();
    }
}

2.方法区持有对象引用

方法区包含类结构、常量和静态变量,是GC Roots之一,与整个应用的生命周期相同,由于长期存活即拿即用,比较方便,但也因此容易导致内存泄漏。

2.1 静态成员变量

静态成员变量在类加载的时候就会赋值,如果其中持有Activity对象,会直接导致无法回收。

2.2 单例

双重检查是单例的常规写法,其实现中也用到了静态变量,因此同样有泄漏的风险。

3.资源未关闭

当Activity销毁时,资源性对象不再使用,应当关闭该对象,然后将对象置为null,常见的有BitmapInputStreamOutputStream等实现了Closable接口的类。

// Bitmap
Bitmap bitmap = new Bitmap();
...
bitmap.recycle();
bitmap = null;

// InputStream
InputStream inp = new FileInputStream("foo.txt");
...
inp.close();
inp = null;

4.注册对象未注销

切记需要成对调用注册-注销,如BroadcastReceiverEventBus,否则会导致Activity一直被持有,从而发生泄漏。在开发总线类框架的时候,也要注意提供相应的注销接口。

5.WebView必然泄漏

这个是Android的痼疾了,一旦创建过WebView对象,就无法回收,与Chromium内核实现有关,网上有建议用如下方法清空WebView资源,但据网友描述在一些机型让仍然会泄露。

override fun onDestroy() {
    val parent = webView?.parent
    if (parent is ViewGroup) {
        parent.removeView(webView)
    }
    webView?.destroy()
    super.onDestroy()
}

既然如此,就得避免创建过多的WebView对象,或者干脆另外起一个进程用作WebView展示。

  1. 构建WebView对象池,实现复用
  2. 将WebView单独开辟一个进程使用,与主进程之间跨进程通信传递数据

6.集合未清空

将对象添加进集合后,集合实例会持有该对象的引用,因此在销毁时应当将集合中对应元素移除。

// 问题场景
// 通过 循环申请Object 对象 & 将申请的对象逐个放入到集合List
List<Object> objectList = new ArrayList<>();        
for (int i = 0; i < 10; i++) {
    Object o = new Object();
    objectList.add(o);
    o = null; // 虽释放了集合元素引用的本身,但仍然被集合持有
}

// 正确写法
objectList.clear();
objectList = null;

内存溢出

当应用在为新的数据结构申请内存时,如果当前可用内存不足以提供给新对象,就会发生内存溢出(OOM,即Out of Memory)。我们面临的大部分都是Java堆内存超限问题。

【万字总结】Android 内存优化知识盘点

当发生OOM时,并不一定是当前执行的代码发生了问题,有可能是由于之前的不正确调用,当前已经处于一个内存异常的状态,在申请开辟新内存时导致问题爆发。

优化思路

这里列出一些内存优化的建议,由于在应用运行的整个生命周期里都存在内存的申请和释放,因此内存优化存在于应用开发的方方面面。

1.精简代码

在JVM中类的数据结构位于方法区,伴随应用的整个生命周期都不会销毁,因此应当尽量精简类的结构。

  • 减少不用的类,以及类中的成员变量
  • 减少不必要的三方依赖包
  • 开启应用混淆,精简类名方法名变量名,自动清理无用的类

2.内存复用

通过复用,减少频繁创建销毁对象的行为,防止产生大量离散的内存碎片。

  • 资源复用:归拢通用的字符串、颜色、度量值等,进行基础布局的复用
  • 视图复用:借助ViewHolder减少视图布局开销
  • 对象复用:建立对象池,实现复用逻辑

3.选用性能更高的数据结构

3.1 慎用SharedPreferences

读取SP时会将整个xml加载到内存里,占据几十K、上百K的内存。

3.2 使用SparseArray、ArrayMap代替原生的HashMap

HashMap最初是为JVM设计的,为了减少哈希冲突,选用了一个较大的数组作为容器来降低冲突,这是一种空间换时间的思路。在Android中提供了不同的替换结构。

  • KeyInt时,选用SparseArray
  • Key是其他类型时,选用ArrayMap

它们的思路是时间换空间,内部是两个数组,节约了HashMap中空置的元素位。从性能角度看,当元素数量在1000以下时,性能几乎与HashMap持平。

4.选用性能更高的数据类型

4.1 避免频繁拆箱装箱(AutoBoxing)

在自动装箱时,会创建一个新的对象,从而产生更多的内存和性能开销。int只占4字节,而Integer对象占据16字节,尤其是HashMap此类容器,当用Integer作为Key/Value,进行添加、删除、查找操作时,会产生大量自动装箱操作。

排查方法:通过TraceView查看耗时,如果发现调用了大量的Integer.valueOf()函数,说明存在装箱行为。

4.2 避免使用枚举类型,用IntDef、StringDef等注解代替

枚举的优点是提供强制的类型检查,但缺点是对于dex构建、运行时内存都会造成更大的消耗(相比于IntegerString)。为了满足编译期间的类型检查,可以改用IntDefStringDef,需要引入依赖:

compile 'com.android.support:support-annotations:22.0.0'

5.实现onLowMemory()、onTrimMemory()方法

它们都是在Application、Activity、Fragment、Service、ContentProvider中提供的,用于应用内存紧张时的通知,在发生这些回调时,应当主动清除缓存,释放次要资源。通过Context.registerComponentCallbacks()进行注册监听。

  • onLowMemory:发生时,所有后台进程已经被杀死
  • onTrimMemory:Android 4.1引入,存在多个不同级别的回调

【万字总结】Android 内存优化知识盘点

6.声明largeHeap以增大可用内存

在AndroidManifest.xml中声明android:largeHeap=true可以将应用的可用内存扩展到dalvik.vm.heapsize。但这是一种治标不治本的方法,并不推荐使用。

7.建立LruCache

对于可能存在复用的对象,建立LruCache,内部由LinkedHashMap双向链表实现(也可以自定义双向链表)。支持get/set/sizeOf函数。当收到低内存警告(onLowMemoryonTrimMemory)时,清空LruCache。

LruCache的实现可以参考Glide源码。

8.排查内存泄漏

针对上文中提出的内存泄漏风险点,使用Memory Profiler、LeakCanary、MAT等工具,逐页面进行排查。

典型问题:Bitmap优化

Bitmap是内存消耗的大头,在Android 8.0及以上版本,Bitmap内存位于Native层,可以利用虚拟内存提升存储量,同时避免JVM堆内存耗尽。即使这样,在创建Bitmap时仍然会先占用JVM的堆空间,只不过在创建后会将其复制到Native堆进行维护。

ARGB_8888格式为例,一个像素占用4bytes,所以整个Bitmap所占据的字节数=长*宽*4 bytes,假设一张图片长宽各为500px,那么它需要占用500*500*4=1,000,000B=1MB的内存,对于长列表而言,如果使用不当,一次就可能导致几百MB的内存泄漏。

因此,Bitmap优化是内存优化的重中之重,通常有以下几个思路:

  1. 缩放减小宽高
  2. 减少每个像素占用的内存
  3. 内存复用,避免重复分配
  4. 大图局部加载
  5. 使用完毕后释放图片资源
  6. 设置图片缓存
  7. 大图监控

1.缩放减小宽高

图片尺寸不应大于View的尺寸,例如图片是200*200,而View是100*100的,此时应当对图片进行缩放,通过inSampleSize实现。

BitmapFactory.Options options = new BitmapFactory.Options();
// 宽高都变为原来的1/2
options.inSampleSize = 2;
BitmapFactory.decodeStream(is, null, options);

2.减少每个像素占用的内存

这一条适用于那些些不需要展示高清图的场景,以及低配机器的图片显示。在API 29中,将Bitmap分为6个等级,如果图片不含透明通道,可以考虑用RGB_565代替ARGB_8888,能够节约一半的内存。

  • ALPHA_8:不存储颜色信息,每个像素占1个字节;
  • RGB_565:仅存储RGB通道,每个像素占2个字节,对Bitmap色彩没有高要求,可以使用该模式;
  • ARGB_4444:已弃用,用ARGB_8888代替;
  • ARGB_8888:每个像素占用4个字节,保持高质量的色彩保真度,默认使用该模式;
  • RGBA_F16:每个像素占用8个字节,适合宽色域和HDR
  • HARDWARE:一种特殊的配置,减少了内存占用同时也加快了Bitmap的绘制。

3.内存复用,避免重复分配

通过设置BitmapFactory.Options.inBitmap,可以在创建新的Bitmap时尝试复用inBitmap的内存,在4.4之前需要inBitmap与目标Bitmap同样大小,在4.4之后只要inBitmap比目标Bitmap大即可。

4.大图局部加载

有些场景下需要展示巨大的一张图片,比如地铁路线图、微博长图、对高像素图片进行编辑等,如果按照原图尺寸进行加载,一张4800万像素的照片会占据48M的内存,非常可怕。

因此,在大图展示的场景下,通常采用BitmapRegionDecoder来实现。

BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(inputStream, false);
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;
// 加载图片中央200*200的局部区域
Bitmap bitmap = decoder.decodeRegion(new Rect(width/2-100, height/2-100, width/2+100, height/2+100), options);
mImageView.setImageBitmap(bitmap);

5.使用完毕后释放图片资源

Bitmap使用完毕后应当及时释放。

5.1 常规释放

Bitmap bitmap = ...
// 使用该bitmap后,进行释放
bitmap.recycle();
bitmap = null;

5.2 非常规,泄漏兜底

如果Activity、Fragment发生泄漏,会导致它们持有的View无法释放,进而使View中展示的Bitmap对象无法被系统回收。

此时,可以通过覆写View.onDetatchedFromWindow进行监控兜底,当View从Window中被移除,且Activity处于destroy状态时,延迟2s判断,如果2s后图片仍然没有被移除,说明发生了泄漏。此时应当主动将Bitmap释放。

【万字总结】Android 内存优化知识盘点

6.设置图片缓存

7.进行大图监控

前面说过,如果Bitmap的尺寸大于目标View的尺寸,是没有意义的,这里提供一些技术手段协助进行大图监控,我们声明一个checkBitmapFromView的函数,用于扫描当前Activity、Fragment的全部View,并取出Bitmap进行尺寸比较,如果发现大图case则报警。

/**
 * 1. 遍历Activity中每个View
 * 2. 获取View加载的Bitmap
 * 3. 对比View尺寸和Bitmap尺寸
 */
fun checkBitmapFromView() {
    // 实现略
}

如何将这段检查的逻辑插入到代码当中?有以下几种方式。

方案实现思路优点缺点
BaseActivity在基类的onDestroy()中检查接入简单侵入性强,对全部Activity产生影响,所有Activity需要继承BaseActivity
ArtHook是针对ART虚拟机的Hook方案,通过Hook ImageView的setImageBitmap函数,在其中进行检测无侵入性,一次配置全局生效;可以获取代码调用堆栈,便于定位问题Hook方案存在兼容性风险,需要全面覆盖测试
ASM在编译过程中对setImageBitmap进行插桩无侵入性增加编译耗时,ASM代码维护成本高
registerActivityLifecycleCallback在Application中注册无侵入性,接入简单暂无

分析工具

Memory Profiler

MAT(Memory Analyzer Tool)

MAt是用来分析内存快照,分析对象引用链,从而找出导致内存泄漏根因的工具。

MAT的使用步骤

  1. 下载 eclipse.org/mat/downloa…
  2. 在Android Studio中进入Memory Profiler功能,操作应用关键页面
  3. 点击GC以清理掉那些没有被泄露的对象,此时剩余的是发生泄漏的
  4. 将内存快照dump到本地
  5. 使用Android SDK自带的转换工具(位于platform-tools),将Dalvik/ART格式的.hprof文件转换为MAT能识别的J2SE格式,最后用MAT打开文件
./hprof-conv aaa.hprof aaa-converted.hprof

MAT中的术语说明

  • Dominator:支配者,如果B引用了A,那么B是A的支配者
  • Dominator Tree:支配者引用链
  • Shallow Heap:当前对象自身的内存占用
  • Retained Heap:当前对象及其成员变量加在一起的总内存占用
  • Outgoing References:当前对象引用了哪些对象
  • Incoming References:当前对象被哪些对象引用
  • Top Consumers:通过饼图方式列出内存占用最多的对象

LeakCanary

线上OOM监控:美团Probe与快手KOOM

当线上发生OOM时,我们需要立即采集现场数据,并报警给服务器进行收集。对此,不同公司都提出了各自的方案,例如美团的Probe(未开源),快手的KOOM。这些方案主要解决了以下几个问题:

  • 监控OOM:当发生OOM时触发采集上报流程
  • 堆转储:即采集内存快照,dump hprof
  • 解析快照:把对分析问题无用的数据裁剪去掉,仅保留有用的信息。进行可达性分析,生成到GC Roots的调用链,找出泄漏根因
  • 生成报告并上传:KOOM支持在端侧生成json格式报告,并进行上传,将解析工作交由端侧进行,能有效减少云端的分析压力

面试常见提问

Q:说说你对内存优化的理解

问题比较大,采用总-分-总方式作答。

  • (总)内存管理是应用性能优化中非常重要的一环,在我以往的工作中也从事过一些内存优化的工作,业余也查阅积累了一些内存优化的知识。接下来我从重要性、问题分类、常见问题场景、优化思路几个方面讲一下我的理解
  • (分)不要讲的太细,一是占用时间太多,过犹不及,容易让面试官感到啰嗦,二是会留下背答案的印象
  • (总)略

Q:开发过程中你是怎样判断有内存泄漏的

在开发过程中,我通常使用以下几种方法,对内存泄漏可能存在的场景进行快速检测和定位。

  1. 内存泄漏-应用全生命周期分析shell命令 + LeakCanary + MAT,运行程序并将主要链路页面都打开一遍,完全退出程序并手动触发GC。然后使用adb shell dumpsys meminfo <packagename> -d打印应用当前进程信息,如果存活的View和Activity、Fragment数量不是0,说明它们发生了泄漏。接着借助LeakCanary查看哪些对象发生了泄漏,最后用MAT找出这些泄漏对象的引用关系,定位问题根因
  2. 内存泄漏-单一页面分析:使用Memory Profiler工具,对于目标页面,反复进出5次,在最后一次退出后手动触发GC。如果此时内存曲线没有回到进入页面之前的状态,说明发生内存泄漏。然后,将内存快照保存下来,并在Memory Profiler中查看该Activity,如果存在多个实例,说明没有被回收,即发生泄漏
  3. 内存上涨-单一页面分析:利用Memory Profiler观察进入每个页面的内存变化情况,对于上升幅度大的页面,下载进入页面前后的两个内存快照并用MAT的对比分析功能,找出新页面内存的分配情况,被哪些大的对象所占用

参考资料