本文首發於個人博客:http://t.cn/RijitdDhtml
最初接下開屏廣告熱啓動需求時,對於即將踏入一個什麼樣的深坑,我內心毫無概念。在當時看來,開屏廣告的相關代碼已經基本實現,我只要額外添加熱啓動功能就能夠,即便算上調研設計、後端聯調加上測試的時間,我也只給本身規劃了一週多的時間來完成雙端的需求。java
所謂的開屏廣告熱啓動是指,應用程序進入後臺後(按 Home 鍵或者跳轉到其餘應用),等待一段時間再回到應用時展現開屏廣告。因爲操做系統會定時清理不活躍且佔內存的應用,因此此時展現開屏廣告會讓用戶覺得應用正在從新啓動。因爲對用戶體驗傷害小,甚至不少時候幾乎能夠作到無感知,因此目前不少日活量較高的 app 都實現了開屏廣告熱啓動功能,常見的有微博、頭條等。android
若是不考慮最短間隔時間,天天熱啓動次數上限等附加限制,開屏廣告熱啓動的核心需求其實就在於準確地檢測應用切到後臺再回到前臺的行爲。所謂的準確,指的是不漏掉真正的進入後臺,也不誤把普通操做當作進入後臺。ios
這個需求看上去很是容易,直接調用系統 API 便可完成,而在實際開發的過程當中卻遇到了很多坑,我按照平臺逐一分析一下,不會有太多的實現細節,主要是聊聊設計和實現思路。spring
先說我最熟悉的,也是相對來講比較容易實現的 iOS 平臺。編程
在實現開屏廣告需求時,從設計角度來考慮,因爲 application:didFinishLaunchingWithOptions:
函數執行結束後會自動發送通知,因此咱們只須要監聽 UIApplicationDidFinishLaunchingNotification
通知便可。在展現廣告時,可使用 UIView
直接蓋上一張圖片。不過考慮到有倒計時按鈕,跳過按鈕,以及未來有可能支持除了圖片之外的其餘格式(好比 VR 視頻),因此使用 UIViewController
雖然麻煩些,但也不失爲一種穩妥的,方便後續拓展維護的作法。swift
具體作法就不詳細描述了,感興趣的讀者能夠參考 無入侵的開屏廣告插入方式。後端
前文說過熱啓動須要知足必定條件,好比進入後臺和再次回到前臺的時間間隔必須大於某個值,不然回到桌面後快速返回應用也會出現開屏廣告,帶給用戶的體驗不好。而且這個值最好是作成服務器動態下發,好處是一旦開屏廣告的邏輯出現問題,能夠把間隔時間設爲很是大的值,從而關閉此功能。一樣是出於用戶體驗考慮,天天開屏廣告熱啓動的次數也須要作限制,超出預設次數之後再也不展現。緩存
爲了管理以上邏輯,而且與原有開屏廣告邏輯有效解耦,單獨抽離一個 HotSplashManager
類就顯得頗有必要。因爲應用的整個生命週期內都有可能展現開屏廣告,因此能夠考慮設計爲單例模式,而且對外統一暴露一個 - (BOOL)canShowHotSplashAdvertisement
方法。安全
不過因爲目前項目中沒有使用通知,而是與 application:didFinishLaunchingWithOptions:
方法強耦合。因此我接手之後的思路也是沿用前人的代碼,主要是在 applicationDidEnterBackground
函數中通知 HotSplashManager
類應用進入後臺。
這裏的第一個小坑在於鎖屏一樣會觸發 applicationDidEnterBackground
函數,而從邏輯上講,應用鎖屏後再解鎖並不該該被認爲是一種先後臺切換,而若是已經按 Home 鍵進入後臺,這時候再鎖屏/解鎖就不該該影響 App 進入了後臺再切回前臺的事實,也就是不影響開屏廣告的正常展現,這裏的邏輯比較繞,須要整理一下邏輯並仔細測試。
檢測鎖屏和解鎖的方法有好幾種, 其中有的方法不能徹底兼容 iOS 九、10 兩大主流版本。最終找到的有效方案是利用 Darwin 層面的通知:
// 檢測鎖屏和解鎖
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), //center
NULL, // observer
displayStatusChanged,
CFSTR("com.apple.springboard.lockstate"),
NULL, // object
CFNotificationSuspensionBehaviorDeliverImmediately);
// 接受通知後的處理
static void displayStatusChanged(CFNotificationCenterRef center,
void *observer,
CFStringRef name,
const void *object,
CFDictionaryRef userInfo) {
// 每次鎖屏和解鎖都會發這個通知,第一次是鎖屏,第二次是解鎖,交替進行
[[HotSplashManager sharedInstance] lockStateChanged];
}
複製代碼
若是不是個人使用方式有誤,那麼理論上來講是拿不到準確的鎖屏 or 解鎖狀態的,只能知道每次解鎖或者鎖屏都會觸發這個通知,而且第一次必定是鎖屏,日後依次交替,因此要在本身的 HotSplashManager
中管理好屏幕狀態。
天天展現次數有上限就意味着展現次數必須被持久化保存在本地,這能夠理解爲一種特殊的緩存:「僅在一個天然日內有效,跨日自動清空」。考慮到這樣的需求並非開屏廣告這個業務獨有,因此不妨抽取成一個基礎類: XXXDailyCache
,而且給它一個 namespace
的概念來針對不一樣業務作隔離。
須要強調的是,雖然不少項目都會實現本身的基礎緩存類 XXXCache
,這裏我強烈反對使用繼承模式,感興趣的讀者能夠參考我以前的文章: 從 Swift 的面向協議編程說開去 一文的倒數第二節: 「繼承與組合」,說的就是這種很是常見的誤用繼承關係的場景。因此這裏正確的作法是使用組合模式,用 namespace
去建立基礎的 XXXCache
類實現緩存功能,而 DailyCache
則持有緩存對象而且實現按天然日刪除的邏輯。
按天然日區分的邏輯很簡單, 只要把緩存的 Key 設置爲當前日期,而後每次讀取以前先判斷日期便可。這是比較簡單的體力活,就很少費口舌了。封裝得好的話,只會對外暴露三個簡潔方法:
@interface XXXDailyCache : NSObject
- (id)initWithNameSpace:(NSString *)namespace;
- (id)getValueWithKey(NSString *)key;
- (void)writeWithKey:(NSString *)key value:(id)value;
@end
複製代碼
以前的同事已經實現了開屏廣告功能,他們提供了一個 showSplashAD
的方法,方法內部會把根 UIView
的 rootViewController
設置爲開屏廣告的 ViewController。
如今添加好了相關判斷條件之後,只須要簡單改造一下 app 進入前臺的回調便可,對原來業務的改動相對來講很小:
- (void)applicationWillEnterForeground:(UIApplication *)application {
if ([[HotSplashManager sharedInstance] canShowSplashAd]) {
[self showSplashAD];
}
}
複製代碼
總的來講 iOS 的實現至關簡單, 作好基礎類的封裝,注意判斷一下鎖屏問題就能夠了。
首先 iOS 存在的問題安卓都有,因此一樣須要封裝天然日失效的 DailyCache
,處理鎖屏邏輯則是使用了通知機制,監聽系統的通知。
爲了可複用性,咱們能夠封裝出一個單獨的類來監聽鎖屏通知,並記錄當前狀態,以便未來能夠對外提供相應的服務:
public class ScreenLockReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
boolean isScreenOff = false;
if (intent.getAction().equals(Intent.ACTION_SCREEN_OFF)) {
isScreenOff = true;
} else if (intent.getAction().equals(Intent.ACTION_SCREEN_ON)) {
isScreenOff = false;
}
}
}
複製代碼
而後實例化這個 ScreenLockReceiver
併爲它添加好過濾:
ScreenLockReceiver screenLockReceiver = new ScreenLockReceiver();
IntentFilter lockFilter = new IntentFilter();
lockFilter.addAction(Intent.ACTION_SCREEN_ON);
lockFilter.addAction(Intent.ACTION_SCREEN_OFF);
lockFilter.addAction(Intent.ACTION_USER_PRESENT);
registerReceiver(screenLockReceiver, lockFilter);
複製代碼
因爲安卓沒有提供系統級別的先後臺切換通知,因此不得不本身手動實現。第一種思路是實現 onTrimMemory
函數:
@Override
public void onTrimMemory(final int level) {
if (level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
// Get called every-time when application went to background.
}
}
複製代碼
它的原理來源於官網對於 onTrimMemory
的解釋,當 level 的值是 TRIM_MEMORY_UI_HIDDEN
時,按照文檔的解釋是應用程序進入後臺,須要釋放 UI 資源。基於這種思路的先後臺切換檢測在 Stack Overflow 上獲得了很是多的贊同。然而根據咱們的測試,在某些高端機型上,即便應用程序進入後臺,因爲內存相對充足,並不會觸發上述方法。
考慮到官方文檔沒有明確說明進入後臺時必定會調用 onTrimMemory
方法, 不少時候是開發者本身的總結,咱們最終放棄了這種實現思路。
實際上還有一種最老土,也相對來講最準確的判斷方法。應用進入後臺時,Activity 會調用 onPause
方法,回到前臺又會調用 onResume
方法。雖然在切換 Activity 時也會走這樣的流程,可是兩個方法的調用時間間隔很是短,即便考慮到低端機的性能問題, 兩秒鐘也足夠完成一次頁面跳轉了。因此只須要記錄 onPause
的時間戳,再拿到 onResume
的時間戳,二者作差比較便可。
若是以前的應用封裝的好的話,應該會有一個繼承自系統的 Activity
的子類,好比叫作 BaseActivity
。顯然以上邏輯應該在這個 BaseActivity
裏完成, 不過一個應用中並不必定全部的視圖都繼承自這個 BaseActivity
,咱們還有可能使用 FragmentActivity
及其子類,因此在對應的 BaseFragmentActivity
中也要添加相似的邏輯。
與 iOS 不一樣的是,進入前臺事件的直接處理邏輯應該寫在 HotSplashManager
類中,而非 iOS 的 Appdelegate
,喚起開屏廣告的方式也略有不一樣。在 HotSplashManager
中咱們能夠直接拿到當前展現的 activity(BaseActivity
把本身傳過來),而後調用它的 startActivity
方法就能夠喚起開屏廣告所在的 Activity 了,注意關掉動畫效果。
開屏廣告的 SplashActivity
也須要對喚起方式作區分,判斷本身是冷啓動展現仍是熱啓動展現。若是是熱啓動展現,不須要涉及後續的引導頁流程,而是直接調用 this.finish()
便可。
以上功能完成之後,基本上開屏廣告熱啓動的需求就算開發完了,直到測試時有用戶反饋全屏查看圖片時大機率也會展現開屏廣告。通過排查後發現,咱們的應用中諸如查看圖片、打開網頁等操做都會放到其餘進程中完成,從而避免與主進程爭奪內存,致使 OOM。
多進程場景下會有多個 Application 和 Activity 實例在同時運行。在主進程切換到子進程的過程當中,實際上調用到的是主進程的 onPause
和子進程的 onResume
,子進程回到主進程時調用的則是子進程的 onPause
和主進程的 onResume
。
不難看出對於主進程而言,onPause
和下一次 onResume
以前的時間間隔至少是在子進程中停留的時間。因此容易出現先後臺切換的誤報。
解決這個問題有多個思路,但任何基於 Application 類,利用內存存儲數據的作法均不可能實現,應該避免在這種思路上浪費沒必要要的時間。首先能夠考慮 AIDL、Binder 等多進程通訊模型,不過網上搜了一圈,廣泛實現起來比較囉嗦,並且實際上我這裏的需求並非通訊,而是傳遞一個很是小的數據,表示 App 是否進入子進程,因此這些方案首先排除。
因爲沒有找到合適的跨進程內存共享方案,因此接下來考慮的是文件共享的方式,表明技術有 ContentProvider。不過 ContentProvider 其實是對下層具體文件讀寫實現方案的抽象封裝,提供了一套 CURD 接口。也就是說我還得本身實現文件的讀寫。考慮到實現成本過大,而需求比較簡單,也排除了這種方案。
最後考慮到通知,先調研了 LocalBroadcastManager
這種本地通知,看了一下源碼之後發現不適用於跨進程通訊,內部實際上是利用 context
參數拿到了 ApplicationContext
,而後實現了簡單的觀察者模式。感興趣的讀者能夠閱讀文章末尾的參考資料。
最終的解決方案是選擇了普通的 BroadcastManager
,注意添加 filter 過濾一下,以及設置好包名,限制廣播的接收者。
Intent intent = new Intent(BROADCAST_KEY);
intent.setPackage(getPackageName());
intent.putExtra("flag", false); // 通知主進程 application: "已經進入子進程"
sendBroadcast(intent);
複製代碼
開發過程當中的坑遠遠不止上面列出的這些,好比還順手解決了一個弱引用過早釋放的 bug 和一個內存泄漏的問題。此外,在開發的過程當中對 context 概念還比較模糊,Java 閉包對捕獲的變量的處理也挺有意思,不過考慮到大部分都是 Java 語法,就不在這篇業務學習總結裏面多囉嗦了,待我整理一下,另起一篇文章專門討論。
以及其餘閱讀過但沒有記錄下來的優秀文章,感謝前輩們的分享。