3.5 消灭全局变量
本节我们要讨论的是一个深刻的话题。相信很多人都遇到过App莫名其妙就崩溃的情况,尤其是一些配置很低的手机,重现场景就是在App切换到后台,闲置了一段时间后再继续使用时,就会崩溃。
3.5.1 问题的发现
导致上述崩溃发生的罪魁祸首就是全局变量。下述代码就是在生成一个全局变量:
public class GlobalVariables { public static UserBean User; }
在内存不足的时候,系统会回收一部分闲置的资源,由于App被切换到后台,所以之前存放的全局变量很容易被回收,这时再切换到前台继续使用,在使用某个全局变量的时候,就会因为全局变量的值为空而崩溃。这不是个例。我经历过最糟糕的App竟然使用了200多个全局变量,任何页面从后台切换回前台都有崩溃的可能。
想彻底解决这个问题,就一定要使用序列化技术。
3.5.2 把数据作为Intent的参数传递
想一劳永逸地解决上述问题就是不使用全局变量,使用Intent来进行页面间数据的传递。因为,即使目标Activity被系统销毁了,Intent上的数据仍然存在,所以Intent是保存数据的一个很好的地方,比本地文件靠谱。但是Intent能传递的数据类型也必须支持序列化,像JSONObject这样的数据类型,是传递不过去的。对于一个有200多个全局变量的App而言,重构的工作量很大,风险也很大。
另外,如果Intent上携带的数据量过大,也会发生崩溃。第7章会对此有详细的介绍。
3.5.3 把全局变量序列化到本地
另一个比较稳妥的解决方案是,我们仍然使用全局变量,在每次修改全局变量的值的时候,都要把值序列化到本地文件中,这样的话,即使内存中的全局变量被回收,本地还保存有最新的值,当我们再次使用全局变量时,就从本地文件中再反序列化到内存中。
这样就解了燃眉之急,数据不再丢失。但长远之计还是要一个模块一个模块地将全局变量转换为Intent上可序列化的实体数据。但这是后话,眼前,我们先要把全局变量序列化到本地文件,如下所示,我们对全局GlobalsVariables变量进行改造:
public class GlobalVariables implements Serializable, Cloneable { /** * @Fields: serialVersionUID */ private static final long serialVersionUID = 1L; private static GlobalVariables instance; private GlobalVariables() { } public static GlobalVariables getInstance() { if (instance == null) { Object object = Utils.restoreObject( AppConstants.CACHEDIR + TAG); if(object == null) { // App首次启动,文件不存在则新建之 object = new GlobalVariables(); Utils.saveObject( AppConstants.CACHEDIR + TAG, object); } instance = (GlobalVariables)object; } return instance; } public final static String TAG = "GlobalVariables"; private UserBean user; public UserBean getUser() { return user; } public void setUser(UserBean user) { this.user = user; Utils.saveObject(AppConstants.CACHEDIR + TAG, this); } // — — — — —以下 3个方法用于序列化— — — — — — — — public GlobalVariables readResolve() throws ObjectStreamException, CloneNotSupportedException { instance = (GlobalVariables) this.clone(); return instance; } private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException { ois.defaultReadObject(); } public Object Clone() throws CloneNotSupportedException { return super.clone(); } public void reset() { user = null; Utils.saveObject(AppConstants.CACHEDIR + TAG, this); } }
就是这短短的六十多行代码,解决了全局变量GlobalsVariables被回收的问题。我们对其进行详细分析:
1)首先,这是一个单例,我们只能以如下方式来读写user数据:
UserBean user = GlobalsVariables.getInstance().getUser(); GlobalsVariables.getInstance().setUser(user);
同时,GlobalsVariables还必须实现Serializable接口,以支持序列化自身到本地。然而,为了使一个单例类变成可序列化的,仅仅在声明中添加“implements Serializable”是不够的。因为一个序列化的对象在每次反序列化的时候,都会创建一个新的对象,而不仅仅是一个对原有对象的引用。为了防止这种情况,需要在单例类中加入readResolve方法和readObject方法,并实现Cloneable接口。
2)我们仔细看GlobalsVariables这个类的构造函数。这和一般的单例模式写的不太一样。我们的逻辑是,先判断instance是否为空,不为空,证明全局变量没有被回收,可以继续使用;为空,要么是第一次启动App,本地文件都不存在,更不要说序列化到本地了;要么是全局变量被回收了,于是我们需要从本地文件中将其还原回来。
为此,我们在Utils类中编写了restoreObject和saveObject两个方法,分别用于把全局变量序列化到本地和从本地文件反序列化到内存,如下所示:
public static final void saveObject(String path, Object saveObject) { FileOutputStream fos = null; ObjectOutputStream oos = null; File f = new File(path); try { fos = new FileOutputStream(f); oos = new ObjectOutputStream(fos); oos.writeObject(saveObject); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } finally { try { if (oos != null) { oos.close(); } if (fos != null) { fos.close(); } } catch (IOException e) { e.printStackTrace(); } } } public static final Object restoreObject(String path) { FileInputStream fis = null; ObjectInputStream ois = null; Object object = null; File f = new File(path); if (!f.exists()) { return null; } try { fis = new FileInputStream(f); ois = new ObjectInputStream(fis); object = ois.readObject(); return object; } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } finally { try { if (ois != null) { ois.close(); } if (fis != null) { fis.close(); } } catch (IOException e) { e.printStackTrace(); } } return object; }
3)全局变量的User属性,具有getUser和SetUser这两个方法。我们就看这个setUser方法,它会在每次设置一个新值后,执行一次Utils类的saveObject方法,把新数据序列化到本地。
值得注意的是,如果全局变量中有一个自定义实体的属性,那么我们也要将这个自定义实体也声明为可序列化的,UserBean实体就是一个很好的例子。它作为全局变量的一个属性,其自身也必须实现Serializable接口。
接下来我们看如何使用全局变量。
1)在来源页:
private void gotoLoginActivity() { UserBean user = new UserBean(); user.setUserName("Jianqiang"); user.setCountry("Beijing"); user.setAge(32); Intent intent = new Intent(LoginNew2Activity.this, PersonCenterActivity.class); GlobalVariables.getInstance().setUser(user); startActivity(intent); }
2)在目标页PersonCenterActivity:
protected void initVariables() { UserBean user = GlobalVariables.getInstance().getUser(); int age = user.getAge(); }
3)在App启动的时候,我们要清空存储在本地文件的全局变量,因为这些全局变量的生命周期都应该伴随着App的关闭而消亡,但是我们来不及在App关闭的时候做,所以只好在App启动的时候第一件事情就是清除这些临时数据:
GlobalVariables.getInstance().reset();
为此,需要在GlobalVariables这个全局变量类中增加一个reset方法,用于清空数据后把空值强制保存到本地。
public void reset() { user = null; Utils.saveObject(AppConstants.CACHEDIR + TAG, this); }
3.5.4 序列化的缺点
再次强调,把全局变量序列化到本地的方案,只是一种过渡型解决方案,它有几个硬伤:
1)每次设置全局变量的值都要强制执行一次序列化的操作,容易造成ANR。
我们看一个例子,写一个新的全局变量GlobalVariables3,它有3个属性,如下所示:
private String userName; private String nickName; private String country; public void reset() { userName = null; nickName = null; country = null; Utils.saveObject(AppConstants.CACHEDIR + TAG, this); } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; Utils.saveObject(AppConstants.CACHEDIR + TAG, this); } public String getNickName() { return nickName; } public void setNickName(String nickName) { this.nickName = nickName; Utils.saveObject(AppConstants.CACHEDIR + TAG, this); } public String getCountry() { return country; } public void setCountry(String country) { this.country = country; Utils.saveObject(AppConstants.CACHEDIR + TAG, this); }
那么在给GlobalVariables3设值的时候,如下所示:
private void simulateANR() { GlobalVariables3.getInstance().setUserName("jianqiang.bao"); GlobalVariables3.getInstance().setNickName("包包 "); GlobalVariables3.getInstance().setCountry("China"); }
我们会发现,每次设置值的时候,都要将GlobalVariables3强制序列化到本地一次。性能会很差,如果属性多了,强制序列化的次数也会变多,因为读写文件的次数多了,就会造成ANR。
相应的解决方案很丑陋,如下所示:
public void setUserName(String userName, boolean needSave) { this.userName = userName; if(needSave) { Utils.saveObject(AppConstants.CACHEDIR + TAG, this); } } public void setNickName(String nickName, boolean needSave) { this.nickName = nickName; if(needSave) { Utils.saveObject(AppConstants.CACHEDIR + TAG, this); } } public void setCountry(String country, boolean needSave) { this.country = country; if(needSave) { Utils.saveObject(AppConstants.CACHEDIR + TAG, this); } }
也就是说,为每个set方法多加一个boolean参数,来控制是否要在改动后做序列化。同时在GlobalVariables3中提供一个save方法,就是做序列化的操作。
这样改动之后,我们再给GlobalVariables3设值的时候就要这样写了:
private void simulateANR2() { GlobalVariables3.getInstance().setUserName("bao", false); GlobalVariables3.getInstance().setNickName("包包 ", false); GlobalVariables3.getInstance().setCountry("China", false); GlobalVariables3.getInstance().save(); }
也就是说,每次set后不做序列化,都设置完后,一次性序列化到本地。这么写代码很恶心,但我之前说过,这只是权宜之计,相当于打补丁,是临时的解决方案。
2)序列化生成的文件,会因为内存不够而丢失。
这个问题也是在把全局变量都序列化到本地后发现的,究其原因,就是因为我们将序列化的本地文件放在了内存/data/data/com.youngheart/cache/这个目录下。内存空间十分有限,因而显得可贵,一旦内存空间耗尽,手机也就无法使用了。因为我们的全局变量非常多,所以内部空间会耗尽,这个序列化文件会被清除。其实SharedPreferences和SQLite数据库也都是存储在内存空间上,所以这个文件如果太大,也会引发数据丢失的问题。
有人问我为什么不存在SD卡上,嗯,SD卡确实空间大得很,但是不稳定,不是所有的手机ROM对其都有完好的支持,我不能相信它。
临时解决方案是,每次使用完一个全局变量,就要将其清空,然后强制序列化到本地,以确保本地文件体积减小。
3)Android提供的数据类型并不全都支持序列化。
我们要确保全局变量的每个属性都可以序列化。然而,并不是所有的数据类型都可以序列化的。那么,哪些数据可以序列化呢?表3-1是我经过测试得到的结果。
表3-1 各种类型数据对序列化的支持程度
这就从另一方面证明了,我们尽量不要使用不能序列化的数据类型,包括JSONObject、JSONArray、HashMap<String,Object>、ArrayList<HashMap<String,Object>>。
新项目可以尽量规避这些数据类型,但是老项目可就棘手了。好在天无绝人之路,我经过大量实践,得到一些解决方案,如下所示。
1)JSONObject和JSONArray
虽然JSONObject不支持序列化,但是可以在设置的时候将其转换为字符串,然后序列化到本地文件。在需要读取的时候,就从本地文件反序列化处理这个字符串,然后再把字符串转换为JSONObject对象,如下所示:
private String strCinema; public JSONObject getCinema() { if(strCinema == null) return null; try { return new JSONObject(strCinema); } catch (JSONException e) { return null; } } public void setCinema(JSONObject cinema) { if(cinema == null) { this.strCinema = null; Utils.saveObject(AppConstants.CACHEDIR + TAG, this); return; } this.strCinema = cinema.toString(); Utils.saveObject(AppConstants.CACHEDIR + TAG, this); }
JSONArray如法炮制。只需要把上述代码中的JSONObject替换为JSONArray即可。
2)HashMap<String,Object>和ArrayList<HashMap<String,Object>>
因为Object可以是各种类型,有可能是JSONObject和JSONArray,所以以上两种类型不一定支持序列化。
首选的解决方案是,如果HashMap中所有的对象都不是JSONObject和JSONArray,那么以上两种类型就是支持序列化的。建议将Object全都改为String类型的。
private HashMap<String, String> rules; public HashMap<String, String> getRules() { return rules; } public void setRules(HashMap<String, String> rules) { this.rules = rules; Utils.saveObject(AppConstants.CACHEDIR + TAG, this); }
其次,如果HashMap中存放有JSONObject或JSONArray,那么我们就要在set方法中,遍历HashMap中存放的每个Object,将其转换为字符串。
以下是代码实现,你会看到算法超级繁琐,效率也非常差:
HashMap<String, Object> guides; public HashMap<String, Object> getGuides() { return guides; } public void setGuides(HashMap<String, Object> guides) { if (guides == null) { this.guides = new HashMap<String, Object>(); Utils.saveObject(AppConstants.CACHEDIR + TAG, this); return; } this.guides = new HashMap<String, Object>(); Set set = guides.entrySet(); java.util.Iterator it = guides.entrySet().iterator(); while (it.hasNext()) { java.util.Map.Entry entry = (java.util.Map.Entry) it.next(); Object value = entry.getValue(); String key = String.valueOf(entry.getKey()); this.guides.put(key, String.valueOf(value)); } Utils.saveObject(AppConstants.CACHEDIR + TAG, this); }
对于HashMap<String,Object>类型,无论是get方法还是set方法,都非常慢,因为要遍历HashMap中存放的所有对象。
ArrayList<HashMap<String,Object>>是HashMap<String,Object>的集合,所以对其进行遍历,会更加慢。
在遇到了N多次以上解决方案导致的ANR之后,我决定将这两种超级复杂的数据结构,全部改造为可序列化的实体。好在这样的数据类型在App中不太多,重构的成本不是很大。
3.5.5 如果Activity也被销毁了呢
如果内存不足导致当前Activity也被销毁了呢?比如说旋转屏幕从竖屏到横屏。
即使Activity被销毁了,传递到这个Activity的Intent并不会丢失,在重新执行Activity的onCreate方法时,Intent携带的bundle参数还是在的。所以,我们的解决方案是重新执行当前Activity的onCreate方法,这样做最安全。
但是另一个问题就又浮出水面了:Activity需要保存页面状态吗?
想必各位亲们都看过Android SDK中的贪食蛇游戏,它讲的就是在Activity被销毁后保存贪食蛇的位置,这样的话,恢复该页面时就能根据之前保存的贪食蛇的位置继续游戏。
这个Demo用到了Activity的以下2个方法:
·onSaveInstanceState()
·onRestoreInstanceState()
网上关于以上两个方法的介绍和讨论不胜枚举,下面只是分享我的使用心得。
对于游戏以及视频播放器而言,保存页面上每个控件的状态是必须的,因为每当Activity被销毁,用户都希望能恢复销毁之前的状态,比如游戏进行到哪个程度了,视频播放到哪个时间点了。
但是对于社交类或者电商类App而言,页面繁多,多于100个页面的App比比皆是。如果每个页面都保存所有控件的状态,工作量就会很大,要知道这样的App,每个页面都有大量的控件和交互行为,需要记录的状态会很多。
所以,不记录状态,直接让页面重新执行一遍onCreate方法,是一种比较稳妥的方法。丢失的数据,是页面加载完成之后的用户行为,让用户重新操作一遍就是了。
额外说一句,想保存页面状态,是件很难的事情。这一点WindowsPhone做得很好,因为它是基于MVVM的编程模型,它把业务逻辑ViewModel和页面View彻底分开,同时,View中的每个控件的状态,都与ViewModel中的属性进行了绑定,这样的话,View中控件状态变化,ViewModel中的属性也会相应变化,反之亦然。所以把ViewModel序列化到本地,即使View被销毁了,重新创建View,并把保存到本地的ViewModel与之绑定,就可以重现View被销毁之前的状态——我们称为墓碑机制。
不得不说,微软的墓碑机制确实做得很好,它吸取了iOS和Android的经验,让恢复页面状态变得容易很多。
3.5.6 如何看待SharedPreferences
在我们决定禁止使用全局变量后,曾经一段时间确实有了很好的效果,但是我后来仔细一看项目,新的全局变量倒是真的不再有了,大家都改为存取SharedPreferences的方式了。
在我看来,SharedPreferences是全局变量序列化到本地的另一种形式。SharedPreferences中也是可以存取任何支持序列化的数据类型的。
我们应该严格控制SharedPreferences中存放的变量的数量。有些数据存在SharedPreferences中是合理的,比如说当前所在城市名称、设置页面的那些开关的状态等等。但不要把页面跳转时要传递的数据放在SharedPreferences中。这时候,要优先考虑使用Intent来传递数据。
3.5.7 User是唯一例外的全局变量
依我看来,App中只有一个全局变量的存在是合理的,那就是User类。我们在任何地方都有可能使用到User这个全局变量,比如获取用户名、用户昵称、身份证号码等等。
User这个全局变量的实现,可以参考本章讲解的例子。
每次登录,都要把登录成功后获取到的用户信息保存到User类。以后,每当User的属性有变动时,我们都要把User保存一次。退出登录,就把User类的信息进行清空。与之前我们所设计的全局变量不同,App启动时不需要清空User类的数据。因为我们希望App记住上次用户的登录状态以及用户信息。再讲下去就涉及用户Cookie的机制了。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论