[Android]摸鱼计划:仿生人会梦到电子圣诞树吗

2,964 阅读7分钟

摸鱼计划简介
将代码应用在工作之外的地方是一件令人愉悦的事情,这种行为通常被定义为摸鱼。摸鱼行为会产生大量的垃圾代码,但偶尔也会有需要新知识的时候,把摸鱼学到的知识用在工作里的时候,又可以获得一份愉悦。两件快乐的事重合在一起,而这两份快乐又会给我带来更多的快乐,得到的本应是梦境般的幸福时光,但是,为什么会……

讲个故事

圣诞节快到了,虽然这种不放假的节日对于社畜来说没有任何额外的意义,但每个白学家心里都不会忘记那一年 12 月 24 号的夜晚……咳咳,打住🤭

去年年底我在微博抽奖中了一棵小小的圣诞树,可惜狭窄的屋子里没什么合适的位置摆放,只在 25 号晚上在桌上摆了一下。在节日气氛和奇怪的仪式感加成下,似乎生活变得有趣了一点点。

于是今年,我想给大家送点「概念圣诞树」,比起实物的圣诞树,更多的是给大家一种收到圣诞树的心情。(并不是因为没有钱,不是)

添加和尺寸调整:

点击效果:

(看起来朴素了点…但我真的尽力了……)

这个功能可以用 AppWidget 实现。AppWidget 的翻译方式很多,我们使用官方文档的翻译方式「应用微件」。应用微件是伴随 Android 系统出现的基本功能,可是对于大多数应用来说都是鸡肋,甚至很多教程中都不会提到怎么写应用微件。而今年 iOS 也增加了类似的功能,终于是大家一起鸡肋了。

PS:一时兴起,也摸索着写了一个 iOS 用的,不过没办法发布,就当是给自己的小礼物吧(没错我背叛了Android 阵营🧐)

背景似乎不能做成透明的,也可能是我没找到吧。

实现方法

应用微件的基本概念

相信大家至少都用过桌面时钟这个应用微件,可以通过桌面时钟来了解一下应用微件的功能。首先,应用微件在桌面编辑的状态下可添加、删除、移动、改变尺寸;有的时钟在尺寸改变之后可能调整排版,或者增减显示的内容;在没有操作的情况下,微件的数据也会更新;锁屏再打开可以看到时间的跳跃,说明更新的时机受系统限制。

创建一个应用微件需要两部分工作:配置信息和处理事件。

配置

第一步:继承 AppWidgetProvider

class SampleAppWidgetProvider: AppWidgetProvider() {
    override fun onReceive(context: Context?, intent: Intent?) {
        super.onReceive(context, intent)
    }

    override fun onUpdate(
        context: Context?,
        appWidgetManager: AppWidgetManager?,
        appWidgetIds: IntArray?
    ) {
        super.onUpdate(context, appWidgetManager, appWidgetIds)
    }

    override fun onAppWidgetOptionsChanged(
        context: Context?,
        appWidgetManager: AppWidgetManager?,
        appWidgetId: Int,
        newOptions: Bundle?
    ) {
        super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
    }

    override fun onDeleted(context: Context?, appWidgetIds: IntArray?) {
        super.onDeleted(context, appWidgetIds)
    }

    override fun onEnabled(context: Context?) {
        super.onEnabled(context)
    }

    override fun onDisabled(context: Context?) {
        super.onDisabled(context)
    }

    override fun onRestored(context: Context?, oldWidgetIds: IntArray?, newWidgetIds: IntArray?) {
        super.onRestored(context, oldWidgetIds, newWidgetIds)
    }
}

方法可以根据业务功能选择重写,详情在第二部分的【处理事件】中讲解。

第二步:编写应用微件的配置 xml

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:minWidth="80dp"
    android:minHeight="120dp"
    android:resizeMode="horizontal|vertical"
    android:updatePeriodMillis="0"
    android:previewImage="@drawable/ic_christmas_tree"
    android:initialLayout="@layout/widget_tree"
    android:configure="com.moqi.sample.TreeConfigAct"
    android:widgetCategory="home_screen">
</appwidget-provider>

应用微件的尺寸并不是随意的,必须是桌面单个“格子”尺寸的倍数。

  • minWidth | 最小宽度,初始化时的宽度
  • minHeight | 最小高度,初始化时的高度
  • resizeMode | 可以修改尺寸的方向
  • updatePeriodMillis | 自动更新时间间隔,由广播触发更新 onUpdate
  • previewImage | 预览图片,在桌面编辑状态下可以看到
  • initialLayout | 添加到桌面时初始的布局,只能用 RemoteView 支持的 View
  • configure | 配置 Activity,可选,配置了的话添加微件时会打开这个页面
  • widgetCategory | 微件的种类,我们选择桌面微件

第三步:在 AndroidManifest 中注册

<receiver android:name=".widget.TreeWidgetProvider"
        android:enabled="true">
        <intent-filter>
            <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        </intent-filter>

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

receiver 的 name 指向自定义的 AppWidgetProvider,meta-data 的 resource 指向 xml 配置文件。

处理事件

AppWidgetProvider 是一个 BroadcastReceiver,所有事件都是通过广播发过来的,AppWidgetProvider 中对固定的事件进行了封装,实现了类似生命周期的回调。

