Jetpack Compose+无障碍开发的某信真实好友检测APP过程分享

1,999 阅读7分钟

背景

正在学习Jetpack Compose,这是第一个应用。
应用通过无障碍模拟转账操作并记录模拟转账结果,然后通过悬浮窗将结果显示通讯录联系人下方

开发语言及相关库:Kotlin、Room、ViewModel、Navigation、Paging

APP效果

首页在某信通讯录里显示

如想体验,请关注公众号:可乐多星,关注后会自动下发应用下载地址,或者公众号回复“007”也能获取地址。
关于好友关系检测原理及详情也请查看公众号文章
注:开发的是安卓应用,无IOS端

本文有两部分内容:第一部分是Compose的学习,第二部分是无障碍的使用

第一部分:从0开始Jetpack Compose

第一步

啥也不管直接在Android Studio里选择 New Project,然后在 Phone and Tablet 类别下,选择 Empty Compose Activity

创建Compose项目 这时一个Jetpack Compose项目就创建完成了,看起来是比较简单的 创建结果

第二步

查看Android Compose 官方教程,这个是最基础的教程,了解@Composable注解是创建可组合函数、@Preview是在Android Studio中预览函数,会看到Text、Button、Image、Icon等字面熟悉的元素,对于布局使用的是Row、Column、Box,列表是LazyRow、LazyColumn,还有界面修饰符Modifier,通过修饰符,您可以更改可组合项的大小、布局、外观,还可以添加高级互动,例如使元素可点击等。

Text(text = "文本")
Button(onClick = { /*TODO*/ },) {
    Text(text = "按钮")
}
Row {
    Image(
        painter = painterResource(R.drawable.profile_picture),
        contentDescription = "Contact profile picture",
    )
   Column() {
        Text(text = msg.author)
        Text(text = msg.body)
    }
}
LazyRow(content = )
LazyColumn(content = )

上边的内容对于一个简单的应用元素来说,已经完全够用了,通过相应的组合可以搭建出想要的界面,谷歌也给用户封装了一些常用组件Material 组件和布局  |  Jetpack Compose  |  Android Developers (google.cn)可以直接拿来用。

对于初学,我一开始的困惑点在于记不住组件的写法和太多的参数以及省略的大小括号,比如

一个Text的参数:

@Composable
fun Text(
    text: String,
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    fontSize: TextUnit = TextUnit.Unspecified,
    fontStyle: FontStyle? = null,
    fontWeight: FontWeight? = null,
    fontFamily: FontFamily? = null,
    letterSpacing: TextUnit = TextUnit.Unspecified,
    textDecoration: TextDecoration? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    overflow: TextOverflow = TextOverflow.Clip,
    softWrap: Boolean = true,
    maxLines: Int = Int.MAX_VALUE,
    onTextLayout: (TextLayoutResult) -> Unit = {},
    style: TextStyle = LocalTextStyle.current
)

一样的意思不同的写法,及省略的大小写括号:

Text(text = "文本")
Text("文本")
Row(content = {})
Row() {}
Row{}

后来仔细想一下,这其实是kotlin语法,Text写法是省略的参数名;Row的写法是Kotlin里的高阶函数和尾随 lambda

高阶函数,即接收其他函数作为参数的函数。当最后一个参数为 lambda 的高阶函数,您可以使用尾随 lambda 语法,将 lambda 表达式放在圆括号后面,而不是将其放在圆括号内

接下来就是练习、练习,不考虑项目架构、生命周期、网络等等之类,只是单纯练习使用Material组件和查看官方示例项目,及当组件的样式没法满足需要时能查看源码修改成想要的样式(PS:官方组件文档图片加载不出来,不知道有没有替代了网站)。

如在使用TabRow/ScrollableTabRow选项卡时,底部白色的指示器太长,需要将它改短 默认TabRow

查看源码

TabRow源码 有一个indicator指示器参数,类型为@Composable (tabPositions: List) -> Unit 的方法,并提供了默认方法,默认方法里调用了TabRowDefaults.Indicator(xxx)方法,参数是Modifier。

查看Modifier.tabIndicatorOffset方法(kotlin语法里的扩展函数)

tabIndicatorOffset 可以看到这里就是设置默认指示器的地方,指示器长度是和Tab长度是一样的,没法修改。将代码复制出来修改

val selectIndex = 0
TabRow(selectedTabIndex = selectIndex,indicator = @Composable { tabPositions ->

    val currentTabPosition = tabPositions[selectIndex]
    //修改指示器长度为Tab的一半
    val currentTabWidth by animateDpAsState(
        targetValue = currentTabPosition.width / 2,
        animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
    )
    //修改指示器偏移量为居中
    val indicatorOffset by animateDpAsState(
        targetValue = currentTabPosition.left + currentTabPosition.width / 4,
        animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
    )
    //自带的Indicator指示器,只需改Modifier就可以了
    TabRowDefaults.Indicator(
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentSize(Alignment.BottomStart)
            .offset(x = indicatorOffset)
            .width(currentTabWidth)
    )
}) {
    Tab(selected = false, onClick = {  }) {
        Text(text = "文本1")
    }
    Tab(selected = false, onClick = {  }) {
        Text(text = "文本2")
    }
}

修改后:

指示器修改后

第三步

