第3章 Android经典场景设计

同样是使用Java语言,为什么做MobileAPI的开发人员写不了Android程序,反之亦然。我想大概是各行有各行的规矩和做事法则,本章介绍的这几种Android经典场景就是如此,看似都是些平谈无奇的UI,但其中却蕴藏着大智慧。

闲话少叙,且听我一一道来。

3.1 App图片缓存设计

App缓存分为两部分,数据缓存和图片缓存。我们在第2章的2.2节介绍了App数据缓存,从而把从Mo-bileAPI获取到的数据缓存到本地,减少了调用MobileAPI的次数。本节将介绍图片缓存策略。

3.1.1 ImageLoader设计原理

Android上最让人头疼的莫过于从网络获取图片、显示、回收,任何一个环节有问题都可能直接OOM。尤其是在列表页,会加载大量网络上的图片,每当快速划动列表的时候,都会很卡,甚至会因为内存溢出而崩溃。

这时就轮到ImageLoader上场表演了。ImageLoader的目的是为了实现异步的网络图片加载、缓存及显示,支持多线程异步加载。

ImageLoader的工作原理是这样的:在显示图片的时候,它会先在内存中查找;如果没有,就去本地查找;如果还没有,就开一个新的线程去下载这张图片,下载成功会把图片同时缓存到内存和本地。

基于这个原理,我们可以在每次退出一个页面的时候,把ImageLoader内存中的缓存全都清除,这样就节省了大量内存,反正下次再用到的时候从本地再取出来就是了。

此外,由于ImageLoader对图片是软引用的形式,所以内存中的图片会在内存不足的时候被系统回收(内存足够的时候不会对其进行垃圾回收)。

3.1.2 ImageLoader的使用

ImageLoader由三大组件组成:

  • ImageLoaderConfiguration——对图片缓存进行总体配置,包括内存缓存的大小、本地缓存的大小和位置、日志、下载策略(FIFO还是LIFO)等等。
  • ImageLoader——我们一般使用displayImage来把URL对应的图片显示在ImageView上。
  • DisplayImageOptions——在每个页面需要显示图片的地方,控制如何显示的细节,比如指定下载时的默认图(包括下载中、下载失败、URL为空等),是否将缓存放到内存或者本地磁盘。

借用博客园上陈哈哈的博文对三者关系的一个比喻,“他们有点像厨房规定、厨师、客户个人口味之间的关系。Im-ageLoaderConfiguration就像是厨房里面的规定,每一个厨师要怎么着装,要怎么保持厨房的干净,这是针对每一个厨师都适用的规定,而且不允许个性化改变。ImageLoader就像是具体做菜的厨师,负责具体菜谱的制作。DisplayImageOptions就像每个客户的偏好,根据客户是重口味还是清淡,每一个ImageLoader根据DisplayImageOptions的要求具体执行。”

下面我们介绍如何使用ImageView:

  1. 在YoungHeartApplication中总体配置ImageLoader:
public class YoungHeartApplication extends Application {
        @Override
        public void onCreate() {
            super.onCreate();
            CacheManager.getInstance().initCacheDir();
            ImageLoaderConfiguration config = new ImageLoaderConfiguration.
                    Builder(getApplicationContext()).
                    threadPriority(Thread.NORM_PRIORITY - 2).
                    memoryCacheExtraOptions(480, 480).
                    memoryCacheSize(2 * 1024 * 1024).
                    denyCacheImageMultipleSizesInMemory().
                    discCacheFileNameGenerator(new Md5FileNameGenerator()).
                    tasksProcessingOrder(QueueProcessingType.LIFO).
                    memoryCache(new WeakMemoryCache()).build();
            ImageLoader.getInstance().init(config);
        }
    }
  1. 在使用ImageView加载图片的地方,配置当前页面的ImageLoader选项。有可能是Activity,也有可能是Adapter:
public CinemaAdapter(ArrayList<CinemaBean> cinemaList, AppBaseActivity context) {
        this.cinemaList = cinemaList;
        this.context = context;
        options = new DisplayImageOptions.Builder().
                showStubImage(R.drawable.ic_launcher).
                showImageForEmptyUri(R.drawable.ic_launcher).
                cacheInMemory().cacheOnDisc().
                build();
    }
  1. 在使用ImageView加载图片的地方,使用ImageLoader,代码片段节选自CinemaAdapter:
context.imageLoader.displayImage(cinemaList.get(position)       
        .getCinemaPhotoUrl(), holder.imgPhoto);

其中displayImage方法的第一个参数是图片的URL,第二个参数是ImageView控件。

