1
/
5

30バイトで改善する画像読み込み体験: BlurHashアルゴリズムを用いたprogressive image loading

こんにちは、あるいはこんばんは。川崎(@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では、サイトパフォーマンス向上にこだわりのあるウェブエンジニア、新規プロダクトを愛情を込めて育てたいプロダクト開発エンジニア、プラットフォームで広く使われる技術基盤を開発するエンジニアなど、幅広い分野でソフトウェアエンジニアを募集しています。「話を聞きに行きたい」ボタンからの応募をお待ちしています。


Webエンジニア
急成長中の新規事業を開発の力で加速してくれるWEBエンジニアを募集します!
ウォンテッドリーは「シゴトでココロオドルひとをふやす」というミッションの元、2011年2月の Wantedly リリースから一貫して「共感」を軸に、下記2つのプロダクトで人と会社の出会いを創出してきました。 ・「共感」を軸にした運命の出会いを創出する会社訪問アプリ「Wantedly Visit」 ・働き手同士のつながりを深めるつながり管理アプリ「Wantedly People」 そして、このサービスで出会い、生まれた「共感」を入社した後も継続し続けることを目的に、従業員の方のご定着・ご活躍を支援する新規事業として2020年に、”エンゲージメント事業”を開始しました。 <提供サービス> ・Perk:社内の幅広いニーズにフィットする1000以上のサービスを、会社で働くメンバーとそのご家族に特別価格で提供する福利厚生サービス ・Pulse:自律して同じ価値に向かうチームを生み出す、新しいモチベーション・マネージメントツール ・Story:会社のビジョンや、事業にかけるメンバーの想い等、共感できるストーリーの発信を通じて、会社全体の意思疎通を促進するオンラインの ”社内報” プラットフォーム。 これらをまとめて「 Engagemenet Suite 」と呼びます。 ▶サービス詳細:https://www.wantedly.com/about/engagement リリースから3年目を迎えたエンゲージメント事業は、これまでに加速度的な成長を続けており、 今後ウォンテッドリーの第二の柱となっていく事業です。
Wantedly, Inc.
ソフトウェアエンジニア
数年後を見据えたアーキテクチャを考えたいWEBエンジニア募集
”究極の適材適所により、シゴトでココロオドルひとをふやす” ウォンテッドリーは、究極の適材適所を通じて、あらゆる人がシゴトに没頭し成果を上げ、その結果成長を実感できるような「はたらくすべての人のインフラ」を構築しています。 私たちは「シゴトでココロオドル」瞬間とは「シゴトに没頭し成果を上げ、その結果成長を実感できる状態」瞬間と定義しています。 その没頭状態に入るには、内なるモチベーションを産み出す3要素が重要と考えています。 ・自律:バリュー(行動指針)を理解していて、自分で意思決定しながらゴールへ向かっている状態 ・共感:ミッションを有意義なものであり、その達成が自分の使命と感じられる状態 ・挑戦:簡単/困難すぎないハードルを持ち、成長を実感しながらフロー状態で取り組んでいる状態 この要素に基づき、下記のプロダクトを開発しています。 ・「共感」を軸にした運命の出会いを創出する会社訪問アプリ「Wantedly Visit」 ・働き手同士のつながりを深めるつながり管理アプリ「Wantedly People」 2020年より従業員の定着・活躍を支援すべく提供開始したEngagement Suite ・新しい福利厚生「Perk」 ・モチベーション・マネジメント「Pulse」 ・社内報「Story」 目下の目標は全世界1000万人のユーザーにWantedlyを使っていただくこと。 そのため海外展開にも積極的に取り組んでおり、シンガポールに拠点を構えています。
Wantedly, Inc.
DevOps Engineer
未来のWantedlyを牽引するインフラや技術基盤を一緒に作りませんか?
”究極の適材適所により、シゴトでココロオドルひとをふやす” ウォンテッドリーは、究極の適材適所を通じて、あらゆる人がシゴトに没頭し成果を上げ、その結果成長を実感できるような「はたらくすべての人のインフラ」を構築しています。 私たちは「シゴトでココロオドル」瞬間とは「シゴトに没頭し成果を上げ、その結果成長を実感できる状態」瞬間と定義しています。 その没頭状態に入るには、内なるモチベーションを産み出す3要素が重要と考えています。 ・自律:バリュー(行動指針)を理解していて、自分で意思決定しながらゴールへ向かっている状態 ・共感:ミッションを有意義なものであり、その達成が自分の使命と感じられる状態 ・挑戦:簡単/困難すぎないハードルを持ち、成長を実感しながらフロー状態で取り組んでいる状態 この要素に基づき、下記のプロダクトを開発しています。 ・「共感」を軸にした運命の出会いを創出する会社訪問アプリ「Wantedly Visit」 ・働き手同士のつながりを深めるつながり管理アプリ「Wantedly People」 2020年より従業員の定着・活躍を支援すべく提供開始したEngagement Suite ・新しい福利厚生「Perk」 ・モチベーション・マネジメント「Pulse」 ・社内報「Story」 目下の目標は全世界1000万人のユーザーにWantedlyを使っていただくこと。 そのため海外展開にも積極的に取り組んでおり、シンガポールに拠点を構えています。
Wantedly, Inc.
Invitation from Wantedly, Inc.
If this story triggered your interest, have a chat with the team?
27 Likes
27 Likes

Weekly ranking

Show other rankings
Like Yoshinori Kawasaki's Story
Let Yoshinori Kawasaki's company know you're interested in their content