「打造自己的Library」SharedPreferences篇

96
郭非文
2016.01.06 21:12* 字数 2610

Updated on 2016/1/26
欢迎转载,但请保留作者链接:http://www.jianshu.com/p/64ef6eb7406f
LitePreferences完整源码传送门GitHub

开局闲谈

SharedPreferences是Android之中的基础内容,是一种非常轻量化的存储工具。核心思想就是在xml文件中保存键值对。而正因为采用的是文件读写,所以它天生线程不安全。Google曾经想要对其进行一番扩展以令其实现线程安全读写,但最终以失败告终。后来于是有了民间替代方案,详细可以参考GitHub上这个项目
笔者本身对SharedPreferences是否线程安全是没有需求的,我主要是觉得它——
限、制、太、多!使、用、太、麻、烦!

吐槽及预期

// get it
SharedPreferences p = mContext.getSharedPreferences("Myprefs", Context.MODE_PRIVATE);
// or
p = PreferenceManager.getDefaultSharedPreferences(mContext);

// read
p.getString("preference_key", "default value");

// write
p.edit().putString("preference_key", "new value").commit();
// or
p.edit().putString("preference_key", "new value").apply();

这里演示了String类型的情况,其他也是类似。
以上就是SharedPreferences的基本使用情况了,足以应付绝大部分情况,看上去也就那么几行,挺简单、挺好用的嘛!
那好,我们现在来看一下它究竟有哪些短板。

限制之一,使用之前必须拿到Context:

// get it
SharedPreferences p = mContext.getSharedPreferences("Myprefs", Context.MODE_PRIVATE);
// or
p = PreferenceManager.getDefaultSharedPreferences(mContext);

这里展示了两种方式,第一种的优势是可以自定义名称,并且如果需要的话可以指定全局读写(虽然Google不推荐用SharedPreferences来跨应用读写,相关字段早就被置上了deprecated),如果不需要则纯粹成了消耗多余体力的代码。
而且,Context并不是永远都那么好拿的,所以有一种最简单粗暴的作法就是做一个自己的Application类像是这样:

public class App extends Application {
    private static Context sMe;
    public static Context getInstance() {
        return sMe;
    }
    @Override
    public void onCreate() {
        super.onCreate();
        sMe = this;
    }
}

但是杀鸡焉用牛刀,你做这样一个全局可得的ApplicationContext本就是为了不时之需,拿来用SharedPreferences,每次还得这样写App.getInstance(),逼格太低又很累啊。

限制之二,读值为什么会要这么多代码:

// read
p.getString("preference_key", "default value");

初看上去,这似乎是无比正常的代码:"default value"的存在确保了你永远可以取到值,但问题就出在这个"default value"上了,在某种情况下,你需要取某个值的地方很多,而且全都可能还没有初始化过,也就是说在这些地方实际第一次处理时使用到值的是"default value",假如某一天"default value"值需要变更,你就要细心谨慎地把每个地方都改一轮了。

限制之三,写值代码也很多:

// write
p.edit().putString("preference_key", "new value").commit();
// or
p.edit().putString("preference_key", "new value").apply();

先拿到Editor内部类,再操作,最后再提交,虽然IDE自带补全功能,但补全三次也不是那么方便吧?源码中的说法是,“so you can chain put calls together.”,因为每次putXXX()操作后仍旧返回同一个Editor内部类对象,所以你能一次性put许多下最后再提交。可实际情况中使用到链式调用的机会还是挺少的,毕竟很难出现Web上那种出现一整个表单给用户填写,最后一次性提交的情况。

总的来说,在不同的地方重复获取SharedPreferences是没有必要的,可以拿一个单例来解决;读值和写值太累赘了,要做下封装……
不,这还不够,作为一个名有追求的工程师——
我们需要一个强有力的Library来解决这些问题,力争达到一经写就,永久受益的效果。

常规解决方案

一般是做一个单例工具类,然后简单封装一下方法,这里截取了一下Notes中的部分代码如下:

/**
 * Created by lgp on 2014/10/30.
 */
public class PreferenceUtils{

    private SharedPreferences sharedPreferences;

    private SharedPreferences.Editor shareEditor;

    private static PreferenceUtils preferenceUtils = null;

    public static final String NOTE_TYPE_KEY = "NOTE_TYPE_KEY";

    public static final String EVERNOTE_ACCOUNT_KEY = "EVERNOTE_ACCOUNT_KEY";

    public static final String EVERNOTE_NOTEBOOK_GUID_KEY = "EVERNOTE_NOTEBOOK_GUID_KEY";

    @Inject @Singleton
    protected PreferenceUtils(@ContextLifeCycle("App") Context context){
        sharedPreferences = context.getSharedPreferences(SettingFragment.PREFERENCE_FILE_NAME, Context.MODE_PRIVATE);
        shareEditor = sharedPreferences.edit();
    }