一般来说,ImageLoader性能如果有问题,就和这里的配置有关,尤其是ImageLoader-Configuration。我列举在上面的配置代码是目前比较通用的,请大家参考。

3.1.3 ImageLoader优化

尽管ImageLoader很强大,但一直把图片缓存在内存中,会导致内存占用过高。虽然对图片的引用是软引用,软引用在内存不够的时候会被GC,但我们还是希望减少GC的次数,所以要经常手动清理ImageLoader中的缓存。

我们在AppBaseActivity中的onDestroy方法中,执行Im-ageLoader的clearMemoryCache方法,以确保页面销毁时,把为了显示这个页面而增加的内存缓存清除。这样,即使到了下个页面要复用之前加载过的图片,虽然内存中没有了,根据Im-ageLoader的缓存策略,还是可以在本地磁盘上找到:

public abstract class AppBaseActivity extends BaseActivity {
        protected boolean needCallback;
        protected ProgressDialog dlg;
        public ImageLoader imageLoader = ImageLoader.getInstance();

        protected void onDestroy() {// 回收该页面缓存在内存的图片        
            imageLoader.clearMemoryCache();
            super.onDestroy();
        }

    }

本章没有过多讨论ImageLoader的代码实现,只是描述了它的实现原理。有兴趣的朋友可以参考下列文章,里面有很深入的研究:

  1. 简介ImageLoader。地址:http://blog.csdn.net/yueqinglkong/article/de-tails/27660107

  2. Android-Universal-Image-Loader图片异步加载类库的使用(超详细配置)。地址:http://blog.csdn.net/vipzjyno1/article/de-tails/23206387

  3. Android开源框架Universal-Image-Loader完全解析。地址:http://blog.csdn.net/xiaanming/article/de-tails/39057201

3.1.4 图片加载利器Fresco

就在本书写作期间,Facebook开源了它的Android图片加载组件Fresco。

我之所以关注这个Fresco组件,是因为我负责的App用一段时间后就占据了180M左右的内存,App会变得很卡。我们使用MAT分析内存,发现让内存居高不下的罪魁祸首就是图片。于是我们把目光转向Fresco,开始优化App占用的内存。

Fresco使用起来很简单,如下所示:

  • 在Application级别,对Fresco进行初始化,如下所示:Fresco.initialize(getApplicationContext());
  • 与ImageLoader等传统第三方图片处理SDK不同,Fresco是基于控件级别的,所以我们把程序中显示网络图片的Im-ageView都替换为SimpleDraweeView即可,并在Im-ageView所在的布局文件中添加fresco命名空间,如下所示:
  • 在Activity中为这个图片控件指定要显示的网络图片:
    Uri uri = Uri.parse("http:// www.bb.com/a.png");draweeView.setImageURI(uri);

Fresco的原理是,设计了一个Image Pipeline的概念,它负责先后检查内存、磁盘文件(Disk),如果都没有再老老实实从网络下载图片,如图3-1所示,箭头上标记了jpg或bmp格式的,表示Cache中有图片,直接取出;没有标记,则表示Cache中找不到。

图3-1 Image Pipeline的工作流

我们可以像配置ImageLoader那样配置Fresco中的ImagePipeline,使用ImagePipelineConfig来做这个事情。

Fresco有3个线程池,其中3个线程用于网络下载图片,2个线程用于磁盘文件的读写,还有2个线程用于CPU相关操作,比如图片解码、转换,以及放在后台执行的一些费时操作。

接下来介绍Fresco三层缓存的概念。这才是Fresco最核心的技术,它比其他图片SDK吃内存小,就在于这个全新的缓存设计。

第一层:Bitmap缓存
  • 在Android 5.0系统中,考虑到内存管理有了很大改进,所以Bitmap缓存位于Java的堆(heap)中。
  • 而在Android 4.x和更低的系统,Bitmap缓存位于ash-mem中,而不是位于Java的堆(heap)中。这意味着图片的创建和回收不会引发过多的GC,从而让App运行得更快。

当App切换到后台时,Bitmap缓存会被清空。

第二层:内存缓存

内存缓存中存储了图片的原始压缩格式。从内存缓存中取出的图片,在显示前必须先解码。当App切换到后台时,内存缓存也会被清空。

第三层:磁盘缓存

磁盘缓存,又名本地存储。磁盘缓存中存储的也是图片的原始压缩格式。在使用前也要先解码。当App切换到后台时,磁盘缓存不会丢失,即使关机也不会。

