本次开发主要内容

本次开发主要完成了:

  • 新建移动端项目
  • Android原生层权限管理的相关功能
  • Android原生层无障碍服务开发
  • Android原生层全局账单对话框弹出
  • Flutter层权限授予页面

Flutter安装

本项目的移动端采用 Flutter 3.38.10 版本,仅支持 Android 端,采用 FVM 进行 Flutter 版本管理。
首先安装 FVM:
由于笔者使用的是 macOS,故直接使用 Homebrew 即可很方便地安装 FVM:

brew tap leoafarias/fvm
brew install fvm

然后通过 FVM 安装 Flutter:

fvm install 3.38.10

若遇到安装时出现网络问题,可配置终端代理。此处不赘述。

创建项目

安装好 Flutter 后,使用 FVM 设定全局 Flutter 版本(若只有一个 Flutter 版本,无需此步):

fvm global 3.38.10

用 Flutter 新建项目:

flutter create --template=app --platforms android ./

若不指定--platforms 参数,会要求提供 iOS 开发者凭证(Flutter 默认同时创建 iOS 和 Android 项目)

第三方依赖

需要用到 Pigeon 进行代码生成,以及目前需要使用 Fluttertoast 库进行轻量级的用户提示和 get 进行路由管理及状态管理,所以在项目的 pubspec.yaml 文件中添加:
在 dev_dependencies块中添加 pigeon:

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0
  pigeon: ^26.3.3 # 在此处添加Pigeon

在 dependencies 块中添加 Fluttertoast:

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8
  fluttertoast: ^9.0.0 # Fluttertoast
  get: ^4.7.3 # Get

Pigeon 是开发时的代码生成工具,所以添加到 dev_dependencies中,在打包成 APK 时不会被包括,能够减小包体积

编写Pigeon生成协议并生成代码

要使用 Pigeon 进行代码生成,需要先编写告知 Pigeon 生成什么代码,以及输出代码的位置等信息。
我们在项目根目录新建一个名为 pigeon.dart 的文件,填入以下内容:

import 'package:pigeon/pigeon.dart';

(
  PigeonOptions(
    dartPackageName: "consumption_analyst",
    dartOut: "lib/src/messages.g.dart",
    dartOptions: DartOptions(),
    kotlinOut: 'android/app/src/main/kotlin/com/example/generated/Messages.g.kt',
    kotlinOptions: KotlinOptions(),
  )
)
()
abstract class PermissionManager {
  /// 检查当前是否有悬浮窗权限, 没有返回 false,有返回 true
  bool checkOverlayPermission();
  /// 若当前无悬浮窗权限,该方法将跳转至系统设置界面,引导用户授权
  
  void requestOverlayPermission();
}

()
abstract class AccessibilityManager {
  /// 检查是否有无障碍权限
  bool checkAccessibilityPermission();
  
  
  void requestAccessibilityPermission();

  /// 开启页面监听服务
  void startForegroundService();
}

@ConfigurePigeon 注解中传入一些元信息,例如,Kotlin 代码(对应 Android 原生)的输出路径、Dart 应用包名、dart 代码输出路径等等
然后我们使用@HostApi 注解表示在 Android 进行实现,在 Flutter 端进行调用的类,注意需要声明未抽象类。
这里我们定义了两个类 PermissionManager 和 AccessibilityManager,分别用来管理悬浮窗权限和无障碍功能。
定义好后,运行:

dart run pigeon --input pigeon.dart

检查 lib/src/ 目录,即可看到生成的 Dart 代码,检查android/app/src/main/kotlin/com/example/generated即可看到生成的 Android 代码。

PermissionManager实现

在 Android 工程中新建包:android/app/src/main/kotlin/com/example/generateImpls
在该包下新建PermissionManagerImpl.kt,创建类 PermissionManagerImpl,让其实现 PermissionManager 接口(先前由 Pigeon 生成):

class PermissionManagerImpl(private val activity: MainActivity): PermissionManager {
	//...
}

