返回介绍

3.5 消灭全局变量

发布于 2024-08-17 23:46:12 字数 12930 浏览 0 评论 0 收藏 0

本节我们要讨论的是一个深刻的话题。相信很多人都遇到过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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文