资源加载

2017 年 6 月 29 日,作者:Eric Traut


我们收到了一些问题,询问我们如何以同时适用于 React Native 和 React JS(web)的方式处理资源(图像、视频、声音)。

指定资源位置

在 Web 上,资源简单地通过 URL 引用,并由浏览器异步加载。

<RX.Image source={ 'https://mydomain.com/images/appLogoSmall.jpg' }/>

React Native 应用程序通常将资源打包在应用程序包中,因此它们从本地设备存储中加载。在这种情况下,路径以相对文件系统路径的形式指定。但是,您需要通过调用“require”来调用 React Native 打包器,而不是直接传递路径。

<RX.Image source={ require('./images/appLogoSmall.jpg') }/>

打包器要求资源路径指定为字符串字面量。换句话说,它不能在运行时构造或由辅助方法返回。有关此限制的更多详细信息,请参阅 React Native 文档

这使得编写在 Web 和原生平台都能运行的跨平台代码变得困难。以下是我们在 Skype 应用程序中解决此问题的方法。

AppAssets 模块

我们创建了一个“AppAssets”接口,其中包括应用程序中每个资源的访问器方法。

// File: AppAssets.d.ts

declare module 'AppAssets' {
    interface IAppAssets {
        appLogoSmall: string;
        appLogoLarge: string;
        notificationIcon: string;
        // ... etc.
    }
    const Assets: IAppAssets;
}

然后我们为 Web 和原生平台都实现了这个接口。

// File: AppAssetsWeb.ts

import AppAssets = require('AppAssets');
import AppConfig = require('./AppConfig');

class AppAssetsImpl implements AppAssets.IAppAssets {
    appLogoSmall = AppConfig.getImagePath('skypeLogoSmall.png');
    appLogoLarge = AppConfig.getImagePath('skypeLogoLarge.png');
    notificationIcon = AppConfig.getImagePath('notificationIcon.gif');
    // ... etc.
}

export const Assets: AppAssets.IAppAssets = new AppAssetsImpl();
// File: AppAssetsNative.ts

import AppAssets = require('AppAssets');

class AppAssetsImpl implements IAppAssets.Collection {
    get appLogoSmall() { return require('..images/skypeLogoSmall.png'); }
    get appLogoLarge() { return require('..images/skypeLogoLarge.png'); }
    get notificationIcon() { return require('../images/notificationIcon.gif'); }
    // ... etc.
}

export const Assets: AppAssets.IAppAssets = new AppAssetsImpl();

上述代码中有几点值得注意。首先,我们使用接口来确保 Web 和原生实现保持同步。如果您忘记将资源添加到这两个文件,TypeScript 编译器将在构建时检测到错误。

其次,Web 实现正在使用辅助方法 getImagePath 来构造完整的 URL。它使用动态可配置的域名构建此 URL,允许我们将应用程序部署到测试 Web 服务器或发布到生产服务器。

第三,原生实现使用了访问器。这会将资源的加载延迟到首次访问时。如果没有这个技巧,所有资源都会在 AppAssetsNative 模块初始化时加载,从而增加应用程序启动时间。

现在我们可以以跨平台的方式引用资源。

import AppAssets = require('AppAssets');

<RX.Image source={ AppAssets.Assets.appLogoSmall }/>

别名

现在我们有了两种实现(一种用于 Web,另一种用于原生),我们如何根据我们正在构建的平台“链接”正确的版本呢?我们通过在构建过程中进行轻量级的“别名”步骤来完成此操作。此步骤将 require('AppAssets') 替换为 require('./ts/AppAssetsWeb')require('./ts/AppAssetsNative'),具体取决于正在构建的平台。

我将提供 gulp 语法的示例,但相同的技术可用于 grunt 或其他任务脚本运行时。

var config = {
    aliasify: {
        src: './.temp/' + argv.platform,
        dest: getBuildPath() + 'js/',
        aliases: (argv.platform === 'web') ?
        // Web Aliases
        {
            'AppAssets': './ts/AppAssetsWeb'
        } :
        // Native Aliases
        {
            'AppAssets': './ts/AppAssetsNative'
        }
    }
}

function aliasify(aliases) {
    var reqPattern = new RegExp(/require\(['"]([^'"]+)['"]\)/g);

    // For all files in the stream, apply the replacement.
    return eventStream.map(function(file, done) {
        if (!file.isNull()) {
            var fileContent = file.contents.toString();
            if (reqPattern.test(fileContent)) {
                file.contents = new Buffer(fileContent.replace(reqPattern, function(req, oldPath) {
                    if (!aliases[oldPath]) {
                        return req;
                    }

                    return "require('" + aliases[oldPath] + "')";
                }));
            }
        }

        done(null, file);
    });
}