我们在 PermissionManager 接口中定义了两个方法:checkOverlayPermission和requestOverlayPermission。接下来一一实现:

checkOverlayPermission

检查权限的方法checkOverlayPermission:

override fun checkOverlayPermission(): Boolean {
   return Settings.canDrawOverlays(activity)
}

利用我们之前在构造函数中传入的 MainActivity实例,将其传入 Settings.canDrawOverlays,系统会返回当前应用是否有显示悬浮窗的权限

requestOverlayPermission

接下来实现请求权限的方法requestOverlayPermission:

override fun requestOverlayPermission(callback: (Result<Unit>) -> Unit) {
    if (checkOverlayPermission()) {
        callback(Result.success(Unit))
        return
    }

    val intent = Intent(
        Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
        "package:${activity.packageName}".toUri()
    )

    activity.setOverlayPermissionCallback {
        callback(Result.success(Unit))
        // 防止重复调用 callback
        activity.setOverlayPermissionCallback(null)
    }

    activity.overlayPermissionLauncher.launch(intent)
}

先检查当前是否已经有权限,调用 checkOverlayPermission 检查即可,如果有权限了直接调用 callback 并传入 Result.success(Unit)通知 Flutter 层。

我们在定义Pigeon协议文件时,给该方法加了一个@async 注解,这会使生成的接口方法带一个 callback 参数,只有调用该参数或,Flutter 侧才视为该方法调用完毕,并将结果存入 Future 对象中。
我们没有直接让 requestOverlayPermission 方法返回是否申请成功,Flutter 侧在调用此方法后还需再次调用 checkOverlayPermission 检查权限是否申请成功,这提供了最大的灵活性。

然后我们使用Settings.ACTION_MANAGE_OVERLAY_PERMISSION初始化一个 Intent,并同时传入本应用的包名,这会让打开权限授予页面时,自动滑动到本 APP 的位置,改善用户体验。
为了实现用户在从系统的设置界面返回后才调用 callback 在 Flutter 层结束函数,我们需要配合Android 提供的 ActivityResultLauncher实现,当然,也可以通过 onResume 生命周期函数 + 标志变量的方式实现,但是过于麻烦且增加复杂度,所以这还是采用前者。
上述代码使用了 MainActivity 的 setOverlayPermission方法,以及属性 overlayPermissionLauncher,这其实是我提前在 MainActivity 中定义好的,代码如下(已省略无关代码):

class MainActivity : FlutterFragmentActivity() {
    val overlayPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) {
        overlayPermissionCallback?.invoke()
    }

    private var overlayPermissionCallback: (() -> Unit)? = null

    fun setOverlayPermissionCallback(callback: (() -> Unit)?) {
        this.overlayPermissionCallback = callback
    }
}

首先我们要修改 MainActivity 的父类,Flutter 项目创建时,默认是继承自 FlutterActivity,但是 FlutterActivity 直接继承自 Activity,没有 registerForActivityResult 方法,所以我们要修改MainActivity 的父类为 FlutterFragmentActivity,该类继承自FragmentActivity -> ComponentActivity,可以使用 registerForActivityResult 方法。
这样一来,结合requestOverlayPermission的代码,整个回调流的逻辑就很清晰了。只是还有一个地方需要注意,在requestOverlayPermission中,我们在调用setOverlayPermissionCallback时,需要在传入的回调函数最后再次调用setOverlayPermissionCallback并将回调函数设为 null,这是因为与 Flutter 层通信的 callback 必须调用且仅调用一次,如果不取消回调,那么可能会多次调用或者使 MainActivity 持有 callback 实例过久,造成 FlutterEngine 内部内存泄漏。

AccessibilityManager 实现

checkAccessibilityPermission

AccessibilityManager 的 checkAccessibilityPermission 方法实现:

