デバイスピクセル比が3のスマホでも表示領域の2倍の画像を表示してファイルサイズを大幅に削減する(横幅がわからない画像の場合)

に公開

前置き

Retinaディスプレイなどの高解像度デバイス向けに表示領域の2倍のピクセル数の画像を用意することはよく知られているテクニックかと思います。

しかし、Retinaのデバイスピクセル比(DPR)が2だったのは今や昔で、最近では3以上のデバイスも増えてきています。(iPhoneではXあたりからのようです)

しかし、表示領域に対して3倍の画像を表示させたいかというと多くのユースケースでファイルサイズの増大と比較してユーザー体験を向上させられているかというと微妙なのではと思ってしまいます。

400pxの表示領域に対して800pxの画像を表示した場合と1200pxの画像を表示した場合で、9/4=2.25倍のファイルサイズになってしまいますが、ユーザーが実感できる差は大きくないと思います。

下の画像を400px程度のDPRが3のスマホか、Macでウィンドウサイズを400pxに調整して比べてみてください。

2倍の画像

3倍の画像

https://assets.imgix.net/unsplash/motorbike.jpg?h=180&w=400&dpr=3

https://assets.imgix.net/unsplash/motorbike.jpg?h=180&w=400&dpr=2

課題

Next.jsを用いて説明していきますが、他のフレームワークや素のHTMLであっても同様のことを達成できます。

Next.jsで事前に表示領域のサイズがわからない画像にはfillを指定します。

<Image
  src="/sample.jpg"
  alt="Sample Image"
  fill
  sizes="100dvw"
  priority
  fetchPriority="high"
/>

このように記載するとレンダーされたHTMLは以下のようになります。(読みやすさのために改行しています。)

<img alt="Sample Image" decoding="async" data-nimg="fill" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent"
sizes="100dvw"
srcset="/_next/image?url=%2Fsample.jpg&amp;w=16&amp;q=75 16w,
/_next/image?url=%2Fsample.jpg&amp;w=32&amp;q=75 32w,
 /_next/image?url=%2Fsample.jpg&amp;w=48&amp;q=75 48w,
 /_next/image?url=%2Fsample.jpg&amp;w=64&amp;q=75 64w,
 /_next/image?url=%2Fsample.jpg&amp;w=96&amp;q=75 96w,
 /_next/image?url=%2Fsample.jpg&amp;w=128&amp;q=75 128w,
 /_next/image?url=%2Fsample.jpg&amp;w=256&amp;q=75 256w,
 /_next/image?url=%2Fsample.jpg&amp;w=384&amp;q=75 384w,
 /_next/image?url=%2Fsample.jpg&amp;w=640&amp;q=75 640w,
 /_next/image?url=%2Fsample.jpg&amp;w=750&amp;q=75 750w,
 /_next/image?url=%2Fsample.jpg&amp;w=828&amp;q=75 828w,
 /_next/image?url=%2Fsample.jpg&amp;w=1080&amp;q=75 1080w,
 /_next/image?url=%2Fsample.jpg&amp;w=1200&amp;q=75 1200w,
 /_next/image?url=%2Fsample.jpg&amp;w=1920&amp;q=75 1920w,
 /_next/image?url=%2Fsample.jpg&amp;w=2048&amp;q=75 2048w,
 /_next/image?url=%2Fsample.jpg&amp;w=3840&amp;q=75 3840w"
src="/_next/image?url=%2Fsample.jpg&amp;w=3840&amp;q=75"
>

srcsetになにやら色々記載されていますが、ブラウザが自動的にお使いのデバイスのビューポートの横幅とデバイスピクセル比(DPR)に合わせて画像を選択します。

私のスマートフォンはiPhone16 Proなのですが、ビューポートの横幅は402pxでDPRは3です。
この場合、1920pxの画像が選ばれてしまいます。(上記のsrcsetで1200wの次が1920wのため)

1920pxの画像が選ばれています

一応、Next.js側の設定でdeviceSivesに1206を追加して設定しておけば1206pxの画像を取得しようとしてくれはしますが。

しかし、ここで800px程度の画像を取得するようにできれば半分以上もバイト数を削減することができます。(もしも、何も設定せず1920pxが選ばれた場合、800pxと比較して5.76倍のファイルサイズになってしまいます。)
上記のsrcsetの場合なら828pxの画像が表示させたいです。

