SharedPreferences注解:自动生成SP读写代码、类型安全访问

SharedPreferences,简称SP,是Android开发中最古老的本地存储方案之一。说实话,我入行那会儿它还是官方推荐的轻量级存储方式。但用过的朋友都知道,手写SP代码有多痛苦——每次都要写getStringputStringapply,还得自己维护key常量。更坑的是,类型写错了编译器也不报错,运行时直接崩。

今天我们就用注解处理器,把SP的读写代码自动生成出来。顺便实现类型安全访问——说白了,就是让编译器帮我们检查类型,写错了直接编译失败。

痛点分析:手写SP到底有多烦

先看一段典型的SP代码:

// 保存用户信息
val sp = context.getSharedPreferences("user_config", Context.MODE_PRIVATE)
sp.edit().putString("user_name", "张三").apply()
sp.edit().putInt("user_age", 25).apply()
sp.edit().putBoolean("is_vip", true).apply()

// 读取用户信息
val name = sp.getString("user_name", "")
val age = sp.getInt("user_age", 0)
val isVip = sp.getBoolean("is_vip", false)

这段代码有什么问题?我列几个:

  • key散落各处"user_name"这种字符串常量,写错一个字母就读取失败
  • 类型不安全getString("user_age", "")编译器不会报错,但运行时返回null
  • 样板代码多:每个字段都要重复getXxx/putXxx模式
  • 没有默认值管理:每个读取操作都要手动传默认值

我曾经在一个老项目里接手过这种代码,光SP的key就维护了200多个,散落在十几个类里。改一个key名,得全局搜索替换,还经常漏掉。嗯,那次重构让我下定决心——必须用注解处理器干掉手写SP。

设计思路:注解 + 处理器 + 代码生成

我们的目标很简单:定义一个注解,标注在数据类上,自动生成对应的SP读写代码。

核心流程如下:

@SharedPref(name = "user_config")
data class UserConfig(
    val userName: String = "",
    val userAge: Int = 0,
    val isVip: Boolean = false
)

处理器会生成一个UserConfigSP类,提供类型安全的读写方法:

// 自动生成的代码
class UserConfigSP(context: Context) {
    private val sp = context.getSharedPreferences("user_config", Context.MODE_PRIVATE)

    var userName: String
        get() = sp.getString("userName", "") ?: ""
        set(value) = sp.edit().putString("userName", value).apply()

    var userAge: Int
        get() = sp.getInt("userAge", 0)
        set(value) = sp.edit().putInt("userAge", value).apply()

    var isVip: Boolean
        get() = sp.getBoolean("isVip", false)
        set(value) = sp.edit().putBoolean("isVip", value).apply()
}

你看,使用方只需要操作属性,完全不用关心SP的细节。类型不对?编译器直接报红。

核心思想:用Kotlin属性代理 + 注解处理器,把SP的读写操作封装成属性访问。开发者只需要定义数据类,剩下的交给代码生成。

注解定义:@SharedPref

我们先定义注解:

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class SharedPref(
    val name: String = "",       // SP文件名,默认用类名
    val mode: Int = Context.MODE_PRIVATE
)

这里我用了SOURCE级别,因为生成的代码不需要在运行时反射注解。处理器在编译时读完注解信息,生成代码后,注解本身就可以丢弃了。

小技巧name参数留空时,处理器自动使用类名作为SP文件名。我个人习惯在注解里显式指定,方便后期迁移。

处理器实现:解析注解 + 生成代码

处理器需要做三件事:

  1. 找到所有标注了@SharedPref的类
  2. 解析类的属性信息(名称、类型、默认值)
  3. 生成对应的SP操作类

核心代码片段:

@AutoService(Processor::class)
@SupportedOptions(SharedPrefProcessor.KAPT_KOTLIN_GENERATED_OPTION_NAME)
@SupportedSourceVersion(SourceVersion.RELEASE_8)
class SharedPrefProcessor : AbstractProcessor() {

