一个null引发的SharedPreference惨案

*本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
(之前发表在我的csdn博客中,现在同步到简书来)
本周在安卓博客周刊里看到了一篇请不要滥用SharedPreference,感觉颇有收获。而我恰好在这周碰到了一个关于SharedPreference的bug,让我费了一番功夫才找出,所以在这里就写一篇文章来记录一下这个bug。

1.bug再现

首先让我来讲下这个bug的由来,并通过一个demo来模拟下bug现场。

在我所做的产品中有个XX云盘模块,其中有部分登陆信息是记录在SharedPrefrence中的。突然有一天,测试的同学拿来一部手机跟我说,这个手机登陆以后,杀掉进程再重新启动app就会丢失掉之前的登陆信息。当时我就一脸懵逼,再拿来观察一下现象,更加奇怪的事情是:登陆以后我发现登陆信息确实保存在了SharedPreference中,杀掉进程以后也还在,但是重新打开app以后,发现SharedPreference里的数据有部分被清空了(注意不是全部清空,而是部分)。

从现象上来看让人感到一头雾水,同时有的手机上又没有这种奇怪的现象发生,让我一时间都觉得是不是这个手机的rom比较烂所导致的。但是作为一个开发人员当然不能如此妄下定论,于是我写了一个demo来测试,发现没有上述的问题。那么可以肯定的是,这是我自己的app的逻辑存在bug!但是我全局找了一遍代码,并没有发现有任何去删除SharedPreference值的逻辑,于是这条线索又断了。

一头雾水的我只能转而研究SharedPreference文件本身,此时我发现了一个很怪异的现象,我的SharedPreference里有个key为null的值。key为null?作为一个开发人员,对于空指针还是有着十足的敏感,于是我就怀疑到了是不是这个为null的key引起的呢?下面我通过一个demo演示就真相大白了:

public class MainActivity extends FragmentActivity
        implements
            View.OnClickListener {

    Button saveOne, saveTwo, getOne, getTwo;

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

        saveOne = (Button) findViewById(R.id.saveone);
        saveTwo = (Button) findViewById(R.id.savetwo);
        getOne = (Button) findViewById(R.id.getOne);
        getTwo = (Button) findViewById(R.id.getTwo);

        saveOne.setOnClickListener(this);
        saveTwo.setOnClickListener(this);
        getOne.setOnClickListener(this);
        getTwo.setOnClickListener(this);

    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.saveone :
                putString("one", "saveone");
                break;
            case R.id.savetwo :
                putString(null, "savetwo");
                break;
            case R.id.getOne :
                Toast.makeText(this, getString("one"), Toast.LENGTH_LONG)
                        .show();
                break;
            case R.id.getTwo :
                Toast.makeText(this, getString(null), Toast.LENGTH_LONG).show();
                break;
        }
    }

    public void putString(String key, String value) {
        SharedPreferences sharedPreferences = getSharedPreferences("test",
                MODE_PRIVATE);
        SharedPreferences.Editor editor = sharedPreferences.edit();
        editor.putString(key, value);
        editor.commit();
    }

    public String getString(String key) {
        SharedPreferences sharedPreferences = getSharedPreferences("test",
                MODE_PRIVATE);
        return sharedPreferences.getString(key, "null");
    }
}

存储一个正常的key并获取值:

存储一个正常的key并获取值

存储一个key为null的值并获取:

存储一个key为null的值并获取

以上我们的存储与获取都是正常的,并没有什么好说的,现在我杀掉进程再重新启动这个demo,那么诡异的现象就发生了:

杀掉进程以后去查看值发现都还在

杀掉进程以后去查看值发现都还在

但是都获取不到值了

但是都获取不到值了

是不是很怪异,明明值还在,但是就是获取不到真实的值,但是更加怪异的事情还在后面,当我点击了"存储一个为null的key进去"的时候再去观察SharedPreference数据发现数据都没了:


数据都没了!

真相大白,原来杀掉进程以后数据被清空了是因为存储了一个为null的key引起的。

2分析bug

既然问题已经定位了,就很容易解决了,将那个为null的key找出来赋值进去即可。但是作为一个对自己有高要求的研发人员,还是很有必要研究清楚整个bug的来龙去脉的。
首先,从现象上来分析下这个bug产生的原因,在我杀掉进程第二次进入app没有获取到值的时候,在logcat上打下了如下一段日志:

10-29 22:54:37.099 10964-11037/? W/SharedPreferencesImpl: getSharedPreferences
                                                          org.xmlpull.v1.XmlPullParserException: Map value without name attribute: string
                                                              at com.android.internal.util.XmlUtils.readThisMapXml(XmlUtils.java:568)
                                                              at com.android.internal.util.XmlUtils.readThisValueXml(XmlUtils.java:821)
                                                              at com.android.internal.util.XmlUtils.readValueXml(XmlUtils.java:755)
                                                              at com.android.internal.util.XmlUtils.readMapXml(XmlUtils.java:494)
                                                              at android.app.SharedPreferencesImpl.loadFromDiskLocked(SharedPreferencesImpl.java:113)
                                                              at android.app.SharedPreferencesImpl.access$000(SharedPreferencesImpl.java:48)
                                                              at android.app.SharedPreferencesImpl$1.run(SharedPreferencesImpl.java:87)

