Doric 在比心核心页面的落地

avatar
@比心

一、写在开始前

在比心 App 内,Doric 已经被广泛应用,为业务带来了许多收益。我们深切感受到动态化带来的便利,但一直在高曝光场景中缺乏完整的使用和验证机会。随着电竞指导业务的上线,比心需要开发一个全新的首页,该首页要求能够快速发版、快速验证,并且合规支持,以减少发版频次,以应对不断增长的业务需求。在之前的双端开发中,我们面临了以下三个痛点:

(1)双端不一致:在一些边界情况下,Android 和 iOS 的实现效果存在差异,如颜色的深浅、圆角大小、图片缩放等方面。这导致了用户在不同平台上的视觉体验不一致。

(2)不支持动态更新:当遇到合规问题或严重的 bug 时,需要重新发版来解决。这限制了我们快速响应和修复问题的能力。

(3)原生迭代周期长:Doric 的出现逐渐弥补了 Android 和 iOS 开发技术栈带来的问题。通过一套代码在两个平台上线,我们可以理论上提升人效一倍,并支持更多的业务需求。这将加快业务迭代速度,缩短开发周期。

在这个背景下,我们希望通过采用 Doric 开发新的首页,能够解决以上痛点并满足业务需求。Doric 的特性和优势使我们能够快速进行版本迭代和验证,并且支持动态更新,从而更好地满足比心日益增长的业务需求。

二、 Doric在首页中的实践

1. 页面组件化(复杂页面 ≠ 复杂代码)

在首页中, 我们通过组件化的设计, 使用Functional Component以及部份原生ViewNode, 以TSX的方式写出简洁易读、易于维护的代码.

  1. Stack 布局包含下拉刷新(List)、Loding 态、错误态。通过状态管理来展示不同的 View。

  2. 主内容呈现使用一个 List 实现,多 Type 类型,在上面截图中,搜索+品类+金刚位+筛选器 是一个组件,大神技能卡片是一个组件,金刚位是一个组件。

  3. 在大神组件中,对于声音条,多边形头像,带动效的搜索框等控件,复用原生控件,使用 ViewNode 桥接。

金刚位的组件化

为了能够高度还原设计稿,我们需要实现金刚位(Quick Access)的排布,其中目前有两种样式,而未来可能会有四个、五个等更多的样式。为了满足这一需求,我们需要进行动态计算位置和大小的操作。

已知的条件包括屏幕的宽度、各个入口的宽高比以及它们之间的间距等信息。基于这些条件,我们可以动态计算出两个、三个或四个样式所需的高度,以及每个入口在布局中的 left 和 top 值。

通过这些计算,我们可以在一个 Stack(堆栈)组件中动态地陈列金刚位入口,以高度还原设计稿。这样的实现方式使得金刚位的排布灵活且能够适应未来更多样式的增加。

通过动态计算位置和大小,我们可以确保金刚位在不同屏幕尺寸上都能够呈现出准确的布局,从而提供一致的用户体验。

const EntranceItem = (props: { entranceItem: IndexEntranceItem, pageId: string, panelContext: BridgeContext }) => {
    const { entranceItem, pageId, panelContext } = props
    return (
        <Image
             ....
        />
    )
}

