Android爬坑之旅之不易發現的BUG

香辣牛肉麵發表於2018-01-18

在Android的app開發過程中,除了機型適配等問題,常常還會出一些特殊的bug,這些bug往往需要特殊的場景情況下才會發生,這裡羅列了一些平時專案中遇到的問題及注意點。


#App打包apk安裝後重復啟動根介面的問題

這個問題很特殊,一般情況下很難被發現,是Android系統一直以來的一個Bug。

當我們把app打包成apk安裝程式,通過點選apk檔案進行安裝時,會啟動安裝介面, 並在安裝成功後會跳轉安裝完成介面, 如圖:

安裝完成介面

我們點選圖中的 開啟按鈕,此時會啟動我們的app

這裡為了讓大家更容易理解一些,

我們假設app有兩個介面

  • 啟動介面SplashActivity
  • 主介面MainActivity
  • app啟動後開啟SplashActivity,3秒後自動跳轉MainActivity,介面不做強制finish

接下來,我們需要了解下Task任務棧和Back Stack返回棧, 如果有同學對這兩個概念還不熟悉的, 可以看一下官方文件,講得很詳細:

Android任務和返回棧官方文件

這裡我們引用官方文件的一句話:

The device Home screen is the starting place for most tasks. When the user touches an icon in the application launcher (or a shortcut on the Home screen), that application's task comes to the foreground. If no task exists for the application (the application has not been used recently), then a new task is created and the "main" activity for that application opens as the root activity in the stack.

當我們點選home介面的應用啟動圖示時(安裝完成介面點選開啟同理)

如果沒有對應Task任務棧存在,則會建立一個新的任務棧, 並且把應用啟動的首頁面作為根Activity放到任務棧中。

如果存在對應的Task任務棧,則會直接呼叫對應的Task任務棧到前臺,並將棧頂的介面顯示給使用者,

那麼當我們的app啟動後開啟SplashActivity並跳轉主介面MainActivity後,我們app的任務棧應該如圖所示:

Paste_Image.png

此時,當我們點選Home鍵退回到桌面, app的Task任務棧進入後臺,然後我們點選桌面上的啟動圖示,

正常情況下,app應該會把它對應的Task任務棧調到前臺,並顯示剛剛棧頂的MainActivity介面,

正常流程

正常流程

然而,實際情況是,app會把它的Task任務棧呼叫到前臺,

並在任務棧上重新建立新的SplashActivity ,再跳轉到MainActivity,

在不重新載入application的情況下,它又重新走了一遍啟動的流程,這個時候,我們會發現任務棧中的Activity重複了,SplashActivity跟MainActivity都變成了兩個

為了更清晰的讓大家理解,這裡畫了兩個圖,

  • 錯誤的bug流程
  • 錯誤狀態下的Task任務棧

bug流程

Paste_Image.png

新呼叫的SplashActivity會被置於該app的task棧頂

Paste_Image.png

多出了兩個Activity

當然這個bug一般使用者也很難注意到,它的產生必須滿足下面的條件:

  • 點選apk檔案安裝app
  • 安裝完成介面點選開啟按鈕
  • 點選Home鍵,進入系統桌面,此時app退到後臺
  • 再點選桌面上啟動圖示

那麼對於這種問題我們如何來處理呢?

按照上文的舉例, 在正常流程下啟動app進入MainActivity介面時的任務棧:

正常情況

bug情況下,會調起任務棧到前臺並新增根Acitivy SplashActivity到棧頂,此時的任務棧:

Paste_Image.png

我們可以看到,在bug情況下啟動app時,SplashActivity(app的根Activity)再次建立併疊加到Task任務棧上了

理應只會出現在棧底的SplashActivity出現在了其他位置,所以這裡我們直接判斷了app根Activity SplashActivity的位置

在app的SplashActivity(app的根Activity)的onCreate方法中通過 isTaskRoot() 方法來判斷是否是任務棧中的根Activity,如果是就不做任何處理,如果不是則直接finish掉;

public class SplashActivity extends BaseActivity {
@Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        setTheme(R.style.AppTheme_NoActionBar);
        super.onCreate(savedInstanceState);

        if (!isTaskRoot()) {
            finish();
            return;
        }
    }

}

複製程式碼

這樣棧頂的SplashActivity在還未執行其他程式碼的情況下就finish()掉了,此時會顯示棧頂的MainActivity。


#Android包含Fragment介面的Activity介面,在app被系統釋放後,重新回到前臺時,重建Activity造成Fragment重疊

隨著功能需求的多樣化,Fragment的應用場景也是越來越廣,其中我們的首頁底欄可能是最常見的場景了。