同时,此异常只有在我第一次获取值没有获取到的时候才打印出来,后面无论有多少次未获取到都不会打印。大概从字面上的意思上来说,就是去获取了一个没有attribute的一个Map值产生的。通过我开头提到的文章了解到,SharedPreference每次取值的时候都是在第一次get的时候就将SharedPreference文件里的值一一读取并存入内存中,而后我们的每次get操作都是从内存当中取出的。

基于这个推断,我大胆的推测:之所以为null,是因为我们在此第一次去解析xml文件失败(抛异常了),所以导致了我们内存中取出来的map里为null,以至于我们后面每次取值都为null。而后写入的值则是因为我们将内存的值直接写入磁盘,覆盖了原有的值,所以导致了我们的数据"部分被删除"。至于有的手机没有该问题,我猜测的是android在5.0开始对于这类问题进行了容错处理,就是当发现有的value没有key的时候也能将值写入内存,只不过对应的key为null而已。

3.验证猜想

为了验证之前的猜想,就必须深入到安卓系统源码研究了。这里我们拿4.4的代码与5.1的代码来进行对比。首先我们根据抛异常的位置到4.4的源码里来一窥究竟:

    public static final HashMap readThisMapXml(XmlPullParser parser, String endTag, String[] name)
    throws XmlPullParserException, java.io.IOException
    {
        HashMap map = new HashMap();

        int eventType = parser.getEventType();
        do {
            if (eventType == parser.START_TAG) {
                Object val = readThisValueXml(parser, name);
                if (name[0] != null) {
                    //System.out.println("Adding to map: " + name + " -> " + val);
                    map.put(name[0], val);
                } else {
                    throw new XmlPullParserException(
                        "Map value without name attribute: " + parser.getName());
                }
            } else if (eventType == parser.END_TAG) {
                if (parser.getName().equals(endTag)) {
                    return map;
                }
                throw new XmlPullParserException(
                    "Expected " + endTag + " end tag at: " + parser.getName());
            }
            eventType = parser.next();
        } while (eventType != parser.END_DOCUMENT);

        throw new XmlPullParserException(
            "Document ended before " + endTag + " end tag");
    }

真相豁然开朗,当name为null的时候抛出了异常,与我之前在logcat上看到的一模一样。那么接着我们再看看5.1的代码这段是怎么处理的:

 public static final HashMap<String, ?> readThisMapXml(XmlPullParser parser, String endTag,
            String[] name, ReadMapCallback callback)
            throws XmlPullParserException, java.io.IOException
    {
        HashMap<String, Object> map = new HashMap<String, Object>();

        int eventType = parser.getEventType();
        do {
            if (eventType == parser.START_TAG) {
                Object val = readThisValueXml(parser, name, callback);
                map.put(name[0], val);
            } else if (eventType == parser.END_TAG) {
                if (parser.getName().equals(endTag)) {
                    return map;
                }
                throw new XmlPullParserException(
                    "Expected " + endTag + " end tag at: " + parser.getName());
            }
            eventType = parser.next();
        } while (eventType != parser.END_DOCUMENT);

        throw new XmlPullParserException(
            "Document ended before " + endTag + " end tag");
    }

我们可以看到明显的区别是,这里没有对name为null的分支进行处理,而是一视同仁的去读取值写入了map中。
那么,至于为什么在杀掉进程以后再次开启APP又会"删除"掉部分值呢?我们接着分析源码:

SharedPreferencesImpl.java


private MemoryCommitResult commitToMemory() {
            MemoryCommitResult mcr = new MemoryCommitResult();
            synchronized (SharedPreferencesImpl.this) {
                if (mDiskWritesInFlight > 0) {
                    mMap = new HashMap<String, Object>(mMap);
                }
                //我们可以看到在内存中commit值的时候会基于创建SharedPreferences时候得到的map来进行一个copy并写入新值最后写入磁盘中
                mcr.mapToWriteToDisk = mMap;
                mDiskWritesInFlight++;
                省略以下代码....

在这一块上4.4的代码与5.1的代码几乎一致,所以,导致4.4会删除值的原因就在于之前并没有从磁盘中读取到值到内存中。在对值进行copy的时候就"丢失",从而导致写入进去的新文件里没有以前的旧值,由此现象上来看好像是某些值被删除了,但实际上确切的说应该是被空值所覆盖。

4总结

总的来说,这个问题的根源在于4.X的sdk里允许了往SharedPreferences里写入key为null的值而不允许取出来,并且不仅不能取出来,甚至还在写的过程当中引发了"删除"数据这样灾难性的后果。这样是及其不合理的,所以google在后续的SDK里也对这一块的逻辑修复了。不过,作为一个应用层开发人员,也是需要从自身检讨一下这样的低级错误。毕竟这个错误的后果十分严重,但又无色无味难以发现,以后需要尽可能的避免这样的错误再次发生。

相信通过这样一次从现象入手分析问题到追踪,再到SDK源码比对各版本间的差异,对于自身的水平提高还是有很多的益处的,希望以后还能够这样来分析一些更加深入的问题。当然,最后想说的是,我们还是从自身做起,尽量少犯低级错误,少出bug,做一个高水平的工程师,而不是整天debug的码农。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,050评论 25 707
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,493评论 18 399
  • 第一章 Nginx简介 Nginx是什么 没有听过Nginx?那么一定听过它的“同行”Apache吧!Ngi...
    JokerW阅读 32,605评论 24 1,002
  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,137评论 11 349
  • 什么是闭包闭包就是能够读取其他函数内部变量的函数,由于在Javascript语言中,只有函数内部的子函数才能读取局...
    陈7号阅读 434评论 0 1