RE: 從零開始的車載Android HMI(二) - Widget

語言: CN / TW / HK

1. Widget 概述

Widget,又叫“微件”、“小部件”。小部件是放置在主螢幕(Launcher)上的Android應用程式的小工具或控制元件。通過小部件可以將自己喜歡的應用程式放在主螢幕上,以便快速訪問它們或是顯示一些重點資訊。

小部件可以是多種型別,例如資訊小部件、集合小部件、控制元件小部件和混合小部件。Android為我們提供了一個完整的框架來開發我們自己的小部件。在手機上我們已經看過一些常見的小部件,例如音樂小部件,天氣小部件,時鐘小部件等。

由於車載系統需要我們額外開發天氣、音樂、時鐘等應用,所以Widget在車載應用開發中,也算是必修課了。不僅如此,開發車載Launcher時還需要做額外開發,使Launcher具有擺放Widget的能力。

本文參考資料:http://developer.android.google.cn/guide/topics/appwidgets/overview


2. 建立一個最簡單的Widget

1.建立Widget的佈局,simple_widget.xml

```

<TextView
    android:id="@+id/appwidget_text"
    style="@style/Widget.CarWidget.AppWidget.InnerView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerHorizontal="true"
    android:layout_centerVertical="true"
    android:layout_margin="8dp"
    android:contentDescription="@string/appwidget_text"
    android:text="@string/appwidget_text"
    android:textSize="24sp"
    android:textStyle="bold|italic" />

```

2.在res/xml下建立一個新的XML

XML檔案的資源型別應設定為appwidget-provider用於定義Widget的基本屬性。在XML檔案中,定義一些屬性,如下所示:

```

```

各個屬性的具體含義,下一節會詳細介紹。

3.擴充套件AppWidgetProvider的實現

重寫AppWidgetProviderUpdae方法,並在其中呼叫AppWidgetManager.updateAppWidget()將資料更新到佈局RemoteViews中,完整的程式碼如下:

``` class SimpleWidget : AppWidgetProvider() { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { for (appWidgetId in appWidgetIds) { updateAppWidget(context, appWidgetManager, appWidgetId) } Log.e(TAG, "onUpdate: $appWidgetIds") } }

internal fun updateAppWidget(context: Context,appWidgetManager: AppWidgetManager, appWidgetId: Int) { val widgetText = "林栩" val views = RemoteViews(context.packageName, R.layout.simple_widget) views.setTextViewText(R.id.appwidget_text, widgetText) // 更新整個widget appWidgetManager.updateAppWidget(appWidgetId, views) } ```

4.最後,在AndroidManifes.xml中宣告AppWidgetProvider

```

<meta-data
    android:name="android.appwidget.provider"
    android:resource="@xml/simple_widget_info" />

```

執行這個程式,並在Launcher上新增這個Widget,就可以看到一個最簡單的Widget了。

到這一步,我們就完成了Widget的helloworld。總體來說Widget的架構組成如下所示,接下來我們逐個介紹每個元件的作用。


3. 定義小部件的基礎屬性 - AppWidgetProviderInfo

AppWidgetProviderInfo用於描述這個Widget的各種基本資訊,包括layout佈局,重新整理頻率以及AppWidgetProvider。這些資訊都會定義在xml中,tag標記是<appwidget-provider>

3.1. AppWidgetProviderInfo 常用屬性與說明

