作者:红橙Darren https://www.jianshu.com/p/18bb507d6e62
今天是个奇怪的日子,有三位同学找我,都是关于界面卡顿的问题,问我能不能帮忙解决下。由于性能优化涉及的知识点比较多,我一时半会也无法彻底回答。恰好之前在做需求时也遇到了一个卡顿的问题,因此今晚写下这篇卡顿优化的文章,希望对大家有所帮助。
从上面的现象来看,应该是主线程执行了耗时操作引起了卡顿,因为正常滑动是没问题的,只有在刷新数据的时候才会出现卡顿。至于什么情况下会引起卡顿,之前在自定义 View 部分已有详细讲过,这里就不在啰嗦。我们猜想可能是耗时引起的卡顿,但也不能 100% 确定,况且我们也并不知道是哪个方法引起的,因此我们只能借助一些常用工具来分析分析,我们打开 Android Device Monitor 。
图:打开 Android Device Monitor
图:查找耗时方法
我们找到了是高斯模糊处理耗时导致了界面卡顿,那现在我们把高斯模糊算法处理放入子线程中去,处理完后再次切换到主线程,这里采用 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 的实现原理大家可以参考以下几篇文章:
第三方开源库 RxJava - 基本使用和源码分析 https://www.jianshu.com/p/3e8fa8db6db1 第三方开源库 RxJava - 自己动手写事件变换 https://www.jianshu.com/p/b3b0170152ff 第三方开源库 RxJava - Android实用开发场景 https://www.jianshu.com/p/2bb332f39f7d
把耗时操作放到子线程中去处理,的确解决了界面卡顿问题。但这其实是治标不治本,我们发现图片加载处理异常缓慢,内存久高不下有时可能会导致内存溢出。接下来我们来分析一下高斯模糊的算法实现:
看上面这几张图,我们通过怎样的操作才能把第一张图处理成下面这两张图?其实就是模糊化,怎么才能做到模糊化?我们来看下高斯模糊算法的处理过程。再上两张图:
所谓"模糊",可以理解成每一个像素都取周边像素的平均值。上图中,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; }
最后我们还可以再做一些优化,数据没有改变时不去刷新数据,还有就是采用 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 - 源码分析(补):https://www.jianshu.com/p/223dc6205da2
工具的使用其实并不难,相信我们在网上找几篇文章实践实践,就能很熟练找到其原因。难度还在于我们需要了解 Android 的底层源码,第三方开源库的原理实现。个人还是建议大家平时多去看看 Android Framework 层的源码,多去学学第三方开源库的内部实现,多了解数据结构和算法。真正的做到治标又治本.
— — — END — — —
本文分享自微信公众号 - 刘望舒(liuwangshuAndroid)
原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。
原始发表时间:2018-11-19
本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。
我来说两句