[Android] 生命週期導致變數歸零的地雷。

話說昨天晚上無意間又翻到 Fred 的這一篇「千萬別相信 Android 上的應用程式」,因為裡面所描述的事情和我對於 Android / Dalvik VM 和 Garbage Collection 的機制的理解實在差異太大,所以一直覺得怪怪的,總覺得裡面描述的症頭不太像 GC 所造成的。

雖然我不是很確定 Fred 遇到的到底是什麼問題,不過卻知道另一種因為 Android 應用程式的生命週期的定義,而會出現「變數歸零」的情況,就在這邊分享一下。

一切的起因

在 Android 上的程式的資料,真的會莫名其妙地消失或歸零嗎?基本上我認為這個問題的答案是肯定的,同時也是否定的,這要看你從哪個角度來看。

如果你是習慣了 Desktop / Web 的開發,而且對於 Android 的程式的生命週期運作方式不熟悉的話,確實有可能會踩到「變數莫名其妙被歸零」這樣的地雷,但實際上 Android 這樣的行為是有很嚴謹的運作方式,在官方文件也都有特別提到的。

大家都知道 Android 主要是給手機和平板電腦用的作業系統,這樣的作業系統基本上都會面臨一個同樣的問題--可以用資源是有限的。

與個人電腦動不動就是以 GB 為單位來記算的記憶體容量,大部份的手機的記憶體可能都只有幾百 MB 而已,像我的 Google G1 這個第一隻 Android 手機,RAM 就只有 256MB 而已。

在這樣的狀況下,怎麼樣確保手機有足夠的記憶體可以給使用者正在使用的程式,就是一個相當重要的問題。

特別是 Android 和較早期只有單工的 iOS 不一樣,他基本上和 Windows / Linux 沒啥不同,是個實實在在的分時多工系統

也就是說,當你在 Android 上開了兩個應用程式的時候,就真的有兩個應用程式佔住了你的記憶體,即便其中一個程式已經不在畫面上也是。

但這樣問題就來了,假設扣除掉系統本身使用的記憶體後,可以給應用程式的記憶體空間總共有 100MB。

這個時候如果你開了四個應用程式,分別需要佔用 50MB 的記憶體空間,總共為 200MB 的需求量,剩餘的記憶體完全不夠用,這個時候 Android 這時候到底應該要怎麼辦呢?

Android 的程式架構

要了解這個問題,首先要知道 Android 的程式的運作模式,特別是當使用者開了兩個以上的應用程式的時候會怎樣(基本上由於 Android 上的 Home Screen 也是一般的應用程式,所以你的 Android 手機應該經常都有兩個以上的應用程式被開著)。

在 Android 上,當你打開了一個自己寫應用程式之後,事實上會發生以下的幾件事情:

  1. 一個 Linux Process 被產生
  2. 這個 Process 上會生出一個 Dalvik VM
  3. 然後這個 Davlik VM 會執行你實際上寫的程式碼

一定要先搞清楚這樣的觀念:每一個 Android 應用程式(基本上就是看你的 AndroidManifest.xml 裡宣告的 package 名稱)被執行時,都會是獨立的 process,有自己的 memory space,互不干擾,A 看不到 B 裡的東西,B 也看不到 A 裡的東西。

這也是為什麼 Android 裡面不同的應用程式不能直接共用元件,要透過 Intent / Content Provider 來溝通的其中一個原因。

斯斯有兩種,記憶體釋放也有兩種

就像斯斯有兩種一樣,記憶體的釋放也有兩種:

  • 一種是在一個 process 裡(應用程式裡),Davlik VM 利用 Garbage Collection 機制釋放已經用不到的記憶體。
  • 一種是 OS 把 process 結束掉,讓 process 所佔的記憶體釋放出來,把他標示為可以給其他人用。

GC 的運作原理

Garbage Collection 簡稱 GC,顧名思意就是把記憶體的垃圾收集起來丟掉是也。

至於怎麼知道哪些東西是垃圾呢?一般來說用的是一個叫 Mark and Sweep 的概念,Davlik VM 也不例外(至少在 2009 年 Thinker 追的結果是這樣)。

那什麼是 Mark and Sweep 呢?雖然我們講的是 Android 程式,但因為其實這部份和一般的 Java 程式沒什麼差別,所以我們直接來看一下程式碼唄。

public class Garbage
{
    private int x;
    private List<String> y = new ArrayList<String>();

    public Garbage(int x)
    {
        this.x = x;
    }

    public static void main (String [] args) throws Exception
    {
        Garbage aGarbage = new Garbage(10);
        Garbage bGarbage = new Garbage(20);

        System.out.println("GC Point1")

        bGarbage = null;
        System.out.println("GC Point2")

        aGarbage.y = null;
        System.out.println("GC Point3")

        while (true) {
            Thread.sleep(1000);
        }

    }
}