| 屬性 | 說明 | | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | updatePeriodMillis | 定義小部件通過呼叫onUpdate()回撥方法從AppWidgetProvider請求更新的頻率。實際更新不能保證使用此值準時進行,儘可能不頻繁地更新。updatePeriodMillis不支援小於30分鐘的值。如果要禁用定期更新,可以指定為0小部件的其他更新方式,請參考後面的 《小部件進階用法 - 優化更新頻率》 | | initialLayout | 指向定義小部件佈局的佈局資源。 | | initialKeyguardLayout | 指向定義小部件佈局的佈局資源。 | | configure | 定義使用者新增小部件時啟動的Activity,允許他們配置小部件屬性。 | | description | 指定要為小部件顯示的小部件選擇器的描述。Android 12中引入。 | | previewLayout (Android 12)previewImage (Android 11 and lower) | 從Android 12開始,previewLayout屬性指定了一個可擴充套件的預覽,您將提供一個設定為小部件預設大小的XML佈局。理想情況下,指定為該屬性的佈局XML應該與具有實際預設值的實際小部件相同。在Android 11或更低版本中,previewImage屬性指定了小部件配置後的預覽,使用者在選擇應用程式小部件時會看到該預覽。如果未提供,則使用者會看到應用程式的啟動器圖示。該欄位對應於AndroidManifest中元素中的android:previewImage屬性。注意:建議同時指定previewImage和previewLayout屬性,以便在使用者的裝置不支援previewLayout的情況下,應用程式可以使用previewImage。 | | autoAdvanceViewId | 指定小部件主機應自動推進的小部件子檢視的檢視ID。Android 3.0中引入。 | | widgetCategory | 宣告小部件是否可以顯示在主螢幕(home_screen)、鎖屏(keyguard)或兩者上。只有低於5.0的Android版本支援鎖屏小部件。對於Android 5.0及更高版本,只有home_screen有效。 | | widgetFeatures | 宣告小部件支援的功能。例如,如果您希望小部件在使用者新增時使用其預設配置,請指定configuration_optional和reconfigurable 。這繞過了在使用者新增小部件後啟動配置活動。(之後使用者仍然可以重新配置小部件。) | | targetCellWidth、targetCellHeight (Android 12)minWidth、minHeight | 從Android 12開始,targetCellWidth和targetCellHeight屬性指定小部件的預設大小(以網格單元為單位)。在Android 11及更低版本中,這些屬性將被忽略,如果主螢幕不支援基於網格的佈局,則這些屬性可能會被忽略。minWidth和minHeight屬性指定dp中小部件的預設大小。如果小部件的最小寬度或高度的值與單元格的尺寸不匹配,則將這些值四捨五入到最接近的單元格大小。注意:建議同時指定targetCellWidth/targetCellHeight和minWidth/minHeight屬性集,以便在使用者的裝置不支援targetCellWidth和targetCellHeight的情況下,應用程式可以使用minWidth和minHeight。如果支援,targetCellWidth和targetCellHeight屬性優先於minWidth和minHeight屬性。 | | minResizeWidthminResizeHeight | 指定小部件的絕對最小大小。這些值應指定小部件無法辨認或無法使用的大小。使用這些屬性,使用者可以將小部件的大小調整為可能小於預設小部件大小的大小。如果minResizeWidth屬性大於minWidth或未啟用水平調整大小,則忽略該屬性(請參見resizeMode)。同樣,如果minResizeHeight屬性大於minHeight或未啟用垂直調整大小,則忽略該屬性。Android 4.0中引入。 | | maxResizeWidthmaxResizeHeight | 指定小部件的建議最大大小。如果值不是網格單元尺寸的倍數,則會將其四捨五入到最近的單元尺寸。如果maxResizeWidth屬性小於minWidth或未啟用水平調整大小,則忽略該屬性(請參見resizeMode)。同樣,如果maxResizeHeight屬性大於minHeight或未啟用垂直調整大小,則忽略該屬性。Android 12中引入。 | | resizeMode | 指定可以調整小部件大小的規則。可以使用此屬性使主螢幕小部件可以水平、垂直或在兩個軸上調整大小。使用者長按小部件以顯示其大小調整手柄,然後拖動水平和/或垂直手柄以更改其在佈局網格上的大小。resizeMode屬性的值包括horizontal、vertical和none。要將小部件宣告為可水平和垂直調整大小,請使用horizontal | vertical。在Android 3.1中引入。 |

關於小部件尺寸的計算問題請參考 : Provide flexible widget layouts

3.2. AppWidgetProviderInfo 使用方法

AppWidgetProviderInfo需要在res/xml中使用<appwidget-provider/>標記將需要的屬性定義出來即可。

```

```


4.Widget功能提供者 - AppWidgetProvider

AppWidgetProvider繼承自BroadcastReceiver,本質上就是一個廣播接收器,AppWidgetProvider也只是在onReceive中解析接收到的intent,並使用接收到的資料呼叫其他擴充套件方法。

