モバイルで綴ったデータをサーバへ届ける管を作っている連載の続き。これまでテキストだけが届いていたところに、写真の画像本体を流す回の記録です。
ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。
これまでの回で、スマホで綴った「写真+言葉」のうち言葉のほうはサーバに届くようになった。でも写真の画像本体はまだ送っていない。同期APIはテキストとメタ情報だけを運ぶ作りで、画像は別口になっています。今回はその画像を届けた。
画像と言葉が、別の写真に座ってしまう
同期で言葉を送った時点で、サーバには端末が振った写真のID(client_photo_id)で写真が1行できています。言葉はその行にぶら下がります。同期は段階的に作っていて、まず言葉から通した。画像本体を送るのはこの回からで、その行の画像の中身はまだ空だ。
一方、サーバには画像アップロード用の口がもともと別にあった。ただしそれはファイルを受け取るたびに新しい写真を1行作るだけの素朴な作りで、client_photo_id を見ていません。この口をそのまま使うと、画像は同期で作った行に載らず、別の新しい行にぶら下がります。結果、「言葉はあるが画像のない行」と「画像はあるが言葉のない行」に分裂して、web 側はどちらも中途半端にしか出せません。
なので、画像を送る前にこの口を変えた。同じ client_photo_id で既存の写真を探し、いればそこへ画像を載せます(いなければそのIDで作ります)。同期と同じIDを鍵にするから、画像が言葉と同じ行に着地します。
ここはサーバ側だけで完結する地味な変更で、入れた時点では見た目は何も変わらない(まだ誰も画像を上げていないから)。それでも、画像を上げる処理と、画像の貼り先を用意する処理は別の回に割った。貼り先がズレていると上げても無駄になるので、土台のほうを先に静かに通しておきたかった。
バイトはどこにある? ——端末のOSで違う
画像を上げるには、まず端末から画像のバイト(中身データ)を取り出さないといけません。さっきの client_photo_id(端末が振る番号で、どのOSでも同じ)とは別の話だ。ここで引っかかったのは、その中身データが端末のどこにあるかがOSで違うこと。
整理すると、同じ「写真のバイトをくれ」でも経路が割れます。
- スマホ:
photo_managerに写真のIDを渡してもらいます - デスクトップ(自分は
flutter run -d windowsで開発している): 端末に保存したファイルパスから直接読みます
// desktop はファイルから、mobile は photo_manager から、と分岐する
if (PlatformUtils.isDesktopOS) {
final file = File(photo.localPath); // ← デスクトップ:パスから直接
return resizeToJpeg(await file.readAsBytes());
}
final asset = await AssetEntity.fromId(photo.assetId); // ← スマホ:ライブラリ越し
return asset.thumbnailDataWithSize(const ThumbnailSize(1920, 1920));スマホ側で thumbnailDataWithSize を使っているのには理由があります。iPhone の写真の実体は HEIC という形式で、そのまま読むとあとのリサイズ処理がデコードできません。photo_manager のサムネ取得はJPEGで返してくれるので、HEIC を踏まずに済みます。「縮小も兼ねてサムネで取る」というより、HEICを回避する出口として選んだほうが近い。
Windowsで動く縮小を選ぶ
サイトできれいに見せるにはある程度の解像度が要る一方、元画像をそのまま上げると重いです。長辺1920pxくらいのJPEGに縮めてから送ることにした。縮小は端末側でやります。
縮小ライブラリを何にするかで少し止まった。よく使われる flutter_image_compress は速いが、Windows デスクトップに対応していません。自分はまさに Windows で開発・テストしているので、ここで詰む。なので、純粋な Dart で書かれた image パッケージにした。ネイティブ実装より遅いが、今回は手動の開発用ボタンから叩くだけなので速度は問題にならない。動くプラットフォームの広さを取った。
// 長辺が1920を超えるときだけ縮小し、JPEGで再エンコードする(拡大はしない)
final longEdge = decoded.width >= decoded.height ? decoded.width : decoded.height;
final out = longEdge > maxLongEdge
? (decoded.width >= decoded.height
? img.copyResize(decoded, width: maxLongEdge) // 横長:横を1920に
: img.copyResize(decoded, height: maxLongEdge)) // 縦長:縦を1920に
: decoded;
return img.encodeJpg(out, quality: 88);縮小して送るだけだと、写真に埋まった撮影場所(GPS)や機材情報が EXIF というメタデータとしてそのまま web まで付いてきます。image パッケージは縮小しない小さい画像だと EXIF を持ち越すので、回転だけ画素に焼いてからEXIF を明示的に空にしています(exif: img.ExifData())。「再エンコード ≠ メタデータ削除」という点で、縮小とは別に意識的に処理を入れています。
「もう上げた」を覚えておく
最後に、同じ画像を毎回上げ直さないようにした。写真に「アップロード済み」の印(サーバが返した画像URL)を持たせて、印が付いていない写真だけを上げます。
これは前の回でテキストに対してやった「同期済みの綴りは送らない」と同じ発想の、画像版。送るのは「綴りは同期済みだが画像はまだ」の写真だけになります。一度上げたら印が付くので、次からは飛ばします。
// 画像URL(印)が無い=まだ上げてない写真だけを対象にする
..where((photo) =>
photo.syncState.equals('synced') &
photo.remoteUrl.isNull())これで、スマホで撮って綴った写真が、画像ごと web まで流れる道がつながった。残っているのは、自分の端末で実際にボタンを押して、サイトに写真が出るのを目で見る一手だけになった。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。