override fun checkAccessibilityPermission(): Boolean {
    // 系统无障碍服务总开关是否已开启
    val enabled = Settings.Secure.getInt(
        activity.contentResolver,
        Settings.Secure.ACCESSIBILITY_ENABLED, 0
    ) == 1

    if (!enabled) return false

    val settingValue = Settings.Secure.getString(
        activity.contentResolver,
        Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
    ) ?: return false

    val services = TextUtils.SimpleStringSplitter(':')
    services.setString(settingValue)

    while (services.hasNext()) {
        val service = services.next()
        if (service.equals(SERVICE_NAME, ignoreCase = true)) {
            return true
        }
    }
    return false
}

这个实现有点复杂,Android 中的无障碍服务有一个总开关,所以首先我们需要通过

Settings.Secure.getInt(
    activity.contentResolver,
    Settings.Secure.ACCESSIBILITY_ENABLED, 0
) == 1

来检查用户是否开启了无障碍服务总开关,若总开关都没开启,自然无法授予我们的应用无障碍权限。当无障碍总开关开启后,我们就可以检查是否授予本应用无障碍权限了。
这里检查本应用是否拥有无障碍权限,系统没有提供现成的返回 true/false 的方法,而是提供了一个返回当前所有有无障碍权限的服务的名称,格式是"应用包名1/无障碍Service的全限定名1:应用包名2/无障碍Service的全限定名2:…"每个服务用冒号进行分割,所以我们需要使用 split 函数将其切分成 String 列表,然后检查列表中的每个元素是否有我们的服务名称即可。

requestAccessibilityPermission

该方法与 PermissionManager 中的 requestOverlayPermission 方法类似,也是用了@async 注解,需要使用提供的 callback 进行函数的返回,也使用 ActivityResultLauncher 实现,这里不再赘述。唯一需要注意的地方是,在打开无障碍设置页面是,不能像打开悬浮窗设置页面那样传入本应用包名,否则会造成 App 闪退,这是因为无障碍设置页面不支持自动滚动到指定 APP 处,需要用户手动寻找本 APP

startForegroundService

实现很简单,就是开启一个 Android 的 Service:

override fun startForegroundService() {
    // 开启无障碍服务
    activity.startService(Intent(activity, SelectToSpeakService::class.java))
}

这个方法用于启动无障碍服务SelectToSpeakService,该无障碍服务有些特殊,其全限定名为com/google/android/accessibility/selecttospeak/SelectToSpeakService,看起来像是 Google 官方的服务,但其实是伪装的,如果不伪装成 Google 的服务,有些 APP 不允许读取屏幕内容,例如微信。关于这个无障碍服务的实现,我们稍后介绍。

无障碍服务SelectToSpeakService实现

需要将该 Service 放到com/google/android/accessibility/selecttospeak/包下,理由前文已经解释。
实现非常简单,就是一个标准的 Android AccessibilityService 的写法:

private const val TAG = "SelectToSpeakService"

/**
 * 无障碍服务,伪装成 Google 的 SpeakService,实时检查屏幕内容变化
 * 检测到微信支付或支付宝支付后通知
 */
class SelectToSpeakService: AccessibilityService() {
    companion object {
        val WECHAT_PACKAGE = "com.tencent.mm"
        // TODO: 待验证
        val ALIPAY_PACKAGE = "com.eg.android.AlipayGphone"
    }

    // 标志变量,防止添加账单对话框重复显示
    private var hasClosed = false

