Android AsyncTask 問題

很多時開發 app 需要經網絡拿取資料,android 的話最簡單是用 AsyncTaskAsyncTask提供一個方便清晰的方法,使用另一 thread 去執行費時的工作,然後更新介面,這能避免阻擋 UI Thread 的工作,導致 "Android Not Responding" 的出現。

這次我們來看看 AsyncTask 的用法和它潛在的問題。

AsyncTask 一般做法

因為需要更新 UI,所以 AsyncTask 一般會以 inner class 的形式加在 Activity 中。如 MainActivity 中要下載一檔案,一般會這樣寫:

 public class MainActivity extends Activity {
      TextView resultTextView;
      @Override
      public void onCreate(Bundle savedInstanceState) {
             //....initialize resultTextView

                   new DownloadFilesTask().execute(url1, url2, url3);
      }

      private class DownloadFilesTask extends AsyncTask<URL, Integer, Long> {
             protected Long doInBackground(URL... urls) {
                   int count = urls.length;
                   long totalSize = 0;
                   for (int i = 0; i < count; i++) {
                          totalSize += Downloader.downloadFile(urls[i]);
                          publishProgress((int) ((i / (float) count) * 100));
                          // Escape early if cancel() is called
                          if (isCancelled()) break;
                   }
                   return totalSize;
             }

             protected void onPostExecute(Long result) {
                   resultTextView.setText("Downloaded " + result + " bytes");
             }
      }
 }

這是最簡單的用法,但此做法其實有兩個問題:

不死的 Activity 問題

因為使用的是 nested class,DownloadFilesTask 會有一 implicit reference 指向 MainActivity。結果是,DownloadFileTask 只要未完成工作,MainActivity 就算被 destroy,也不會被回收 (Garbage collected)。由於 Activity 佔記憶體較多,不能被適時清埋的話會做成 OS 效率降低。

覺得影響不大? 試想想一個有 AsyncTaskactivity ,然後不斷旋轉 android 手機,由於 android 在 rotate screen 時會殺掉舊有的 activity ,再新建一個 activity,但是舊有的 activityAsyncTask 鎖住,未能及時釋放,你會發現程式所佔的記憶體會不斷上升,直至 OutOfMemoryException 的出現。

AsyncTasks should ideally be used for short operations (a few seconds at the most.)

From Android API reference

雖然 Android document 提及過 AsyncTask 不適宜執行太費時的工作(只多只是數秒),但沒有人能預測網絡速度,下載同一 file 有時不用一秒,有時要數分數呀。難道要使用 Service 或自行寫 Thread 來做嗎? 這樣 AsyncTask 還有存在價值嗎?

臃腫

因為要使用 resultTextView 去進行更新,所以最方便的做法是如上面的 nested class。但這樣令原本跟 MainActivity 不相干的程式碼都要放在一起,令 MainActivity 檔案太長太臃腫。

改良之一:static class

最簡單的解決方法,是將 inner class 變為 static,這樣便沒有 implicit reference,解決了僵屍 activity 的問題。

public class MainActivity extends Activity {
      TextView resultTextView;
	  
      @Override
      public void onCreate(Bundle savedInstanceState) {
             //....initialize resultTextView

             new DownloadFilesTask(this).execute(url1, url2, url3);
      }
	  
      public void updateDownloadResult(long result){
           resultTextView.setText("Downloaded " + result + " bytes");
      }

      static class DownloadFilesTask extends AsyncTask<URL, Integer, Long> {
		private MainActivity mainActivity;
    
		public DownloadFilesAsyncTask(MainActivity activity){
			mainActivity = activity;
		}
		
		 protected Long doInBackground(URL... urls) {
			   int count = urls.length;
			   long totalSize = 0;
			   for (int i = 0; i < count; i++) {
					  totalSize += Downloader.downloadFile(urls[i]);
					  publishProgress((int) ((i / (float) count) * 100));
					  // Escape early if cancel() is called
					  if (isCancelled()) break;
			   }
			   return totalSize;
		 }

		 protected void onPostExecute(Long result) {
			   mainActivity.updateDownloadResult(result);
		 }
      }
}

不過還是使用 inner class,檔案太大太長,實在不好看。

改良之二: 獨立的 AsyncTask 檔案

