一种Android App在Native层动态加载so库的方案

时间:2022-04-26
本文章向大家介绍一种Android App在Native层动态加载so库的方案,主要内容包括1. 为什么在Native层动态加载so库、2. Native层的so库动态加载的实现、3. Java层调用Native层动态加载的实现、4. so库之间动态加载需要解决的问题、5. 总结、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

这篇文章通过实战案例,介绍了一种有条理的组织Native层代码层级结构的方法。并且,在良好的代码层级、作用分工的基础上,实现了动态的按需加载、卸载so库。文章的最后,还介绍了实践过程中遇到的困难以及对应的解决方案,能让读者少走弯路。 — 责任编辑 wingyipye

1. 为什么在Native层动态加载so库

随着Android App发展的不断变化,App的性能和系统API框架外的功能拓展显得越来越重要。App从性能方面考虑,需要在Native层使用C/C++实现的方案,Native层再通过JNI的方式提供方案给实现应用基本功能的Java层调用,来拓展一些计算密集型的功能。例如App如果要支持播放手机自身不支持播放的音频格式,就需要在Native层实现App自己的音频解码功能。

随着项目规模的增大,Native层的代码规模也逐渐膨胀起来。为了更清晰的组织代码,Native层之间也会按照模块分别构建成独立的so库。其中为了简化Java层与Native层之间的通信方式,通常会特地使用一个JNI层so库引用其他实现具体功能的功能实现so库。Java层只加载这个JNI层so库,来间接调用功能实现so库。

so库之间通过引用头文件和运行时指定共享库依赖的方式形成了依赖关系。但是这种简单的模块划分方式存在着一些问题:

  • 应用上层的热修复方案需要so库能够支持被动态加载,这样出现问题的so库才能够在应用运行的时候先被替换为修复问题的库文件然后才被加载。对于Java层直接引用的so库,动态加载可以使用Java层的系统API提供的方法System.load()或者System.loadLibrary()方式实现。然而对于功能实现的so库,是通过JNI层so库被Java层间接引用的,自身没有直接与Java层对接的JNI函数。所以对于功能实现so库,无法再使用Java层动态加载的方法。
  • 加载JNI层so库的时候,即使这次JNI调用有些功能实现so库里面的数据结构或函数没有被调用到,只要这个so库被JNI层so库声明为运行时需要依赖的共享库,也需要跟JNI层so库一起被加载,这无形中也增大了Native层的常驻内存。

为了解决这些问题,就不能再使用Java层动态加载so库的方法,而需要在Native层直接动态加载so库,由JNI层so库动态加载功能实现so库。被加载的so库可以声明一些不能轻易增删和修改其定义的接口函数,调用方只需知道这些接口函数的名字,不需要依赖头文件就能调用这些函数,这样调用方和so库之间就不存在直接的依赖,被加载的功能实现so库甚至可以不用打包到App也能被运行时加载,功能实现so库的独立性得到很大程度的保持,方便了热修复的so库替换。so库被调用时动态加载,结束调用时动态卸载,也能一定程度上减少so库加载需要的常驻内存。

2. Native层的so库动态加载的实现

在Native层的C/C++代码环境,so库动态加载是使用dlopen()dlsym()dlclose()这三个函数实现的。这三个函数均在头文件<dlfcn.h>中定义,它们的作用分别是:dlopen()打开一个动态链接库,返回一个动态链接库的句柄;dlsym()根据动态链接库句柄和符号名,返回动态链接库内的符号地址,这个地址既可以是变量指针,也可以是函数指针;dlclose()关闭动态链接库句柄,并对动态链接库的引用计数减1,当这个库的引用计数为0,这个库将会被系统卸载。

一般使用C/C++实现so库动态加载的流程如下:

  1. 首先调用dlopen()函数,这个函数所需的参数,一个是so库的路径,一个是加载模式。一般使用的加载模式有两个:RTLD_NOW在返回前解析出所有未定义符号,如果解析不出来,dlopen()返回NULLRTLD_LAZY则只解析当前需要的符号(只对函数生效,变量定义仍然是全部解析)。显然对于动态加载,加载方只需知道当前被加载的so库里面自己需要用的函数和变量定义,所以这里选择的是后者。如果这个调用成功将返回一个so库的句柄;
  2. 在上一步得到so库句柄之后,这时就可以调用dlsym()函数,传入so库句柄和所需的函数或变量名称,返回相应的函数指针或变量指针;加载方这时就可以使用返回的指针调用被加载so库之中定义的函数和数据结构;
  3. 当so库的调用结束,调用dlclose()函数关闭卸载so库;
  4. 如果在打开关闭so库,或者获取so库里操作对象的指针出现错误的时候,可以调用dlerror()函数获取具体的错误原因。

3. Java层调用Native层动态加载的实现

确定动态加载的方案后,Native层代码模块的划分也有所修改:增加一个公共数据结构定义的so库,专门存放一些通用常量和基本的数据操作接口,例如一些基类的定义,JNI层so库操作基类对象,而在功能实现的so库则继承这些基类定义实现具体操作。由于基类数据结构定义需要事先获知,所以这个so库需要作为共享库被JNI层so库和功能实现so库在运行时依赖(具体表现就是在构建这些so库的Android.mk文件中,把这个公共定义的so库指定到LOCAL_SHARED_LIBRARIES变量中),而JNI层so库则通过调用dlopen()动态加载功能实现so库;

