Android进阶知识树——RemoteViews详解

3,696 阅读9分钟

1、初识RemoteViews

在我们平时的开发中,使用RemoteViews的机会并不是很对,可能多数还是在自定义通知界面时,但RemoteViews凭借可以跨进程更新的特点,可以帮助我们实现不同的产品效果,Android中官方的使用就是通知和桌面小部件,今天就一起来看看它是如和使用和如何跨进程传输的;

简介

1.1、控制限制
  • 对于小工具可用的唯一手势
  1. 触摸
  2. 垂直滑动
1.2、 支持布局

RemoteViews虽然可以很容易的实现跨进程的控制视图,但并非所有的View都支持跨进程使用,根据GooGle官方文档指出只支持以下ViewGroup和View,不知持他们的子类和自定义View,所以在写RemoteViews的布局文件时应注意选择

  • 支持的布局
  1. FrameLayout
  2. LinearLayout
  3. RelativeLayout
  4. GridLayout
  • 支持的View
  1. 一般View:Button、ImageButton、TextView、ImageView、ProgressBar
  2. 集合:ListView、GridView、StackView、AdapterViewFlipper
  3. 其余View:AnalogClock、Chronometer、ViewFlipper

2、自定义通知界面

2.1、Notification

通知中的使用比较简单也比较固定,创建RemoteViews导入布局并设置点击事件,然后将视图设置为通知的contentView:

RemoteViews notificationLayout = new RemoteViews(getPackageName(), R.layout.notification);
notificationLayout.setOnClickPrndingIntent(…,…)//设置布局中的点击事件(单个View的PendingIntent)
notification.contentView = notificationLayout
notification.contentIntent = … // 设置整个通知的PendingIntent

3、AppWidget

另一个使用场景就是桌面小部件,桌面小部件确实丰富了产品的使用,更方便了用户的适应这点本人在开发中涉及到的很少,AppWidget的开发虽然比通知使用复杂一些但也是有章可循,只要遵循每一步的流程即可实现,下面一起实现一个桌面小部件:

3.1、AppWidgetProvider

AppWidgetProvider是BroadcastReceive的子类,主要用于接收小部件操作或修改时的广播意图,AppWidget会根据状态的不同发送以下广播:

  • ACTION_APPWIDGET_UPDATE:在每个小部件更新时发送广播
  • ACTION_APPWIDGET_DELETED:在每次删除小部件时发送广播
  • ACTION_APPWIDGET_ENABLED:第一次添加小部件时发送广播
  • ACTION_APPWIDGET_DISABLED:删除最后一个小部件时发送广播
  • ACTION_APPWIDGET_OPTIONS_CHANGED:当AppWidget内容修改时发送广播

AppWidgetProvider除了直接监听广播外,其内部简化了广播的使用,提供了不同状态的回调方法,在开发中也主要使用这些方法即可,具体如下:

  1. onUpdate():桌面小部件的更新方法,当用户添加App Widget时会回调一次,然后会按照updatePeriodMillis间隔循环调用
  2. onAppWidgetOptionsChanged():首次创建布局窗口时或窗口大小调整时回调
  3. onDelete():每次删除桌面小部件时都会回调此方法
  4. onEnabled(Context):仅在第一次添加AppWidget实例时回调此方法(可执行初始化操作,如:打开数据库)
  5. onDisabled(Context):当删除最后一个小部件时回调(可以执行清理操作:如删除数据库)

既然AppWidgetProvider是广播的子类,所以它的使用也必须在清单文件中完成注册:

<receiver android:name="MyAppWidgetProvider">
        <intent-filter>
        //配置AppWidget的意图过滤
       <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
       </intent-filter>
        // 添加设置appwidget_info的xml文件
       <meta-data android:name="android.appwidget.provider"
               android:resource="@xml/example_appwidget_info" />
</receiver>
3.2、ConfigActivity

ConfigActivity顾名思义,用来设置桌面小部件,它在第一次添加小部件时会直接接入配置界面,可以在其中提供RemoteViews的相关配置,在配置完成后退出活动即可自动更新视图,具体实现方式分两步:

  • 创建Activity并在清单文件中配置隐式启动
