一、前言
爲了提高流暢性,耗時任務放後臺線程運行,已是APP開發的常識了。
關於異步有很多方案,當前最流行的,莫過於RxJava了;
更早一些時候,還有AsyncTask(骨灰級的API)。
總的來說,AsyncTask構思精巧,代碼簡潔,使用方便,有不少地方值得借鑑。
當然問題也有不少,比如不能隨Activity銷燬而銷燬導致的內存泄漏,還有不適合做長時間的任務等。
筆者以AsyncTask爲範本,寫了一個“AsyncTaskPlus”:
保留了AsyncTask的所有用法,解決了其中的一些問題,同時引入了一些新特性。
接下來給大家介紹一下這“加強版”的框架,希望對各位有所啓發。
二、任務調度
2.1 AsyncTask的Executor
AsyncTask的任務調度主要依賴兩個Executor:ThreadPoolExecutor 和 SerialExecutor。
代碼如下:
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
public static final Executor THREAD_POOL_EXECUTOR;
public static final Executor SERIAL_EXECUTOR = new SerialExecutor();
static {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, 30, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(128), sThreadFactory);
threadPoolExecutor.allowCoreThreadTimeOut(true);
THREAD_POOL_EXECUTOR = threadPoolExecutor;
}
private static class SerialExecutor implements Executor {
final ArrayDeque<Runnable> mTasks = new ArrayDeque<Runnable>();
Runnable mActive;
public synchronized void execute(final Runnable r) {
mTasks.offer(new Runnable() {
public void run() {
try {
r.run();
} finally {
scheduleNext();
}
}
});
if (mActive == null) {
scheduleNext();
}
}
protected synchronized void scheduleNext() {
if ((mActive = mTasks.poll()) != null) {
THREAD_POOL_EXECUTOR.execute(mActive);
}
}
}
關於線程池,估計大家都很熟悉了,參數就不多作解釋了。
如果不是很熟悉,推薦閱讀筆者的另一篇文章《速讀Java線程池》。
上面代碼中,通過巧用“裝飾者模式”,增加“串行調度”的功能。
裝飾者模式有以下特點:
- 裝飾對象和真實對象有相同的接口,這樣客戶端對象就能以和真實對象相同的方式和裝飾對象交互。
- 裝飾對象包含一個真實對象的引用。
- 裝飾對象接受所有來自客戶端的請求,它把這些請求轉發給真實的對象。
- 裝飾對象可以在轉發這些請求以前或以後增加一些附加功能。
SerialExecutor只有二十來行代碼,卻用了兩次裝飾者模式:Runnable和Executor。
- Runnable部分,往隊列添加的匿名Runnable對象(裝飾對象),當被Executor調用run()方法時,先執行“真實對象”的run()方法,然後再調用scheduleNext();
- Executor部分,通過增加一個任務隊列,實現串行調度的功能,而具體的任務執行轉發給“真實對象”THREAD_POOL_EXECUTOR。
想要串行調度,爲什麼不多加一個coreSize=1的ThreadPoolExecutor呢?
兩個ThreadPoolExecutor,彼此線程不可複用。
雖然SerialExecutor的方案很不錯,但是THREAD_POOL_EXECUTOR的coreSize太小了(不超過4),
這導致AsyncTask不適合執行長時間運行的任務,否則多幾個任務就會堵塞。
因此,如果要改進AsyncTask,首先要改進Executor。
2.2 通用版Executor
實現思路和 SerialExecutor 差不多,加一個隊列, 實現另一層調度控制。
首先,把 Runnable 和 scheduleNext 兩部分都抽象出來:
interface Trigger {
fun next()
}
class RunnableWrapper constructor(
private val r: Runnable,
private val trigger: Trigger) : Runnable {
override fun run() {
try {
r.run()
} finally {
trigger.next()
}
}
}
接下來的實現和SerialExecutor類似:
class PipeExecutor @JvmOverloads constructor(
windowSize: Int,
private val capacity: Int = -1,
private val rejectedHandler: RejectedExecutionHandler = defaultHandler) : TaskExecutor {
private val tasks = PriorityQueue<RunnableWrapper>()
private val windowSize: Int = if (windowSize > 0) windowSize else 1
private var count = 0
private val trigger : Trigger = object : Trigger {
override fun next() {
scheduleNext()
}
}
fun execute(r: Runnable, priority: Int) {
schedule(RunnableWrapper(r, trigger), priority)
}
@Synchronized
internal fun scheduleNext() {
count--
if (count < windowSize) {
startTask(tasks.poll())
}
}
@Synchronized
internal fun schedule(r: RunnableWrapper, priority: Int) {
if (capacity > 0 && tasks.size() >= capacity) {
rejectedHandler.rejectedExecution(r, TaskCenter.poolExecutor)
}
if (count < windowSize || priority == Priority.IMMEDIATE) {
startTask(r)
} else {
tasks.offer(r, priority)
}
}
private fun startTask(active: Runnable?) {
if (active != null) {
count++
TaskCenter.poolExecutor.execute(active)
}
}
}
解析一下代碼中的參數和變量:
- tasks:任務緩衝區
- count:正在執行的任務的數量
- windowSize:併發窗口,控制Executor的併發
- capacity:任務緩衝區容量,小於等於0時爲不限容量,超過容量觸發rejectedHandler
- rejectedHandler:默認爲AbortPolicy(拋出異常)
- priority:調度優先級
當count>=windowSize時,priority高者先被調度;
優先級相同的任務,遵循先進先出(FIFO)的調度規則。
需要注意的是,調度優先級不同於線程優先級,線程優先級更底層一些。
比如AsyncTask的doInBackground()中就調用了:
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
這可以使得後臺線程的線程優先級低於UI線程。
以下是PipeExecutor的流程圖:
定義了PipeExecutor了之後,我們可以實現多個實例。
例如,可以仿照 RxJava 的 Schedulers,定義適用於“IO密集型”任務和“計算密集型”任務的Executor。
val io = PipeExecutor(20, 512)
val computation = PipeExecutor(Math.min(Math.max(2, cpuCount), 4), 512)
也可以定義串行調度的Executor:
val single = PipeExecutor(1)
不過我們不建議定義全局的串行調度Executor,因爲會有相互阻塞的風險。
但是可以根據場景定義專屬的串行調度Executor,比如給日誌收集創建一個,給數據上報創建一個……
不同實例,猶如不同的水管,往同一個池子進水,故而命名爲PipeExecutor。
2.3 去重版Executor
我們項目中,頁面更新用的是“發佈訂閱模式”:
數據層有變更,發佈更新消息;
上層收到消息,異步加載數據,刷新頁面。
然後就碰到一個問題:若短時間內有多次數據更新,就會有多個消息發往上層。
不做特殊處理,就會幾乎同時啓動多個異步任務,浪費計算資源;
多個線程對併發讀取同一數據,多線程問題也隨之而來,若處理不好,結果不可預知。
用串行執行器?所有任務串行的話,無法利用任務併發的優勢。
所以經過比較多種方案,最終的結論是:
- 1、任務分組,不同組並行,同組串行
- 2、同組的任務,如果有任務在執行,最多隻能有一個在等待,丟棄後面的任務
所謂分組,就是給任務打tag, 比如刷新A數據的任務叫ATask, 刷新B任務的叫BTask。
關於第2點,其實有考慮過其他一些方案,比如下面兩個:
- 取消正在執行的任務
- 首先不是所有任務都可以中斷的,可以不接收其結果,但是不一定能中斷其執行
- 即使能取消(比如中斷網絡請求),也不是最佳方案。
比方說當前線程或許已經快要下載完了,在等一會後面的任務就可以讀緩存去結果了;
任務2取消任務1,任務3取消任務2……等到最後一個任務執行,用戶可能已經不耐煩了。
- 如果有任務在執行,丟棄後面的任務
比方說任務1讀取了數據,在計算的時候,數據源變更,然後發送事件,啓動任務2……
直接丟棄後面的任務,最終頁面顯示的是舊的數據。
我們定義了一個LaneExecutor來實現這個方案,示意圖如下:
各組任務就像一個個車道(Lane), 故而命名爲LaneExecutor。
洋蔥似地一層包一層,很明顯,也是裝飾者模式。
職責分配:
LaneExecutor負責任務去重;
PipeExecutor負責任務併發控制和調度優先級;
ThreadPoolExecutor負責分配線程來執行任務。
但後來又遇到另一個問題:
有多個控件要加載同一個URL的數據,然後很自然地我們就以 URL作爲tag了,以避免重複下載(做有緩存,第一任務下載完成之後,後面的任務可以讀取緩存)。
但是用LaneExecutor來執行時,只保留一個任務在等待,然後最終只有兩個控件能顯示數據。
查到問題後,筆者給LaneExecutor加了一種模式,該模式下,不丟棄任務。
如此,所有任務都會被執行,但是隻有第一個需要下載數據,後面任務讀緩存就好了。
2.4 統一管理Executor
當項目複雜度到了一定程度,如果沒有統一的公共定義,可能會出現各種冗餘實例。
分散的Executor無法較好地控制併發;
如果各自創建的是ThreadPoolExecutor,則還要加上一條:降低線程複用。
故此,可以集中定義Executor,各模塊統一調用。
代碼如下:
object TaskCenter {
internal val poolExecutor: ThreadPoolExecutor = ThreadPoolExecutor(
0, 256,
60L, TimeUnit.SECONDS,
SynchronousQueue(),
threadFactory)
// 常規的任務調度器,可控制任務併發,支持任務優先級
val io = PipeExecutor(20, 512)
val computation = PipeExecutor(Math.min(Math.max(2, cpuCount), 4), 512)
// 帶去重策略的 Executor,可用於數據刷新等任務
val laneIO = LaneExecutor(io, true)
val laneCP = LaneExecutor(computation, true)
// 相同的tag的任務會被串行執行,相當於串行的Executor
// 可用於寫日誌,上報統計信息等任務
val serial = LaneExecutor(PipeExecutor(Math.min(Math.max(2, cpuCount), 4), 1024))
}
2.5 Executor的使用
TaskCenter.io.execute{
// do something
}
TaskCenter.laneIO.execute("laneIO", {
// do something
}, Priority.HIGH)
val serialExecutor = PipeExecutor(1)
serialExecutor.execute{
// do something
}
TaskCenter.serial.execute ("your tag", {
// do something
})
- PipeExecutor的使用和常規的Executor是一樣的,execute中傳入Runnable即可,
然後由於Runnable只有一個方法,也沒有參數,lambda的形式就顯得更加簡潔了。 - LaneExecutor由於要給任務打tag, 所以要傳入tag參數;
如果不傳,則沒有分組的效果,也就是回退到PipeExecutor的特性; - 兩種Executor都可以傳入優先級。
很多開源項目都設計了API來使用外部的Executor,比如RxJava可以這樣用:
object TaskSchedulers {
val io: Scheduler by lazy { Schedulers.from(TaskCenter.io) }
val computation: Scheduler by lazy { Schedulers.from(TaskCenter.computation) }
val single by lazy { Schedulers.from(PipeExecutor(1)) }
}
Observable.range(1, 8)
.subscribeOn(TaskSchedulers.computation)
.subscribe { Log.d(tag, "number:$it") }
這樣有一個好處,各種任務都在一個線程池上執行任務,可複用彼此創建的線程。
三、流程控制
3.1 AsyncTask的執行流
上一章我們分析了任務調度,構造了一系列Executor,增強任務處理方面的通用性。
不過任務調度只是AsyncTask的一部分,AsyncTask的精髓其實在於流程控制:在任務執行的不同階段,回調相應的方法。
下面是AsyncTask的流程圖:
通過使用FutureTask和Callable,使得AsyncTask具備對任務執行更強的控制力,比如cancel任務。
有的文章說cancel()不一定的立即中斷任務,但其實Futuret.cancel()確實已經是最好的方案了,
如果強行調用Thread.stop(),則猶如關掉空中飛機的引擎,後果不堪設想。
通過與Handler的配合,AsyncTask可以在任務執行過程中和執行結束後發佈數據到UI線程,
這使得AsyncTask尤其適用於“數據加載+界面刷新”的場景。
而這類場景在APP開發中較爲常見,這也是AsyncTask一度被廣泛使用的原因之一。
3.2 生命週期
AsyncTask其中一個廣爲詬病的問題就是內存泄漏:
若AsyncTask持有Activity引用,且生命週期比Activity的長,則Activity無法被及時回收。
這個問題其實不是AsyncTask獨有,Handler,RxJava等都存在類似問題。
解決方案有多種,靜態類、弱引用、Activity銷燬時取消等。
RxJava提供了dispose方法來取消任務,同時也有很多集成生命週期的開源方案,比如RxLifecycle、AutoDispose等。
AsyncTask也提供了cancel方法,但是比較命苦,吐槽者衆,助力者寡。
其實要實現自動cancel不難,建立和Activity/Fragment的關係即可,可通過觀察者模式來實現。
UITask是參考AsyncTask寫的一個類, 使用了上一章介紹的Executor。
結構上,UITask爲觀察者,Activity/Fragment爲被觀察者,LifecycleManager爲 UITask 和 Activity/Fragment 構建關係的橋樑。
實現上需要兩個數據結構:一個SparseArray,一個List。
SparseArray的key爲被觀察者的identityHashCode, value爲觀察者列表。
UITask提供了host()方法,方法中獲取宿主(也就是Activity/Fragment)的identityHashCode,
通過register()方法,添加 “Activity->UITask” 到SparseArray中。
abstract class UITask<Params, Progress, Result> : LifeListener {
fun host(host: Any): UITask<Params, Progress, Result> {
LifecycleManager.register(System.identityHashCode(host), this)
return this
}
override fun onEvent(event: Int) {
if (event == LifeEvent.DESTROY) {
cancel(true)
} else if (event == LifeEvent.SHOW) {
changePriority(+1)
} else if (event == LifeEvent.HIDE) {
changePriority(-1)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
TestTask().host(this).execute("hello")
}
需要在BaseActivity中通知事件:
abstract class BaseActivity : Activity() {
override fun onDestroy() {
super.onDestroy()
LifecycleManager.notify(this, LifeEvent.DESTROY)
}
override fun onPause() {
super.onPause()
LifecycleManager.notify(this, LifeEvent.HIDE)
}
override fun onResume() {
super.onResume()
LifecycleManager.notify(this, LifeEvent.SHOW)
}
}
調用notify()方法時,會根據Activity索引到對應觀察者列表,然後遍歷列表,回調觀察者onEvent()方法。
其中,當通知的事件爲DESTROY時,UITask執行cancel()方法,從而取消任務。
3.3 動態調整優先級
上一節,我們看到UITask除了關注DESTROY事件,還關注 Activity/Fragment 的HIDE和SHOW,
並根據可見狀態調整優先級。
調整優先級有什麼用呢? 下面先看兩張圖感受一下。
爲了凸顯效果,我們把加載任務的併發量控制爲1(串行)。
第一張是不會自動調整優先級的,完全的先進先出:
可以看到,切換到第二個頁面,由於上一頁的任務還沒執行完,
所以要一直等到上一頁的任務都完成了才輪到第二個頁面加載。
很顯然這樣體驗不太好。
接下來我們看下動態調整優先級是什麼效果:
切換到第二個頁面之後,第一個頁面的任務的“調度優先級”被降低了,所以會優先加載第二個頁面的圖片;
再次切換回第一個頁面,第二個頁面的優先級被降低,第一個頁面的優先級恢復,所以優先加載第一個頁面的圖片。
那可否進入第二個頁面的時暫停第一個頁面的任務?
暫停的方案不太友好,比方說用戶在第二個頁面停留很久,第二個頁面的任務都完成了,然後切換回第一個頁面,發現只有部分圖片(其他被暫停了)。
而如果只是調整優先級,則第二個頁面的任務都執行完之後,會接着執行第一個頁面的任務,返回第一個頁面時就能夠看到所有圖片了。
這就好比趕車,讓其他人給插個隊,沒有問題,但是不能不給別人排隊了吧。
3.4 鏈式調用
UITask的用法和AsyncTask大同小異,回調方法和參數泛型都是一樣的,所以就不多作介紹了。
如今很多開源庫都提供了鏈式API,使用起來確實靈活方便,視覺上也比較連貫。
喜歡冰糖葫蘆一樣的鏈式調用?
項目中提供了一個ChainTask類,拓展了UITask,提供鏈式調用的API。
override fun onCreate(savedInstanceState: Bundle?) {
val task = ChainTask<Double, Int, String>()
task.tag("ChainTest")
.preExecute { result_tv.text = "running" }
.background { params ->
for (i in 0..100 step 2) {
// do something
task.publishProgress(i)
}
"result is:" + (params[0] * 100)
}
.progressUpdate { values ->
val progress = values[0]
progress_bar.progress = progress
progress_tv.text = "$progress%"
}
.postExecute { result_tv.text = it }
.cancel { showTips("ChainTask cancel") }
.priority(Priority.IMMEDIATE)
.host(this)
.execute(3.14)
}
四、總結
最後,可能會這樣的疑問:
既然已經有 RxJava 這樣好用的開源庫來實現異步了, 爲什麼還要寫這個項目呢?
首先,RxJava 不僅僅是異步而已:“ReactiveX是一個通過使用可觀察序列來編寫異步和基於事件的程序的庫。”
“可觀察序列 - 事件 - 異步”加起來才使得 RxJava 如此富有魅力。
有所得,必有所付出,爲了實現這些豐富的特性,代碼量也是比較可觀的(當前版本jar包約2.2M)。
AsyncTask則比較簡單,除去註釋只有三百多行代碼;
功能也比較純粹:執行異步任務,在任務執行的不同階段,回調相應的方法。
Task參考了AsyncTask,功能類似,只是做了一些完善;
jar包大小45K,也算是比較輕量的。
這個年頭,apk動輒幾十M甚至上百M,2.2M的庫並非不可接受。
但是也有一些場景,比方說給第三方寫SDK的時候,對包大小和依賴比較敏感,而且也不需要這麼大而全的特性,這時一些輕量級的方案就比較合適了。
而且,除了包大小之外,Task所實現的功能和RxJava也不盡相同。
如果說AsyncTask是自行車,RxJava是汽車,則Task是摩托車。
各有各的用途,各有各的靈魂。
五、下載
項目已經上傳到maven和github, 歡迎大家下載 & star
dependencies {
implementation 'com.horizon.task:task:1.0.6'
}