    public static PreferenceUtils getInstance(Context context){
        if (preferenceUtils == null) {
            synchronized (PreferenceUtils.class) {
                if (preferenceUtils == null) {
                    preferenceUtils = new PreferenceUtils(context.getApplicationContext());
                }
            }
        }
        return preferenceUtils;
    }

    public String getStringParam(String key){
        return getStringParam(key, "");
    }

    public String getStringParam(String key, String defaultString){
        return sharedPreferences.getString(key, defaultString);
    }

    public void saveParam(String key, String value)
    {
        shareEditor.putString(key,value).commit();
    }

    ......
}

可以看到其思想还是挺简单的,基本上对于限制一二三全都照顾到了。
对于限制一,因为是单例,只要明确这个类已经初始化过一次了,后面就可以这样来获取实例PreferenceUtils.getInstance(null)——必须说明这是一种取巧的手段,而且看上去非常丑陋——所以说不需要依赖Context(另外我们还可以增加对于resId的支持,让这种方式成为可能getStringParam(int resId)只要在这个类中持有Context就能做到——但要注意为防内存泄漏应给这个类传ApplicationContext);关键是限制二的解决并不漂亮,因为不同的设置项的default值多数情况下是不一样的,所以还是提供了一个二参方法getStringParam(String key, String defaultString),本质上并没有解决。

不过不管怎样,我们的Library LitePreferences最起码要包含以上这个工具类的全部功能,然后再谈突破。

极致简约

既然是个单例,那么在使用之前就必须调用getInstance()了,像是这样:

LitePrefs.getInstance(mContext).getInt(R.string.tedious);

在这行代码中,如果LitePrefs已经初始化过一次了,那么中间的getInstance(mContext)纯粹就是毫无意义。我们希望代码简约成这样:

LitePrefs.getInt(R.string.tedious);

要达到这样的效果,只需让getInt()是一个静态方法即可。直接包装一层:

public static int getInt(int resId) {
       return  getInstance().getIntLite(resId);
}

为什么这里的getInstance()无参?因为LitePrefs构造方法是这样的:

private LitePrefs() {}

无参,什么也不做。对于这个类的初始化全都剥离到一个专门的初始化方法中去了。这意味着要使用这个类之前,必须先初始化。它们看上去像是这样:

private boolean valid = false;

public static void init(Context ctx) {
     getInstance().initLite(ctx);
}

public void initLite(Context ctx) {
     // do something to initialize 
     
     valid = true;
}

    private void checkValid() {
        if (!valid) {
            throw new IllegalStateException("this should only be called when LitePrefs didn't initialize once");
        }
    }

记得用一个标志位来保障工具类已经初始化过。
使用这种方式,所有的操作都可以简化为LitePrefs.静态方法()。

支持文件配置

完成之后,我们的Library会拥有这样的初始化技能:

        try {
            LitePrefs.initFromXml(context, R.xml.prefs);
        } catch (IOException | XmlPullParserException e) {
            e.printStackTrace();
        }

支持文件配置不仅会让配置变得很方便,同时也绕过了限制二:依常理考虑,一个设置项的默认值应该是惟一的。那么,如果在第一次启动应用时写一次初始值到SharedPreferences中,那么今后取值的时候不就永远有值了吗?那么上面那种单参封装也就可以一直正常使用了。

既然要用文件读写,那就开搞吧,很容易想到使用一个xml文件来放配置项像是这样:

<?xml version="1.0" encoding="utf-8"?>
<prefs name="liteprefs">
    <pref>
        <key>preference_key</key>
        <def-value>default value</def-value>
        <description>Write some sentences if you want,
        the LitePrefs parser will not parse the tag "description"</description>
    </pref>
    <pref>
        <key>boolean_key</key>
        <def-value>false</def-value>
    </pref>
    <pref>
        <key>int_key</key>
        <def-value>233</def-value>
    </pref>
    <pref>
        <key>float_key</key>
        <def-value>3.141592</def-value>
    </pref>
    <pref>
        <key>long_key</key>
        <def-value>4294967296</def-value>
    </pref>
    <pref>
        <key>String_key</key>
        <def-value>this is a String</def-value>
    </pref>
</prefs>

由于xml解析器由我们自己来写,所以非常自由。这里attribute"name"中写上了对应的SharedPreferences使用的name。tag也是各种随意。而且多写几个不解析的tag用来在配置文件中添加说明也没有问题,像是上面的"<description>","</description>"。
基本数据类型全都可以很容易写出来,处理也容易,就是Set<String>不是太好处理,但SharedPreferences中这个支持用到的场合还是非常少的,目前我在Android源码中从未见过使用的例子。

