Android 性能优化实战 - 界面卡顿

作者: 你也不知道 | 来源:发表于2020-07-16 16:49 被阅读0次

    今天是个奇怪的日子,有三位同学找我,都是关于界面卡顿的问题,问我能不能帮忙解决下。由于性能优化涉及的知识点比较多,我一时半会也无法彻底回答。恰好之前在做需求时也遇到了一个卡顿的问题,因此今晚写下这篇卡顿优化的文章,希望对大家有所帮助。先来看看卡顿的现象:


    刷新数据时卡顿

    1. 查找卡顿原因

    从上面的现象来看,应该是主线程执行了耗时操作引起了卡顿,因为正常滑动是没问题的,只有在刷新数据的时候才会出现卡顿。至于什么情况下会引起卡顿,之前在自定义 View 部分已有详细讲过,这里就不在啰嗦。我们猜想可能是耗时引起的卡顿,但也不能 100% 确定,况且我们也并不知道是哪个方法引起的,因此我们只能借助一些常用工具来分析分析,我们打开 Android Device Monitor 。

    打开 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 ,因此性能优化,很多时候往往也需要跟底层机制打交道。

    视频地址:https://pan.baidu.com/s/1jtuLBcV6l6sMKLiTDFMJDw
    视频密码:svzw

    相关文章

      网友评论

        本文标题:Android 性能优化实战 - 界面卡顿

        本文链接:https://www.haomeiwen.com/subject/zaxmhktx.html