gulp.task('apply-aliases', function() {
    return gulp.src(path.join(config.aliasify.src, '**/*.js'))
        .pipe(aliasify(config.aliasify.aliases))
        .pipe(gulp.dest(config.aliasify.dest))
        .on('error', handleError);
});

// Here's our full build task pipeline. I haven't provided the task
// definitions for all of these stages, but you can see where the
// 'apply-aliases' task fits into the pipeline.
gulp.task('run', function(callback) {
    runSequence('clean', 'build', 'apply-aliases', 'watch', 'lint', callback);
});

性能调优

2017 年 5 月 24 日,作者:Eric Traut


性能调优是任何应用程序开发工作的重要组成部分。在本文中,我将讨论我们用于识别和解决基于 ReactXP 的 Skype 应用程序中的性能瓶颈的一些工具和技术。

跨平台代码库的好处之一是许多性能改进会使所有平台受益。

测量与分析

有人说,你无法改进你无法衡量的事物。这对于性能调优尤其如此。我们使用各种工具来确定哪些代码路径对性能至关重要。

无论使用哪种分析工具,您都可能希望在测量性能时使用应用程序的生产版本。React JavaScript 代码在“开发模式”下执行时会执行许多昂贵的运行时检查,这会显著扭曲您的测量结果。

Chrome 性能工具

Chrome 浏览器提供了出色的跟踪和可视化工具。打开开发者工具窗口,单击“性能”选项卡,然后单击“记录”按钮。完成记录后,Chrome 将显示一个带有调用层次结构的详细时间轴。放大和缩小以确定时间都花在了哪里。

Systrace

React Native 提供了一种启用和禁用 Systrace 的方法,Systrace 是一种方法级跟踪记录工具。它记录原生和 JavaScript 方法,因此可以很好地概述整个应用程序中发生的事情。要使用 Systrace,请构建并部署开发版本到您的设备。摇晃设备以显示开发人员菜单(如果您在 iOS 模拟器中运行,请按 command-D)。选择“Start Systrace”(启动 Systrace),然后执行您要测量的操作。当您停止 Systrace 时,将创建一个 HTML 跟踪文件。您可以在 Chrome 中可视化和与此跟踪进行交互。最新版本的 Chrome 弃用了 Systrace 代码中使用的功能,因此您需要按如下方式进行编辑。只需将以下行添加到生成的 HTML 文件的 head 部分。

<script src="https://rawgit.com/MaxArt2501/object-observe/master/dist/object-observe.min.js"></script>

控制台日志记录

原始的控制台日志记录通常是衡量性能的有效方法。日志条目可以以毫秒级分辨率的时间戳发出。只需调用 Date.now() 即可获取当前时间。性能关键操作(例如应用程序启动)的持续时间也可以计算并输出到日志中。

插桩

一旦您的应用程序大规模部署,监控关键操作的性能就非常重要。为此,我们记录发送到服务器并聚合所有用户数据的插桩。然后我们能够按时间、按平台、按设备类型等可视化数据。

跨越桥接

React Native 应用程序包含两个独立的执行环境——JavaScript 和原生。这些环境相对独立。它们各自在独立的线程上运行并访问自己的数据。两个环境之间的所有通信都通过 React Native“桥接”进行。您可以将桥接视为双向消息队列。消息按它们在每个队列中放置的顺序进行处理。

数据以序列化形式传递——JSON 格式的 UTF16 文本。所有 I/O 都发生在原生环境中。这意味着 JavaScript 代码发起的任何存储或网络请求都必须通过桥接,然后将结果数据序列化并通过桥接以相反方向发送回来。这对于小块数据来说效果很好,但一旦数据大小或消息数量增加,成本就会很高。

缓解此瓶颈的一种方法是避免通过桥接传递大量数据。如果它不需要在 JavaScript 环境中进行处理,则将其留在原生端。它可以在 JavaScript 代码中表示为“句柄”。这就是我们处理所有图像、声音和复杂动画定义的方式。

协作式多任务处理

JavaScript 在单个线程上运行。如果您的应用程序的 JavaScript 代码运行时间很长,它会阻塞事件处理程序、消息处理程序等的执行,并且应用程序会感觉无响应。如果您需要执行长时间运行的操作,您有以下几个选项:

  1. 将其实现为原生模块并在单独的线程上运行(仅适用于 React Native)。
  2. 将操作分解成更小的块,并将其作为链式任务执行。
  3. 只计算当前所需结果的一部分。

虚拟化

