一、简介

RemoteViews是一种远程View,可以在其他进程中显示,为了能够更新它的界面,RemoteViews提供了一组基础操作用于跨进程更新它的界面。

RemoteViews常用在通知和桌面小组件中。

二、使用

RemoteViews在通知栏上的应用

1、简单使用

<1> 创建NotificationManager对象

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

<2> 创建RemoteViews对象

RemoteViews remoteViews = new RemoteViews(getPackageName(),R.layout.activity_test_notification_custom);

参数说明:第一个是程序的包名,第二个自定义的布局

<3> 创建通知对象并设置RemoteViews

NotificationCompat.Builder builder ;
//适配android8.0
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){
		String channelID = "1";
        String channelName = "TestRemoteViews";
        NotificationChannel channel = new NotificationChannel(channelID,channelName, NotificationManager.IMPORTANCE_HIGH);

        manager.createNotificationChannel(channel);

        builder = new NotificationCompat.Builder(this,channelID);

}else{

         builder = new NotificationCompat.Builder(this,null);

}
		
builder.setSmallIcon(R.mipmap.ic_launcher)
   			    .setContentTitle("TestRemoteViews")
                .setContentText("");
			    //设置RemoteViews
                .setContent(remoteViews);
		
Notification notification = builder.build();

<4> 发送通知

manager.notify(1002,builder.build());

这样就完成了自定义布局的通知。

2、更新RemoteViews界面

RemoteViews没有提供findViewById方法,因为RemoteViews在远程进程中显示,因此无法直接访问里面的View元素,而必须通过RemoteViews所提供的一系列set方法来完成。比如:

<1> 更新RemoteViews布局中的TextView内容

remoteViews.setTextViewText(R.id.test_notification_custom_tv,"我是一个自定义的RemoteViews");

参数说明:第一个参数是布局中TextView的ID,第二个参数是需要更新的内容

<2> 为RemoteViews布局中的Button设置点击事件

Intent intent1 = new Intent(this,TestTargetActivity1.class);

PendingIntent pendingIntent1 = PendingIntent.getActivities(this,0,new Intent[]{intent1},PendingIntent.FLAG_UPDATE_CURRENT);
//给第一个按钮设置点击事件,实现跳转到TestTargetActivity1
 remoteViews.setOnClickPendingIntent(R.id.test_notification_custom_btn_1,pendingIntent1);
			
Intent intent2 = new Intent(this,TestTargetActivity2.class);

PendingIntent pendingIntent2 = PendingIntent.getActivities(this,0,new Intent[]{intent2},PendingIntent.FLAG_UPDATE_CURRENT);
//给第二个按钮设置点击事件,实现跳转到TestTargetActivity2
remoteViews.setOnClickPendingIntent(R.id.test_notification_custom_btn_2,pendingIntent2);

<3> 为RemoteViews布局中的ImageView设置图片

//根据图片资源设置图片
remoteViews.setImageViewResource(R.id.test_notification_custom_iv,R.drawable.test);

除了上面介绍的三种方法外,RemoteViews还提供了其他的一些方法

setTextColor(viewId, color)                       设置文本颜色
setTextViewTextSize(viewId, units, size)          设置文本大小 
setImageViewBitmap(viewId, bitmap)                设置图片          
setViewPadding(viewId, left, top, right, bottom)  设置Padding间距
3、注意

RemoteViews设置的布局文件并不支持所有的View(包括自定义view),以下是RemoteViews所支持的View:

layout:

FrameLayout,LinearLayout,RelativeLayout,GridLayout

view:

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

RemoteViews在桌面小组件上的应用

1、简单使用

<1> 定义小部件布局

在res/layout/下新建一个布局文件layout_widget.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="vertical" 
		android:layout_width="match_parent"
        android:layout_height="match_parent">

         <TextView
               android:id="@+id/test_app_widget_custom_tv"
               android:layout_width="match_parent"
               android:layout_height="wrap_content"
               android:gravity="center"
               android:text="TextView" />
</LinearLayout>

<2> 定义小部件配置信息

在res/xml/下新建一个资源文件,命名custom_app_widget.xml

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

解释下appwidget-provider各个属性的含义:

android:initialLayout:指定小部件的初始化布局
 android:minHeight:小部件最小高度
 android:minWidth:小部件最小宽度
 android:previewImage:小部件列表显示的图标
 android:updatePeriodMillis:小部件自动更新的周期
 android:widgetCategory:小部件显示的位置,默认为home_screen表示只在桌面上显示

<3> 定义小部件的实现类

public class TestAppWidgetRemoteViews extends AppWidgetProvider{

      /**
        * 当小组件被添加到屏幕上时回调
       */
       @Override
       public void onEnabled(Context context) {
           super.onEnabled(context);
		    //启动TestAppWidgetRemoteViewsService服务
            context.startService(new Intent(context, TestAppWidgetRemoteViewsService.class));
       }

        /**
         * 当小组件被添加或每次被刷新时回调,更新时机由android:updatePeriodMillis来指定
         */
        @Override
        public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
            super.onUpdate(context, appWidgetManager, appWidgetIds);
         }
         /**
          * 当widget小组件从屏幕移除时回调
         */
          @Override
          public void onDeleted(Context context, int[] appWidgetIds) {
             super.onDeleted(context, appWidgetIds);
          }

         /**
          * 当最后一个该类型的小组件被从屏幕中移除时回调
         */
         @Override
         public void onDisabled(Context context) {
             super.onDisabled(context);
                    
         }

}

<4> 创建TestAppWidgetRemoteViewsService服务

public class TestAppWidgetRemoteViewsService extends Service {

    Timer timer;

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
          return null;
     }

    @Override
    public void onCreate() {
        super.onCreate();

        timer = new Timer();

        TimerTask timerTask = new TimerTask() {
             @Override
             public void run() {
                 updateTime();
             }
       };
      //服务启动时会每个1s去更新一次RemoteViews界面
      timer.schedule(timerTask,0,1000);

      }

      private void updateTime() {

          RemoteViews remoteViews = new RemoteViews(getPackageName(), R.layout.layout_widget);
          //将RemoteViews界面的TextView内容设置为时间,实现类似钟表的效果
          remoteViews.setTextViewText(R.id.test_app_widget_custom_tv,new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));

          AppWidgetManager manager = AppWidgetManager.getInstance(getApplicationContext());

          ComponentName componentName = new ComponentName(this,TestAppWidgetRemoteViews.class);

          manager.updateAppWidget(componentName,remoteViews);

        }

       @Override
       public int onStartCommand(Intent intent, int flags, int startId) {
            return super.onStartCommand(intent, flags, startId);
       }
}

<5> 在清单文件上声明小部件和服务

<service android:name=".TestAppWidgetRemoteViewsService"/>
		
		    <receiver android:name=".TestAppWidgetRemoteViews">
			
			    <intent-filter>
                    <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
                </intent-filter>
			
                <meta-data
                    android:name="android.appwidget.provider"
                    android:resource="@xml/custom_app_widget" />

            </receiver>
            
			AppWidgetProvider 继承自 BroadcastReceiver,本质是一个广播,所以需要在清单文件中注册。
			
			meta-data标签中的name属性是固定的android.appwidget.provider,resource属性则是刚才新建的小部件的配置信息的xml。
			
			intent-filter中的android.appwidget.action.APPWIDGET_UPDATE是必须加的,它作为小部件的标识存在,这是系统的规范,
			
			否则这个receiver就不是一个桌面小部件,并且也无法出现在手机的小部件列表里。

<6> 添加小部件到桌面

经过上面的5步就可以实现一个类似时钟的小部件,然后只需长按屏幕空白处,在小部件(或小工具)的列表中找到开发的小部件,拖到桌面即可。

三、RemoteViews的内部机制

通知栏和桌面小部件分别由NotificationManager和AppWidgetManager管理,而NotificationManager和AppWidgetManager通过Binder分别与SystemServer进程中的NotificationManagerService和AppWidgetService进行通信。

由此可见,通知栏和桌面小部件中的布局文件实际上是在NotificationManagerService和AppWidgetService中被加载的,而他们运行在系统的SystemService中,这就和我们的进程构成了跨进程通信的场景。

