Skip to content

如何在 react-native 與 react-native-web 上做 A/B testing

Posted on:May 2, 2021

近期工作上接到需做 A/B testing 的需求,而專案上是三平台共同開發的,也就是使用 RN 與 RN-Web,從未在 App 上做 A/B testing 的我來說,此需求是項滿有挑戰性的。

在一番研究後,在 App 上可以使用 Google 提供的 Firebase,Web 上可以使用一樣由 Google 提供的 Optimize,兩項工具在當前需求上是可以免試使用的。
而要如何讓 App 與 Web 吃同一套邏輯的 code 呢? 後面會詳細說明。

目前先介紹如何使用這兩套服務吧!

Firebase A/B testing

首先先來介紹 Firebase 如何實現 A/B testing,在 Firebase 上,是透過他提供的 remote config 服務,再搭配 A/B testing 服務來連結 remote config 進而進行分組,在開發上只要取得 remote config 的值即可。

這邊我們先到 Firebase 的 remote config (還未建立 Firebase 專案的,照著官方指示就能順利建立了)

之後直接新增參數,參數名隨意輸入就好。這邊我取為「experimentTest」,預設值設為 0 (稍後我們會給實驗組為 1,控制組為 0)。
新增完後直接發佈即可。

接下來點擊 “筆” 右側的三個點,選擇「A/B 版本測試」。

在第一步的地方,看讀者要不要改名稱與說明欄位。
在第二步的指定目標處,要注意「觸及率」,若要全部使用者都進到這個實驗中,就得拉到 100%。

第三步的目標就選擇一個此實驗勝出的目標指標吧。
第四步的變化版本,要記得將控制組的值設為 “0”,變化組的值設為 “1”。

預設分組比例是一半一半,可以點開「調整變化版本權重」來自訂比重。

之後按下審查後就可以開始此實驗了。
到這裡我們已經設定完 Firebase 的前置設定了,接下來是寫 code 的部分。

React Native Firebase

這邊我們使用 @react-native-firebase/remote-config 來接上剛設定的值,注意在裝 @react-native-firebase/remote-config 時也要一併把 @react-native-firebase/app 裝起來。
iOS 的 pod install 部份就不多贅述了。

引入以下三段 code:

import remoteConfig from "@react-native-firebase/remote-config";

export const fetchConfig = () => remoteConfig().fetchAndActivate();

export const getRemoteValue = (key: string) => remoteConfig().getValue(key);

我們透過 fetchAndActivate 來拉到 Firebase 上的資料,透過 getValue 來取得值。 這邊要注意的是 getValue 拿到的並不是個可以直接拿來使用或判斷的值,還需要再使用別的 method 來取得真正的值,詳細參考: https://github.com/invertase/react-native-firebase/blob/master/packages/remote-config/lib/index.d.ts#L180

接下來就可以使用下段 code 來取得在 remote config 上的值了:

fetchConfig().then(() => {
  const value = getRemoteValue("experimentTest").asString(); // "0" or "1"
});

若上述步驟都做正確,基本上你會拿到 0 或者 1 的字串值。

這邊我測試是否真的有分組效果,是一直刪除後再安裝來測試的,我知道挺蠢的,但懶得去查如何更新取得的值,讀者可以自行去研究看看。

App 的部分就先到這裡,接下來我們來看 Web 的實作:

Google Optimize

首先我們直接到 Optimize,建立好容器後進入,點擊右側的設定 (齒輪 icon),將最佳化代碼嵌進你的 code 中

接下來我們建立體驗,這邊我們選「A/B 版本測試」

接下來新增變化版本,但不用進入編輯,我們只需要他的分組功能就好,

之後設定完目標就可以開始此體驗了。開始後,我們滑到目標那,記錄這個實驗 ID,稍後會用到。

那麼要如何拿到分組資訊呢?
在 optimize 上,有個 callback 可以用,他會回傳當前用戶的分組,開發者就可以拿此分組來運用,詳細參考: https://support.google.com/optimize/answer/9059383?hl=en#zippy=%2Cin-this-article

以我的實驗來說,code 是長這樣:

function implementExperimentA(value) {
  if (value === "0") {
    // Provide code for visitors in the original.
  } else if (value === "1") {
    // Provide code for visitors in first variant.
  }
}

gtag("event", "optimize.callback", {
  name: "hfL_bpEAR2mDEqbAyyOPcw",
  callback: implementExperimentA,
});