public void onReceive(Context context, Intent intent) { //防止惡意更新廣播(不是真正的安全問題,只是過濾出壞的Broacast,這樣子類就不太可能崩潰)。 String action = intent.getAction(); if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(action)) { Bundle extras = intent.getExtras(); if (extras != null) { int[] appWidgetIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS); if (appWidgetIds != null && appWidgetIds.length > 0) { this.onUpdate(context, AppWidgetManager.getInstance(context), appWidgetIds); } } } else if (AppWidgetManager.ACTION_APPWIDGET_DELETED.equals(action)) { Bundle extras = intent.getExtras(); if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)) { final int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID); this.onDeleted(context, new int[] { appWidgetId }); } } else if (AppWidgetManager.ACTION_APPWIDGET_OPTIONS_CHANGED.equals(action)) { Bundle extras = intent.getExtras(); if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID) && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_OPTIONS)) { int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID); Bundle widgetExtras = extras.getBundle(AppWidgetManager.EXTRA_APPWIDGET_OPTIONS); this.onAppWidgetOptionsChanged(context, AppWidgetManager.getInstance(context), appWidgetId, widgetExtras); } } else if (AppWidgetManager.ACTION_APPWIDGET_ENABLED.equals(action)) { this.onEnabled(context); } else if (AppWidgetManager.ACTION_APPWIDGET_DISABLED.equals(action)) { this.onDisabled(context); } else if (AppWidgetManager.ACTION_APPWIDGET_RESTORED.equals(action)) { Bundle extras = intent.getExtras(); if (extras != null) { int[] oldIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_OLD_IDS); int[] newIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS); if (oldIds != null && oldIds.length > 0) { this.onRestored(context, oldIds, newIds); this.onUpdate(context, AppWidgetManager.getInstance(context), newIds); } } } }

原始碼不復雜主要就是完成以下事件的分發邏輯

ACTION_APPWIDGET_UPDATE -> onUpdate

ACTION_APPWIDGET_DELETED -> onDeleted

ACTION_APPWIDGET_OPTIONS_CHANGED -> onAppWidgetOptionsChanged

ACTION_APPWIDGET_ENABLED -> onEnabled

ACTION_APPWIDGET_DISABLED -> onDisabled

ACTION_APPWIDGET_RESTORED -> onRestored

4.1. AppWidgetProvider 基本屬性與說明

該類將BroadcastReceiver擴充套件為一個方便的類來處理小部件廣播。它只接收與小部件相關的事件廣播,例如當小部件被更新、刪除、啟用和禁用時。當這些廣播事件發生時,將呼叫以下方法:

  • onUpdate

public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { }

如果在前面的AppWidgetProviderInfo中定義了updatePeriodMillis,系統會根據這個時間週期性的產生ACTION_APPWIDGET_UPDATE事件。當用戶新增widget時也會產生這一事件。

此方法在使用者新增小部件時也會呼叫,因此它應執行基本設定,例如為 View 物件定義事件處理程式或啟動作業以載入要在小部件中顯示的資料。但是,如果您聲明瞭一個沒有標誌的配置活動,則在使用者新增小部件時不會呼叫此方法,而是為後續更新呼叫此方法。配置活動負責在配置完成後執行第一次更新。

  • onAppWidgetOptionsChanged

public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) { }

在第一次放置小部件或調整小部件的大小時產生這一事件。使用此回撥可以根據小部件的大小範圍顯示或隱藏內容或者獲取大小範圍。

通過AppWidgetManager.getAppWidgetOptions(appWidgetId)可以獲取對應WidgetId的Bundle,其中包括以下內容:

OPTION_APPWIDGET_MIN_WIDTH:包含小部件例項的寬度下限(單位dp)。

OPTION_APPWIDGET_MIN_HEIGHT:包含小部件例項高度的下限(單位:dp)。

OPTION_APPWIDGET_MAX_WIDTH:包含小部件例項的寬度上限(單位:dp)。

OPTION_APPWIDGET_MAX_HEIGHT:包含小部件例項高度的上限(單位:dp)。

  • onDeleted

public void onDeleted(Context context, int[] appWidgetIds) { }

每次從視窗小部件主機中刪除視窗小部件時,都會呼叫該函式。

  • onEnabled

public void onEnabled(Context context) { }

這在第一次建立小部件的例項時呼叫。

例如,如果使用者添加了兩個小部件例項,則這只是第一次呼叫。如果您需要開啟一個新的資料庫或執行另一個只需要對所有小部件例項執行一次的設定,那麼這是一個很好的地方。

  • onDisabled

public void onDisabled(Context context) { }

當建立的小部件的最後一個例項從AppWidgetHost中刪除時,將呼叫此函式。

  • onRestored

public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) { }

