協程在RN中的實踐

juejin周輝發表於2018-05-29

首發於我的部落格 轉載需留出處 FaiChou 2018-05-29

協程在RN中的實踐

Demo地址 vs 不使用Coroutine的控制元件地址

本篇並不是 ScrollView 的新輪子, 而是對比兩種實現方式的差別, 來認識coroutine.

要實現的是一個對 RN 中 ScrollView 的封裝, 給它新增一個隱藏的 Header, 具有下拉重新整理功能.

假設你已經對 js 的 Iterators and generators有所瞭解.

什麼是 Coroutine

function* idMaker() {
  let index = 0;
  while(true)
    yield index++;
}
let gen = idMaker();
console.log(gen.next().value); // 0
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
複製程式碼

這是官網 generator 的栗子, yield 作為一個類似 return 的語法返回id, 下次呼叫 next() 時候, 繼續上次位置 -> 迴圈 -> 繼續返回新id.

The next() method also accepts a value which can be used to modify the internal state of the generator. A value passed to next() will be treated as the result of the last yield expression that paused the generator.

yield 還可以捕獲 next(x) 傳的引數, 所以可以根據傳的不同引數, yield 代理轉接不同的方法.

再舉個新的栗子.

function* logTest(x) {
  console.log('hello, in logTest!');
  while (true) {
    console.log('received:', yield);
  }
}
let gen = logTest();
gen.next(); // hello, in logTest!
gen.next(1); // received: 1
gen.next('b'); // received: b
gen.next({a: 1}); // received: {a: 1}
複製程式碼

這個方法中, 獲取了 next 的引數, 呼叫 gen.next(1) 直接輸出了結果.

如何自動執行 generator , 而不是手動呼叫 next() 呢? 使用 coroutine:

function coroutine(f) {
    var o = f(); // instantiate the coroutine
    o.next(); // execute until the first yield
    return function(x) {
        o.next(x);
    }
}
複製程式碼

這樣可以給 logTest 裝備上 coroutine:

let coLogTest = coroutine(logTest); // hello, in logTest!
coLogTest('abc'); // received: abc
coLogTest(2); // received: 2
複製程式碼

再看個簡單栗子吧:

let loginState = false;
function* loginStateSwitcher() {
    while (true) {
        yield;
        loginState = true;
        console.log('Login!');
        yield;
        loginState = false;
        console.log('Logout!');
    }
}

let switcher = coroutine(loginStateSwitcher);
switcher(); // Login!
switcher(); // Logout!
switcher(); // Login!
複製程式碼

直接一個 switcher() 使用者登入登出便捷明瞭.

ScrollView 下拉重新整理的邏輯

效果圖

可以大致看下沒有使用 coroutine 的處理方式:

  1. 放一個 RefreshHeaderScrollView 的頭上
  2. 繫結 onScrollBeginDrag, onScroll, onScrollEndDrag 方法
  3. 使用者開始拖拽 scrollview, 記錄 _dragFlag = true_offsetY
  4. 使用者拖拽過程中
    • 判斷是否為使用者手動觸發的 onScroll
    • 判斷此時是否正在重新整理
    • 拖拽高度大於觸發高度, 設定 this.state,refreshStatusreleaseToRefresh
    • 拖拽高度小於出發高度, 設定 this.state,refreshStatuspullToRefresh
  5. 使用者釋放手指
    • 設定標誌位 _dragFlag = false 和記錄 _offsetY
    • 如果沒在重新整理, 並且剛才的狀態為 releaseToRefresh, 去重新整理, 設定 _isRefreshing = true 並且 this.state,refreshStatus 設定為 refreshing, 呼叫 props.onRefresh() 方法, scrollView 滾動到保持重新整理狀態位置 { x: 0, y: -80 }
    • props 裡的 onRefresh(onEndRefresh), 需要將結束重新整理的方法回撥給使用者
    • onRefreshEnd 方法裡將 _isRefreshing 設為 false, this.state,refreshStatus 設為 pullToRefresh, scrollView 滾動到初始位置 { x: 0, y: 0}