那我們這裡說的app在被系統釋放後,重回前臺Activity時,重建造成Fragment重疊又是怎麼回事呢?

我們知道,要使用Fragment的Activity必須繼承v7的AppCompatActivity, 而AppCompatActivity繼承自FragmentActivity

當我們的app退到後臺處於容易被系統回收的狀態時,會觸發我們的onSaveInstanceState方法,

而使用Fragment的Activity會呼叫到父類FragmentActivity的onSaveInstanceState方法,

這裡我擷取FragmentActivity中onSaveInstanceState的關鍵程式碼:

/**
     * Save all appropriate fragment state.
     */
    @Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        Parcelable p = mFragments.saveAllState();//獲取FragmentManager儲存的所有Fragments
        if (p != null) {
            outState.putParcelable(FRAGMENTS_TAG, p);//Fragment不為空,執行儲存操作
        }
       ...
        }
    }

複製程式碼

我們看到,這裡的程式碼把Fragment的狀態儲存了下來, 而在FragmentActivity的onCreate方法中,又將這些Fragment重建了:

 /**
     * Perform initialization of all fragments and loaders.
     */
    @SuppressWarnings("deprecation")
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        mFragments.attachHost(null /*parent*/);

        super.onCreate(savedInstanceState);

        ...
        if (savedInstanceState != null) {
            Parcelable p = savedInstanceState.getParcelable(FRAGMENTS_TAG);
            mFragments.restoreAllState(p, nc != null ? nc.fragments : null);

        ...   
        }

   ...
    }

複製程式碼

也就是說,介面因為被系統釋放後重建,重新觸發了Activity的onCreate方法,

如果開發人員沒有判斷onCreate的saveInstance變數調整建立邏輯,直接執行了Fragment的建立程式碼,那新建的Fragment就會跟系統恢復的重疊。

這個問題一方面因為記憶體不足的極端情況下才會觸發(紅米等低端裝置屬於常態,經常會釋放app),

另一方面由於部分開發的Fragment介面不是透明的,因此即使疊加了也不一定能發現這個問題。

那對於這樣的問題,我們如何處理呢,這裡給出了三種處理方案:

1.在Activity的onCreate中判斷savedInstanceState變數是否為null, 如果savedInstanceState為null說明是介面是新建,則執行完整的fragment tab初始化工作; 如果savedInstanceState不為null,說明Activity是被釋放重建,那就不執行Fragment的建立,執行相關邏輯程式碼,

程式碼如下:

@Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        if (savedInstanceState == null) {
        //介面正常情況下create時的邏輯
            initTab();
        }
        else {
        //介面在記憶體不足情況下被強制回收後重新create的邏輯
        }
}
複製程式碼

2.這個方法我稱之為懶人做法

使用了Fragment的Activity在呼叫onCreate方法時會首先呼叫super.onCreate()

而super.onCreate最終又會執行FragmentActivity的onCreate方法,

從上文擷取的程式碼中,我們看到,FragmentActivity的onCreate方法會判斷saveInstanceState裡的Fragment是否為空,不為空就恢復儲存的Fragment

if (savedInstanceState != null) {
            Parcelable p = savedInstanceState.getParcelable(FRAGMENTS_TAG);
            mFragments.restoreAllState(p, nc != null ? nc.fragments : null);

        ...   
        }
複製程式碼

也就是說,我們在執行到這段程式碼前把FRAGMENTS_TAG對應的值清空,那樣就不會觸發系統重建的恢復了

那麼我們只需要在使用Fragment的Activity的onCreate方法新增以下程式碼就可以了:

@Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        if (savedInstanceState != null) {
            savedInstanceState.putParcelable("android:support:fragments", null);//清空儲存Fragment的狀態資料
        }
        super.onCreate(savedInstanceState);
}
複製程式碼

這樣,在執行到FragmentActivity的onCreate前,FRAGMENTS_TAG對應的資料就已經清空了。

3.同樣是懶人方法,直接重寫onSaveInstanceState方法,註釋掉super.onSaveInstanceState,這樣就不會儲存Fragment的資料了,不過副作用也是非常明顯,就是onSaveInstanceState就完全失去作用了, 所以並不太推薦大家這麼去做,僅做參考:

  @Override
    public void onSaveInstanceState(Bundle outState, PersistableBundle outPersistentState) {
//        super.onSaveInstanceState(outState, outPersistentState);
    }
複製程式碼

關於模擬app被釋放的場景,這裡介紹個小方法,就是在app執行之後,按home鍵退到後臺,然後開啟電腦命令列工具,執行:

  adb shell am kill 包名packagename
複製程式碼