在处理用户界面中出现的长数据列表时,使用某种形式的虚拟化非常重要。虚拟化视图只渲染可见内容。当用户滚动列表时,新披露的项会被渲染。我们查看了所有可用的虚拟化视图,但我们没有找到任何能够同时提供我们所需的速度和灵活性的视图,所以我们最终编写了自己的实现。我们的 VirtualListView 经历了六个主要迭代,才最终确定了我们满意的设计和实现。

启动您的应用

应用程序启动时间可能是 React Native 应用程序面临的最大性能挑战。在较慢的 Android 设备上尤其如此。我们仍在努力减少此类设备的启动时间。以下是我们在此过程中学到的一些技巧。

延迟模块初始化

在 TypeScript 或 JavaScript 代码中,在每个模块的顶部包含一堆 import 语句是很常见的做法。例如,这是您在 hello-world 示例的 App.tsx 文件顶部会发现的内容。

import RX = require('reactxp');
import MainPanel = require('./MainPanel');
import SecondPanel = require('./SecondPanel');

这些“require”调用中的每一个都会在首次遇到时初始化指定的模块。然后缓存对该模块的引用,因此后续对同一模块的“require”调用几乎是免费的。在启动时,第一个模块需要几个其他模块,每个模块又需要几个其他模块,依此类推。这会一直持续到整个模块依赖树都被初始化。所有这些都发生在您的第一个模块的第一行执行之前。随着应用程序中模块数量的增加,初始化时间也会增加。

解决此问题的方法是通过延迟初始化。为什么要在启动时为一些很少使用的 UI 面板初始化模块而付出代价呢?只需将其初始化延迟到需要时即可。为此,我们使用 Facebook 创建的 babel 插件,名为 inline-requires。只需下载脚本并创建一个看起来像这样的 “.babelrc” 文件:

{
   "presets": ["react-native"],
   "plugins": ["./build/inline-requires.js"]
}

这个脚本做什么?它消除了模块顶部的 require 调用。每当文件内部使用导入的变量时,它都会插入一个 require 调用。这意味着所有模块都会在首次使用之前立即初始化,而不是在应用程序启动时。对于大型应用程序,这可以在较慢的设备上将应用程序的启动时间缩短几秒钟。

最小化

对于生产版本,对 JavaScript 进行“最小化”非常重要。此过程消除多余的空白并尽可能缩短变量和方法名称。它减少了磁盘和内存中 JavaScript 包的大小,并加快了代码的解析速度。

原生模块初始化

React Native 包含许多内置的“原生模块”。这些模块提供了您可以从 JavaScript 调用功能。许多应用程序不会使用所有默认的原生模块。每个原生模块都会增加数十毫秒的应用程序初始化时间,因此初始化应用程序不会使用的原生模块是浪费的。在 Android 上,您可以通过创建 MainReactPackage 的子类来消除此开销,该子类特定于您的应用程序。将 createViewManagers() 方法复制到您的子类中,并注释掉您不使用的视图管理器。然后更改您应用程序的 ReactInstanceHost 类中的 getPackages() 方法以实例化您的自定义类,而不是正常的 MainReactPackage。此技术可以在慢速 Android 设备上将启动时间缩短 100 毫秒或更多。

额外资源

有关性能调优的其他提示,请参阅 Facebook 的 React Native 文档网站上的性能页面。此博客也包含有用的提示。

使用 ReactXP 构建 Skype

2017 年 4 月 27 日,作者:Eric Traut


ReactXP 由微软的 Skype 团队开发,旨在提高开发敏捷性和效率。在本文中,我将更多地讨论新 Skype 应用程序的架构。

Skype application architecture

使用 ReSub 实现存储

我们最初尝试使用 Flux,这是 Facebook 工程师创建的一种架构模式。我们喜欢它的一些特性,但我们发现它很麻烦,因为它要求我们实现一堆辅助类(调度器、操作、操作创建器)。在更复杂的组件中,状态管理也变得难以管理。由于这些原因,我们开发了一种新的机制,我们称之为 ReSub,是“React Subscriptions”的缩写。ReSub 在组件和存储之间提供了粗粒度的数据绑定,并自动化了订阅和取消订阅的过程。更多详细信息和示例代码可以在 ReSub github 网站上找到。

应用程序中的某些存储是单例对象,并在启动时分配——甚至可能填充。其他存储则按需分配,并且具有明确的生命周期,与用户交互或模式相对应。

本地缓存数据

存储负责维护内存中的数据表示。我们还需要以结构化的方式持久化数据。在本地存储数据允许应用程序在“离线”模式下运行。它还可以实现快速启动,因为我们无需等待数据通过网络下载。