達成したいこと

100dvwや50%などの横幅の事前に表示領域の大きさがわからない画像に対して、最近のRetinaディスプレイなどのDPRが3のデバイスに対してもDPRが2のデバイスと同じ画像を表示させます。(つまり、表示領域の2倍のピクセル数の画像を表示させるということです。)

また、このような目的がある場合、真に達成したい目的は表示速度の改善である場合が多いかと思います。(通信量の削減そのものが目的の場合もあるかと思いますが)
したがって、preloadの際にも2倍のピクセル数の画像を取得させられることも要件に加えます。

https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Attributes/rel/preload

また、必要以上に画質の低い画像を表示させることは避けたいので以下の2つも要件に加えます。

  • DPRが2のデバイスに対しては通常通り2倍のピクセル数の画像を表示させること。
  • デスクトップなどで実際に大きな画像が必要とされる場合は必要な大きさの画像を表示させること。

表示領域のサイズが事前にわかっている場合(少し脱線)

200pxの画像に対して以下のように2倍までのピクセル数用の画像をsrcsetに2xまでの記載に抑え、3xを記載しないことで実現できます。

<img
    src="/en-US/docs/Web/HTML/Element/img/clock-demo-200px.png"
    alt="Clock"
    srcset="/en-US/docs/Web/HTML/Element/img/clock-demo-400px.png 2x" />

Next.jsのImageコンポーネントにwidthheightを指定した場合も2xまで記載されます。

Next.jsのImageコンポーネントの実装読んでみると、こんなコメントがあります。

This means that most OLED screens that say they are 3x resolution,
are actually 3x in the green color, but only 1.5x in the red and
blue colors. Showing a 3x resolution image in the app vs a 2x
resolution image will be visually the same, though the 3x image
takes significantly more data. Even true 3x resolution screens are
wasteful as the human eye cannot see that level of detail without
something like a magnifying glass.
https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/capping-image-fidelity-on-ultra-high-resolution-devices.html

https://github.com/vercel/next.js/blob/adc81abd894e1a3ea77b9ec23ed314275596319f/packages/next/src/shared/lib/get-img-props.ts#L186

要約すると2倍と3倍で見た目変わらないのに、物凄く多くのデータを必要とするので3x用の記載はしないと明示的にコメントしています。

また、コメント中で引用されているTwitter社の記事でも要約すると「2倍と3倍の画像の差なんて虫眼鏡でも使わないとわからない」「DPRが2より大きくても、2と同じ画像を表示させる。ただし、画像がクリックされたときにはオリジナル画像を表示する。」と書いています。

https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/capping-image-fidelity-on-ultra-high-resolution-devices.html

やっぱり、2倍と3倍で見た目変わらないのにバイト数増えすぎという意見が主流のようですね。

しかし、事前に画像の表示領域のサイズがわかっていない場合には、この方法を使用することができません。
そのため、別の方法を利用する必要があります。

sizesってメディアクエリ書けるんです。widthだけじゃないんです。

レスポンシブに画像を表示させたい場合、例えば、スマホでは100dvwで表示させて、デスクトップでは50dvwで表示させたいといったケースでsizes="(width < 750px) 100dvw, 50dvw"のように指定することができます。

https://nextjs.org/docs/pages/api-reference/components/image#responsive-image-with-fill

<Image
  src="/sample.jpg"
  alt="Sample Image"
  fill
  sizes="(width < 750px) 100dvw, 50dvw"
  priority
/>

このsizesプロパティですが、preloadでも利用できるので、preloadはしたいけどスマホ用とデスクトップ用で無駄に二重で取得したくないという場合に利用することができます。

先ほどの実装ではpriorityというオプションを設定しているので、preload用のlinkタグが以下のようにレンダリングされています。imagesizesimgタグにおけるsizesと同様の役割を担ってくれます。

<link rel="preload" as="image"
imagesrcset="/_next/image?url=%2Fsample.jpg&amp;w=16&amp;q=75 16w, /_next/image?url=%2Fsample.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=%2Fsample.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=%2Fsample.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=%2Fsample.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=%2Fsample.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=%2Fsample.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=%2Fsample.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=%2Fsample.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=%2Fsample.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=%2Fsample.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=%2Fsample.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=%2Fsample.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=%2Fsample.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=%2Fsample.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=%2Fsample.jpg&amp;w=3840&amp;q=75 3840w"
imagesizes="(width < 750px) 100dvw, 50dvw">

