像操作Room一樣操作SharedPreferences和File檔案

L_Xian發表於2019-06-10

導讀

我們的任務,不是去發現一些別人還沒有發現的東西。
而是針對所有人都看見的東西做一些從未有過的思考。 --魯迅

問題

經歷過多個專案或者維護一些比較老的專案的小夥伴可能會發現,在運算元據和檔案這一方面(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 檔案:

像操作Room一樣操作SharedPreferences和File檔案

File 檔案:

像操作Room一樣操作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

相關文章