一个Null值引发的神奇Bug,关于SharedPreferences的探索

2,571 阅读5分钟

现象

最近项目中出现一个诡异的Bug,测试同学发现,在完成某项业务流程后,登录状态被清空了。接到问题后,我们第一时间进行复现,均未能成功。

分析定位

开发中,我们使用SDK提供的SharedPreferences(下文中简称为Shared)进行数据的持久化,在出现Bug的代码中,没有任何清除登录标志位的操作,于是我提出猜想,是不是某一次操作Shared时出现问题,导致所有数据被清空。但由于一直未能在测试机上复现,所以迟迟没有定位到原因。直到最近,我们使用出现Bug的同型号手机,在进行一项“查看”操作后,将其复现。

根据以上信息,我们定位到“查看”功能代码,发现在操作Shared写入数据时,会有null作为key的情况。应用进程被杀后再次进入时,就会出现登录信息被清空的情况。关于在测试机上无法复现的问题。经过验证,发现这个问题只在系统5.0版本以下出现。看来5.0之后应该是做了处理。

探索

Bug是处理完了,但我们一向提倡要知其所以然。于是我写了一个Demo,看看到底发生了什么。界面很简单,只有两个按钮:

界面
界面

Activity代码如下:

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //创建一个名为fenglx的SharedPreferences,模式为MODE_PRIVATE
        SharedPreferences sharedPreferences = getSharedPreferences("fenglx" , MODE_PRIVATE);
        final SharedPreferences.Editor editor= sharedPreferences.edit();
        //按钮1
        findViewById(R.id.write_btn).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                //分别存入3个正常Key-value的测试值
                editor.putString("key1","value1");
                editor.putString("key2","value2");
                editor.putString("key3","value3");
                editor.commit();
            }
        });
        //按钮2
        findViewById(R.id.write_null_btn).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                //存入Key值为null的测试值
                editor.putString(null ,"value4");
                editor.commit();
            }
        });
    }
}

调用getSharedPreferences()后,会在/data/data/包名/shared_prefs目录下创建一个xml,用于持久化数据。通过adb命令,可以查看这些xml文件,以便观察不同操作下的数据变化。
Demo应用运行后,第一步先在Shared中存入3个测试数据。接下来,用adb操作打开名为fenglx.xml的文件,命令如下:

命令行截图
命令行截图
可以看到,我之前存储的三条数据都在文件中。然后我继续写入Key为null的数据,再次进行查看:
命令行截图
命令行截图
新数据被成功保存,但是没有key值,同时在Shared中能够获取到数据。但是,当我kill掉应用进程重新进入时,Shared中就取不到任何数据了。接着,我又增加了一种情况。在Kill进程,重新进入应用后,再次向Shared写入数据,发现xml中原来的数据被新数据覆盖了。讲的比较乱,为方便理解,用以下表格表示,在存入key为null后,发生的变化:
不退出应用 Kill进程重新进入 Kill进程,写入新数据
xml中 数据都在 数据都在 只有新数据
代码读取 数据都在 读取不到 只有新数据

根据以上情况,我得出这样的结论。在程序中,如果每次Shared读取,都去解析xml,显然耗时费力。通过源码可知,Shared在运行时,存储的数据会放在Map中。由此可见,应用启动时,程序会将xml解析加载到内存,映射成Map。而之后的读写,都是对内存上Map对象的操作。只有数据需要更新时,才会操作xml。
出现Shared数据丢失,很可能就是xml没有成功加载到内存,之后的操作又抹掉了xml中的原有数据。从而引发了像“登录状态被清除”的Bug。

控制台异常
控制台异常
十分凑巧,控制台的一段错误信息帮我定位到了读取xml的源码,一言不合就上源码,查看源码的方式有很多,我习惯使用 grepcode在线查看,它有着强大的搜索功能。
接下来,我通过源码来验证之前的想法,先以4.4.4源码为例:
551    public static final HashMap More readThisMapXml(XmlPullParser parser, String endTag, String[] name)
552    throws XmlPullParserException, java.io.IOException
553    {
554        HashMap map = new HashMap();
555
556        int eventType = parser.getEventType();
557        do {
558            if (eventType == parser.START_TAG) {
559                Object val = readThisValueXml(parser, name);
560                if (name[0] != null) {//!!!关键代码!!!
561                    //System.out.println("Adding to map: " + name + " -> " + val);
562                    map.put(name[0], val);
563                } else {
564                    throw new XmlPullParserException(
565                        "Map value without name attribute: " + parser.getName());
566                }
567            } else if (eventType == parser.END_TAG) {
568                if (parser.getName().equals(endTag)) {
569                    return map;
570                }
571                throw new XmlPullParserException(
572                    "Expected " + endTag + " end tag at: " + parser.getName());
573            }
574            eventType = parser.next();
575        } while (eventType != parser.END_DOCUMENT);
576
577        throw new XmlPullParserException(
578            "Document ended before " + endTag + " end tag");
579    }

关键代码部分,对Key进行了判空处理,name[0] == null时,直接抛出了XmlPullParserException异常。
那么5.0是否进行容错处理呢,接下来是5.0源码:

774     public static final HashMap<String, ?> More ...readThisMapXml(XmlPullParser parser, String endTag,
775             String[] name, ReadMapCallback callback)
776             throws XmlPullParserException, java.io.IOException
777     {
778         HashMap<String, Object> map = new HashMap<String, Object>();
779 
780         int eventType = parser.getEventType();
781         do {
782             if (eventType == parser.START_TAG) {
783                 Object val = readThisValueXml(parser, name, callback);
784                 map.put(name[0], val);
785             } else if (eventType == parser.END_TAG) {
786                 if (parser.getName().equals(endTag)) {
787                     return map;
788                 }
789                 throw new XmlPullParserException(
790                     "Expected " + endTag + " end tag at: " + parser.getName());
791             }
792             eventType = parser.next();
793         } while (eventType != parser.END_DOCUMENT);
794 
795         throw new XmlPullParserException(
796             "Document ended before " + endTag + " end tag");
797     }

真相大白,5.0源码中取消了if(name[0] != null)这段判空逻辑。所以,Key为null时,不会影响数据加载到内存。

问题总结

总结一下,两个版本源码唯一的差别在于,解析xml时,4.4.4版本对Key值进行了判空,如果存在null值,数据则不能顺利加载到内存。继而引发一个更严重的问题,原有数据无法加载到内存,新的数据存储操作会基于全新Map,写入xml时便会导致原有数据被抹去。数据的丢失是灾难性的。所以Google在5.0以后,修复了这个问题。 SharedPreferences是十分常用的数据持久化方式,开发人员应该避免使用null作为Key,即便这样做合法。在这个案例中,由于我们的疏忽,忽略了代码的健壮性。希望大家在开发时,注意这个问题,避免“因小失大”。