Fresco有很多高级的应用,对于大部分App而言,基本还用不到。只要掌握上述简单的使用方法就能极大地节省内存了。我做的App原先占用180MB的内存,现在只会占据80MB左右的内存了。这也是我为什么要在本书中增加这一部分内容的原因。

关于Fresco的更多介绍请参见:

3.2 对网络流量进行优化

对App的最低容忍限度是,在2G、3G和4G网络环境下,每个页面都能打开,都能正常跳转到其他页面。要能够完成一次完整的支付流程。

慢点儿没关系,尤其是2G网络。但是动不动就弹出“无法连接到网络”或者“网络连接超时”的对话框,就是我们开发人员必须要解决的问题了。

3.2.1 通信层面的优化

让我们先从MobileAPI层面进行优化:

  1. MobileAPI接口返回的数据,要使用gzip进行压缩。注意:大于1KB才进行压缩,否则得不偿失。经过gzip压缩后,返回的数据量大幅减少。

  2. App与MobileAPI之间的数据传递,通常是遵守JSON协议的。JSON因为是xml格式的,并且是以字符存在的,在数据量上还有可以压缩的空间。我这里推荐一种新的数据传输协议,那就是ProtoBuffer。这种协议是二进制格式的,所以在表示大数据时,空间比JSON小很多。

  3. 接下来要解决的是频繁调用MobileAPI的问题。我们知道,发起一次网络请求,服务器处理的速度是很快的,主要花费的时间在数据传输上,也就是这一来一回走路的时间上。
    走路时间的长度,网络运维人员会去负责解决。移动开发人员需要关注的是,减少网络访问次数,能调用一次MobileAPI接口就能取到数据的,就不要调用两次。

  4. 我们知道,传统的MobileAPI使用的是HTTP无状态短连接。使用HTTP协议的速度远不如使用TCP协议,因为后者是长连接。所以我们可以使用TCP长连接,以提高访问的速度。缺点是一台服务器能支持的长连接个数不多,所以需要更多的服务器集成。

  5. 要建立取消网络请求的机制。一个页面如果没有请求完网络数据,在跳转到另一个页面之前,要把之前的网络请求都取消,不再等待,也不再接收数据。
    我遇到过一个真实的例子,首页要在后台调用十几个MobileAPI接口,用户一旦进入二级页面,在二级页面获取列表数据时,经常会取不到数据,并弹出“网络请求超时”的提示。我们通过在App输出log的方式发现,二级页面还在调用首页没有完成的那些MobileAPI接口,App网络底层的请求队列已经被阻塞了,原因是在进入下一个页面时,首页发起的网络请求仍然存在于网络请求队列中,并没有移除掉。
    无论是iOS还是Android,都应该在基类(BaseViewCon-troller或者BaseActivity)中提供一个cancelRequest的方法,用以在离开当前页面时清空网络请求队列。

  6. 增加重试机制。如果MobileAPI是严格的RESTful风格,那么我们一般将获取数据的请求接口都定义为get;而把操作数据的请求接口都定义为post。

这样的话,我们就可以为所有的get请求配置重试机制,比如get请求失败后重试3次。

有人会问post请求失败后,是否需要重试呢?我们举个例子吧,比如说下单接口是个post请求,如果请求失败那么就会重试3次,直到下单成功。但是有时候post请求并没有失败,而是超时了,超时时间是30秒,但是却31秒返回了,如果因此而重新发起下单请求,那么就会连续下单两次。所以post请求是不建议有重试机制的。此外,对所有的post请求,都要增加防止用户1分钟内频繁发起相同请求的机制,这样就能有效防止重复下单、重复发表评论、重复注册等操作。

如果post请求具有防重机制,那么倒是可以增加重试机制。但是要可以在服务器端灵活配置重试的次数,可以是0次,意味着不会重试。在App启动的时候,告诉App所有的MobileAPI接口的重试次数。

3.2.2 图片策略优化

首先,我们从图片层面进行优化,这里说的图片,是根据MobileAPI返回的图片URL地址新启一个线程下载到App本地并显示的。很多App崩溃的原因就是图片的问题没处理好。

1. 要确保下载的每张图,都符合ImageView控件的大小

这对于Android是有难度的,因为手机分辨率千奇百怪,所以App中的图片,我们大多做成自适应的,有时是等比拉伸或缩放图片的宽和高,有时则固定高度而动态伸缩宽度,反之亦然。

于是我们要求运营人员要事先准备很多套不同分辨率的图片。我们每次根据URL请求图片时,都要额外在URL上加上两个参数,width和height,从而要求服务器返回其中某一张图,URL如下所示:http://www.aaa.com/a.png?width=100&height=50

