像操作Room一样操作SharedPreferences和File文件

3,310 阅读7分钟

导读

我们的任务,不是去发现一些别人还没有发现的东西。
而是针对所有人都看见的东西做一些从未有过的思考。 --鲁迅

问题

经历过多个项目或者维护一些比较老的项目的小伙伴可能会发现,在操作数据和文件这一方面(SharedPreferences文件,File文件,数据库)通常我们会用一个工具类去完成,比如 SPUtils、FileUtils、XXXDaoManager... 之类的,里面会是一些静态方法去一个个实现具体的操作,看起来没啥问题,用得还挺爽。

那么问题来了,随着项目的迭代和人员的变换,你会发现这类型的工具类越来越多,因为不同的人他们有自己用习惯的代码,比如我现在的项目里面操作SharedPreferences文件的类就有 SpUtils,ContentUtil.getSp(),XXApplication.getContext().getSP(),还有直接用不封装的。操作 File 文件的类就有 FileUtils,CommonUtils 等,数据库就一个表一个 Manager 类。所以维护起来非常的麻烦。

思考

自从看了 Room 的源码后发现,原来操作数据库也可以封装得这么好,那么能不能也把 SharedPreferences 文件和 File 文件也模仿一下 Room 去封装成那样用呢,这样做的好处:

  1. 可以去掉工具类死版的写法,让操作这些文件变得更加面向对象,更加灵活。
  2. 封装过程中可以学到 APT 相关的知识
  3. 或者有些人认为这是瞎折腾,用工具类不就好了,但正如导读所说的,最后在这过程中学到的才是自己的。

那么文件存储跟数据库有什么相似之处: 保存文件的文件夹可以代表是一个数据库,里面的一个文件代表一张表,如果存储数据是用 key-value 形式的话,key 就是字段,value 就是值,这样就关联起来了。

开始

这里主要大概讲讲设计思路,如果不是很清楚 Room 实现原理和 APT 相关知识的朋友建议先了解一下。
完整的代码在这里:ElegantData

首先,提出愿景。我希望是这样使用的:

public interface SharedPreferencesInfo {
    String keyUserName = "";
}

定义一个接口,里面定义一些字段,字段的类型就是保存的类型。以上面代码为例,在使用的时候,会自动生成 putKeyUserName() 和 getKeyUserName 方法并自动存在 SharedPreferences 文件或 File 文件中。这样只需要维护好这个接口类就好了,维护成本很低,达到了想要的效果。

要自动生成代码,实现方式就选用 APT 去实现。 (关于 APT 网上有很多文章,这里就不具体将怎么去生成代码了)

首先定义一个注解,这个注解是加在接口上面的,因为只需要维护一个接口类,所有这个注解应该要可以定义文件的名称,以及要把数据存在 SharedPreferences 文件还是 File 文件中,所以这样写:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface ElegantEntity {
    int TYPE_PREFERENCE = 0;
    int TYPE_FILE = 1;
    String fileName() default "";
    int fileType() default TYPE_PREFERENCE;
}

定义两个方法,两个类型,文件名默认为空,默认存在 SharedPreferences 文件中。

使用效果:

//会生成名为UserInfo_Preferences的sp文件
@ElegantEntity(fileName = "UserInfo_Preferences")
public interface SharedPreferencesInfo {
    String keyUserName = "";
}

//会生成名为CacheFile.txt的File文件
@ElegantEntity(fileName = "CacheFile.txt", fileType = ElegantEntity.TYPE_FILE)
public interface FileCacheInfo extends IFileCacheInfoDao {
    int keyPassword = 0;  
}

接口和注解都定义好了,接下来就按照 APT 的规则去对应的生成相关代码即可。

可问题来了: 在使用 Room 的时候,我们需要定义一个 Dao 接口,里面定义一些增删查改的接口方法,用的时候就直接调用相关的方法即可,这里的接口其实是跟 Dao 接口类似的,但是因为 Dao 接口需要自己定义方法,而我们这里操作文件其实无非只需要 putXXX 方法和 getXXX 方法(大部分情况下),我只想写上字段即可,并不想给每个字段还写上 putXXX 和 getXXX 接口方法,但是不写的话又怎么调用呢?APT 并不能给现有的类添加方法。

