转载

一种跨平台的App开发解决方案

目前移动应用商店中的大部分的App,都存在iOS和Android两个平台的版本,并且两个版本的UI,底层逻辑大致都相同。而相同的部分,却需要不同平台的开发人员实现两次。

  • UI部分,因为是与平台强相关的,所以目前只能依靠各个平台的开发人员来实现。
  • 逻辑部分,与平台无关,目前其实是有方案使得多个平台使用同一份实现的。
    • Google开源的J2ObjC,可以将逻辑部分的Java代码转换成Objective-C代码。J2ObjC使得Java代码可作为iOS应用构建的一部分,而且无需对生成的文件进行编辑。
    • Dropbox公司,实现了一个基于C++11的跨平台库,将核心的逻辑封装到库里面,提供接口供平台层调用,可以达到一次编写,iOS,Android上均可以运行。

通过对比现有的方案,我们也设计了一个跨平台的解决方案,基于C++实现,来解决不同平台上的逻辑需要重复实现的问题。 该方案命名为Core Component(以下简称CC)。

CC主要想解决的问题

  • 统一各平台的逻辑
    相同的逻辑代码,只需要实现一次即可,降低多次实现带来的出错的风险,减少工作量。
  • 利于bug定位/修复
    一套代码,降低bug的产生数量,同时bug的定位与修复均只需要一次即可。
  • 分离UI与数据
    CC层处理几乎所有的数据逻辑,存储,网络请求等,这样UI层只需要关注在特定平台的UI展示上面。
  • 性能优化
    逻辑部分的性能优化时,可以减Dropbox影响,主要关注在CC层的代码逻辑的性能中。
  • 减少Client对Server的依赖
    实现某个功能时,CC层可以先定义api及数据结构,然后模拟网络请求的结果,提供假数据,便可以使Client先行,减少Client对Server的部分依赖。
    虽然依赖被转移到了CC层和Server之间,但是总好过于多平台同时依赖Server的情况。

CC使用到的技术

明确了我们的目的,我们开始选择CC核心的功能需要涉及到的技术,选择过程中,主要考虑到跨平台性,同类技术横向对比中,性能处于前列等方面。

C++11

我们使用C++来编写CC核心模块,然后增加一层适配层,用来连接各个平台和CC。在iOS中,可以使用Objective-C++来做适配层;在Android中,可以通过NDK来调到C++中。

由于适配层大多是处理一些类型转换,线程切换,api调用等操作,因此适配层的代码其实是可以自动生成的,后面会介绍我们自己实现的适配层代码自动生成器。

我们最终选择的C++11,已经包含了很多新的特性(”C++11 feels like a whole new language” -Bjarne Stroustrup, creator of C++),例如lambdas,smart pointers等等,能够在大多数场景下满足我们的需求。

SQLite

CC层最核心的一部分,即是数据的逻辑以及存储,因此在数据存储上,我们使用了在移动端普遍使用的SQLite。SQLite的C api不是那么容易使用,不过现在已经有很多库将SQLite封装成面向对象的接口(就像Objective-C中的FMDB)。

cURL

在网络库方面,我们选择了cURL,cURL强大的网络处理能力,使得我们能够很容易的与Server进行交互,以及监控相应的网络数据流量,耗时等信息,方便后续的调整优化。

CC与Client,Server间的数据传递

在CC层与Client,Server之间的数据传递方面,我们挑选了几种候选方案,最终选择了利用 Thrift 来传递数据的方案。

  • Wrapper

    类似Dropbox使用的技术,需要CC层的每个数据对象,在平台层都有相对应的对象(二者的成员变量也需要相对应),然后在平台层对象的构造函数中(initWith*,以Objective-C为例),传入一个CC层的对象指针,然后在构造函数内部,将CC层对象的属性,转换成平台类型的属性(如下所示)。

    这种方案的缺点在于,需要维护大量的适配层的代码。

– (id)initWithPhotoItemStruct:(const struct dbx_photo_item *)item {

if ((self = [super init])) {

_itemId = [[NSString alloc] initWithUTF8String:item->dpi_id];

_fileInfo = [[DBFileInfo alloc] initWithInfoStruct:&(item->dpi_file_metadata)];

_timeTaken = [DBUtilDateFromISO8601String(item->dpi_time_taken) retain];

// …

}

return self;

}

  • 共享内存

    同样需要CC层的每个数据对象,在平台层都有相对应的对象(二者的成员变量也需要相对应),与Wrapper方式不同之处在于,这种方案在平台层的对象中,封装一个C++的对象,然后重载平台层对象的getter方法,当Client需要访问某些属性时,实际上是将C++对象的属性转换成平台的类型,然后返回给调用者(如下所示)。

    这种方案与Wrapper方案类似,缺点也是需要维护大量的适配层的代码,以及内存管理的问题,类型频繁转换的性能开销问题。

– (NSString *)itemId

// _obj为CC层的对象指针

