RemoteViews
是一种远程View,它和远程Service是一样的,可以跨进程更新界面。RemoteViews在Android中的使用场景有两种:通知栏和桌面小部件。
通知栏主要是通过 NotificationManager
的 notify()
实现更新,除了默认效果还可以另外定义布局。桌面小部件则是通过 AppWidgetProvider
实现,AppWidgetProvider本质上是一个广播。RemoteViews提供了一系列 set()
更新View,但是支持的View类型也是有限的。
// 自定义布局,使用RemoteViews更新通知栏界面
RemoteViews remoteViews = new RemoteVies(getPackageName(), R.layout.notification);
remoteViews.setTextViewText(R.id.msg, "test"); // 更新TextView
remoteViews.setImageViewResource(R.id.icon, R.drawable.icon); // 更新ImageView
PendingIntent openActivityPendingIntent = PendingIntent.getActivity(this, 0, new Intent(this, MyActivity2.class), PendingIntent.FLAG_UPDATE_CURRENT);
// 给View添加点击事件
remoteViews.setOnClickPendingIntent(R.id.open_activity, openActivityPendingIntent);
Intent intent = new Intent(this, MyActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 ,intent, PendingIntent.FLAG_UPDATE_CURRENT);
Notification notification = new Notification();
notification.icon = R.drawable.ic_launcher;
notification.tickerText = "hello world";
notification.when = System.currentTimeMillis();
notification.flags = Notification.FLAG_AUTO_CANCEL;
notification.contentView = remoteViews;
notification.contentIntent = pendingIntent;
notification.setLatestEventInfo(this, "test", "this is notification", pendingIntent);
NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
manager.notify(1, notification);
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:id="@+id/imageView1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/icon1" />
</LinearLayout>
在 res/xml/
下新建 appwidget_provider_info.xml
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget" // 小部件布局
android:minHeight="84dp" // 最小尺寸
android:minWidth="84dp"
android:updatePeriodMillis="86400000" /> // 自动更新周期,单位ms
这个类需要继承 AppWidgetProvider
:
// 在小部件上显示一张图片,点击它后图片旋转一周
public class MyAppWidgetProvider extends AppWidgetProvider {
public static final String CLICK_ACTION = "com.example.appwidget.ACTION_CLICK";
public MyAppWidgetProvider() {
super();
}
@Override
public void onReceive(final Context context, Intent intent) {
super.onReceive(context, intent);
if (CLICK_ACTION.equals(intent.getAction())) {
new Thread(new Runnable() {
@Override
public void run() {
Bitmap srcbBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.icon1);
AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
for (int i = 0; i < 37; i++) {
float degree = (i * 10) % 360;
RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.widget);
remoteViews.setImageViewBitmap(R.id.imageView1, rotateBitmap(context, srcbBitmap, degree));
Intent intentClick = new Intent();
intentClick.setAction(CLICK_ACTION);
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intentClick, 0);
remoteViews.setOnClickPendingIntent(R.id.imageView1, pendingIntent);
appWidgetManager.updateAppWidget(new ComponentName(context, MyAppWidgetProvider.class), remoteViews);
SystemClock.sleep(30);
}
}
}).start();
}
}
// 每次桌面小部件更新时都调用一次该方法
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
super.onUpdate(context, appWidgetManager, appWidgetIds);
final int counter = appWidgetIds.length;
for (int i = 0; i < counter; i++) {
int appWidgetId = appWidgetIds[i];
onWidgetUpdate(context, appWidgetManager, appWidgetId);
}
}
private void onWidgetUpdate(Context context, AppWidgetManager appWidgetManager, int appWidgetId) {
RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.widget);
Intent intentClick = new Intent();
intentClick.setAction(CLICK_ACTION);
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intentClick, 0);
remoteViews.setOnClickPendingIntent(R.id.imageView1, pendingIntent);
appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
}
private Bitmap rotateBitmap(Context context, Bitmap srcbBitmap, float degree) {
Matrix matrix = new Matrix();
matrix.reset();
matrix.setRotate(degree);
Bitmap tmpBitmap = Bitmap.createBitmap(srcbBitmap, 0, 0, srcbBitmap.getWidth(), srcbBitmap.getHeight(), matrix, true);
return tmpBitmap;
}
}
<receiver android:name=".MyAppWidgetProvider">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_provider_info" />
<intent-filter>
<action android:name="com.example.appwidget.action.CLICK" />
// 系统指定的小部件标志,不添加小部件无法显示在小部件列表里
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
</receiver>
AppWidgetProvider
除了 onUpdate()
,还有其他方法:onEnabled()
、onDisabled()
、onDeleted()
和 onReceive()
。这些方法会自动地被 onReceive()
在合适的时间调用。
onEnabled:当该窗口小部件第一次添加到桌面时调用该方法,可添加多次但只在第一次调用
onUpdate:小部件被添加时或者每次小部件更新时都会调用一次该方法,小部件的更新时机由 updatePeriodMillis
来指定,每个周期小部件都会自动更新一次
onDeleted:每删除一次桌面小部件就调用一次
onDisabled:当最后一个该类型的桌面小部件被删除时调用该方法,注意是最后一个
onReceive:这是广播的内置方法,用于分发具体的事件给其他方法
关于PendingIntent的使用和分析,可以参考之前的写的一篇文章:
PendingIntent的使用和分析
RemoteViews并不支持所有的View类型,它所支持的所有类型如下:
Layout | View |
---|---|
FrameLayout、LinearLayout、RelativeLayout、GridLayout | AnalogClock、Button、Chronometer、ImageButton、ImageView、ProgressBar、TextView、ViewFlipper、ListView、GridView、StackView、AdapterViewFlipper、ViewStub |
RemoteViews没有提供findViewById所以无法直接访问布局里面的View元素,必须通过RemoteViews提供的一系列 set()
来完成:
方法名 | 作用 |
---|---|
setTextViewText(int viewId, CharSequence text) | 设置TextView的文本 |
setTextViewTextSize(int viewId, float size) | 设置TextView的字体大小 |
setTextColor(int viewId, int color) | 设置TextView的字体颜色 |
setImageViewResource(int viewId, int srcId) | 设置ImageView的图片资源 |
setInt(int viewId, String methodName, int value) | 反射调用View对象的参数类型为int的方法 |
setLong(int viewId, String methodName, long value) | 反射调用View对象的参数类型为long的方法 |
setBoolean(int viewId, String methodName, boolean value) | 反射调用View对象的参数类型为boolean的方法 |
setOnClickPendingIntent(int viewId, PendingIntent pendingIntent) | 为View添加点击事件,事件类型只能为PendingIntent |
在这里先列出RemoteViews跨进程更新的图例,接下来会对途中的元素进行分析。涉及到跨进程在Android中肯定需要用到Binder,对于Binder不清楚的可以参考:Binder原理
由于RemoteViews主要用于通知栏和桌面小部件之中,这里就通过过它们来分析RemoteViews的工作过程。
通知栏和桌面小部件分别有 NotificationManager
和 AppWidgetManager
管理,而NotificationManager和AppWidgetManager通过Binder分别和SystemServer进程中的 NotificationManagerService
和 AppWidgetService
进行通信。所以,通知栏和桌面小部件中的布局文件实际上是在NotificationManagerService以及AppWidgetService中被加载的,而它们运行在系统的SystemServer中,所以是跨进程通信的场景。
从理论上来说,系统完全可以通过Binder去支持所有的View和View操作,但是这样做的话代价太大,因为View的方法太多了,另外就是大量的IPC操作会影响效率。
RemoteViews会通过Binder传递到SystemServer进程,因为RemoteViews实现了Parcelable接口,因此可以跨进程传输,系统会根据RemoteViews中的包名等信息去得到该应用的资源。然后通过 LayoutInflater
加载RemoteViews中的布局文件,在SystemServer中加载后的布局文件是一个普通的View。加载完成后在SystemServer中显示,这就是我们看到的通知栏或桌面小部件。
系统并没有通过Binder直接支持View的跨进程访问,而是提供了 Action
的概念,Action代表一个View操作,Action同样实现了Parcelable接口。当我们调用RemoteViews提供的 set()
方法时,会将这些操作封装到Action对象中。
当我们通过 NotificationManager
和 AppWidgetManager
提交我们的更新时(即 NotificationManager.notify()
和 AppWidgetManager.updateAppWidget()
),会将本地进程的一系列Action对象跨进程传输到远程进程,然后在远程进程调用RemoteViews的 apply()
或 reapply()
遍历一系列Action调用它们的 apply()
进行View的更新操作。
上面做大的好处显而易见,首先不需要定义大量的Binder接口,其次通过在远程进程中批量执行RemoteViews的修改操作从而避免了大量的IPC操作,提供程序性能。
我们从 setText()
来分析源码走向。
public void setCharSequence(int viewId, String methodName, CharSequence value) {
// 和上面分析的一样,调用RemoteViews的set()会添加一个Action
addAction(new ReflectionAction(viewId, methodName, ReflectionAction.CHAR_SEQUENCE, value));
}
private void addAction(Action a) {
...
if (mActions == null) {
mActions = new ArrayList<>();
}
mActions.add(a); // 只是将View的操作封装成Action并且保存起来,并没有开始执行
a.updateMemoryUsageEstimate(mMemoryUsageCounter);
}
private final class ReflectionAction extends Action {
ReflectionAction(int viewId, String methodName, int type, Object value) {
this.viewId = viewId;
this.methodName = methodName;
this.type = type;
this.value = value;
}
...
@Override
public void apply(View root, ViewGroup rootParent, OnClickHandler handler) {
final View view = root.findViewById(viewId);
if (view == null) return;
Class<?> param = getParameterType();
if (param == null) {
throw new ActionException("bad type: " + this.type);
}
try {
// 对View的操作有一些是通过反射实现,有些不是
getMethod(view, this.methodName, param).invoke(view, wrapArg(this,.value));
} catch (ActionException e) {
throw e;
} catch (Exception ex) {
throw new ActionException(ex);
}
}
}
// RemoteViews.apply
public View apply(Context context, ViewGroup parent, OnClickHandler handler) {
RemoteViews rvToApply = getRemoteViewsToApply(context);
View result;
...
// 在RemoteViews.apply的时候加载布局
LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
inflater = inflater.cloneInContext(inflationContext);
inflater.setFilter(this);
// layoutId是new RemoteViews()传递的layoutId
result = inflater.inflate(rvToApply.getLayoutId(), parent, false);
// 更新操作
rvToApply.performApply(result, parent, handler);
return result;
}
private void performApply(View v, ViewGroup parent, OnClickHandler handler) {
if (mActions != null) {
handler = handler == null ? DEFAULT_ON_CLICK_HANDLER : handler;
final int count = mActions.size();
for (int i = 0; i < count; i++) {
Action a = mActions.get(i);
a.apply(v, parent, handler); // 实际执行View的操作
}
}
}
// 在NotificationManager和AppWidgetManager调用notify()和updateAppWidget()时
// 才会调用RemoteViews的apply()和reapply()加载和更新界面
// apply()是会加载布局并更新界面,而reapply()只有更新界面
private void updateNotificationViews(NotificationData.Entry entry,
StatusBarNotification notification, boolean isHeadsUp) {
final RemoteViews contentView = notification.getNotification().contentView;
final RemoteViews bigContentView = isHeadsUp
? notification.getNotification().headsUpContentView
: notification.getNotification().bigContentView;
final Notification publicVersion = notification.getNotification().publicVersion;
final RemoteViews publicContentView = publicVersion != null ? publicversion.contentView : null;
contentView.reapply(mContext, entry.expanded, monClickHandler); // 更新界面
...
}
// 在AppWidgetHostView的updateAppWidget()有以下代码说明apply()和reapply()的区别
mRemoteContext = getRemoteContext();
int layoutId = remoteVies.getLayoutId();
// 如果RemoteViews的layoutId和当前相同,调用reapply()只更新界面
if (content == null && layoutId == mLayoutId) {
try {
remoteViews.reapply(mContext, mView, mOnClickHandler);
content = mView;
recycled = true;
} catch (RuntimeException e) {
exception = e;
}
}
// 如果没有则调用apply()加载布局并更新界面
if (content == null) {
try {
content = remoteViews.apply(mContext, this, mOnClickHandler);
} catch (RuntimeException e) {
exception = e;
}
}
在实际的场景中,我们需要从一个应用更新另一个应用的界面(当然,两个应用也必须是在某些参数下约定好的),我们可以选择AIDL去实现,但是如果对界面的更新比较频繁,这个时候就会有效率问题,同时AIDL接口就有可能会变得很复杂。这个时候采用RemoteView来实现就没有这个问题了,当然RemoteViews也有缺点,那就是它仅支持一些常见的View,对于自定义View它是不支持的。
面对这种问题,是采用AIDL还是RemoteViews要看具体情况,如果界面中的View都是一些简单的且RemoteViews支持的View,那么可以考虑采用RemoteViews,否则就要使用其他方式。
使用RemoteViews在两个应用间更新界面还有一个问题,就是布局加载问题。如果A和B属于不同的应用,那么B中的布局文件的资源id传输到A中以后很有可能是无效的,因为A中的这个布局文件的资源id不可能刚好和B中的资源id一样。我们可以通过资源名称来加载布局文件,两个应用要提前约定好RemoteViews中的布局文件的资源名称,然后再A中根据名称查找到对应的布局文件并加载,接着再调用RemoteViews的 reapply()
进行加载。
// 使用RemoteViews.apply()会出现问题,因为使用的是B传递给A的RemoteView布局id
// 两个应用中的布局id是不相同的会导致加载无效
View view = remoteViews.apply(this, mRemoteViewsContent);
mRemoteViewsContent.addView(view);
// 从B应用中拿到了需要加载的布局名称layout_simulated_notification
// A应用在本地查找自己layout目录下的这个布局文件进行加载
int layoutId = getResources().getIdentifier("layout_simulated_notification", "layout", getPackageName());
View view = getLayoutInflater().inflate(layoutId, mRemoteViewsContent, false);
remoteViews.reapply(this, view); // 更新UI
mRemoteViewsContent.addView(view);