こんにちは、あるいはこんばんは。川崎(@kawasy)です。CTO業と兼務(?)で、プロダクト開発エンジニア業をやっています。今回は、最近隙を見て実装した、画像の読み込み体験改善についての小ネタを紹介します。
まえがき
ウェブサイトやアプリのパフォーマンスは、ユーザ体験に大きな影響を与え、プロダクトの成長にも影響をあたえる重要な要素であることが知られています。遅いウェブサイトにおいては、ユーザの離脱が増え、コンバージョンとなるユーザアクションが減るということが、過去の実験結果によって示されています。近年では Core Web Vitals (https://web.dev/vitals/) のような指標も提示され、ウェブサイトのパフォーマンスとユーザ体験への影響は引き続き注目を集めていると言っていいでしょう。
歴史的に、ウェブサイトパフォーマンスにおいては、画像の読み込みは課題でありつづけました。Google の提供する Lighthouse のようなパフォーマンス計測ツールにおいても、画像に対するチェック項目が多く存在しています。このことからも、ネットワーク環境やブラウザな技術の進歩した現代においても、画像読み込み速度の改善によるユーザ体験の向上機会はまだまだ多いことがうかがえます。
ユーザインタフェースの研究分野では、実際の速度よりも体感速度が重要であることが知られています。ウェブサイトの画像読み込みにおいては、プログレッシブ画像読み込みと呼ばれる手法が、体感速度を早めるための手法として広く用いられています(以降、progressive image loading あるいは progressive image と表記します)。progressive image とは、読み込みの早い低解像度の画像を先に表示し、後から高解像度の画像に切り替える手法です。ブログサービスの Medium が、記事中の画像にこの手法を用いているのが有名な事例です。
さて、私の所属するチームで開発しているプロダクトの1つ、新しい福利厚生サービスの「Perk (パーク)」においても、ウェブサイト上で画像が多用されています。今後もさらに画像の使用が増え続けていくことが予想されており、より良い画像読み込み体験の提供が求められはじめていました。本記事では、Perk で導入した内容を基に、実装もデータも軽量な BlurHash と呼ばれているアルゴリズムを用い、 progressive image を実現する手法について紹介します。
結果
まずここで、得られる結果から先に紹介します。実際のプロダクト上の画面です。
本記事で紹介するBlurHashを用いると、blurを強くかけたような画像プレースホルダーが、20から30バイト程度の文字列で表現可能となります。上のスクリーンショット中の3枚の画像プレースホルダーがBlurHashによって生成されたものです。そしてこれは、追加のHTTPリクエストを挟むことなく表示しています。
BlurHash導入前と、導入後の画像読み込み前の状態の比較です。右側のスクリーンショットの方が魅力的に感じられるということは、感覚的にも同意してもらえるのではないでしょうか。
BlurHashによるプレースホルダと元画像の比較です。数十バイトで表現されていることを考慮すると、十分に元画像の雰囲気の伝わるプレースホルダーだと言って良いでしょう。左から右へ、スムーズにtransitionすることで、体感する画像の読み込み時間を減らしています。
BlurHashの概要
BlurHash (https://blurha.sh/) は日本にも最近進出したフードデリバリーアプリの Wolt が開発したアルゴリズムです。前述の通り、20から30バイトと短いバイト数で、blur効果を強くかけたような画像を表現できる点が特徴です。またエンコード結果の文字列は、JSONセーフかつHTMLセーフで、エスケープ処理で悩まされにくいことも使い勝手を良くしています。
簡単に説明すると、2次元離散コサイン変換の低周波数成分だけをとりだし、独自に選んだ安全な文字セット83文字で、Base83エンコードするというアルゴリズムです。とても雑な理解をするのであれば、超低クオリティで保存したJPEGファイルだと思っておけば良いでしょう。アルゴリズムの詳細はこちらを参照してください。 https://github.com/woltapp/blurhash/blob/master/Algorithm.md
なお、利用される文字は以下の83文字です。アルゴリズム中に出現する即値は、このBase83でコンパクトに表現できる値域となるように決めたものと予想しています。
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~
BlurHashを用いた progressive image loading の実装
BlurHash は公式・非公式含め、複数の言語でエンコード・デコードライブラリが提供されています。ここでは、前出の Perk のウェブサイトに導入した実装を抜粋して紹介します。現時点で利用しているフレームワークとそのバージョンは、Rails 6.1 + Webpacker 5 + React 16.14 となっています。適宜みなさんの環境に置き換えて読んでください。
フロントエンド側(React)
ウェブフロントエンド側は、本家である Wolt 社が npm パッケージを提供しています。
前者のパッケージではエンコード・デコード処理本体が提供され、後者のパッケージではそれを<canvas>で描画するReact Componentが提供されています。
progressive image を実現するためには、<img> 要素と重ねて表示した上で、img.onload で <Blurhash> から実際の <img> に切り替えるtransitionを行います。
以下に実際のコードを簡素化して要点を抜粋したものを載せています。ここでは CSS-in-JS のために styled-components を利用しています。なお、簡単のため、このコードスニペットでは読み込み完了時にopacityを一気に切り替えていますが、実際のUIとしてはanimationをつかって2つの要素をなめらかに切り替えるべきでしょう。私達のチームでは framer-motion を利用してアニメーションを実現しています。
繰り返しになりますが、低解像度画像を利用した progressive image loading の実現手法と比較して、低バイト数かつ追加のHTTPリクエストが不必要なことがこの実装の利点です。
import { Blurhash } from "react-blurhash";
export const ProgressiveImage: React.FC<ProgressiveImageProps> = (props) => {
const { width, height, src, blurhash } = props;
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const imageToLoad = new Image();
imageToLoad.src = src;
imageToLoad.onload = () => {
setLoaded(true);
};
}, [src]);
return (
<Base>
{blurhash && <BlurredImage hash={blurhash} width={width} height={height} loaded={loaded} />}
<LoadedImage src={src} width={width} height={height} loaded={loaded} />
</Base>
);
};
const Base = styled.div`
position: relative;
`;
const BlurredImage = styled(Blurhash)<BlurredImageProps>`
position: absolute;
top: 0;
left: 0;
opacity: ${({ loaded }) => (loaded ? 0 : 1)};
`;
const LoadedImage = styled.img<LoadedImageProps>`
position: absolute;
top: 0;
left: 0;
width: ${({ width }) => width}px;
height: ${({ height }) => height}px;
opacity: ${({ loaded }) => (loaded ? 1 : 0)};
`;
バックエンド側(Rails)
Wolt本家製ではありませんが、Ruby用にはエンコードライブラリが公開されています。実装としては、公式に提供されているCのエンコーダー実装を、ffi gemを用いてrubyインタフェースとしています。2021/01/27時点で rubygems に登録されているものは ffi gem のバージョン成約が厳しいため、master branchのものを利用する必要のあるケースが多いでしょう。
https://github.com/Gargron/blurhash
このblurhash gemを、RailsのActiveStorageの機構と組み合わせて利用する方法を説明します。
ActiveStorageにはアップロードしたファイルを事後的に処理しメタデータを付加するためのAnalyzerという仕組みがあります。公式には画像サイズを保存する ActiveStorage::Analyzer::ImageAnalyzer が提供されており、これを継承して拡張する形で実装します。
(参照: 6.1 における ActiveStorage::Analyzer::ImageAnalyzer 実装 https://github.com/rails/rails/blob/6-1-stable/activestorage/lib/active_storage/analyzer/image_analyzer.rb)
class BlurhashImageAnalyzer < ActiveStorage::Analyzer::ImageAnalyzer
def self.accept?(blob)
blob.image?
end
def metadata
read_image do |image|
rotate = rotated_image?(image)
width, height = image_size(image)
blurhash = encode_to_blurhash(image)
{
width: width,
height: height,
rotate: rotate,
blurhash: blurhash,
}
end
end
private
def image_size(image)
if rotated_image?(image)
[image.height, image.width]
else
[image.width, image.height]
end
end
def encode_to_blurhash(image)
require 'blurhash'
pixels = image.get_pixels.flatten
Blurhash.encode(image.width, image.height, pixels)
end
end
なお、ActiveStorageの実装を読めばわかるのですが、Analyzerの処理は、ActiveJobを用いて非同期で実行されます。それはつまり、タイミングによっては metadata が保存されていない瞬間が存在することを意味します。そのため、APIサーバやその結果を利用するクライアント実装で、blurhashの値が存在しなかったとしてもエラーにならないような対応が必要となります。
さらに、ActiveJobは初期設定では同一Rubyプロセス内のruby threadで実行されることにも注意が必要です。ImageMagickによる画像変換のようにCPUとメモリを消費する処理は、web serverと同一のプロセスで処理するのは好ましくないでしょう。実運用上は別のバックエンドでActiveJobを実行するべきでしょう。
blurhash gem のドキュメントでは rmagick gem を使っていますが、ここでは ImageAnalyzer の実装を流用しているため mini_magick gem になっていることも補足しておきます。
こうしてActiveStorage::Blobに保存したメタデータは、以下のように取得することができます。あとは、フロントエンドに渡す処理を書くだけなので自明でしょう。
Product.first.image_1_attachment.metadata
=> {"identified"=>true, "analyzed"=>true, "blurhash"=>"LTFsGNay8_fl-oj@IAbH8woKkDWV"}
余談: ImageMagick を気軽に使ってはいけない件について
ここまで見てきたように、BlurHashを利用するアプリケーションコード自体は非常に簡単に書くことができます。しかし、ImageMagick はサーバでの利用に「手間と覚悟」が必要だともしばしば言われていることには言及しておきたいと思います。過去にあった数々の大きな脆弱性や、バージョンアップによる画像変換処理のデグレなどがその理由です。適切なバージョンを選び、利用用途に適切なビルドパラメータでコンパイルすることが難しいことを踏まえた上で、実サービスでの運用は十分慎重に行ってください。
より詳しくは、以下の記事が参考になります。
今後の課題
Wantedlyでは2015年に画像配信の基盤を作成し、今もほぼ変わらない形で利用を続けています( https://github.com/wantedly/nginx-image-server / https://speakerdeck.com/spesnova/nginx-image-server )。この基盤は、nginx の拡張モジュールである ngx_small_light を使って、Amazon S3上の画像を動的に変換し、CDN上でキャッシュさせるという構成になっています。
この基盤にはプラットフォーム上の多数のプロダクトで共有して利用されているために、実験的な変更や改善が大規模になり気軽に手を付けづらいという課題が存在しています。今回の progressive image の実装は、共有の画像配信レイヤーには直接手を入れずに、(仕事の合間に)短時間で簡単にユーザ体験を向上することを目的に行われました。今後は体感速度の向上だけでなく、抜本的な画像配信基盤の改善にもチームで取り組んで行きたいと考えています。
また、今回実装したような画像読み込み体験の改善は、大規模なプラットフォーム全体にいきなり導入するよりも、まずは小さな部分プロダクトから導入することで気軽に実験ができ、結果として全体に広げていくことが容易になるでしょう。一定期間後には、結果を踏まえてプラットフォーム全体への展開を判断していきたいと考えています。
おわりに
本記事では、超軽量な画像プレースホルーダー生成アルゴリズムであるBlurHashを紹介し、Rails + React による progressive image の実装を説明しました。
ユーザ体験の向上にこだわりたいウェブエンジニアの皆さんの参考になれば幸いです。
(この記事を読んだ社員向け情報: wantedly/perk#1898)
Wantedlyでは、サイトパフォーマンス向上にこだわりのあるウェブエンジニア、新規プロダクトを愛情を込めて育てたいプロダクト開発エンジニア、プラットフォームで広く使われる技術基盤を開発するエンジニアなど、幅広い分野でソフトウェアエンジニアを募集しています。「話を聞きに行きたい」ボタンからの応募をお待ちしています。