此時app就會被釋放,接著通過工作管理員或者啟動圖示開啟app,這個時候剛剛的介面就會重建走onRestoreInstanceState了。


#app呼叫系統相機後,拍照返回崩潰

一般情況下,我們大部分情況是通過傳遞uri的方式來呼叫系統相機的:

Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
mTakePhotoUri = FileUtils.getOutputMediaFileUri(FileUtils.MEDIA_TYPE_IMAGE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, mTakePhotoUri);
                startActivityForResult(intent, CAPTURE_IMAGE_ACTIVITY_REQUEST_CODE);
複製程式碼

這種通過指定uri儲存路徑的方式呼叫系統相機的方式

在onActivityResult的時候,返回的intent會沒有資料

因此我們一般都是在onActivityResult裡獲取之前保留的uri(例子中的mTakePhotoUri,這個變數是個全域性變數)變數來獲取具體圖片檔案。

正式因為這個問題,導致不管呼叫系統相機導致app退到後臺被釋放 還是三星之類的手機呼叫相機時的自動旋轉 都會導致呼叫相機的介面被釋放並重建,從而使得Activity介面的全域性變數值丟失。

如果沒有在onSaveInstanceState裡儲存這個全域性變數,在onRestoreInstanceState取回mTakePhotoUri的值,那重建之後的介面變數就丟失了,因此onActivityResult中取到的mTakePhotoUri就為null了,從而導致獲取圖片路徑變數的時候報null。

經過測試,經過這樣的處理後,大部分相機的崩潰問題都得以解決。

其實不僅是相機,很多功能在實際開發過程中都可能遇到因介面被釋放導致變數資料丟失的情況,所以我們需要在onSaveInstanceState方法中根據實際情況來儲存需要的變數,在onRestoreInstanceState方法中取回變數。

當然如果覺得太麻煩,這裡給大家推薦一個懶人庫,可以自動儲存我們的變數,非常方便

https://github.com/frankiesardo/icepick


#在Android 4.1等裝置上使用EventBus報caused by: java.lang.ClassNotFoundException: Didn’t find class “android.os.PersistableBundle” on path: DexPathList

這個問題我只在Android 4.1的裝置上發生過,在其他裝置上均未報錯

而造成這個錯誤的原因是我在無意中重寫了 onSaveInstanceState(Bundle outState, PersistableBundle outPersistentState) 這個方法 (正常情況下應該重寫 onSaveInstanceState(Bundle outState)

如果你的手頭沒有4.1的裝置,這個問題可能一直髮現不了


#引入圖片框架fresco後,出現is 32-bit instead of 64-bit的錯誤

這個問題主要由於Android系統對於so檔案的載入機制造成的

不同CPU架構的手機載入時會在libs下找自己對應的目錄,從對應的目錄下尋找需要的.so檔案;如果沒有對應的目錄,就會去armeabi下去尋找,如果已經有對應的目錄,但是如果沒有找到對應的.so檔案,也不會去armeabi下去尋找了。

我的專案只引用armeabi和 x86架構的so檔案,這裡我們假設為lib.so檔案

當我使用一臺arm64-v8架構的手機時,因為找不到arm64-v8對應的目錄,因此係統會降級到armeabi中去查詢lib.so檔案。

而fresco圖片框架因為考慮到了so的相容性,compile引入編譯的時候自帶了arm64-v8的so檔案,因此產生了一個arm64-v8的目錄。

當專案打包編譯安裝後,arm64-v8架構的手機因為查詢到了arm64-v8的目錄,因此所有的so檔案都會到arm64-v8的目錄下查詢,不會再去查詢armeabi目錄,而在arm64-v8的目錄下,我並沒有配置對應的lib.so檔案,所以找不到lib.so檔案,隨即丟擲is 32-bit instead of 64-bit的錯誤。

那我們如何解決了,這裡介紹三種方法:

  1. 為專案已經引用的so庫新增對應arm64-v8架構的so庫,對於沒有原始碼的情況下很難去配置編譯對應版本的so檔案;

  2. 刪除引用的庫的arm64-v8目錄的so檔案;

  3. 在gradle的defaultConfig中設定

ndk {
    // 設定支援的 SO 庫構架,注意這裡要根據你的實際情況來設定
    abiFilters 'armeabi' , 'x86'
}
複製程式碼

這樣就固定只會打包armeabi和x86目錄的so檔案了,這麼做可以防止在使用不熟悉的庫的時候不小心引入了其他目錄的so檔案造成app報錯


Android app的實際開發過程中還有各種各樣奇怪的問題,如果你也遇到了一些特殊或者奇葩的bug,歡迎進行補充

相關文章