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

bestswifter發表於2019-03-04

本文首發於我的部落格:http://t.cn/RijitdD

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

需求

所謂的開屏廣告熱啟動是指,應用程式進入後臺後(按 Home 鍵或者跳轉到其他應用),等待一段時間再回到應用時展示開屏廣告。由於作業系統會定時清理不活躍且佔記憶體的應用,所以此時展示開屏廣告會讓使用者以為應用正在重新啟動。由於對使用者體驗傷害小,甚至很多時候幾乎可以做到無感知,所以目前很多日活量較高的 app 都實現了開屏廣告熱啟動功能,常見的有微博、頭條等。

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

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

iOS

先說我最熟悉的,也是相對來說比較容易實現的 iOS 平臺。

在實現開屏廣告需求時,從設計角度來考慮,由於 application:didFinishLaunchingWithOptions: 函式執行結束後會自動傳送通知,所以我們只需要監聽 UIApplicationDidFinishLaunchingNotification 通知即可。在展示廣告時,可以使用 UIView 直接蓋上一張圖片。不過考慮到有倒數計時按鈕,跳過按鈕,以及將來有可能支援除了圖片以外的其他格式(比如 VR 視訊),所以使用 UIViewController 雖然麻煩些,但也不失為一種穩妥的,方便後續擴充維護的做法。

具體做法就不詳細描述了,感興趣的讀者可以參考 無入侵的開屏廣告插入方式

前文說過熱啟動需要滿足一定條件,比如進入後臺和再次回到前臺的時間間隔必須大於某個值,否則回到桌面後快速返回應用也會出現開屏廣告,帶給使用者的體驗很差。並且這個值最好是做成伺服器動態下發,好處是一旦開屏廣告的邏輯出現問題,可以把間隔時間設為非常大的值,從而關閉此功能。同樣是出於使用者體驗考慮,每天開屏廣告熱啟動的次數也需要做限制,超出預設次數以後不再展示。

為了管理以上邏輯,並且與原有開屏廣告邏輯有效解耦,單獨抽離一個 HotSplashManager 類就顯得很有必要。由於應用的整個生命週期內都有可能展示開屏廣告,所以可以考慮設計為單例模式,並且對外統一暴露一個 - (BOOL)canShowHotSplashAdvertisement 方法。

不過由於目前專案中沒有使用通知,而是與 application:didFinishLaunchingWithOptions: 方法強耦合。所以我接手以後的思路也是沿用前人的程式碼,主要是在 applicationDidEnterBackground 函式中通知 HotSplashManager 類應用進入後臺。

鎖屏檢測

這裡的第一個小坑在於鎖屏同樣會觸發 applicationDidEnterBackground 函式,而從邏輯上講,應用鎖屏後再解鎖並不應該被認為是一種前後臺切換,而如果已經按 Home 鍵進入後臺,這時候再鎖屏/解鎖就不應該影響 App 進入了後臺再切回前臺的事實,也就是不影響開屏廣告的正常展示,這裡的邏輯比較繞,需要整理一下邏輯並仔細測試。

檢測鎖屏和解鎖的方法有好幾種, 其中有的方法不能完全相容 iOS 9、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 具體使用以及安全性探究

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

相關文章