    override fun getSupportedAnnotationTypes(): Set<String> {
        return setOf(SharedPref::class.java.canonicalName)
    }

    override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
        roundEnv.getElementsAnnotatedWith(SharedPref::class.java).forEach { element ->
            val classElement = element as TypeElement
            val packageName = processingEnv.elementUtils.getPackageOf(classElement).qualifiedName.toString()
            val className = classElement.simpleName.toString()
            val annotation = classElement.getAnnotation(SharedPref::class.java)
            val spName = annotation.name.ifEmpty { className }

            // 收集属性信息
            val properties = mutableListOf<PropertyInfo>()
            classElement.enclosedElements
                .filterIsInstance<VariableElement>()
                .filter { it.kind == ElementKind.FIELD }
                .forEach { field ->
                    val fieldName = field.simpleName.toString()
                    val fieldType = field.asType().toString()
                    val defaultValue = field.constantValue ?: getDefaultValue(fieldType)
                    properties.add(PropertyInfo(fieldName, fieldType, defaultValue))
                }

            // 生成代码
            generateSpClass(packageName, className, spName, properties)
        }
        return true
    }

    private fun generateSpClass(
        packageName: String,
        className: String,
        spName: String,
        properties: List<PropertyInfo>
    ) {
        val spClassName = "${className}SP"
        val builder = TypeSpec.classBuilder(spClassName)
            .addModifiers(KModifier.PUBLIC)
            .addField(
                FieldSpec.builder(
                    ClassName("android.content", "SharedPreferences"),
                    "sp",
                    KModifier.PRIVATE
                ).build()
            )
            .addFunction(
                FunSpec.constructorBuilder()
                    .addParameter("context", ClassName("android.content", "Context"))
                    .addStatement("sp = context.getSharedPreferences(\$S, Context.MODE_PRIVATE)", spName)
                    .build()
            )

        // 为每个属性生成getter/setter
        properties.forEach { prop ->
            val getterStmt = when (prop.type) {
                "java.lang.String", "kotlin.String" ->
                    "sp.getString(\$S, \$L) ?: \$L"
                "int", "kotlin.Int" ->
                    "sp.getInt(\$S, \$L)"
                "boolean", "kotlin.Boolean" ->
                    "sp.getBoolean(\$S, \$L)"
                "float", "kotlin.Float" ->
                    "sp.getFloat(\$S, \$L)"
                "long", "kotlin.Long" ->
                    "sp.getLong(\$S, \$L)"
                else -> throw IllegalArgumentException("Unsupported type: ${prop.type}")
            }

            val setterStmt = when (prop.type) {
                "java.lang.String", "kotlin.String" ->
                    "sp.edit().putString(\$S, value).apply()"
                "int", "kotlin.Int" ->
                    "sp.edit().putInt(\$S, value).apply()"
                "boolean", "kotlin.Boolean" ->
                    "sp.edit().putBoolean(\$S, value).apply()"
                "float", "kotlin.Float" ->
                    "sp.edit().putFloat(\$S, value).apply()"
                "long", "kotlin.Long" ->
                    "sp.edit().putLong(\$S, value).apply()"
                else -> throw IllegalArgumentException("Unsupported type: ${prop.type}")
            }

            builder.addProperty(
                PropertySpec.builder(prop.name, ClassName.bestGuess(prop.type))
                    .addModifiers(KModifier.VAR)
                    .getter(
                        FunSpec.getterBuilder()
                            .addStatement(getterStmt, prop.name, prop.defaultValue, prop.defaultValue)
                            .build()
                    )
                    .setter(
                        FunSpec.setterBuilder()
                            .addParameter("value", ClassName.bestGuess(prop.type))
                            .addStatement(setterStmt, prop.name)
                            .build()
                    )
                    .build()
            )
        }

        val file = FileSpec.builder(packageName, spClassName)
            .addType(builder.build())
            .build()

        file.writeTo(processingEnv.filer)
    }

    private fun getDefaultValue(type: String): Any {
        return when (type) {
            "java.lang.String", "kotlin.String" -> "\"\""
            "int", "kotlin.Int" -> "0"
            "boolean", "kotlin.Boolean" -> "false"
            "float", "kotlin.Float" -> "0.0f"
            "long", "kotlin.Long" -> "0L"
            else -> "null"
        }
    }

    data class PropertyInfo(
        val name: String,
        val type: String,
        val defaultValue: Any
    )
}