當AppWidget提供的例項從備份中恢復使呼叫。此方法呼叫後,會立即呼叫onUpdate。

當需要從持久化資料中恢復Widget時,需要重寫此方法將舊的AppWidgetID重新對映到新值,並更新任何其他可能相關的狀態。

  • onReceive

這是為每個廣播呼叫的,通常不需要實現此方法。


5. Widget 的佈局 - RemoteViews

RemoteViews是一個用於描述可在另一個程序中顯示的檢視層次結構的類。主要用於通知欄和Widget上。

在定義AppWidgetProviderInfo時需要把Widget的佈局檔案引入,Widget的佈局與傳統的Android佈局檔案一樣,儲存在專案的res/layout/下。

但是需要注意的是,Widget的佈局基於RemoteViews,與傳統的佈局方式不同,並不是每種佈局或檢視Widget都支援。RemoteViews 僅支援以下佈局型別:

FrameLayout LinearLayout RelativeLayout GridLayout

以及以下控制元件類:

AnalogClock Button Chronometer ImageButton ImageView ProgressBar TextView ViewFlipper ListView GridView StackView AdapterViewFlipper

Android 12 之後,支援的控制元件類增加了三個

CheckBox Switch RadioButton RadioGroup

RemoteViews 也支援 ViewStub,它是一個大小為零的不可見檢視,我們在使用傳統佈局,進行效能優化時也會經常使用。

5.1. RemoteViews 常用方法與說明

  • 建立 RemoteViews | | | ---------------------------------------------------------------------------------------------------------------------- | | RemoteViews(String packageName, int layoutId)建立一個新的 RemoteViews 物件,該物件將顯示指定佈局檔案中包含的檢視。| | RemoteViews(String packageName, int layoutId, int viewId)建立一個新的 RemoteViews 物件,該物件將顯示指定佈局檔案中包含的檢視,並將根檢視的 ID 更改為指定的 id。 | | RemoteViews(RemoteViews landscape, RemoteViews portrait)建立一個新的 RemoteViews 物件,該物件將填充為指定的橫向或縱向 RemoteViews,具體取決於當前配置。 | | RemoteViews(Map remoteViews)建立一個新的 RemoteViews 物件,該物件將使用最接近的大小規範來膨脹佈局。 | | RemoteViews(RemoteViews src)基於RemoteViews建立一個副本。|

  • 設定文字

void setTextViewText(@IdRes int viewId, CharSequence text)

相當於TextVIew.setText(),setTextViewText內部使用了setCharSequence,所以其實也可以呼叫setCharSequence來完成設定文字的操作。

public void setTextViewText(@IdRes int viewId, CharSequence text) { setCharSequence(viewId, "setText", text); }

  • 設定字型顏色

void setTextColor(@IdRes int viewId, @ColorInt int color) void setInt(viewId, "setTextColor", color);

  • 設定字型大小

void setTextViewTextSize(@IdRes int viewId, int units, float size)

  • 設定圖片

void setImageViewResource(@IdRes int viewId, @DrawableRes int srcId) void setInt(viewId, "setImageResource", srcId);

void setImageViewUri(@IdRes int viewId, Uri uri) void setUri(viewId, "setImageURI", uri);

void setImageViewBitmap(@IdRes int viewId, Bitmap bitmap) void setBitmap(viewId, "setImageBitmap", bitmap);

void setImageViewIcon(@IdRes int viewId, Icon icon) void setIcon(viewId, "setImageIcon", icon);

  • 設定單個控制元件的點選事件

void setOnClickPendingIntent(@IdRes int viewId, PendingIntent pendingIntent) void setOnClickResponse(@IdRes int viewId, @NonNull RemoteResponse response)

``` val url = "http://www.baidu.com" val intent = Intent(Intent.ACTION_VIEW) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.data = Uri.parse(url) val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_MUTABLE) views.setOnClickPendingIntent(R.id.appwidget_text, pending)

appWidgetManager.updateAppWidget(appWidgetId, views) ```

  • 設定ProgressBar

void setProgressBar(@IdRes int viewId, int max, int progress, boolean indeterminate)

或者使用

setBoolean(viewId, "setIndeterminate", indeterminate); if (!indeterminate) { setInt(viewId, "setMax", max); setInt(viewId, "setProgress", progress); }

  • 調整RemoteViews的佈局屬性