会使用Compose组件和布局搭建界面后,接下来就是学习逻辑性的知识了,如页面跳转、处理系统返回按钮、请求运行时权限、如何异步操作、数据传递等等,具体可以查看官方文档:Compose 和其他库  |  Jetpack Compose  |  Android Developers (google.cn)

项目中用到了Room、ViewModel、Navigation、Paging等库,这些是之前学过了,这里不在展开说明。

开发中遇到其他问题及原因:

1、侧滑菜单能在所有界面触发。使用Navigation跳转打开一个新界面后,新界面也能侧滑出菜单

第一个界面能侧滑出菜单新打开的界面还能侧滑菜单
侧滑出菜单新打开的页面还能侧滑出菜单
注:上两张截图为官方示例项目中的Jetchat项目

真是有点费头发,官方Demo就是这个问题,我需要的是只能在首页能侧滑菜单,新打开的页面不能侧滑出菜单。抓了三根头发想了一下,应该是侧滑菜单的层级太高了或者是在最外层导致了,看一下Jetchat的代码

1651843711(1).png 果然是侧滑放在最外层了,这样一来,所有新打开的Compose界面都能侧滑出菜单,知道了原因,就能对应的改动了,只需要把侧滑放在首界面里就行了。

1651845045(1).jpg

2、弹窗会显示在新打开的界面上。例子:用户首次打开应用时,会显示隐私权限弹窗,点击隐私详情链接打开新界面后,弹窗还会显示。这个问题和侧滑菜单的问题是同一个原因:层级问题。这个项目是一个单Activity应用,整个Compose布局都是在一个窗口里,所以经常出现界面覆盖问题

Compose学习小结

就是看官方文档,练习使用Material组件,然后看官方示例项目,再自己写一个实际可用的小Demo项目。

第二部分:无障碍

无障碍之前也是没有用过,一阵网上搜索之后,才算入了门。

配置文件

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFeedbackType="feedbackAllMask"
    android:accessibilityFlags="flagIncludeNotImportantViews|flagReportViewIds|flagRetrieveInteractiveWindows"
    android:description="@string/accessibility_describe"
    android:notificationTimeout="10"
    android:canPerformGestures="true"
    android:canRetrieveWindowContent="true"
    />

无障碍执行手势操作需要Android SDK 24及以上,在app的build.gradle的minSdk设为24

defaultConfig {
    minSdk 24
}

模拟点击、滑动使用dispatchGesture方法

accessibilityService.dispatchGesture(gestureDescription, callback, null)

根据操作定义执行步骤:

执行步骤

经过相当麻烦的反复调式之后,就可以得到用户备注名及其对应的好友关系了,关系保存在本地数据库中,接下来就是要将关系显示在通讯录里。通过悬浮窗的方式。

悬浮窗

添加悬浮窗:
// 获取WindowManager服务
windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
val layoutParam = WindowManager.LayoutParams()
layoutParam.apply {
    type = when {
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 -> WindowManager.LayoutParams.TYPE_PHONE
        else -> WindowManager.LayoutParams.TYPE_TOAST
    }
    //不可点击模式
    flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
    //全屏且透明
    width = WindowManager.LayoutParams.MATCH_PARENT
    height = WindowManager.LayoutParams.MATCH_PARENT
    format = PixelFormat.TRANSPARENT
}
view = LayoutInflater.from(context).inflate(R.layout.float_app_view, null)
windowManager?.addView(view, layoutParam)

注意上边layoutParam的type使用的是TYPE_ACCESSIBILITY_OVERLAY而不是TYPE_APPLICATION_OVERLAY。在无障碍下使用TYPE_ACCESSIBILITY_OVERLAY则不需要申请悬浮窗权限就能显示悬浮窗了

<!--不需要添加-->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW"/>

另外layoutParam的flags要设置为不可点击、不可响应事件的模式,让事件穿透悬浮窗。

在通讯录下显示好友关系

通过getBoundsInScreen可以获取到控件在屏幕中的位置

val rect = Rect()
nodeInfo.getBoundsInScreen(rect)//获取控件在屏幕中的位置

将位置保存在List集合里,悬浮窗使用的View是一个自定义View,在onDraw里将List集合的位置和对应的文字绘制出来

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    if (!list.isNullOrEmpty()) {
        list!!.forEach {
            //不含手机状态栏,需要减掉
            canvas.drawText(it.text, it.x, it.y - statusBarHeight, mPaint.apply {
                color = it.color
                textSize = it.textSize
                isAntiAlias = true
            })
        }
    }
}

当屏幕滑动、或者不是在通讯录界面时,将List集合清空。

遇到的问题
  1. 一开始type使用的是 TYPE_APPLICATION_OVERLAY,但是发现在小米手机 Android 11上,悬浮窗后面的界面无法响应用户操作,无法点击、滑动。相同代码在华为手机 Android 10上是正常能响应用户操作。
  2. 悬浮窗的自定义的View继承SurfaceView时,在小米手机 Android 11上悬浮窗后的界面又无法响应用户操作了,华为手机 Android 10上是正常能响应用户操作,最后只能是继承View了。

有小伙伴知道原因的话,也请评论区留言

以上就是本文的全部内容,开发这个应用初衷只是为了学习技术,如有侵权,请联系删除。应用将在2022-08-04不可用,想体验的小伙伴请在文章开头获取链接。