return [[NSString alloc] initWithUTF8String:_obj->item_id];

}

  • JSON
    Client和CC之间,通过将对象序列化成JSON,然后进行传递,然后再解析。不过C++对对象的反射不是很友好,没有找到合适的方法来进行对象的序列化以及反序列化。
  • Protobuf
    Protobuf 是Google的一项开源技术,可以把某种数据结构的信息,以某种格式保存起来,主要用于数据存储、传输协议格式等场合,Protobuf有以下优点:
    • 性能好/效率高
      快速的序列化/反序列化能力,以及较少的存储空间消耗。
    • 代码生成机制
      只需要按照语法格式,编写简单的类声明,便可以自动生成相关的所有代码。
  • Thrift
    Thrift 与Protobuf的功能大致相同,也具有Protobuf的优点,在网上的 性能对比中 ,Thrift的序列化/反序列化的性能稍弱于Protobuf。
    但是因为我们的Server端是使用的Thrift,如果Client采用Protobuf,则Server需要做很多数据转换的工作(从Thrift到Protobuf),因此我们采用了Thrift,同时Client和Server之间的大部分场景下,可以使用同一套Thrift定义。
    Client与CC,以及CC与Server之间,可以使用Thrift库自带的序列化/反序列化的方法进行数据的传递(如下所示)。
bool convertCCThriftToOCThrift(apache::thrift::TBase *ori, id<TBase> dst) {  if (!ori)   return false;  std::shared_ptr<CTMemoryBuffer> trans(new CTMemoryBuffer());  std::shared_ptr<CTProtocol> proto(new CTBinaryProtocol(trans));  ori->write(proto.get());  std::string binaryStr = trans->getBufferAsString();  NSData *bin = [NSData dataWithBytes:binaryStr.c_str() length:binaryStr.size()];  TMemoryBuffer *buf = [[TMemoryBuffer alloc] initWithData:bin];  TBinaryProtocol *pro = [[TBinaryProtocol alloc] initWithTransport:buf];  [dst read:pro];  return true; } 

CC模块

线程池

由于CC层只处理逻辑相关的部分,与UI无关,因此CC层不需要使用主线程来进行相关操作。反而需要避免一些耗时操作在主线程上执行,导致UI卡顿。因此我们在封装模块时,集成了各自的线程池,在api的入口处,切换到模块的线程,然后再执行任务,最后异步返回结果(由于使用了支持lambdas的C++11,异步返回变得很好实现)。目前模块中的线程池主要有:

  • I/O ThreadPool
  • Network ThreadPool
  • Storage ThreadPool

线程池的实现,网上开源的库有很多,我们采用了C++11实现的一个开源的 ThreadPool 。

存储