想到的解决办法是既然修改不了现有的,那么就根据现有的生成一个有 putXXX 和 getXXX 接口方法的类,然后继承不就好了。

public interface ISharedPreferencesInfoDao {
  void putKeyUserName(String value);

  String getKeyUserName();

  String getKeyUserName(String defValue);

  boolean removeKeyUserName();

  boolean containsKeyUserName();

  boolean clear();
}

ISharedPreferencesInfoDao 就是根据 SharedPreferencesInfo 生成的接口类,然后我们修改一下之前的代码:

@ElegantEntity(fileName = "UserInfo_Preferences")
public interface SharedPreferencesInfo extends ISharedPreferencesInfoDao {
    String keyUserName = "";
}

这样,SharedPreferencesInfo 就有了对应的接口方法了。

ElegantData

接下来说说 ElegantData 这个库。在上面所说的定义好接口类后,接下来定义一个抽象类并继承ElegantDataBase

@ElegantDataMark
public abstract class AppDataBase extends ElegantDataBase {
}

并且加上 @ElegantDataMark 注解让编译器找到它。Room 的 RoomDataBase 的功能主要是创建数据库,而这里的 ElegantDataBase 功能也是类似的,它主要的作用是创建文件夹。

然后里面我们再对应上面加上两个抽象方法:

@ElegantDataMark
public abstract class AppDataBase extends ElegantDataBase {

    public abstract SharedPreferencesInfo getSharedPreferencesInfo();

    public abstract FileCacheInfo getFileCacheInfo();
}

rebuild 一下看看生成的代码:

public class AppDataBase_Impl extends AppDataBase {
    private com.lzx.elegantdata.SharedPreferencesInfo mSharedPreferencesInfo;

    private com.lzx.elegantdata.FileCacheInfo mFileCacheInfo;

    //该方法主要用于创建文件夹
    @Override
    protected IFolderCreateHelper createDataFolderHelper(Configuration configuration) {
        return configuration.mFactory.create(configuration.context, configuration.destFileDir);
    }
    
    //getSharedPreferencesInfo具体实现方法
    @Override
    public com.lzx.elegantdata.SharedPreferencesInfo getSharedPreferencesInfo() {
        if (mSharedPreferencesInfo != null) {
            return mSharedPreferencesInfo;
        } else {
            synchronized (this) {
                if (mSharedPreferencesInfo == null) {
                    SharedPreferences sharedPreferences = getCreateHelper().getContext()
                            .getSharedPreferences("UserInfo_Preferences", Context.MODE_PRIVATE);
                    mSharedPreferencesInfo = new SharedPreferencesInfo_Impl(sharedPreferences);
                }
                return mSharedPreferencesInfo;
            }
        }
    }
    
    //getFileCacheInfo具体实现方法
    @Override
    public com.lzx.elegantdata.FileCacheInfo getFileCacheInfo() {
        if (mFileCacheInfo != null) {
            return mFileCacheInfo;
        } else {
            synchronized (this) {
                if (mFileCacheInfo == null) {
                    IFolderCreateHelper createHelper = getCreateHelper();
                    mFileCacheInfo = new FileCacheInfo_Impl(createHelper);
                }
                return mFileCacheInfo;
            }
        }
    }
}

抽象方法和接口都会对应的生成实现类,实现类的名字是抽象类或者接口类名字加上 _Impl。

AppDataBase 的实现类 AppDataBase_Impl 定义了两个变量和三个方法,其中 createDataFolderHelper 方法主要是用于创建文件夹的,对于 SharedPreferences 文件我们不需要创建文件夹,所以这方法是针对 File 文件用的。其他方法和变量是根据在 AppDataBase 中定义的抽象方法生成的。

SharedPreferencesInfo 接口的实现类是 SharedPreferencesInfo_Impl,在 getSharedPreferencesInfo 方法中通过单例模式获取。
getFileCacheInfo 也一样。而他们的实现类里面实现的就是接口方法的具体操作了。

如何使用

那么在看了生成的代码后,我想大概都知道是怎么回事了,下面看看如何使用。
首先在 AppDataBase 中使用单例去获取 AppDataBase_Impl 实例,AppDataBase 完整代码:

@ElegantDataMark
public abstract class AppDataBase extends ElegantDataBase {