so库动态加载的流程如下:

  1. 为了便于配置so库路径,so库路径的获取方法在Java层实现,在动态加载开始之前Native层通过JNI对象指针调用Java层的so库路径配置,获取so库路径并将其回传到Native层;
  2. 功能实现so库对外声明构造和析构操作接口子类的函数,JNI层so库通过dlopen()打开功能实现so库之后,在调用dlsym()获取这两个对外声明的函数的指针,然后调用构造函数获取操作接口对象,并把析构函数指针和so库句柄登记到一个以操作接口对象为键值的映射表中;
  3. 当需要释放关闭so库的时候,从映射表中取回析构函数指针和so库句柄,先调用析构函数释放操作接口对象,然后调用dlclose()函数,传入so库句柄,卸载so库,并删除析构函数指针和so库句柄在映射表中的登记。

4. so库之间动态加载需要解决的问题

  1. 不同Native层模块的构建的STL版本不一致,会导致参数错误 由于动态加载的调用方和被调用方是分别构建成具体的so库或其他可执行文件,所以其中使用的来自C++ 标准库的数据定义和实现,就不一定相同。这是因为Android提供给NDK开发的C++运行时有几个版本:STLport,GNU STL,libc++,这几个版本不仅在异常使用,RTTI支持,还有开源授权都有差异,而且其中包含的C++标准库,实现细节也不一样。所以如果动态加载双方使用的C++运行时不一样,那么标准库里面,像std::string这种基本的数据类型声明,即使标准库所在头文件名字、命名空间名字和类型名字都一样,但因为在双方各自引用的实现也会不一样,实际上还是不一样的数据类型。这样调用方直接引用被加载so库里面的函数,就有可能因为参数类型错误而出错。 具体的解决方法,就是调用方和被动态加载的so库要同时构建,并且在统一的Application.mk文件里面的APP_STL属性指定统一的运行时,这样构建出来的可执行文件都是使用同一个C++标准库。综合功能支持力度和开源限制的考虑,选择STLport运行时是相对较好的选择。使用时只需要指定APP_STL属性为stlport_static(静态链接)或者stlport_shared(动态链接)即可。
  2. dlopen直接加载存放在SD卡的so库,会出现权限禁止的问题 在尝试动态加载存放在SD卡的so库的时候,出现了原因是“Permission denied”的UnsatisfiedLinkError。这是由于SD卡在Android系统上的挂载并不具有可执行文件的权限,所以SD卡的挂载目录不能直接用来作为可执行文件的运行目录,使用前应该把可执行文件复制到APP内部存储再运行。所以如果Android App要动态加载的so库存放在SD卡,就首先需要把so库拷贝到应用自身在/data里的存储目录,或者其他有可执行文件运行权限的目录(如/data/local/)。
  3. dlopen函数的使用需要兼容C++ dlopendlclosedlsym函数是C语言库里面的函数,自身是没有考虑到C++的支持的,调用dlopen无法直接加载C++的类及其成员函数。这是因为C语言直接把函数名当做符号名,dlsym直接用符号名就能加载相对应的目标库内的函数,但是由于C++有类和类成员函数的概念,符号名的生成采用了”name managing”的方式,把函数名、类定义、类的成员函数采用复杂的方式将其转换为只能让机器读懂的符号,所以在C++,函数名和其对应的符号名不是直接对等的。 解决方法就是在调用方和被加载的so库都静态引用的公共数据定义中,定义一个虚基类作为操作接口。这个类的具体子类在被加载的so库中实现,调用方使用基类指针操作被加载的so库中的子类实例。 至于如何让调用方创建并获取被加载的so库里的子类实例,首先需要在被加载so库里的子类实现中定义两个前缀带有extern "C"的非成员函数,因为在C++中带有extern "C"这个前缀的函数,在符号名生成的处理将跟C语言的函数一样,是直接把函数名当做符号名,所以这两个函数就可以作为可以让调用方用名字获取其指针的接口函数,这两个函数再分别调用子类的构造函数和析构函数,就可以实现子类实例的构建和销毁。
    //声明两个接口函数指针类型,这两个函数分别用来构造和销毁操作接口类的实例

   typedef BaseClass* (*createClassFcn)();

   typedef void (*destroyClassFcn)(BaseClass*);

   //子类实现一个返回子类具体对象的extern “C”的非成员函数(子类定义在被动态加载的so库中)

   extern "C" SubClass* create_SubClass() {

       return new SubClass;

   }

   //子类实现一个销毁子类具体对象的extern “C”的非成员函数(子类定义在被动态加载的so库中)

   extern "C" void destroy_SubClass(SubClass* p) {

       delete p;

   }

   //动态加载时,传入子类定义的这两个非成员函数的名字,使用之前定义的两个函数指针分别指向这两个非成员函数。

   *libHandler = dlopen(libNameStr, RTLD_LAZY);

   if (!(*libHandler)) {        

       return ERROR;

   }

   createClassFcn *p_create_fcn = (createClassFcn) 

     dlsym(*libHandler, "create_SubClass");

   const char* dlsym_error = dlerror();

   if (dlsym_error) {

       return ERROR;

   }

   destroyClassFcn *p_destroy_fcn = (destroyClassFcn) 

     dlsym(*libHandler, "destroy_SubClass");

   const char* dlsym_error = dlerror();

   if (dlsym_error) {

       return ERROR;

   }

   //操作函数指针就能控制类对象的创建和销毁。

   BaseClass* instance = p_create_fcn();

   p_destroy_fcn(instance);

5. 总结

使用动态加载so库的方案之后,实测起来跟直接依赖对比,对性能并没有明显的负面影响,功能实现的so库与JNI层完全解耦,有高度的独立内聚性。便于进行单独替换so库的热修复操作。同时支持动态加载卸载so库,也一定程度上减少了Native层的常驻内存。

腾讯音乐招聘: 腾讯音乐招聘-前端专场 腾讯音乐招聘-后台/算法专场