業務爬坑與總結——開屏廣告熱啓動實現方案

本文首發於個人博客:http://t.cn/RijitdDhtml

最初接下開屏廣告熱啓動需求時,對於即將踏入一個什麼樣的深坑,我內心毫無概念。在當時看來,開屏廣告的相關代碼已經基本實現,我只要額外添加熱啓動功能就能夠,即便算上調研設計、後端聯調加上測試的時間,我也只給本身規劃了一週多的時間來完成雙端的需求。java

需求

所謂的開屏廣告熱啓動是指,應用程序進入後臺後(按 Home 鍵或者跳轉到其餘應用),等待一段時間再回到應用時展現開屏廣告。因爲操做系統會定時清理不活躍且佔內存的應用,因此此時展現開屏廣告會讓用戶覺得應用正在從新啓動。因爲對用戶體驗傷害小,甚至不少時候幾乎能夠作到無感知,因此目前不少日活量較高的 app 都實現了開屏廣告熱啓動功能,常見的有微博、頭條等。android

若是不考慮最短間隔時間,天天熱啓動次數上限等附加限制,開屏廣告熱啓動的核心需求其實就在於準確地檢測應用切到後臺再回到前臺的行爲。所謂的準確,指的是不漏掉真正的進入後臺,也不誤把普通操做當作進入後臺。ios

這個需求看上去很是容易,直接調用系統 API 便可完成,而在實際開發的過程當中卻遇到了很多坑,我按照平臺逐一分析一下,不會有太多的實現細節,主要是聊聊設計和實現思路。spring

iOS

先說我最熟悉的,也是相對來講比較容易實現的 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 的方法,方法內部會把根 UIViewrootViewController 設置爲開屏廣告的 ViewController。

如今添加好了相關判斷條件之後,只須要簡單改造一下 app 進入前臺的回調便可,對原來業務的改動相對來講很小:

- (void)applicationWillEnterForeground:(UIApplication *)application {
if ([[HotSplashManager sharedInstance] canShowSplashAd]) {
[self showSplashAD];
}
}
複製代碼

總的來講 iOS 的實現至關簡單, 作好基礎類的封裝,注意判斷一下鎖屏問題就能夠了。

Android

首先 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 語法,就不在這篇業務學習總結裏面多囉嗦了,待我整理一下,另起一篇文章專門討論。

參考文章

  1. Android編程之LocalBroadcastManager源碼詳解
  2. LocalBroadcastManager 的實現原理,仍是 Binder?
  3. LocalBroadcastManager分析
  4. 無入侵的開屏廣告插入方式
  5. How to detect when an Android app goes to the background and come back to the foreground
  6. Android 探索之 BroadcastReceiver 具體使用以及安全性探究

以及其餘閱讀過但沒有記錄下來的優秀文章,感謝前輩們的分享。

相關文章
相關標籤/搜索