动态计算各入口的位置
    // 根据百分比,动态计算两个
    transformTwo(entranceItemList: IndexEntranceItem[]) {
        return entranceItemList.map((item, index) => ({
              ......
        }));
      }
    // 根据百分比,动态计算三个
      transformThree(entranceItemList: IndexEntranceItem[]) {
        return entranceItemList.map((item, index) => {
            .......
      }
展示金刚位
              {entranceItemList && entranceItemList.length == 2 ? (
                this.transformTwo(entranceItemList).map((item) => {
                  return (
                    <EntranceItem
                         ..........
                    />
                  );
                })
              ) : entranceItemList && entranceItemList.length == 3 ? (
                this.transformThree(entranceItemList).map((item) => {
                  return (
                    <EntranceItem
                     ...............
                    />
                  );
                })
              )

强大的FlexLayout

需求:黑色块固定右侧,并且与左侧内容保持间距;左侧文本长度不定,绿色块要紧挨文本。

这类需求使用常规的 HLayout 方式比较困难,可尝试使用 Flex 布局,代码如下:

     <FlexLayout
          layoutConfig={layoutConfig().mostWidth().justHeight()}
          height={50}
          flexConfig={{
            flexDirection: FlexDirection.ROW,
              justifyContent: Justify.SPACE_BETWEEN
          }}
          parent={rootView}>
          <FlexLayout
            flexConfig={{
              flex: 1,
                flexDirection: FlexDirection.ROW,
                  marginRight: 10,
            }}
            backgroundColor={Color.GRAY}>
            <Text
              layoutConfig={layoutConfig().fitWidth().justHeight()}
              text={"11111111111111111111"}
              flexConfig={{ flex: 1, marginRight: 10 }}
              textSize={16}
              fontStyle={"bold"}
              textColor={Color.BLACK}
              backgroundColor={Color.CYAN}
              height={50}
              />
        
            <HLayout
              flexConfig={{ flex: 0 }}
              layoutConfig={layoutConfig().just()}
              backgroundColor={Color.GREEN}
              width={50}
              height={50}
              />
          </FlexLayout>
          <HLayout
            flexConfig={{
              flex: 0,
            }}
            layoutConfig={layoutConfig().just()}
            backgroundColor={Color.BLACK.alpha(40)}
            width={50}
            height={50}
            />
        </FlexLayout>

2. 状态管理在首页中应用

1.为什么需要状态管理?
  • 状态管理的核心就是数据单一性和单向数据流。

  • 有效分离 UI 层和数据处理层。

  • 能够结构化数据。

2.为什么选择 mobx?
  • Mobx 是 Flux 实现的后起之秀,以简洁的 API 和 更少的概念,让 flux 使用起来更简单。

  • 相比 Redux 有mutation, action, dispatch 等概念. Mobx则更加简洁, 更符合对Store 增删改查的操作概念。

  • Mobx代码量少,与TS结合性更好,可以使用更少的代码实现更复杂的页面。

  • 使用好Mobx可以极大地降低页面的不必要的重绘次数。

3.Mobx 中一些常用的概念

(1)observable和autorun

import{observable, autorun} from "mobx";

@observable state: string = "";

autorun(() => {
  console.log(state);
});

state = "test";

可以看到控制台会输出 test。@observable 可以观测数字,字符串,数组,对象等类型,当观测到的数据发生改变时,如果变化的值处在 autorun 中,就会自动执行。

(2)computed

这个过程可以理解为对原始值不感兴趣,但是想通过一次 transform,并且对 transform 后的结果感兴趣,那么就可以使用 computed。举一个例子,有个可观测的字符,输出它长度是否大于 6.

import{observable, autorun} from "mobx";

@observable state: string = "";

const sizeOverSix = computed(() => state.length > 6);

autorun(() => {
  console.log(sizeOverSix);
});

state = "test";
state = "test|test";

依次输出 false、true。

4.mobx-doric

Mobx 本身和 Doric 之间没有直接关系,Mobx-Doric 将 Mobx 和 Doric 绑定,提供 HOC,高效且灵活。

(1)observer

采用了 Mobx autorun 机制来 combineView

5.首页状态管理实战

首页通过Mobx管理业务状态, 实现数据与视图的映射。 (1)创建 HomeState,在构造函数中使用 makeObservable 使自己可观测。

     constructor(context: BridgeContext) {
        makeObservable(this);
     }

(2)创建可观测变量

    @observable list: any[] = []; // 列表数据
    @observable pageState: HomeState = HomeState.init; //页面状态
    @observable end = true; // 分页状态
    .......

(3)数据请求,更新状态

     //请求数据
    const biggieModel = await getHomeBiggie(.........);
    // 数据组装
    const resultList = resultArr.concat(
              biggieModel.list.map((biggie) => ({
                ...biggie,
                cellType:
                  biggie.type === 1
                    ? HomeCellType.biggie
                    : biggie.type === 2
                    ? HomeCellType.banner
                    : undefined,
              }))
            );
    // 赋值
    this.list = [...resultList];

(4)状态监听

     <Observer<List>
              onChange={(list) => {
                list.itemCount = this.state.list.length;
                .......
              }}
            >
              <List
                backgroundColor={Color.TRANSPARENT}
                ref={this.listRef}
                layoutConfig={layoutConfig().most()}
                renderItem={(index) => {
                  const cellType = (this.state.list[index] as HomeBaseModel)
                    .cellType;
                   // 根据不同 type 返回不同的 cell    
                }}
                preloadItemCount={this.preLoadItemCount}
                loadMore={true}
                onLoadMore={() => {
                  this.state.loadMore();
                }}
                loadMoreView={this.loadMoreView()}
              />
            </Observer>

3. 原生基建助力Doric开发

曝光插件

曝光能力在比心 APP 里是一个通用的业务,原有 Android/iOS 就有一个曝光工具,在原有基础上包装一下,抽出一个通用的 doric 曝光插件,既做到核心逻辑统一收归,又能做到 doric 插拔。符合软件架构设计中的分层设计。

1.设计思路

Doric 长列表页面创建的时候,在插件内存中维护当前列表(RecycleView/TableView)和曝光实现类的映射关系,在页面销毁的时候,移除映射。

2.代码实现(以 Android 为例)

(1)创建插件

    @DoricPlugin(name = "bxExposure")
    public class DoricBxExposurePlugin extends DoricJavaPlugin

(2)创建内存映射数据结构

    private final Map<RecyclerView, DoricExposureSeedListener> listeners = new HashMap<>();

(3)建立内存映射

    @DoricMethod
        public void exposure(JSObject arguments, DoricPromise promise) {
               ..............
               listeners.put(recyclerView, exposureSeedListener);
               promise.resolve();
           } else {
               promise.reject(new JavaValue("Not Found ListView"));
           }
        }

(3) DoricExposureSeedListener 继承原有曝光的实现 ExposureSeedListener

    private static class DoricExposureSeedListener implements ExposureSeedListener.OnExposureSeedListener {
       
            public DoricExposureSeedListener(RecyclerView recyclerView,DoricPromise doricPromise) {
                 
            }

            @Override
            public void onExposureSeed(int position) {
            }

            @Override
            public boolean onUploadSeed(List<Integer> exposureList) { }

            public void reset(){  }
        }

(4)将曝光位置通过 DoricPromise 传给 Doric,Doric 层组装业务数据做曝光埋点

    @Override
    public void onExposureSeed(int position) {
    }

    bxExposure(this.context).expose(this.listRef!.current, (position) => {
      // 处理曝光
    })

(5)下拉刷新重置曝光

    @DoricMethod
        public void reset(JSObject arguments, DoricPromise promise) { }

活动悬浮窗

活动悬浮窗是为运营者提供消费券等活动入口,支持动态配置,可配置项包括:位置,大小,形态(图片/webview)等,并且这个悬浮窗是一个通用组件,在任意场景都可以使用。

三、性能指标及相关监控

1 加载链路耗时

  1. 页面加载时间监控,目前采用的是 Soraka 上报,MAT 呈现,钉钉群每日APM 通知,目前涉及的指标如下:
MonitorTypetypeNameMonitorSubTypesubTypeName备注
CUSTOMSkillHomeSTARTDrawStart页面开始渲染
CUSTOMSkillHomeSTEPLOAD_SUCCESSjs 加载成功
CUSTOMSkillHomeENDDrawEnd页面首次渲染成功
  1. MAT 呈现

  1. 钉钉群日报

2 GPU呈现模式

四、整体收益以及总结

1. 收益

  1. 人效提升:人效的提升,取决于业务复杂度,在简单交互的页面,人效提升在 50% 维护成本。

  2. 代码复用率:纯展示页面代码复用率在 100%,简单交互页面代码复用率保持在 70%左右。

  3. Doric 通用组件沉淀:(1)通用的 Doric 搜索组件 (2) 通用曝光插件 (3)通用的广告运营位 (4)svip 折扣价组件(5)通用的声音播放组件 (6)通用的斜边头像组件

  4. 热更:目前比心采用的是正常双周版本迭代流程+每周非版本迭代上线流程。Doric 页面既可以跟版迭代,也可以不跟版迭代,这样可以有效地减少流程的复杂度和降低QA的测试成本,而周迭代流程可以有效地利用 Doric 动态发版的灵活性。混合式开发和原生开发应尽量保持时间节点和已有流程的一致。这种设计的好处在于,一方面随着动态化的比例越来越高,版本迭代将可以无限拉长,另一方面从双周迭代逐渐演变成周迭代的切换成本也得到大幅的降低。

整体流程可以参考平台 Doric 热更需求的研发规范:

《Doric业务热更需求研发规范》

2. 最后的总结

随着业务的发展,工程复杂度在不断提升,代码也在持续变多,在没有外力的情况下,开发效率必然下降。如何在有限的资源下不断提升开发效率是一个永恒的话题,比心客户端通过借助 Doric,推动混合式架构来推动开发效率,提供稳定的可靠的服务。

Doric + 原生 混合开发对团队是一种挑战,Doric 虽然能做到跨端,但有时候仍然需要对特定平台有较深入了解,才能解决问题,这就要求团队有较好的双端基建沉淀。如果双端基建完成度较高,转向Doric开发就会变的非常轻松。


wxg.JPG