1、在主进程中,创建RemoteViews,由于它实现了Parcelable接口,因此可以通过Binder跨进程传输到SystemServer进程。
2、在SystemServer进程中,系统通过RemoteViews携带的包名属性获取应用资源,并加载RemoteViews携带的布局文件,得到一个View。这样RemoteViews就在SystemServer中完成加载了。
3、如果要对RemoteViews进行操作,可以在主进程中调用RemoteViews提供的set方法,系统将每一个View操作对应地封装成一个Action对象,然后通过Binder传输到SystemServer进程中,

在RemoteViews中添加一个Action对象,当NotificationManager或AppWidgetManager提交更新时,RemoteViews就执行apply方法来更新View,这会遍历所有暂存的Action对象并调用他们的apply方法来执行具体的View更新操作。

四、源码分析:

以一个set方法为例,比如setTextViewText方法。

setTextViewText方法:
public void setTextViewText(int viewId, CharSequence text) {
                setCharSequence(viewId, "setText", text);
            }
			
	        setTextViewText方法中调用了setCharSequence方法
			
		setCharSequence方法:
			
			public void setCharSequence(int viewId, String methodName, CharSequence value) {
                addAction(new ReflectionAction(viewId, methodName, ReflectionAction.CHAR_SEQUENCE, value));
            }

该方法中调用了addAction方法,传入一个ReflectionAction实例,ReflectionAction继承自Action,它是用反射调用的。

addAction方法:
private void addAction(Action a) {
                if (hasLandscapeAndPortraitLayouts()) {
                    throw new RuntimeException("RemoteViews specifying separate landscape and portrait" +
                              " layouts cannot be modified. Instead, fully configure the landscape and" +
                              " portrait layouts individually before constructing the combined layout.");
                }
                if (mActions == null) {
                    mActions = new ArrayList<Action>();
                }
                mActions.add(a);

                // update the memory usage stats(更新内存的使用情况)
                a.updateMemoryUsageEstimate(mMemoryUsageCounter);
            }

addAction方法,用了一个mActions集合来保存Action实例,然后更新已使用内存的统计情况

这样setTextViewText里面的内容就分析完了,通过上面的简单使用知道在设置完RemoteViews后,就是通过调用manager.notify(1002,builder.build());来发送通知了,接下来追踪这个notify方法。

notify方法:
public void notify(int id, Notification notification){
                notify(null, id, notification);
            }

			public void notify(String tag, int id, Notification notification){
                notifyAsUser(tag, id, notification, new UserHandle(UserHandle.myUserId()));
            }
	
	        public void notifyAsUser(String tag, int id, Notification notification, UserHandle user){
                INotificationManager service = getService();
                String pkg = mContext.getPackageName();
                // Fix the notification as best we can.
                Notification.addFieldsFromContext(mContext, notification);
                if (notification.sound != null) {
                    notification.sound = notification.sound.getCanonicalUri();
                    if (StrictMode.vmFileUriExposureEnabled()) {
                        notification.sound.checkFileUriExposed("Notification.sound");
                    }
                }
                fixLegacySmallIcon(notification, pkg);
                if (mContext.getApplicationInfo().targetSdkVersion > Build.VERSION_CODES.LOLLIPOP_MR1) {
                    if (notification.getSmallIcon() == null) {
                        throw new IllegalArgumentException("Invalid notification (no valid small icon): "+ notification);
                    }
                }
                if (localLOGV) Log.v(TAG, pkg + ": notify(" + id + ", " + notification + ")");
                final Notification copy = Builder.maybeCloneStrippedForDelivery(notification);
                try {
                    service.enqueueNotificationWithTag(pkg, mContext.getOpPackageName(), tag, id,copy, user.getIdentifier());
                } catch (RemoteException e) {
                    throw e.rethrowFromSystemServer();
                }
            }

这个方法中的内容比较多,最主要的就是INotificationManager service = getService();INotificationManager是系统服务,是在SystemServer进程添加的。

最后会调用enqueueNotificationWithTag方法。RemoteViews在进程间更新UI界面是通过AIDL的方式进行的。之后在这个进程 RemoteViews会执行它的apply方法或者reapply方法。

回到RemoteViews 的apply/reapply方法:
public class RemoteViews implements Parcelable, Filter {
                
				......
				public View apply(Context context, ViewGroup parent) {
                    return apply(context, parent, null);
                }