注意!問題來了!

問:xy 這兩個變數分別存的是什麼?!

答:一個整數和一個 ArrayList 物件 。

錯!而且錯的離譜!

x 存的是整數沒錯,但 y 存的絕對不是 ArrayList 物件!

它存的是一個數字(你可以把他想像成地址),而 VM 可以透過這個數字找到記憶體中實際的 ArrayList 物件,用 Java 的術語來說 y 是一個 reference,用 C 的術語來說 y 就是一個 pointer

此外要注意的是,JVM / Davlik VM 的 Garbage Collection 回收的東西是「沒被任何 reference 指到的物件」,而由於你的 x 並不是 reference,也沒有指到任何物件,只要含有該變數的物件還沒被回收掉,理論上 x 所佔據的空間和他所存放的值是不會被動到的。

那麼 GC 是怎麼判斷要回收哪些物件的呢?在回答這個問題之前,首先我們要先知道的是到底程式裡有哪些標的是可以被回收的。

以上述的程式碼為例,因為每產生一個 Garbage 類別的物件,就會隨帶產生一個 ArrayList 物件,而我們在程式執行後又產生了兩個 Garbage 物件,所以實際上有以下四個標的物件:

  1. aGarbage 指到的 Garbage 物件中的 y 所指到的 ArrayList 物件
  2. bGarbage 指到的 Garbage 物件中的 y 所指到的 ArrayList 物件
  3. aGarbage 所指到的 Garbage 物件本身
  4. bGarbage 所指到的 Garbage 物件本身

上面的列表或許饒舌,但我認為這是很重要的觀念,在 Java 裡你的變數絕對不會是物件,而只會是指到物件的一個 reference 而已!

回到正題,那麼 GC 到底怎麼看哪個物件到底可不可以回收掉?答案其實很簡單:從根物件開始走訪所有的 reference ,把有走到的物件打勾,說這些是還在用,不能去動的物件。

等到全部走訪完之後,就會發現有一些物件是沒有被打勾的,這代表這些物件已經沒辦法在程式中被任何人取得(沒有任何 reference 可以指到他,也就代表沒有人可以用這些物件),等於是記憶體中的垃圾,所以可以清掉,把原來的空間給別人用。

有了這樣的概念就會可以知道:

  1. 執行到 GC Point1 那行時,四個物件都不能被回收
  2. 執行到 GC Point2 那行時,由於 bGarbage 被設為 null,所以物件 4 的 Garbage 物件已經沒有人可以用到,所以他是可以回收的。
  3. 但時由於物件 2 又只有被物件 4 所指到,但現在又已經沒有人可以找到物件 4,所以物件 2 也會變成可以被回收的狀態。
  4. 執行到 GC Point3 那行時,由於 aGarbage 指到的 Garbage 物件中的 y 被改成 null,使得沒有人可以從根物件找到物件 1,所以現在物件 1 也成為可回收狀態。
  5. 但接著由於是無窮回圈,所以程式不會結束,但又始終有一個 aGarbage 在指著物件 3,所以到了這個階段,物件 3 不論怎樣都不會被標成回收的。

要注意的是,GC 的動作是 Davlik VM 在負責的,而各個應用程式又是在不同的 memory space,所以 A 應用程式的 GC 是不可能回收到 B 應用程式的記憶體的。

Process 的死亡

OK,上面看完了 GC 的部份,現在來看另一種記憶體被釋放出來的情況--Process 死掉。

什麼時候 Process 會死掉呢?

如果是在 Linux / Windows 上等作業系統,大抵就是你在視窗上按下那個X按鈕,把他關掉的時候,又或是在 Linux command line 下的程式,被你按下 Ctrl-C 強制終止的時候。

但這都有一個特點,就是這些桌面作業系統並不會自己自做主張地去把 Process 終結掉,大部份的情況是如果你有許多的程式佔掉了大量的記憶體,最後的結果就是不斷的進行 SWAP(可以想像成把被縮小到工具列的 A 程式的記憶體狀態寫到硬碟上,再把使用者切回來的 B 程式的狀態從硬碟倒回到記憶體)的動作,讓整個系統卡死。

但相較之下,Android 為了跑在記憶體有限的機器上,又確保重要的程式可以有足夠的記憶體可以用,所以 Android 會砍掉不重要的 Process

由於 Process 死掉後,Davlik VM 也會跟著死掉(廢話,那個 process 就是用來跑 VM 的啊),所以在這個情況下,記憶體的回收和 GC 無關(還記得 GC 是 VM 的工作嗎),單純的是 OS 回收了你的應用程式的整個 memory space。

Android Activity 的生命週期

Note

