Grtsinry43的前端札记 | 大三技术成长实录 & 学习笔记 | 「岁月漫长,值得等待」
文章
技术学习

现代安卓开发之 Jetpack Compose、Xposed Hook 与 Kotlin Multiplatform

2025年4月23日 12 分钟阅读 浏览 0 喜欢 0 评论 0

emm上来堆三个技术名词太劝退,每个部分开始之前我都会写一段简短的介绍,用很通俗的语言讲解下这是个什么东西。

写这篇文章起源于我最近悲惨的生活和精神状态,因此适当摸摸鱼写写有趣的东西,尤其是写kotlin真的很爽,最近是真的写了很多 wakatime


Jetpack Compose

Jetpack Compose 是 Google 推出的用于构建原生 Android 界面的现代化声明式 UI 工具包。它从传统的命令式 UI(手动查找并更新 View)转变为声明式 UI,说人话就是你现在只需要描述你的UI在不同状态下是什么样子的,至于渲染和更新放心交给Compose就好。另外他完全基于 Kotlin 构建,充分利用了 Kotlin 的语言特性(如 Lambda、DSL、协程等),如果你会Kotlin,上手这个是相当舒服的。

前情提要(bushi): app

声明式

声明式UI首先是在web前端领域广泛应用的,Jetpack Compose受到启发,将它作为安卓平台的全新UI构建工具,它简化了原命令式UI复杂的操作过程find set等等

函数式

首先它是函数式的,函数是一等公民,而这些描述UI的函数被称为Composable函数,其具有@Composable注解,他们不是返回特定的对象,而是类似“发出UI”,它们之间相互调用可以组成复杂的UI结构

状态驱动

这里就和 React 什么的很像了,当State 对象改变时,Compose 会智能地“重组”(Recomposition)受影响的 Composable 函数,更新 UI。其中mutableStateOf() 创建一个可观察的可变状态持有者,而remember { ... } 用于在重组过程中“记住”状态或对象,避免每次重组都重新创建。

注:remember 本身是为了在多次重组之间保持实例不变,避免重复创建开销。与 mutableStateOf 结合使用 (remember { mutableStateOf(...) }) 才是创建既能被记住又能触发重组的状态的标准方式。

当然,类似于 React/Vue 的 diff,Compose 的优化也是相当好的,当 Composable 函数依赖的状态发生变化时,Compose 运行时会自动重新调用该函数及其可能受影响的子函数,以更新 UI。Compose 会进行智能优化,只重组必要的部分。

副作用和单向数据流

副作用(Side Effects): 我们都知道不会影响状态的叫做纯函数,而改变状态的是副作用函数,实际开发中,还需要处理副作用(如网络请求、数据库操作、启动协程等)。Compose 提供了 LaunchedEffect, SideEffect, DisposableEffect等 API 来安全地处理这些与 Composable 生命周期相关的副作用。

单向数据流 (Unidirectional Data Flow - UDF): Compose 强烈推荐遵循 UDF 模式:状态向下流动(父组件传给子组件),事件向上传递(子组件通过回调通知父组件)。这有助于构建更可预测、更易于维护的 UI。

浅浅上手

在之前摸鱼的时候我写了个小小简陋的app:传送门

我们就以这个为例来快速上手 Jetpack Compose

as

我们以这个卡片组件为例,如你所见,在Android Studio中,工作区分为两部分,代码和Compose Preview,你可以通过添加@Preview注解来启动对应组件的预览,当然也可以添加主题来查看对应主题的效果,比如默认的Material3

kotlin
@Preview(showBackground = true)
@Composable
fun ArticleCardPreview() {
    MaterialTheme{
        // 你的组件
    }
}

我们重心还是回到上面的Composable函数上:

它从外向内分别是Box Row Column,类似三层flex div,modifier是样式修饰符,类比css,而里面也是由一个个封装好的Composable函数嵌套而成的(其实就是调用函数,组合UI)

我们可以根据传入的参数描述不同的UI,进而渲染不同的元素,例如:

kotlin
// AsyncImage 加载封面
            if (!cover.isNullOrEmpty()) {
                // 构建完整URL
                val fullImageUrl = if (cover.startsWith("http")) cover else "$baseUrl$cover"

                AsyncImage(
                    model = ImageRequest.Builder(LocalContext.current)
                        .data(fullImageUrl)
                        .crossfade(true)
                        .build(),
                    contentDescription = "文章封面",
                    contentScale = ContentScale.Crop,
                    modifier = Modifier
                        .size(80.dp)
                        .clip(MaterialTheme.shapes.medium),
                    error = painterResource(id = R.mipmap.author),
                    placeholder = painterResource(id = R.mipmap.logo)
                )
            } else {
                // 如果没有封面,显示默认图片
                Image(
                    painter = painterResource(id = R.mipmap.author),
                    contentDescription = null,
                    modifier = Modifier
                        .size(64.dp)
                        .padding(8.dp)
                )
            }

当涉及到副作用时,我们可以以简单的网络加载为例

kotlin
@Composable
fun ArticlesScreen(navController: NavController = rememberNavController()) {
    // 获取LocalContext
    val context = LocalContext.current

    // 创建ViewModel工厂
    val factory = remember {
        object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                return ArticleViewModel(context.applicationContext as Application) as T
            }
        }
    }

    // 使用工厂创建ViewModel
    val viewModel: ArticleViewModel = viewModel(factory = factory)
    val listState = rememberLazyListState()

    // 收集分页状态
    val pagingState by viewModel.pagingState.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()

    // 检测滚动到底部
    val shouldLoadMore by remember {
        derivedStateOf {
            val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
            val totalItems = pagingState.items.size
            lastVisibleItem >= totalItems - 3 && !pagingState.loading && !pagingState.endReached
        }
    }

    // 触发加载更多
    LaunchedEffect(shouldLoadMore) {
        if (shouldLoadMore) {
            viewModel.loadArticles()
        }
    }

    Scaffold { innerPadding ->
        LazyColumn(
            modifier = Modifier.padding(innerPadding),
            state = listState
        ) {
            item {
                PageHeader(title = "文章")
            }

            if (pagingState.items.isEmpty() && isLoading) {
                // 显示骨架屏
                items(6) { // 显示6个骨架项
                    ArticleCardSkeleton()
                }
            } else {
                // 显示实际内容
                itemsIndexed(pagingState.items) { _, article ->
                    ArticleCard(
                        title = article.title,
                        cover = article.cover,
                        description = article.summary,
                        date = article.createdAt,
                        onClick = {
                            navController.navigate(NavRoutes.articleDetail(article.shortUrl))
                        }
                    )
                }

                // 底部加载指示器
                item {
                    if (pagingState.loading) {
                        Box(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(16.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            CircularProgressIndicator()
                        }
                    }
                }

                // 错误提示
                item {
                    if (pagingState.error != null) {
                        Box(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(16.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            Text(text = pagingState.error ?: "加载失败")
                        }
                    }
                }
            }
        }
    }
}

注:这里我手动创建了 ViewModel Factory 来传递 Application Context,但在实际项目中,通常会使用 Hilt 或 Koin 等依赖注入库来简化这个过程。别感觉复杂,koin很简单的。

当然,想要深入使用还是得先浅浅学学Android中Activity的生命周期,这样才能会用ViewModel和各个Effect,不过这里只是简单介绍啦,它的思想和前端框架还是蛮像的。

Xposed Hooks

这个如果是搞机大佬肯定不会陌生,它是 一个运行于 Android 系统底层的框架,允许用户和开发者在不修改应用程序 APK 文件的情况下,实时地修改(“Hook”)系统和应用程序的行为,当然现在用的更多的还是LSPosed(老**框架

Hook意为钩子,可以理解为钩住对应的函数,拦截下来,进而完成你想要的操作,它在应用程序启动时加载 Xposed Bridge,从而获得在 Zygote 进程中 Hook 任意 Java 方法的能力。

emm还是一个摸鱼时候随便写的小项目:传送门

作者自评:我看你小子没少摸鱼

核心实践

我们首先要确定一个模块入口类,让他继承IXposedHookLoadPackage,这样我们就有了修改程序的能力。

代码结构

在这个重载方法里,我们可以针对加载模块做一些自定义,比如因为我后续需要广播信息,这里就获取了一下context。

然后hook的方法就是这样,提供具体的类,方法名,随后我们可以自定义自己的前钩子函数和后钩子函数。 为了便于调试,我们可以添加一些日志。

kotlin
// 2. Hook 这个特定类的 onCreate 方法
XposedHelpers.findAndHookMethod(
    appClass, // *** 修改点:使用找到的特定类 appClass ***,真是被气炸
    "onCreate",
    object : XC_MethodHook() {
        override fun beforeHookedMethod(param: MethodHookParam?) {
            XposedBridge.log("[justforfun] Custom App ($TARGET_APPLICATION_CLASS_NAME) onCreate: BEFORE")
        }

        override fun afterHookedMethod(param: MethodHookParam) {
            XposedBridge.log("[justforfun] Custom App ($TARGET_APPLICATION_CLASS_NAME) onCreate: AFTER")
            // 3. 获取 Context 并调用业务 Hook
            targetAppContextRef = WeakReference(param.thisObject as Context)
            XposedBridge.log("[justforfun] Acquired context via custom App")
            hookBusinessMethods(lpparam) // 获取 Context 后调用业务 Hook
        }
    })

这里有一个param,我们可以通过它的args(Arr)和result(Object)来拿到所hook方法的参数和结果,整体效果大概这样:

kotlin
package com.example.myxposedmodule // 替换成你的包名

import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage

class HookEntry : IXposedHookLoadPackage {

    override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
        // 检查是否是目标应用 (例如:com.target.app)
        if (lpparam.packageName != "com.target.app") {
            return
        }

        XposedBridge.log("模块加载成功,目标应用: ${lpparam.packageName}")

        try {
            // 假设要 Hook 的类是 com.target.app.SomeClass
            // 假设要 Hook 的方法是 someMethod(String text, int number) 返回 boolean
            val targetClass = XposedHelpers.findClass("com.target.app.SomeClass", lpparam.classLoader)

            XposedHelpers.findAndHookMethod(
                targetClass, // 目标类
                "someMethod", // 目标方法名
                String::class.java, // 参数1类型
                Int::class.javaPrimitiveType, // 参数2类型 (注意基本类型用 .javaPrimitiveType)
                object : XC_MethodHook() { // Hook 回调
                    @Throws(Throwable::class)
                    override fun beforeHookedMethod(param: MethodHookParam) {
                        XposedBridge.log("进入 beforeHookedMethod: someMethod")
                        val arg1 = param.args[0] as String
                        val arg2 = param.args[1] as Int
                        XposedBridge.log("  参数1: $arg1")
                        XposedBridge.log("  参数2: $arg2")

                        // 示例:修改第一个参数
                        param.args[0] = "已被 Hook 修改"

                        // 示例:如果参数2是特定的值,则阻止原方法执行并直接返回 true
                        if (arg2 == 123) {
                            XposedBridge.log("  参数2为 123,阻止原方法执行并返回 true")
                            param.result = true // 设置返回值
                        }
                    }

                    @Throws(Throwable::class)
                    override fun afterHookedMethod(param: MethodHookParam) {
                        XposedBridge.log("进入 afterHookedMethod: someMethod")
                        // 只有当 beforeHookedMethod 没有设置 param.result 时,原方法才会执行
                        if (param.result != null) {
                            XposedBridge.log("  方法返回值 (可能被 before 修改): ${param.result}")
                        } else {
                            XposedBridge.log("  原方法执行完毕")
                            // 注意:如果原方法抛出异常,这里不会执行,且 param.throwable != null
                        }

                        // 示例:读取/修改最终返回值
                        val originalResult = param.result as? Boolean ?: false // 安全转换
                        XposedBridge.log("  最终返回值 (处理前): $originalResult")
                        // param.result = !originalResult // 比如取反
                    }
                }
            )

            XposedBridge.log("成功 Hook com.target.app.SomeClass.someMethod")

        } catch (e: Throwable) { // 捕获所有可能的错误,如 ClassNotFoundException, NoSuchMethodException 等
            XposedBridge.log("Hook 失败: ${e.message}")
            XposedBridge.log(e) // 打印完整堆栈跟踪
        }
    }
}

完整简要流程

当然,为了大家复现,我也贴了一段由AI生成的创建模块的讲解

开发流程:

  1. 环境准备:

    • IDE: Android Studio (最新稳定版推荐)。
    • 构建系统: Gradle (Android Studio 内置)。
    • 编程语言: Java 或 Kotlin (Kotlin 因其简洁性和现代特性更受欢迎)。
    • LSPosed API: 需要在项目中引入 LSPosed 提供的 API 库。
    • 测试设备:
      • 一台已 Root 的 Android 设备或模拟器。
      • 已安装 Magisk (用于 Zygisk) 或 Riru (较旧)。
      • 已安装 LSPosed Manager (通过 Magisk Manager 或 Riru 安装 LSPosed Zygisk/Riru 版本,然后安装管理器 APK)。
  2. 创建 Android 项目:

    • 在 Android Studio 中,选择 “File” -> “New” -> “New Project…”。
    • 选择一个模板,通常 “Empty Activity” 或 “No Activity” 都可以,因为模块本身不一定需要界面 (除非你想提供配置界面)。
    • 配置项目名称、包名、保存位置、语言 (Java/Kotlin) 和最低 SDK 版本。最低 SDK 通常可以设置得较低 (如 API 21 或更高),但要确保你的代码兼容。
  3. 配置项目依赖和 Manifest:

    • 添加 LSPosed API 依赖:

      • 打开项目级别的 build.gradlebuild.gradle.kts 文件 (通常是 app 模块下的那个)。
      • dependencies 代码块中添加 LSPosed API 依赖。关键: 使用 compileOnlyprovided 作用域,因为这个 API 在运行时由 LSPosed 框架提供,不需要打包进你的模块 APK 中。

      Gradle

      kotlin
      // build.gradle (Groovy)
      dependencies {
          // ... 其他依赖
          compileOnly 'de.robv.android.xposed:api:82' // 使用官方 Xposed API 版本号 82
          // 或者,如果你想用 LSPosed 提供的包含一些额外帮助类的 API (不常用,一般用官方 82 就行)
          // compileOnly 'org.lsposed.lsparanoid:lsparanoid:+' // '+ ' 会获取最新版,建议指定版本
      }
      
      // build.gradle.kts (Kotlin)
      dependencies {
          // ... 其他依赖
          compileOnly("de.robv.android.xposed:api:82")
          // compileOnly("org.lsposed.lsparanoid:lsparanoid:+")
      }
      
      • 注意: API 版本 82 是 Xposed 原始 API 的最后一个正式版本,LSPosed 完全兼容它,通常使用这个即可。

        提示:这里别忘加源

        kotlin
        dependencyResolutionManagement {
            repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
            repositories {
                google()
                mavenCentral()
        
                maven { url = uri("https://api.xposed.info/") }
            }
        }
        
    • 配置 AndroidManifest.xml:

      • 打开 app/src/main/AndroidManifest.xml
      • <application> 标签内添加以下 <meta-data> 标签,以声明这是一个 LSPosed 模块:

      XML

      xml
      <application ...>
          <meta-data
              android:name="lsposedmodule"
              android:value="true" />
          <meta-data
              android:name="lsposeddescription"
              android:value="这里写模块的描述,会显示在 LSPosed 管理器中" />
          <meta-data
              android:name="lsposedminversion"
              android:value="1" /> <activity .../>
      </application>
      
  4. 分析目标应用和寻找 Hook 点:

    • 确定目标: 你想修改哪个应用或系统功能的行为?
    • 反编译: 使用反编译工具 (如 JADX-GUI) 打开目标应用的 APK 文件。
    • 代码分析: 浏览反编译后的 Java 代码,找到你想要修改的功能对应的类和方法。记下完整的类名、方法名、参数类型和返回类型。
    • 注意事项:
      • 目标应用可能经过混淆 (ProGuard/R8),类名和方法名可能变成无意义的字母 (如 a.b.c)。这会增加定位难度,并且 Hook 点可能在应用更新后失效。
      • 优先寻找逻辑清晰、不易变动的方法进行 Hook。
      • 注意方法的访问修饰符 (public, private, protected, package-private)。
  5. 编写 Hook 代码:

    • 创建 Hook 入口类: 创建一个新的 Java 或 Kotlin 类,实现 de.robv.android.xposed.IXposedHookLoadPackage 接口。这个接口只有一个方法需要实现:handleLoadPackage

    • 实现 handleLoadPackage 方法:

      这个方法会在每个应用加载时被 LSPosed 调用。

      • 过滤目标应用: 在方法内部,首先检查 LoadPackageParam (通常命名为 lpparam) 的 packageName 字段,判断当前加载的是否是你的目标应用。如果不是,直接 return

      • 执行 Hook: 如果是目标应用,使用 de.robv.android.xposed.XposedHelpers 类提供的静态方法来查找并 Hook 目标方法。最常用的是 findAndHookMethod

      • XC_MethodHook 回调:findAndHookMethod 的最后一个参数是一个XC_MethodHook的匿名内部类 (或 Lambda 表达式) 实例。你需要重写它的beforeHookedMethod和/或afterHookedMethod方法。

        • beforeHookedMethod(MethodHookParam param): 在原始方法执行 之前 调用。你可以读取/修改方法的输入参数 (param.args),或者直接阻止原始方法执行并设置返回值/抛出异常 (param.setResult(), param.setThrowable())。
        • afterHookedMethod(MethodHookParam param): 在原始方法执行 之后 调用。你可以读取/修改方法的返回值 (param.getResult(), param.setResult()),或者读取原始方法的执行结果和参数,然后执行其他操作。

Kotlin Multiplatform

Kotlin Multiplatform (以前称为 Kotlin Multiplatform Mobile 或 KMM,现在范围更广) 是 JetBrains 提供的一种技术,允许开发者使用 Kotlin 编写代码,并将其共享到多个平台,如 Android, iOS, Web (JS), Desktop (JVM, Native), Server (JVM) 等。

简单来说就是逻辑代码一次编写处处运行,尽管现在 Compose Multiplatform 正在探索跨平台 UI,但是目前还不是production ready。kmp还主要是共享业务逻辑、数据层、网络请求、数据模型等非 UI 代码。

它大概是这样的:

  • Common Code (commonMain): 编写平台无关的 Kotlin 代码。

  • Platform-Specific Code (androidMain, iosMain, jsMain 等): 编写需要调用特定平台 API 的代码。

  • expect/actual 机制:commonMain 中声明预期的功能 (expect class/function/property),然后在每个平台源集 (androidMain, iosMain 等) 中提供具体的实现 (actual class/function/property)。

    kotlin
    // commonMain
    expect fun getPlatformName(): String
    
    // androidMain
    actual fun getPlatformName(): String = "Android"
    
    // iosMain
    actual fun getPlatformName(): String = "iOS"
    

其实就是能共用的就共用,平台有关的就通过expect/actual在各个平台分别实现。

~~当然这个我也摸鱼…~~额,是有的,只不过这个想认真写写,写的差不多再开源。

emm这个怎么说,学习曲线有点陡,因为得各个平台开发比较熟悉,整体是:

  • 编写 Common Logic:commonMain 中用纯 Kotlin 编写大部分共享逻辑非常顺畅,尤其是利用 Kotlin Coroutines 处理异步、Serialization 处理 JSON 等,共用的网络库,拦截管道和错误处理,共用的viewstate什么的真的非常爽

  • 处理平台差异 (expect/actual): 对于需要平台 API 的功能(如文件系统访问、特定硬件交互、日期格式化等),使用 expect/actual 模式。这部分需要同时理解 Kotlin 和目标平台的 API。当平台差异很大时,actual 实现可能会变得复杂,这个就得每个平台都得会了。

还有,kotlin和swift互操作是真的难受

这里先不贴代码了,因为我感觉自己写的不是很行啊@_@

简要说说

好了,这次的“摸鱼心得”就先到这里。(误)

从 Jetpack Compose 带来的 UI 编写新范式,到 Xposed (LSPosed) 赋予我们的“魔改”能力,再到 Kotlin Multiplatform 对“一次编写,多端运行”的探索,不难看出安卓(以及更广阔的移动开发领域)正经历着快速的演进。

这三者或许代表了现代安卓开发的不同切面:面向未来的 UI 构建深入底层的系统定制、以及跨平台代码复用的探索。它们都基于 Kotlin 的强大能力。对我来说,折腾这些新技术,也确是“悲惨生活”中的一点短暂休息了。

讲的不深,简单说说,希望这篇小文章让你对这些技术产生兴趣,那就下次摸鱼再见吧!

分享此文
评论区在赶来的路上...