<activity android:name=".OtherActivity">
    <intent-filter>
        //必须设置APPWIDGET_CONFIGURE意图用于隐式启动活动
        <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
    </intent-filter>
</activity>
  • 在AppWidgetProviderInfo XML文件中声明配置活动
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
   android:configure="com.alex.kotlin.remoteview.OtherActivity">
</appwidget-provider>
  • 使用细节
  1. 在配置结束后在返回的setResult()中,必须返回本次修改的AppWidget的ID
3.3、AppWidgetProviderInfo

AppWidgetProviderInfo主要用于设置AppWidget的基本数据,如:布局、尺寸、更新频率等,所有信息设置在xml文件中,并在清单文件中配置xml文件:

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:minWidth="40dp"
    android:minHeight="40dp"
    android:updatePeriodMillis="86400000"
    android:previewImage="@drawable/preview"
    android:initialLayout="@layout/example_appwidget"
    android:configure="com.example.android.ExampleAppWidgetConfigure"
    android:resizeMode="horizontal|vertical"
    android:widgetCategory="home_screen">
</appwidget-provider>

xml标签属性:

  1. minWidth 和 minHeight :默认情况下应用程序小部件的最小空间
  2. updatePeriodMillis:定义App Widget框架调用update()方法的频率
  3. android:initialLayout:指向定义应用程序小部件布局的布局资源
  4. android:previewImage:指定应用程序小部件添加时的预览图片
  5. android:widgetCategory:配置应用程序窗口小部件是否可以显示在主屏幕(Home Sub屏幕)、锁定屏幕(KEGHARID)上
  6. resizeMode :指定可调整小部件的调整规则(水平或竖直)
3.4、桌面小部件实战
  • 创建AppWidgetProvider的继承类重写update()
class WidgetProvider : AppWidgetProvider() {
    override fun onUpdate(context: Context?, appWidgetManager: AppWidgetManager?, appWidgetIds: IntArray?) {
        super.onUpdate(context, appWidgetManager, appWidgetIds)
        val remoteView = RemoteViews(context?.packageName,R.layout.remoteview)
        val pendingIntentClick = PendingIntent.getActivity(context,0,Intent(context,MainActivity::class.java),0)
        remoteView.setBitmap(R.id.imageView,"setImageBitmap", BitmapFactory.decodeResource(context?.resources,R.drawable.a_round))
        remoteView.setOnClickPendingIntent(R.id.button,pendingIntentClick)
        for (id in appWidgetIds!!){
            appWidgetManager?.updateAppWidget(id,remoteView)
        }

上面程序中创建了AppWidgetProvider的子类,在onUpdate()中创了RemoteView并设置数据,最后使用AppWidgetManager更新AppWidget

  • 创建xml文件配置AppwidgetProviderInfo数据
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="100dp"
android:minHeight="100dp"
android:updatePeriodMillis="86400000"
android:initialLayout="@layout/remoteview"
android:previewImage="@mipmap/ic_launcher_round"
android:configure="com.alex.kotlin.remoteview.OtherActivity"
android:widgetCategory="home_screen">
</appwidget-provider>
  • 在清单文件中注册WidgetProvider
<receiver android:name=".test.WidgetProvider">
    <meta-data android:name="android.appwidget.provider"
        android:resource="@xml/widget_provider_info"/>
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
        <action android:name="com.example.administrator.WidgetProvider.action.click"/>
    </intent-filter>
</receiver>
  • 运行程序后添加桌面小部件效果如下
    在这里插入图片描述
  • 添加ConfigActivity后(添加方式见上面)运行效果:
  1. 在预览界面为圆形Android 图标
  2. 添加Widget后界面显示圆形Icon和Button
  3. 添加配置界面,在配置界面中修改为 方形Icon 和Button
    在这里插入图片描述
3.5、列表小部件

在桌面小部件使用中,除了上面的使用还有一种就是列表小部件,即在桌面中添加显示数据的列表如:ListView;此处不能使用RecyclerView,而且在此处的ListView使用方式也有所不同,在创建列表小部件之前,先介绍两个类:

  1. RemoteViewsService:提供创建RemoteViewsFactory的实例
  2. RemoteViewsFactory:它的作用就和ListView使用的Adapter作用相同,都是根据数据设置ListView的Item

下面一起实现一个列表的AppWidget,主要实现步骤如下:

  • 实现并注册RemoteViewsService服务,重写方法用于创建RemoteViewsFactory的实例
class RemoteServiceImpl : RemoteViewsService() {
    override fun onGetViewFactory(intent: Intent?): RemoteViewsFactory {
       return WidgetFactory(applicationContext)
    }
}

  • 在清单文件中注册服务
<service android:name=".appwidget.AppWidgetService"
//设置RemoteViews的权限
android:permission="android.permission.BIND_REMOTEVIEWS" />
  • 在AppWidgetProvider中的update()中初始化RemoteView和列表
const val CLICK_ACTION: String = "com.example.administrator.WidgetProvider.action.click"
val intent = Intent(context, RemoteServiceImpl::class.java)  //设置绑定List数据的Service
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetIds!![0])
intent.data = Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))
remoteView.setRemoteAdapter(R.id.listView, intent)  //为RemoteView的List设置适配服务
 
