SDK 跨平台支持常见问题及解决方案实践

作者简介: 李叶,毕业于华中科技大学,现为 LeanCloud JavaScript SDK 负责人。在前端工程化、前端性能方面有丰富的经验,关注 React 及相关技术。

责任编辑: 唐小引,技术之路,共同进步。欢迎技术投稿、约稿,给文章纠错,请发送邮件至[email protected]

导语: 本文主要介绍 SDK 在跨平台支持过程中开发者们经常遇见的问题,以及解决这些问题时用到的工具并总结的一些最佳实践。希望可以为那些对跨平台开发有兴趣的同学提供有价值的帮助。

背景

作为后端云服务提供商,我们在底层通过 REST API 与 WebSocket 提供数据、文件存储、短信、推送、实时消息等服务。还为各个目标平台编写了 SDK 来封装这些 API,在 SDK 中实现客户端状态的持久化,为用户提供更加符合直觉的抽象。一个有趣的现象是越来越多的平台使用的都是 JavaScript:

  • Web(浏览器/WebView/Windows Universal App/…)
  • Node.js
  • Electron/NW.js
  • React Native
  • Cocos2d-x(JavaScript binding)
  • 微信小程序

为什么 SDK 要跨平台?降低成本是最为重要的一大原因。对于用户,提供跨平台的 SDK 可以降低学习与切换成本。并且,随着同构应用以及服务端渲染的流行,对于采用这种方案的用户,跨平台 SDK 可以方便地作为「平台无关」代码进行共享。而对于公司而言,如果能够在多个平台中共享这部分代码,将会减少 SDK 的开发与维护成本。

基于以上前提,我们的目标具体表现为:

  • 使用一套代码;
  • 一致的 API;
  • 各平台提供一致的安装加载方式。

接下来,我们分 API、编译打包、小程序、测试四个部分详细了解 SDK 在跨平台实践中遇到的常见问题及解决方案。

API

平台间的相同点

  • JavaScript Engine

这些平台都会使用内置或者外部的 JavaScript Engine 来执行 JavaScript 代码。所有属于 ECMAScript 标准的 API 都是所有平台都支持的,比如 Math、Array、TypedArray、Promise、正则表达式。这倒不是指它们使用的是同一个 JavaScript Engine(事实上存在 V8、SpiderMonkey、JSC、Chakra 等各种实现),得益于 TC39 的存在以及 Babel 的出色表现,我们几乎不需要担心我们的 JavaScript 代码在不同平台上的一致性问题。这也意味着,如果一个第三方库只使用了 ECMAScript 的 API,那么它一定是跨平台的,我们可以放心使用,一个典型的例子就 lodash。

不同点

ECMAScript 的 API 是语言层面上的,除此之外,各个平台还会根据自己需要解决的问题提供平台特有的 API。比如,其中唯一有委员会(W3C)来制定标准的平台——Web 平台——提供了下面这些 API。

  • DOM API;
  • 设备 API:地理位置,陀螺仪、电池、MediaCapture;
  • 通讯 API:网络请求、WebSocket、WebRTC、推送;
  • 数据管理 API:文件、本地存储、数据库。

其中 DOM API 在其他平台上都没有,而网络请求 API 在 Node.js 平台上则是完全不同的设计。对于 LeanCloud SDK,我们关心的是实现以下这些功能以及实现所需要用到的 API:

SDK 跨平台支持常见问题及解决方案实践_第1张图片

从上表中可以看到平台在设计这些基础能力 API 时,分为三大流派:

  1. 客户端平台(Web、React Native、cocos2d-x):(尽可能)内置 W3C 标准 API 的实现;
  2. Node.js API:作为唯一的服务端 Runtime,提供底层的能力,上层实现交给社区(上面说到的 W3C API,几乎都能找到基于 Node.js API 的实现);
  3. 微信小程序。

API 的本质是对实现的抽象,SDK 就像一个由 API 调用构成的金字塔,越往上抽象越贴近用户。要跨平台,用户就需要将不同的底层 API 抽象成一个。这里有两种思路,假设我们有两个平台的 API A 与 B:

  1. 用 B 实现 A;
  2. 分别用 A 与 B 实现 C;