每个应用微件可以在桌面上添加多次,不同的应用微件可以通过 appWidgetId 区分,更新的时候也可以选择对应的微件id单独更新或者遍历更新全部微件。

onReceive 则是处理生命周期之外的其他广播的地方,自定义的广播可以在此处理。比如点击圣诞树可以开灯,就是点击的时候控制灯光层 ImageView 的显隐。RemoteViews 的点击事件不能直接设置 OnClickListener,只能设置一个 PendingIntent。所以需要一个广播的 PendingIntent 发给自己,再通过 onReceive 接收事件修改 RemoteViews 实现功能。

发送广播:

    val lightOn = Intent().apply {
        setAction(ACTION_REFRESH)
        setComponent(ComponentName(context!!, TreeWidgetProvider::class.java))
        putExtra("light", true)
    }
    val lightOff = Intent().apply {
        setAction(ACTION_REFRESH)
        setComponent(ComponentName(context!!, TreeWidgetProvider::class.java))
        putExtra("light", false)
    }
    val lightOnIntent = PendingIntent.getBroadcast(context, 1000, lightOn, PendingIntent.FLAG_UPDATE_CURRENT)
    val lightOffIntent = PendingIntent.getBroadcast(context, 1001, lightOff, PendingIntent.FLAG_UPDATE_CURRENT)
    val remoteTree = RemoteViews(context!!.packageName, R.layout.widget_tree)
    remoteTree.setOnClickPendingIntent(R.id.remote_iv_tree, lightOnIntent)
    remoteTree.setOnClickPendingIntent(R.id.remote_iv_tree_light, lightOffIntent)
    AppWidgetManager.getInstance(context).updateAppWidget(appWidgetIds, remoteTree)

RemoteViews 是一种跨进程使用的 View 的包装,虽然实现很复杂,但 API 很清晰,可以通过函数名判断功能是什么。点击事件也是包装到另一个进程中执行,所以不能用类似 OnClickListener 的接口来实现,而是要用 Intent。我们的功能是点击修改应用微件本身,只需要发个广播给自己就好了。

广播必须使用 PendingIntent 发送,跟 Notification 相似。PendingIntent 有一个小小的坑,它的 get 系列方法会判断是否已存在相同参数的 PendingIntent 并复用,而判断相同的方式不会比较 Intent 附加的 extras 内容,所以要区分开关灯的 PendingIntent 需要使用两个不同的 requestCode。

接受广播并处理:

// onReceive 中
    if (intent != null){
        val action = intent.action
        when(action){
            ACTION_REFRESH -> {
                val lightOn = intent.getBooleanExtra("light", false)
                val remoteTree = RemoteViews(context!!.packageName, R.layout.widget_tree)
                if (lightOn){
                    remoteTree.setViewVisibility(R.id.remote_iv_tree_light, View.VISIBLE)
                } else {
                    remoteTree.setViewVisibility(R.id.remote_iv_tree_light, View.GONE)
                }
                AppWidgetManager.getInstance(context).updateAppWidget(ComponentName(context, TreeWidgetProvider::class.java), remoteTree)
            }
        }
    }
   

用自定义的 action 过滤广播,记得在 AndroidManifest.xml 中 AppWidgetProvider 的 intent-filter 里补上 action。

还要注意的是,修改 RemoteViews 之后必须调用 updateAppWidget 才能生效,updateAppWidget 在 onUpdate 里能指定 id 更新桌面微件,在我们的 onReceive 中无法直接得到是哪个桌面微件被点击了,所以只能全部更新。(想区分也有办法,在 onUpdate 里给不同的桌面微件添加不同的 PendingIntent 并把 id 作为参数传过来即可)

知识扩展

为什么使用 RemoteView 而不是获取 View 对象呢?

Android 系统中,每个 App 都运行在独立的进程中,App 之间是从虚拟机的层面上隔离开的,两个 App 在内存中完全不互通,必须选择合适的进程间通信方式进行通信。应用微件是直接在 Launcher 应用中添加到桌面上的一个 View,View 对象本身在 Launcher 的进程中,而我们的 AppWidgetProvider 实际上是我们 App 的一部分,是注册在我们的 AndroidManifest.xml 中的一个 BroadcastReceiver。Launcher 对我们应用微件的通信方式是广播,我们要对 Launcher 里属于我们的 View 进行操作,使用的就是 RemoteView。

RemoteView 如何实现跨进程通信

跨进程通信是一个比较高级的话题,这里不深入原理,只说明 RemoteView 的实现方式。RemoteViews 是一个 Parcelable,它本身只作为跨进程通信的数据本身,经历创建和执行各种 setter 方法之后,还需要其他的跨进程通信方式将 RemoteViews 发给接受者。

在 AppWidgetProvider 中,我们通过AppWidgetManager.getInstance(context).updateAppWidget(appWidgetIds, remoteViews) 进行跨进程通信,本质是 Binder 机制,与其他的 WindowManager 之类的系统服务相似,通过 getSystemService 获取。

后记

虽然没什么技术含量,但这次还是把 apk 上传了 github,如果看到这里了,不妨去下载体验一下。下载地址:github.com/moqi-Git/An…

源码没有单开项目,是 demo 项目中的一个 module,代码可以在这里找到。

提前祝大家圣诞快乐🎄、元旦快乐、以及 2021 年每天都快乐~

(以及悄悄祝各位白学家不去滑雪)