Coding Story

在黑暗中寫故事 👻

0%

Android 特效:RemoteViews 的應用和滿滿的坑

前言故事

如何讓系統 UI 畫出客製化的 View,是小唯團隊最近一直在實驗的玩意。要在 Android 上面做到,就要使用 RemoteViews,通常第一次接觸到的開發者,都是經過 Google 搜尋如何實作某樣某樣功能,才間接找到 RemoteViews,然後就開始照著搜尋結果去做,接著就遇到許許多多問題、踩了很多坑,然後就是更多的 Google 搜尋,不斷的循環。小唯的團隊也是這樣走過來,所以特地把一些經驗記錄下來。

客製化通知訊息

小唯一開始也是為了找如何實作「客製化推播訊息的 UI 樣式」,所以找到了 RemoteViews,目標是在收到推播訊息後,顯示一個接受或拒絕來電的通知。照著搜尋結果,小唯組出了預期的畫面:

如圖片所示,點選 [Answer] 可以接通來電,[Decline] 則是掛斷來電,兩個按鈕都可直接運作,而不用透過另一個來電介面去做接通或掛斷,這就是一個典型客製化通知訊息的一個例子,要做這種效果的通知,需要準備幾樣東西:

1. 通知訊息頻道

首先,需要一個用來發送通知訊息的頻道(Notification Channel)。從 Android 8 開始,所有通知訊息都要有一個對應的發送頻道,這些頻道會出現在系統設定中的 [App & notifications] 內,各別的 App 通知設定中:

透過這介面,使用者可以個別去開關某個通知訊息,應該是說可以有限度地去控制自己如何「被」訊息通知。但小唯覺得,這設計雖然立意良好,但是一來會落實使用的開發者不多(誰家的企劃規格會寫到這個?),二是知道如何進入調整的使用者那真是少之又少,兩個比率相乘下,造成了這個設計實際上不太具有太大意義。

2. 準備一個 RemoteViews

它可以透過一般 inflate XML 的方式來建立出來,「大多數」規則如同一般在 App 中使用 View 一致,注意這裡寫大多數,表示其中有先不一樣的限制,例如:

一號坑!不是所有 Android View/ViewGroup 都可以使用在 RemoteViews 內!


使用不支援的 View/ViewGroup,通知會跳不出來,但沒有強制關閉提示,需要看背景 log 才知道發生什麼事 😆。>> 支援的 View/ViewGroup 清單可以看這 <<

二號坑!XML layout 中不能使用 App theme 的參數!


如果使用到 App theme 中的參數(e.g. background 引用到 theme 內容 android:background="?attr/colorPrimary"),通知也是會跳不出來,但沒有強制關閉提示,看背景 log 會發現報錯說 view 無法被建立,如下:

1
2
3
4
5
6
E StatusBar: android.view.InflateException: Binary XML file line #2: Binary XML file line #2: Error inflating class android.widget.RelativeLayout
E StatusBar: Caused by: android.view.InflateException: Binary XML file line #2: Error inflating class android.widget.RelativeLayout
E StatusBar: Caused by: java.lang.reflect.InvocationTargetException
E StatusBar: at java.lang.reflect.Constructor.newInstance0(Native Method)
E StatusBar: at java.lang.reflect.Constructor.newInstance(Constructor.java:343)


這是可預期的,因為其他 process 無從得知 App theme 的設定為何。雖然不能用 theme,但是一般的 resources_name-qualifier 仍是可以使用(也是合理可預期,畢竟我們還是要用 qualifier 來設定寬高等的設定)。這也間接地讓小唯遇到了第三個坑:

三號坑!即使的 App 不支援黑暗模式,我們還是要為 RemoteViews 準備黑暗模式。


既然跑在他人的 process 中,按照人家的規矩來顯示呈現也是應該的,設定 -night 資源來依照目前的模式來呈現,才不會造成在黑暗模式中顯示白底或一般模式中顯示黑底的尷尬窘境,像下面這張畫面,黑色的客製化訊息在白色佈景上:


以下是 XML 全文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/custom_notification_background"
android:padding="16dp">

<ImageView
android:id="@+id/icon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_alignParentTop="true"
android:layout_alignParentStart="true" />

<ImageView
android:id="@+id/avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true" />

<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:fontFamily="sans-serif"
android:maxLines="1"
android:text=""
android:textColor="@color/custom_notification_text"
android:textSize="14sp"
android:layout_alignParentTop="true"
android:layout_toEndOf="@id/icon"
android:layout_toStartOf="@id/avatar"/>

<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:fontFamily="sans-serif"
android:maxLines="1"
android:text=""
android:textColor="@color/custom_notification_text"
android:textSize="14sp"
android:layout_below="@id/title"
android:layout_toEndOf="@id/icon"
android:layout_alignParentStart="true" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal"
android:layout_below="@id/text"
android:layout_alignParentStart="true"
android:layout_alignParentEnd="true">

<TextView
android:id="@+id/button_decline"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:background="@drawable/bg_decline"
android:fontFamily="sans-serif"
android:gravity="center"
android:text="Decline"
android:textColor="@color/white"
android:textSize="14sp" />

<TextView
android:id="@+id/button_accept"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/bg_accept"
android:fontFamily="sans-serif"
android:gravity="center"
android:text="Answer"
android:textColor="@color/white"
android:textSize="14sp" />
</LinearLayout>
</RelativeLayout>

3. 準備按鈕行為

