由于项目里之前线上版本出现过一定比例的 OOM, 虽然比例并不大, 但是还是暴露了一定的问题, 所以打算对我们 App 分为几个步骤进行内存分析和优化, 当然内存的优化是个长期的过程, 不是一两个版本的事, 每个版本都需要收集线上内存数据进行监控以及分析.
版本迭代过程中, 内存增长过快, 不仅会导致一定概率的 OOM, 运行时若出现内存抖动, 导致频繁 GC, 则会对 App 的流畅度以及用户体验造成很大影响.
本文主要会根据实际项目中优化步骤分为以下几部分:
Android 内存分析基础
内存泄漏
静态内存分析优化
运行时内存分析优化
监控
1.Android 内存分析基础
这部分主要先介绍一些进行内存分析的基础方法以及工具, 对这部分比较熟悉的同学可以先跳过哈.
一. App 的内存使用情况概览
每个 App 进程可以分配到的最大内存是有限的, 当然不同手机每个 App 进程可以分配到的最大内存有可能不一样, 可以通过以下命令进行查看:
adb shell getprop | grep dalvik.vm.heapsize
我们可以输出我们 App 的内存使用情况概览:
adb shell dumpsys meminfo 包名
我们就可以看到:
Pss: 该进程独占的内存 + 与其他进程共享的内存(按比例分配, 比如与其他 3 个进程共享 9K 内存, 则这部分为 3K)
Privete Dirty: 该进程独享内存
Heap Size: 分配的内存
Heap Alloc: 已使用的内存
Heap Free: 空闲内存
二, Android Profiler
AndroidStduio3.0 后 Android Profiler 变得比之前更强大, 内存分析页变得更加直观更加方便, 下面是截图:
进程占用总内存
javaHeap: 这部分内存大小是有限制的, 溢出则会 OOM, 这部分内存也是我们分析优化的重点
NativeHeap:native 层的 so 中调用 malloc 或 new 创建的内存, 对于单个进程来说大小没有限制, 所以可以利用在 native 层分配内存来缓解 javaHeap 的压力(比如 2.3.3 之前 Android Bitmap 的内存分配就是在 native 层, 之后移到 javaHeap, 8.0 又回到 native)
Graphics: 这部分一般游戏 app 中用的较多, OpenGL 和 SurfaceFlinger 相关的内存, 若没有直接调用到 OpenGL, 则一般不会涉及到这块内存
Stack: 栈, 了解 jvm 内存模型的应该都知道
Code: 代码, 主要是 dex 以及 so 等占用的内存
Others: 就是 others 啦
所以我们可以看到事实上我们可以优化的点有: JavaHeap,NativeHeap,Stack,Code 所占用的内存
三, 强大的 MAT
MAT 是做比较细致的内存分析的利器了, 功能十分强大, 其中的:
Hisogram:Lists number of instances per class
Dominator Tree:List the biggest objects and what they keep alive.
可以非常方便的排序查看当前内存中最占内存的 class 或者实体对象, 而且有一条非常清晰的引用链来查看该对象的持有者, 这对内存的分析以及内存泄漏的分析都是非常友好的.
同时 MAT 支持 compare 对比功能, 将两个. hprof 文件导入, 都 Add to Compare Basket 之后即可进行对比, 这对于对比某个页面相较与前一页面的内存增量来说是非常有意义的.
有一点比较不友好的是, MAT 需要标准的. hprof 文件, 所以在 AndroidStduio 的 Profiler 中 GC 后 dump 出的内存快照还要自己手动利用 android sdk platform-tools 下的 hprof-conv 进行转换一下才能被 MAT 打开. 当然如果觉得麻烦的话也可以自己写个脚本执行几条命令来直接完成 GC->dump java heap->转换. hprof 文件 这个流程:
- //adb and hprof-conv
- ADB=${ANDROID_HOME}/platform-tools/adb
- HPROF_CONV=${ANDROID_HOME}/platform-tools/hprof-conv
- //GC
- ${ADB} shell pkill -l 10 $(PACKAGE_NAME)
- //dump java heap
- ${ADB} shell "am dumpheap $(PACKAGE_NAME) $(OUT_PATH)"
- //conv hprof
- ${HPROF_CONV} -z ${FILE_NAME} droid-${FILE_NAME}
2. 内存泄漏
根据以往经验, 其实做内存优化最先要搞定的应该是内存中的大头, 这类大头对内存的占用很大, 也是内存问题的主要祸首, 相对来说比较容易定位问题, 且优化后效果也非常明显, 性价比非常高.
事实上很多优化都是这样, 比如减包大小的优化, 也是要先分析出主要大头祸首, 比如可能你的包里包含了一张 3M 大小的无用图片, 如果你没找到这种祸首, 可能你做了大量的工作去想办法减少无用代码等, 最终可能只有几百 K 的收益.
相对内存来说, 这个大头就是:
内存泄漏
图片
所以首先你要确保你的应用里没有存在内存泄漏, 然后再去做其他的内存优化.
内存泄漏检测
现在内存泄漏的检测已经变得非常简便了, 使用 App 后在 Android Profiler 中先触发 GC 然后 dump 内存快照, 之后点击按 package 分类, 就可以迅速查看到你的 App 目前在内存中残留的 class, 点击 class 即可在右边查看到对应的实例以及引用对象.
当然你也可以在 debug 下集成 LeakCanary 做内存泄漏监控警告
排除内存泄漏后, 图片就是另一个占用内存大头的对象了.
图片
对于图片来说一个是颜色模式, 检查一下项目里的图片的颜色模式, 是否可以降低, 比如从 RGB_8888 降到 RGB_565, 则每张图片可以节省 1/2 的内存, 如果没有使用到透明通道等的话基本上肉眼看不出差别.
还有一个是降低图片的大小, 可能你的 ImageView 只有你图片的一半大, 则这部分内存就大大浪费了, 我们项目服务端会根据前端的参数做动态切图.
前端也可以通过降低采样率 (inSampleSize) 来达到降低图片占用内存大小的目的, 但是这个采样率 InSampleSize 只能是整数(甚至只能是 2 的次方), 如果 inSampleSize=2, 则最终内存占用就会是原来的 1/4, 适用于图片过大很多的情况, 对于只是想做小幅度压缩的话, 基本没用.
ok, 接下来开始做具体的内存分析与稍微细致一点的内存优化.
3. 静态内存分析优化
这边说的静态内存指的是在伴随着 App 的整个生命周期一直存在的那部分内存, 也就是打底的, 具体获取这部分内存快照的方式是: 打开 App 开始重度使用 App, 基本打开每一个主要页面主要功能, 然后回到首页, 进开发者选项打开 "不保留后台活动", 然后将我们的 app 退到后台. 最后 GC,dump 出内存快照. 下面是我们 app dump 出的内存快照, 进行分析后制图如下:
通过对静态内存数据的分析, 主要发现了以下几个问题:
问题 1: App 首页的主图有两张(一张是保底图, 一张是动态加载的图), 都比较大, 而且动态加载的图回来后, 保底图并没有及时被释放
优化: 首先是对首页的主图进行颜色通道的改变以及压缩, 可以大大降低这两张图所占的内存, 然后在动态加载图回来后及时释放掉保底图 -5M
问题 2: 首页底部的轮播背景图占用内存 1.6M, 且在图片加载回来后, 背景图一直没有置空
优化: 首先一般来说对背景图的质量并没有很高的要求, 所以这张背景图是可以被成倍压缩的, 并且在图片加载回来后, 背景图要及时的释放掉. 同时首页的多张轮播图以及其他图片都可以进行颜色模式的改变以及质量压缩. -1.6M -4M
问题 3: 项目会在 App 启动时拉一个接口获取一些实验配置, 放进单例, 在内存分析时发现, 这些实验配置竟然接近 1M
优化: 排查后发现, 接口拉的是整个公司所有部门的实验配置, 上千个, 这也给遍历拿一个实验配置带来一定的性能损耗, 推动接口去改进, 只获取当前部门业务需要的实验配置, 可节省内存 90% 以上 -700K
问题 4: 发现几个 lottie 动画一直没有被回收, 并且同一个 lottie 动画会有几个不同的实例存在, 总共占用内存 450K
优化: 首先要确定几个 lottie 动画为什么在页面退出后没有被回收, 并且同一个动画有几个不同的实例, 很容易就联想到内存泄漏, 由于页面没有被销毁, 所以导致几个 lottie 动画也没有被回收, 排查下来是项目里的 RN 页面存在内存泄漏, 解决后大概可以节省 3-5M 内存
问题 5: SharePreference 在内存里占用了 700K 的内存
优化: 由于 SP 中的东西是会一次性加载到内存里并且保存为静态的, 直到 App 进程结束才会被销毁, 所以 SP 中千万别放大的对象, 别图一时方便把对象序列化成 json 后保存到 SP 里, 优化点就是把已经保存在 SP 中的一些较大的 json 字符串或者对象迁移到文件或者数据库缓存. -400K
问题 6: 埋点数据
优化: 产品或者运营为了统计数据会在每个版本不断的增加新埋点, 但是也需要定期去清理掉一些过时的不需要的埋点, 来适当优化内存以及 CPU 的压力.
问题 7: 还有就是一些 App 里的单例以及一些静态缓存
优化: 整个看下来在我们项目中这部分占整体的静态内存其实较小, 综合考虑内存情况以及使用的高效性可以进行一定程度的优化, 不过这部分内存在 App 内存紧张时可以选择清理掉他们
我们可以选择在 App 退到后台后内存紧张即将被 Kill 掉时选择释放掉一些内存, 如图片的缓存, 静态缓存等来自保, 具体做法是在 Activity 中重写 onTrimMemory()方法(4.0 之前是 onLowMemory()), 在这里面来做内存的释放.
静态内存优化: 约 15M
4. 运行时内存分析优化
接下来做一下每个页面的运行时内存分析优化, 这一部分就是随着 App 运行过程增长以及回收的内存, 这部分工作十分繁琐, 需要耐得住寂寞啊.
分析和优化运行时内存主要是通过以下两个核心方式:
从首页开始用脚本 dump 出每个页面的内存快照文件, 然后利用 MAT 的对比功能, 找出每个页面相对于上个页面内存里主要增加了哪些东西, 做针对性优化
利用 Android Profiler 实时观察进入每个页面后的内存变化情况, 对产生的内存较大波峰做分析
首先介绍一下我们 App 中我们产线的主要核心页面流程: 搜索页 -->列表页 -->详情页 -->信息页 -->支付, 这里重点对列表页和详情页做运行时内存分析优化.
(1)列表页内存优化
下面是列表页的内存快照与搜索页的对比:
可以看到, 绝大部分的内存增加还是图片, 当然还有一些静态缓存:
问题 1: 列表 item 被回收时还持有图片的引用
优化: 应该在 item 被回收不可见时释放掉对图片的引用, 这里注意 RecyclerView 与 ListView 的区别, 如果是 ListView, 因为每次 item 被回收后再次利用都会重新绑定数据, 只需在 ImageView onDetchFromWindow 的时候释放掉图片引用即可. 而对于 RecyclerView 来说, 因为被回收不可见时第一选择是放进 mCacheView 中, 而这里面的 item 被复用时并不会执行 bindViewHolder 来重新绑定数据, 只有被回收进 mRecyclePool 中后拿出来复用才会重新绑定数据, 所以如果是 RecyclerView, 我们释放图片引用的时机应该是 item 被回收进 RecyclePool 的时候, 只要重写 Adapter 中的 onViewRecycled 方法即可:
- @Override
- public void onViewRecycled(@Nullable VH holder) {
- super.onViewRecycled(holder);
- if (holder != null) {
- // 做释放图片引用的操作
- }
- }
问题 2: 图片大小有优化空间
优化: 这个因为我司在服务端会对图片进行动态切图, 所以最简单的方法就是根据实际情况来改变动态切图的大小达到节省内存的作用, 当然如果从服务端请求回来的图片实在大 (一般不要比装载的 ImageView 要大), 前端就可以采用降低采样率的方式来进行压缩, 当然这个上面说了采样率(inSampleSize) 只支持 2 的次方, 所以对图片占用内存大小的压缩是非常大的, 如果你只是想小幅度的压缩, 基本上这个是没用的.
问题 3: 对 ImageLoader 图片缓存策略的思考
对于 UIL 这个图片框架, 他的缓存策略是内存缓存 + 磁盘缓存, 内存缓存默认的数据结构是 LruMemoryCache, 对图片是强引用, 默认最大 Size 是内存的 1/8, 满后会按照 LRU 算法对最近最不常用的图片进行移除, 看起来比较合理, 但是会有一个问题, 就是当图片缓存达到 1/8 后则图片所占的内存一直会保持在接近 1/8, 它没有自我清理的能力, 可能长时间过去了这 1/8 内存里的有些图片都不再需要了, 它也依然会保留在内存里不会被清除, 所以我们可以考虑对缓存的图片做一个有效期的管理, 图片过期后则自动清理一波, 这样可以优化很大一部分内存空间.
由于 UIL 对于内存缓存图片是以 "url+targetWidth+targetHeight" 作为 key, 如果我们加载图片的时候没有设置 targetSize, 则框架里默认会以 ImageView 的大小作为 targetSize, 那么就会出现一种情况, 同一张图片, 由于放在大小有轻微差异的 ImageView 上显示, 则由于 targetSize 不一样, 会在内存中被缓存两份, 当然要解决这个问题也很简单, 只要设置 denyCacheImageMultipleSizesInMemory()即可避免这种情况, 这样同一张图片在内存里就只会有一份缓存 (之前的会被之后的替换掉). 设置完 denyCacheImageMultipleSizesInMemory() 后又会出现一个新问题, 虽然内存里同一张图片只有一份了, 但这也意味着有轻微差异的 ImageView 加载的同一张图片在内存里没办法被复用了, 每次都要去磁盘缓存里重新加载(磁盘缓存是只以 url 作为 key 的).
那么如何做到让有轻微大小差异的 ImageView 加载同一张图片时既实现在内存缓存里进行复用又不会在内存缓存里保留两份缓存呢?
开启 denyCacheImageMultipleSizesInMemory()避免同一张图片因为 targetSize 不同而存在多个内存缓存
将有轻微大小差异的 ImageView 加载图片时手动设置一样的 targetSize, 这样缓存的 Key 就一致了, 就可以实现在内存里进行复用了, 而指定一样的 targetSize 并不会有什么风险, 因为上面说了, 只有你指定的 targetSize 比图片实际大小小 2 倍以上, 采样率才会生效, 实际图片才会被压缩.
(2)详情页的内存分析优化
可以看看刚进入详情页后会有一个明显的波峰, 通过点击 Adnroid Profiler 上的红色圆点来记录查看这段波峰里的内存分配.
首先详情页依然有大量的图片, 所以对于图片的大小以及复用上的优化上面已经说了, 这里就不重复说了.
问题 1: 在内存里发现两个极少概率出现的 empty view, 占用了接近 2M 的内存
优化: 用 ViewStub 对 empty view 做了懒加载, 对于这些没有马上用到的资源要做延迟加载, 还有很多大概率不会出现的 View 更加要做懒加载. -2M
问题 2: 发现详情页的轮播大图的 Viewpager 用的 Adapter 是 FragmentPagerAdapter, 导致了所有的 page 都会被保存, 当图片页数多的时候, 往后翻内存会不断上升.
优化: 这种页数多的 ViewPager 使用 FragmentStatePagerAdapter 来替代, 它只会保留前后 pager, 在页数多的时候可以 节省大量内存.
问题 3: 对于一些实在大的图并且复用频率并不高的大图只采用文件缓存就行了, 不做内存缓存.
问题 4: 我们项目在 debug 下会打印网络请求的 reqeust 和 response, 并且会用 String.subString()对较长的 response json 进行截取
优化: 本身 subString()就比较耗内存, 所以在 response 较大的时候就会申请大量的内存, 好在这种情况只会在 debug 下发生, 但是依然需要改进这种打印.
5. 监控
内存的分析优化并不是一两个版本的事, 而是一个必须每个版本持续进行的工作, 这需要一套完善的线上用户内存使用情况监测系统来进行数据上传, 数据分析, 数据整理, 数据对比, 方便我们明确的了解每个版本线上 App 内存的具体情况. 公司的一套性能监控平台, 可以在这方面给我们 App 开发人员提供很直观的监控数据和版本迭代对比.
通过上面我们项目的内存分析, 可以发现图片绝对是内存中的一块大头, 所以对于图片的使用监控就显得尤为重要, 我们自定义了一个简单的可以监控加载的图片是否过大的 ImageView, 可以在 debug 阶段发出警告, 方便开发人员及早发现过大的图片.
当然要做的工作还有很多, 比如当我们发现占用内存过高时, 可以尝试来释放一些静态的缓存, 一次来缓存内存的压力.
6. 总结
这个版本利用了点时间对项目的内存占用做了以上分析以及优化, 还需要做的还有很多, 之后的版本会继续跟进, 总得来说做内存分析和优化还是比较辛苦的, 特别是各种内存快照的分析以及对代码问题的排查, 当然时间有限, 可能很多地方说的可能也有疏漏或者错误, 纸上得来终觉浅, 绝知此事要躬行, 对于性能优化特别内存优化这一块, 实践远比理论得到的要多.
目前项目里关于流畅度以及耗电量还没发现太大的问题, 因为每个版本或多或少都会做一些优化, 线上也有数据监测, 之后还是想整理一下关于卡顿流程度的分析优化以及耗电量的分析优化实践.
来源: http://mobile.51cto.com/hot-576167.htm