val tempIntent = Intent(CLICK_ACTION)  //创建点击的临时Intent
tempIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
remoteView.setPendingIntentTemplate( //设置ListView中Item临时占位Intent
    R.id.recyclerView,PendingIntent.getBroadcast(context, 0, tempIntent, PendingIntent.FLAG_CANCEL_CURRENT))
appWidgetManager?.updateAppWidget(appWidgetIds!![0],remoteView)

针对上面的程序有几点说明:

  1. 创建Intent用于启动服务RemoteServiceImpl,并使用setRemoteAdapter将其设置给ListView
  2. 点击事件由PendingIntent传递的,而对于列表的点击事件为避免为每个item创建PendingIntent,此处使用setPendingIntentTemplate()为真个ListView设置占位的PendingIntent
  • 实现RemoteViewsFactory类重写方法,为list的每个item设置数据
  public RemoteViews getViewAt(int i) {      // 设置每个item的数据 
  val remoteViews = RemoteViews(context.packageName,R.layout.remoteview)
  remoteViews.setTextViewText(R.id.button,listArray[position])
  val intent = Intent(WidgetProvider.CLICK_ACTION)
  intent.putExtra("Extra",listArray[position])
  remoteViews.setOnClickFillInIntent(R.layout.remoteview,intent). //设置占位填充的Intent
  return remoteViews
 }
  1. RemoteViewsFactory中的方法和Adapter基本一样使用也很简单
  2. 在RemoteViewsFactory()中为每个Item设置数据时,使用setOnClickFillInIntent()填充每个Item的点击事件,此处设置的Intent会和前面设置的临时PendingIntent共同完成点击操作
  • 运行程序添加后效果:

在这里插入图片描述

  • 响应列表的点击事件 在设置ListView的点击事件时使用PendingIntent.getBroadcast(),所以Item的点击事件是以广播形式发送的,要响应点击操作只需在AppWidgetProvider的onReceiver()中接收广播并更新AppWidget界面;
override fun onReceive(context: Context?, intent: Intent?) {
        super.onReceive(context, intent)
        when (intent?.action) {
            CLICK_ACTION -> {
             val positionDrawable = intent.getIntExtra("Extra", 0)
                val remoteView = RemoteViews(context?.packageName, R.layout.remoteview)
                remoteView.setImageViewBitmap(
                    R.id.imgBig,
                    BitmapFactory.decodeResource(context?.resources, WidgetFactory.getDrawable(positionDrawable))
                )
                val manager = AppWidgetManager.getInstance(context)
                val componentName = ComponentName(context, WidgetProvider::class.java)
                manager.updateAppWidget(componentName,remoteView)
            }
        }
    }

上面代码实现的是在点击ListView的Item时,将RemoteViews中的大图片换成点击Item对应的图片,效果如下:

在这里插入图片描述

4、RemoteViews的工作原理