对于本地存储,我们开发了一个跨平台 No-SQL 数据库抽象。它使用每个平台的原生数据库实现(iOS 的 sqlite,某些浏览器的 indexDB 等)。该抽象允许我们创建和查询多个表。每个表都可以有多个索引,包括复合(多键)索引。它还支持事务和字符串索引以进行全文搜索。

服务和启动管理

后台任务,例如获取新消息,由我们称为“服务”的模块处理。这些是应用程序启动时实例化的单例对象。一些服务负责更新存储并将信息保存到本地数据库。其他服务负责侦听一个或多个其他存储并从这些存储合成信息(例如,为需要用户立即关注的传入消息生成的通知)。

在某些情况下,服务与特定存储的操作紧密耦合,以至于我们将它们的功能合并到一个模块中。例如,我们创建了一个 ConfigurationStore 来跟踪应用程序级别的配置设置(例如,为特定用户启用了哪些功能)。我们本可以实现一个相应的 ConfigurationService 来获取配置更新,但出于实用性考虑,我们选择在 ConfigurationStore 中实现此功能。

在启动时,应用程序需要实例化所有单例存储和服务,其中一些存储和服务具有对其他存储和服务的依赖。为了促进此启动过程,我们创建了一个启动管理器。每个希望启动的存储或服务都必须实现一个名为“IStartupable”的接口,该接口包含一个返回 promise 的“startup”方法。模块向启动管理器注册自己,并指定它们依赖于哪些其他模块(如果有)。这允许启动管理器并行运行启动例程。一旦启动 promise 得到解决,它就会解除任何依赖模块的启动阻塞。这会一直持续到所有注册模块都已启动。

这是一个启动例程,它用数据库中的数据填充其存储。请注意,启动例程返回一个 promise,该 promise 直到异步数据库访问完成后才会被解决。

startup(): SyncTasks.Promise<void> {
    return ClientDatabase.getRecentConversations().then(conversations => {
        this._conversations = conversations;
    });
}

与外部世界的沟通

Skype 构建在十几个不同的 Azure 微服务之上。例如,一个微服务处理消息传递,另一个处理照片和视频的存储和检索,还有一个提供表情符号包的动态更新。每个微服务通过一个简单的 REST API 暴露其功能。对于每个服务,我们都实现了一个 REST 客户端,它将 API 暴露给应用程序的其余部分。每个 REST 客户端都是 Simple REST Client 的子类,它处理重试逻辑、身份验证和 HTTP 头值的设置。

响应式行为

Skype 应用程序可在从手机到具有大屏幕的台式 PC 等各种设备上运行。它能够在运行时适应屏幕尺寸(和方向)的变化。这主要由视图层次结构上层的组件负责,这些组件根据可用的屏幕宽度改变其行为。它们订阅了一个我们称为“ResponsiveWidthStore”的存储。尽管有这个名称,但此存储还跟踪屏幕(或窗口)高度和设备方向(横向与纵向)。

与大多数响应式网站一样,我们定义了几个“断点”宽度。在我们的例子中,我们选择了三个这样的断点,这意味着我们的应用程序在四种不同的响应式“模式”之一中工作。

Responsive breakpoints

在最窄的模式下,应用程序使用“堆栈导航”模式,其中 UI 面板一个堆叠在另一个上面。这是手机的典型导航模式。对于更宽的模式,应用程序使用“复合导航”模式,其中面板彼此相邻定位,从而更好地利用扩展的屏幕空间。

该应用程序通过使用 NavigationStateStore 协调导航更改。组件可以订阅此存储以确定应用程序当前处于“堆栈导航”模式还是“复合导航”模式。当处于堆栈导航模式时,此存储记录堆栈的内容。当处于复合导航模式时,它记录当前显示哪些面板和子面板(在某些情况下,它们处于哪种模式)。这通过 NavigationContext 对象进行跟踪。视图层次结构中响应导航更改的部分都有一个相应的 NavigationContext。一些上下文引用其他子上下文,反映了 UI 的分层性质。当用户执行导致导航更改的操作时,NavigationAction 模块负责更新 NavigationContext 并将其写回 NavigationStateStore。这反过来又导致 UI 更新。

这是一些演示典型流程的代码。我们从按钮中的事件处理程序开始。

private _onClickConversationButton() {
    // Navigate to the conversation.
    NavigationActions.navigateToConversation(this.props.conversationId);
}

然后 NavigationActions 模块更新当前导航上下文。它需要处理堆栈和复合情况。