                /** @hide */
                public View apply(Context context, ViewGroup parent, OnClickHandler handler) {
                    RemoteViews rvToApply = getRemoteViewsToApply(context);
                 View result = inflateView(context, rvToApply, parent);
                    loadTransitionOverride(context, handler);
               rvToApply.performApply(result, parent, handler);
                return result;
                }

				......
                public void reapply(Context context, View v, OnClickHandler handler) {

                    RemoteViews rvToApply = getRemoteViewsToApply(context);
                    ......
                    rvToApply.performApply(v, (ViewGroup) v.getParent(), handler);

                }

                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);
                            //调用apply方法
                            a.apply(v, parent, handler);
                        }
                    }
                }
                ......
            }

apply方法和reapplay方法的区别在于:`apply会加载布局+更新界面,而reapply只更新界面。通知栏和桌面小部件在初始化的时候会调用apply方法,再后续更新的时候则调用reapply方法。

通过上面的分析知道performApply方法中的mActions就是在setTextViewText方法时将ReflectionAction对象添加到集合中的mActions。这里会遍历该集合调用Action的apply方法。

ReflectionAction的apply方法
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 {
                   getMethod(view, this.methodName, param).invoke(view, wrapArg(this.value));//采用反射的方法来更新View
               } catch (ActionException e) {
                   throw e;
               } catch (Exception ex) {
                   throw new ActionException(ex);
               }
             }
             ...
            }

采用了反射的方法来执行对View的更新操作。

setTextViewText, setBoolean, setLong, setDouble等set方法都使用了ReflectionAction,还有其他Action实现类,比如对应setTextViewSize的TextViewSizeAction,它没有用反射来实现,代码如下:

private class TextViewSizeAction extends Action {
               ...
                @Override
                public void apply(View root, ViewGroup rootParent, OnClickHandler handler) {
                  final TextView target = root.findViewById(viewId);
             if (target == null) return;
             target.setTextSize(units, size);
               }
               }
               ...
            }

之所以不使用反射来实现TextViewSizeAction,是因为setTextSize这个方法有2个参数,无法复用ReflectionAction。

五、RemoteViews的意义

RemoteViews最大的意义在于方便的跨进程更新UI。

1、当一个应用需要更新另一个应用的某个界面,我们可以选择用AIDL来实现,但如果更新比较频繁,效率会有问题,同时AIDL接口就可能变得很复杂。如果采用RemoteViews就没有这个问题,但RemoteViews仅支持一些常用的View,如果界面的View都是RemoteViews所支持的,那么就可以考虑采用RemoteViews。
2、利用RemoteViews加载其他App的布局文件与资源。
final String pkg = "cn.hudp.remoteviews";//需要加载app的包名
        Resources resources = null;
        try {
            resources = getPackageManager().getResourcesForApplication(pkg);
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
        if (resources != null) {
            int layoutId = resources.getIdentifier("activity_main", "layout", pkg); //获取对于布局文件的id
            RemoteViews remoteViews = new RemoteViews(pkg, layoutId);
            View view = remoteViews.apply(this, llRemoteViews);//llRemoteViews是View所在的父容器
            llRemoteViews.addView(view);
        }

了解RemoteViews之后想实现 一个 2个进程间模拟通知栏 其实很简单 原理如下:

<1> 创建两个Activity 在2个进程运行 (这里之所以在一个程序中创建2个进程是为了方便。。)

<2> 在一个Activity中发送一个广播 在广播中put一个remoteViews 因为它是Pracelable对象

<3> 在另一个Activity中接受这个广播 然后取出remoteViews对象 执行它的apply方法 之后把view添加到这个Activity的父布局中即可

如果A和B属于不同应用,那么B中的布局文件的资源id传输到A中以后很有可能是无效的,因为A中的这个布局文件的资源id不可能刚好和B中的资源id一样,

面对这种情况,就要适当的修改Remoteviews的显示过程的代码了。这里给出一种方法,既然资源不相同,那就通过资源名称来加载布局文件。

首先两个应用要提前约定好RemoteViews中的布局的文件名称,比如“layout simulated notification”,然后在A中根据名称找到并加载,

接着再调用Remoteviews 的的reapply方法即可将B中对View所做的一系列更新操作加载到View上了,关于applyHe reapply方法的差别在前面说了,这样历程就OK了

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