构建全新 Base App:大规模预取

Coinbase Wallet 进化为 Base App,通过行为预取系统,根据用户导航习惯和链接可见性,预测用户行为并提前加载数据,从而优化用户体验,减少了加载时间和闪烁,在高配置和低配置设备上都显著提升了性能指标 NTBT、TRT 和 ART。

背景:挑战

一年前,我们做出了一个大胆的决定:将 Coinbase Wallet 发展成 Base App,一个链上万能应用,将社交、交易、支付、应用发现和赚取整合到一个地方。我们的目标是构建一个能与最佳社交应用相媲美的消费级体验,同时优雅地处理链上挑战,如交易确认和实时区块链数据同步。但我们也知道,在速度和规模之间平衡安全性和质量至关重要。为了做到这一点,我们仔细考虑了如何处理加载状态。

我们从加载骨架(或 shimmer UI)开始。它们只有一个目的:在获取数据时保持用户的参与度。用户不会盯着空白屏幕或旋转的加载器,而是看到一个视觉占位符,模仿内容的最终布局。

但加载骨架最终只是一个优雅的 创可贴,我们开始着手做得更好。它们通过在用户等待时提供一些东西来掩盖缓慢的 API 或繁重的计算。虽然它们改善了整体用户体验,但它们并没有解决性能缓慢的根本原因。

在获取内容时,必须首先渲染骨架本身,然后在内容加载后渲染实际内容。这种双重渲染周期会影响性能,尤其是在性能较低的设备上。结果呢?额外的延迟可能会适得其反地使应用程序感觉更慢,尤其是在网络条件较差或资源受限的设备上。

也有人可能会说,重复、跳跃的灰色微光并不像我们想象的那么优雅。请看我们当前的个人资料加载时间线:

post image个人资料加载状态

这种模式解决了一个后端瓶颈:一次性从多个来源加载数据可能很慢。它引入了一个瀑布式的布局加载状态,同时屏幕的各个部分正在被获取。

然而,这样做是以大幅降低用户体验为代价的。

我们想做得更好。因此,我们在新的 Base App 中构建了一个行为预取系统,以预测用户行为并立即交付内容。

解决方案:行为驱动、数据驱动的预取

我们有关于不同链接和屏幕之间导航习惯的 数据。例如:Feed 上的用户更倾向于导航到 Cast 而不是 Profile

我们也知道链接何时 在视口中。我们可以用它来设计一个简单的预取算法:

score = conversionRate * visibility * manual priority

并开始相应地预取屏幕数据:

post image

原则上很简单,但有很多注意事项。

过度获取

任何给定的屏幕上都可能有数百个链接——那么我们如何缩小范围,只预取可见的项目呢?不幸的是,react-native 还没有 Intersection Observer API,所以我们提出了以下几种替代方案。

注意: 这些代码片段旨在用于教育/启发,直接复制粘贴是行不通的。

Measure & onLayout

使用 react 的 New architecture,我们可以利用 useLayoutEffect & measure method

function PrefetchOnVisible({ children, prefetchQuery }) {
  const ref = useRef<View>(null);
  const prefetchManager = PrefetchManager.getInstance();

  // Synchronous, first-frame measure
  // 同步,第一帧测量
  useLayoutEffect(() => {
    ref.current.measure((_x, _y, _width, _height, _pageX, pageY) => {

      // Within Viewport?
      // 在视口内?
      const isComponentVisible = pageY <= viewPortHeight && pageY + 100 >= 0;
      if (!isComponentVisible) return;

      prefetchManager.process(prefetchQuery);
    });
  }, [prefetchQuery]);

  return <View ref={ref}>{children}</View>;
}

对于旧版体系结构,我们可以回退到 onLayout 回调:

function PrefetchOnVisible({ children, prefetchQuery }) {
  const ref = useRef<View>(null);
  const prefetchManager = PrefetchManager.getInstance();

  // Asynchronous, next frame measure
  // 异步,下一帧测量
  const onLayout = useCallback(() => {
    ref.current.measure((_x, _y, _width, _height, _pageX, pageY) => {

      // Within Viewport?
      // 在视口内?
      const isComponentVisible = pageY <= viewPortHeight && pageY + 100 >= 0;
      if (!isComponentVisible) return;

      prefetchManager.process(prefetchQuery);
    });
  }, [prefetchQuery]);

  return (
    <View ref={ref} onLayout={onLayout}>
      {children}
    </View>
  );
}

然后相应地包装我们的链接:

function ProfileLink({ children, profileId }) {
  const navigateToProfile = useCallback(() => {
    navigate('Profile', { profileId });
  }, [profileId]);

  const profileQuery = useProfileScreenContentQuery(profileId);

  return (
    <PrefetchOnVisible prefetchQuery={profileQuery}>
      <Pressable onPress={navigateToProfile}>
        {children}
      </Pressable>
    </PrefetchOnVisible>
  );

如果屏幕渲染时 ProfileLink 在视口中,则将获取并缓存 ProfileQuery。导航到个人资料将立即完成。

但是,这仅适用于静态的、不可滚动的屏幕:useLayoutEffect 和 useLayout 仅在布局更改时触发。

Virtualized list: renderItem & onViewableItemsChanged

即使不是全部,大多数虚拟化列表(FlatListFlashList & LegendList)都有我们感兴趣的两种方法:

我们可以包装这些方法来跟踪项目何时在屏幕上可见:

让我们首先用一个 provider 包装 renderItem 函数,以共享项目的索引:

function usWrappedRenderItem({ renderItem, listId }) {

  // Wrap list items with a provider surfacing the index of the item
  // 用一个 provider 包装列表项,显示该项的索引
  return useCallback((args) => (
    <ListItemVisibilityProvider listItemVisibilityIndex={args.index} listId={listId}>
      {renderItem(args)}
    </ListItemVisibilityProvider>
  ), [renderItem]);
}

此函数中的所有子元素现在都可以访问其在列表中的索引和 listId:

const { index, listId } = useListItemVisibility()

接下来,让我们包装 onViewableItemsChanged 回调:

 function useVisibilityTracker({ onViewableItemsChanged, listId }) {

  // 1. Keep track of mounted list
  // 1. 跟踪已挂载的列表
  useEffect(
    function trackListVisibility() {
      visibilityTracker.registerList(listId);

      return visibilityTracker.removeList(listId);
    },
    [listId],
  );

  // 2 Update visible items when array of visible items changes
  // 2. 当可见项目数组更改时,更新可见项目
  const updateVisibleItems = useCallback((visibleIndexes: Set<number>) => {
    visibilityTracker.setVisibleItems(listId, visibleIndexes);
  }, [listId]);

  // 3. Debounce to avoid rapid-fire updates while scrolling
  // 3. 防抖以避免滚动时快速更新
  const debouncedUpdateVisibleItems = useDebouncedCallback(updateVisibleItems, 150);

  // 4. Return the wrapped onViewableItemsChanged callback
  // 4. 返回包装后的 onViewableItemsChanged 回调
  return useCallback((event: { viewableItems: ViewToken[]; changed: ViewToken[] }) => {

    // Call the original callback first
    // 首先调用原始回调
    onViewableItemsChanged?.(event);

    // Get visible indexes
    // 获取可见索引
    const { viewableItems } = event;
    const visibleIndexes = new Set(
      viewableItems.filter(({ isViewable }) => !!isViewable).map(({ index }) => index),
    );

    debouncedUpdateVisibleItems(visibleIndexes);
  }, [debouncedUpdateVisibleItems, onViewableItemsChanged]);
}

最后,在我们的列表中使用它:

const wrappedRenderItem = useWrappedRenderItem({
    renderItem,
    listId: 'MyList',
});

 const wrappedOnViewableItemsChanged = useVisibilityTracker({
    onViewableItemsChanged
    listId: 'MyList',
});

return (
  <FlashList
    renderItem={wrappedRenderItem}
    onViewableItemsChanged={wrappedOnViewableItemsChanged}
    ...

这样,我们就有效地为列表项提供了完整的可见性上下文,因此我们可以使用它来预取数据,如下所示:

function PrefetchListItemOnVisible({
  children,
  prefetchQuery,
}) {
  const visibilityTracker = ListVisibilityTracker.getInstance();

  // from wrappedRenderItem
  // 来自 wrappedRenderItem
  const { index, listId } = useListItemVisibility();

  // Subscribe on mount
  // 挂载时订阅
  useEffect(() => {
    visibilityTracker.subscribe('listId', (visibleIndexes) => {

      // Item index is not in the viewport, return
      // 项目索引不在视口中,返回
      if(!visibleIndexes.has(index)) return;

      // Item is in the viewport, prefetch !
      // 项目在视口中,预取!
      prefetchManager.process(prefetchQuery);
    });

    return () => visibilityTracker.unsubscribe();
  }, []);

  return children;
};

最困难的部分已经完成,接下来是我们有了滚动感知的视口上下文,并且可以相应地预取数据。

后端压力:

即使仅获取可见数据,这也可能意味着每个用户有数百个请求,乘以数十万用户。

现在是队列时间

设备上的队列为我们提供了灵活性和安全性,我们可以添加延迟、最大并发请求,甚至可以设置每分钟最大请求数:

post image

我们还可以根据设备类别设置这些队列选项:最新的 iPhone Pro 可以在几秒钟内预取数百个查询,但低端 Android 手机几乎会立即开始挣扎。

Killswitches

即使是最适度的队列设置和保守的可见性跟踪也无法完全避免过度获取:我们通过定义“触发器”(例如,链接到屏幕)和“目标”(例如,屏幕)来添加细粒度的 killswitches。

如果我们的个人资料后端看到来自预取的异常压力,我们可以禁用特定的预取流程,例如“搜索结果 → 个人资料”,或者完全禁用个人资料预取。

post image

现在,前端不会过度获取,并且我们已为后端采取了多种缓解措施。是时候解决房间里的大象了:DevX

🐘 DevX

我们有 20 多个类、组件、上下文、助手、Hook。我们如何使 实现 预取的过程变得容易?

解决方案: 用于预取目标和触发器的单个 API:

function createPrefetchableComponent( { query, prefetchTarget, options }, Component) {

  // Wraps "target" component
  // 包装“目标”组件
  const PrefetchableComponent = memo(function PrefetchableComponent({ variables }) {

    // Fetching & refresh methods
    // 获取和刷新方法
    const queryRef = useLazyLoadQuery(query, variables, options);
    const { refresh, isRefreshing } = useRefreshQuery(query, variables);

    const props = { queryRef, refresh, isRefreshing, variables };

    return <Component {...props} />
  });

  // Attach the Trigger component with the query & prefetchTarget
  // 将 Trigger 组件与查询和 prefetchTarget 附加
  PrefetchableComponent.TriggerComponent = memo(function TriggerComponent({ children }) {
    return <PrefetchObserver prefetchQuery={query} >{children}</PrefetchObserver>;
  });

  return PrefetchableComponent;
}

注意: 这是一个经过大量简化的代码片段,实际上它是一个 typescript fiesta。

然后使用它来包装我们的可预取组件:

const PrefetchableProfile = createPrefetchableComponent({
  query: profileQuery,
  prefetchTarget: 'profile'
}, function Profile({data, refresh}) {

   // ...

})

最后,在我们的触发器周围:

 function ProfileLink({ children, profileId }) {
  const navigateToProfile = useCallback(() => {
    navigate('Profile', { profileId });
  }, [profileId]);

  return (
    <PrefetchableProfile.TriggerComponent variables={{profileId}}>
      <Pressable onPress={navigateToProfile}>
        {children}
      </Pressable>
    </PrefetchableProfile.TriggerComponent>
  );

在顶部添加一些 AI 规则和上下文,实现预取就变得一次性完成。

结果

流畅的用户体验

以前,在选项卡之间导航会导致微光盛宴:

播放视频

启用预取后,导航和渲染是即时的:

播放视频

我们添加了调试工具,让我们“看到”预取在运行

播放视频post image

性能

我们通过在 Coinbase 引入的 统一评分系统 来衡量我们应用程序的性能:

  • NTBT:导航总阻塞时间

  • ART:首屏渲染时间

  • TRT:总渲染时间

这些指标共同衡量用户可以多快地交互、查看初始内容和查看完全加载的屏幕。

请参阅高端设备上的性能比较结果:

NTBT: -80-100%

数据在缓存中可用,未触发 suspense,导航是即时的

| Screen        | Before | After | Diff (%) |
|---------------|--------|-------|----------|
| Search        | 44     | 0     | -100%    |
| Transact      | 183    | 25    | -86.3%   |
| Notifications | 48     | 0     | -100%    |
| Wallet        | 126    | 38    | -69.8%   |
| Profile       | 229    | 0     | -100%    |

TRT & ART: -70-80%

我们完全跳过了加载步骤,屏幕几乎立即以其最终状态渲染。

| Screen        | Before | After | Diff (%) |
|---------------|--------|-------|----------|
| Search        | 283    | 33    | -88.3%   |
| Transact      | 765    | 141   | -81.6%   |
| Notifications | 447    | 127   | -71.6%   |
| Wallet        | 849    | 128   | -84.9%   |
| Profile       | 511    | 94    | -81.6%   |

我们在低端设备上看到了类似的结果

NTBT: -60%

数据在缓存中可用,但在低端设备上反序列化仍然很昂贵。

TRT & ART: -40%

渲染速度更快,但在低端设备上仍然很昂贵。


立即试用

下载 Base app 并亲自体验即时、无缝的导航 - 没有微光,无需等待,只有链上万能应用,就像它应该感受到的那样。


附录:渲染、技术栈 & RN 新架构

奇怪的是,即使启用了预取,一些屏幕仍然非常慢。怎么回事?

我们所有的调查都指向同一个结论:我们的堆栈正在老化。

在旧版模式下运行 React Native 0.77.3 意味着错过了错误修复和新功能。

新架构,其引人注目的新 Fabric UI 渲染引擎和无桥原生调用也应简化渲染。

但更重要的是,它阻止我们升级第三方库,如 FlashList V2、Reanimated V4 和许多其他已迁移到 New Architecture 的库。

从理论上讲,这只是在应用程序配置中切换一个标志的问题:

-newArchEnabled=true
+newArchEnabled=false

听起来很容易,对吧?

订阅 Base Engineering Blog,敬请关注有关新架构的更多信息。

  • 原文链接: blog.base.dev/base-app-p...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Base 中文
Base 中文
江湖只有他的大名,没有他的介绍。