navigateToConversation(conversationId: string) {
    let convContext = this.createConversationNavContext(conversationId);

    if (NavigationStateStore.isUsingStackNav()) {
        NavigationStateStore.pushNewStackContext(convContext);
    } else {
        NavigationStateStore.updateRightPanel(convContext);
    }
}

这会导致 NavigationStateStore 更新其内部状态并触发更改,从而通知所有订阅者。

pushNewStackContext(context: NavigationContext) {
    this._navStack.push(context);

    // Tell subscribers that the nav context changed.
    this.trigger();
}

NavigationStateStore 的主要订阅者是一个名为 RootNavigationView 的组件。它负责渲染 RootStackNavigationView 或 RootCompositeNavigationView。

protected _buildState(/* params omitted */): RootNavigationViewState {
    return {
        isStackNav: NavigationStateStore.isUsingStackNav(),
        compositeNavContext: NavigationStateStore.getCompositeNavContext(),
        stackNavContext: NavigationStateStore.getStackNavContext()
    };
}

render() {
    if (this.state.isStackNav) {
        return (
            <RootStackNavigationView navContext={ this.state.stackNavContext } />
        );
    } else {
        return (
            <RootCompositeNavigationView navContext={ this.state.compositeNavContext } />
        );
    }
}

ReactXP 简介

2017 年 4 月 6 日,作者:Eric Traut


微软的 Skype 团队很高兴地宣布,我们将开源 ReactXP,这是一个我们为跨平台开发而开发的库。它建立在 React JS 和 React Native 之上,允许您使用单个代码库创建跨 Web 和原生应用程序。

ReactXP 的历史

Skype 运行在许多平台——台式机、笔记本电脑、手机、平板电脑、浏览器,甚至电视和汽车。历史上,每个 Skype 客户端的 UI 都是用每个平台的“原生”语言从头编写的(iOS 上用 Objective C,Android 上用 Java,Web 上用 HTML 和 JavaScript 等)。大约一年前,我们开始重新构想 Skype。我们决定需要对客户端开发采取一种全新的方法——一种能够最大限度地提高我们的工程效率和敏捷性的方法。我们希望摆脱在不同代码库中多次实现每个新功能。我们希望最大限度地减少重复工作。我们探索了许多可用的选项。像 Cordova (PhoneGap) 这样的 Web 包装器无法提供我们想要的性能或“原生感觉”。Xamarin 是一个出色的跨平台移动开发解决方案,但它对 Web 方面没有帮助。我们最终决定在 React JS 和 React Native 之上构建我们的新客户端。ReactXP 是我们尝试统一 React JS 和各种 React Native 实现的行为和接口。(我们最初将其称为 ReactX,因此在源代码中引用了此术语。)

Skype 团队还对 React Native 代码库做出了许多贡献,以修复错误、提高性能并消除 React JS 和 React Native 之间的行为差异。最大的贡献是对 React Native 布局引擎的重大重构。原始实现松散地遵循 W3C flexbox 标准,但在某些重要方面与标准不同。更新后的布局引擎现在可以可靠地生成与所有符合标准的 Web 浏览器相同的布局。

ReactXP 设计理念

ReactXP 的设计宗旨是成为一个轻薄的跨平台抽象层,位于 React 和 React Native 之上。它实现了十几个基础组件,可用于构建更复杂的组件。它还实现了一组大多数应用程序所需的 API 命名空间。

ReactXP 目前支持以下平台:web(React JS)、iOS(React Native)、Android(React Native)和 Windows UWP(React Native)。Windows UWP 仍在开发中,某些组件和 API 尚未完成。

ReactXP “核心”只包含通用功能。更专业的跨平台功能可以以 ReactXP 扩展的形式提供。Skype 团队已经开发了大约二十个这样的扩展,我们计划随着时间的推移将其中一些开源。扩展允许我们扩展 ReactXP,而不会增加其占用空间或复杂性。

当我们决定在 ReactXP 中公开哪些属性和样式属性时,我们尝试坚持那些可以在所有支持平台上统一实现的功能。例如,我们不公开 React Native 中不支持的任何 HTML 特定属性或 CSS 特定属性。在少数情况下,我们决定公开选定的平台特定属性或样式属性,并将其记录为在其他平台上“无操作”,但这只有在我们找不到其他可行的变通方法时才这样做。

ReactXP 的未来

Skype 团队将继续维护和发展 ReactXP。微软内部的其他团队也开始使用它并做出贡献。今天,我们将其开放给更广泛的开源社区。我们希望其他人会觉得它有用,我们欢迎反馈和贡献。

我们计划大约每月发布一个新版本的 ReactXP,大致与 React Native 的发布保持一致。