Android 性能優化實戰 - 界面卡頓

2021-02-15 秦子帥

點擊上方藍字關注 👆👆

來源:https://www.jianshu.com/p/18bb507d6e62

詳解

今天是個奇怪的日子,有三位同學找我,都是關於界面卡頓的問題,問我能不能幫忙解決下。由於性能優化涉及的知識點比較多,我一時半會也無法徹底回答。恰好之前在做需求時也遇到了一個卡頓的問題,因此今晚寫下這篇卡頓優化的文章,希望對大家有所幫助。先來看看卡頓的現象:


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);
           }
         });

2. 高斯模糊算法分析

把耗時操作放到子線程中去處理,的確解決了界面卡頓問題。但這其實是治標不治本,我們發現圖片加載處理異常緩慢,內存久高不下有時可能會導致內存溢出。接下來我們來分析一下高斯模糊的算法實現:

看上面這幾張圖,我們通過怎樣的操作才能把第一張圖處理成下面這兩張圖?其實就是模糊化,怎麼才能做到模糊化?我們來看下高斯模糊算法的處理過程。再上兩張圖:

所謂"模糊",可以理解成每一個像素都取周邊像素的平均值。上圖中,2是中間點,周邊點都是1。"中間點"取"周圍點"的平均值,就會變成1。在數值上,這是一種"平滑化"。在圖形上,就相當於產生"模糊"效果,"中間點"失去細節。

為了得到不同的模糊效果,高斯模糊引入了權重的概念。上面分別是原圖、模糊半徑3像素、模糊半徑10像素的效果。模糊半徑越大,圖像就越模糊。從數值角度看,就是數值越平滑。接下來的問題就是,既然每個點都要取周邊像素的平均值,那麼應該如何分配權重呢?如果使用簡單平均,顯然不是很合理,因為圖像都是連續的,越靠近的點關係越密切,越遠離的點關係越疏遠。因此,加權平均更合理,距離越近的點權重越大,距離越遠的點權重越小。對於這種處理思想,很顯然正太分布函數剛好滿足我們的需求。但圖片是二維的,因此我們需要根據一維的正太分布函數,推導出二維的正太分布函數:

if (radius < 1) {
           return (null);
       }

       int w = bitmap.getWidth();
       int h = bitmap.getHeight();

       
       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];
               
               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 (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;
   
   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 (expectedWidth > 0 && expectedHeight > 0) {
       setInBitmap(options, bitmapPool, expectedWidth, expectedHeight);
     }
   }
   
   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) {
     
     
     downsampled.setDensity(displayMetrics.densityDpi);

     rotated = TransformationUtils.rotateImageExif(bitmapPool, downsampled, orientation);
     if (!downsampled.equals(rotated)) {
       bitmapPool.put(downsampled);
     }
   }

   return rotated;
 }

4. LruCache 緩存

最後我們還可以再做一些優化,數據沒有改變時不去刷新數據,還有就是採用 LruCache 緩存,相同的高斯模糊圖像直接從緩存獲取。需要提醒大家的是,我們在使用之前最好了解其源碼實現,之前有見到同事這樣寫過:


 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());

   
   
   
   
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isLowMemoryDevice(activityManager)) {
       bitmapPoolScreens = 0;
   }
 }

 
 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 源碼分析大家可以看看這篇:

https://www.jianshu.com/p/223dc6205da2

5. 最後總結

工具的使用其實並不難,相信我們在網上找幾篇文章實踐實踐,就能很熟練找到其原因。難度還在於我們需要了解 Android 的底層源碼,第三方開源庫的原理實現。個人還是建議大家平時多去看看 Android Framework 層的源碼,多去學學第三方開源庫的內部實現,多了解數據結構和算法。真正的做到治標又治本

在最後呢,還是要多方面提醒一下大家,本地的內存卡頓還是比較容易處理的,因為我們手上有機型能復現。比較難的是線上用戶手中的卡頓搜集,我們也不妨多花點時間做一些思考。後面我也會出一系列文章用來幫助大家收集線上卡頓問題。但大部分內容都是基於 NDK ,因此性能優化,很多時候往往也需要跟底層機制打交道。

—————END—————


