↓↓↓
##大きく変わったところ
- チャンネルが横スクロールですべて見れるようになった
- サイドバーにチャンネル一覧ができた
- 日付切り替えがセレクトボックスからカレンダー表示になった
etc.
今まではチャンネルが 5ch 毎に区切られていたので、わざわざページを切り替えて見ないといけなかったのを、横スクロールですべてのチャンネルが一覧で見られるようになりました。
さらにチャンネル毎の番組表にもアクセスしやすくなり、日付の切り替えもしやすくなったかと思います。
このリリースによって、ユーザービリティが少しでも向上していれば幸いです。
しかし、便利になった反面、このリリースに至るまでに、パフォーマンス面で様々なところで問題になりました。
今回は実際にリニューアル中に課題となった箇所と解決方法の一部を紹介します。
AbemaTV 以外であまり参考にならないかもしれませんが備忘録として…。
##今回のリニューアルでパフォーマンスの改善ポイント
- チャンネルが横スクロールですべて見れるようになった
特にパフォーマンス面で顕著に現れたのは、上記で、
今までは 5ch ごとに分割表示をしてたところを全件表示にしたので何も考えないで今まで通りに実装すると、単純に表示待ち時間が増大しました。
↑ざっくり今までのロジックでチャンネル全件並べた例
初回レンダーに入るまでに処理が重すぎて真っ白になってしまいました…
##前提条件
〜なぜそもそも普通に実装するだけでは重くなるのか〜
api
slots: [
{
timetableStartAt: 1475910000,
tableEndAt: 1475918400,
title: "AbemaNews午後③"
detailHighlight: "注目ニュース&話題の情報を24時間配信中!緊急速報や注目の会見は随時、まるごと生中継!何か起きたらAbemaNewsでチェック!",
thumbImg: "hogehoge"
}
…
]
(なんとなくの雰囲気json)
番組枠情報(セル)を slot
として、↑のようなデータに対して
表示優先順位
1. タイトル
2. サムネイル画像
3. 見どころ
というのは決まっており、最初に title
(タイトル)、 thumbImg
(サムネイル画像)、 detailHighlight
(見どころ)の要素の高さを取得するために一度レンダーし、その後 timetableStartAt
(開始時間)、 tableEndAt
(終了時間)をもとにセルの高さを計算し、上記の優先順位をもとにセルの高さ内に収まるように配置していくために再レンダーさせる必要がありました。
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. 初期ロードの高速化
前述のセルのレンダリングの前提条件は変えられそうにないため、ユーザーの体感速度をできるだけ早くなるように、番組表のロジックに引きずられないヘッダー、サイドカラムを先に表示するように変更してみました。
セル部分がブロッキングしてたからか、体感速度が若干あがったような…?
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 ずつ時差式にレンダリングするようにしてみました。
初期ロードの体感速度はほぼ旧番組表と同じぐらいになりました。
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
を使って props
と state
を比較しているようなので React.PropTypes.node
や React.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);
}
最初に比べてかなりパフォーマンスが上がりました。
##スクロールの最適化
###改善その 1. GPUアクセラレーション巻き込み防止
Compositing Border を見てみると、意図しないレイヤーがたくさん生成され、パフォーマンスに影響が出てたので position: relative;
周りと z-index
のチューニングをしました。
Before
いたるところに意図しないレイヤーが生成されていて、スクロールする度に描画にもたつきが発生しています。(gif だとわかりづらいけど…)
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 アクセスをしないで、 state
や props
を更新することで 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 を書き換えているところも left
や top
だと、再レイアウトを引き起こしてしまい非常にコストが高くなってしまうので、 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 して取得する
ヘッダーや、番組表内の日付のバーなど、fixed 要素のスクロール追尾部分はリアルタイムでついてきてほしいので、 throttle はしていませんが、番組表内にフロートで出している 前の日を見る
次の日を見る
現在時刻に戻る
や 左右への移動ボタンなど、 mousemove
や resize
イベントを取得して出し分けしています。
普通にそのまま取得してしまうと、マウスを動かしたり、リサイズする度に高負荷がかかってしまうので、 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 の導入をしたり、技術進歩にともなってやれることもたくさんあるかと思います。パフォーマンスチューニングは、基本的に泥臭く地道に向き合っていかないと行けないと思うので、今後も引き続き改善していく予定です