前言
在Android中,性能优化非常重要,但是,性能优化涉及多个方面,如提升线程性能、降低内存占用、提高渲染速度、提高内存读写速度、提高磁盘读写速度、提高网络访问速度等,主要目的是降低卡顿、卡慢、数据延迟展示等体验问题。
接下来我们先回顾一下常见的优化方案:
CPU线程调度优化
从线程性能、锁、线程优先级、日志异步化等方面,还有比较高级的用法如大核绑定、线程收敛等,在之前的文章中我们也进行过相关汇总过 《Android 线程性能优化方法总结》。
内存占用优化
内存占用过高不仅容易引发OOM,还会引起卡顿,为什么这么说呢?内存占用过高容易触发GC和虚拟机堆对象移动,其次过大的内存占用会影响i/O耗时,从而影响线程的处理速度。关于内存的优化,一般需要从下面几个方面着手
- 有损压缩:这里说的有损压缩主要涉及图片、音视频、Http/rtmp资源压缩等,这种方式是最简单、直接、高效的手段。
- 格式转换或使用SVGA、WEBP:一些图像格式可以更好的降低内存占用,其次还能降低内存i/o
- Bitmap复用:主要指Buffer空间的复用,其次是绘制缓冲方面的数据复用
- 享元模式:享元是提高对象或者Buffer复用率的高效手段,比如Message消息池、ExoPlayer的各种队列。在之前的一篇文章中,也有涉及,如《StringBuilder内存碎片优化》
- Bitmap压缩:实际上Bitmap压缩和上面说的有些重复,关于这方面,我们需要做到Size+格式压缩,另外就是要避免放大图片,尽可能使用算法,如线性过滤、防锯齿、Matrix去实现图像放大和变换;另外也可以使用Hardware类型,将数据放置到GPU内存中。
- FD进行优化:FD优化是非常必要的,在前面的文章中也有涉及,如《Android HandlerThread FD 优化》实现FD裁剪。
- 多级缓存优化:三级缓存、LFU/LRU/FIFO算法,目前,Glide源码是通过LRU+引用计数实现LFU的,非常值得参考。
- 线程内存: 线程有自己的私有内存,过大的占用也会引发GC等问题,因此要控制线程数量,其次对一些线程池,可以适当降低StackSize。
- 消息调度:消息调度主要从Choreograper、同步屏障、消息排序方面进行优化,对优先级高的消息可以使用Choreograper(可产生"异步"消息)或者Handler"异步"消息。
- 算法优化:从算法的复杂度、数据结构方面提升性能,当然还有些技巧性的,如更新数据时删除和添加逻辑。
- 多路复用:多路复用是优化i/O的重要手段,比如ZygoteServer就是多路复用的典型逻辑,其目的是使用中央处理器以外的硬件设备去优化i/O,比如GPU、DSP、DMA芯片来减少CPU负载
- 使用多进程:多进程可以提高app的容灾能力,其次也能获取更多的内存。
View渲染优化
- 减少布局层级:主要目的是减少measure和layout的对性能额度损耗。
- View延迟加载:布局主要涉及include + viewStub延迟加载。
- 减少主动触发requestLayout几率:可以参考RecyclerView的一些抑制策略和我们之前文章中的《Android TextView性能与文本展示优化》
- 减少绘制面积
- 避免过度绘制
- 硬件加速,硬件绘制(lockHardwareCanvas)
- setLayerType实现缓冲,优化alpha效果,当然,本篇优化我们也提到过 《Android Alpha动画隐形成本优化》
- 高频隐藏展示的效果,选择VISBLE/INVISBLE方式,而不是GONE/VISIBLE
- 提前为Drawable同步View大小,这一步也是为了减少requestLayout
- 图片、视频异步解码
- 图片缓存
- 使用VapPlayer/AlphaPlayer/PAG/LazyAnimationDrawable实现动效,或者直接不要用动效
- TextueView/SurfaceView/GLSurfaceView加速渲染。
- 延迟加载资源
- MutableContextWrapper 预加载
- AysncLayoutInflater 异步加载
- 布局xml2java
- 图片延迟加载:要尽可能避免从xml中加载大图片或者gif资源,换句话说,避免在View的构造方法中加载资源
- RecyclerView优化:预加载ViewHolder,优化缓存、多加载一屏幕数据,必要时多加载一屏UI(这种可能降低首屏性能,慎用)等。
- RenderNode Canvas加速,低版本不支持
- AGLS:类似opengl的shader渲染,低版本不支持
网络优化
主要涉及以下几个方面
- DNS 优化
- HTTP协议优化
- TLS 优化
- 缓存优化
- WebView优化
- 连接池优化
这部分其实在之前的文章中也有相当多的涉及,如《Android OkHttp使用过程中的一些经验总结》。
电量优化
以能量守恒定律的角度去思考,显然我们要避免不必要的Task,尽可能避免CPU的空转,其次是提高工作速度等。一个简单的例子,加载资源图片、i/O、屏幕常亮、Binder通信、音视频渲染等都是耗电主要因素。
进程通信优化
减少CPU中断和内核拷贝次数,直观的来说就是减少i/o操作或者进行内存映射,在Android平台上,这部分以Binder、Localsocket、MemoryFile为主,这里不再赘述。
录音优化
- 使用系统默认的采样率,避免重采样
- 降低采样位深
音频输出优化
- 异步写入
- 使用低延迟模式
- 启用offload 模式(需要解码器支持)
我们可以看到,上面的一些优化手段有很多I/O相关的东西,那么我们本篇还需要补充什么?
实际上,上面是大家比较熟悉的方案,我们来重点熟悉一下内存映射和直接内存。
内存I/O优化
内存映射
传统的i/O如下
在传统 I/O 上,进程间的通信经过两次拷贝,而且这只是单向传输数据,如果 B进程要返回结果给 A进程,只需要把这张图中的 A进程和B进程角色关系调换一下。也就是说,如果是带返回值的 IPC,至少需要4次拷贝。
在计算机体系中,普通应用能访问的硬件设备只有内存,当然,这里的内存也只是虚拟内存。实际上,这里的i/O是需要CPU参与的,那么,有没有更好的办法呢?
肯定是有的,Binder就是这么实现的
但是,从这张图上我们可以看到,Binder也有一次拷贝,那么还能不能再优化呢?显然是可以的,参考MemoryFile或者ShareMemory实现共享内存即可。
然而,这里有个问题是,很多api都是C层实现,不过好在是Android 5.0 之后,增加了Os类,可以方便我们使用一些方法,如mmap相关的调用。
java
android.system.Os
但是,这种优化是不是所有场景都合适呢?
显然不是的,内存映射如果是较小的数据量或者频次较低i/O操作性价比很低,因为内存映射过程是非常耗时的,如果涉及频繁扩容,其性能只会更低。
显然,适应的条件是有要求的
- 高频数据传输
- 1M(经验值)以上的数据传输
- 极低频次的扩容,这种是要提前映射更低的内存,这也就是囚徒困境,扩容多了担心浪费内存,扩容少了会提升扩容频次。
因此,一般情况下,使用内存映射一般作为Buffer使用,比如Binder、Socket + epoll、xlog等案例。
直接内存
直接内存DirectByteBuffer是非常重要的优化手段,DirectByteBuffer从堆外申请内存,当然,并不是说堆外就不会OOM,但是这有个好处是,GC对堆外内存影响较小,比如数组一般需要连续的内存空间,如果在堆中申请,那么GC时会进行内存碎片整理,这个时候数组的指针地址会变化,引发耗时操作,但是堆外内存时不可移动内存,从而能避免此类问题。
HardwareBuffer
这是一个比较特殊的Buffer,不仅仅能提高性能,还支持多进程访问,可以实现多进程渲染。
磁盘 I/O优化
内存i/o优化旨在提高内存和磁盘的读写速度,我们调用磁盘的文件往往都需要通过内存,因此,磁盘I/O绝大部分情况都需要和内存相关,内存速度优化也是必要的。
实际上,磁盘 I/O和内存I/O有一些一样的优化方法,如压缩、内存映射,但也有些特殊的方法,下面我们主要看看各种优化手段
但是,我们这里要有一个常识,磁盘i/O不一定需要CPU参与,之所以在java中进行文件I/O会耗时,主要是程序的i/O模型决定的,比如InputStream#read方法,相当于"空等待",实际上使用epoll机制将其转换为类似监听模式。 ! aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy80MjM2NTUzLTE3NGI4ZDljYzYxMTllNjcucG5n (1).png
BufferedOutputStream/BufferedInputStream
缓冲I/O数据可以有效减少CPU中断,提升i/O效率。
mmap
处理Os.mmap,实际上,java的FileChannel就可以实现mmap机制,提升拷贝效率,主要手段是将内核态内存和用户态映射
零拷贝 (共享内存)
实际上,mmap也可以实现零拷贝,但是Linux中也有专门的函数,sendfile函数来实现
当然,java也对齐有封装,FileChannel中有2个非常高性能的方法,不过,也不是说使用下面的方法就能触及sendfile,其实能否触发sendfile和资源大小有关,每个版本有所区别,如果资源较小的话会走mmap机制,非常适合文件拷贝。
java
public abstract long transferFrom(ReadableByteChannel src,
long position, long count)
throws IOException;
public abstract long transferTo(long position, long count,
WritableByteChannel target)
throws IOException;
文件移动优化
文件移动很简单,但是很多人总是忘记这么做,反而选择拷贝的方式。
使用File#rename即可,因为rename方法只修改link,没有I/O操作,然而,一些版本的rename是有bug的,不能跨根目录rename,比如从/data/data移动数据文件到sd卡就会异常,Android高版本中android.app.ActivityThread.AndroidOs已经修复了此bug。
当然,使用rename时一定要判断返回值,防止rename失败。
java
@Override
public void rename(String oldPath, String newPath) throws ErrnoException {
try {
super.rename(oldPath, newPath);
} catch (ErrnoException e) {
// On emulated volumes, we have bind mounts for /Android/data and
// /Android/obb, which prevents move from working across those directories
// and other directories on the filesystem. To work around that, try to
// recover by doing a copy instead.
// Note that we only do this for "/storage/emulated", because public volumes
// don't have these bind mounts, neither do private volumes that are not
// the primary storage.
if (e.errno == OsConstants.EXDEV && oldPath.startsWith("/storage/emulated")
&& newPath.startsWith("/storage/emulated")) {
Log.v(TAG, "Recovering failed rename " + oldPath + " to " + newPath);
try {
Files.move(new File(oldPath).toPath(), new File(newPath).toPath(),
StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e2) {
Log.e(TAG, "Rename recovery failed ", e2);
throw e;
}
} else {
throw e;
}
}
}
总结
本篇涉及到的内容主要是i/o相关,当然,我们这里强调一下,关于拷贝的次数的定义,在操作系统的中,只要是cpu参与的才会被统计,如DMA芯片、DSP芯片参与的不计入。因此,看到mmap或者sendfile拷贝次数有疑问是正常的,和定义有关。
i/O优化非常重要,特别是一些音视频采集app,需要经常实现磁盘、网络、内存的i/O,本篇就到这里,希望本篇内容对你有所帮助。