RE: 从零开始的车载Android HMI(二) - Widg
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
的实现
重写AppWidgetProvider
的Updae
方法,并在其中调用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。