具体到我们的实现:

SDK 跨平台支持常见问题及解决方案实践_第2张图片

打包

要想达成只使用一套 codebase 的目标,除了统一的 API 在各平台上的不同实现,还需要在不同的平台上运行对应的代码。我们先来看看有哪些工具能完成这个任务,这里以 WebSocket 为例。

运行时判断

最开始,我们的 SDK 是没有编译打包环节的,在运行时进行平台检测来执行不同的代码。

// src/websocket.js
let WebSocket;
if (!utils.isNode) {
  WebSocket = window.WebSocket;
} else {
  WebSocket = require('ws').WebScoket;
}
  • ☹️ 随着平台数量增加,文件体积大(这是一个例子,实际上 if else 的内容可能很长);
  • ☹️ 平台检测不可靠,随着平台数量增加难以维护。

条件编译

为了解决这个问题,我们引入了 webpack 来实现「条件编译」:

// src/websocket.js
let WebSocket;
if (process.env.PLATFORM === 'Browser') {
  WebSocket = window.WebSocket;
} else {
  WebSocket = require('ws').WebScoket;
}
// webpack/browser.js
module.exports = {
  // ...
    plugins: [
      new webpack.EnvironmentPlugin(["PLATFORM"])
    ]
};
// package.json:
{
  "scripts": {
    "build:browser": "PLATFORM=Browser webpack --config webpack/browser.js"
  }
}

webpack 后:

var WebSocket;
if ('Browser' === 'Browser') {
  WebSocket = window.WebSocket;
} else {
  WebSocket = require('ws').WebScoket;
}

uglify 后:

var WebSocket;WebSocket=window.WebSocket;
  • ☹️ 现在有多个入口了,怎么告诉平台使用哪个呢?(需要提供一致的加载方式)
  • ☹️ ws 怎么也打包进来了?(因为 bundler 不知道不需要 ws,换句话说,我们需要找到一种方式告知 bundler。)

package browser field spec

可见:https://github.com/defunctzombie/package-browser-field-spec

// package.json:
{
    "browser": {
    "ws": "./src/websocket.js"
  }
}
// src/websocket-browser.js
module.exports = window.WebSocket;
// src/websocket.js
const WebSocket = require('ws');

除了对内告诉 bundler 要如何打包模块,browser field 也用来对外申明浏览器版本的入口:

// package.json:
{
    "main": "./dist/node/index.js",
    "browser": {
      "./dist/node/index.js": "./dist/av.js",
    "ws": "./src/websocket-browser.js"
  }
}

作为事实标准,browser 字段得到了市面上几乎所有 bundler 的支持(包括 React Native 内置的 Packager、cocos creator 使用的 browserify,以及 webpack 与 rollup),npm 上众多跨平台的 package 也都是采用了这种申明方式。

同样的,我们还有一些 React Native 特有的代码需要在打包时替换。webpack 使用了一种更通用的方式支持了这个特性。

// package.json:
{
    "main": "./dist/node/index.js",
    "browser": {
      "./dist/node/index.js": "./dist/av.js",
    "ws": "./src/websocket.js"
  },
    "react-native": {
      "./dist/node/index.js": "./dist/av-rn.js",
    "./src/utils/localstorage.js": "./src/utils/localstorage-rn.js"
    }
}
// webpack/react-native.js
module.exports = {
  // ...
    resolve: {
      aliasFields: ['react-native', 'browser']
  }
};

预编译?

刚才说到,市面上几乎所有的 bundler 都支持这个标准,bundler 会按照我们的配置正确的使用对应的模块,所以为目标平台编译出一个文件并不是必须的。事实上这样做是有缺点的

  • 无法与其他模块共享代码
  • 无法自动得到依赖模块的 patch

与此同时,预编译的版本也不会自动得到依赖模块的新 bug,并且考虑到很多 bundler 在具体的实现上总有各种各样的问题,所以我们目前依然在每次一发布时都提供了各个平台的预编译版本。

至此,我们几乎完成了前面所设定的目标:

  • 我们使用一个 codebase,共享了大部分的代码;
  • 统一使用 npm 安装,使用 require 加载,为不同的平台指定不同的入口;
  • 提供了统一的 API。

