Chromium net是chromium浏览器及ChromeOS中,用于从网络获取资源的模块。这个网络库是用C++编写的,且用了大量的C++11特性。它广泛地支持当前互联网环境中用到的大量的网络协议,如HTTP/1.1,SPDY,HTTP/2,FTP,QUIC,WebSockets等;在安全性方面也有良好的支持,如SSL等;同时,针对性能,它也有诸多的优化,如引入libevent的基于事件驱动的设计。从而,将chromium net库移植到android平台上并用起来,以提升我们的APP的网络访问性能成为了一件非常有价值的事情。
这里我们就尝试将chromium net移植到android平台上并用起来。但由于庞大的chromium项目有着自己比较特别的开发流程,开发方式,而给我们的移植工作制造了一些障碍。具体地说是:
- gn/gyp + ninja的构建方式;
- 为了方便开发,chromium的仓库中包含了构建所需的完整的工具链,包括编译、链接的工具,android的SDK和NDK,甚至是STL。这个工具链与我们惯常用在项目中的工具链多少还是有一点点区别的,这会给我们的编译链接等制造一些困扰。
但好在Google出品比较具有hack风,因而移植工作的难度变得可控。比如gn工具,可以让我们比较方便地对工程做更多的探索,我们可以通过这个工具来了解我们编译一个模块时,整个环境的配置,该模块具体包含了哪些源文件,模块导出的头文件,编译链接时都用了什么样的参数等等。
在具体移植开始之前,先设定我们移植的目标:
- 让chromium的net模块可以单独地在android平台上跑起来,可以通过JNI的方式进行调用;
- 将chromium net模块从整个的chromium代码库中抽离,依然基于gn + ninja,建立独立的开发环境,可以进行配置、构建,以方便后续针对chromium net的开发。
- 针对与移动端上受限的资源,及项目的需要,对chromium net进行一些裁剪瘦身,比如对ftp协议的支持就没有太大的必要,砍掉这些用不到的东西以节省资源,提升效能。
这里主要就基于我们的目标,来探索移植的方法。本文尝试逐步的探索整个的移植过程。本文会记录遇到的每个问题,并给出出错的提示,比如编译器报的error,链接器报的error,运行时崩溃抓到的backtrace等等,然后还会给出对问题的基本分析与定位,及问题的解决方法。
编译net模块
首先要做的事情就是下载完整的chromium代码,这可以参考Chromium Android编译指南来完成。然后执行(假设当前目录是chromium代码库的根目录)命令:
下载构建chromium所需的系统工具。
之后需要对编译进行配置。编辑out/Default/args.gn
文件,并参照Chromium Android编译指南中的说明,输入如下内容:
保存退出之后,执行:
产生ninja构建所需的各个模块的ninja文件。随后输入如下命令编译net模块:
这个命令会编译net模块,及其依赖的所有模块,包括base,crypto,borringssl,protobuf,icu,url等。可以看一下我们编译的成果:
总共10个共享库文件。
在我们的工程的app模块的jni目录下为chromium创建文件夹app/src/main/jni/third_party/chromium/libs
和app/src/main/jni/third_party/chromium/include
,分别用于存放我们编译出来的共享库文件和net等模块导出的头文件及这些头文件include的其它头文件。
这里我们将编译出来的所有so文件拷贝到app/src/main/jni/third_party/chromium/libs/armeabi
和app/src/main/jni/third_party/chromium/libs/armeabi-v7a
目录下:
编译共享库似乎挺顺利。
提取导出头文件
为了使用net模块提供的API,我们不可避免地要将net导出的头文件引入我们的项目。要做到这些,我们首先就需要从chromium工程中提取net导出的头文件。不像许多其它的C++项目,源代码文件、私有头文件及导出头文件存放的位置被很好地做了区隔,chromium各个模块的所有头文件和源代码文件都是放在一起的。这还是给我们提取导出头文件的工作带来了一点麻烦。
这里我们借助于gn工具提供的desc功能(关于gn工具的用法,可以参考GN的使用 - GN工具一文),输出中如下的这两段:
编写脚本来实现。
我们可以传入[chromium代码库的src目录路径],[输出目录的路径],[模块名],及[保存头文件的目标目录路径]作为参数,来提取模块的所有导出头文件,[保存头文件的目标目录路径]参数缺失时默认使用当前目录,比如:
这里一并将该脚本的完整内容贴出来:
Chromium net的简单使用
参照chromium/src/net/tools/get_server_time/get_server_time.cc
的代码,来编写简单的示例程序。首先是JNI(JNI的用法,可以参考android app中使用JNI)的Java层代码:
然后是native层的JNI代码,MyApplication/app/src/main/jni/src/NetJni.cpp
:
这个文件里,在nativeSendRequest()函数中调用chromium net做了网络请求,获取响应,并打印出响应的headers及响应的content。
MyApplication/app/src/main/jni/src/JNIHelper.h
中定义了一些宏,以方便native methods的注册,其具体内容则为:
配置Gradle
要在Android Studio中使用JNI,还需要对Gralde做一些特别的配置,具体可以参考在Android Studio中使用NDK/JNI - 实验版插件用户指南一文。这里需要对MyApplication/build.gradle
、MyApplication/gradle/wrapper/gradle-wrapper.properties
,和MyApplication/app/build.gradle
这几个文件做修改。
修改MyApplication/build.gradle
文件,最终的内容为:
在这个文件中配置gradle插件的版本为gradle-experimental:0.7.0
。
修改MyApplication/gradle/wrapper/gradle-wrapper.properties
文件,最终的内容为:
在这个文件中配置gradle的版本。
修改MyApplication/app/build.gradle
文件,最终的内容为:
我们在这里对native代码的整个编译、链接做配置。这包括,编译要包含哪些源文件,编译时头文件的搜索路径,我们的native代码依赖的要链接的系统共享库/静态库,我们的native代码依赖的要链接的预编译的共享库/静态库,比如我们的chromium net等。配置编译出来的共享库的文件名。同时还要为buildTypes
配置abiFilters
,以防止构建系统尝试为其它我们不打算支持的ABI,如arm64-v8a、mips、mips64、x86和x86_64,构建共享库,而发生找不到响应的chromium net的so文件的错误。
应用工程编译
做了上面的配置之后,我们怀着激动的心情,小心翼翼地点击“Rebuild Project”菜单项,并期待奇迹发生。编译过程启动之后,很快就终止了,报出了如下的error:
提示找不到base模块的头文件base/strings/string16.h
。base模块是chromium项目中大多数模块都会依赖的模块,它提供了许多基本的功能,比如字符串,文件操作,消息队列MessageQueue,内存管理的一些操作等等。net模块的头文件include了base的头文件的地方还不少,而在我们自己的JNI native层代码中,也难免要引用到base定义的一些组件,因而我们需要将base模块导出的头文件一并引入我们的工程。提取base的头文件并引入我们的工程:
配置stl:
引入了base模块的头文件之后,再次编译我们的应用工程。又报error了:
这里是提示找不到STL的头文件。在MyApplication/app/build.gradle中配置stl:
引入chromium的build配置头文件
配置了stl之后,再次编译我们的应用工程。接着报error:
提示找不到”build/build_config.h”文件。这个文件定义了一些全局性的宏,以对编译做一些全局的控制。这次直接将chromium代码库中的对应文件拷贝过来。
引入遗漏的头文件
再次编译我们的应用工程。接着报error:
提示找不到base模块的头文件base/callback_forward.h
。直接将chromium代码库中的对应文件拷贝过来。我们似乎是被gn desc
的输出欺骗了,它似乎并没有将一个模块导出的所有的头文件都告诉我们。像 base/callback_forward.h
一样,逃过了gn desc
的眼睛,同时也逃过了我们提取模块头文件的脚本的眼睛的头文件还有如下的这些:
这里我们就不将缺少这些文件导致的错误的错误输出列出来了,内容与前面看到的缺少base/callback_forward.h
文件导致的错误的错误输出类似,解决的方法也相同。
算下来,我们提取模块的头文件的脚本遗漏了8个头文件。不能不让人感慨,移植这个事情真是个体力活啊。不过还好遗漏的不是80个文件,或800个,要不然真是要把人逼疯了。
引入url模块的导出头文件
引入了那些遗漏的头文件之后,再次编译。继续报错:
这次是提示找不到url模块的头文件url/origin.h
。url模块与我们要用的net联系紧密,同样不可避免地要在我们的native层代码中引用到。因而我们要将url模块导出的头文件一并引入我们的工程。提取url的导出头文件并引入我们的工程:
注释掉gtest相关代码
引入了url模块的导出头文件之后,再次编译。继续报错:
这一次是提示找不到gtest的头文件。暂时我们还无需借助于gtest来做单元测试,因而这个include显得没有必要。我们将MyApplication/app/src/main/jni/third_party/chromium/include/base/gtest_prod_util.h
文件中对"testing/gtest/include/gtest/gtest_prod.h"
的include注释掉,同时修改FRIEND_TEST_ALL_PREFIXES
宏的定义:
这样就可以注释掉类定义中专门为gtest插入的那些代码了。
配置C++11
再次编译,继续报错:
这里提示std命名空间中没有unique_ptr
,没有hash
等,前者是C++11中新添加的智能指针模板,而后者则是哈希模板,它定义一个函数对象,实现散列函数。chromium net中,像这样用到了C++11的特性的地方还有很多,这里的这个错误是由于build.gradle中没有进行C++11的配置。我们在MyApplication/app/build.gradle中配置C++11:
不过在前面,我们也有看到如下这样的错误消息:
如果我们能早些注意到这样的错误消息,做了C++11的配置,大概这里的这个error还是可以避免的。
链接
做了C++11的配置之后,再次编译,继续报错:
这次是链接错误,提示找不到base库和url库中的符号base::MessageLoop::MessageLoop(base::MessageLoop::Type)
、GURL::~GURL()
和base::MessageLoop::DoIdleWork()
等。看来经过前面的各种艰难险阻,我们总算是将native层的cpp文件编译为了.o文件。
为了解决这个问题,我们还需要在MyApplication/app/build.gradle
中添加对base和url这两个库的依赖:
选择正确的STL库
经过了前面的修改,再次编译,然而……继续报错:
这次是提示找不到符号‘net::HttpResponseHeaders::EnumerateHeaderLines(unsigned int, std::string, std::string*) const’, ‘base::BasicStringPiece
可是為什麼能引用不到这些符号呢?那so中到底有没有相关的这些class呢?通过GNU的binutils工具包中的readelf工具可以查看so中的所有符号,无论是函数符号,还是引用的未定义符号。而且编译器在编译C++文件时,符号都是会被修饰的,以便于支持函数重载等C++的一些特性。binutils工具包还提供了工具c++filt,以帮助我们将修饰后的符号,还原回编译前的样子。
我们利用这些工具,来检查那些so中是否真的没有包含未找到的那些符号:
so还真的没有我们的代码中要引用的那些符号。那到底是怎么一回事呢?对比编译我们的工程时报的错,实际情况似乎是,在so中包含了我们需要的函数,但两边修饰后的符号不一致,从而导致链接出错。主要是std中的符号,在so中引用的std的符号,其命名空间都增加了一级”__1”。
这似乎与stl有关。NDK可用的stl库大体有如下的这些:
逐个地对这些标准库进行尝试。除了我们前面配置的gnustl_static
,上面列出的前面的5种,甚至都找不到头文件<atomic>
,gnustl_shared
报出了与gnustl_static
相同的问题。这就只剩下c++_static
和c++_shared
可选了。
我们首先尝试c++_static,但依然报了错:
情况发生了变化。尽管依然报了undefined reference to
的error,但这次引用不到的符号却已经与我们在so中抓出来的那些符号非常接近了。c++_static
和c++_shared
是LLVM libc++ runtime,编译我们的JNI时使用的这个STL的实现似乎与编译chromium代码时使用的那个不太一样,但无疑编译chromium时使用的是LLVM libc++ runtime。
我们随便打开一个std的头文件来一窥究竟,比如声明std::string的头文件android-ndk-r12b/sources/cxx-stl/llvm-libc++/libcxx/include/iosfwd,我们注意到了如下的这几行:
这个宏的定义在android-ndk-r12b/sources/cxx-stl/llvm-libc++/libcxx/include/__config:
由此我们终于揭开了引用的符号,中间插入的那一级命名空间__ndk1
的秘密。这是LLVM libc++ runtime加的。
但编译出来的so中的符号又是怎么回事呢?chromium的代码库中,有完整的构建工具链,包括android的NDK和SDK,它们位于chromium/src/third_party/android_tools
。我们打开文件chromium/src/third_party/android_tools/ndk/sources/cxx-stl/llvm-libc++/libcxx/include/__config,可以看到如下这样的宏定义:
至此,这个链接过程中符号找不到的问题终于被厘清——chromium中的NDK不是标准NDK。
这样的话解法也就显而易见了,编译我们的工程时,stl用c++_static,同时定制编译chromium所用的NDK。
采用标准NDK编译chromium net
为了解决编译chromium时所用的NDK与编译我们的工程时所用的NDK不一致,导致的链接错误,我们需要定制编译chromium时所用的NDK。这需要重新对chromium的构建进行配置,并再次编译chromium net。首先需要修改out/Default/args.gn,修改后的样子如下:
然后,通过如下的命令,再次编译chromium net。但出错了:
这次是找不到ndk的一个文件android-ndk-r12b/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/lib/gcc/arm-linux-androideabi/4.9/libgcc.a
。我们发现android-ndk-r12b/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/lib/gcc/arm-linux-androideabi/4.9
目录不存在,但是android-ndk-r12b/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/lib/gcc/arm-linux-androideabi/4.9.x
是存在的,我们直接将4.9.x复制到4.9。
再次编译,在链接生成libbase.cr.so时,出错了:
这次提示找不到符号_Unwind_GetIP,这个符号是libunwind中的。我们还需要修改base/BUILD.gn
,在为android编译时,添加对libunwind的依赖:
再次编译时依然出错了:
这是在编译icu时,找不到符号__cxa_bad_typeid。这需要重新配置编译环境,更改所用的工具链,不能是clang,修改之后的out/Default/args.gn如下:
重新配置chromium的构建环境,并再次编译net。
这终于编译好了。将编译出来的so文件拷贝进我们的工程。再次编译我们的工程。但依然在报错:
不过这次已经不是找不到chromium的库中的符号了,而是标准库中的一些符号。这需要我们修改依赖的stl为c++_shared
,而不是c++_static
。再次编译,终于build pass。
编译链接的标记设置
chromium中,有许多feature的开关是通过预定义宏来控制的,它的整个的编译链接都有它独特的参数。如果在编译我们的工程时,预定义的宏与编译chromium时预定义的宏不一致,就很容易出现两边的class实际的定义不一样的问题。比如net::URLRequestContextBuilder类的定义:
会根据宏定义来确定某些字段是否存在,如ftp_enabled_
等。如果两边的宏定义不同,则会导致两边对net::URLRequestContextBuilder对象的处理不同。对于定义了宏DISABLE_FTP_SUPPORT的一边,它在为net::URLRequestContextBuilder对象分配内存空间时,将小于另一边看到的相同类对象的内存空间大小,可想而知,在运行期该会要出现什么样的奇怪问题了。
比如,我们在我们的工程中,定义了宏DISABLE_FTP_SUPPORT,而在编译chromium net时没有定义这个宏。在我们的native代码里,在栈上创建了一个net::URLRequestContextBuilder对象,内存将由我们的工程的编译器分配。而在运行期,分配的这块内存会被传递给类的构造函数,该构造函数则是在chromium net的so中,而它对这块内存空间的预期要大于我们的工程的编译器分配的大小,则在初始化的过程中,难免要踩坏周围的内存的。
为了避免这个问题,最好的方法就是将相关的这些编译、连接,预定义宏等,在两边保持一致。gn工具在这个问题上也可以帮到我们,同样是gn desc,我们需要从输出的如下这些段中提取我们要的参数:
最终,我们的MyApplication/app/build.gradle
文件将如下面这样: