Facebook工程师是如何改进他们Android客户端的

safe_image

本文来源于 Facebook工程师博客

作为世界上最大的社交网络,Facebook的Android客户端面临着各种各样的使用环境(地理环境、Android设备以及移动网络等环境的差异)。也正是这个原因,为了检测自家Android客户端在发展中国家的性能表现,Android的产品经理、工程师在2013年的时候去了一趟非洲。当时我看到这个新闻的时候觉得有点怪异,后来看到他们这篇博客才有点理解他们这样做的原因了。

这群Facebook的工程师来到非洲之后,并在当地使用Facebook的最新版本的Android客户端。测试的结果的确让他们印象深刻:

  1. 当地的网络环境十分糟糕,App经常中断网络连接。
  2. 当地人民使用的Android设备内存小,导致App加载缓慢,而且经常崩溃。
  3. 他们的月流量在40分钟之内就用完了。

经过这个让人印象深刻的测试的之后,Facebook的工程师们开始对他们的Android客户端进行了一系列的优化。

性能优化

这里主要是改进了App在低端机上的性能问题。

  • 问题:单核的Android手机在启动Facebook的时候更慢,这是因为app在启动的时候并行初始化了多个模块。
    解决方案:在单核手机上延缓这些初始化过程到启动之后,甚至只有在某个模块要被使用的时候才开始初始化这个模块。
  • 问题:信息流在网络环境差时加载速度慢。
    解决方案:尽早地从服务器抓取信息流数据,用更多的时间来建立连接,并下载信息流的内容。

最终的效果是App的启动时间减少了50%。

数据处理效率的优化

非洲的旅程让工程师们发现流量在发展中国家非常昂贵,而且作为Facebook重要体验一环的照片则是流量花费的大头,于是为了让人民在不担心流量的前提下安心享用Facebook,工程师们决定对App里面的照片动刀:

  1. 寻找现有图片格式的替换者。经过工程师们的调研,在众多的图片格式中,最后工程师选择了Google的WebP。原因很简单:压缩效率高,而且对Android的支持好(毕竟就是Google提出来的)。使用 WebP 之后,相对于JPG格式的图片,流量省了将近 25% 到 35 %;相对于 PNG 格式的图片,流量省了将近80%。最重要的是使用WebP之后图片质量还没改变。
  2. 按照设备处理图片的能力来加载图片。在之前,Facebook的App都是统一加载最大分辨率的图片,这样做是为了让用户可以自由的缩放图片。后来改进之后,app最先加载的图片大小适合显示这个图片窗口大小一样。如果需要缩略图,app就只加载缩略图大小的图片,用户需要更高分辨率的图片,app也能加载,而且之前的统一加载最大分辨率的图片了。
  3. 调整缓存和重用图片的策略。工程师测试了不同的缓存策略,不同的缓存大小,最后综合出最优方案。

最后的效果也是讲流量花费减少了50%。

网络优化

由于许多地区的网络环境比较差,这让Facebook的App的体验也变得十分糟糕,于是工程师也对app的网络效率和可靠性进行了一番改进。

  1. 使用OkHttpFacebook 很早就开始使用Square公司开发的 OkHttp(一个开源的网络协议栈)了,现在Google 官方也从Android 4.4开始使用 OkHttp作为HttpURLConnection的默认实现了。  OkHttp 支持在糟糕的网络环境下面更快的重试,并且还能利用 SPDY 协议进行快速的并发网络请求。
  2. 利用Okhttp调整图片的预先抓取算法,确保app中下载队列前面的图片被优先处理,防止队列阻塞时间过长。

经过优化后,图片加载慢或者加载 失败的反馈少了将近90%。

App文件大小优化

工程师在非洲的时候发现人们使用数量最多的手机磁盘空间很小,也就是说这给用户升级带来的障碍,进而可以推断这些人们因为手机的空间问题而一直使用旧版本的app,那么他们也就无法升级享受前面提到过的优化后的app体验。于是工程师开始致力于如何对app文件大小进行优化:

  1. 利用Google Play提供的功能为不同的Android版本、不同的Android屏幕分辨率的手机提供不同的安装文件。这样就可以在不同的设备上面进行功能的取舍了。
  2. 当然在这个过程中需要监测工具和测试工具来保证优化app文件大小之后app功能的正常性。现在Facebook的工程师已经开发出一套可以计算每个特性对Facebook Android App贡献了多大的空间。

经过优化之后的文件大小减少了将近65%。

反思

Facebook 工程师们的非洲之旅让他们更加理解了移动app性能、数据处理的有效性、网络的可靠性以及app的文件大小对发展中国家移动市场意味着什么。

工程师在这之后开始对每次app新添加的特性都会进行各方面的测试验证,而且Facebook还有一套工具可以直接获得用户对这些特性的反馈,而且工程师开始将这些实践延伸到 Messenger 和 Instagram 的Android App。

 

Linkedin工程师是如何优化他们的Java代码的

最近在刷各大公司的技术博客的时候,我在Linkedin的技术博客上面发现了一篇很不错博文。这篇博文介绍了Linkedin信息流中间层Feed Mixer,它为Linkedin的Web主页,大学主页,公司主页以及客户端等多个分发渠道提供支撑(如下图所示)。

feed_mixer_1

在Feed Mixer里面用到了一个叫做SPR(念“super”)的库。博文讲的就是如何优化SPR的java代码。下面就是他们总结的优化经验。

1. 谨慎对待Java的循环遍历

Java中的列表遍历可比它看起来要麻烦多了。就以下面两段代码为例:

  • A:
    private final List<Bar> _bars;
    for(Bar bar : _bars) {
        //Do important stuff
    }

     

  • B:
    private final List<Bar> _bars;
    for(int i = 0; i < _bars.size(); i++) {
    Bar bar = _bars.get(i);
    //Do important stuff
    }

     

代码A执行的时候 会为这个抽象列表创建一个迭代器,而代码B就直接使用 get(i) 来获取元素,相对于代码A省去了迭代器的开销。

实际上这里还是需要一些权衡的。代码A使用了迭代器,保证了在获取元素的时候的时间复杂度是 O(1) (使用了 getNext()hasNext() 方法),最终的时间复杂度为 O(n) 。但是对于代码B,循环里每次在调用 _bars.get(i) 的时候花费的时间复杂度为 O(n)  (假设这个list为一个 LinkedList),那么最终代码B整个循环的时间复杂度就是 O(n^2)  (但如果代码B里面的list是 ArrayList, 那 get(i) 方法的时间复杂度就是 O(1)了)。所以在决定使用哪一种遍历的方式的时候,我们需要考虑列表的底层实现,列表的平均长度以及所使用的内存。最后因为我们需要优化内存,再加上 ArrayList 在大多数情况下查找的时间复杂度为 O(1) ,最后决定选择代码B所使用的方法。

2.在初始化的时候预估集合的大小

从Java的这篇 文档我们可以了解到: “一个HashMap 实例有两个影响它性能的因素:初始大小和加载因子(load factor)。 […] 当哈希表的大小达到初始大小和加载因子的乘积的时候,哈希表会进行 rehash操作 […] 如果在一个HashMap 实例里面要存储多个映射关系时,我们需要设置足够大的初始化大小以便更有效地存储映射关系而不是让哈希表自动增长让后rehash,造成性能瓶颈。”

在Linkedin实践的时候,常常碰到需要遍历一个 ArrayList 并将这些元素保存到 HashMap 里面去。将这个 HashMap 初始化预期的大小可以避免再次哈希所带来的开销。初始化大小可以设置为输入的数组大小除以默认加载因子的结果值(这里取0.7):

  • 优化前的代码:
    HashMap<String,Foo> _map;
    void addObjects(List<Foo> input)
    {
      _map = new HashMap<String, Foo>(); 
      for(Foo f: input)
      {
        _map.put(f.getId(), f);
      }
    }

     

  • 优化后的代码
    HashMap<String,Foo> _map;
    void addObjects(List<Foo> input)
    {
    _map = new HashMap<String, Foo>((int)Math.ceil(input.size() / 0.7)); 
    for(Foo f: input)
    {
    _map.put(f.getId(), f);
    }
    }

     

3. 延迟表达式的计算

在Java中,所有的方法参数会在方法调用之前,只要有方法参数是一个表达式的都会先这个表达式进行计算(从左到右)。这个规则会导致一些不必要的操作。考虑到下面一个场景:使用ComparisonChain比较两个 Foo 对象。使用这样的比较链条的一个好处就是在比较的过程中只要一个 compareTo 方法返回了一个非零值整个比较就结束了,避免了许多无谓的比较。例如现在这个场景中的要比较的对象最先考虑他们的score, 然后是 position, 最后就是 _bar 这个属性了:

public class Foo {
private float _score;
private int _position;
private Bar _bar;
 
public int compareTo (Foo other) {
return ComparisonChain.start().
compare(_score, other.getScore()).
compare(_position, other.getPosition()).
compare(_bar.toString(), other.getBar().toString()). 
result;
}
}

但是上面这种实现方式总是会先生成两个 String 对象来保存 bar.toString() 和other.getBar().toString() 的值,即使这两个字符串的比较可能不需要。避免这样的开销,可以为Bar 对象实现一个 comparator:

public class Foo {
private float _score;
private int _position;
private Bar _bar;
private final BarComparator BAR_COMPARATOR = new BarComparator();
 
public int compareTo (Foo other) {
return ComparisonChain.start().
compare(_score, other.getScore()).
compare(_position, other.getPosition()).
compare(_bar, other.getBar(), BAR_COMPARATOR).
result();
}
private static class BarComparator implements Comparator<Bar> {
@Override
public int compare(Bar a, Bar b) {
return a.toString().compareTo(b.toString());
}
}
}

4. 提前编译正则表达式

字符串的操作在Java中算是开销比较大的操作。还好Java提供了一些工具让正则表达式尽可能地高效。动态的正则表达式在实践中比较少见。在接下来要举的例子中,每次调用 String.replaceAll() 都包含了一个常量模式应用到输入值中去。因此我们预先编译这个模式可以节省CPU和内存的开销。

  • 优化前:
    private String transform(String term) {
    return outputTerm = term.replaceAll(_regex, _replacement); 
    }
  • 优化后:
    private final Pattern _pattern = Pattern.compile(_regex);
    private String transform(String term) {
    String outputTerm = _pattern.matcher(term).replaceAll(_replacement); 
    }

5. 尽可能地缓存Cache it if you can

将结果保存在缓存里也是一个避免过多开销的方法。但缓存只适用于在相同数据集撒花姑娘吗的相同数据操作(比如对一些配置的预处理或者一些字符串处理)。现在已经有多种LRU(Least Recently Used )缓存算法实现,但是Linkedin使用的是 Guava cache (具体原因见这里) 大致代码如下:

private final int MAX_ENTRIES = 1000;
private final LoadingCache<String, String> _cache;
// Initializing the cache
_cache = CacheBuilder.newBuilder().maximumSize(MAX_ENTRIES).build(new CacheLoader<String,String>() {
@Override
public String load(String key) throws Exception {
return expensiveOperationOn(key);
}
}
);
 
//Using the cache
String output = _cache.getUnchecked(input);

 

6. String的intern方法有用,但是也有危险

String 的 intern 特性有时候可以代替缓存来使用。

从这篇文档,我们可以知道:
“A pool of strings, initially empty, is maintained privately by the class String. When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned”.

这个特性跟缓存很类似,但有一个限制,你不能设置最多可容纳的元素数目。因此,如果这些intern的字符串没有限制(比如字符串代表着一些唯一的id),那么它会让内存占用飞速增长。Linkedin曾经在这上面栽过跟头——当时是对一些键值使用intern方法,线下模拟的时候一切正常,但一旦部署上线,系统的内存占用一下就升上去了(因为大量唯一的字符串被intern了)。所以最后Linkedin选择使用 LRU 缓存,这样可以限制最大元素数目。

最终结果

SPR的内存占用减少了75%,进而将feed-mixer的内存占用减少了 50% (如下图所示)。这些优化减少了对象的生成,进而减少了GC得频率,整个服务的延迟就减少了25%。MemUtil_incapacity

注:本文翻译自Linkedin 技术博客