RemoteViews使用场景和工作原理

使用

RemoteViews 的作用是在其他进程中显示并更新 View 界面。主要用于通知栏和桌面小部件上。

1 通知栏的使用

我们使用 NotificationCompat.Builder.build() 来创建一个通知,然后调用 NotificationManager.notify() 来显示通知栏,在需要自定义通知栏 UI 时,就需要 RemoteViews 来帮忙了。

第一步:设置通知栏的 UI 布局文件

第二步:使用 RemoteViews 绑定布局

注意:Android 8.0 (API 26) 以上的手机需要 NotificationChannel 实现通知栏。

@TargetApi(26)
void testRemoteViewsInNotification() {
    Intent intent = new Intent(this, NotiActivity.class);
    PendingIntent pendingIntent = PendingIntent
            .getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);

    String id = "my_channel_01";
    CharSequence name = "channel";
    String description = "description";
    int importance = NotificationManager.IMPORTANCE_DEFAULT;
    NotificationChannel channel = new NotificationChannel(id, name, importance);
    channel.setDescription(description);
    channel.enableLights(true);
    channel.setLightColor(Color.RED);
    channel.enableVibration(true);
    // 偶数表示静止时间,奇数表示振动时间
    channel.setVibrationPattern(new long[]{100, 200, 300, 400, 500, 400, 300, 200, 400});

    NotificationManager manager =
            (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
    manager.createNotificationChannel(channel);

    RemoteViews remoteViews = new RemoteViews(getPackageName(), R.layout.layout_notification);
    remoteViews.setTextViewText(R.id.msg, "demo");
    remoteViews.setOnClickPendingIntent(R.id.notification, pendingIntent);

    Notification notificaton = new Notification.Builder(this, id)
            .setAutoCancel(false)
            .setContentTitle("title")
            .setContentText("text")
            .setSmallIcon(R.mipmap.ic_launcher_round)
            .setOngoing(true)
            .setCustomContentView(remoteViews)
            .setWhen(System.currentTimeMillis())
            .build();
    manager.notify(1, notificaton);
}

RemoteViews 通过 new RemoteViews(packageName, layoutId) 来关联一个 View 布局,并可以通过一系列 set 方法更新布局。最后用 Notification.Builder 的 setCustomContentView(RemoteViews) 来设置自定义的通知栏布局。

  • setTextColor(viewId, color)
  • setTextViewText(viewId, text)
  • setImageViewResource(viewId, srcId)
  • setOnClickPendingIntent(viewId, pendingIntent)

2 桌面小部件的使用

第一步:定义小部件界面

<!-- res/layout/my_app_widget.xml -->
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/colorAccent"
    android:padding="8dp">

    <TextView
        android:id="@+id/my_app_widget_view_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:layout_margin="8dp"
        android:text="@string/app_name"
        android:textSize="24sp"
        android:textStyle="bold|italic"/>

</RelativeLayout>

第二步:定义小部件配置信息

<!-- res/xml/my_app_widget_info.xml -->
<appwidget-provider
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/my_app_widget"
    android:minHeight="84dp"
    android:minWidth="84dp"
    android:updatePeriodMillis="86400000">

</appwidget-provider>

上述参数中,updatePeriodMillis 自动小部件自动更新的时间,initialLayout 指定了默认的布局。

第三步:定义小部件的实现类

public class MyAppWidgetProvider extends AppWidgetProvider {
    public static final String CLICK_ACTION = "com.mindle.androidtest.action.CLICK";

