react-native中长列表ListView优化

2,145 阅读4分钟

长列表是项目中常见的组件之一,大量数据同时渲染在页面上时,会导致页面的卡顿。我们可以尝试控制只显示用户需要看到的来提高性能。 为了列表滚动条的正常展示,需要将顶部和底部用户看不到的部分分别通过等高的元素进行替换

思路

在滚动过程中,有两个互不相关的过程:滚动替换和数据加载

滚动替换

将不需要展示的顶部和底部位置通过等高元素进行替换;在滚动过程中,根据所有卡片的高度itemHeights,屏幕高度H,默认加载卡片数S,滚动距离y------>获取需要显示的起始卡片p、结束卡片q、顶部替换盒子的高度H1、底部替换盒子的高度H3

const nextReplaceScrollState = (cards, itemHeights, H,  S,  y ) => {
  const sTop = Math.floor(S / 2)
  const sBottom = Math.floor(S / 2)
  // p : 开始的卡片,第一个top大于(y - H)的卡片,多渲染一屏是为了保障用户体验的连续性
  let sum = 0
  let p = 0
  for(let i = 0; i < cards.length; i++) {
    if(sum > y - H ) {
      p = cards[i].id
      break
    }
    sum += itemHeights[cards[i].id]
  }
  p = p - sTop
  if(p < 0) {
    p = 0
  }
  
  // q:结束的卡片
  const q = p + S - 1
  
  // H1: 顶部替换盒子的高度 sum (1 + ... + p-1)
  const lst1 = cards.filter(card => card.id < p).map(card => itemHeights[card.id])
  const H1 =   lst1.length > 0 ? lst1.reduce((h1, h2) => h1 + h2) : 0
  
  // H3: 底部替换盒子的高度 sum (q + ... + (cards.length - 1))
  const lst3 = cards.filter(card => card.id > q).map(card => itemHeights[card.id])
  const H3 = lst3.length > 0 ? lst3.reduce((h1, h2) => h1 + h2) : 0
  return {p, q, H1, H3}
}

数据加载

  • 数据加载开始时,将滚动替换过程锁定
  • 新的卡片添加到列表底部进行渲染(方便计算每个卡片的高度)
  • 新卡片渲染完成后解锁,继续滚动替换

开启代码之路

初始化

在渲染时,展示visibleData(p---q)

export class ListView extends Component {
  static defaultProps = {
    displaySize : 20,  //默认同时渲染20张卡片
    initialData : [],
    }
  }
  constructor(props) {
    super()
    this.y = 0 // 滚动距离
    this.itemHeights = [] // 所有的卡片的高度
    this.id_counter = 0 // ID 计数器,为每个卡片添加id使用  
    this.state = {
      data : [], 
    }
  }
  _renderItem({item, id}){ // 每个卡片的渲染
    return <View key={id} onLayout={this._itemLayout(id).bind(this)}>
      {this.props.renderItem(item, id)}
    </View>
  }
  
  render(){
    const {p, q, H1, H3, newlyAdded, scrollLock, data} = this.state
    let visibleData = data.filter( ({item, id}) => {
      if(id >= p && id <= q) return true
      return false
    })
    return (
      <ScrollView
      	onLayout={this._layout.bind(this)}
      >
        <View style={{height : H1}}></View>
        {
          visibleData.map(this._renderItem.bind(this))
        }
        <View style={{height : H3}}></View>
      </ScrollView>
    )
  }
}

初始化列表数据data

在componentDidMount中,调用append方法将initialData填充到state.data中

  componentDidMount() {
    this.append(this.props.initialData)
  }
  append(list) {
    const nList = list.map(((item, i) => { // 分配ID
      return { id : ++this.id_counter, item }
    }).bind(this))
    this.setState({
      data : [...this.state.data, ...nList],
      newlyAdded : nList,
    })
  }

在scrollView上定义onLayout,获取整个listView的高度

  _layout({nativeEvent : {layout}}){ // 获取listView的高度
    this.height = layout.height
  }

在renderItem上,通过onLayout,将每个卡片的高度放在数组中

  _itemLayout(i) {
    return ({nativeEvent : {layout}}) => {
      this.itemHeights[i] = layout.height
    }
  }

上拉加载更多

定义scroll事件,滚动到底部时加载更多