比較好的做法當然是將 AsyncTask 抽出來成獨文的 DownloadFilesTask ,然後將 MainActivity 當成 parameter 在 AsyncTask 中使用:

MainActivity.java:

 public class MainActivity extends Activity {
      TextView resultTextView;

      @Override
      public void onCreate(Bundle savedInstanceState) {
           //....initialize resultTextView
           new DownloadFilesTask(this).execute(url1, url2, url3);
      }

      public void updateDownloadResult(long result){
           resultTextView.setText("Downloaded " + result + " bytes");
      }
 }

DownloadFilesTask.java:

 public class DownloadFilesTask extends AsyncTask<URL, Integer, Long> {
      private MainActivity mainActivity;

      public DownloadFilesTask(MainActivity mainActivity){
           this.mainActivity = MainActivity;
      }

      protected Long doInBackground(URL... urls) {
           //... downloading
           return totalSize;
      }

      protected void onPostExecute(Long result) {
           mainActivity.updateDownloadResult("Downloaded " + result + " bytes");
      }
 }

這樣 MainActivity.java 便能減少不相關的 coding。但熟悉 OO 的你,一定覺得這樣太 tightly coupled: DownloadFilesTask 只能在 MainActivity 中使用,不能由其他 class 執行。

改良之三: 使用 interface

要解決這問題,我們可以用 interfaceMainActivityDownloadFilesTask 拆開:

新增 OnDownloadFinishedListener:

 public interface OnDownloadFinishedListener {
      public void updateDownloadResult(long result);
 }

MainActivity implements OnDownloadFinishedListener:

 public class MainActivity extends Activity implements OnDownloadFinishedListener {
      TextView resultTextView;
      @Override
      public void onCreate(Bundle savedInstanceState) {
           //....initialize resultTextView
           new DownloadFilesTask(this).execute(url1, url2, url3);
      }
      @Override
      public void updateDownloadResult(long result){
           resultTextView.setText("Downloaded " + result + " bytes");
      }
 }

DownloadFilesTask 便可在完成工作後,用 OnDownloadFinishedListener 去更新 UI:

 public class DownloadFilesTask extends AsyncTask<URL, Integer, Long> {
      private OnDownloadFinishedListener listener;

      public DownloadFilesTask(OnDownloadFinishedListener listener){
           this.listener = listener;
      }

      protected Long doInBackground(URL... urls) {
           //... downloading
           return totalSize;
      }

      protected void onPostExecute(Long result) {
           listener.updateDownloadResult("Downloaded " + result + " bytes");
      }
 }

這樣任何 implement OnDownloadFinishedListenerclass 都能使用 DownloadFilesTask 了!

改良之四:使用 Local Broadcast

DownloadFilesTask 完成後需要通知幾個不同的 object 要怎辦?不會是在 OnDownloadFinihsedListener.updateDownloadResult() 裏 call 它們去更新吧?

這種情況,我們可使用 local broadcast。Android OS 跟不同程式的溝通便是使用廣播 intent 來進行,因為我們不需要通知其他 app,所以只用到 local broadcast。

新加 ResultBroadcastReceiver 以進行接收 broadcast 後的更新

public class ResultBroadcastReceiver extends BroadcastReceiver {
      private OnDownloadFinishedListener listener;
      public ResultBroadcastReceiver(OnDownloadFinishedListener listener){
         this.listener = listener;
      }
      @Override
    public void onReceive(Context context, Intent intent) {
        if (intent.getAction().equals(MainActivity.RESULT)) {
                long result = intent.getLongExtra(MainActivity.RESULT_DATA, 0);
                listener.updateDownloadResult(result);
           }
      }
 }

MainActivity 加進 ResultBroadcastReceiver 去準備接收

 public class MainActivity extends Activity implements OnDownloadFinishedListener {
      public static final String RESULT_ACTION = "com.thirtysparks.blog.MainActivity.result";
      public static final String RESULT_DATA = "result_data";

      private ResultBroadcastReceiver receiver;

      TextView resultTextView;

      @Override
      public void onCreate(Bundle savedInstanceState) {
           //....initialize resultTextView

           receiver = new ResultBroadcastReceiver(this);
           LocalBroadcastManager.getInstance(this).registerReceiver(receiver, new IntentFilter(RESULT_ACTION));

           new DownloadFilesTask().execute(url1, url2, url3);
      }

      @Override
      public void onDestroy(){
           super.onDestroy();
           LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver);
      }

      @Override
      public void updateDownloadResult(long result){
           resultTextView.setText("Downloaded " + result + " bytes");
      }
 }

