本文章已授权微信公众号郭霖(guolin_blog)转载。
本文章讲解的内容是Android的内存管理。
概览
Android Runtime(ART)虚拟机和Dalvik虚拟机都使用分页(Paging)和内存映射(Memory-mapped file)来管理内存。这意味着应用修改的任何内存,无论修改的方式是分配新对象还是轻触内存映射的页面,都会一直驻留在RAM中,并且无法换出。要从应用中释放内存,只能释放应用保留的对象引用,使内存可供垃圾回收器回收。这种情况有一个例外:对于任何未被修改的内存映射文件(例如:代码),如果系统想要在其他位置使用其内存,可将其从RAM中换出。
垃圾回收
Android Runtime(ART)虚拟机或者Dalvik虚拟机的受管内存环境会跟踪每次内存分配。一旦确定程序不再使用某块内存,它就会将该内存重新释放在堆中,无需程序员进行任何干预,这种回收受管内存环境中的未使用内存的机制称为垃圾回收。垃圾回收有两个目标:在程序中查找将来无法访问的数据对象,并且回收这些对象使用的资源。
Android的堆是分代的,这意味着它会根据分配对象的预期寿命和大小跟踪不同的分配存储分区,例如:最近分配的对象属于新生代,当某个对象保持活动状态达足够长的时间,可将其提升为较老代,然后是永久代。
堆的每一代对相应对象可占用的内存量都有其自身的专用上限。每当一代开始填满时,系统便会执行垃圾回收事件以释放内存。垃圾回收的持续时间取决于它回收的是哪一代对象以及每一代有多少个活动对象。
尽管垃圾回收速度非常快,但是仍然会影响应用的性能。通常情况下,我们无法从代码中控制何时发生垃圾回收事件,系统有一套专门确定何时执行垃圾回收的标准,当满足条件时,系统会停止执行进程并开始垃圾回收。如果在动画或者音乐播放等密集型处理循环过程中发生垃圾回收,则可能会增加处理时间,进而可能会导致应用中的代码执行超出建议的16ms阈值,无法实现高效、流畅的帧渲染。
此外,我们的代码流执行的各种工作可能迫使垃圾回收事件发生得更频繁或者导致其持续时间超过正常范围,例如:我们在Alpha混合动画的每一帧期间,在for循环的最内层分配多个对象,则可能在堆中创建大量的对象,在这种情况下,垃圾回收器会执行多个垃圾回收事件,并可能降低应用的性能。
共享内存
为了在RAM中容纳所需的一切,Android会尝试跨进程共享RAM页面,它可以通过以下方式实现:
- 每个应用进程都从一个名为Zygote的现有进程分叉(fork)。系统启动并加载通用框架(Framework)代码和资源(例如:Activity主题背景)时,Zygote进程随之启动。为启动新的应用进程,系统会分叉(fork)Zygote进程,然后在新进程中加载并运行应用代码,这种方法可以让框架(Framework)代码和资源分配的大多数RAM页面在所有应用进程之间共享。
- 大多数静态数据会内存映射到一个进程中,这种方法使得数据不仅可以在进程之间共享,还可以在需要时换出。静态数据示例包括:Dalvik代码(通过将其放入预先链接的.odex文件中进行直接内存映射)、应用资源(通过将资源表格设计为可内存映射的结构以及通过对齐APK的zip条目)和传统项目元素(例如:.so文件中的原生代码)。
- 在很多地方,Android使用明确分配的共享内存区域(通过ashmem或者gralloc)在进程间共享同一动态RAM。例如:窗口surface使用在应用和屏幕合成器之间共享的内存,而光标缓冲区则使用在内容提供器和客户端之间共享的内存。
分配与回收应用内存
Dalvik堆局限于每个应用进程的单个虚拟内存范围。这定义了逻辑堆大小,该大小可以根据需要增长,但不能超过系统为每个应用定义的上限。
堆的逻辑大小与堆使用的物理内存量不同。在检查应用堆时,Android会计算按比例分摊的内存大小(PSS)值,该值同时考虑与其他进程共享的脏页和干净页,但其数量与共享该RAM的应用数量成正比。此(PSS)总量是系统认为的物理内存占用量。
Dalvik堆不压缩堆的逻辑大小,这意味着Android不会对堆进行碎片整理来缩减空间。只有当堆末尾存在未使用的空间时,Android才能缩减逻辑堆大小,但是系统仍然可以减少堆使用的物理内存。垃圾回收之后,Dalvik遍历堆并查找未使用的页面,然后使用madvise将这些页面返回给内核,因此大数据块的配对分配和解除分配应该使所有(或者几乎所有)使用的物理内存被回收,但是从较小分配量中回收内存的效率要低很多,因为用于较小分配量的页面可能仍在与其他尚未释放的数据块共享。
限制应用内存
为了维持多任务环境的正常运行,Android会为每个应用的堆大小设置硬性上限。不同设备的确切堆大小上限取决于设备的总体RAM大小。如果应用在达到堆容量上限后尝试分配更多内存,则可能会收到OutOfMemory异常。
在某些情况下,例如:为了确定在缓存中保存多少数据比较安全,我们可以通过调用getMemoryClass()方法查询系统以确定当前设备上确切可用的堆空间大小,这个方法返回一个整数,表示应用堆的可用兆字节数。
切换应用
当用户在应用之间切换时,Android会将非前台应用保留在缓存中。非前台应用就是指用户看不到或者未运行的前台服务(例如:音乐播放)的应用。例如:当用户首次启动某个应用时,系统会为其创建一个进程,但是当用户离开此应用时,该进程不会退出,系统会将该进程保留在缓存中,如果用户稍后返回该应用,系统就会重复使用该进程,从而加快应用切换速度。
如果应用具有缓存的进程并且保留了目前不需要的资源,那么即使用户未使用应用,它也会影响系统的整体性能,当系统资源(例如:内存)不足时,它就会终止缓存中的进程,系统还会考虑终止占用最多内存的进程以释放RAM。
要注意的是,当应用处于缓存中时,所占用的内存越少,就越有可能免于被终止并得以快速恢复,但是系统也可能根据当下的需求不考虑缓存进程的资源使用情况而随时将其终止。
进程间的内存分配
Android平台在运行时不会浪费可用的内存,它会一直尝试利用所有可用的内存。例如:系统会在应用关闭后将其保留在内存中,以便用户快速切回到这些应用,因此,通常情况下,Android设备在运行时几乎没有可用的内存,所以要在重要系统进程和许多用户应用之间正确分配内存,内存管理至关重要。
下面会讲解Android是如何为系统和用户应用分配内存的基础知识和操作系统如何应对低内存情况。
内存类型
Android设备包含三种不同类型的内存:RAM、zRAM和存储器,如下图所示:
要注意的是,CPU和GPU访问同一个RAM。
- RAM是最快的内存类型,但其大小通常有限。高端设备通常具有最大的RAM容量。
- zRAM是用于交换空间的RAM分区。所有数据在放入zRAM时会进行压缩,然后在从zRAM向外复制时进行解压缩。这部分RAM会随着页面进出zRAM而增大或者缩小。设备制造商可以设置zRAM大小上限。
- 存储器中包含所有持久性数据(例如:文件系统等)和为所有应用、库和平台添加的对象代码。存储器比另外两种内存的容量大得多。在Android上,存储器不像在其他Linux实现上那样用于交换空间,因为频繁写入会导致这种内存出现损坏,并缩短存储媒介的使用寿命。
内存页面
随机存取存储器(RAM)分为多个页面。通常,每个页面为4KB的内存。
系统会将页面视为可用或者已使用。可用的页面是未使用的RAM,已使用的页面是系统目前正在使用的RAM,可以分为以下类别:
-
缓存页:有存储器中的文件(例如:代码或者内存映射文件)支持的内存。缓存内存有两种类型:
-
私有页:由一个进程拥有且未共享。
- 干净页:存储器中未经修改的文件副本,可由内核交换守护进程(kswapd)删除以增加可用内存。
- 脏页:存储器中经过修改的文件副本,可由内核交换守护进程(kswapd)移动到zRAM或者在zRAM中进行压缩以增加可用内存。
-
共享页:由多个进程使用。
- 干净页:存储器未经修改的文件副本,可由内核交换守护进程(kswapd)删除以增加可用内存。
- 脏页:存储器中经过修改的文件副本,允许通过内核交换守护进程(kswapd)或者通过明确使用msync()或munmap()将更改写回存储器中的文件,以增加内存空间。
-
私有页:由一个进程拥有且未共享。
-
匿名页:没有存储器中的文件支持的内存(例如:由设置了MAP_ANONYMOUS标记的mmap()进行分配)。
- 脏页:可由内核交换守护进程(kswapd)移动到zRAM或者在zRAM中进行压缩以增加可用内存。
要注意的是,干净页包含存在于存储器中文件(或者文件一部分)的精确副本。如果干净页不再包含文件的精确副本(例如:因应用操作所致),则会变成脏页。干净页可以删除,因为始终可以使用存储器中的数据重新生成它们;脏页不可以删除,否则数据将会丢失。
内存不足管理
Android有两种处理内存不足情况的主要机制:内核交换守护进程和低内存终止守护进程。
内核交换守护进程(kswapd)
内核交换守护进程(kswapd)是Linux内核的一部分,用于将已使用内存转换为可用内存。当设备上的可用内存不足时,该守护进程将变为活动状态。Linux内核设有可用内存上下限阈值。当可用内存降至下限阈值以下时,kswapd开始回收内存;当可用内存达到上限阈值时,kswapd停止回收内存。
kswapd可以删除干净页来回收它们,因为这些页面受到存储器的支持且未经修改。如果某个进程尝试处理已删除的干净页,则系统会将该页面从存储器复制到RAM,这个操作成为请求分页。
下图展示的是由存储器支持的干净页已删除:
kswapd可以将缓存的私有脏页和匿名脏页移动到zRAM进行压缩,这样可以释放RAM中的可用内存(可用页面)。如果某个进程尝试处理zRAM中的脏页,该页面将被解压缩并移回到RAM。如果与压缩页面关联的进程被终止,则该页面将从zRAM中删除。如果可用内存量低于特定阈值,系统会开始终止进程。
下图展示的是脏页被移至zRAM并进行压缩:
低内存终止守护进程(LMK)
很多时候,内核交换守护进程(kswapd)不能为系统释放足够多的内存。在这种情况下,系统会使用onTrimMemory()方法通知应用内存不足,应该减少其分配量。如果这还不够,Linux内核会开始终止进程以释放内存,它会使用低内存终止守护进程(LMK)来执行此操作。
LMK使用一个名为oom_adj_score的内存不足分值来确定正在运行的进程的优先级,以此决定要终止的进程。最高得分的进程最先被终止。后台应用最先被终止,系统进程最后被终止。
下图列出了从高到低的LMK评分类别,评分最高的类别,即第一行中的项目将最先被终止:
- 后台应用(Background apps):之前运行过且当前不处于活动状态的应用。LMK将首先从具有最高oom_adj_score的应用开始终止后台进程。
- 上一个应用(Previous app):最近用过的后台应用。上一个应用比后台应用具有更高的优先级(得分更低),因为相比某个后台应用,用户更有可能切换到上一个应用。
- 主屏幕应用(Home app):这是启动器应用。终止该应用会使壁纸消失。
- 服务(Services):服务由应用启动,例如:同步或者上传到云端。
- 可觉察的应用(Perceptible apps):用户可通过某种方式察觉到的非前台应用,例如:运行一个显示小界面的搜索或者听音乐。
- 前台应用(Foreground app):当前正在使用的应用。终止前台应用看起来就像是应用崩溃了,可能会向用户提示设备出了问题。
- 持久性(服务)(Persisient):这些是设备的核心服务,例如:电话和WLAN。
- 系统(System):系统进程。这些进程被终止后,手机可能看起来即将重新启动。
- 原生(Native):系统使用的极低级别的进程,例如:内核交互终止守护线程(kswapd)。
要注意的是,设备制造商可以更改LMK的行为。
计算内存占用量
内核会跟踪系统中的所有内存页面。
下图展示的是不同进程使用的页面:
在确定应用使用的内存量时,系统必须考虑共享的页面。访问相同服务或者库的应用将共享内存页面,例如:Google Play服务和某个游戏应用可能会共享位置信息服务,这样便很难确定属于整个服务和每个应用的内存量分别是多少。下图展示的是由两个应用共享的页面(中间):
如果需要确定应用的内存占用量,可以使用以下任一指标:
- 常驻内存大小(RSS):应用使用的共享和非共享页面的数量。
- 按比例分摊的内存大小(PSS):应用使用的非共享页面的数量加上共享页面的均匀分摊数量(例如:如果三个进程共享3MB,则每个进程的PSS为1MB)。
- 独占内存大小(USS):应用使用的非共享页面数量(不包括共享页面)。
如果操作系统想要知道所有进程使用了多少内存,那么按比例分摊的内存大小(PSS)非常有用,因为页面只统计一次,不过计算需要花很长时间,因为系统需要确定共享的页面以及共享页面的进程数量。常驻内存大小(RSS)不区分共享和非共享页面,因此计算起来更快,更适合跟踪内存分配量的变化。
管理应用内存
随机存取存储器(RAM)在任何软件开发环境中都是一项宝贵资源,尤其是在移动操作系统中,由于物理内存通常都有限,因此RAM就更加宝贵了。虽然Android Runtime(ART)虚拟机和Dalvik虚拟机都执行例行的垃圾回收任务,但这并不意味着我们可以忽略应用分配和释放内存的位置和时间。我们仍然需要避免引入内存泄漏问题(通常因为在静态成员变量中保留对象引用而引起),并且在适当时间(例如:生命周期回调)释放所有Reference对象。
监控可用内存和内存使用量
我们需要先找到应用中内存使用问题,然后才能修复问题。可以使用Android Studio中的内存性能剖析器(Memory Profiler)来帮助我们查找和诊断内存问题:
- 了解我们的应用在一段时间内如何分配内存。Memory Profiler可以显示实时图表,包括:应用的内存使用量、分配的Java对象数量和垃圾回收事件发生的时间。
- 发起垃圾回收事件,并在应用运行时拍摄Java堆的快照。
- 记录应用的内存分配情况,然后检查有分配的对象、查看每个分配的堆栈轨迹,并在Android Studio编辑器中跳转到对应的代码。
释放内存以响应事件
如上面所述,Android可以通过多种方式从应用中回收内存或者在必要时完全终止应用,从而释放内存以执行关键任务。为了进一步帮助平衡系统内存并避免系统需要终止我们的应用进程,我们可以在Activity类中实现ComponentCallback2接口并且重写onTrimMemory()方法,就可以在处于前台或者后台时监听与内存相关的事件,然后释放对象以响应指示系统需要回收内存的应用生命周期事件或者系统事件,示例代码如下所示:
/**
* Created by TanJiaJun on 2020/7/7.
*/
class MainActivity : AppCompatActivity(), ComponentCallbacks2 {
/**
* 当UI隐藏或者系统资源不足时释放内存。
* @param level 引发的与内存相关的事件
*/
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
when (level) {
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
/**
* 释放当前持有内存的所有UI对象。
*
* 用户界面已经移动到后台。
*/
}
ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
/**
* 释放应用程序不需要运行的内存。
*
* 应用程序运行时,设备内存不足。
* 引发的事件表示与内存相关的事件的严重程度。
* 如果事件是TRIM_MEMORY_RUNNING_CRITICAL,那么系统将开始杀死后台进程。
*/
}
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
ComponentCallbacks2.TRIM_MEMORY_MODERATE,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> {
/**
* 释放进程能释放的尽可能多的内存。
*
* 该应用程序在LRU列表中,同时系统内存不足。
* 引发的事件表明该应用程序在LRU列表中的位置。
* 如果事件是TRIM_MEMORY_COMPLETE,则该进程将是第一个被终止的。
*/
}
else -> {
/**
* 发布任何非关键的数据结构。
*
* 应用程序从系统接收到一个无法识别的内存级别值,我们可以将此消息视为普通的低内存消息。
*/
}
}
}
}
要注意的是,onTrimMemory()方法是Android4.0才添加的,对于早期版本,我们可以使用onLowMemory()方法,这个回调方法大致相当于TRIM_MEMORY_COMPLETE事件。
查看应该使用多少内存
为了允许多个进程同时运行,Android针对为每个应用分配的堆大小设置了硬性限制,这个限制会因设备总体可用的RAM多少而异。如果我们的应用已达到堆容量上限并尝试分配更多内存,系统会抛出OutOfMemory异常。
为了避免用尽内存,我们可以查询系统以确定当前设备上可用的堆空间,可以通过调用getMemoryInfo()方法向系统查询此数值,这个方法会返回ActivityManager.MemoryInfo对象,这个对象会提供与设备当前的内存状态有关的信息,例如:可用内存、总内存和内存阈值(如果达到此内存级别,系统就会开始终止进程)。ActivityManager.MemoryInfo对象还会提供一个布尔值lowMemory,我们可以根据这个值确定设备是否内存不足。示例代码如下所示:
fun doSomethingMemoryIntensive() {
// 在执行需要大量内存的逻辑之前,检查设备是否处于低内存状态
if (!getAvailableMemory().lowMemory) {
// 执行需要大量内存的逻辑
}
}
// 获取设备当前内存状态的MemoryInfo对象
private fun getAvailableMemory(): ActivityManager.MemoryInfo =
ActivityManager.MemoryInfo().also {
(getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(it)
}
使用内存效率更高的代码结构
我们可以在代码中选择效率更高的方案,以尽可能降低应用的内存使用量。
谨慎使用服务(Service)
如果我们的应用需要某项服务(Service)在后台执行工作,请不要让其保持运行状态,除非它真的需要运行作业,在服务完成任务后应该使其停止运行,否则可能会导致内存泄漏。
在我们启动某项服务后,系统更倾向于让此服务的进程始终保持运行状态,这种行为会导致服务进程代价十分高昂,因为一旦服务使用了某部分RAM,那么这部分RAM就不再供其他进程使用,这样会减少系统可以在LRU缓存中保留的缓存进程数量,从而降低应用切换效率。当内存紧张,并且系统无法维护足够的进程以托管当前运行的服务时,就可能导致内存抖动。
通常应该避免使用持久性服务,因为它们会对可用内存提出持续性的要求,我们可以使用JobScheduler调度后台进程。
如果我们必须使用某项服务,则限制此服务的生命周期的最佳方式是使用IntentService,它会在处理完启动它的intent后立即自行结束。
使用经过优化的数据容器
编程语言所提供的部分类并未针对移动设备做出优化,例如:常规HashMap实现的内存效率可能十分低下,因为每个映射都需要分别对应一个单独的条目对象。
Android框架包含几个经过优化的数据容器,例如:SparseArray、SparseBooleanArray和LongSparseArray,以SparseArray为例,它的效率更高,因为它可以避免系统需要对键(有时还对值)进行自动装箱(这会为每个条目分别创建1~2个对象)。
根据业务需要,尽可能使用精简的数据结构,例如:数组。
谨慎对待代码抽象
开发者往往会将抽象简单地当做一种良好的编程做法,因为抽象可以提高代码灵活性和维护性,不过抽象的代价很高,通常它们需要更多的代码才能执行,需要更多的时间和更多的RAM才能将代码映射到内存中,因此,如果抽象没有带来显著的好处时,我们就应该避免使用抽象。
针对序列化数据使用精简版Protobuf
协议缓冲区(Protocol Buffers)是Google设计的一种无关语言和平台并且可扩展的机制,用于对结构化数据进行序列化,它与XML类似,但是更小、更快也更简单。在移动端中使用精简版的Protobuf,因为常规Protobuf会生成极其冗长的代码,这会导致应用出现各种问题:例如:RAM使用量增多、APK大小显著增加和执行速度变慢。
避免内存抖动
如前面所述,垃圾回收事件通常不会影响应用的性能,不过如果在短时间内发生许多垃圾回收事件,就可能会快速耗尽帧时间,系统花在垃圾回收上的时间越多,能够花在呈现界面或者流式传输音频等其他任务上的时间就越少。
通常,内存抖动可能会导致出现大量的垃圾回收事件,实际上,内存抖动可以说明在给定时间内出现的已分配临时对象的数量,例如:我们在for循环中分配多个临时对象或者在View的onDraw()方法中创建Paint对象或者Bitmap对象,在这两种情况下,应用都会快速创建大量对象,这些操作可以快速消耗新生代(young generation)区域中的所有可用内存,从而迫使垃圾回收事件发生。
我们可以借助Android Studio中内存性能剖析器(Memory Profiler)找到内存抖动较高的位置,确定代码中问题区域后,尝试减少对性能至关重要的区域中的分配数量,可以考虑将某些代码逻辑从内部循环中移出或者使用工厂方法模式。
移除会占用大量内存的资源和库
代码中的某些资源和库可能会在我们不知情的情况下吞噬内存,APK的总体大小(包括第三方库或者嵌入式资源)可能会影响应用的内存消耗量,我们可以通过从代码中移除任何冗余、不必要或者臃肿的组件、资源或者库,降低应用的内存消耗量。
缩减总体APK大小
我们可以通过缩减应用的总体大小来显著降低应用的内存使用量。位图(bitmap)大小、资源、动画帧数和第三方库都会影响APK的大小。Android Studio和Android SDK提供了帮助我们缩减资源和外部依赖项大小的多种工具,这些工具可以缩减代码,例如:R8编译。
当我们使用Android Gradle插件3.4.0版本及更高版本构建项目时,这个插件不再使用ProGuard来执行编译时代码优化,而是与R8编译器协同工作来处理以下编译时任务:
- 代码缩减(即摇树优化(Tree Shaking)):从应用及其库依赖项中检测并安全地移除未使用的类、字段、方法和属性(这使其成为了一个对于规避64K引用限制非常有用的工具)。例如:如果我们仅使用某个库依赖项的少数几个API,缩减功能可以识别应用未使用的库代码,并且从应用中移除这部分代码。
- 资源缩减:从封装应用中移除不使用的资源,包括应用库依赖项中不使用的资源,这个功能可以与代码缩减功能结合使用,这样一来,移除不使用的代码后,也可以安全地移除不再引用的所有资源。
- 混淆处理:缩短类和成员的名称,从而减少DEX文件的大小。
- 优化:检查并重写代码,以进一步减少应用的DEX文件的大小。例如:如果R8检测到从未使用过某段if/else语句的else分支的代码,则会移除else分支的代码。
使用Android App Bundle上传应用(仅限于Google Play)
要在发布到Google Play时立即缩减应用大小,最简单的方法就是将应用发布为Android App Bundle,这是一种全新的上传格式,包含应用的所有编译好的代码和资源,Google Play负责处理APK生成和签名工作。
Google Play的新应用服务模式Dynamic Delivery会使用我们提供的App Bundle针对每位用户的设备配置生成并提供经过优化的APK,因此他们只需下载运行我们的应用所需的代码和资源,我们不需要再编译、签署和管理多个APK以支持不同的设备,而用户也可以获得更小、更优化的下载文件包。
要注意的是,Google Play规定我们上传的签名APK的压缩下载大小限制为不超过100MB,而对使用App Bundle发布的应用压缩下载大小限制为150MB。
使用Android Size Analyzer
Android Size Analyzer工具可让我们轻松地发现和实施多种缩减应用大小的策略,它可以作为Android Studio插件或者独立JAR使用。
在Android Studio中使用Android Size Analyzer
我们可以使用Android Studio中的插件市场下载Android Size Analyzer插件,可以按着以下步骤操作:
- 依次选择Android Studio>Preferences,如果是Windows的话,依次选择File>Settings。
- 选择左侧面板中的Plugins部分。
- 点击Marketplace标签。
- 搜索Android Size Analyzer插件。
- 点击分析器插件的Install按钮。
如下图所示:
安装插件后,从菜单栏依次选择Analyze>Analyze App Size,对当前项目运行应用大小分析,分析了项目后,系统会显示一个工具窗口,其中包含有关如何缩减应用大小的建议,如下图所示:
通过命令行使用分析器
我们可以从GitHub以TAR或者ZIP文件形式下载最新版本的Android Size Analyer,解压缩文件后,使用以下某个命令对Android项目或者Android App Bundle运行size-analyzer脚本(在Linux或者MacOS上)或者size-analyzer.bat脚本(在Windows上):
./size-analyzer check-bundle <path-to-aab>
./size-analyzer check-project <path-to-project-directory>
了解APK结构
在讨论如何缩减应用的大小之前,有必要了解下APK的结构。APK文件由一个Zip压缩文件组成,其中包含构成应用的所有文件,这些文件包括Java类文件、资源文件和包含已编译资源的文件。
APK包含以下文件夹:
- META-INF/:包含CERT.SF和CERT.RSA签名文件,以及MANIFEST.MF清单文件。
- assets/:包含应用的资源,可以使用AssetManager对象检索这些资源。
- res/:包含未编译到resources.arsc中的资源。
- lib/:包含特定于处理器软件层的已编译代码。这个目录包含每种平台类型的子目录,例如:armeabi、armeabi-v7a、arm64-v8a、x86、x86_64和mips。
APK还包含以下文件,在这些文件中,只有AndroidManifest.xml是必需的:
- resources.arsc:包含已编译的资源,这个文件包含res/values/文件夹的所有配置中的XML内容。打包工具会提取此XML内容,将其编译成二进制文件形式,并压缩内容,这些内容包括语言字符串和样式,以及未直接包含在resources.arsc文件中的内容(例如:布局文件和图片)的路径。
- classes.dex:包含以Android Runtime(ART)虚拟机和Dalvik虚拟机可理解的DEX文件格式编译的类。
- AndroidManifest.xml:包含Android清单文件,这个文件列出了应用的名称、版本、访问权限和引用的库文件,它使用了Android的二进制XML格式。
缩减资源数量和大小
APK的大小会影响应用加载速度、使用的内存量和消耗的电量。缩减APK大小的一种简单方法是缩减其包含的资源数量和大小,具体来说,我们可以移除应用不再使用的资源,并且可以用可伸缩的Drawable对象取代图片文件。
移除未使用的资源
lint工具是Android Studio中附带的静态代码分析器,可以检测到res/文件夹中未被代码引用的资源,当lint工具发现项目中有可能未使用的资源时,会显示一条消息,消息如下所示:
res/layout/preferences.xml: Warning: The resource R.layout.preferences appears
to be unused [UnusedResources]
要注意的是,lint工具不会扫描assets/文件夹、通过反射引用的资源和已链接至应用的库文件,此外,它不会移除资源,只会提醒我们它们的存在。
如果我们在应用的build.gradle文件中启用了shrinkResource,那么Gradle可以帮我们自动移除未使用的资源,示例代码如下:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
要使用shrinkResource,我们必须启用代码缩减功能,在编译过程中,R8首先会移除未使用的代码,然后Android Gradle插件会移除未使用的资源。
在Android Gradle插件0.7版本及更高版本中,我们可以声明应用支持的配置。Gradle会使用resConfig和resConfigs变体以及defaultConfig选项将这些信息传递给编译系统,随后,编译系统会阻止来自其他不受支持配置的资源出现在APK中,从而缩减APK的大小。
要注意的是,代码缩减可以清理库的一些不必要代码,但可能无法移除大型内部依赖项。
尽量减少库中的资源使用量
在开发Android应用时,我们通常需要使用外部库来提高应用的可用性和多功能性,例如:我们可以使用Glide来实现图片加载功能。
如果库是为服务器或者桌面设备设计的,则它可能包含应用不需要的许多对象和方法,如果库许可允许我们修改库,我们可以编辑库的文件来移除不需要的部分,我们还可以使用适合移动设备的库。
仅支持特定密度
Android支持多种设备,涵盖了各种屏幕密度。在Android 4.4(API级别19)及更高版本中,框架支持各种密度:ldpi、mdpi、tvdpi、hdpi、xhdpi、xxhdpi和xxxhdpi。尽管Android支持所有这些密度,但是我们无需将光栅化资源导出为每个密度。
如果我们不添加用于特定屏幕密度的资源,Android会自动缩放为其他屏幕密度设计的资源,建议每个应用至少包含一个xxhdpi图片变体。
使用可绘制对象
某些图片不需要静态图片资源,框架可以在运行时动态绘制图片。我们可以使用Drawable对象(XML中的shape元素)来动态绘制图片,它只会占用APK中的少量空间,此外,XML的Drawable对象可以生成符合Material Design准则的单色图片。
重复使用资源
我们可以为图片的变体添加单独的资源,例如:同一图片经过色调调整、阴影设置或者旋转的版本。建议重复使用同一组资源,并在运行时根据需要对其进行自定义。
在Android5.0(API级别21)及更高版本上,使用android:tint和android:tintMode属性可以更改资源的颜色,对于较低版本的平台,则使用ColorFilter类。
我们可以省略仅是另一个资源的旋转等效项的资源,下面例子展示了通过绕图片中心位置旋转180度,将拇指向上变成拇指向下,示例代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_thumb_up"
android:fromDegrees="180"
android:pivotX="50%"
android:pivotY="50%" />
从代码进行渲染
我们可以通过按一定程序渲染图片来缩减APK大小,这样可以释放不少空间,因为不需要在APK中存储图片文件。
压缩PNG文件
aapt工具可以在编译过程中通过无损压缩来优化放置在res/drawable/中的图片资源,例如:aapt工具可以通过调色板将不需要超过256种颜色的真彩色PNG转换为8位PNG,这样做会生成质量相同但内存占用量更小的图片。
要注意的是,aapt工具具有以下限制:
aapt工具不会缩减asset/文件夹中包含的PNG文件。
图片文件需要使用256种或更少的颜色才可供aapt工具进行优化。
-
aapt工具可能会扩充已压缩的PNG文件,为防止出现这种情况,我们可以在Gradle中使用cruncherEnabled标记为PNG文件停用此过程,示例代码如下:
aaptOptions { cruncherEnabled = false }
压缩PNG和JPEG文件
我们可以使用pngcrush、pngquant或者zopflipng等工具缩减PNG文件的大小,同时不损失画质。所有这些工具都可以缩减PNG文件的大小,同时保持肉眼感知的画质不变。
pngcrush工具是最有效的:该工具会迭代PNG过滤器和zlib(Deflate)参数,使用过滤器和参数的每个组合来压缩图片,然后它会选择可产生最小压缩输出的配置。
要压缩JPEG文件,我们可以使用packJPG和guetzli等工具。
使用WebP文件格式
如果以Android3.2(API级别13)及更高版本为目标(target),我们可以使用WebP文件格式的图片代替PNG文件或者JPEG文件。WebP格式提供有损压缩(例如:JPEG)和透明度(例如:PNG),不过与PNG或者JPEG相比,这种格式可以提供更好的压缩效果。
我们可以使用Android Studio将现有的BMP、JPG、PNG或者静态GIF图片转换成WebP格式。
要注意的是,Google Play只接受PNG格式的启动器图标。
使用矢量图形
我们可以使用矢量图形创建与分辨率无关的图标和其他可伸缩媒体,它可以极大地减少APK占用的空间。矢量图片在Android中以VectorDrawable对象的形式表示,100字节的文件可以生成与屏幕大小相同的清晰图片。
要注意的是,系统渲染每个VectorDrawable对象需要花费大量时间,使用VectorDrawable对象渲染较大的图片需要更长的时间才能显示在屏幕上,因此建议在显示小图片时才使用VectorDrawable对象。
将矢量图形用于动画图片
请勿使用AnimationDrawable创建逐帧动画,因为这样做需要为动画的每个帧添加单独的位图(bitmap)文件,而这样做就会大大增加APK的大小,应该改为使用AnimatedVectorDrawableCompat创建动画矢量可绘制资源。
减少原生(Native)和Java代码
我们可以使用多种方法来缩减应用中的原生(Native)和Java代码库的大小。
移除不必要的生成代码
确保了解自动生成任何代码所占用的空间,例如:许多协议缓冲区工具会生成过多的类和方法,这可能会使应用的大小增加一倍或者两倍。
避免使用枚举
单个枚举会使应用的classes.dex文件增加大约1.0到1.4KB的大小,这些增加的大小会快速累积,产生复杂的系统或者共享库,如果可能,请考虑使用@IntDef注解和代码缩减移除枚举并将它们转换为整数,此类型转换可保留枚举的各种安全优势。
缩减原生二进制文件的大小
如果我们的应用使用原生代码和Android NDK,我们还可以通过优化代码来缩减发布版应用的大小,移除调试符号和避免解压缩原生库是两项很实用的技术。
移除调试符号
如果应用正在开发中且仍需要调试,则使用调试符号非常合适,我们可以使用Android NDK中提供的arm-eabi-strip工具从原生库中移除不必要的调试符号,之后,我们就可以编译发布版本。
避免解压缩原生库
在构建应用的发布版本时,我们可以通过在应用清单的application元素中设置android:extractNativeLibs="false",将未压缩的.so文件打包在APK中。停用此标记可防止PackageManager在安装过程中将.so文件从APK复制到文件系统,并具有减少应用更新的额外好处。使用Android Gradle插件3.6.0版本及更高版本构建应用时,插件会默认将此属性设为false。
维护多个精简APK
APK可能包含用户下载但从不使用的内容,例如:其他语言或者针对特定屏幕密度的资源。要确保为用户提供最小的下载文件,我们应该使用Android App Bundle将应用上传到Google Play。通过上传App Bundle,Google Play能够针对每位用户的设备配置生成并提供经过优化的APK,因此用户只需下载运行我们的应用所需的代码和资源,我们无需再编译、签署和管理多个APK以支持不同的设备,而用户也可以获得更小、更优化的下载文件包。
如果我们不打算将应用发布到Google Play,则可以将应用细分为多个APK,并按屏幕尺寸或者GPU纹理支持等因素进行区分。
当用户下载我们的应用时,我们的设备会根据设备的功能和设置接收正确的APK,这样的话设备就不会接收设备所不具备的功能和资源,例如:如果用户具有hdpi设备,则不需要为更高密度显示器提供的xxxhdpi资源。
使用Dagger2实现依赖注入
依赖注入框架可以简化我们编写的代码,并提供一个可供我们进行测试及其他配置更改的自适应环境。
如果我们打算在应用中使用依赖注入框架,请考虑使用Dagger2。Dagger2不使用反射来扫描应用的代码,它的静态编译时实现意味着它可以在Android应用中使用,而不会带来不必要的运行时代价或者内存消耗量。
其他使用反射的依赖注入框架倾向于通过扫描代码中的注释来初始化进程,这个过程可能需要更多的CPU周期和RAM,并可能在应用启动时导致出现明显的延迟。
谨慎使用外部库
外部库代码通常不是针对移动环境编写的,在移动客户端上运行可能效率低下。如果我们决定使用外部库,则可能需要针对移动设备优化该库,在决定使用该库之前,请提前规划,并在代码大小和RAM消耗量方面对库进行分析。
即使是一些针对移动设备进行优化的库,也可能因实现方式不同而导致问题,例如:一个库可能使用的是精简版Protobuf,而另一个库使用的是Micro Protobuf,导致我们的应用出现两种不同的Protobuf实现。日志记录、分析、图片加载框架以及许多我们意外之外的其他功能的不同实现都可能导致这种情况。
虽然ProGuard可以使用适当的标记移除API和资源,但是无法移除库的大型内部依赖项。我们所需要的这些库中的功能可能需要较低级别的依赖项。如果存在以下情况,这就特别容易导致出现问题:我们使用某个库中的Activity子类(往往会有大量的依赖项)、库使用反射(这很常见,意味着我们需要花费大量的时间手动调整ProGuard以使其运行)等。
此外,请避免针对数十个功能中的一两个功能使用共享库,这样会产生大量我们甚至根本用不到的代码和开销,在考虑是否使用这个库时,请查找与我们的需求十分契合的实现,否则,我们可以决定自己去创建实现。
参考文献:Android Developers
我的GitHub:TanJiaJunBeyond
Android通用框架:Android通用框架
我的掘金:谭嘉俊
我的简书:谭嘉俊
我的CSDN:谭嘉俊