RemoteViews主要用途是通知 和 桌面小部件,这两者分别由NotificationManger 和 Appwidgetmanger 管理,NotificationManger 和 AppwidgetManger 通过Binder 与SystemServer中的NotificationMangerServer 和 AppwidgetServer 实现进程通信, 那它是如何跨进程控制布局的呢?我们设置的布局又是何时被加载的呢?带着这个问题我们一起分析下其内部的工作原理:

  • setTextViewText(int viewId, CharSequence text)
public void setTextViewText(int viewId, CharSequence text) {
    setCharSequence(viewId, "setText", text);  // 调用setCharSequence,传入方法名
}
public void setCharSequence(int viewId, String methodName, CharSequence value) {
    addAction(new ReflectionAction(viewId, methodName, ReflectionAction.CHAR_SEQUENCE, value));    // 添加一个反射的Action
}
private void addAction(Action a) {
...
if (mActions == null) {
        mActions = new ArrayList<Action>();   
    }
    mActions.add(a);        // 将Action 储存在集合中
    a.updateMemoryUsageEstimate(mMemoryUsageCounter);
}

本次原理分析以setTextViewText()为例,上面程序执行以下操作:

  1. 调用setCharSequence()并传入方法名和参数值
  2. 创建ReflectionAction实例,保存操作View 的ID、方法名、参数值
  3. 将ReflectionAction实例保存在mActions中

AppWidgetManager提交更新之后RemoteViews便会由Binder跨进程传输到SystemServer进程中 ,之后在这个进程 RemoteViews会执行它的apply方法或者reapply方法

  • apply()
  1. 作用:加载布局到ViewGroup中
  2. 与apply()方法作用类似的还有reApply(),二者区别在于:apply加载布局并更新布局、reApply只更新界面
public View apply(Context context, ViewGroup parent, OnClickHandler handler) {
    RemoteViews rvToApply = getRemoteViewsToApply(context);  // 获取之前创建时保存的RemoteViews
    View result = inflateView(context, rvToApply, parent);  // 调用inflateView()导入布局
    loadTransitionOverride(context, handler);
    rvToApply.performApply(result, parent, handler);  // 调用 performApply 执行apply()
    return result;
}

执行操作:

  1. 获取创建的RemoteViews实例
  2. 通过调用inflateView()方法加载布局到布局容器parent中
  3. 调用RemoteViews的performApply()执行保存的Action
  • inflateView()
private View inflateView(Context context, RemoteViews rv, ViewGroup parent) {
...
LayoutInflater inflater = (LayoutInflater)
        context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View v = inflater.inflate(rv.getLayoutId(), parent, false);  // 导入布局
}

inflateView()中只是获取LayoutInflater实例,然后根据保存的layout文件,将视图导入布局到parent中

  • performApply ()方法
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);   // 获取之前储存的 反射的Action
            a.apply(v, parent, handler);  // 调用Action的Apply()方法
        }
    }
}

performApply中就干了一件事,取出之前保存Action的集合mActions,循环执行其中的每个Action执行其apply(),从上面我们直到此处保存的是ReflectionAction实例,所以一起看看ReflectionAction中apply()方法;

  • ReflectionAction中apply()
@Override
public void apply(View root, ViewGroup rootParent, OnClickHandler handler) {
    final View view = root.findViewById(viewId); //获取Action保存View的Id
    
    Class<?> param = getParameterType();   // 一眼就看出这是反射获取
    if (param == null) {
        throw new ActionException("bad type: " + this.type);
    }
    try {
        getMethod(view, this.methodName, param).invoke(view, wrapArg(this.value));
    } catch (ActionException e) {
        throw e;
    } catch (Exception ex) {
        throw new ActionException(ex);
    }
}

RemoteViews的工作过程总结如下:

  1. RemoteViews在调用set方法后并不会直接更新布局,此时会创建反射Action保存在ArrayList中
  2. RemoteView在跨进程设置后,通过调用apply()和reapply()加载和更新布局
  3. 加载布局完成后,从ArrayList中遍历所有的Action,执行其apply()
  4. 在apply()方法中,根据保存的方法名和参数,反射执行方法修改界面