Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
263
Help us understand the problem. What are the problem?

More than 5 years have passed since last update.

Organization

AbemaTV の番組表リニューアルに伴うパフォーマンス改善

以前の番組表
スクリーンショット 2016-10-08 07.43.42.png

↓↓↓

AbemaTV 新番組表
スクリーンショット 2016-10-08 07.44.28.png

大きく変わったところ

  • チャンネルが横スクロールですべて見れるようになった
  • サイドバーにチャンネル一覧ができた
  • 日付切り替えがセレクトボックスからカレンダー表示になった

etc.

今まではチャンネルが 5ch 毎に区切られていたので、わざわざページを切り替えて見ないといけなかったのを、横スクロールですべてのチャンネルが一覧で見られるようになりました。
さらにチャンネル毎の番組表にもアクセスしやすくなり、日付の切り替えもしやすくなったかと思います。
このリリースによって、ユーザービリティが少しでも向上していれば幸いです。

しかし、便利になった反面、このリリースに至るまでに、パフォーマンス面で様々なところで問題になりました。
今回は実際にリニューアル中に課題となった箇所と解決方法の一部を紹介します。
AbemaTV 以外であまり参考にならないかもしれませんが備忘録として…。

今回のリニューアルでパフォーマンスの改善ポイント

  • チャンネルが横スクロールですべて見れるようになった

特にパフォーマンス面で顕著に現れたのは、上記で、
今までは 5ch ごとに分割表示をしてたところを全件表示にしたので何も考えないで今まで通りに実装すると、単純に表示待ち時間が増大しました。

Untitled.gif

↑ざっくり今までのロジックでチャンネル全件並べた例

スクリーンショット 2016-10-08 08.39.29.png
react-addons-perf での計測結果

初回レンダーに入るまでに処理が重すぎて真っ白になってしまいました…

前提条件

〜なぜそもそも普通に実装するだけでは重くなるのか〜

番組表のひとつひとつのセルの表示条件
スクリーンショット 2016-10-08 10.41.12.png

api

slots: [
 {
    timetableStartAt: 1475910000,
    tableEndAt: 1475918400,
    title: "AbemaNews午後③"
    detailHighlight: "注目ニュース&話題の情報を24時間配信中!緊急速報や注目の会見は随時、まるごと生中継!何か起きたらAbemaNewsでチェック!",
    thumbImg: "hogehoge"
  }
  
]

(なんとなくの雰囲気json)

番組枠情報(セル)を slot として、↑のようなデータに対して

表示優先順位
1. タイトル
2. サムネイル画像
3. 見どころ

というのは決まっており、最初に title (タイトル)、 thumbImg (サムネイル画像)、 detailHighlight (見どころ)の要素の高さを取得するために一度レンダーし、その後 timetableStartAt (開始時間)、 tableEndAt (終了時間)をもとにセルの高さを計算し、上記の優先順位をもとにセルの高さ内に収まるように配置していくために再レンダーさせる必要がありました。

スクリーンショット 2016-10-11 7.00.51 PM.png
タイトル、見どころ、サムネイル画像全部入りの例

スクリーンショット 2016-10-11 7.02.59 PM.png
タイトル、見どころ(途中まで表示)サムネイル画像のみの例

スクリーンショット 2016-10-11 7.01.03 PM.png
タイトル、サムネイル画像のみの例

スクリーンショット 2016-10-11 7.01.29 PM.png
タイトル(途中まで表示)のみの例


state = {
  heights: {},
  showConditions: { // 初期値では全部表示する
    title: true,
    poster: true,
    tableHighlight: true
  }
};

componentDidMount() {
  const heights = {
    title: this.refs.title.offsetHeight
  };

  // 表示の優先度を決める関数に渡す
  const showConditions = showPriority(this.props.slot, heights);

  this.setState({
    isRendered: true,
    heights,
    showConditions
  });
}


/**
 * タイトル表示
 */
// タイトル、サムネイル画像、見どころ それぞれに同じような処理をする
renderTitle() {
  const { showConditions } = this.state;
  const { title } = this.props.slot;

  let el = null;
  if (showConditions.title) {
    el = (
      <p ref="title">
       {title}
      </p>
    );
  } else if (showConditions.lessTitle) {
    let heightStyle = {
      height: `${showConditions.lessTitle * LINE_HEIGHT}px`,
      lineHeight: `${LINE_HEIGHT}px`
    };

    el = (
      <p style={heightStyle}>
        {title}
      </p>
    );
  }
  return el;
}