    private val dialogCloseReceiver = object: BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            hasClosed = true
        }
    }

    override fun onServiceConnected() {
        super.onServiceConnected()
        val listenFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            RECEIVER_EXPORTED
        } else {
            0
        }
        // 注册对话框关闭 Receiver
        registerReceiver(dialogCloseReceiver, IntentFilter(OverlayService.BILL_BOTTOM_SHEET_CLOSE_BROADCAST), listenFlag)
    }

    override fun onAccessibilityEvent(event: AccessibilityEvent?) {
        if (hasClosed) {
            hasClosed = false
            return
        }

        if (event == null || event.packageName == null) return
        val packageName = event.packageName.toString()
        Log.d(TAG, "当前支付 APP 包名: ${packageName}")

        if (packageName != WECHAT_PACKAGE && packageName != ALIPAY_PACKAGE) {
            return
        }

        val eventType = event.eventType
        Log.d(TAG, "当前事件类型: ${eventType}")
        if (eventType != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
            return
        }

        val rootNode = getRootInActiveWindow()
        when (packageName) {
            WECHAT_PACKAGE -> handleWeChatPay(rootNode)
            ALIPAY_PACKAGE -> handleAliPay(rootNode)
            else -> return
        }
    }

    /**
     * 处理微信支付
     */
    private fun handleWeChatPay(nodeInfo: AccessibilityNodeInfo) {
        val text = getAllText(nodeInfo)
        // TODO: 这里解析逻辑需要完善,支付成功的页面有不同的类型, 此处使用 mock 数据
        if (!text.contains("支付成功")) return
        OverlayService.openBillBottomSheet(
            this,
            OverlayService.BillDialogData.Builder()
                .amount("¥30")
                .note("测试账单")
                .subject("测试交易对象")
                .build()
        )
    }

    /**
     * TODO: 处理支付宝支付
     */
    private fun handleAliPay(nodeInfo: AccessibilityNodeInfo) {

    }

    override fun onInterrupt() {
        Log.d(TAG, "无障碍服务被中断")
    }

    private fun getAllText(node: AccessibilityNodeInfo?): String {
        if (node == null) return ""
        val sb = StringBuilder()

        val text = node.text
        val desc = node.contentDescription
        if (!text.isNullOrEmpty()) sb.append(text).append(" ")
        if (!desc.isNullOrEmpty()) sb.append(desc).append(" ")

        for (i in 0 until node.childCount) {
            val child = node.getChild(i)
            if (child != null) {
                sb.append(getAllText(child))
            }
        }
        return sb.toString()
    }

    override fun onDestroy() {
        super.onDestroy()
        // 取消 Receiver 注册,防止内存泄露
        unregisterReceiver(dialogCloseReceiver)
    }
}

上述实现并不完整,没有实现支付宝的检测、没有实现微信支付不同页面的结构解析、且未对支付宝支付完成页面包名进行测试。
但是目前该服务可以识别并解析一种微信支付页面且检测到后能够通知 OverlayService 打开 BottomSheet 通知用户,理论基础已经有了,只差完善上述提到的细节,计划在后面的开发中再完成编写。
这里仅对其中的一些关键部分进行说明。
首先要对对话框进行限制,防止重复开启,这里通过一个 hasClosed + BroadcastReceiver 来实现,我们后面会在 OverlayService 中实现在对话框关闭时发出一个广播,在这里我们会接收这个广播并将 hasClosed 设为 true,防止当 BottomSheet 关闭后无障碍服务识别到屏幕变化又再次打开 BottomSheet。

系统全局 BottomSheet 实现:OverlayService

系统全局 BottomSheet 通过直接在 Service 中获取 WindowManger,并调用其 addView 方法来实现。这里的 Service 就是项目中定义的 OverlayService,实现如下:

const val TAG = "OverlayService"

/**
 * 用于支持账单BottomSheet的弹出以及声明周期的管理
 * BottomSheet 关闭时将发出[BILL_BOTTOM_SHEET_CLOSE_BROADCAST]广播
 */