可以去看下程式碼, 幾乎所有拖拽釋放邏輯分散到 onScrollBeginDrag, onScroll, onScrollEndDrag 方法中了, 如果這幾個方法要共享狀態就需要申請幾個臨時變數, 比如 _offsetY, _isRefreshing, 和 _dragFlag.

使用 coroutine 統籌管理

    this.loop = coroutine(function* () {
      let e = {};
      while (e = yield) {
        if (
          e.type === RefreshActionType.drag
          && that.state.refreshStatus !== RefreshStatus.refreshing
        ) {
          while (e = yield) {
            if (e.type === RefreshActionType.scroll) {
              if (e.offsetY <= -REFRESH_VIEW_HEIGHT) {
                that.changeRefreshStateTo(RefreshStatus.releaseToRefresh);
              } else {
                that.changeRefreshStateTo(RefreshStatus.pullToRefresh);
              }
            } else if (e.type === RefreshActionType.release) {
              if (e.offsetY <= -REFRESH_VIEW_HEIGHT) {
                that.changeRefreshStateTo(RefreshStatus.refreshing);
                that.scrollToRefreshing();
                that.props.onRefresh(() => {
                  // in case the refreshing state not change
                  setTimeout(that.onRefreshEnd, 500);
                });
              } else {
                that.scrollToNormal();
              }
              break;
            }
          }
        }
      }
    });
複製程式碼

只需要在相應的事件時候呼叫 this.loop 即可.

  onScroll = (event) => {
    const { y } = event.nativeEvent.contentOffset;
    this.loop({ type: RefreshActionType.scroll, offsetY: y });
  }

  onScrollBeginDrag = (event) => {
    this.loop({ type: RefreshActionType.drag });
  }

  onScrollEndDrag = (event) => {
    const { y } = event.nativeEvent.contentOffset;
    this.loop({ type: RefreshActionType.release, offsetY: y });
  }
複製程式碼

協程方法接受引數 {type: drag, offsetY: 0}, 用來根據當時拖拽事件和位置處理相應邏輯.

可以看到協程方法裡有兩個 while (e = yield):

while (e = yield) {
  if (
    e.type === RefreshActionType.drag
    && that.state.refreshStatus !== RefreshStatus.refreshing) {
    // ..
}
複製程式碼

第一個配合 if, 可以限制使用者只有當第一次拖拽開始時候來開啟下一步.

 while (e = yield) {
   if (e.type === RefreshActionType.scroll) {}
   else if (e.type === RefreshActionType.release) {}
}
複製程式碼

第二個用來處理滑動過程中和釋放的事件, 這裡可以肯定使用者是進行了拖拽才有的事件, 於是就免去了 _dragFlag 臨時變數.

當事件為 RefreshActionType.scroll, 再根據 offsetY 呼叫 changeRefreshStateTo() 設定當前重新整理的狀態為 releaseToRefresh 還是 pullToRefresh.

當事件為 RefreshActionType.release, 判斷 offsetY, 如果超過觸發重新整理位置, 呼叫 changeRefreshStateTo() 設定當前重新整理狀態為 refreshing, 將 scrollview 固定到重新整理狀態的位置(否則會自動滑上去), 並且呼叫 props.onRefresh(); 如果不超過觸發重新整理位置, 則將 scrollView 滑動到初始位置(隱藏header). break 退出當前 while 迴圈, 繼續等待下次 drag 事件到來.

<Header /> 會根據當前狀態展示不同文字, 提示使用者繼續下拉重新整理,釋放重新整理和重新整理中, 根據重新整理狀態設定下尖頭,上箭頭還是 Loading.

PS.

setState() as a request rather than an immediate command to update the component. For better perceived performance, React may delay it, and then update several components in a single pass. React does not guarantee that the state changes are applied immediately.

一直是下拉狀態的issue, 是由於setState不會立即觸發改變狀態導致的, 為解決這個問題, 我的處理方式是加一個半秒的延遲:

that.props.onRefresh(() => {
  // in case the refreshing state not change
  setTimeout(that.onRefreshEnd, 500);
});
複製程式碼

使用 coroutine 的優點

  1. 邏輯清晰
  2. 減少不必要的變數

如果發現其他優點, 歡迎留言.

其他使用場景

照片檢視器

如果還有見過其他使用場景, 歡迎留言.

參考連結

相關文章