RecyclerView
能有效處理大量 item 的顯示,其中一項特性是會將捲出螢幕外的 view 回收再利用,螢幕外的 item 並不會出現在 view hierarchy 裡,因此螢幕外的 item 不能用 onView()
來找,再加上 RecyclerView
並非 AdapterView
,所以 onData()
也不適用。
那要怎麼操作或驗證 RecyclerView
某個 item 的內容?而且那個 item 可能在螢幕之外…
要解決的問題
假設畫面的狀態如上面的示意圖,試想下面兩個問題:
- 如何取消 Item 011 的核取方塊?
- 如何檢查 Item 012 的核取方塊沒有勾選?
首先你會遇到如何把螢幕外的 item 捲進畫面的問題。所幸 RecyclerViewActions
提供的 action 都會自動處理掉捲動的問題,包括單純把某個 item 捲進畫面的 scrollTo*()
,以及對某個 item 進行操作的 actionOn*()
(操作之前,會自動將 item 捲進畫面),所以上面兩個問題看似都不難?
|
|
好像還差那麼一點,其中:
- 取消 Item 011 的核取方塊 -
actionOnItemAtPosition(10, click())
的寫法會按到整個 item (R.id.item
) 的中心點,而非核取方塊 (R.id.item_checkbox
)。 - 檢查 Item 012 的核取方塊沒有勾選 - 雖然 Item 12 會因為
scrollToPosition(11)
而捲進畫面,但check(matches(isNotChecked()))
檢查的是onView(withId(R.id.recycler))
所表示的整個RecyclerView
,而非 Item 12 下的核取方塊。
問題在於,目前 RecyclerViewActions
只能操作 item 本身,不能操作 item 內部的組成,而在檢查 item 這一塊則完全沒有著墨,更不用提如何檢查 item 的內部組成了。
解決方案
由於 RecyclerViewActions
將 item 捲進畫面的部份處理得很好,加上開發人員也習慣用它來處理 RecyclerView
,所以我傾向朝 不違反 Espresso 設計原則,擴充現有 API 的方向來解決問題,而非創造一組全新的 API,這樣可以減少學習及後續溝通的成本。
將 API 的可讀性也一併考量進來,理想上 test code 會像是:
|
|
其中:
onChildView(Matcher<View> childMatcher, ViewAction viewAction)
實作ViewAction
,擴充了現有RecyclerViewActions.actionOn*()
的用法,將原先作用在整個 item 的viewAction
,轉移到符合childMatcher
的 child view。itemAtPosition(int position).onChildView(Matcher<View> childMatcher).matches(Matcher<View> viewMatcher)
實作ViewAssertion
,擴充了現有的ViewInteraction.check()
,將原先作用在整個RecyclerView
的檢查viewMatcher
,轉移到特定 item 下的符合childMatcher
的 child view;當然,若只寫itemAtPosition(position)
則會作用在特定的 item。
另外,perform(scrollToPosition(11))
與 check(itemAtPosition(11)...)
重複描述 item 位置的部份似乎有點多餘?那是因為 perform()
與 check()
的對象都是 onView(with(R.id.recycler))
所代表的 RecyclerView
- 可以視為一個整體,也可以視為許多 item 的組成,將某個 item 捲進畫面,有可能是為了檢查 RecyclerView
其他部份的變化。若想要少寫一些程式碼,倒是可以建立一個 helper method 把這些邏輯封裝起來,同時又保有底層 API 的彈性。例如:
|
|
完整的實作可以在這裡找到 (Gist),這裡就不細談,而是把重點擺在為什麼 API 要以這種方式擴充,背後有什麼考量…
為什麼不那樣做?
當然在這之前,網路上也有其他人提出過不同的方法,有很大的機會會找到 Danny Roa 在 2015 年提出的構想 - Espresso: Matching Views in RecyclerView,用起來像是這個樣子:
|
|
主要的差別在於:
- 在定位 recycler view、item 或 child view 時都只接受 resource ID,少了一點彈性。
- 操作時捨棄了原有的
RecyclerViewActions.actionOn*()
,所以自創的actionOnItemViewAtPosition()
為了實現自動捲動,仿RecyclerViewActions
在內部重新實作了ScrollToPositionViewAction
。 - 檢查時從
onView()
下手,直接定位到某個 item (或是底下的 child view),所以無法把perform(scrollToPosition(11))
寫在同一行,把 item 捲入畫面的動作要分開處理。
若你仔細研究過 RecyclerViewMatcher.matchesSafely(View view)
的實作,會發現若要支援以 view matcher 來找 RecyclerView
,效率恐怕會是個問題?
在思考如何擴充現有 API 的過程中,也有人提出可以將 check/assertion 視為一種 action 的想法,也就是在 ViewAction
裡做檢查 (有可能拋出 AssertionError
),例如:
|
|
當然,這在技術上是可行的,但…
Just because you can doesn’t mean you should.
不是對 ViewAction
的解釋太過狹隘,而是這明顯打破了 Espresso API - onView(Matcher<View>).perform(ViewAction).check(ViewAssertion)
對 ViewAction
所賦予的責任,更何況 ViewAction
的 API 文件也明確寫著:
Do not throw
AssertionError
s. Assertions belong inViewAssertion
classes.
所以這個方向就不在考慮之列。
結論
未來希望讓 itemAtPosition(int position)
也能支援 Matcher<VH> viewHolderMatcher
與 Matcher<View> itemViewMatcher
,使用上會更具彈性。
如果你也有相同的困擾,建議試試這裡提出的解法,有機會也比較一下 Danny Roa 的方法,希望能聽到你使用上的回饋。
參考資料
- Testing RecyclerViews at Specific Positions with Espresso (2016-04-15)
- Espresso: Matching Views in RecyclerView (2015-05-10)