Xposed模块使用DataStore存储配置信息

探索

想在Xposed模块中存储用户偏好配置信息?翻看各种教程文章和API,都是使用 SharedPreferences 来存储用户配置信息:

在模块中 val sp = Context.getSharedPreferences("name", MODE_WORLD_READABLE) 来获取 SharedPreferences 对象,在宿主中,使用 val xsp = XSharedPreferences(BuildConfig.APPLICATION_ID, "prefFileName") 来获取 XSharedPreferences 对象。

优点是易于理解,缺点是需要定义字符串的 key ,每次获取需要写默认值: getString("key", "defaultValue") ,当有大量的配置时,代码量会非常多。

在常规的安卓开发中,已经有了现代化的解决方案:DataStore

Jetpack DataStore 是一种数据存储解决方案,允许您使用协议缓冲区存储键值对或类型化对象。DataStore 使用 Kotlin 协程和 Flow 以异步、一致的事务方式存储数据。

如果您目前是使用 SharedPreferences 存储数据的,请考虑迁移到 DataStore 。

对比 DataStore 和 XSharedPreferences 文件存储位置

DataStore 把数据存在哪里了呢?查看源码:

package androidx.datastore.core

public fun Context.deviceProtectedDataStoreFile(fileName: String): File {
    // Make sure it is a DeviceProtectedStorageContext and convert to a
    // DeviceProtectedStorageContext if it is not.
    val deviceProtectedContext = requireDeviceProtectedStorageContext()
    return File(deviceProtectedContext.filesDir, "datastore/$fileName")
}
package androidx.datastore

public fun Context.dataStoreFile(fileName: String): File {
    return File(this.applicationContext.filesDir, "datastore/$fileName")
}
  • 使用 val Context.dataStore by dataStore("YourFileName.json", YourSerializer) 则存储在 /data/data/your.packagename/files/datastore/YourFileName.json 文件中。
  • 使用 val Context.dataStore by deviceProtectedDataStore("YourFileName.json", YourSerializer) 则存储在 /data/user_de/0/your.packagename/files/datastore/YourFileName.json 文件中。

宿主能不能读取这两个文件呢? 用 MT管理器给这两个文件赋予 644 权限( -rw-r--r-- 全都可读),再到 Termux 里 cat 一下,很不幸,全都是 Permission denied 。

那 XSharedPreferences 是怎样做到宿主读取配置文件的呢?

试一下 XSharedPreferences(BuildConfig.APPLICATION_ID, "YourFileName").file.absolutePath ,路径是 /data/misc/f1624d22-c2dd-46cd-9adb-ce0d87bf645c/prefs/your.packagename/YourFileName.xml (其中的 UUID 每个手机并不相同),而且发现它的权限就是 644 ,Termux cat 一下,成功读取。

劫持模块自身 DataStore 创建文件

那么就有一个绝妙的思路:劫持模块自身 DataStore 创建文件!在上述路径下创建一个文件,并通过 hook 返回该文件:

object SelfHook {
    fun enableDataStoreFileSharing(loadPackageParam: LoadPackageParam) {
        if (loadPackageParam.packageName != BuildConfig.APPLICATION_ID) return
        val path = XSharedPreferences(BuildConfig.APPLICATION_ID).file.parent
        val file = File(path, "YourDataStoreFileName.json")
        if (!file.exists()) {
            file.writeText("{}")
            @SuppressLint("SetWorldReadable")
            file.setReadable(true, false)
        }

        findAndHookMethod(
            "androidx.datastore.core.DeviceProtectedDataStoreFile",
            loadPackageParam.classLoader,
            "deviceProtectedDataStoreFile",
            Context::class.java,
            String::class.java,
            returnConstant(file)
        )
        findAndHookMethod(
            "androidx.datastore.DataStoreFile",
            loadPackageParam.classLoader,
            "dataStoreFile",
            Context::class.java,
            String::class.java,
            returnConstant(file)
        )
    }
}

Shift + F10 运行完毕,立刻到 Termux cat 一下,哇,能读取到数据!

立马编写 hook 逻辑,在宿主里

val path = XSharedPreferences(BuildConfig.APPLICATION_ID).file.parent
val file = File(path, "YourDataStoreFileName.json")
val json = file.readText()

果然在宿主里也能读取到数据,大功告成!了吗?

解决文件权限

开发一定要做好自测✍️✍️✍️。在模块中修改配置,然后重启宿主,发现功能没有生效,打印日志发现文件的权限变成 600 (其他用户不可读)。

此时灵感枯竭,于是问当今最强AI: Gemini 2.5 Pro,它告诉我在每回 dataStore.updateData {} 之后重新设置该文件为可读。我才不会这样做,这太不优雅了,每次调用加一句 dataStoreFile.setReadable(true, false) ,跟正常的 DataStore 使用方式不一样,很麻烦,而且如果某次忘了加这句代码,就会导致配置文件都变成不可读,导致 hook 功能失效。