ちなみに脱線なのですが、Next.jsはまだ対応してないですが、React側で提供してくれているpreload関数を利用することで、sizesの代わりにmediaを利用することもできます。モバイル版ではそもそもpreloadしたくないという画像の場合にはこちらの機能の利用も検討できるかと思います。

https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Attributes/rel/preload#media_を含める

https://ja.react.dev/reference/react-dom/preload

https://nextjs.org/docs/app/api-reference/components/image#getimageprops

さらに、このsizesなんですが横幅以外にもメディアクエリで使用できる機能は使うことができます。

https://developer.mozilla.org/ja/docs/Web/API/HTMLImageElement/sizes

話を戻します、メディアクエリのresolution(解像度という意味です)というものを利用することでDPRに応じて表示領域のサイズを指定することができます。

https://developer.mozilla.org/ja/docs/Web/CSS/@media/resolution

察しの良い方なら気づいたと思いますが、例えば100dvwの領域で表示したい画像の場合、DPRが3のデバイスなら66dvwと指定することで、じっさいには 2/3 * 100dvw * 3 = 200dvw相当の画像を表示させることができます。
つまり、DPRが2のデバイスと同じ画像を表示させることができます。

具体的なsizesの指定については
sizes="(min-rssolution: 3x) 66dvw, 100dvw"
と記載します。

<Image
  src="/sample.jpg"
  alt="Sample Image"
  fill
  sizes="(resolution > 2x) 66vw, 100vw"
  priority
/>

実際にネットワークを確認してみると、828pxの画像が選ばれていることがわかります。

828pxの画像が選ばれています

何もしなかった場合と比べて、約80%もファイルサイズを削減できています!

しかし、AndroidなどではDPRが2と3の間の機種も多く、さらには3より大きい機種もあるため、より細かく指定した方が良いケースもあるかと思います。

例えば、DPRが2.9の機種の場合、290dvwのピクセル数の画像を取得することになってしまうので、
例えば
sizes="(min-rssolution: 3x) 66dvw, (min-rssolution: 2.5x) 80dvw,100dvw"
と書くことで、
2.9 / 2.5 * 200dvw = 232dvwの画像を取得させることができ、290dvwと比較してファイルサイズを36%節約することができています!

pictureを利用する方法について

pictureタグを利用する方法もあります。

<picture>
  <source
    srcSet="/sample.jpg?w=828&q=75 1206w, /sample.jpg"
    media="(min-resolution: 3x)"
    type="image/jpeg"
  />
  <img
    srcSet="/sample.jpg?w=1206&q=75 1206w, /sample.jpg"
    src="/sample.jpg"
    alt="Sample Image"
  />
</picture>

上記の実装ではDPRが3以上の場合(media="(min-resolution: 3x)"で指定しています)に/sample.jpg?w=828&q=75 1206wと指定していますが、1206px要求された場合に828px用の画像を表示させるという点でsizesを利用した方法と同様のことをやっています。

しかし、これをpreloadさせたいという場合に少し面倒になってしまいます。
また、srcSetの記載は面倒なのでNext.js側に生成して欲しいところですが3x用にカスタムのloaderを作成する必要があるでしょう。
具体的な実装方法についてはsizesのところで触れている脱線の内容を参照するとできると思います。

ただ、自前でやらないといけないことが多くて面倒ではあるのでNext.jsを利用している場合はsizesを利用するのが楽だと考えています。

まとめ

sizesに指定するメディアクエリにresolutionを利用することで、DPRが2より大きいデバイスに対しても必要以上に大きな画像を表示しないようにできることがわかりました。

写真として楽しむ分には画質は高ければ高いほど良いことだと思っていますが、プロダクト開発をする上ではLCPなどのパフォーマンスとトレードオフの関係がありバランスを取ってユーザーの体験を最適化する必要があると考えています。

今回は知覚が難しい割にファイルサイズが大幅に増えてしまうという問題だったので、トレードオフを意識すると十分に検討できる内容ではないでしょうか。

参考

下記の記事やIssueを参考にしました。

https://kurtextrem.de/posts/modern-way-of-img

https://github.com/ascorbic/unpic-img/issues/202

リポジトリ

ここに今回のデモアプリを置いています。

https://github.com/muka-nakazato/limit-image-dpr-demo

Discussion