ScrollView组件的滚动操作
  • 判断是否滚动到底部:滚去的高度+滚动区域的高度>=整体高度时,表示到底部
  • 如果到底部,将滚动超出底部的差值传递给父组件
  • 滚动替换:非锁定状态时进行滚动替换,即不断计算需要显示的卡片和替换盒子的高度
  _scroll(e) {
    this.y = e.nativeEvent.contentOffset.y
    const atBottom = (this.y + this.height >= e.nativeEvent.contentSize.height)
    if(atBottom) {
      this.props.onScrollToBottom(this.y + this.height - e.nativeEvent.contentSize.height)
    }
    if(!this.state.scrollLock) {
      this.setState({
        ...nextReplaceScrollState(this.state.data, this.itemHeights, this.height, this.props.displaySize, this.y)
      })
    }
  }
父组件定义滚动底部事件

主要在此获取更多数据,然后通过ref调用ScrollView组件的append方法加载新数据

  _onScrollToBottom(y){
    this.y = y
    if (this.state.loading){return}
    this.setState({ loading : true }, (() => {
      setTimeout((() => {
        const courses = []
        for(let i = 0; i < 20; i++) {
          courses.push(course_gen())
        }
        this.refs.listView.append(courses)
        this.setState({
          loading : false
        })
      }).bind(this), 2000) 
    }).bind(this))
  }
  <ListView
    ...
    onScrollToBottom={this._onScrollToBottom.bind(this)}
	... 
  />
重新定义ScrollView组件的append方法
  • 将新增加的数据保存在data上,并将新增加的数据单独保存
  • 锁定滚动状态
  • 在setInterval中进行渲染检查:当最大卡片id的高度存在,说明渲染完成;此时,解除滚动锁定,进行滚动替换

锁定的目的:为了通知父组件已经触发了loadMore,请求会的数据正在加载,避免重复触发loadMore

  append(list) {
    const nList = list.map(((item, i) => ({ id : ++this.id_counter, item })).bind(this))
    const I = setInterval( (() => {
      if(this.itemHeights[this.id_counter]) {
        clearInterval(I)
        this.setState({
          ...nextReplaceScrollState(this.state.data, this.itemHeights, this.height, this.props.displaySize, this.y),
          scrollLock : false, // 解除锁定
          newlyAdded : []
        })
      }
    }).bind(this), 100)

    this.setState({
      data : [...this.state.data, ...nList],
      newlyAdded : nList,
      scrollLock : true   // 将滚动替换过程锁定
    })
  }
render中,将新增的数据合并
 render(){
	...
    if(newlyAdded && newlyAdded.length > 0) {
      visibleData = [        ...visibleData,        ...newlyAdded.filter(x => !visibleData.find(t => t.id === x.id))      ]
    }
 }

当滚动底部,获取更多数据后,会进行滚动锁定。在render中,通过数据合并渲染,可以获取新卡片的高度。在append方法中,如果新卡片的最后有高度,说明渲染完成,渲染完成后将清空新数据数组,并解除锁定,在scroll中可以继续执行滚动替换操作

在底部添加上拉加载更多的loading

在父组件定义底部的loading
// 父组件
 _renderBottomIndicator(){
    if (this.state.loading) {
      return (
        <View style={{height : 42, ...flexCenter}}>
          <ActivityIndicator />
        </View>
      )
    }
    return null
 }
 
 <ListView
    ...
    renderBottomIndicator={this._renderBottomIndicator.bind(this)}
  }
/>
在ScrollView中使用底部loading
<ScrollView  ... >
       ...
       {this.props.renderBottomIndicator()}
     </ScrollView>

下拉刷新

父组件获取刷新方法

获取新数据,然后调用ScrollView组件的reset方法重置数据

 _refresh(){
   if(!this.state.loading) {
     this.setState({loading : true}, (() => {
       setTimeout((() => {
         const courses = []
         for(let i = 0; i < 20; i++) {
           courses.push(course_gen())
         }
         this.refs.listView.reset(courses)
         this.setState({
           loading : false
         })
       }).bind(this), 2000)
     }).bind(this))
   }
 }
 <ListView
 	...
   refreshControl={
     <RefreshControl refreshing={false} onRefresh={this._refresh.bind(this)} />
   }
 />

ScrollView组件定义reset方法:重置列表数据

 reset(list){
   this.itemHeights = []
   this.id_counter = 0
   this.setState({
     data : [],
     newlyAdded : [],
     scrollLock : false,
     p : 0,
     q : 0
   }, (() => {
     this.append(list)
   }).bind(this))
 }