如果认为每次准备很多套图片是件很浪费人力的事情,我还有另一种解决方案,这种方案只需要一张图。但我们需要事先准备一台服务器,称为ImageServer。具体流程是这样的:

  • 首先,App每次加载图片,都会把URL地址以及width和height参数所组成的字符串进行encode,然后发送给Image-Server,新的URL如下所示:
    http://www.ImageServer.com/getImage?param=(encodevalue)
  • 然后,ImageServer收到这个请求,会把param的值de-code,得到原始图片的URL,以及App想要显示的这张图片的width和height。ImageServer会根据URL获取到这张原始图片,然后根据width和height,重新进行绘制,保存到Image-Server上,并返回给App。
  • 最后,App请求到的是一张符合其显示大小的图片。

接下来收到同样的请求,直接返回ImageServer上保存的那种图片即可。但是要每天清一次硬盘,不然过不了几天硬盘就满了。

如果width和height的比例与原图的宽高比不一致呢?我们需要再加一个参数imagetype,以下是定义:

  • 1表示等比缩放后,裁减掉多余的宽或者高。
  • 2表示等比缩放后,不足的宽或者高填充白色。

当然你也可以定义0表示不进行缩放,直接返回。

这种方案的缺点就是,ImageServer频繁地写硬盘,硬盘坚持不到两周就坏掉。所以,我们在损失了几块硬盘后,决定事先规定几套width和height,App必须严格遵守,比如说100×50,200×100,那么就不允许向服务器发送类似99×51这样的图片尺寸。

但这样规定,并不能防止App开发人员犯错,他在UI上就是不小心为某个ImageView控件指定了99×51这样的尺寸,那么ImageServer还是会生成这样的图片。

唯一的办法就是在出口加以控制,也就是向ImageServer发起请求的时候。我们会拿99×51这个实际的图片尺寸,去轮询我们事先规定好的那几个尺寸100×50和200×100,看更接近哪个,比如说99×51更接近100×50,那么就向ImageServer请求100×50这种尺寸的图片。

找最接近图片尺寸的办法是面积法:

S = (w1-a) × (w1-w) + (h1-h) × (h1-h)

w和h是实际的图片宽和高,w1和h1是事先规定的某个尺寸的宽和高。S最小的那个,就是最接近的。

2. 低流量模式

在2G和3G网络环境下,我们应该适当降低图片的质量。降低图片质量,相应的图片大小也会降低,我们称为低流量模式。

还记得我们前面提到的ImageServer吗?我们可以在URL中再增加一个参数quality,2G网络下这个值为50%,3G网络下这个值为70%,我们把这个参数传递给ImageServer,从而Im-ageServer在绘制图片时,就会将jpg图片质量降低为50%或70%,这样返回给App的数据量就大大减少了。

在列表页,这种效果最为明显,能极大的节省用户流量。

3. 极速模式

我们后来发现,在2G和3G网络环境下,用户大多对图片不感兴趣,他们可能就是想快速下单并支付,我们需要额外设计一些页面,区别于正常模式下图文并茂的页面,我们将这些只有文字的页面称为极速模式。

比如,首页往往图片占据多数,而且这些图片大多数从网络动态下载的,在2G网络下,这些图片是很浪费流量的。所以在极速模式下,我们需要设计一个只有纯文字的首页。

在每次开启App进入首页前会先进行预判,如果发现当前网络环境为2G、3G或4G,但是当前模式为正常模式,就会弹出一个对话框询问用户,是否要进入极速模式以节省流量。如果是WiFi网络环境,但当前模式是极速模式,也会提示用户是否要切换回正常模式,以看到最炫的效果。

仅在开启App时提示用户极速模式是不够的,我们在设置页也要提供这个开关,供用户手动切换。

3.3 城市列表的设计

很多App都有城市列表这一功能。看似简单,但就像登录功能一样,做好它并不容易。

一份城市列表的数据包括以下几个字典:

  • cityId:城市Id。
  • cityName:城市名称。
  • pinyin:城市全拼。
  • jianpin:城市简拼。

其中,全拼和简拼是用来在App本地做字母表排序和关键字检索的。

我曾经经历过把城市列表数据写死在本地文件的做法,日积月累,就会产生两个问题:

  • Android和iOS维护的数据,差异会越来越大。
  • 一千多个城市,每次从本地加载都要很长时间。

针对问题1的解决办法是,写一个文本分析工具,找出An-droid和iOS各自维护文件的不同数据。