直到出现了一位新玩家。

小程序带来的新挑战

先来看下小程序的架构。

SDK 跨平台支持常见问题及解决方案实践_第3张图片

在第一部分说到,由于 Web API 抽象层级高、后台硬、现有轮子多,各个平台都倾向于实现 Web API。SDK 大部分时候都是直接调用的 Web API。另一方面,我们也使用了 superagent/axios 等第三方库提供更加易用的 API,并不希望去修改这些第三方库。

很自然地,为了适配小程序,最便捷的方案是用小程序的 API 来 polyfill Web API。很快我们就遇到了两个问题:

unpolyfillable runtime

小程序的 JavaScript 代码在真机上是运行在 JSC / JSCore 上的,但是在开发者工具中,这部分代码是直接运行在浏览器环境中的,是能够使用包括 windowdocumentXMLHttpRequest 在内的所有 Web API 的。为了保证 IDE 与真机运行环境的一致性,IDE 在编译阶段会在每个文件的 CommonJS wapper 中申明这些变量:

define("app", function(require, module, exports, window,document,frames,self,location,navigator,localStorage,history,Caches,screen,alert,confirm,prompt,XMLHttpRequest,WebSocket,webkit,WeixinJSCore,WeixinJSBridge,Reporter){
    'use strict'; 
  // SDK code
  new XMLHttpRequest(); // throw
  new window.XMLHttpRequest(); // throw
});

这意味着即使能够为 global object 增加 Web API,也无法在其他文件中访问到。

define("app", function(require, module, exports, window,XMLHttpRequest/* ... */){
    'use strict'; 
  // polyfill code
  window = window || {};
  window.XMLHttpRequest = require('./xmlhttprequest.js');
    try {
    XMLHttpRequest = XMLHttpRequest || require('./xmlhttprequest.js');
    } catch (e) {}
  // SDK code
  new XMLHttpRequest();
  new window.XMLHttpRequest();
});

小程序的 API 的抽象层级在 Web API 之上

还是以 HTTP 请求为例,小程序的 wx.request API 在开发者工具中是用浏览器中的 XMLHttpRequest 实现的。因此小程序的 API 缺少了很多实现 Web API 需要的特性:

  • Response Headers 无法获得;
  • 不支持上传进度;
  • 不支持 abort
  • 拿不到 HEADERS_RECEIVEDLOADING 等中间状态。

一方面,我们只能在微信小程序中禁用掉 SDK 的一些功能,比如文件上传进度功能。另一方面尽可能去 mock 一些特性或数据来保证现有的基于 Web API 的代码逻辑不会抛异常,比如 getResponseHeade('content-type') 始终返回 'application/json',其他 key 始终返回 ‘’

这些 polyfill 开源在 GitHub - leancloud/weapp-polyfill: Polyfills for w3c API on top of Weapp API 。目前我们 polyfill 了以下 API,如果有在小程序中使这些 API 的需求,这个库应该能节省你一些时间。

  • XMLHttpRequest
  • FormData
  • WebSocket
  • localStorage

测试

测试是保证 SDK 质量的重要手段,我们使用了 Mocha 作为测试框架,Sinon.js 作为 spy 与 mock 工具,它们都同时支持浏览器与 Node.js。再加上 SDK 提供的 API 是平台无关的,使得我们能够使用一份测试代码分别在浏览器与 Node.js 中运行测试。

对于跨平台 SDK,测试流程的自动化是必不可少的。我们使用 travis-ci 来运行 Node.js 的测试,使用 Saucelabs(Selenium)来运行浏览器测试,保证每次提交在我们支持的所有 Node.js 版本与我们支持的所有浏览器中都能通过测试。

遗憾的是,对于其他平台,由于工具的缺失,目前并没有良好的测试方案,我们现在也只是在发布之前手动进行冒烟测试。


了解最新移动开发、VR/AR 干货技术分享,请关注 mobilehub 微信公众号(ID: mobilehub)。

mobilehub

你可能感兴趣的:(SDK 跨平台支持常见问题及解决方案实践)