這裡討論的情況是假設你的應用程式只有 Activity 組件,沒有其他像是 Service 之類的組件。這些其他組件也是會影響到你的應用程式 Process 的重要程度,和會不會被砍掉的,請自己參閱 Google 的官方開發文件。

我聽到你的唉嚎了,Process 被砍掉,使用者原本在跑的程式突然不見了,使用者不會來抗議嗎?

沒錯,事情不是只有把 Process 砍掉讓程式停止運行這麼簡單,實際上 Android 定義了一個相當嚴謹的 Activity 的生命週期,和重啟的流程。當 Android 因為記憶體不夠而把優先權較低的 Process 砍掉後,其實還是有記錄著原本使用者在執行哪些程式,當這些程式又被叫出來的時候,就會開始進行重啟的動作。

整個 Activity 的生命週期如下圖所示:

Android Activity 生命週期

Android Activity 生命週期

你可以看到,當一個 Activity 已經不在前景,也就是說目前最上層的 Activity 不屬於你自己的應用程式後,你的應用程式的 Process 就有可能被幹掉的!

這個時候如果使用者又按了 Back 鍵回到你的應用程式,那麼你的應用程式又會被重新啟動,再產生一個你的 Activity 類別的新物件,並且叫 void onCreate() 這個函式。

當這種情況發生的時候,雖然使用者好像「感覺回到了之前在用的 Activity」,但其實根本就是一個全新的 Activity,你存在那個 Activity 的 private 變數當然會變為初始的狀態

所以一個常見的情境就會像下面一樣:

  1. 你寫了一個 Activity,他大概像下面這樣:

    1. 有一個 private 變數當計數器,預設值是零
    2. 畫面上有一個地方顯示目前計數器的值
    3. 畫面上有一個 A 按鈕,按下去計數器會加一,並且更新畫面上的數字
    4. 畫面上有一個 B 按鈕,按下去會開啟通訊錄
  2. 使用者打開了你的應用程式,一直按 A 按鈕按到一百,現在畫面上計數器的值會顯示 100

  3. 使用者不小心按到了 B 按鈕,通訊錄被打開。這個時候你的 Activity 已經不在前景,換句話說已經經過了 onPause()onStop() 狀態,這時你的應用程式的 Process 就已經是可以被系統終結掉的候選人了。

  4. 這個時候系統發現記憶體不夠,於是你的應用程式和通訊錄都各自跑各自的 GC 來回收記憶體,但結果記憶體還是不夠用,於是這個時候系統就決定幹掉你的應用程式的 Process,因為他已經不在畫面上了。

  5. 這個時候使用者發現他按錯了,所以他又按下了 Back 鍵回到你的應用程式。

  6. 哇,挫賽!你的應用程式早就被系統終結掉了,所以系統只好重新啟動你的應用程式的 Process ,並 new 一個你的 Activity 出來,這個時候你的那個 private 變數自然會是初始值的零。

  7. 使用者:「幹,計數器怎麼變零,我剛剛都白按了!」

正確的做法

事實上上面講的事情,在 Android 的官方網站的 Activites 這一篇都講得非常清楚,也有一張程式重新啟動的流程圖:

Android Activity 結束和重啟流程

Android Activity 結束和重啟流程

而且在 Notepad Exercise 3 裡也特別強調了因為 Activity Life Cycle 而導致使用者輸入的資料消失的問題,這可以說是開發 Android 程式要特別注意的一點。

至於正確的解法是什麼呢?很簡單:

  • 在你的 Activity 裡一定要覆寫 void onSaveInstanceState(Bundle savedInstanceState) 這個方法,把現在的程式狀態存到那個 savedInstanceState 裡面(當然你存到其他地方其實也是可以啦,只是你要撈得回正確的資料才行)!
  • 記得在你的 Activity 裡複寫 void onCreate(Bundle savedInstanceState)void onRestoreInstanceState(Bundle savedInstanceState) 這兩個函式之一,並且把 savedInstanceState 裡的資料倒回你的應用程式內。
  • 透過上面的兩件事情,你就可以騙過使用者,讓他覺得你的程式一直都在執行,但實際上搞不好早就已經被系統幹掉好幾次了。

雖然說上面的標準作法在官方網站上的文件都一再強調,但好像還是有很多人就是懶……畢竟當你的 Activity 被丟掉背景後,只是「有可能」被砍掉而已。

所以就算你不照上面的做,測試或使用的時候還是會覺得你的應用程式一切正常,直到有一天膝蓋就莫名其妙地中了一箭……

以上大概就是最容易遇到的,由於不熟悉 Activity 的生命週期而踩到的應用程式的變數莫名其妙變成預設值的地雷,請大家在寫 Android 程式的時候要特別小心啊!

回響