1. 背景

React Native(以下简称 RN)目前在 Shopee 前端团队得到大量应用。RN 虽然有很多优势,但是其开发和调试流程与 Mobile Web 相比却不那么友好,特别是在运行时的调试。

在开发模式下,虽然 RN 提供了官方的调试工具,但是相比纯前端的浏览器 Devtool,它的功能比较弱;而非开发模式下,例如 Test 和 UAT 环境,RN 代码被打包成了一个 Bundle,这个时候官方调试工具也派不上用场了,这不仅对测试同学的问题复现产生阻碍,也使开发同学的问题定位变得更加困难。

目前业界对于 RN 的调试虽然有工具,但或多或少都存在缺陷(如下图所示),而且这些工具都是针对开发模式下的调试,对于打包后的生产环境的调试往往还是需要靠人肉去做,效率比较低下。

因此一款能够帮助在非开发环境定位问题的工具显得尤为重要,Luna 就此应运而生,本文将介绍这款 RN 工具关键技术的设计以及实现。

2. 功能介绍

先通过下面几张图了解一下 Luna:

从图片可以看出来,Luna 是一款 RN 的应用内调试工具,更偏向于解决生产环境调试的痛点

Luna 由一个橙色的触发按钮以及占据半屏的本体组成。本体包含了 Log、Network、Redux 和 Shopee 这四个版块,分别承载了日志记录、网络请求查看、Redux 树查看以及 Shopee 相关信息查看的功能。

其中,Log 和 Network 作为核心模块存在,而 Shopee 和 Redux 则是作为 Luna 提供的公共插件引入进来的。这种 Core-Plugin 模式就是 Luna 现在的运行模式:默认提供 Log、Network 等功能,也支持使用者编写自定义模块导入到 Luna。

四大版块的功能如下:

1)Log 版块

Log 版块接管了 console.log,将所有 Log 和未捕获的错误收集到 Luna ,然后倒序展示出来。它支持按 Log 的类型进行过滤,也支持对 Log 进行模糊查找。如下图所示:

2)Network 版块

Network 版块收集了页面发出的请求信息,包含了请求状态、请求耗费时长、请求头、请求体以及响应头和响应体等等,用户可以方便地查看 API 请求。

3)Shopee 版块

Shopee 版块提供了一些 Shopee App 相关的功能,比如便捷的翻译文案切换、Cookies 查看、DataStore 存储查看与删除,还有用户 ID / 名字与设备系统信息,以及版本号相关的信息查看。这些功能可以帮助开发者更方便地调试应用,也便于 QA 更快地复现与定位 bug。

4)Redux 版块

Redux 版块展示了 Store(共享数据存储仓库)树,方便用户查看整个 Store 的状态。

3. 方案设计

3.1 整体设计

Luna 作为一个 monorepo 多包单仓库架构的项目,包含了 Core、Shopee Plugin 和 Redux Plugin 三个包模块。

其中,Core 核心模块包含了三大部分:Log 日志版块、Network 网络版块、Plugins 插件接入版块。下文将一一介绍每个模块的设计。

3.2 Core

Core 模块是 Luna 的核心模块,作为一个单独的 npm 包存在,提供了最基本的功能与插件接入的能力。Core 模块作为一个 Provider 嵌套在组件树的根部,接受业务代码,并将 Luna 插入进去。Core 使用 mobx 作为存储,维护了 Log 日志和 Network 记录的收集与展示、以及自定义插件的控制等等功能。

3.2.1 接入方案

Luna 的灵感源自于 Web 端开源的 vConsole 和 Eruda 这两款调试工具,但在 Luna 的接入方案选择中,我们碰到了在 Mobile Web 中从未碰到过的难题:在现代化 Web 开发中,不论是 Vue 还是 React,只要是单页应用,都会有一个用于挂载的根节点,以这个根节点为起点构建整个组件树。所以调试工具也只需要挂在某一根节点下,即可感知整个应用的状态:

而在 React Native 中,每个页面(View)都有自己的根节点(如下图所示),不同的页面之间并没有一个公共的祖先节点,如果要保证每个页面都能访问到 Luna,就得在每个页面都单独进行一次注入,不仅接入成本陡增,而且数据的保留也成了一大难题。

所以如何保证 Luna 在各个页面都能访问到,并且还能保留不同页面数据、以及在发生错误时不影响到 Luna,同时还要减少页面接入的成本,成为了一个难题。那么 Luna 是怎么做的呢?