相關焦點

  • 那些 Android 程式設計師必會的視圖優化策略
    概述現在的APP一些視覺效果都很炫,往往在一個界面上堆疊了很多視圖,這很容易出現一些性能的問題,嚴重的話甚至會造成卡頓。因此,我們在開發時必須要平衡好設計效果和性能的問題。本文主要講解如何對視圖和布局進行優化:包括如何避免過度繪製,如何減少布局的層級,如何使用ConstraintLayout等等。2. 過度繪製(Overdraw)2.1 什麼是過度繪製?
  • 找出卡頓的元兇 —— 渲染性能優化
    喜愛人家就標星一下嘛~一個 Android 應用是否流暢,或者說是否存在卡頓、丟幀現象,都與 60fps 和 16ms 有關。那麼這兩個值是怎麼來的呢?為什麼以這兩個值為衡量標準呢?本文主要討論下渲染性能方面決定 Android 應用流暢性的因素。為什麼是 60fps?
  • 《英雄聯盟手遊》卡頓怎麼解決 遊戲設置優化教程
    在主界面點擊右上角的設置按鈕,進入遊戲參數的設置。 此時可以看到了有很多按鈕,這個初始界面介紹的是遊戲內的部分功能的開關,例如全員聊天等,這裡大家根據自己的需求進行開啟即可,對流暢度優化沒有幫助。
  • Android性能優化-過渡繪製解決方案
    需要進行優化。1. 去除Activity自帶的默認背景顏色: 查看Android源碼裡的Theme主題,如下:正常情況下,很多界面其實是不需要背景的。下面是華為自帶天氣APP的首頁,我們可以看到文字部分以及圖標部分都是綠色,說面已經是第三層過渡繪製了,其中背後天氣圖是一層,文字又是一層,正常來說應該只有兩層,也就是文字和圖標應該是藍色。那麼這多出來的一層應該就是Activity自帶的背景色了。也就是theme裡面設置的。
  • android app殺死啟動專題及常見問題 - CSDN
    ,所以直到這裡,應用的第一次啟動才算完成,這時候看到的界面也就是首幀。多進程優化Android app 是支持多進程的,在 Manifest 中只要在組件聲明中加入android:process屬性就可以讓組件在啟動時運行在不同的進程中。
  • Android性能優化之APK瘦身詳解(瘦身73%)
    基於這種快速開發的現狀,我們app優化前已經有87.1M了,包大了,運營說這樣轉化不高,只能好好搞一下咯。優化過後包大小為23.1M(優化了73%,不要說我標題黨)。好了好了,我要闡述我的apk超級無敵魔鬼瘦身之心得了。文章主要內容從理論出發,再做實際操作。分為下面幾個方面:1. 結構分析, 2.具體實操 3. 總結 4. 參考資料1.
  • 小米手機卡頓怎麼辦?簡單設置教你優化手機性能,提高用戶體驗!
    01為何優化?很多人對手機都有不一樣的需求,學生黨喜歡玩遊戲,上班族用於社交或查看文檔,不同人群的需求是不同的,但有一點是相同的,那就是對手機的體驗,想像一下,如果一款手機由於卡頓、死機等原因影響你的體驗,是不是很不好呢?
  • 為Android 2.2系統優化!搶先直擊酷狗音樂2011手機版
    圖1.酷狗音樂2011手機版(Android)據官方人員透露這個針對android 2.2系統而優化開發的酷狗音樂2011手機版安裝包僅為2mb,並且無需手機Root(提取最高權限),因此這也就保證了程序運行的穩定性
  • [優化]28日更新後優化卡頓的方法
    然後我就發現了和很多人都注意到的問題了,進圖的時候會卡頓很久...之前,毒奶粉在進圖的時候就會卡頓,但卡頓的時間卻沒有現在這麼久,而且我的遊戲帳號還進入了安全模式。納尼??!!眾所周知,企鵝往毒奶粉裡塞了太多東西,挺噁心的。但是,這次也讓人無語了吧。這次企鵝往毒奶粉裡塞的東西,已經完全影響了玩家的遊戲體驗。
  • Google官方 詳解 Android 性能優化【史詩巨著之內存篇】
    明雲(張明雲),知乎專欄上寫了一系列的性能優化,都相當實用,open dev也會定期送上乾貨,不定期匯總推薦當下優秀文章)今天來看下楊超凡授權本公眾號的Google官方的性能優化一些優化建議:點擊閱讀原文,可查看楊超凡的原文:http://blog.csdn.net/chivalrousman/article/details/51553114,話不多說,看下正文。
  • Android卡頓千古謎案全面解析
    不過從古到今,試圖解釋Android卡頓的觀點就有千百種,據說即便是採訪Android內部開發工程師,他們也說這是個說不清道不明的問題。這次我們就從相關Android卡頓的幾個主流說法談起,嘗試從相對淺顯的角度來理解這一問題。  都是Dalvik VM虛擬機惹的禍?
  • 微信 v8.0.0 for Android 官方正式版
    新版特性v8.0.0- 新增個人狀態欄,可以發表心情想法、工作學習、活動休息等設置狀態- 重繪了表情包,大部分眼睛變大了、並加了炫酷動效,畫風更可愛年輕- 最有趣的是炸彈表情,除了爆炸效果,你手機還會震動一下的震動效果- 「 浮窗 」改版,移到了主界面首頁左上角的兩個點
  • 讓你的Mac擺脫卡頓,必裝清理軟體推薦
    電腦運行速度慢,垃圾文件清理不徹底,真是太影響工作啦,不要慌,今天小編給您推薦三款好用的系統清理工具,不僅能優化清理,還可以對系統進行性能檢測,是MAC 系統必不可少的工具,讓你的電腦擺脫卡頓,提升運行速度!
  • lol手遊錄屏直播很卡 直播時卡頓解決方法一覽
    英雄聯盟手遊目前已經正式的在海外進行公測了,因此許多玩家也是對這款遊戲十分的好奇和喜愛,所以不少玩家或者主播也是想通過直播來向大家分享這款遊戲,那麼lol手遊錄屏直播很卡是什麼情況呢,下面我們就一起來看一下直播時卡頓解決方法一覽吧。
  • 成功解決安卓「卡頓」問題,OPPO Reno5系列太猛了
    不少小夥伴在使用安卓手機的時候,都會碰到一個問題,那就是越用越卡頓。有的時候甚至還出現死機等情況,用手機管家來清理緩存數據,手機還是會一樣的卡頓。這是因為長時間使用,手機產生了大量簡訊、聯繫人、圖片、視頻等靜態資源;再加上眾多APP產生出的大量文件碎片,導致了手機卡頓。
  • Android TV開發簡介
    下面的例子展示了一個基本的AndroidMainifest:<application  android:banner="@drawable/banner" >  ...  <activity    android:name="com.example.android.MainActivity"    android:label="@string/app_name" >    <intent-filter>      <action android:name="android.intent.action.MAIN" />      <
  • 拳頭優化LOL客戶端,縮短運行時間並解決卡頓,啟動時間將降至15s
    英雄聯盟這款遊戲陪伴玩家很多年了,而且遊戲不斷推出了新內容,包括去遊戲整體的優化。而英雄聯盟玩家們對客戶端的連接機制還是很不滿意,而拳頭設計師在開發者日誌裡也按到了將會對英雄聯盟客戶端進行為期六個月的修復計劃,也是為了響應玩家們的要求。
  • 從前端性能優化引申出來的5道經典面試題
    這個插件可以幫我們優化打包日誌,我們打包時候經常看到很長一個日誌信息,有的時候是不需要的,也不會去看所以可以用這個插件來簡化代碼優化這是最後一部分代碼優化了,這裡的代碼性能優化我只說我在工作中感受到的,至於其他的比較小的優化點比如createDocumentFragment使用可以查查其他文章
  • Android TV開發總結(六)構建一個TV app的直播節目實例
    CCTV-1:這裡有幾個點要注意 :為演示,並未對層級進行使用FrameLayout,及viewstub,include等性能優化相關的,在實際商用項目中,建議寫xml文件,儘可能遵循過少的層級,高級標籤及FrameLayout
  • android studio布局嵌套_android studio相對布局和線性布局嵌套...
    在IDEA的基礎上,Android Studio 增加了:基於Gradle的構建支持Android 專屬的重構和快速修復提示工具以捕獲性能、可用性、版本兼容性等問題支持ProGuard 和應用籤名基於模板的嚮導來生成常用的 Android 應用設計和組件