Android优化之内存优化倒计时篇

未来已来2018-09-14 13:16

作者:聂雷震


本篇文章介绍的内容是如何在安卓手机上实现高效的倒计时效果,这个高效有两个标准:1、刷新频率足够高,让用户觉得这个倒计时的确是倒计时,而不是幻灯片;2、不能占用太多的内存资源和CPU资源,让用户有一种“我手机真吊,倒计时效果全开一点不卡”的错觉。如果本文内容有很明显的错误,请各位及时指出。如果各位有更好的思路,也希望能够拿出来和大家分享一下。(不能设置段落的首行缩进,排版看起来很不舒服)


一、实现的功能

倒计时就是倒计时,举个简单的例子,每隔30ms刷新一次剩余时间的显示

1. 倒计时控件显示效果


二、优化的效果

鱼与熊掌不可兼得,控件刷新频率的提高必然会导致资源消耗的增加,既然本文的定位是内存优化,那我们就以控件刷新频率为常量,对比下优化前后倒计时效果对内存资源的消耗。

2. 优化前倒计时效果对内存的占用情况

3. 优化后倒计时效果对内存的占用情况

有图有真相,在刷新间隔30ms的情况下,优化前内存消耗每分钟大约会增加2.52MB,优化后则几乎不会对内存消耗有任何影响。


三、问题的思路

先把代码贴出来,因为代码比较简单,所以全贴出来吧。全文如下:

public class MainActivity extends Activity {
  private static final int MSG = 0;
  private final static int INTERVAL = 30;
  private TextView textView;
  private long timeUp = 0;
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    textView = new TextView(this);
    textView.setTextSize(60);
    setContentView(textView);
    super.onCreate(savedInstanceState);
  }

  @Override
  protected void onResume() {
    timeUp = System.currentTimeMillis() + 3600 * 1000;
    update();
    super.onResume();
  }

  private void update() {
    long time = timeUp - System.currentTimeMillis();
    if (time < 0) {
      return;
    }
    String str = String.format("%02d:%02d:%02d:%03d", time / 60 / 60000 % 60, time / 60000 % 60, time / 1000 % 60, time % 1000);
    textView.setText(str);
    handler.sendEmptyMessageDelayed(MSG, INTERVAL);
  }

  private Handler handler = new Handler() {
    @Override
    public void handleMessage(android.os.Message msg) {
      if (msg.what == MSG) {
        update();
      }
    }
  };

由于倒计时功能需要不停的刷新倒计时的数字,大量占用资源的原因主要有两种:一、耗时函数重复执行(占用CPU);二、没有及时释放类对象(占用内存)。那么优化的方法也相应的有两种:一、优化关键函数的复杂度;二、及时释放非必须的类对象。显然优化复杂度是不可能的,因为一开始说的问题就在内存上,而且update函数好像也没有可以优化的空间了吧。另一方面,如果要及时释放类的对象,就必须触发GC,不管是手动触发还是自动触发,GC操作的优先级都是高于UI刷新的,频繁GC必然会导致卡顿。于是脑洞大开,想着是不是有什么办法可以每次执行的时候都复用旧的对象,而不创建新的对象。


四、最初的尝试

看了一下String的源码,发现不创建新的对象是很难的,因为我们随随便便修改一下String的内容,java就会自动替我们创建一个新的对象,而不是修改原来的对象。(java虽然在使用上极大的方便了使用者,但是其精确控制的能力缺远不如C呀)为了解决问题,还是不怕麻烦使用了传说中的反射机制,这种看起来不是很安全的方法还是在一定程度上解决了重复创建对象的问题。

public final class String implements Serializable, Comparable, CharSequence {
    private final char[] value;
    private final int offset;
    private final int count; 
}

String源码里主要的属性如上所示,其中value是保存String内容的char型数组,offsetcount分别是String内容在数组中保存的起始位置和长度。由于倒计时功能显示内容的字数是固定的,所以我们通过反射的方法获取到value之后,只需要修改相应位置的字符即可。另一方面,我们没有通过setText的方法设置textView的属性,必须通过TextViewinvalidate方法通知页面需要重新刷新View

