• Android~快捷方式兼容适配



    背景

    2022年了,你们的APP创建快捷方式适配得怎么样了?最近收到用户反馈公司APP创建快捷方式无效点击无任何反应,然后我去看了下相关代码,发现代码最后一次提交是19年的了。想想19年到现在安卓都推出几个版本了,比较怀疑代码兼容性适配做的不好!找了几部手机对比了微信是如何做适配的,发现微信这块做得不怎么好,只要用户点击添加到桌面,总会提示已尝试添加到桌面的弹框,如果确实添加无效跳转到各个厂商的打开相关权限的指导页面,个人感觉用户体验糟糕透了。快捷方式这个功能对于用户还是蛮重要的,它能很方便打开我们APP中一些常用功能(虽然大多数人总是先打开APP再点下一步下一步…),于是自己花了点时间调研了安卓快捷方式的适配,方便大家参考。


    一、适配思路

    首先快捷方式的创建,Jetpack中有
    ShortcutManagerCompat ,它有静态、动态、固定几种快捷方式,具体如何使用官方文档比较详细,这里不再重复,本文针对的动态快捷方式的创建。
    通过查看ShortcutManagerCompat 提供的几个API源码,它内部已经做了一些适配。经过苦苦测试,发现使用它这套API不同厂商机子上结果难以把控。存在几个问题:

    • 低版本<25安卓有的可以重复创建,有的不可以
    • 同样的程序,有的手机功能正常,有的手机无任何反应(权限隐藏的比较深)
    • 不同系统版本和手机,权限管理、交互设计不一样

    看到这里,相信你大概知道微信为啥点击就弹框提醒了吧,它已经躺平,但我们不能放弃。
    掘金这篇文章不错Android 创建桌面快捷方式,从中我们可以看到各个厂商魔改安卓的思路,但我们真的对付不过来厂商,假如A厂商的高管又跑路去创办了C厂,那我们是不是又要花点时间分析它是如何实现的。为了用户体验稍微再好一点,我们的适配方案采取下面的流程:
    流程图

    二、实现细节

    1.判断快捷方式是否存在

    上述流程图中,可以看到我们调用到了两次这个api。先读取是否支持请求PinShortcut,读不到则遍历读取contentResolver提供的,都读不到就返回快捷方式不存在。

    fun hasShortCut(
        context: Context,
        id: String,
        title: String,
        result: ((Boolean, Exception?) -> (Unit))
    ) {
        val isReqPinShortcut =
            ShortcutManagerCompat.isRequestPinShortcutSupported(context) && (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1)
        val shortCuts =
            ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_PINNED)
        if (shortCuts.isNotEmpty() || isReqPinShortcut) {
            var hasShortcut = false
            shortCuts.find {
                it.id == id
            }?.let {
                hasShortcut = true
            }
            result.invoke(hasShortcut, null)
        } else {
            val resolver = context.contentResolver
            for (s in getCheckPackages(context)) {
                try {
                    val url = Uri.parse("content://${s}.settings/favorites?notify=true")
                    val cursor = resolver.query(
                        url,
                        arrayOf("title", "iconResource"),
                        "title=?",
                        arrayOf(title.trim()),
                        null
                    )
                    cursor?.run {
                        result.invoke((count > 0), null)
                        return
                    }
                } catch (e: Exception) {
    
                    e.printStackTrace()
                    result.invoke(false, e)
                }
            }
            result.invoke(false, null)
        }
    }
    private fun getCheckPackages(context: Context): Array<String> {
        val intent = Intent(Intent.ACTION_MAIN)
        intent.addCategory(Intent.CATEGORY_HOME)
        val res = context.packageManager.resolveActivity(intent, 0)
        val pkgName = res?.activityInfo?.packageName
        return if (pkgName.isNullOrEmpty()) {
            arrayOf(Const.LAUNCHER_SETTINGS)
        } else {
            arrayOf(Const.LAUNCHER_SETTINGS, pkgName)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54

    这个函数依赖于权限配置,我们还需要在清单文件中配置各个厂商的Launcher读取权限,这里说明一下,我们不考虑第三方Launcher了。原因是厂商Launcher不会让你随便卸载,除非你有root权限 爱搞机。

    
    
    <uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
    <uses-permission android:name="com.android.launcher.permission.UNINSTALL_SHORTCUT" />
    <uses-permission android:name="com.android.launcher.permission.READ_SETTINGS" />
    <uses-permission android:name="com.android.launcher.permission.WRITE_SETTINGS" />
    <uses-permission android:name="com.android.launcher.permission.READ_SETTINGS" />
    <uses-permission android:name="com.android.launcher2.permission.READ_SETTINGS" />
    <uses-permission android:name="com.android.launcher2.permission.WRITE_SETTINGS" />
    <uses-permission android:name="com.android.launcher3.permission.READ_SETTINGS" />
    <uses-permission android:name="com.android.launcher3.permission.WRITE_SETTINGS" />
    
    <uses-permission android:name="com.lge.launcher.permission.READ_SETTINGS" />
    <uses-permission android:name="com.lge.launcher.permission.WRITE_SETTINGS" />
    <uses-permission android:name="com.lge.launcher2.permission.READ_SETTINGS" />
    <uses-permission android:name="com.lge.launcher2.permission.WRITE_SETTINGS" />
    <uses-permission android:name="com.lge.launcher3.permission.READ_SETTINGS" />
    <uses-permission android:name="com.lge.launcher3.permission.WRITE_SETTINGS" />
    
    <uses-permission android:name="com.htc.launcher.permission.READ_SETTINGS" />
    <uses-permission android:name="com.htc.launcher.permission.WRITE_SETTINGS" />
    
    <uses-permission android:name="com.huawei.android.launcher.permission.READ_SETTINGS" />
    <uses-permission android:name="com.huawei.android.launcher.permission.WRITE_SETTINGS" />
    <uses-permission android:name="com.huawei.launcher2.permission.WRITE_SETTINGS" />
    <uses-permission android:name="com.huawei.launcher2.permission.READ_SETTINGS" />
    <uses-permission android:name="com.huawei.launcher2.permission.WRITE_SETTINGS" />
    <uses-permission android:name="com.huawei.launcher3.permission.READ_SETTINGS" />
    <uses-permission android:name="com.huawei.launcher3.permission.WRITE_SETTINGS" />
    
    <uses-permission android:name="com.sec.android.app.twlauncher.settings.READ_SETTINGS" />
    <uses-permission android:name="com.sec.android.app.twlauncher.settings.WRITE_SETTINGS" />
    
    <uses-permission android:name="com.oppo.launcher.permission.READ_SETTINGS" />
    <uses-permission android:name="com.oppo.launcher.permission.WRITE_SETTINGS" />
    
    <uses-permission android:name="com.bbk.launcher2.permission.READ_SETTINGS" />
    <uses-permission android:name="com.bbk.launcher2.permission.WRITE_SETTINGS" />
    
    <uses-permission android:name="com.meizu.flyme.launcher.permission.READ_SETTINGS" />
    <uses-permission android:name="com.meizu.flyme.launcher.permission.WRITE_SETTINGS" />
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    2.创建快捷方式

    主要是ShortcutManagerCompat的使用,然后配合BroadcastReceiver,附上关联的两个类这里不细说了。

    fun addShortcut(
        context: Context,
        id: String,
        icon: Int,
        title: String,
        intent: Intent
    ): Boolean {
        val shortcut = ShortcutInfoCompat.Builder(context, id)
            .setShortLabel(title)
            .setIcon(IconCompat.createWithResource(context, icon))
            .setIntent(intent)
            .build()
        val bundle = Bundle().apply {
            putString(Const.EXTRA_SHORTCUT_ID, id)
            putString(Const.EXTRA_SHORTCUT_LABEL, title)
        }
        val sender = IntentSenderHelper.getDefaultIntentSender(
            context,
            ShortcutBroadcastReceiver.ACTION, ShortcutBroadcastReceiver::class.java, bundle
        )
        val isReqPinShortcut = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
        return if(isReqPinShortcut) {
            ShortcutManagerCompat.requestPinShortcut(context, shortcut, sender)
        } else false
    }
    class ShortcutBroadcastReceiver : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            if (ACTION == intent.action) {
                val id = intent.getStringExtra(Const.EXTRA_SHORTCUT_ID)
                val label = intent.getStringExtra(Const.EXTRA_SHORTCUT_LABEL)
                Log.w(TAG, "onReceive: id = $id, label = $label")
                if(label.isNullOrEmpty() || id.isNullOrEmpty()){
                    return
                }
                ShortcutUtil.hasShortCut(context,id,label) { ret, err ->
                    Toast.makeText(context,"create result:$ret",Toast.LENGTH_SHORT).show()
                }
            }
        }
    
        companion object {
            const val ACTION = "com.vesync.shortcut.create"
        }
    }
    object IntentSenderHelper {
        fun getDefaultIntentSender(context: Context, action: String): IntentSender {
            return PendingIntent.getBroadcast(
                context, 0, Intent(action),
                PendingIntent.FLAG_ONE_SHOT
            ).intentSender
        }
    
        fun getDefaultIntentSender(
            context: Context,
            action: String,
            clz: Class<*>,
            bundle: Bundle?
        ): IntentSender {
            val intent = Intent(action)
            intent.component = ComponentName(context, clz)
            if (bundle != null) {
                intent.putExtras(bundle)
            }
            return PendingIntent.getBroadcast(context, 0, intent,PendingIntent.FLAG_ONE_SHOT).intentSender
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66

    3.修改删除快捷方式

    其实删除快捷方式这个api不叫删除,叫disable不可用,调用后图标会置灰用户点击后会提示你设置的提示语。删除的权限应该还是Launcher把控着的。修改函数在低版本上测试是不可用的,简单封装。

    fun updateShortCut(
        context: Context,
        id: String,
        icon: Int,
        title: String,
        intent: Intent
    ): Boolean {
        return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
            /*val addIntent = Intent(Const.ACTION_INSTALL_SHORTCUT).apply {
                    putExtra(Intent.EXTRA_SHORTCUT_NAME, title)
                    putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, Intent.ShortcutIconResource.fromContext(context, icon))
                    putExtra(Intent.EXTRA_SHORTCUT_INTENT, intent)
                }
                context.sendBroadcast(addIntent)*/
            false
        } else {
            val shortcut = ShortcutInfoCompat.Builder(context, id)
                .setShortLabel(title)
                .setIcon(IconCompat.createWithResource(context, icon))
                .setIntent(intent)
                .build()
            ShortcutManagerCompat.enableShortcuts(context, arrayListOf(shortcut))
            ShortcutManagerCompat.updateShortcuts(context, arrayListOf(shortcut))
        }
    }
    /**
     * 删除快捷方式
     */
    fun delShortCut(context: Context, clz: Class<*>, id: String, title: String) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
            val mainIntent = Intent(Intent.ACTION_MAIN).apply {
                setClass(context.applicationContext, clz)
                addFlags(Intent.FLAG_ACTIVITY_TASK_ON_HOME)
                addCategory(Intent.CATEGORY_LAUNCHER)
            }
            val delIntent = Intent(Const.ACTION_UNINSTALL_SHORTCUT).apply {
                putExtra(Intent.EXTRA_SHORTCUT_NAME, title)
                putExtra(Intent.EXTRA_SHORTCUT_INTENT, mainIntent)
            }
            context.sendBroadcast(delIntent)
        } else {
            ShortcutManagerCompat.disableShortcuts(
                context,
                arrayListOf(id),
                "$title has been removed."
            )
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    总结

    安卓适配坑很多,会感觉越测越没底,但我们还是要尝试一下,解决问题的思路比较重要。
    该方案中比较核心的点:

    1. 给用户比较好的体验(产品角度,用户点击有回应)
    2. 各厂商Launcher获取快捷方式以及相关权限适配
    3. 对快捷方式的理解
      安卓碎片化真的很严重,严重到你都不相信自己写的代码逻辑,作为一个移动开发者我们能做的就是协调好用户和厂商。

    附上源码,上面就是安卓适配的全部内容,如果写的不错欢迎点赞收藏,点个关注。

  • 相关阅读:
    IntelliJ IDEA安装教程
    js第六章
    森林防火综合解决方案
    this is biaoti
    【前端面试必知】JS面试之数据结构
    AtCoder Beginner Contest 267 (A~D)
    【JS】数据结构之栈
    超声波清洗机品牌哪些好用?好评不断的超声波清洗机推荐
    Ansible角色定制实例
    centos 安装php7.4,搭建hyperf,转发RDS
  • 原文地址:https://blog.csdn.net/Bluechalk/article/details/126336546