透過客製化的通知訊息,使用者有三個動作可以執行,點選 [Answer]、點選 [Decline]、或是點選訊息本身,所以小唯這三個動作準備了三個 PendingIntent,已正式線上產品來看,三個動作應該分別對應到接通電話、掛斷電話、和進入來電介面,讓使用者看更多訊息,再決定是否要接通電話。以下是小唯建立通知訊息的程式碼與每個步驟對應註解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
fun showCustomHeadsUpNotification(context: Context, message: String) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

// 1. 為 Android 8+ 準備通知頻道
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
HEADS_UP_CHANNEL_ID,
HEADS_UP_CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
)

// 設定通知頻道
channel.enableLights(true)
channel.enableVibration(true)
notificationManager.createNotificationChannel(channel)
}

// 2. 準備一個 RemoteViews,之後用在 setCustomHeadsUpContentView
val headsUpRemoteView = RemoteViews(context.packageName, R.layout.heads_up_notification).apply RemoteViews@ {

// 3. 設定 RemoteView 上面顯示的資訊
setImageViewResource(R.id.icon, R.drawable.ic_round_call_24)
setImageViewResource(R.id.avatar, R.drawable.mom_avatar)
setTextViewText(R.id.title, context.getString(R.string.app_name))
setTextViewText(R.id.text, message)

// 3.1 點選 [Answer] 按鈕後,要執行的動作
Intent().apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
// 展示 PendingIntent 用!先導去 MainActivity 並顯示指定訊息
setClass(context, MainActivity::class.java)
putExtra(SNACK_BAR_MESSAGE, "Answer call clicked")
putExtra(TAG_NOTIFICATION_ID, HEADS_UP_NOTIFICATION_ID)
// 為 [Answer] 按鈕設定 PendingIntent
this@RemoteViews.setOnClickPendingIntent(
R.id.button_accept,
PendingIntent.getActivity(context, 5001, this, PendingIntent.FLAG_UPDATE_CURRENT)
)
}

// 3.2 點選 [Decline] 按鈕後,要執行的動作
Intent().apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
// 展示 PendingIntent 用!先導去 MainActivity 並顯示指定訊息
setClass(context, MainActivity::class.java)
putExtra(SNACK_BAR_MESSAGE, "Decline call clicked")
putExtra(TAG_NOTIFICATION_ID, HEADS_UP_NOTIFICATION_ID)
// 為 [Decline] 按鈕設定 PendingIntent
this@RemoteViews.setOnClickPendingIntent(
R.id.button_decline,
PendingIntent.getActivity(context, 5002, this, PendingIntent.FLAG_UPDATE_CURRENT)
)
}
}

// 3.3 點選 notification 本身後,要執行的動作
val pendingIntent = Intent().let {
it.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
it.setClass(context, MainActivity::class.java)
it.putExtra(SNACK_BAR_MESSAGE, "Notification clicked")
it.putExtra(TAG_NOTIFICATION_ID, HEADS_UP_NOTIFICATION_ID)
PendingIntent.getActivity(context, 5003, it, PendingIntent.FLAG_UPDATE_CURRENT)
}

// 4. 設定通知訊息(這邊注意 setStyle 樣式,某些樣式會截掉 RemoteView)
val notification = NotificationCompat.Builder(context, HEADS_UP_CHANNEL_ID).let {
it.setStyle(NotificationCompat.BigTextStyle())
NotificationCompat.Style
it.priority = NotificationCompat.PRIORITY_HIGH
it.color = ContextCompat.getColor(context, R.color.custom_notification_text)
it.setSmallIcon(R.drawable.ic_round_call_24)
it.setContentTitle(message)
it.setContentText("Tap to enter the dialing screen...")
it.setCategory(NotificationCompat.CATEGORY_CALL)
it.setDefaults(NotificationCompat.DEFAULT_ALL)
it.setVibrate(longArrayOf(1000, 1000))
it.setOngoing(true)
it.setAutoCancel(false)
it.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
it.setCustomHeadsUpContentView(headsUpRemoteView)
it.setFullScreenIntent(pendingIntent, true)
it.setContentIntent(pendingIntent)
it.build()
}

// 5. 一切就緒,發送通知
notificationManager.notify(
HEADS_UP_NOTIFICATION_ID,
notification
)
}

4. 設定通知訊息

有了 RemoteViews 和 PendingIntent 定義的動作後,就可以用 NotificationCompat.Builder 來把一切都接起來,這邊要注意使用的 style:

四號坑!慎選 style 樣式,要不然 RemoteViews 畫面很可能被截掉 😭


小唯這邊選擇的是 BigTextStyle,因為在眾多現成 Style 中,只有這個剛好適用,又不會截掉 RemoteViews 畫面。其他像是 DecoratedCustomViewStyle,實驗發現會把 heads-up 通知的底部給截掉,BigPictureStyleInboxStyleMessagingStyle 的應用則相對不適合來電通知。

5. 一切就緒,發送通知

記得將 notification ID 記錄下來,之後動作執行後,要針對紀錄的 notification ID 去做 cancel 的動作,否則 ongoing notification 會一直存在於通知列裡面。

更多限制

RemoteViews 其實在不同廠牌的手機上,行為或顯示都會有不盡相同的效果,在使用上,需要多多測試不同廠牌,甚至不同型號的手機。舉例來說,在小米手機上,小唯團隊就遇到了 heads-up 的畫面被截掉。無奈是 Android 手機廠牌、型號多到數不清,實在要驗也驗不完啊~~ 😭

相關連結: Demo 專案, RemoteViewes 支援的元件

------------- 本文结束 有比 RemoteViews 更好的選擇嗎? -------------