那我 hook 自身的 dataStore.updateData {} 在其执行后执行

val path = XSharedPreferences(BuildConfig.APPLICATION_ID).file.parent
val file = File(path, "YourFileName.json")
@SuppressLint("SetWorldReadable")
file.setReadable(true, false)

真是优雅的实现,但经过测试发现,在更改配置后, YourDataStoreFileName.json 仍然是 600 不可读……

不放弃,那我试试 hook Serializer 的 writeTo 方法

object PreferenceSerializer : Serializer<Preference> {
    override suspend fun readFrom(input: InputStream): Preference {
        return try {
            Json.decodeFromString(
                deserializer = Preference.serializer(),
                string = input.readBytes().decodeToString()
            )
        } catch (e: SerializationException) {
            e.printStackTrace()
            defaultValue
        }
    }

    override suspend fun writeTo(t: Preference, output: OutputStream) {
        output.write(
            Json.encodeToString(
                serializer = Preference.serializer(),
                value = t
            ).encodeToByteArray()
        )
    }

    override val defaultValue: Preference  = Preference()
}

val Context.dataStore by dataStore("whatever", PreferenceSerializer)

很不幸,还是 600 不可读……

思索良久,每次写入配置文件都要设置全部可读权限,那能不能在写入配置文件完毕后,一次性设置为全部可读权限呢,只要保证设置权限之后不再修改模块配置,就能保证宿主可读取。那这不就是生命周期该干的事情吗?一个好的安卓开发此时应该意识到了 onPause() ,在 Activity 失去焦点时便会调用它,正符合需求。

修改 MainActivity :

// 这里使用 "whatever" 是因为 dataStoreFile() 方法已经被 hook 劫持了,所以用什么名字都行。
val preferenceFile = dataStoreFile("whatever")

@SuppressLint("SetWorldReadable")
override fun onPause() {
    super.onPause()
    preferenceFile.setReadable(true, false)
}

再次测试,修改模块配置,重启宿主,功能生效了。至此解决了模块配置文件权限问题。

新安装崩溃

再次谨记,开发一定要做好自测✍️✍️✍️,卸载模块,重新安装,并且不在 LSPosed 中启用,用来模拟第一次安装。打开模块,哦吼,闪退了,查看 logcat :

java.lang.RuntimeException: Unable to instantiate activity ComponentInfo{your.packagename/your.packagename.MainActivity}: java.lang.NullPointerException: Attempt to invoke virtual method 'android.content.Context android.content.Context.getApplicationContext()' on a null object reference

……

Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'android.content.Context android.content.Context.getApplicationContext()' on a null object reference

是 val preferenceFile = dataStoreFile("whatever") 出错了,根据源码

public fun Context.dataStoreFile(fileName: String): File {
    return File(this.applicationContext.filesDir, "datastore/$fileName")
}

此时没有 applicationContext,所以崩溃了。好办,直接一手

val preferenceFile by lazy { dataStoreFile("whatever") }

@SuppressLint("SetWorldReadable")
private fun setWorldReadable(): Boolean = preferenceFile.setReadable(true, false)

@SuppressLint("SetWorldReadable")
override fun onPause() {
    super.onPause()
    setWorldReadable()
}

完美解决,而且还可以通过 setWorldReadable() 来判断模块是否被启用:

ProGuard/R8 压缩

接下来,开发一定要做好自测✍️✍️✍️。打 release 包,安装测试,又双叒叕出错了,查看 LSPosed 日志,发现找不到 DataStore 相关的类。哦哦哦,是我启用了 ProGuard/R8 压缩,那就保留相关的类及其方法:

-keep class androidx.datastore.DataStoreFile {
    public static java.io.File dataStoreFile(android.content.Context, java.lang.String);
}

-keep class androidx.datastore.core.DeviceProtectedDataStoreFile {
    public static java.io.File deviceProtectedDataStoreFile(android.content.Context, java.lang.String);
}

再次自测,没问题了。

总结引入 DataStore 的步骤

1. 添加依赖

libs.versions.toml :

[versions]
datastore = "1.2.0-alpha02"
kotlinxSerializationJson = "1.9.0"

[libraries]
datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }


[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

build.gradle.kts :

plugins {
    alias(libs.plugins.kotlin.serialization)
}

dependencies {
    implementation(libs.datastore)
    implementation(libs.kotlinx.serialization.json)
}

按 Ctrl+Shift+O Sync Project With Gradle Files

2. Hook DataStore

PreferenceProvider :

object PreferenceProvider {
    private const val PREFERENCE_FILE_NAME = "preference.json"

    val preference: Preference? = try {
        Json.decodeFromString<Preference>(getPreferenceFile().readText())
    } catch (_: Throwable) {
        null
    }

    fun getPreferenceFile(): File {
        val path = XSharedPreferences(BuildConfig.APPLICATION_ID).file.parent
        val file = File(path, PREFERENCE_FILE_NAME)

        if (!file.exists()) {
            file.writeText("{}")
            @SuppressLint("SetWorldReadable")
            file.setReadable(true, false)
        }

        return file
    }
}

SelfHook :

object SelfHook {
    fun enableDataStoreFileSharing(loadPackageParam: LoadPackageParam) {
        if (loadPackageParam.packageName != BuildConfig.APPLICATION_ID) return
        val callback = returnConstant(PreferenceProvider.getPreferenceFile())

        findAndHookMethod(
            "androidx.datastore.core.DeviceProtectedDataStoreFile",
            loadPackageParam.classLoader,
            "deviceProtectedDataStoreFile",
            Context::class.java,
            String::class.java,
            callback
        )
        findAndHookMethod(
            "androidx.datastore.DataStoreFile",
            loadPackageParam.classLoader,
            "dataStoreFile",
            Context::class.java,
            String::class.java,
            callback
        )
    }
}

MainHook :

class MainHook : IXposedHookLoadPackage, IXposedHookInitPackageResources {
    override fun handleLoadPackage(lpparam: LoadPackageParam) {
        if (lpparam.packageName == BuildConfig.APPLICATION_ID) {
            SelfHook.enableDataStoreFileSharing(lpparam)
        }

        val preference = PreferenceProvider.preference ?: return

        when (lpparam.packageName) {
            Package.ANDROID -> {
                // 省略余下

3. 确保 DataStore 文件全部可读

修改偏好配置所在 Activity,在其失焦时使文件全部可读

MainActivity :

val preferenceFile by lazy { dataStoreFile("whatever") }

@SuppressLint("SetWorldReadable")
private fun setWorldReadable(): Boolean = preferenceFile.setReadable(true, false)

@SuppressLint("SetWorldReadable")
override fun onPause() {
    super.onPause()
    setWorldReadable()
}

4. ProGuard/R8

如果启用了代码压缩/混淆,则需要添加以下规则:

-keep class your.packagename.MainHook

-keep class androidx.datastore.DataStoreFile {
    public static java.io.File dataStoreFile(android.content.Context, java.lang.String);
}

-keep class androidx.datastore.core.DeviceProtectedDataStoreFile {
    public static java.io.File deviceProtectedDataStoreFile(android.content.Context, java.lang.String);
}

其中 your.packagename.MainHook 是模块入口类,与 assets/xposed_init 文件中的类名一致。

libxposed 102 使用 DataStore

XposedServiceManager.kt:

import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper

object XposedServiceManager {

    @Volatile
    var xposedService: XposedService? = null
        private set

    val isModuleActive: Boolean
        get() = xposedService != null

    init {
        XposedServiceHelper.registerListener(object : XposedServiceHelper.OnServiceListener {
            override fun onServiceBind(service: XposedService) {
                xposedService = service
            }

            override fun onServiceDied(service: XposedService) {
                if (xposedService == service) {
                    xposedService = null
                }
            }
        })
    }
}

PreferenceSerializer.kt:

import android.content.Context
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import io.github.soclear.oneuix.XposedServiceManager
import io.github.soclear.oneuix.common.IgnoreUnknownKeysJson
import io.github.soclear.oneuix.common.Preference
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import java.io.InputStream
import java.io.OutputStream

object PreferenceSerializer : Serializer<Preference> {
    private const val TAG = "PreferenceSerializer"

    @OptIn(ExperimentalSerializationApi::class)
    override suspend fun readFrom(input: InputStream): Preference = try {
        val service = XposedServiceManager.xposedService ?: return defaultValue
        val parcelFileDescriptor = service.openRemoteFile(Preference.FILE_NAME)

        ParcelFileDescriptor.AutoCloseInputStream(parcelFileDescriptor).use { inputStream ->
            if (inputStream.channel.size() == 0L) return defaultValue
            IgnoreUnknownKeysJson.decodeFromStream<Preference>(inputStream)
        }
    } catch (e: Exception) {
        Log.e(TAG, "readFrom", e)
        defaultValue
    }


    @OptIn(ExperimentalSerializationApi::class)
    override suspend fun writeTo(t: Preference, output: OutputStream) {
        try {
            val service = XposedServiceManager.xposedService ?: return
            val parcelFileDescriptor = service.openRemoteFile(Preference.FILE_NAME)

            ParcelFileDescriptor.AutoCloseOutputStream(parcelFileDescriptor).use { outputStream ->
                outputStream.channel.truncate(0)
                IgnoreUnknownKeysJson.encodeToStream(Preference.serializer(), t, outputStream)
                outputStream.channel.force(true)
            }
        } catch (e: Exception) {
            Log.e(TAG, "writeTo", e)
        }
    }

    override val defaultValue: Preference = Preference()
}

val Context.dataStore by dataStore("whatever", PreferenceSerializer)

优点:只维护一份配置,不用双向同步;不用写混淆规则;即时写入,不用等 onResume 。