考虑一个问题:上面怎么说也有五种类型的数据,我们要怎么读?只有两个tag显然不足以判断这一项的具体类型是int还是String,难道我们要加一个tag专门来区分吗?
虽然可以这样做,但这样写model类又会是老大难的问题——要写一个model类让它持有标志类型的flag,再加上持有五种类型的域?这也太恐怖了吧!

话说回来,写入配置到xml这一步真的是必要的吗?
因为SharedPreferences要写过之后才有值,所以我们想要在第一次运行应用时读配置文件然后把值写进xml,之后运行则不再需要进行这样的操作——这就是原定计划了,但这其实是存在漏洞的,漏洞出在SharedPreferences中的两个方法上:remove(String key)clear()
这两个方法会把值清空,用户来一发恢复默认设置的时候就是它们登场的时候。

既然如此,我们更改计划:应用启动时读取配置文件并持有这些信息,在读Preference项的时候,如该项未设置则返回配置文件中的默认值
这样一来,无须考虑写文件操作的情况下,我们读文件时条件也可放宽了:根本就不需要知道Preference的数据类型,全部用String类型保存就好,编程者为正确使用它们而负责

我们用一个Pref类作为Preference项的模型,这样设计:

 public class Pref {

    public String key;

    /**
     * use String store the default value
     */
    public String defValue;

    /**
     * use String store the current value
     */
    public String curValue;

    /**
     * flag to show the pref has queried its data from SharedPreferences or not
     */
    public boolean queried = false;

    public Pref() {
    }

    public Pref(String key, String defValue) {
        this.key = key;
        this.defValue = defValue;
    }

    public Pref(String key, int defValue) {
        this.key = key;
        this.defValue = String.valueOf(defValue);
    }

   .......

    public int getDefInt() {
        return Integer.parseInt(defValue);
    }

    public String getDefString() {
        return defValue;
    }

   .......

    public int getCurInt() {
        return Integer.parseInt(curValue);
    }

    public String getCurString() {
        return curValue;
    }
    
    .......

    public void setValue(int value) {
        curValue = String.valueOf(value);
    }

    public void setValue(String value) {
        curValue = value;
    }
    
    ......

以上代码片段展示了对于int及String类型的处理,用一个defValue保存该Pref项的默认值;用queried标志是否该Pref曾经进行过查询,假如有,那么其实际值保存在curValue之中。通过这样的处理,每一个Preference项最多只会查询一次。

所以,解析器可以非常简单地写成像是这样:

public class ParsePrefsXml {

    private static final String TAG_ROOT = "prefs";
    private static final String TAG_CHILD = "pref";
    private static final String ATTR_NAME = "name";

    private static final String TAG_KEY = "key";
    private static final String TAG_DEFAULT_VALUE = "def-value";

    public static ActualUtil parse(XmlResourceParser parser)
            throws XmlPullParserException, IOException {
        Map<String, Pref> map = new HashMap<>();
        int event = parser.getEventType();

        Pref pref = null;
        String name = null;
        Stack<String> tagStack = new Stack<>();

        while (event != XmlResourceParser.END_DOCUMENT) {
            if (event == XmlResourceParser.START_TAG) {
                switch (parser.getName()) {
                    case TAG_ROOT:
                        name = parser.getAttributeValue(null, ATTR_NAME);
                        tagStack.push(TAG_ROOT);
                        if (null == name) {
                            throw new XmlPullParserException(
                                    "Error in xml: doesn't contain a 'name' at line:"
                                            + parser.getLineNumber());
                        }
                        break;
                    case TAG_CHILD:
                        pref = new Pref();
                        tagStack.push(TAG_CHILD);
                        break;
                    case TAG_KEY:
                        tagStack.push(TAG_KEY);
                        break;
                    case TAG_DEFAULT_VALUE:
                        tagStack.push(TAG_DEFAULT_VALUE);
                        break;
//                    default:
//                        throw new XmlPullParserException(
//                                "Error in xml: tag isn't '"
//                                        + TAG_ROOT
//                                        + "' or '"
//                                        + TAG_CHILD
//                                        + "' or '"
//                                        + TAG_KEY
//                                        + "' or '"
//                                        + TAG_DEFAULT_VALUE
//                                        + "' at line:"
//                                        + parser.getLineNumber());
                }

            } else if (event == XmlResourceParser.TEXT) {
                switch (tagStack.peek()) {
                    case TAG_KEY:
                        pref.key = parser.getText();
                        break;
                    case TAG_DEFAULT_VALUE:
                        pref.defValue = parser.getText();
                        break;
                }

            } else if (event == XmlResourceParser.END_TAG) {
                boolean mismatch = false;
                switch (parser.getName()) {
                    case TAG_ROOT:
                        if (!TAG_ROOT.equals(tagStack.pop())) {
                            mismatch = true;
                        }
                        break;
                    case TAG_CHILD:
                        if (!TAG_CHILD.equals(tagStack.pop())) {
                            mismatch = true;
                        }
                        map.put(pref.key, pref);
                        break;
                    case TAG_KEY:
                        if (!TAG_KEY.equals(tagStack.pop())) {
                            mismatch = true;
                        }
                        break;
                    case TAG_DEFAULT_VALUE:
                        if (!TAG_DEFAULT_VALUE.equals(tagStack.pop())) {
                            mismatch = true;
                        }
                        break;
                }

                if (mismatch) {
                    throw new XmlPullParserException(
                            "Error in xml: mismatch end tag at line:"
                                    + parser.getLineNumber());
                }

            }
            event = parser.next();
        }
        parser.close();
        return new ActualUtil(name, map);
    }
}

这里解析完成最后返回的ActualUtil是一个实际操作SharedPreferences的基础工具类,它的逻辑也很简单,像是这样:

public class ActualUtil {
    private int editMode = LitePrefs.MODE_COMMIT;
    private String name;
    private SharedPreferences mSharedPreferences;
    private Map<String, Pref> mMap;

    public ActualUtil(String name, Map<String, Pref> map) {
        this.name = name;
        this.mMap = map;
    }

    public void init(Context context) {
        mSharedPreferences = context.getSharedPreferences(name, Context.MODE_PRIVATE);
    }

    public void setEditMode(int editMode) {
        this.editMode = editMode;
    }

    public void putToMap(String key, Pref pref) {
        mMap.put(key, pref);
    }

    private void checkExist(Pref pref) {
        if (null == pref) {
            throw new NullPointerException("operate a pref that isn't contained in data set,maybe there are some wrong in initialization of LitePrefs");
        }
    }

    private Pref readyOperation(String key) {
        Pref pref = mMap.get(key);
        checkExist(pref);
        return pref;
    }

    public int getInt(String key) {
        Pref pref = readyOperation(key);
        if (pref.queried) {
            return pref.getCurInt();
        } else {
            pref.queried = true;
            int ans = mSharedPreferences.getInt(key, pref.getDefInt());
            pref.setValue(ans);
            return ans;
        }
    }
    
    public boolean putInt(String key, int value) {
        Pref pref = readyOperation(key);
        pref.queried = true;
        pref.setValue(value);

        if (LitePrefs.MODE_APPLY == editMode) {
            mSharedPreferences.edit().putInt(key, value).apply();
            return true;
        }
        return mSharedPreferences.edit().putInt(key, value).commit();
    }

    ......
}

可扩展性

无扩展性、泛用性不够的代码只能作为一次性使用。

UML

我们的结构如图中所示,ActualUtil持有SharedPreferences,实际完成读写操作,ParsePerfsXml提供解析方法将xml配置文件解析成相应的ActualUtil,而提供给用户的实际操作类则为LitePrefs。
看上去抽象程度还算不错,当我们需要针对项目特性定制的时候只需要继承LitePrefs就可以……问题就出在这里,LitePrefs是个单例

    private static volatile LitePrefs sMe;

    private LitePrefs() {

    }

    public static LitePrefs getInstance() {
        if (null == sMe) {
            synchronized (LitePrefs.class) {
                if (null == sMe) {
                    sMe = new LitePrefs();
                }
            }
        }
        return sMe;
    }

因为是单例,所以LitePrefs的构造方法为private,这保障了它不会在类外部被创建。但这也同时使得其无法派生出子类。这可不是一件好事。出于这个原由,我们特别设计一个不标准的单例BaseLitePrefs用于扩展:

    private static volatile BaseLitePrefs sMe;

    protected BaseLitePrefs() {

    }

    public static BaseLitePrefs getInstance() {
        if (null == sMe) {
            synchronized (BaseLitePrefs.class) {
                if (null == sMe) {
                    sMe = new BaseLitePrefs();
                }
            }
        }
        return sMe;
    }

因为将访问权限修改为了protected,所以这个类可以被顺利继承,虽然损失了一点严谨性,但这完全值得。

现在,我们可尝试着写一个子类看看:

public class MyLitePrefs extends BaseLitePrefs {
      public static final String THEME = "choose_theme_key";

      public static void initFromXml(Context context) {
          try {
                initFromXml(context, R.xml.prefs);
          } catch (IOException | XmlPullParserException e) {
                e.printStackTrace();
          }
      }

      public static ThemeUtils.Theme getTheme() {
          return ThemeUtils.Theme.mapValueToTheme(getInt(THEME));
      }

      public static boolean setTheme(int value) {
          return putInt(THEME, value);
      }
}

本篇至此结束,完整源码链接在顶部。

Android
Web note ad 1