    static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
                                int appWidgetId) {
        // Construct the RemoteViews object
        RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.my_app_widget);

        Intent onIntent = new Intent();
        onIntent.setAction(CLICK_ACTION);
        PendingIntent onPendingIntent = PendingIntent.getBroadcast(context, 0, onIntent, 0);
        remoteViews.setOnClickPendingIntent(R.id.my_app_widget_view_text, onPendingIntent);

        // Instruct the widget manager to update the widget
        appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
    }

    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        // There may be multiple widgets active, so update all of them
        for (int appWidgetId : appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId);
        }
    }

    @Override
    public void onEnabled(Context context) {
        // Enter relevant functionality for when the first widget is created
    }

    @Override
    public void onDisabled(Context context) {
        // Enter relevant functionality for when the last widget is disabled
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        super.onReceive(context, intent);
        RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.my_app_widget);
        if (Objects.equals(intent.getAction(), CLICK_ACTION)) {
            Toast.makeText(context, "hello, widget!", Toast.LENGTH_SHORT).show();

            AppWidgetManager manager = AppWidgetManager.getInstance(context);

            ComponentName thisName = new ComponentName(context, MyAppWidgetProvider.class);

            manager.updateAppWidget(thisName, remoteViews);
        }
    }
}

上面的代码中,onReceive 方法需要引用父类的方法,这样才能处理默认的广播事件,自动调用 onUpdate、onDeleted、 onEnabled、onDisabled 等方法。更新桌面小部件时给小部件设置了 RemoteViews 作为 UI 部件,而且给这个 RemoteViews 添加了一个点击时会发送广播的意图。所以每次点击 UI 部件,该部件的 onReceive 方法就会接收到点击事件的广播,然后进行相关处理。

方法名描述
onReceive广播内置方法,用于分发接收到的事件给下列其它方法
onUpdate每次桌面小部件更新时都调用
onDeleted每删除一次就调用一次
onEnabled当该桌面小部件第一次添加时调用
onDisabled最后一个该桌面小部件被删除时调用

最后一步:注册桌面小部件

桌面小部件本质上是一个广播组件,所以需要用广播的方式注册,如下:

<!-- AndroidManifest.xml -->
<receiver android:name=".NewAppWidget">
    <intent-filter>
        <action android:name="com.mindle.androidtest.action.CLICK"/>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
    </intent-filter>

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

注册时有两个 Action,第一个用于标识小部件的点击行为,而第二个 Action 作为小部件的标识必须存在,否则就不是一个桌面小部件,而且无法出现在手机的小部件列表。

工作原理

RemoteViews 是实现了 Parcelable 接口的,可以通过 Binder 传递到其他进程中。比如,前面提到的两个使用场景中,通知栏和桌面小部件分别由 NotificationManager 和 AppWidgetManager 管理,它们 通过 Binder 分别和 SystemServer 进程中的 NotificationManagerService 以及 AppWidgetManagerService 通信,实现了跨进程传递 RemoteViews。

RemoteViews 的一系列 set 操作是通过将更新操作包装成 Action(Parcelable)对象集合保存下来。Action 最重要的是 reply 方法,通过反射或别的方法,在其它进程中更新 RemoteViews 中控件的布局。

RemoteViews 中有 apply 方法和 reApply 方法。apply 方法会加载布局并更新界面,而 reApply 方法仅仅更新界面。通知栏和桌面小部件在初始化界面时会调用 apply 方法,在后续的更新界面时会调用 reApply 方法。

注意:RemoteViews 的 apply 方法会加载布局,如下面代码所示,其中 rv.getLayoutId() 得到的 RemoteViews 的布局文件 ID 是在构造函数中传进去的,即 RemoteViews(packageName, layoutId)。因为 RemoteViews 常用于在其他进程中显示并更新 View 界面,但是如果两个进程属于不同的应用,那么它们的资源 ID 很可能是不一致的。在应用 A 中使用资源 ID1 创建了 RemoteViews 传给应用 B 之后,apply 方法中直接在应用 B 中用 ID1 来绘制布局很可能会出错。

View v = inflater.inflate(rv.getLayoutId(), parent, false);

解决上面问题的办法是,手动加载布局,然后用 reApply 方法来更新界面。如下面的例子所示,通过约定好的布局文件名来获取资源 ID,然后再手动加载布局。

int layoutId = getResources()
        .getIdentifier("layout_notification", "layout", getPackageName());
View v = getLayoutInflater().inflate(layoutId, layout, false);
remoteViews.reapply(this, v);
layout.addView(v);