render() {
  const fixedHeight = isRendered ? timeTableMaxHeight(tableStartAt, tableEndAt) : "auto";
  return (
    <div style={{ height: fixedHeight }}>
      {this.renderTitle()}
      {this.renderTableHighlight()}
      {this.renderPoster()}
    </div>
  );
}

ここがセル分どうしても計算しないと行けないので、レンダリングに時間がかかってします…。セルの数が 500 個あると 500 * 2 回レンダーしないといけなくなります。

レンダリングの最適化

改善その 1. 初期ロードの高速化

前述のセルのレンダリングの前提条件は変えられそうにないため、ユーザーの体感速度をできるだけ早くなるように、番組表のロジックに引きずられないヘッダー、サイドカラムを先に表示するように変更してみました。

Untitled.gif
セル部分がブロッキングしてたからか、体感速度が若干あがったような…?

componentDidMount() {
  
  this.isRendered = true;
}

render() {
  
  <div>
    <Header />
    <div>
      <SideColumn />

      {this.isRendered ? (
        <Timetable />
      ) : null}

    </div>
  </div>
}

改善その 2. 分割レンダリング

旧番組表では 5ch ずつに分割されていたので一回のレンダリングするセルの数がその分少なくすることができていました。

計算するまでもないですが…
全 30ch で 1ch あたり 20 個セルがあったとして、
30 * 20 = 600
セルの数は 600 個になります。

5ch ずつ表示するようにすれば、一度あたりの表示数は 100 個で済みます。

同じように、新番組表でもそこに習って 5ch ずつ時差式にレンダリングするようにしてみました。

Untitled.gif
初期ロードの体感速度はほぼ旧番組表と同じぐらいになりました。

timetableListStepRender() {

  clearTimeout(this.timers.stepRender);

  // セルの計算が重いので、データを段階を踏んでレンダーする
  this.timers.stepRender = setTimeout(() => {

    // 5 チャンネル毎データを増やしていく
    if (this.showChannelSchedules < MediaStore.channelSchedules().length) {
      this.showChannelSchedules = this.showChannelSchedules + 5;
      this.setState({
        displayChannelSchedules: MediaStore.channelSchedules().slice(0, this.showChannelSchedules)
      });
    }
  }, INTERVAL_TIME);

}

render() {
  <Timetable channels={displayChannelSchedules} />
}

改善その 3. shouldComponentUpdate のチューニング

AbemaTVでは React v15.3 を使っていて(2016/10現在) React.PureComponent を導入しています。
PureComponent は内部的に shallowEqual を使って propsstate比較しているようなので React.PropTypes.nodeReact.PropTypes.func だと毎回レンダリングが走ってしまうので必要な箇所だけ手動で shouldComponentUpdate に追加するようにしました。参考:[Perf] shouldComponentUpdate/pure components do not work with react element/node type props

shouldComponentUpdate(nextProps) {
  // React.PropTypes.func, node の場合は更新を無視する (≒ それ以外のものを全部書く)
  return (nextProps.className !== this.props.className ||
        nextProps.channels.length !== this.props.channels.length ||
        nextProps.startTime !== this.props.startTime ||
        nextProps.endTime !== this.props.endTime ||
        nextProps.showOndemandInfo !== this.props.showOndemandInfo ||
        nextProps.slotReservationIds.length !== this.props.slotReservationIds.length ||
        nextProps.slotOndemands.length !== this.props.slotOndemands.length);
}

スクリーンショット 2016-10-09 00.49.06.png

最初に比べてかなりパフォーマンスが上がりました。

スクロールの最適化

改善その 1. GPUアクセラレーション巻き込み防止

Compositing Border を見てみると、意図しないレイヤーがたくさん生成され、パフォーマンスに影響が出てたので position: relative; 周りと z-index のチューニングをしました。

47cee9d2-867d-11e6-8b47-afecde79207e.gif
Before
いたるところに意図しないレイヤーが生成されていて、スクロールする度に描画にもたつきが発生しています。(gif だとわかりづらいけど…)

4bb2393c-867d-11e6-84dc-00280285a3b1.gif
After

position: relative; を根本的になくそうとしてみましたが、実装の都合上断念…