首先,Luna 将初始化与页面注册解耦,将 Luna.init 前置到了应用初始化时。这使得数据的收集与页面的注册分离,保证了页面的切换不会导致数据的丢失。

import Luna from "@shopee/luna";
Luna.init();

接着,Luna 利用 Shopee Plugin 重写了用于注册 Shopee RN Page 的方法,用新的组件包裹了传入的页面组件,同时将 Luna 也包含在里面,以 HOC 的形式将组件返回到外层。每一个使用这个注册页面的方法所注册的页面,都会把 Luna 自动包含在页面里,无需在每个页面手动引入 Luna,同时每个页面也都可以访问到 Luna。

最后,Luna 还对传入的 Component 包裹了一层 ErrorBoundary,用于捕获页面产生的运行时错误,使得在页面产生错误时 Luna 还可以访问得到,并且可以在 Luna 里看到报错的信息。

3.2.2 Log

日志收集

Log 模块顾名思义,用于显示系统和用户打印出来的日志。

Luna 劫持了全局变量 global.console,对各种类型的 Log 进行收集;同时, Luna 也劫持了 console.tron.log,收集开发时使用 Reactotron 打印出来的相关 Log;Luna 还劫持了 ErrorUtils,将未捕获的错误也一并收集到日志 Store 里。这三种类型的日志就是 Log 版块的数据来源。

Luna 以类似于中间件的做法劫持了全局的 console,劫持的过程中将其加入 Log store,然后执行其原本的执行函数,其主要代码如下所示:

export const overrideConsole = (consoleStore) => {
  const mixinType = [
    LOG_TYPE.LOG,
    LOG_TYPE.ERROR,
    LOG_TYPE.WARN,
    LOG_TYPE.DEBUG,
    LOG_TYPE.INFO,
  ];
  mixinType.forEach((type) => {
    // @ts-ignore
    const originConsoleFun = global.console[type];
    // @ts-ignore
    global.console[type] = (...params) => {
      consoleStore.addLog(params, type);
      originConsoleFun(...params);
    };
  });
};

日志展示

Log 日志包含了类型筛选、搜索框和日志列表,由于 Luna 日志的类型众多、内容复杂且一直处于一个动态更新的状态,所以很容易产生性能问题。所以在日志列表的展示部分,我们做了大量的性能优化,主要包含两个部分,如下图所示:

1)嵌套类型展示优化

由于开源方案的树状展示库存在兼容性问题,我们选择自己编写树状展示组件,用于解决数据类型复杂、数据量大带来的展示问题。它具有以下特点:

  • 支持多行文本的展开与收缩,收缩时只显示部分内容;
  • 对大数组与对象采取了懒加载方案,展开后只展示小于 100 行的内容,用户每点击一次剩余部分(N),则展示后 N*100 条数据。这种做法避免了大数据显示所带来的性能问题;
  • 对一行的超长文本进行换行控制,保持每个 Log 不超过三行,保证每屏的 Log 数量是受控的。

2)列表滑动性能优化

Luna 的 Log 并不是一次性加载完毕,而是实时生成的。这使得在列表滑动过程中很可能同时有新的数据产生,而用户往往需要往下滑动,来寻找他们打印出来的 Log。所以 Luna 针对滑动的性能也做了一些特定优化:

  • Luna 采用了 FlatList 来渲染 Log 列表,同时还在 Log 收集时隐式生成 ID ,作用于 FlatList 的 keyExtractor,以此提高渲染效率;
  • 由于 Log 是动态生成的,这对 FlatList 的性能有着不小的影响。针对于此,Luna 将 Log 列表进行倒序显示,将最后产生的数据,也就是用户点击 Luna 时最关心的数据放在 FlatList 的最前面,同时打印出时间。这样就减少了用户滑动的频率;
  • 我们还计划对 Luna 进行更严格的日志分页加载,将显示和存储的 Log 列表分开,在滑动进行到底时,获取存储的 Log 列表的「下一页」,彻底保证动态数据产生过程中的列表滑动性能。

3.2.3 Network

Network 模块的数据收集源于 XMLHttpRequest。Luna 劫持了 React Native 的 XMLHttpRequest,重写了 open、send 和 setRequestHeader 方法,将每个请求,以及请求相关的字段都存储到 Network 列表里。由于 RN 的 Fetch 底层其实也是使用了 XHR,所以对 XHR 作劫持,可以达到全覆盖的效果。Network 劫持的主要代码如下所示:

export const overrideNetwork = (consoleStore) => {
  originOpen = XMLHttpRequest.prototype.open;
  const originSetHeader = XMLHttpRequest.prototype.setRequestHeader;
  XMLHttpRequest.prototype.open = function (...args) {
    this._xmlItem = { openData: args };
    this.addEventListener("load", () => {
      const xmlItem = this._xmlItem;
      const requestHeaders = this._requestHeaders;
      const endTime = new Date().getTime();
      const time = endTime - xmlItem.startTime;
      consoleStore.addNetworkLog({
        url: this.responseURL,
        method: xmlItem.openData[0],
        status: this.status,
        rspHeader: this.getAllResponseHeaders(),
        response: this.response,
        body: xmlItem.sendData,
      });
    });
    originOpen.apply(this, args);
  };
};

而在 Network 列表的展示方案中,我们则加入了很多细节上的考量,比如:

  • 优先展示请求的 URL 的末尾 Path;
  • 根据 response 的状态码的不同设置不同的底色;
  • 根据请求时间的长短展示不同的时间单位。

这些细节是在日积月累的使用中进行的点滴改进,它们确实让 Luna 实际的用户体验更上了一层楼。

3.3 Plugins

3.3.1 插件机制

为什么需要插件机制?

在介绍什么是插件机制之前,你可能内心会有一个疑问,为什么会有插件机制呢?究其原因,Luna 在实现功能的时候,有一些功能是依托于 Shopee 的 SDK 实现的;另一部分功能如 Redux 是非必选的,用户使用的状态管理框架可能是 mbox;为了保持 Luna 核心模块的纯净,以及保留 Luna 对于非 Shopee 框架下的可拓展性,我们解开了这些不必要的耦合,将 Shopee 模块与 Redux 模块改造成插件机制,供使用者按需引用。

什么是插件机制?

Luna 在核心模块之外,Core 还支持自定义插件。Luna 提供了两个第一方插件:Redux Plugin 和 Shopee Plugin,如果你有自己 App 的定制化需求,也可以非常方便地编写自己的插件,导入到 Luna 里,如下图所示。

3.3.2 官方插件

Luna 也采用插件机制提供了两个官方插件:Redux Plugin 和 Shopee Plugin,这两个包作为单独的 npm 包供有需要的使用者引入。其中:

  • Redux Plugin 作为一个 Redux 中间件存在,通过 Store.getState 获取到 Redux 的状态,并将其显示在界面上。使用者可以很方便地查找到当前 Redux 的存储值。
  • Shopee Plugin 是依托于 Shopee React Native SDK 的一个插件,专门针对于 Shopee App 内的项目开发。它通过 Shopee 的 SDK 提供了许多功能,这个插件主要面向 Shopee 内部的开发与测试同学,方便他们进行 Shopee App 内的调试。

3.3.2 开发自定义插件

除了官方插件之外,使用者还可以自己扩展插件,如何开发一个 Luna 的插件呢?Luna 的插件机制十分类似 Vue 的 install-use 机制,但是它省略了 Vue 插件的 install 步骤,只要需要提供组件内容注入到 Luna 提供的 use 方法就可以。所以其实步骤非常简单,只需要两步:

  • 编写你的组件,声明名称;
  • 将组件和名称导入到 Luna Core 的实例。

Luna 便可以识别到你的组件,显示在主界面上了,接下来你就可以在插件里添加自己所需的功能。

4. 未来展望

Luna 现阶段已经在 Shopee 的一些业务里稳定运行,也受到了使用它的开发和测试同学的一致好评。在未来我们会朝两个大的目标努力:

1)自动化 Luna 接入

现阶段 Luna 的接入还是具有侵入性的人工代码接入,未来我们打算通过部署平台,在部署的时候自动将 Luna 接入进去,并且只在开发、测试环境下生效,不仅可以实现 0 代码的接入成本,也不影响生产环境,还减少了打包后的代码体积。

2)组件树状态查看器

在 Web 端几乎每个开发者都会使用 React Devtool,而其中深受大家喜爱的就是 Components 模块,它展示了开发时的整棵组件树,以及每个组件相关的 Props、State 和 Hooks。而在 React Native 端现时还没有一个类似 React Devtool 一样好用的开发调试工具,而对 RN 的状态查看又是开发者的一大痛点,因此 Luna 计划在未来增加对于组件树以及组件状态的查看器,届时在 RN 上同时查看 Log、Network 以及组件状态,将变得不再困难。

03-05 14:07