iOS开发人员喜欢使用plist文件作为数据存储的载体,最好能和Android统一使用一份xml文件,这样便于管理类似城市列表这样的数据。

针对问题2的解决方案是,对于一千多个城市,意味着每次都要解析xml城市数据文件,既然每次读取数据都很慢,那么我们干脆就把序列化过的城市列表直接保存到本地文件,跟随App一起发布。这样,每次读取这个文件时,就直接进行反序列化即可,速度得到很大提升。

把城市列表数据保存在本地,有个很烦的事情,就是每次增加新的城市,都要等下次发版,因为数据是写死在App本地的。于是,我们把城市列表数据做成一个MobileAPI接口,由MobileAPI去后台采集数据,这样数据是最新最准的。

但是这样做的问题是,这个MobileAPI接口返回的数据量会很大,上千笔数据,还包括那么多字段,即使打开了gzip压缩,也会有100k的样子。于是我们又增加了版本号字段version的概念,这个MobileAPI接口的定义和返回的JSON格式是这样的:

  1. 入参。version,本地存储的城市列表数据对应的版本号。
  2. 返回值。如果传入参数version和线上最新版本号一致,则返回以下固定格式:
{
        "isMatch": false,
            "version": 1,
            "cities": [
                    {
                    },
        ]
    }

如果传入参数version和线上最新版本号不一致,则返回以下格式:

{
        "isMatch":false,
            "version":1,
            "cities": [
        {
            "cityId":1, 
                "cityName":"北京",
                "pinyin":"beijing",
                "jianpin":"bj"
        },
        {
            "cityId":2,
                "cityName":"上海",
                "pinyin":"shanghai",
                "jianpin":"sh"
        },
        {
            "cityId":3,
                "cityName":"平顶山",
                "pinyin":"pingdingshan",
                "jianpin":"pds"
        }
        ]
    }

version这个字段由MobileAPI进行更新,每当有城市数据更新时,version可以立即自增+1,也可以积累到一定数据后自增+1。具体策略由MobileAPI来决定。

基于此,App的策略可以是这样的:

  • 本地仍然保存一份线上最新的城市列表数据(序列化后的)以及对应的版本号。我们要求每次发版前做一次城市数据同步的事情。
  • 每次进入到城市列表这个页面时,将本地城市列表数据对应的版本号version传入到MobileAPI接口,根据返回的is-Match值来决定是否版本号一致。如果一致,则直接从本地文件中加载城市列表数据;否则,就解析MobileAPI接口返回的数据,在显示列表的同时,记得要把最新的城市列表数据和版本号保存到本地。
  • 如果MobileAPI接口没有调用成功,也是直接从本地文件中加载城市列表数据,以确保主流程是畅通的。
  • 每次调用MobileAPI时,会获取到大量的数据,一般我们会打开gzip对数据进行压缩,以确保传输的数据量最小。
3.3.2 城市列表数据的增量更新机制

上节中我们谈到,每当有城市数据更新时,version可以立即自增+1。我的问题是,如何判断有城市数据更新?一种解决方案是,在服务器建立一个Timer,每十分钟跑一次,检查10分钟前后的数据是否有改动,如果有,version就自增+1,并返回这些有改动的数据(新增、删除和修改)。这样就保证了10分钟内,从A改成B又改回A,这时候我们认为是没有改动的,版本号不需要自增+1。

那么问题来了,对于1000笔城市数据,每次只改动其中的几笔,返回数据中包括那些没有改动过的数据是没有意义的,是否可以只返回这些改动的数据?

分析1.0和2.0版本的城市列表数据,每笔数据都有cityId和其他一些字段,比如说城市名称、简拼、全拼等。我画了一个表,如图3-2所示,试图展示出1.0和2.0这两个版本的城市数据之间的异同。

图3-2 比较两个版本城市数据间的异同

我来解释一下图3-2,以cityId作为唯一标识,只在1.0中出现的cityId是要删除的数据,只在2.0中出现的cityId是要增加的数据,二者的交集则是cityId相同的数据,这又分为两种情况,所有字段都相同的数据是不变的数据;cityId相同但某个字段不相同,则是修改的数据。

增量更新的数据,就由增、删、改这3部分数据构成。于是,我们可以重新定义城市列表的JSON格式,在每笔增量数据中增加一个字段type,用来区别是增(c)、删(d)、改(u)中的哪种情况,如下所示:

{
        "isMatch":false,
            "version":1,
            "cities": [
            {
                "cityId":1,
                    "cityName":"北京",
                    "pinyin":"beijing",
                    "jianpin":"bj",
                    "type":"d"
            },
            {
                "cityId":2,
                    "cityName":"上海", 
                    "pinyin":"shanghai", 
                    "jianpin":"sh", 
                    "type":"c"
            },
            {
                "cityId":3, 
                    "cityName":"平顶山", 
                    "pinyin":"pingdingshan", 
                    "jianpin":"pds", 
                    "type":"u"
            }    
        ]

    }

客户端在收到上述格式JSON数据后,会根据type值来处理存放在本地的数据。因为不是全量更新,所以处理起来很快。这种增量更新城市数据的策略,会使得App的逻辑很简单,但是服务器的逻辑很复杂。这样做是划算的,我们要想尽办法确保App的轻量,把复杂的业务逻辑放在后端。

3.4 App与HTML5的交互

App与HTML5的交互,是一个可以大做文章的话题。有的团队直接使用PhoneGap来实现交互的功能,而我则认为PhoneGap太重了。我们完全可以把这些交互操作在底层封装好,然后给开发人员使用。

为了开发人员方便,我们要准备一台测试用的PC服务器,在上面搭建一个IIS,这样可以快速搭建自己的Demo,对于App开发人员而言,不需要等待HTML5团队就可以自行开发并测试了。他们只需知道一些基本的Html和JavaScript语法,而相应的培训非常简单。

3.4.1 App操作HTML5页面的方法

为了演示方便,我在assets中内置了一个HTML5页面。现实中,这个HTML5页面是放在远程服务器上的。

首先要定好通信协议,也就是App要调用的HTML5页面中JavaScript的方法名称。

例如,App要调用HTML5页面的changeColor(color)方法,改变HTML5页面的背景颜色。

  1. HTML5
<script type="text/javascript">    
      function changeColor (color) {        
            document.body.style.backgroundColor = color;    
      }
</script>
  1. Android
    wvAds.getSettings().setJavaScriptEnabled(true);    
    wvAds.loadUrl("file:// /android_asset/104.html");                
    btnShowAlert.setOnClickListener(new View.OnClickListener() {
        @Override 
        public void onClick (View v) {
            String color = "#00ee00";
            wvAds.loadUrl("javascript: changeColor ('" + color + "');");
        }
    });
3.4.2 HTML5页面操作App页面的方法

仍然是先定义通信协议,这次定义的是JavaScript要调用的Android中方法名称。

例如,点击HTML5的文字,回调Java中的callAndroid-Method方法:

  1. HTML5
<a onclick="baobao.callAndroidMethod(100,100,'ccc',true)">    
CallAndroidMethod</a>
  1. Android
    新创建一个JSInterface1类,包括callAndroidMethod方法的实现:
class JSInteface1 {
        public void callAndroidMethod(int a, float b, String c, boolean d) {
            if (d) {
                String strMessage = "-" + (a + 1) + "-" + (b + 1) + "-" + c + "-" + d;
                new AlertDialog.Builder(MainActivity.this).setTitle("title").setMessage(strMessage).show();
            }
        }
    }

同时,需要注册baobao和JSInterface1的对应关系:

wvAds.addJavascriptInterface(new JSInteface1(), "baobao");

调试期间我发现对于小米3系统,要在方法前增加@JavascriptInterface,否则,就不能触发JavaScript方法。

3.4.3 App和HTML5之间定义跳转协议

根据上面的例子,运营团队就找到了在App中搞活动的解决方案。不必等到App每次发新版才能看到新的活动页面,而是每次做一个HTML5的活动页面,然后通过MobileAPI把这个HTML5页面的地址告诉App,由App加载这个HTML5页面即可。

在这个HTML5页面中,我们可以定义各种JavaScript点击事件,从而跳转回App的任意Native页面。

为此,HTML5团队需要事先和App团队约定好一个格式,例如:

gotoPersonCenter
gotoMovieDetail:movieId=100
gotoNewsList:cityId=1&cityName=北京
gotoUrl:http://www.sina.com

这个协议具体在HTML5页面中是这样的,以gotoNewsList为例:

<a onclick="baobao.gotoAnyWhere(        
  'gotoNewsList:cityId=(int)12&cityName=北京')">            
   gotoAnyWhere</a>

其中,有些协议是不需要参数的,比如说gotoPersonCen-ter,也就是个人中心;有些则需要跳转到具体的电影详情页,我们需要知道movieId;有时候1个参数不够用,我们需要更多的参数,才能准确获取到我们想要的数据,比如说gotoNewsList,我们想要跳转到2014年12月31号北京的所有新闻信息,就不得不需要cityId和createdTime两个参数,处理协议的代码如下所示:

public void gotoAnyWhere(String url) {
        if (url != null) {
            if (url.startsWith("gotoMovieDetail:")) {
                String strMovieId = url.substring(24);
                int movieId = Integer.valueOf(strMovieId);
                Intent intent = new Intent(MainActivity.this, MovieDetailActivity.class);
                intent.putExtra("movieId", movieId);
                startActivity(intent);
            } else if (url.startsWith("gotoNewsList:")) {            // as above        

            } else if (url.startsWith("gotoPersonCenter")) {
                Intent intent = new Intent(MainActivity.this, PersonCenterActivity.class);
                startActivity(intent);
            } else if (url.startsWith("gotoUrl:")) {
                String strUrl = url.substring(8);
                wvAds.loadUrl(strUrl);
            }
        }
    }

这里的if分支逻辑太多,我们要想办法将其进行抽象,参见后面3.4.6节介绍的页面分发器。

3.4.4 在App中内置HTML5页面

什么时候在App中内置HTML5页面?根据我的经验,当有些UI不太容易在App中使用原生语言实现时,比如画一个奇形怪状的表格,这是HTML5所擅长的领域,只要调整好屏幕适配,就可以很好地应用在App中。

下面详细介绍如何在页面中显示一个表格,表格里的数据都是动态填充的。

1. 首先定义两个HTML5文件,放在assets目录下。

其中,102.html是静态页:

<html>    
        <head>    
        </head>    
        <body>        
            <table>            
                <data1DefinedByBaobao>        
            </table>        
        </body>
    </html>

而data1_template.html是一个数据模板,它负责提供表格中一行的样式:

<tr>
        <td>        
            <name>    
        </td>    
        <td>        
            <price>    
        </td>
    </tr>

像<name>、<price>和<data1DefinedByBaobao>都是占位符,我们接下来会使用真实的数据来替换这些占位符。

2. 在MovieDetailActivity中,通过遍历movieList这个集合,我们把数据填充到sbContent中,最终,把拼接好的字符串替换<data1DefinedByBaobao>标签:
    String template = getFromAssets("data1_template.html");
    StringBuilder sbContent = new StringBuilder();
    ArrayList<MovieInfo> movieList = organizeMovieList();
    for(MovieInfo movie :movieList) {
        String rowData;
        rowData = template.replace("<name>", movie.getName());
        rowData = rowData.replace("<price>", movie.getPrice());
        sbContent.append(rowData);
    }
    String realData = getFromAssets("102.html");
    realData =realData.replace("<data1DefinedByBaobao>",sbContent.toString());
    wvAds.loadData(realData,"text/html","utf-8");
3.4.5 灵活切换Native和HTML5页面的策略

对于经常需要改动的页面,我们会把它做成HTML5页面,在App中以WebView的形式加载。这样就避免了Native页面每次修改,都要等一次迭代上线后才能看到——周期太长了,这不是产品经理所希望的。

此外,HTML5的另一个好处是,开发周期短——相比App开发而言。但是HTML5的缺点是慢。

我们来看一下HTML5页面生成的步骤:

  • 从服务器端动态获取数据并拼接成一个HTML。
  • 返回给客户端WebView。
  • 在WebView中解析并生成这个HTML。

相对于Native原生页面加载JSON这种短小精悍的数据并展现在客户端而言,HTML5肯定是慢了很多。鱼和熊掌不可兼得,于是我们只能在灵活性和性能上作出取舍。

但是我们可以换一个思路来解决这个问题。我同时做两套页面,Native一套,HTML5一套,然后在App中设置一个变量,来判断该页面将显示Native还是HTML5的。

这个变量可以从MobileAPI获取,这样的话,正常情况下,是Native页面,如果有类似双十一或双十二的促销活动,我们可以修改这个变量,让页面以HTML5的形式展现。这样,我们只要做个HTML5的页面发布到线上就行了。等活动结束后再撤回到Native页面。

以此类推,App中所有的页面,都可以做成上述这种形式,为此,我们需要改变之前做App的思路,比如:

  • 需要做一个后台,根据版本进行配置每个页面是使用Na-tive页面还是HTML5页面。
  • 在App启动的时候,从MobileAPI获取到每个页面是Native还是HTML5。
  • 在App的代码层面,页面之间要实现松耦合。为此,我们要设计一个导航器Navigator,由它来控制该跳转到Native页面还是HTML5页面。最大的挑战是页面间参数传递,字典是一种比较好的形式,消除了不同页面对参数类型的不同要求。