void setViewLayoutMargin(@IdRes int viewId, @MarginType int type, float value, @ComplexDimensionUnit int units) void setViewLayoutHeight(@IdRes int viewId, float height, @ComplexDimensionUnit int units) void setViewLayoutWidth(@IdRes int viewId, float width, @ComplexDimensionUnit int units)

以上就是常用的一些方法,更多API,請參考官方文件:RemoteViews  |  Android Developers


6. Widget 進階用法

6.1. 優化更新方式

AppWidgetProvider中更新RemoteViews有以下三種不同方式可供選擇:

完整更新

呼叫AppWidgetManager.updateAppWidget可以完整更新整個 widget。效能成本最大。

``` val appWidgetManager = AppWidgetManager.getInstance(context) val views = RemoteViews(context.packageName, R.layout.simple_widget) views.setTextViewText(R.id.appwidget_text, widgetText)

appWidgetManager.updateAppWidget(appWidgetId, views) ```

部分更新

呼叫AppWidgetManager.partialupdateAppWidget可以只更新小部件指定的部分。此更新與updateAppWidget的不同之處在於,傳遞的RemoteViews物件被理解為小部件的不完整表示,因此AppWidgetService不會快取它。

注意,由於這些更新沒有快取,因此在使用AppWidgetService中的快取版本還原Widget的情況下,它們修改的任何未由restoreInstanceState還原的狀態都不會持久。

``` val appWidgetManager = AppWidgetManager.getInstance(context) val views = RemoteViews(context.packageName, R.layout.simple_widget) views.setTextViewText(R.id.appwidget_text, widgetText)

appWidgetManager.partiallyUpdateAppWidget(appWidgetId, views) ```

集合資料的更新

在RemoteViews中使用StackView、ListView、GridView時,需要使用 AppWidgetManager.notifyAppWidgetViewDataChanged來更新檢視的集合資料,這將觸發RemoteViewsFactory.onDataSetChanged。在此期間,舊資料將顯示在Widget中。 val appWidgetManager = AppWidgetManager.getInstance(context) appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.widget_listview) 集合Widget專門用於顯示許多相同型別的元素,例如來自相簿應用程式的圖片集合、來自新聞應用程式的文章集合或來自通訊應用程式的訊息集合。

關於如何開發Widget集合,請參考官方文件:http://developer.android.google.cn/guide/topics/appwidgets/collections

2. 優化更新頻率

定期更新

定期更新Widget很常見,但是updatePeriodMillis不能設定小於30分鐘的數值,如果需要小於30分鐘定時更新事件,建議搭配WorkManger使用,同時要把updatePeriodMillis設為0,禁用Widget的定期更新。

依據廣播的更新

在車載HMI的開發中,有時候需要依據廣播更新Widget,比較常見的是地圖Widget,可選的做法是根據Location廣播更新Widget。

根據廣播更新Widget有以下注意事項:

更新持續時間

通常,系統允許廣播接收器(通常在應用程式的主執行緒中執行)執行10 秒,然後再將其視為無響應並觸發ANR錯誤。如果更新小元件需要更多時間,需要考慮以下替代方法:

  • 使用 WorkManager

  • 使用BroadcastReceiver.``goAsync方法為接收方提供更多時間。這允許接收器執行 30 秒。但是,在此處執行的任何工作都會阻止進一步的廣播,直到它完成為止,因此過度利用這一點可能會適得其反,並導致以後的事件接收速度更慢

更新優先順序

預設情況下,廣播作為後臺程序執行,這意味著當系統資源緊張時可能會導致廣播接收器呼叫延遲。可以通過將廣播設定為前臺廣播Intent.FLAG_RECEIVER_FOREGROUND,提高廣播的優先順序。


7. 總結

最後我們再總結一下Widget的使用方法,<appwidget-provider>用於定義widget的基本屬性和初始佈局。AppWidgetProvider本質上就是一個廣播接收器,我們在AppWidgetProvider中使用RemoteViews顯示UI並填充資料,最後使用AppWidgetManger重新整理UI。

在車載Android系統中,雖然Widget的宿主也是Launcher,但是由於Launcher一般是我們自己重新開發的,所以,如何容納Widget也是需要Launcher的開發者額外開發的,這塊的內容比較複雜,建議閱讀構建應用Widget宿主,並參考AOSP-Launcher3的原始碼實現。

下一篇,我們來介紹泊車雷達、Camera中需要用到的Android HMI 元件 - SurfaceView、TextureView。