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

1. Widget 概述

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

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


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

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


2. 創建一個最簡單的Widget

1.創建Widget的布局,simple_widget.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    style="@style/Widget.CarWidget.AppWidget.Container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:theme="@style/Theme.CarWidget.AppWidgetContainer">

    <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" />
</RelativeLayout>

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

XML文件的資源類型應設置為appwidget-provider用于定義Widget的基本屬性。在XML文件中,定義一些屬性,如下所示:

 <? xml version="1.0" encoding="utf-8" ?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/simple_widget"
    android:minWidth="100dp"
    android:minHeight="100dp"
    android:updatePeriodMillis="0" />

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

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

<receiver
    android:name=".SimpleWidget"
    android:exported="false">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    </intent-filter>

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

運行這個程序,并在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中<receiver>元素中的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/>標記將需要的屬性定義出來即可。

<? xml version="1.0" encoding="utf-8" ?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:configure="com.android.car.carwidget.SimpleWidgetConfigureActivity"
    android:description="@string/app_widget_description"
    android:initialKeyguardLayout="@layout/simple_widget"
    android:initialLayout="@layout/simple_widget"
    android:minWidth="50dp"
    android:minHeight="50dp"
    android:previewImage="@drawable/example_appwidget_preview"
    android:previewLayout="@layout/simple_widget"
    android:resizeMode="horizontal|vertical"
    android:targetCellWidth="2"
    android:targetCellHeight="2"
    android:updatePeriodMillis="86400000"
    android:widgetCategory="home_screen|keyguard" />

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<SizeF, RemoteViews> 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集合,請參考官方文檔:https://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。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容