接下来,就是App运营人员和产品经理随心所欲的进行配置了。

在实际的操作中,一定要注意,HTML5页面只是权宜之计,可以快速上一个活动,比如类似于双十一的节假日,从而以迅雷不及掩耳之势打击竞争对手。随着HTML5和Native的不同步,当一个页面再从HTML5切换回Native时,我们会发现,它们的逻辑已经差了很多了,切回来就会有很多bug,而我们又只能是在App发布后才发现这样的问题。

唯一的解决方案是,把App和HTML5划归到一个团队,由产品经理整理二者的差异性,要做到二者尽量同步,一言以蔽之,App要时刻追赶HTML5的逻辑,追赶上了就切换回Native。

3.4.6 页面分发器

我们知道,跳转到一个Activity,需要传递一些参数。这些参数的类型简单如int和String,复杂的则是列表数据或者可序列化的自定义实体。

但是,如果从HTML5页面跳转到Native页面,是不大可能传递复杂类型的实体的,只能传递简单类型。所以,并不是每个Native页面都可以替换为HTML5。

接下来要讨论的是,对于那些来自HTML5页面、传递简单类型的页面跳转请求,我们将其抽象为一个分发器,放到BaseActivity中。还记得我们在3.4.3节定义的协议吗,以gotoMovieDetail为例:

<a onclick="baobao.gotoAnyWhere('gotoMovieDetail:movieId=12')">            
gotoAnyWhere</a>

我们将其改写为:

<a onclick="baobao.gotoAnyWhere( 
       'com.example.youngheart.MovieDetailActivity,        
       iOS.MovieDetailViewController:movieId=(int)123')">            
        gotoAnyWhere</a>

我们看到,协议的内容分成3段,第一段是Android要跳转到的Activity的名称。第二段是iOS要跳转到的ViewController的名称,第三段是需要传递的参数,以key-value的形式进行组装。

我们接下来要做的就是从协议URL中取出第1段,将其反射为一个Activity对象,取出第3段,将其解析为key-value的形式,然后从当前页面跳转到目标页面并配以正确的参数。其中,写一个辅助函数getAndroidPageName,用来获取Activity名称:

public class BaseActivity extends Activity {
        private String getAndroidPageName(String key) {
            String pageName = null;
            int pos = key.indexOf(",");
            if (pos == -1) {
                pageName = key;

            } else {
                pageName = key.substring(0, pos);
            }
            return pageName;
        }

        public void gotoAnyWhere(String url) {
            if (url == null) return;
            String pageName = getAndroidPageName(url);
            if (pageName == null || pageName.trim() == "") return;
            Intent intent = new Intent();
            int pos = url.indexOf(":");
            if (pos > 0) {
                String strParams = url.substring(pos);
                String[] pairs = strParams.split("&");
                for (String strKeyAndValue : pairs) {
                    String[] arr = strKeyAndValue.split("=");
                    String key = arr[0];
                    String value = arr[1];
                    if (value.startsWith("(int)")) {
                        intent.putExtra(key, Integer.valueOf(value.substring(5)));
                    } else if (value.startsWith("(Double)")) {
                        intent.putExtra(key, Double.valueOf(value.substring(8)));
                    } else {
                        intent.putExtra(key, value);
                    }
                }
            }
            try {
                intent.setClass(this, Class.forName(pageName));
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            }
            startActivity(intent);
        }
    }

注意,在协议中定义这些简单数据类型的时候,String是不需要指定类型的,这是使用最广泛的类型。对于int、Double等简单类型,我们要在值前面加上类似(int)这样的约定,这样才能在解析时不出问题。

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);
        }

    }

就是这短短的六十多行代码,解决了全局变量GlobalsVari-ables被回收的问题。我们对其进行详细分析:

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);
    }
  1. 在目标页PersonCenterActivity:
protected void initVariables() {    
        UserBean user = GlobalVariables.getInstance().getUser();     
        int age = user.getAge();
}
  1. 在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,Ob-ject>、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即可。

  1. 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或JSONAr-ray,那么我们就要在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的机制了。

3.6 本章小结

本章讨论了App中的集中几种场景的设计,其中包括:如何设计App图片缓存,如何优化网络流量,对城市列表的重新思考,如何让HTML5在App中发挥更大的作用,如何解决全局变量过多导致的内存回收问题,等等。

下一章,我将介绍Android的编码规范和命名规范。

推荐阅读更多精彩内容