name 就是填剛剛記錄的實驗 ID,你也可以不填,不過到時若有多個實驗,這個 callback 就會都觸發,是建議填一下比較好。
implementExperimentA 吃三個參數,分別為: 組別、實驗 ID、容器 ID。
組別為字串 0、1、2、3 …,以剛剛在 optimize 所新增的變化版本數量而定。
然後我們就可以在 implementExperimentA 中寫我們的邏輯囉。

Web 方面相對簡單很多,接下來會介紹如何封裝兩邊的 code,讓在使用時,只要 call 一隻 function 就可以拿到組別了。

整合 Firebase 與 Optimize

這邊我們先確認一下取得組別時,最大的問題是什麼? 是非同步,故我們需寫一個 promise,在使用時去呼叫,而在拿到 remote confing value 或者 optimize callback 回來時將 pending 轉為 fulfilled。

以上述,code 大概會長這樣:

export enum ExperimentGroup {
  CONTROL,
  VARIANT,
}

let _abTestingResolve: (
  group: ExperimentGroup | PromiseLike<ExperimentGroup>
) => void;
let _abTestingPromise = new Promise<ExperimentGroup>(resolve => {
  _abTestingResolve = resolve;
});

_abTestingResolve 為等等將 promise 從 pending 轉為 fulfilled 的手段。

接下來再將剛剛的 remote config 與 optimize 的 code 整在一起:

function handleSetExperimentGroup(value: string) {
  switch (value) {
    case "1": {
      return _abTestingResolve(ExperimentGroup.VARIANT);
    }
    case "0":
    default: {
      return _abTestingResolve(ExperimentGroup.CONTROL);
    }
  }
}

export function abTestingSetup() {
  if (Platform.OS === "web") {
    window.gtag("event", "optimize.callback", {
      name: "hfL_bpEAR2mDEqbAyyOPcw",
      callback: handleSetExperimentGroup,
    });
  } else {
    fetchConfig()
      .then(() => {
        const group = getRemoteValue("experimentTest").asString();

        handleSetExperimentGroup(group);
      })
      .catch(() => {
        _abTestingResolve(ExperimentGroup.CONTROL);
      });
  }
}

這邊需將 abTestingSetup export 出去,在需要的地方執行他,可能在 App 啟動的地方之類的。

可以注意到我們在拿到 remote config value 或者 optimize 觸發 callback 時,去執行 _abTestingResolve,並將組別放進去。

到這邊基本上都弄好了,不過還有個致命的問題,就是若 promise 一直處於 pending 狀態或者等太久怎辦? 例如使用者安裝 adblock 或者其他原因造成拿不到 remote config value 或 optimize 無法執行。
故我們需寫另一個 promise,在執行時去倒數 N 秒,N 秒過後強制分組。

function handleException() {
  return new Promise<ExperimentGroup>(resolve => {
    setTimeout(() => {
      resolve(ExperimentGroup.CONTROL);
    }, N);
  });
}

此時我們有兩個 promise 了,以我們要達成的目的,邏輯為: 取得組別,或者 N 秒後強制分組,這邊我們使用 Promise.race 來完成需求

export function getABTestingGroup() {
  return Promise.race([_abTestingPromise, handleException()]);
}

getAbTestingGroup 就是我們在使用時所呼叫的 function 了。

getAbTestingGroup().then((group: ExperimentGroup) => {
  if (group === ExperimentGroup.CONTROL) {
    // ...
  } else {
    // ...
  }
});

以上便是如何在 RN 與 RN-Web 上做到 A/B testing 的介紹了。

一開始在 Survey 階段時,是透過這篇來發現 Firebase 有提供 A/B testing 的解決辦法: A/B Testing in React Native Has Never Been So Easy: Firebase Is Here,那時還挺興奮的想說 web 也可以靠此方法來實現了!!
殊不知 web 雖有支援 remote config,但不支援 A/B testing…

在那悲劇的當下,主管提供 optimize 的方案參考,當時卡在要如何在 optimize call 到我的 source code,或者我要如何知道它的分組結果?
後來就找到 callback 那篇解決辦法,一切都往好的方向發展。

在判斷 adblock 的地方,我們是用 just-detect-adblock 這套,用起來滿簡單的。

寫下此篇,是希望哪天有人也在寫一套能支援 App 與 Web 的系統,也碰上 A/B testing 的問題時,能有個解決方案看~
也順便對自己的記錄。

若以上有哪裡寫錯,或有問題的,麻煩不吝提出,感謝!