一种跨平台的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层对象的属性,转换成平台类型的属性(如下所示)。
    这种方案的缺点在于,需要维护大量的适配层的代码。
1
- (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方案类似,缺点也是需要维护大量的适配层的代码,以及内存管理的问题,类型频繁转换的性能开销问题。

    1
    - (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库自带的序列化/反序列化的方法进行数据的传递(如下所示)。
1
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封装成面向对象的接口,供各个模块调用,例如:

    1
    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);
    });
  • file storage
    文件存储中,主要存储一些配置/记录相关的数据。
    实现方面,我们将需要保存的Thrift对象序列化,然后写入文件。每次更新配置/记录时,同时更新文件存储。

网络

CC的网络模块,是整个App业务相关的网络请求的出口。

cURL库本身也是纯C的库,因此我们首先对cURL进行了封装,同样将其api封装成面向对象的接口。
因为Client和Server交互是利用Thrift(部分api利用JSON),因此网络交互过程中,还涉及到Thrift的序列化/反序列化。因此我们根据需要解析的数据类型(Thrift,JSON等)不同,将其封装成多种Request对象(例如ThriftRequest,JsonRequest等)。
在使用过程中,只需要创建一个Request对象,设置相关参数,然后丢到请求队列中即可,例如:

1
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时,调用函数对其进行检测,例如:

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

    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;
};
http::RequestHub::GetInstance().RegisterResponseDetectHook(userid_detect_func);

一些细节

适配层的代码自动生成

由于适配层多是一些数据类型的转换,api调用等操作,这部分代码的构成大致相同,以iOS-CC为例:

1
+ (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;
}

  1. L75~79:实现了Objective-C的数据结构转换成C++的数据结构;
  2. L80:调用CC层的api;
  3. 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层实现一个纯虚类:

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

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

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

需要完善的地方

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

一些不适合使用CC的场景

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

关于猫头鹰团队

武汉很少有优秀的互联网公司,但是:

  • 有很多湖北籍/华中籍的在一线城市/一线互联网公司打拼的优秀人才,他们因为家庭,房价等各种原因,期望回到武汉。
  • 不少优秀人才回到武汉后,因为没有互联网土壤,因为理念的不一致,处境很尴尬,甚至很多人生活所迫又重新回到一线城市。
  • 另外一种情况是,很多武汉的本地公司,他们又希望能够拥抱互联网,又找不到真正懂互联网的优秀人才。

我们希望通过各种方式,团聚湖北籍/华中籍的优秀互联网人才,首先打造一家真正的互联网公司,最终改变整个武汉互联网的从业环境, 更多细节请“查看原文”后点击这里

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

img

留言

本站总访问量

留言