一个Null值引发的神奇Bug,安卓SharedPreferen
现象
最近项目中出现一个诡异的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,即便这样做合法。在这个案例中,由于我们的疏忽,忽略了代码的健壮性。希望大家在开发时,注意这个问题,避免“因小失大”。