今天是个奇怪的日子,有三位同学找我,都是关于界面卡顿的问题,问我能不能帮忙解决下。由于性能优化涉及的知识点比较多,我一时半会也无法彻底回答。恰好之前在做需求时也遇到了一个卡顿的问题,因此今晚写下这篇卡顿优化的文章,希望对大家有所帮助。先来看看卡顿的现象:
1. 查找卡顿原因
从上面的现象来看,应该是主线程执行了耗时操作引起了卡顿,因为正常滑动是没问题的,只有在刷新数据的时候才会出现卡顿。至于什么情况下会引起卡顿,之前在自定义 View 部分已有详细讲过,这里就不在啰嗦。我们猜想可能是耗时引起的卡顿,但也不能 100% 确定,况且我们也并不知道是哪个方法引起的,因此我们只能借助一些常用工具来分析分析,我们打开 Android Device Monitor 。
2. RxJava 线程切换
我们找到了是高斯模糊处理耗时导致了界面卡顿,那现在我们把高斯模糊算法处理放入子线程中去,处理完后再次切换到主线程,这里采用 RxJava 来实现。
Observable.just(resource.getBitmap())
.map(bitmap -> {
// 高斯模糊
Bitmap blurBitmap = ImageUtil.doBlur(resource.getBitmap(), 100, false);
blurBitmapCache.put(path, blurBitmap);
return blurBitmap;
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(blurBitmap -> {
if (blurBitmap != null) {
recommendBgIv.setImageBitmap(blurBitmap);
}
});
关于响应式编程思想和 RxJava 的实现原理大家可以参考以下几篇文章:
2. 高斯模糊算法分析
把耗时操作放到子线程中去处理,的确解决了界面卡顿问题。但这其实是治标不治本,我们发现图片加载处理异常缓慢,内存久高不下有时可能会导致内存溢出。接下来我们来分析一下高斯模糊的算法实现:
看上面这几张图,我们通过怎样的操作才能把第一张图处理成下面这两张图?其实就是模糊化,怎么才能做到模糊化?我们来看下高斯模糊算法的处理过程。再上两张图:
所谓"模糊",可以理解成每一个像素都取周边像素的平均值。上图中,2是中间点,周边点都是1。"中间点"取"周围点"的平均值,就会变成1。在数值上,这是一种"平滑化"。在图形上,就相当于产生"模糊"效果,"中间点"失去细节。
为了得到不同的模糊效果,高斯模糊引入了权重的概念。上面分别是原图、模糊半径3像素、模糊半径10像素的效果。模糊半径越大,图像就越模糊。从数值角度看,就是数值越平滑。接下来的问题就是,既然每个点都要取周边像素的平均值,那么应该如何分配权重呢?如果使用简单平均,显然不是很合理,因为图像都是连续的,越靠近的点关系越密切,越远离的点关系越疏远。因此,加权平均更合理,距离越近的点权重越大,距离越远的点权重越小。对于这种处理思想,很显然正太分布函数刚好满足我们的需求。但图片是二维的,因此我们需要根据一维的正太分布函数,推导出二维的正太分布函数:
if (radius < 1) {//模糊半径小于1
return (null);
}
int w = bitmap.getWidth();
int h = bitmap.getHeight();
// 通过 getPixels 获得图片的像素数组
int[] pix = new int[w * h];
bitmap.getPixels(pix, 0, w, 0, 0, w, h);
int wm = w - 1;
int hm = h - 1;
int wh = w * h;
int div = radius + radius + 1;
int r[] = new int[wh];
int g[] = new int[wh];
int b[] = new int[wh];
int rsum, gsum, bsum, x, y, i, p, yp, yi, yw;
int vmin[] = new int[Math.max(w, h)];
int divsum = (div + 1) >> 1;
divsum *= divsum;
int dv[] = new int[256 * divsum];
for (i = 0; i < 256 * divsum; i++) {
dv[i] = (i / divsum);
}
yw = yi = 0;
int[][] stack = new int[div][3];
int stackpointer;
int stackstart;
int[] sir;
int rbs;
int r1 = radius + 1;
int routsum, goutsum, boutsum;
int rinsum, ginsum, binsum;
// 循环行
for (y = 0; y < h; y++) {
rinsum = ginsum = binsum = routsum = goutsum = boutsum = rsum = gsum = bsum = 0;
// 半径处理
for (i = -radius; i <= radius; i++) {
p = pix[yi + Math.min(wm, Math.max(i, 0))];
sir = stack[i + radius];
// 拿到 rgb
sir[0] = (p & 0xff0000) >> 16;
sir[1] = (p & 0x00ff00) >> 8;
sir[2] = (p & 0x0000ff);
rbs = r1 - Math.abs(i);
rsum += sir[0] * rbs;
gsum += sir[1] * rbs;
bsum += sir[2] * rbs;
if (i > 0) {
rinsum += sir[0];
ginsum += sir[1];
binsum += sir[2];
} else {
routsum += sir[0];
goutsum += sir[1];
boutsum += sir[2];
}
}
stackpointer = radius;
// 循环每一列
for (x = 0; x < w; x++) {
r[yi] = dv[rsum];
g[yi] = dv[gsum];
b[yi] = dv[bsum];
rsum -= routsum;
gsum -= goutsum;
bsum -= boutsum;
stackstart = stackpointer - radius + div;
sir = stack[stackstart % div];
routsum -= sir[0];
goutsum -= sir[1];
boutsum -= sir[2];
if (y == 0) {
vmin[x] = Math.min(x + radius + 1, wm);
}
p = pix[yw + vmin[x]];
sir[0] = (p & 0xff0000) >> 16;
sir[1] = (p & 0x00ff00) >> 8;
sir[2] = (p & 0x0000ff);
rinsum += sir[0];
ginsum += sir[1];
binsum += sir[2];
rsum += rinsum;
gsum += ginsum;
bsum += binsum;
stackpointer = (stackpointer + 1) % div;
sir = stack[(stackpointer) % div];
routsum += sir[0];
goutsum += sir[1];
boutsum += sir[2];
rinsum -= sir[0];
ginsum -= sir[1];
binsum -= sir[2];
yi++;
}
yw += w;
}
for (x = 0; x < w; x++) {
// 与上面代码类似 ......
对于部分哥们来说,上面的函数和代码可能看不太懂。我们来讲通俗一点,一方面如果我们的图片越大,像素点也就会越多,高斯模糊算法的复杂度就会越大。如果半径 radius 越大图片会越模糊,权重计算的复杂度也会越大。因此我们可以从这两个方面入手,要么压缩图片的宽高,要么缩小 radius 半径。但如果 radius 半径设置过小,模糊效果肯定不太好,因此我们还是在宽高上面想想办法,接下来我们去看看 Glide 的源码:
private Bitmap decodeFromWrappedStreams(InputStream is,
BitmapFactory.Options options, DownsampleStrategy downsampleStrategy,
DecodeFormat decodeFormat, boolean isHardwareConfigAllowed, int requestedWidth,
int requestedHeight, boolean fixBitmapToRequestedDimensions,
DecodeCallbacks callbacks) throws IOException {
long startTime = LogTime.getLogTime();
int[] sourceDimensions = getDimensions(is, options, callbacks, bitmapPool);
int sourceWidth = sourceDimensions[0];
int sourceHeight = sourceDimensions[1];
String sourceMimeType = options.outMimeType;
// If we failed to obtain the image dimensions, we may end up with an incorrectly sized Bitmap,
// so we want to use a mutable Bitmap type. One way this can happen is if the image header is so
// large (10mb+) that our attempt to use inJustDecodeBounds fails and we're forced to decode the
// full size image.
if (sourceWidth == -1 || sourceHeight == -1) {
isHardwareConfigAllowed = false;
}
int orientation = ImageHeaderParserUtils.getOrientation(parsers, is, byteArrayPool);
int degreesToRotate = TransformationUtils.getExifOrientationDegrees(orientation);
boolean isExifOrientationRequired = TransformationUtils.isExifOrientationRequired(orientation);
// 关键在于这两行代码,如果没有设置或者获取不到图片的宽高,就会加载原图
int targetWidth = requestedWidth == Target.SIZE_ORIGINAL ? sourceWidth : requestedWidth;
int targetHeight = requestedHeight == Target.SIZE_ORIGINAL ? sourceHeight : requestedHeight;
ImageType imageType = ImageHeaderParserUtils.getType(parsers, is, byteArrayPool);
// 计算压缩比例
calculateScaling(
imageType,
is,
callbacks,
bitmapPool,
downsampleStrategy,
degreesToRotate,
sourceWidth,
sourceHeight,
targetWidth,
targetHeight,
options);
calculateConfig(
is,
decodeFormat,
isHardwareConfigAllowed,
isExifOrientationRequired,
options,
targetWidth,
targetHeight);
boolean isKitKatOrGreater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
// Prior to KitKat, the inBitmap size must exactly match the size of the bitmap we're decoding.
if ((options.inSampleSize == 1 || isKitKatOrGreater) && shouldUsePool(imageType)) {
int expectedWidth;
int expectedHeight;
if (sourceWidth >= 0 && sourceHeight >= 0
&& fixBitmapToRequestedDimensions && isKitKatOrGreater) {
expectedWidth = targetWidth;
expectedHeight = targetHeight;
} else {
float densityMultiplier = isScaling(options)
? (float) options.inTargetDensity / options.inDensity : 1f;
int sampleSize = options.inSampleSize;
int downsampledWidth = (int) Math.ceil(sourceWidth / (float) sampleSize);
int downsampledHeight = (int) Math.ceil(sourceHeight / (float) sampleSize);
expectedWidth = Math.round(downsampledWidth * densityMultiplier);
expectedHeight = Math.round(downsampledHeight * densityMultiplier);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Calculated target [" + expectedWidth + "x" + expectedHeight + "] for source"
+ " [" + sourceWidth + "x" + sourceHeight + "]"
+ ", sampleSize: " + sampleSize
+ ", targetDensity: " + options.inTargetDensity
+ ", density: " + options.inDensity
+ ", density multiplier: " + densityMultiplier);
}
}
// If this isn't an image, or BitmapFactory was unable to parse the size, width and height
// will be -1 here.
if (expectedWidth > 0 && expectedHeight > 0) {
setInBitmap(options, bitmapPool, expectedWidth, expectedHeight);
}
}
// 通过流 is 和 options 解析 Bitmap
Bitmap downsampled = decodeStream(is, options, callbacks, bitmapPool);
callbacks.onDecodeComplete(bitmapPool, downsampled);
if (Log.isLoggable(TAG, Log.VERBOSE)) {
logDecode(sourceWidth, sourceHeight, sourceMimeType, options, downsampled,
requestedWidth, requestedHeight, startTime);
}
Bitmap rotated = null;
if (downsampled != null) {
// If we scaled, the Bitmap density will be our inTargetDensity. Here we correct it back to
// the expected density dpi.
downsampled.setDensity(displayMetrics.densityDpi);
rotated = TransformationUtils.rotateImageExif(bitmapPool, downsampled, orientation);
if (!downsampled.equals(rotated)) {
bitmapPool.put(downsampled);
}
}
return rotated;
}
4. LruCache 缓存
最后我们还可以再做一些优化,数据没有改变时不去刷新数据,还有就是采用 LruCache 缓存,相同的高斯模糊图像直接从缓存获取。需要提醒大家的是,我们在使用之前最好了解其源码实现,之前有见到同事这样写过:
/**
* 高斯模糊缓存的大小 4M
*/
private static final int BLUR_CACHE_SIZE = 4 * 1024 * 1024;
/**
* 高斯模糊缓存,防止刷新时抖动
*/
private LruCache<String, Bitmap> blurBitmapCache = new LruCache<String, Bitmap>(BLUR_CACHE_SIZE);
// 伪代码 ......
// 有缓存直接设置
Bitmap blurBitmap = blurBitmapCache.get(item.userResp.headPortraitUrl);
if (blurBitmap != null) {
recommendBgIv.setImageBitmap(blurBitmap);
return;
}
// 从后台获取,进行高斯模糊后,再缓存 ...
这样写有两个问题,第一个问题是我们发现整个应用 OOM 了都还可以缓存数据,第二个问题是 LruCache 可以实现比较精细的控制,而默认缓存池设置太大了会导致浪费内存,设置小了又会导致图片经常被回收。第一个问题我们只要了解其内部实现就迎刃而解了,关键问题在于缓存大小该怎么设置?如果我们想不到好的解决方案,那么也可以去参考参考 Glide 的源码实现。
public Builder(Context context) {
this.context = context;
activityManager = (ActivityManager)context.getSystemService(Context.ACTIVITY_SERVICE);
screenDimensions = new DisplayMetricsScreenDimensions(context.getResources().getDisplayMetrics());
// On Android O+ Bitmaps are allocated natively, ART is much more efficient at managing
// garbage and we rely heavily on HARDWARE Bitmaps, making Bitmap re-use much less important.
// We prefer to preserve RAM on these devices and take the small performance hit of not
// re-using Bitmaps and textures when loading very small images or generating thumbnails.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isLowMemoryDevice(activityManager)) {
bitmapPoolScreens = 0;
}
}
// Package private to avoid PMD warning.
MemorySizeCalculator(MemorySizeCalculator.Builder builder) {
this.context = builder.context;
arrayPoolSize =
isLowMemoryDevice(builder.activityManager)
? builder.arrayPoolSizeBytes / LOW_MEMORY_BYTE_ARRAY_POOL_DIVISOR
: builder.arrayPoolSizeBytes;
int maxSize =
getMaxSize(
builder.activityManager, builder.maxSizeMultiplier, builder.lowMemoryMaxSizeMultiplier);
int widthPixels = builder.screenDimensions.getWidthPixels();
int heightPixels = builder.screenDimensions.getHeightPixels();
int screenSize = widthPixels * heightPixels * BYTES_PER_ARGB_8888_PIXEL;
int targetBitmapPoolSize = Math.round(screenSize * builder.bitmapPoolScreens);
int targetMemoryCacheSize = Math.round(screenSize * builder.memoryCacheScreens);
int availableSize = maxSize - arrayPoolSize;
if (targetMemoryCacheSize + targetBitmapPoolSize <= availableSize) {
memoryCacheSize = targetMemoryCacheSize;
bitmapPoolSize = targetBitmapPoolSize;
} else {
float part = availableSize / (builder.bitmapPoolScreens + builder.memoryCacheScreens);
memoryCacheSize = Math.round(part * builder.memoryCacheScreens);
bitmapPoolSize = Math.round(part * builder.bitmapPoolScreens);
}
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(
TAG,
"Calculation complete"
+ ", Calculated memory cache size: "
+ toMb(memoryCacheSize)
+ ", pool size: "
+ toMb(bitmapPoolSize)
+ ", byte array size: "
+ toMb(arrayPoolSize)
+ ", memory class limited? "
+ (targetMemoryCacheSize + targetBitmapPoolSize > maxSize)
+ ", max size: "
+ toMb(maxSize)
+ ", memoryClass: "
+ builder.activityManager.getMemoryClass()
+ ", isLowMemoryDevice: "
+ isLowMemoryDevice(builder.activityManager));
}
}
可以看到 Glide 是根据每个 App 的内存情况,以及不同手机设备的版本和分辨率,计算出一个比较合理的初始值。关于 Glide 源码分析大家可以看看这篇:第三方开源库 Glide - 源码分析(补)
5. 最后总结
工具的使用其实并不难,相信我们在网上找几篇文章实践实践,就能很熟练找到其原因。难度还在于我们需要了解 Android 的底层源码,第三方开源库的原理实现。个人还是建议大家平时多去看看 Android Framework 层的源码,多去学学第三方开源库的内部实现,多了解数据结构和算法。真正的做到治标又治本
在最后呢,还是要多方面提醒一下大家,本地的内存卡顿还是比较容易处理的,因为我们手上有机型能复现。比较难的是线上用户手中的卡顿搜集,我们也不妨多花点时间做一些思考。后面我也会出一系列文章用来帮助大家收集线上卡顿问题。但大部分内容都是基于 NDK ,因此性能优化,很多时候往往也需要跟底层机制打交道。