class OverlayService: Service() {
    companion object {
        private val BILL_DATA = "billData"

        val BILL_BOTTOM_SHEET_CLOSE_BROADCAST = "com.example.analyst.billBottomSheetClose"

        fun openBillBottomSheet(context: Context, data: BillDialogData) {
            val intent = Intent(context, OverlayService::class.java).apply {
                putExtra(BILL_DATA, data)
            }
            // 启动前台服务
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                context.startForegroundService(intent)
            } else {
                context.startService(intent)
            }
        }
    }

    private lateinit var windowManager: WindowManager
    private var view: View? = null
    private var billData: BillDialogData? = null

    override fun onCreate() {
        super.onCreate()
        Log.d(TAG, "OverlayService启动成功")
        startForegroundService()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        if (intent != null) {
            showOverlay(intent)
        }
        // 返回 START_STICKY,销毁后自动重建 Service
        return START_STICKY
    }

    private fun startForegroundService() {
        val channelId = "overlay_channel"

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                channelId,
                "Overlay Service",
                NotificationManager.IMPORTANCE_LOW
            )
            getSystemService(NotificationManager::class.java)
                .createNotificationChannel(channel)
        }

        // 前台服务通知
        val notification = NotificationCompat.Builder(this, channelId)
            .setContentTitle("账单监听中~")
            .setSmallIcon(android.R.drawable.ic_dialog_info)
            .build()

        startForeground(1, notification)
    }

    private fun showOverlay(intent: Intent) {
        // 当前已经在显示,防止再次显示
        if (view != null) return

        // 确保是最新的账单数据
        billData = ensureNewBillData(intent)

        // 没有账单数据,拒绝显示
        if (billData == null) return

        windowManager = getSystemService(WINDOW_SERVICE) as WindowManager

        // TODO: 根据传入的实际账单数据填充 view
        view = LayoutInflater.from(this)
            .inflate(R.layout.bill_bottom_sheet, null)

        val params = WindowManager.LayoutParams(
            WindowManager.LayoutParams.MATCH_PARENT,
            WindowManager.LayoutParams.WRAP_CONTENT,
            WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
            PixelFormat.TRANSLUCENT
        )

        params.gravity = Gravity.BOTTOM

        windowManager.addView(view, params)

        // 动画(从底部滑入)
        view?.apply {
            translationY = 1000f
            animate()
                .translationY(0f)
                .setDuration(300)
                .start()
        }

        // 点击关闭
        view?.findViewById<View>(R.id.btn_close)?.setOnClickListener {
            removeOverlay()
        }
    }

    private fun removeOverlay() {
        view?.let {
            windowManager.removeView(it)
            view = null
            billData = null
        }
        sendBroadcast(Intent(BILL_BOTTOM_SHEET_CLOSE_BROADCAST))
    }

    /**
     * 从最新的 Intent 中获取账单数据,但是不确保有数据
     */
    private fun ensureNewBillData(intent: Intent): BillDialogData? {
        val data = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            intent.getParcelableExtra(BILL_DATA, BillDialogData::class.java)
        } else {
            intent.getParcelableExtra(BILL_DATA)
        }
        return data
    }

    override fun onDestroy() {
        super.onDestroy()
        removeOverlay()
    }

    override fun onBind(intent: Intent?): IBinder? = null
}

上述代码中省略了 BillDialogData 的实现,因为实在太长,并且就是简单的的 Android Parcelable的实现。
由于我们的Service 不需要跨进程调用,所以无需实现 Binder 通信,让 onBind 方法返回 null 即可。
关键在于 onCreate 方法和 onStartCommand 方法,onCreate 方法在首次启动该 Service 的时候会调用,onStartCommand 方法在每次调用 startService 时会被调用,所以我们在 onCreate 方法中进行初始化,例如获取 WindowManager,创建通知渠道、创建前台服务通知等;在 onStartCommand 中取一个 LayoutInflater 并 inflate 一个我们自定义的布局文件,然后使用 WindowManager.addView将BottomSheet显示到屏幕上。
BottomSheet 所需的数据结构BillDialogData 实现了 Parcelable 接口,所以可以通过 Intent 来传递,这里的 ensureNewBillData方法就是从 Intent 中取出账单数据的方法。
除此之外,还有一个 removeOverlay 方法用于关闭 BottomSheet,该方法不仅仅要把创建的 view 从 Window中移除,还需要将 view 设为 null,并发送我们前文提到的 BottomSheet 关闭通知。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