先說結(jié)論:在 Oppo熊榛、vivo舒帮、小米等機(jī)型上如果你沒有開啟后臺彈出界面權(quán)限,當(dāng)你的 App 處于后臺時(shí)澡罚,將無法通過 startActivity
方式啟動頁面。
這一權(quán)限在不同型號的手機(jī)中的名稱不同肾请,以下我們統(tǒng)稱為后臺彈出界面權(quán)限留搔。對業(yè)務(wù)背景和問題定位不感興趣的話,可以直接拉到問題解決這一段落铛铁。
一民傻、業(yè)務(wù)背景
我們的 App 中有這樣一個(gè)場景:當(dāng)收到推送或者長連接消息的時(shí)候迫悠,需要啟動一個(gè) Activity 來展示相關(guān)的信息,在 Activity 展示后回復(fù)服務(wù)端 ACK 表示頁面正常展示。
統(tǒng)計(jì)數(shù)據(jù)顯示取具,ACK 與 消息發(fā)送總量的比例只有 80% 左右,產(chǎn)品經(jīng)理不干了:“你這不行啊悯仙,沒法開展業(yè)務(wù)啦囱修,巴拉巴拉...”。
二、問題定位
為了更詳細(xì)的定位問題当船,我們重新梳理了代碼流程题画,對一些關(guān)鍵節(jié)點(diǎn)(推測可能造成異常,數(shù)據(jù)丟失的地方)進(jìn)行埋點(diǎn)生年。結(jié)果:從線上埋點(diǎn)數(shù)據(jù)來看婴程,我們調(diào)用了 startActivity
方法,但是確沒有任何在目標(biāo)頁面 onCreate
方法中的埋點(diǎn)數(shù)據(jù)抱婉。
碰巧這時(shí)候產(chǎn)品同學(xué)找到我:“有一個(gè)新的業(yè)務(wù)也需要在收到長連接消息的時(shí)候展示頁面...”档叔,希望我給他展示一下已有的功能。
這個(gè)簡單啊蒸绩,我把測試機(jī)拿給產(chǎn)品:“你盯著屏幕衙四,我發(fā)一條消息,你就能看見展示的頁面了”患亿。之后我在云平臺上發(fā)了一條長連接消息传蹈,結(jié)果過了半天也沒見有頁面展示,真是尷尬步藕,不過也因此復(fù)現(xiàn)了收到長連接消息卻沒有頁面展示這一問題惦界。
反復(fù)試了幾次發(fā)現(xiàn),當(dāng) App 在前臺可見時(shí)是可以展示頁面的咙冗,但是當(dāng)按下 Home 鍵返回桌面沾歪,App 處于后臺時(shí),收到再多消息也沒有了反應(yīng)雾消。
三灾搏、問題分析
現(xiàn)在有一個(gè)可以明確的點(diǎn)是,在我的測試機(jī)上(vivo Z1)立润,App 處于后臺時(shí)狂窑,收到消息無法展示頁面,說白了就是在后臺無法通過 startActivity
的方式來啟動一個(gè)新頁面
這個(gè)時(shí)候我們需要考慮:
- 該問題和機(jī)型有沒有關(guān)系
- 該問題和 Android 系統(tǒng)版本有沒有關(guān)系
- 該問題是不是只在 App 處于后臺時(shí)發(fā)生
機(jī)型問題
后期通過我們更詳細(xì)的數(shù)據(jù)聚合分析桑腮,發(fā)現(xiàn)此類問題大量出現(xiàn)在 OPPO泉哈,vivo 手機(jī)上,也有少量的小米機(jī)型破讨。
我從測試那里拿了一些主流的機(jī)型和用戶使用比較多的機(jī)型進(jìn)行測試旨巷,發(fā)現(xiàn) OPPO,vivo 的手機(jī)確實(shí)有這個(gè)問題添忘,華為和三星倒沒這個(gè)問題采呐。
Android 系統(tǒng)版本問題
通過數(shù)據(jù)分析發(fā)現(xiàn),發(fā)生此問題的手機(jī) Android 系統(tǒng)版本分布很均勻搁骑,從 Android 6.0 到 Android 9.0 都有發(fā)生(當(dāng)時(shí) Android Q 還沒有推出)斧吐,因此和 Android 系統(tǒng)版本應(yīng)該沒有關(guān)系又固。
App 前后臺問題
在對少量的異常數(shù)據(jù)和重復(fù)消息進(jìn)行過濾后發(fā)現(xiàn),在調(diào)用 startActivity
方法的時(shí)候煤率,App 確實(shí)都處于后臺仰冠。
在測試的過程中發(fā)現(xiàn)有一臺 OPPO 手機(jī)可以正常展示,我們通過對比這臺 OPPO 手機(jī)和其他 OPPO 手機(jī)的各種開關(guān)蝶糯、配置后發(fā)現(xiàn)洋只,在這臺 OPPO 手機(jī)設(shè)置中打開了一個(gè)叫做 xxx 的權(quán)限開關(guān),我們又去查看 vivo 和 小米手機(jī)發(fā)現(xiàn)都有類似的權(quán)限開關(guān)昼捍。
四识虚、問題解決
我對網(wǎng)上提到的一些方法和自己的一些想法進(jìn)行一一驗(yàn)證,測試機(jī)型為:
- OPPO R17
- vivo Z1
- 小米 6
測試代碼
5s 后將啟動 StartFromBackActivity 這個(gè) Activity妒茬,測試的時(shí)候需要手動將 App 切換到后臺担锤。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
fun onDelayStartClick(view: View) {
val intent = Intent(this@MainActivity, StartFromBackActivity::class.java)
view.postDelayed({
Log.d("realxz","startActivity")
startActivity(intent)
}, 5000)
}
}
class StartFromBackActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_start_from_back)
Log.d("realxz", "StartFromBackActivity onCreate")
}
}
前臺 Service 啟動
我試想構(gòu)建一個(gè)前臺 Service,能否繞過這個(gè)限制乍钻,在這個(gè) Service 的 onStartCommand 方法中延遲啟動 Activity:
class ForegroundService : Service() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate() {
super.onCreate()
Log.d("realxz", "onCreate()")
createNotificationChannel(this, "Test", "Test", NotificationManager.IMPORTANCE_HIGH)
val builder =
Notification.Builder(this, "Test").setSmallIcon(R.drawable.ic_launcher_background)
startForeground(1, builder.build())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("realxz", "onStartCommand")
Handler().postDelayed({
startActivity(Intent(this, StartFromBackActivity::class.java))
}, 5000)
return super.onStartCommand(intent, flags, startId)
}
}
通過 adb shell dumpsys activity services com.example.realxz 命令查看當(dāng)我們的 Service 確實(shí)是一個(gè)前臺 Service
但是頁面沒有啟動成功肛循,這個(gè)方案 pass 掉,通過 logcat 日志可以發(fā)現(xiàn)我們啟動頁面的行為被系統(tǒng)攔截了:
PendingIntent 啟動
通過 PendingIntent 的 send()
方法來執(zhí)行相關(guān)操作
/**
* Perform the operation associated with this PendingIntent.
*
* @see #send(Context, int, Intent, android.app.PendingIntent.OnFinished, Handler)
*
* @throws CanceledException Throws CanceledException if the PendingIntent
* is no longer allowing more intents to be sent through it.
*/
public void send() throws CanceledException {
send(null, 0, null, null, null, null, null);
}
簡單的通過一個(gè)點(diǎn)擊事件來延遲發(fā)送一個(gè) PendingIntent:
fun onPendingClick(view: View) {
view.postDelayed({
val intent = Intent(this, StartFromBackActivity::class.java)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, 0)
pendingIntent.send()
Log.e("realxz","pendingIntent.send()")
}, 10 * 1000)
}
此方案在 OPPO R17 上可行银择,但是在 vivo Z1 上失敗了多糠,logcat 日志顯示啟動 StartFromBackActivity 是不允許的,原因是 App 在 fobid 這個(gè)列表中浩考,這應(yīng)該是禁止后臺啟動的應(yīng)用列表夹孔。
播放音頻
以前做保活的時(shí)候怀挠,我們嘗試通過播放一段無聲的音頻析蝴,期望能提高 App 進(jìn)程的優(yōu)先級害捕,我們來嘗試一下這么操作對于啟動 Activity 有沒有幫助
fun onMusicClick(view: View) {
val mediaPlayer = MediaPlayer.create(this, R.raw.meglive_mouth_open)
mediaPlayer.isLooping = true
mediaPlayer.start()
view.postDelayed({
val intent = Intent(this, StartFromBackActivity::class.java)
startActivity(intent)
Log.e("realxz", "onMusicClick startActivity")
}, 10 * 1000)
}
此方案同樣在 OPPO R17 上可行绿淋,vivo Z1 上仍然不行,logcat 日志與之前相同尝盼⊥讨停看上去 OPPO 的限制要小一點(diǎn),而 vivo 的限制更嚴(yán)格一點(diǎn)盾沫。
嘗試獲取系統(tǒng)權(quán)限
我們換了一種想法裁赠,能否通過 Hack 的方式來修改手機(jī)的權(quán)限設(shè)置(vivo、小米等廠商并沒有提供獲取相關(guān)權(quán)限的 API)赴精,上網(wǎng)搜了一下佩捞,發(fā)現(xiàn)有人研究過這個(gè)問題,以 vivo Z1 為例:
獲取 vivo 系統(tǒng)權(quán)限設(shè)置的 APK
打開手機(jī)到具體的權(quán)限設(shè)置頁面蕾哟,通過 adb 命令一忱,adb shell dumpsys activity top
來獲取當(dāng)前棧頂 Activity 的包名相關(guān)信息莲蜘,如圖可知 vivo Z1 這款手機(jī)的權(quán)限管理的包名為 PermissionManager。
然后通過 Android Studio 的 Device File Explorer 工具來打開 PermissionManager 路徑帘营,將需要的 apk票渠、vdex、odex 文件拷貝出來芬迄。
通過 jadx-gui 打開 apk 文件
按照文章中所說问顷,打開 apk 的清單文件,可以找到如下的權(quán)限定義和 Provider 聲明禀梳。
<permission android:label="provider write pomission"
android:name="com.vivo.permissionmanager.provider.write"
android:protectionLevel="signatureOrSystem"/>
<provider android:name=".provider.PermissionProvider"
android:writePermission="com.vivo.permissionmanager.provider.write"
android:exported="true"
android:authorities="com.vivo.permissionmanager.provider.permission"/>
可以看見杜窄,只有系統(tǒng)應(yīng)用或者和系統(tǒng)應(yīng)用有相同簽名的應(yīng)用,才能夠有寫入數(shù)據(jù)的權(quán)限出皇,到這里基本上可以確定這個(gè)方案 GG 了羞芍。
通過 jadx-gui 打開 dex 文件
Android 破解vivo手機(jī)權(quán)限管理 這篇文章的作者在 Github 上提供了相關(guān)代碼來進(jìn)行測試,我對代碼進(jìn)行簡單的修改郊艘,來測試我們需要讀取的權(quán)限
public static int getVivoApplistPermissionStatus(Context context) {
Uri uri2 = Uri.parse("content://com.vivo.permissionmanager.provider.permission/start_bg_activity");
try {
Cursor cursor = context.getContentResolver().query(uri2, null, null, null, null);
if (cursor != null) {
cursor.moveToFirst();
while (cursor.moveToNext()) {
String pkgName = cursor.getString(cursor.getColumnIndex("pkgname"));
String currentState = cursor.getString(cursor.getColumnIndex("currentstate"));
Log.e("realxz", "----------------" + "\n");
Log.e("realxz", "pkg name is " + pkgName);
Log.e("realxz", "current state is " + currentState);
}
}
} catch (Throwable throwable) {
throwable.printStackTrace();
}
return -1;
}
通過日志可以看到荷科,我們 App 的 state 為 1,這個(gè)時(shí)候后臺啟動 App 權(quán)限為關(guān)閉狀態(tài)纱注,手動打開權(quán)限后畏浆,這個(gè) state 會變?yōu)?0。
如果我們嘗試去修改 Provider 的內(nèi)容時(shí):
Uri uri2 = Uri.parse("content://com.vivo.permissionmanager.provider.permission/start_bg_activity");
ContentValues contentValues = new ContentValues();
contentValues.put("currentstate", 0);
context.getContentResolver().update(uri2, contentValues, "pkgname=?", new String[]{"com.example.realxz.startfromback"});
可以在 logcat 中看到以下崩潰信息:
2019-12-01 17:20:11.641 5050-5068/? E/DatabaseUtils: Writing exception to parcel
java.lang.SecurityException: Permission Denial: writing com.vivo.permissionmanager.provider.PermissionProvider uri content://com.vivo.permissionmanager.provider.permission/start_bg_activity from pid=20117, uid=10299 requires com.vivo.permissionmanager.provider.write, or grantUriPermission()
at android.content.ContentProvider.enforceWritePermissionInner(ContentProvider.java:851)
at android.content.ContentProvider$Transport.enforceWritePermission(ContentProvider.java:593)
at android.content.ContentProvider$Transport.update(ContentProvider.java:390)
at android.content.ContentProviderNative.onTransact(ContentProviderNative.java:211)
at android.os.Binder.execTransact(Binder.java:708)
那么這個(gè)方案到這也就被 Pass 了狞贱。
五刻获、臨時(shí)方案
以上的方案全部以失敗告終,這時(shí)我們已經(jīng)準(zhǔn)備和產(chǎn)品商量改變業(yè)務(wù)模式來避免這個(gè)問題瞎嬉,這個(gè)時(shí)候我們有了一個(gè)新的想法蝎毡,既然在后臺無法啟動 App,那有沒有辦法將 App 移動或者說切換到前臺呢氧枣?
判斷應(yīng)用是否在前臺
private fun isAppRunningForeground(context: Context): Boolean {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val runningAppProcessList = activityManager.runningAppProcesses ?: return false
Log.e("realxz", "running app process list size is ${runningAppProcessList.size}")
runningAppProcessList.forEach {
Log.e(
"realxz",
"running app process name is ${it.processName} and importance is ${it.importance}"
)
if (it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
&& it.processName == context.applicationInfo.processName
) {
return true
}
}
return false
}
ActivityManager 的 getRunningAppProcesses 方法沐兵,會返回一個(gè)在當(dāng)前設(shè)備上運(yùn)行的應(yīng)用進(jìn)程列表,或者返回 null 而不會返回一個(gè) Empty List便监,經(jīng)測試發(fā)現(xiàn)扎谎,此方法僅能獲取自己的 App 信息
-
通過比對進(jìn)程的優(yōu)先級,來判斷 App 是否運(yùn)行在前臺烧董,importance 是一個(gè)枚舉值毁靶,定義了我們 App 是在前臺運(yùn)行,或是在后臺運(yùn)行逊移,又或是有前臺 Service 在運(yùn)行:
@IntDef(prefix = { "IMPORTANCE_" }, value = { IMPORTANCE_FOREGROUND, IMPORTANCE_FOREGROUND_SERVICE, IMPORTANCE_TOP_SLEEPING, IMPORTANCE_VISIBLE, IMPORTANCE_PERCEPTIBLE, IMPORTANCE_CANT_SAVE_STATE, IMPORTANCE_SERVICE, IMPORTANCE_CACHED, IMPORTANCE_GONE, }) @Retention(RetentionPolicy.SOURCE) public @interface Importance {}
將應(yīng)用切換至前臺
private fun moveAppToFront(context: Context) {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val runningTasks = activityManager.getRunningTasks(100)
for (taskInfo in runningTasks) {
if (taskInfo.topActivity!!.packageName == context.packageName) {
activityManager.moveTaskToFront(taskInfo.id, 0)
break
}
}
}
- ActivityManager 的 getRunningTasks 方法雖然已標(biāo)注為 Deprecated预吆,但是仍能返回調(diào)用者自己,也就是我們自己 App 的 Task 信息
- 然后調(diào)用 moveTaskToFront 方法胳泉,將我們 Task 移動到棧頂拐叉,按照方法的注釋所說 “Ask that the task associated with a given task ID be moved to the front of the stack, so it is now visible to the user.” 這樣做我們的 App 就可以對用戶可見了
檢查消息
經(jīng)過上面的操作:如果應(yīng)用在前臺觅够,那么我們可以直接啟動 Activity,如果應(yīng)用不再前臺巷嚣,我們可以通過 ActivityManager 提供的方法將 App 移動到前臺喘先。
在這之后我們有兩種方式來啟動頁面
- 在基類的 onResume 方法中,來編寫讀取有效消息廷粒,并啟動頁面的邏輯
- 輪詢檢測窘拯,在收到消息后采用輪訓(xùn)的方式來將 App 切換到前臺,并啟動頁面
我采用的是輪訓(xùn)的方式:
@SuppressLint("CheckResult")
fun onForegroundClick(view: View) {
Observable.intervalRange(1, 3, 3, 3, TimeUnit.SECONDS)
.subscribe(object : Observer<Long> {
lateinit var disposable: Disposable
override fun onSubscribe(d: Disposable) {
disposable = d
}
override fun onNext(t: Long) {
Log.e("realxz", "interval long value is $t")
val isRunningForeground = isAppRunningForeground(this@MainActivity)
if (isRunningForeground) {
disposable.dispose()
// todo 讀取緩存數(shù)據(jù)坝茎,并啟動頁面
} else {
moveAppToFront(this@MainActivity)
}
}
override fun onComplete() {
}
override fun onError(e: Throwable) {
}
})
}
這種方式通過了我手中所有的測試機(jī)的測試涤姊,有個(gè)小問題是,Vivo 手機(jī)調(diào)用一次 moveAppToFront 方法就可以切換到前臺嗤放,Oppo R17 的表現(xiàn)不太固定思喊,有時(shí)候可能需要調(diào)用三次。
在我們的項(xiàng)目中次酌,我配置的啟動次數(shù)是 3 次恨课。文章開頭所說 “ACK 與 消息發(fā)送總量的比例只有 80% 左右”,這一比例在采用這種方式后上升到了 97% 左右岳服。
六剂公、Android Q
以上所有方式在 Android Q 均失效,Google 在 Android Q 中增加了從后臺啟動 Activity 的限制吊宋,無法訪問的話纲辽,可能需要科學(xué)上網(wǎng)”。
目前我們的后臺統(tǒng)計(jì)還沒有發(fā)現(xiàn)使用 Android Q 設(shè)備的用戶(用戶群體比較特殊)璃搜,但不可避免的隨著時(shí)間的推移拖吼,越來越多的用戶更新自己的設(shè)備,這一問題會徹底暴露这吻,看樣子只能通過其他的表現(xiàn)形式來實(shí)現(xiàn)這一功能了吊档。
不知道大家是否用這種強(qiáng)制提醒的業(yè)務(wù)需求,在 Android Q 下又是怎么適配或?qū)崿F(xiàn)的呢橘原?