存储部分算是CC的核心模块之一,需要负责与Server的数据同步,数据缓存的更新。根据数据的获取形式不同,我们数据存储形式主要有数据库存储,以及文件存储。

  • db storage

    由于SQL查询的便利,因此数据库中,我们主要存储需要条件查询的数据,例如需要分页加载的数据。

    实现方面,我们将SQLite的C api封装成面向对象的接口,供各个模块调用,例如:

    “` C++

    std::stringstream sql;

    sql << "DELETE FROM " << table_name

    << " WHERE "

    << kColId << " = :id;";

    // 由于使用了线程池,因此我们封装的api均为异步调用。

    db_->ExecuteStatementAsync(sql.str(), [=](sqlite::database* db, sqlite::statement* stmt) {

    if (!stmt || !db) {

    LOGE("execute statement failed");

    return;

    }

    stmt->bind(":id", obj_id);

    db->execute(*stmt);

    });</li>

    </ul>

    <br />- file storage 文件存储中,主要存储一些配置/记录相关的数据。 实现方面,我们将需要保存的Thrift对象序列化,然后写入文件。每次更新配置/记录时,同时更新文件存储。 ### 网络 > CC的网络模块,是整个App业务相关的网络请求的出口。  cURL库本身也是纯C的库,因此我们首先对cURL进行了封装,同样将其api封装成面向对象的接口。 因为Client和Server交互是利用Thrift(部分api利用JSON),因此网络交互过程中,还涉及到Thrift的序列化/反序列化。因此我们根据需要解析的数据类型(Thrift,JSON等)不同,将其封装成多种Request对象(例如ThriftRequest,JsonRequest等)。 在使用过程中,只需要创建一个Request对象,设置相关参数,然后丢到请求队列中即可,例如: ``` C++ std::string url = "***"; std::shared_ptr<ThriftRequest<thrift::Comment>> request = std::make_shared<ThriftRequest<thrift::Comment>>(http::POST, url); request->SetBody(comment); // listener 为解析请求结果的回调 request->OnResponse(listener); ServerRequestManager::GetInstance().AddRequest(request);

    网络模块中,有时候还需要统计相关的数据,或者做一些容错处理。因此我们在网络模块中,增加了一个类似于Hub的功能,作为所有的Request的出入口。

    业务模块中,可以编写相应的出口/入口检测函数,然后注册到Hub中,Hub在发出Request/收到Response时,调用函数对其进行检测,例如:

    “` C++

    http::ResponseDetectFunc userid_detect_func = [=](const http::Request* const request, const http::ResponseData& response) {

    if (!request) {

    return true;

    }

    if (response.curl_code != CURLE_OK || response.status_code != http::HTTP_OK) {

    return true;

    }

    <pre><code>bool do_request_without_auth = request->GetTag<bool>(REQUEST_TAG_DO_REQUEST_WITHOUT_AUTH);

    if (!do_request_without_auth) {

    int64_t user_id = request->GetTag<int64_t>(REQUEST_TAG_KEY_USER_ID);

    if (user_id == 0 ||

    Config::GetInstance().GetUserId() != user_id) {

    LOGW("UserId detect failed. user_id in config: %lld, user_id in request: %lld, request url: %s", Config::GetInstance().GetUserId(), user_id, request->url().c_str());

    return false;

    }

    }

    return true;

    </code></pre>

    };http::RequestHub::GetInstance().RegisterResponseDetectHook(userid_detect_func);

    <br />## 一些细节 ### 适配层的代码自动生成 由于适配层多是一些数据类型的转换,api调用等操作,这部分代码的构成大致相同,以iOS-CC为例: ``` Objective-C + (void)useCoupon:(KPCouponRequestParam*)arg0    finished:(void(^)(KPErrorInfo*, KPCoupon*))arg1 {  std::shared_ptr<cc::thrift::CouponRequestParam> cpp_arg0 = std::make_shared<cc::thrift::CouponRequestParam>();  if (!convertOCThriftToCCThrift(arg0, cpp_arg0.get())) {   arg1(nil, nil);   return ;  }  cc::CouponManager::GetInstance().UseCoupon(cpp_arg0, [=](cc::ErrorInfoPtr err, const std::shared_ptr<cc::thrift::Coupon>& ret) {   KPCoupon *value = [[KPCoupon alloc] init];   if (!convertCCThriftToOCThrift(ret.get(), value)) {    value = nil;   }   dispatch_async(dispatch_get_main_queue(), ^{    arg1(convertCCErrorToOCError(err), value);   });  });  return; } 

    L75~79:实现了Objective-C的数据结构转换成C++的数据结构;

    L80:调用CC层的api;

    L81~L88:将调用的结果的C++数据结构转换成Objective-C的数据结构。

    如上述例子所示,不同的api,在适配层只是api名称,参数类型等不同,为了避免重复工作,我们实现了一个代码生成器(CodeGenerator),用于生成这部分代码。CodeGenerator的思路如下:

    1. 编写Android中native api的声明,并编译;
    2. 使用javap命令反编译,输出所有的类和成员,以及内部类型的签名;
    3. 使用正则,解析反编译的结果,分析api的名称以及输入,输出参数;
    4. 得到api的所有信息之后,再套用适配层的模板,进行入参的类型转换,CC层api调用,返回值类型转换等,线程切换等等。

    数据变动,CC触发通知

    我们在CC层统一了数据更新的模型,利用Observer模式,所有的数据变动,都由CC层发送一个通知,通知给各个注册的Observer。例如下拉刷新,Client只需要预先注册一个Observer,然后在刷新的时候,调用Refresh,不需要传递任何pageSize,offset等参数,CC层自己从内存中保存的数据获取相关信息,然后调用Server的api刷新数据,然后发送通知,Client通过注册的Observer收到通知,然后再刷新UI。

    平台相关api调用

    由于CC层的限制,平台SDK级别的api是CC层访问不了的,例如扫描通讯录。针对这种方式,我们在CC层实现一个纯虚类:

    C++
    class SystemContactLoader
    {
    public:
    virtual ~SystemContactLoader() {}
    virtual void LoadAllSystemContacts(const StringSetPtr&amp; loaded_phones, ContactLoadedListener listener) = 0;
    };

    Client层负责继承此类,然后在程序启动的时候,实例化子类,然后将实例化的对象注册到CC层中,这样CC层便可以调用到系统级的api。

    某些api调用可能需要在主线程中,因此平台在实现的时候,可能需要切换到主线程,执行完毕之后,再开启一个新线程,将执行结果传递给回调函数。

    需要完善的地方

    • Android的Crash目前没有什么好的收集工具(fabric已经集成了ndk crash reporting,我们已经集成,正在试用效果)
    • 自动生成的适配层的代码,由于是通过javap去解析的,因此没有办法拿到参数名称,导致了生成的代码可读性不高。

    一些不适合使用CC的场景

    CC这部分,主要工作在于整个模块框架的搭建与完善,后期的开发工作量基本上都比较少。因此如果一个App的逻辑部分不是很多的话,其实没有必要引入CC这种模块。

    ##长按关注猫头鹰技术公众号(mtydev)

    一种跨平台的App开发解决方案
正文到此结束
Loading...