在自己的手机(Android 4.4.4)上跑了一下,感觉还不错,Mission Complete,正开心的时候测试的同学告诉我说,“倒计时有问题,时间不会变”,然后甩了一个Android 6.0给我。

单步分析问题,发现反射没有获取到value,再检查代码,发现没有问题,还是怀疑自己反射相关的代码写错了,于是打印出来所有通过反射获得的成员变量和方法,发现Android 6.0之后String类不在使用value+offset+count的方式存储字符串了。


五、当前的方法

在穷途末路的时候点开了TextView的源码,无意中发现了一个函数,顿时觉得不会再爱了,这个函数的注释如下:

/**
 * Sets the TextView to display the specified slice of the specified
 * char array.  You must promise that you will not change the contents
 * of the array except for right before another call to setText(),
 * since the TextView has no way to know that the text
 * has changed and that it needs to invalidate and re-layout.
 */
public final void setText(char[] text, int start, int len)

查了一下文档,发现以char数组作为参数设置text的方法从api 1里面就加入了,可见Google公司最初考虑的还是蛮周全的。

最终实现的代码如下:

public class MainActivity extends Activity {
  private static final int MSG = 0;
  private final static int INTERVAL = 30;
  private TextView textView;
  private char[] value = new char[] { '0', '0', ':', '0', '0', ':', '0', '0',
      ':', '0', '0', '0', '\0' };
  private int offset = 0;
  private int count = 12;
  private long timeUp = 0;
  private Handler handler = new Handler() {
    @Override
    public void handleMessage(android.os.Message msg) {
      if (msg.what == MSG) {
        update();
      }
    }
  };

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    textView = new TextView(this);
    textView.setTextSize(60);
    setContentView(textView);
    textView.setText(value, offset, count);
    super.onCreate(savedInstanceState);
  }

  @Override
  protected void onResume() {
    timeUp = System.currentTimeMillis() + 3600 * 1000;
    update();
    super.onResume();
  }

  private void update() {
    long time = timeUp - System.currentTimeMillis();
    if (time < 0) {
      return;
    }
    value[0] = (char) ('0' + time / 60 / 60000 % 60 / 10);
    value[1] = (char) ('0' + time / 60 / 60000 % 60 % 10);
    value[3] = (char) ('0' + time / 60000 % 60 / 10);
    value[4] = (char) ('0' + time / 60000 % 60 % 10);
    value[6] = (char) ('0' + time / 1000 % 60 / 10);
    value[7] = (char) ('0' + time / 1000 % 60 % 10);
    value[9] = (char) ('0' + time % 1000 / 100);
    value[10] = (char) ('0' + time % 1000 % 100 / 10);
    value[11] = (char) ('0' + time % 1000 % 10);
    textView.invalidate();
    handler.sendEmptyMessageDelayed(MSG, INTERVAL);
  }
}


六、补充

开发期间很多同事都问我为什么一定要把刷新间隔设置为30ms,这个原因主要是跟人眼的视觉残留有关(胡诌一下,能忽悠几个算几个)。大家都知道很多电影的帧率是24fps(虽然很多电影为了更好的视觉效果都将帧率提高到60fps,但是动画始终是动画,不是高清电影呀),刷新间隔大概是1000/24=41.6ms。另一方面,由于目前很多倒计时功能的毫秒都只显示两位,也就是10ms的级别,如果刷新频率设置为40ms的话,很可能出现最后一位循环出现0,4,8,2,6,0,4,8,2,6,0...这很明显从视觉上是不合理的。综上两个原因,我觉得把刷新间隔设置成30是最合适的。如果要把毫秒显示为三位的话,可以尝试把刷新间隔设置为27ms或者33ms,这样将会避免最低位始终不变的情况。



网易云产品免费体验馆无套路试用,零成本体验云计算价值。  

本文来自网易实践者社区,经作者聂雷震授权发布