注意:上面的代码为了演示清晰,简化了类型映射逻辑。实际项目中,你需要处理Set<String>、可空类型、自定义类型序列化等场景。我曾经因为没处理Set<String>,导致线上用户配置丢失,排查了一整天。

使用示例:一行注解,全自动

定义数据类:

@SharedPref(name = "app_settings")
data class AppSettings(
    val isDarkMode: Boolean = false,
    val fontSize: Int = 16,
    val userName: String = "",
    val lastLoginTime: Long = 0L
)

使用自动生成的SP类:

class MainActivity : AppCompatActivity() {
    private lateinit var settings: AppSettingsSP

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        settings = AppSettingsSP(this)

        // 读取 - 类型安全,编译器检查
        val isDark = settings.isDarkMode
        val fontSize = settings.fontSize

        // 写入 - 直接赋值
        settings.isDarkMode = true
        settings.fontSize = 18
    }
}

你想想看,以前要写多少代码?现在只需要一个注解,所有SP操作都变成了属性访问。而且类型不对?编译直接报错,根本不会留到运行时。

进阶:支持自定义类型序列化

实际项目中,我们经常需要存储复杂对象。比如用户信息:

data class UserInfo(
    val id: Long,
    val name: String,
    val avatar: String
)

@SharedPref(name = "user_cache")
data class UserCache(
    val currentUser: UserInfo? = null,  // 需要序列化
    val loginCount: Int = 0
)

对于非基本类型,我们需要在处理器中判断:如果是自定义类型,自动生成Gson序列化/反序列化代码。

处理器逻辑扩展:

// 在generateSpClass中增加类型判断
val isBasicType = prop.type in basicTypes
if (isBasicType) {
    // 生成基本类型的getter/setter
} else {
    // 生成Gson序列化的getter/setter
    // getter: Gson().fromJson(sp.getString("currentUser", ""), UserInfo::class.java)
    // setter: sp.edit().putString("currentUser", Gson().toJson(value)).apply()
}

避坑指南:我曾经在序列化这里踩过坑——如果数据类改了字段名,旧数据反序列化会失败。建议在fromJson时加上@SerializedName注解,或者用Moshi这种更安全的库。

性能与最佳实践

生成代码的性能怎么样?我直接说结论:和手写SP完全一样。因为生成的代码就是标准的SP API调用,没有任何反射开销。

几点建议:

  • 不要存储大对象:SP本质是XML文件,太大影响读写性能。超过10KB的数据建议用Room或文件存储
  • 合理使用apply/commit:生成代码默认用apply(异步),如果需要同步写入,可以加一个@Commit注解参数
  • 多进程场景慎用:SP不是线程安全的,多进程读写会有问题。如果必须用,考虑ContentProviderMMKV

本章小结

SharedPreferences注解处理器,说白了就是把重复劳动交给机器。你只需要定义数据结构和默认值,剩下的读写代码、类型检查、key管理,全部自动完成。

我个人觉得,这种模式特别适合配置类数据的存储——比如用户设置、缓存标记、功能开关。用注解处理器生成SP代码后,项目里再也没出现过"key拼写错误"这种低级bug。

嗯,如果你还在手写SP,不妨试试这个方案。代码量减少80%,类型安全提升100%。


公众号:蓝海资料掘金营,微信deep3321