.col {
  background-color: #eeeeee;
  border-right: 1px solid #dddddd
  float: left;
  height: 4320px;
  overflow: hidden;
  position: relative; /* 意図しないレイヤー生成を防ぐため。GPUアクセラレーション巻き込み防止 */
  width: 176px;
}

参考:GPUアクセラレーションとposition: relativeによるレイヤー生成について

改善その 2. あえての DOM アクセス

React では基本的に直接 DOM アクセスをしないで、 stateprops を更新することで DOM 更新するように実装します。しかし、ヘッダーや、番組表内の日付のバーなど、fixed 要素のスクロール追尾部分はスクロールする度にイベントが発生するので普通に実装すると shouldComponentUpdate が都度走ってしまいます。なので今回、あえて直接 DOM アクセスするように変更しました。
(AbemaTV ではできるだけコンポーネントに Stateless Functions を使うようにしているので、関係ないコンポーネントも都度レンダーしてしまっていました。)


contentScrollListener() {
  const contentScrollLeft = this.contentWrapperRef.scrollLeft;
  // scrollLeft を直接 style 反映
  this.channelContentHeaderRef.style.transform = `translateX(-${ contentScrollLeft }px)`;
}

componentDidMount() {
  this.handleContentScroll = this.contentScrollListener.bind(this);
  this.contentWrapperRef = this.refs["content-wrapper"];
  this.channelContentHeaderRef = this.refs["channel-content-header"];
  // scrollイベントを取得
  this.contentWrapperRef.addEventListener("scroll", this.handleContentScroll);
}

render() {
  <div className={ styles["content-wrapper"] }
       ref="content-wrapper">
    <div className={ styles["channel-content-header"] }
         ref="channel-content-header"
         style={{
           width: TIMETABLE_ITEM_WIDTH * channelSchedules.length
         }}>
      <ChannelIconHeader
        channels={ channelSchedules }/>
    </div>
  </div>
}

この変更により、 shouldComponentUpdate の変更が増加しないので、滑らかにアニメーションさせることができました。

改善その 3. コストの高い DOM 操作のチューニング

上記のコードで既に対応済みですが、直接 style を書き換えているところも lefttop だと、再レイアウトを引き起こしてしまい非常にコストが高くなってしまうので、 transform を使って対応しています。

// scrollLeft を直接 style 反映
this.channelContentHeaderRef.style.transform = `translateX(-${ contentScrollLeft }px)`;

参考:High Performance Animations

また、DOM に都度アクセスするのもコストが高いのでメンバ変数に入れて集約しています。

this.contentWrapperRef = this.refs["content-wrapper"];
this.channelContentHeaderRef = this.refs["channel-content-header"];

その他

上記に当てはまらないパフォーマンスチューニングです。

改善その 1. addEventListener は throttle して取得する

スクリーンショット 2016-10-12 10.52.56 AM.png

ヘッダーや、番組表内の日付のバーなど、fixed 要素のスクロール追尾部分はリアルタイムでついてきてほしいので、 throttle はしていませんが、番組表内にフロートで出している 前の日を見る 次の日を見る 現在時刻に戻る や 左右への移動ボタンなど、 mousemoveresize イベントを取得して出し分けしています。
普通にそのまま取得してしまうと、マウスを動かしたり、リサイズする度に高負荷がかかってしまうので、 lodash の throttle で間引いて取得するようにしています。

import _ from "lodash";

this.handleResize = _.throttle(this.resizeListener.bind(this), THROTTLE_INTERVAL);
this.handleContentMousemove = _.throttle(this.contentMousemoveListener.bind(this), THROTTLE_INTERVAL);

window.addEventListener("resize", this.handleResize);
this.contentWrapperRef.addEventListener("mousemove", this.handleContentMousemove);

まとめ

今回は AbemaTV の番組表リニューアル時に行ったパフォーマンス改善の一部を紹介しました。まだまだ、改善できるところはたくさんあるかと思います。Passive event listeners でスクロールなどのパフォーマンス改善をしたり、 IntersectionObserver の導入をしたり、技術進歩にともなってやれることもたくさんあるかと思います。パフォーマンスチューニングは、基本的に泥臭く地道に向き合っていかないと行けないと思うので、今後も引き続き改善していく予定です

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
263
Help us understand the problem. What are the problem?