可看到加了 receiver 並在 onCreate() 中透過 LocalBroadcastManager 去註冊接收廣播,並不忘在 onDestory() 去取消註冊。

最後只要在 DownloadFilesTask 進行廣播即可:

 public class DownloadFilesTask extends AsyncTask<URL, Integer, Long> {
      protected Long doInBackground(URL... urls) {
           //... downloading
           return totalSize;
      }

      protected void onPostExecute(Long result) {
           Intent in = new Intent(MainActivity.RESULT_ACTION);
           in.putExtra(MainActivity.RESULT_DATA, result);
           LocalBroadcastManager.getInstance(context).sendBroadcast(in);
      }
 }

透過 local broadcast,DownloadFilesTaskMainActivity 之間再沒有直接關係。任何登記接受 RESULT_ACTIONclass 也會收到 intent` 去做相關的更新工作。

Local Broadcast 的缺點

寫到這裏,程式由一開始的一個檔案,已增加到 4 個了。若要再添一個 AsyncTask 去執行其他工作,再加上相對應的 BroadcastReceiverinterface,又會增加 3 個檔案。怪不得很多人說寫 java 實在太麻煩、太累贅了。

另外,使用 intent 傳送 intlong 這類的沒有問題,但若想在 intent 放自行編寫的 class,一是必須 implements Serializable (慢),一是 implments Parcelable (煩),很是麻煩。

當然,以上缺點一早有人想到解決方法: EventBus

最終的改良: EventBus

EventBus is publish/subscribe event bus optimized for Android.

使用 EventBus 原理其實跟 local broadcast 差不多,不過麻煩的東西 EventBus 已經替你處理掉了。DownloadFilesTask 完成後發出 eventEventBus 再通知需接知此 eventclass 去執行相關行動。

加進 ResultEvent 用作 AysncTask 工作完成後要通知 MainActivity 的信差:

 public class ResultEvent {
      private long result;

      public ResultEvent(long result){
           this.result = result;
      }

      public long getResult() {
           return result;
      }

      public void setResult(long result) {
           this.result = result;
      }
 }

ResultEvent 只是 POJO,不需要碰麻煩的 Parcelable。要加減 field 完全沒難度。

MainActivity 改寫成

 public class MainActivity extends Activity{
      TextView resultTextView;

      @Override
      public void onCreate(Bundle savedInstanceState) {
           //....initialize resultTextView

           EventBus.getDefault().register(this);

           new DownloadFilesTask().execute(url1, url2, url3);
      }

      @Override
      public void onDestroy(){
           super.onDestroy();
           EventBus.getDefault().unregister(this);
      }

      public void onEvent(ResultEvent event){
           updateDownloadResult(event.getResult());
      }

      public void updateDownloadResult(long result){
           resultTextView.setText("Downloaded " + result + " bytes");
      }
 }

重點是 onCreate() 中登記 EventBus,和新增了 onEvent(ResultEvent)onEvent(ResultEvent)只是需要執行 updateDownloadResult()而已。

DownloadFilesTask.onPostExecute() 只需發出 ResultEvent 即可:

 protected void onPostExecute(Long result) {
      EventBus.getDefault().post(new ResultEvent(result));
 }

相比起使用 broadcast ,code 變得更簡潔易明。萬一 ResultEvent 需要更改,也不用處理惱人的 parcelable

可能你會問,為此新加一個 library,不是更麻煩嗎? 我可以答你,使用 EventBus 是為了將來的需要。若像最初的簡單程式,使用最基本的方法當然沒什麼問題,但當你的程式越來越多功能,越來越複雜,使用 EventBus 一定會更簡潔清晰,而更簡潔通常代表更少的 bugs。

總結

AsyncTask 雖然簡單方便,但其實地雷不少,將來總有踩中的一天。用 local broadcast 可以助你避過地雷,但會增加 boilterplate code 。要更方便和支援更大 project 的話,請使用 EventBus (或 Otto) 。

簡單來說,不想增加 dependency 又不介意重複寫類似 coding 的話用 local broadcast,想簡潔的話用 EventBus

參考