    public abstract SharedPreferencesInfo getSharedPreferencesInfo();

    public abstract FileCacheInfo getFileCacheInfo();

    private static AppDataBase spInstance;
    private static AppDataBase fileInstance;
    private static final Object sLock = new Object();

    //使用SP文件
    public static AppDataBase withSp() {
        synchronized (sLock) {
            if (spInstance == null) {
                spInstance = ElegantData
                        .preferenceBuilder(ElegantApplication.getContext(), AppDataBase.class)
                        .build();
            }
            return spInstance;
        }
    }

    //使用File文件
    public static AppDataBase withFile() {
        synchronized (sLock) {
            if (fileInstance == null) {
                String path = Environment.getExternalStorageDirectory() + "/ElegantFolder";
                fileInstance = ElegantData
                        .fileBuilder(ElegantApplication.getContext(), path, AppDataBase.class)
                        .build();
            }
            return fileInstance;
        }
    }
}

如果使用 SharedPreferences 文件,调用 ElegantData#preferenceBuilder 方法去构建实例,如果是 File 文件,则使用 ElegantData#fileBuilder 去构建。
两个方法都需要传入上下文和 AppDataBase 的 class。唯一不一样的是使用 File 文件需要先创建文件夹,所以在第二个参数传入的是创建文件夹的路径。

使用:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        //使用 SP 文件存入数据
        AppDataBase.withSp().getSharedPreferencesInfo().putKeyUserName("小明");
        //使用 File 文件存入数据
        AppDataBase.withFile().getFileCacheInfo().putKeyPassword(123456789);

        String userName = AppDataBase.withSp().getSharedPreferencesInfo().getKeyUserName();
        Log.i("MainActivity", "userName = " + userName);

        int password = AppDataBase.withFile().getFileCacheInfo().getKeyPassword();
        Log.i("MainActivity", "password = " + password);
    }

最后看看存储结果吧:
SharedPreferences 文件:

File 文件:

可以看到,如果是存 File 文件的,内容是加密的。

其他注解:

@IgnoreField

被 @IgnoreField 注解标记的字段,将不会被解析:

@ElegantEntity(fileName = "UserInfo_Preferences")
public interface SharedPreferencesInfo extends ISharedPreferencesInfoDao {
    String keyUserName = "";
    
    @IgnoreField
    int keyUserSex = 0;
}

Rebuild 后,keyUserSex 会被忽略,相关字段的方法不会被生成。

@NameField

被 @NameField 注解标记的字段,可以重命名:

@ElegantEntity(fileName = "UserInfo_Preferences")
public interface SharedPreferencesInfo extends ISharedPreferencesInfoDao {
    String keyUserName = "";
    
    @NameField(value = "sex")
    int keyUserSex = 0;
}

字段 keyUserSex 解析后生成的 put 和 get 方法是 putSex 和 getSex , 而不是 putUserSex 和 getUserSex。

@EntityClass

@EntityClass 注解用来标注实体类,如果你需要往文件中存入实体类,那么需要加上这个注解,否则会出错。

@ElegantEntity(fileName = "UserInfo_Preferences")
public interface SharedPreferencesInfo extends ISharedPreferencesInfoDao {
    String keyUserName = "";

    @EntityClass(value = SimpleJsonParser.class)
    User user = null;
}

如上所示,@EntityClass 注解需要传入一个 json 解析器,存入实体类的原理是把实体类通过解析器变成 json 字符串存入文件,取出来的时候 通过解析器解析 json 字符串变成实体类。

public class SimpleJsonParser extends JsonParser<User> {

    private Gson mGson;

    public SimpleJsonParser(Class<User> clazz) {
        super(clazz);
        mGson = new Gson();
    }

    @Override
    public String convertObject(User object) {
        return mGson.toJson(object);
    }

    @Override
    public User onParse(@NonNull String json)   {
        return mGson.fromJson(json, User.class);
    }
}

json 解析器需要实现两个方法,convertObject 方法作用是把实体类变成 json 字符串,onParse 方法作用是把 json 字符串变成 实体类。

目前还有2个问题还没实现:

  1. 读写文件权限动态申请,这个还需要自己做
  2. 结合 RxJava 和 LiveData

这两个问题后面会完善。

项目地址:ElegantData