2009年1月19日 星期一

請將要執行很久的程式碼,放在 Service 中執行

Run your time-consuming jobs in Service

在你研讀 Android 文件時,應該會注意到,千萬不能將要執行很久的程式碼放在 Main ( or UI) Thread 中執行,要不然一定會發生 ANR (Android is Not Responding) 錯誤。要避免 ANR 錯誤,對於所有可能會執行超過 5 秒的工作,例如網路或資料庫的存取、音樂的播放等等,你都要自行建立一個新的 Thread 物件,並將該費時的工作放在 Thread.run() 中執行。

其實,光將費時的程式碼放在 Thread.run() 中執行,還是不夠的,他只解決一半的問題。如果你不知道我為什麼會這麼說的,請先讀一下 記得要在程式中,處理鍵盤開啟或關閉的動作 這篇。

讀完後,你知道原因了嗎?當使用者打開或關閉鍵盤時,使用者看到的只是螢幕畫面的旋轉。但是,從程式面來看,Activity 已經經歷了一次生死輪迴。Activity 先被殺掉,再重新建立一個新的。那就代表,這個新建立的 Activity,再也不能和原先還正在執行工作的 Thread ,相互溝通,傳遞資料。

用個例子來說明,你可能會更清楚些。例如,你的程式正在執行一個下載檔案的工作。為了讓使用者清楚,目前的下載的進度。你應該會用個 Progress Dialog,顯示目前已經下載的百分比。如果就在此時,使用者打開鍵盤,你猜會發生什麼事?我想你程式上,原來那個顯示下載進度的 Progress Dialog,應該會不見了。負責下載的 Thread,可能還正在執行中(如果你沒在 onStop() 或 onDestroy() 中,停止 Thread 的執行)。可是,從你的 UI 的呈現,使用者會認為剛剛的那個下載的工作,已經被停下來。

要怎麼解決這個問題?我會建議你,不僅要將費時的工作放在 Thread.run() 中執行,還要將這個 Thread 放在 Service 中執行。因為,當螢幕要旋轉時,系統只會殺掉 Activity Stack 中,最上面的那一個 Activity,並不會對扮演執行 background task 的 Service 採取任何的動作。Service 和 Thread 還是可以安穩地繼續執行下載的工作,而被系統重起的 Activity,也可以透過 Service 提供的 remote interface,與 Service 進行 IPC (interprocess communication),以獲取目前下載的進度與執行情形。

想更進一步瞭解實作的你,我建議你先研讀 Android 中 Music 這個程式的原始碼。另外想要知道如何與 Service 進行 IPC 的你,請閱讀 Android 文件中的 <sdk_path>/docs/reference/aidl.html 文件,我覺得這篇寫得還算清楚詳細,值得一讀。

10 則留言:

Anthony 提到...

很好!

yrulee 提到...

其實,在某些情況下不需要另外開一個Service and IPC也可以把某些耗費長時間的放到背景執行。

把某個controller instance塞到某個Activity的static field,然後在那個controller的內部開一個Thread實作控制邏輯,例如傳檔、播放音樂...etc,一樣可以避免ANR。

在沒有多個UI Activity會使用同一個Service的前提下,若一律採用background Service pattern只會浪費Zygote與process資源。

補充一下。

samlu 提到...

Hi yrulee,

你提的意見很好。把東西挪到 Thread 中,的確就可以避免 ANR,不過還是不能避免系統因 memory 不足,或像我舉得開關鍵盤等情形下,殺掉你的 Activity。

雖然 Activity 被殺掉,thread 還是繼續跑,不過執行過程中或結束時,如何將資料傳回,倒不是個好解的問題。

我個人覺得,在這情形下,還是用 service 會好些。翻翻 Android 的音樂播放器,或下載檔案等原始碼,都是在 service 中實現,就知道他的必要性。

yrulee 提到...

因為此文章的主題是如何避免ANR,所以我只想補充的一點是,避免ANR有許多方法,當然放到Service裡跑是很好的,但是這不是唯一的解法。

Inter-thread messaging的訊息傳遞通常可以用android.os.Handler,然後在Handler.handleMessage()中把相關訊息relay至其他元件,如同以下的code (點開"Example ProgressDialog with a second thread")

http://developer.android.com/guide/topics/ui/dialogs.html#ShowingAProgressBar

雖然大多數的Android本身的原始碼都是使用Service開發背景程式,但是那與一般使用者開發的AP不同的是,Android許多的如音樂播放器或下載等功能都是系統層級的AP,因為需要長時間使用,其穩定性十分重要,但是一般的使用者的AP則並不一定,AP開發者應該要能夠依據程式本身的scale去決定pattern。

所以 

"請將要執行很久的程式碼,放在 Service 中執行"

這個title也許有可能會誤導AP開發者,好像耗時的程式碼都得放在Service裡,但這在實際上開發時是應該要有所權衡的。

一點淺見供參考,感謝。

卡哩 提到...

Hi Sam老師,

以你這邊文章舉的鍵盤開關的例子, 如果我利用處理onConfigurationChange的方式讓鍵盤開關的時候(畫面旋轉)可以不要真的關閉activity, 是不是也可以解決這裡提到的問題?

samlu 提到...

卡哩,是可以。不過你就要自行載入直/橫立模式的 layout 檔。有好幾個情況 Activity 都會被殺掉,開關鍵盤只是其中之一。建議你還是得透徹了解這 activity 的生命週期,你才能找出最佳的解法。

李先生 提到...

Hi Sam老師您好

一件事不知能否請教?
目前我正在寫一各android的功能叫onKeyDown
我想要在長按音量鈕(>3秒)後能夠啟動一各class(在下面的程式碼中我先以toast代替這各class)
由於要能夠長駐背景
所以我把偵測長按音量鈕的程式碼放在service裡面
不過程式執行到onKeyDown()這一段沒有反應,
我還在onkeydown()後面放了一各ff()測試,這ff()是會被run然後跳出"測試ff是否被執行",
這代表在我的程式碼中,onKeyDown()無法執行
可以請教您這是什麼原因嗎?
謝謝
Jimmy
===========================================
package com.myvoulume;

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.view.KeyEvent;
import android.widget.Toast;

public class MyVolumeBackground extends Service {

@Override
public IBinder onBind(Intent intent) {
return null;
}
public void onCreate(){
super.onCreate();
Toast.makeText(this, "On Create...", Toast.LENGTH_SHORT).show();
}

public void onStart(Intent intent, int startID){
super.onStart(intent, startID);
Toast.makeText(this, "On Start...", Toast.LENGTH_SHORT).show();
onKeyDown(startID, null);
ff();
}

public void ff(){
Toast.makeText(this, "測試ff是否被執行", Toast.LENGTH_SHORT).show();
}

public boolean onKeyDown(int keyCode, KeyEvent event) {
if(keyCode==KeyEvent.KEYCODE_VOLUME_UP){
Toast.makeText(this, "按下了上音量鍵", Toast.LENGTH_SHORT).show();
return true;
}else if(keyCode==KeyEvent.KEYCODE_VOLUME_DOWN){
Toast.makeText(this, "按下了下音量鍵", Toast.LENGTH_SHORT).show();
// return true;
}
return true;
}

public void onDestroy(){
super.onDestroy();
}
}
===========================================

samlu 提到...

Service class 根本不提供 onKeyDown() 讓你 override. 從你的問題,可以知道你對 Java 不熟,我建議你先熟讀 Java。

李先生 提到...

samlu
謝謝你阿
因為我對java不熟,
這樣子我知道拉
我在想想
用action.SCREEN_ON的方式
改用偵測電源鍵長按的方式看看
試成功在PO上來

匿名 提到...

老師您好:
請問我遇到
E/InputTransport( 155): channel '40aad740 com.android.settings/com.android.settings.Settings (serve
r)' publisher ~ Error -1 pinning ashmem fd 0.
E/InputDispatcher( 155): channel '40aad740 com.android.settings/com.android.settings.Settings (serv
er)' ~ Could not publish key event, status=-2147483648
E/InputDispatcher( 155): channel '40aad740 com.android.settings/com.android.settings.Settings (serv
er)' ~ Channel is unrecoverably broken and will be disposed!
I/WindowManager( 155): WINDOW DIED Window{40aad740 com.android.settings/com.android.settings.Settin
gs paused=false}
E/InputQueue-JNI( 631): channel '40aad740 com.android.settings/com.android.settings.Settings (clien
t)' ~ Publisher closed input channel or an error occurred. events=0x8
E/Surface ( 631): surface (identity=11) is invalid, err=-19 (No such device)

這個error message,但是目前毫無頭緒為什麼會發生,請問可以指點一下嗎?

謝謝~~

張貼留言