Flutter×Django（DRF）で画像をアップロードする方法

バックエンドにDjango Rest Frameworkを使用して、Flutterで画像を扱う方法をまとめます
なお、Flutterの実装では状態管理のProviderを使用しています


# バックエンド導入 Django

ポイントのみ記載

settings.py
```python
# 開発環境のみで使用
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
```

model.py
```python
from django.db import models

def top_image_upload_path(instance, filename):
    ext = filename.split('.')[-1]
    return '/'.join(['images', 'top_image', f'{instance.user.id}{instance.nickname}.{ext}'])


class Profile(models.Model):
    top_image = models.ImageField(
        verbose_name="トップ画像", upload_to=top_image_upload_path, blank=True, null=True)
```


serializers.py
```python
from rest_framework import serializers

class ProfileSerializer(serializers.ModelSerializer):
    class Meta:
        model = Profile
        fields = (
            'top_image',
        )
```


views.py
```python
from .models import Profile
from .serializers import ProfileSerializer

class ProfileViewSet(ModelViewSet):
    queryset = Profile.objects.all()
    serializer_class = ProfileSerializer
```


project urls.py
```python
from django.contrib import admin
from django.urls import path
from django.conf.urls import include
from django.conf.urls.static import static
from django.conf import settings

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('app.urls')),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

```


app urls.py
```python
from rest_framework.routers import DefaultRouter
from django.urls import path
from django.conf.urls import include

app_name = 'app'

router = DefaultRouter()
router.register('profiles', ProfileViewSet)

urlpatterns = [
    path('', include(router.urls)),
]
```

# Flutter

imagepickerを使用するのでインストールします
またバックエンドと通信するためのHttpクライアントのDioを使用します
また状態管理のProviderも使用した実装とします
バージョンは最新のものを使ってください

またnoimages.pngのような画像ファイルを作ってimagesフォルダを作ってその下に格納してください

pubspec.yaml
```
dependencies:
  provider: ^6.0.2
  http: ^0.13.4
  dio: ^4.0.4
  image_picker: ^0.8.4+10

flutter:
  assets:
   - images/
```




main.dart Providerを使用できるようにする
```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => ProfileProvider()),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget  {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MenuScreen(),
      routes: <String, WidgetBuilder>{
        '/my-profile': (BuildContext context) => MyProfileScreen(),
      },
    );
  }
}
```

簡単なモデルですが、モデルを定義します
model.dart
```dart
class ProfileModel {
  late String? topImage = '';

  ProfileModel({
    this.topImage,
  });
}
```

状態管理のProviderを定義します
ここに画像アップロード処理のロジックを記述します
_picker.pickImage(source: ImageSource.gallery)の処理で画像をピックアップしています
ImageSourceの値をCameraにするとカメラ撮影モードにすることもできますがこの例ではアルバムから選択にしています
http://10.0.2.2:8000は127.0.0.1のエイリアスです
Djangoと使用しようとするIPがかぶっているため開発環境ではエイリアスを敷く必要があります
本番環境などの違う環境に対応させる場合は環境変数で対応します

画像のアップロード処理は以下の部分で、フォームデータに整形するのがミソです
画像をアップロードしない場合はnullを詰めて送ります
```dart
FormData.fromMap({
  "top_image": uploadTopImage != null ?
    await MultipartFile.fromFile(
      uploadTopImage!.path,
      filename: uploadTopImage!.path.split('/').last,
     ) :
     myProfile.topImage,
});
```


以下は画像のフェッチと画像新規作成・更新の処理の例です

provider.dart
```dart
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

class ProfileProvider with ChangeNotifier {
  final Uri _uriHost = Uri.parse('http://10.0.2.2:8000');

  bool _isSuccess = false;
  File? uploadTopImage;

  ProfileModel myProfile = ProfileModel(
      topImage: null,
  );

  Future fetchMyProfile(String id) async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      await _prepareDio(dio);

      final Response<dynamic> profile = await dio.get(
        '/api/profiles/$id',
      );
      myProfile = _inputProfileModel(profile.data!);
      _isSuccess = true;
    } catch(error) {
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

  // 画像ピックアップ処理
  Future pickTopImage() async {
    _isSuccess = false;
    final ImagePicker _picker = ImagePicker();
    final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
    if (image != null) uploadTopImage = File(image.path);
    notifyListeners();
    return _isSuccess;
  }

  Future createMyProfile(String id) async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      await _prepareDio(dio);

      FormData formData = FormData.fromMap({
        "top_image": uploadTopImage != null ?
          await MultipartFile.fromFile(
            uploadTopImage!.path,
            filename: uploadTopImage!.path.split('/').last,
          ) :
          myProfile.topImage,
      });

      // 画像アップロード処理
      final Response profile = await dio.post(
        '/api/profiles/$id',
        data: formData,
      );

      myProfile = _inputProfileModel(profile.data!);
      _isSuccess = true;
    } catch(error) {
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }

  Future updateMyProfile(String userId) async {
    _isSuccess = false;
    try {
      Dio dio = Dio();
      await _prepareDio(dio);

      FormData formData = FormData.fromMap({
        "top_image": uploadTopImage != null ?
          await MultipartFile.fromFile(
            uploadTopImage!.path,
            filename: uploadTopImage!.path.split('/').last,
          ) :
          myProfile.topImage,
      });

      // 画像アップロード処理
      final Response profile = await dio.patch(
          '/api/profiles/$id/',
          data: formData,
      );
      myProfile = _inputProfileModel(profile.data!);
      _isSuccess = true;
    } catch(error) {
      print(error);
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }


  ProfileModel _inputProfileModel(dynamic profile) {
    return ProfileModel(
      topImage: profile!['top_image'],
    );
  }

  List<ProfileModel> _inputProfileModelList(dynamic profiles) {
    return profiles.map<ProfileModel>(
      (profile) => _inputProfileModel(profile)
    ).toList();
  }

  Future<void> _prepareDio(Dio dio) async {
    dio.options.baseUrl = _uriHost.toString();
    dio.options.connectTimeout = 5000;
    dio.options.receiveTimeout = 3000;
  }

}
```

画面の例は以下です
Django側で格納されている画像のURLのホストは127.0.0.1で格納されているので、エイリアスに書き換えている処理を挟んでいます
また画像の表示はimagepickerで選択された画像⇒アップロードされている画像⇒noimage画像の順で表示されるようにしています
imagePickerで選択した画像はローカルなFile、バックエンドから取得する画像はNetwork、プログラム上の画像はassetsから取得するなど画像の格納場所でImageウィジェットで使うものが変わることに注意必要です

screen.dart
```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';


class MyProfileScreen extends StatelessWidget {
  const MyProfileScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final Uri _uriHost = Uri.parse('http://10.0.2.2:8000');
    int id = 1;

    return Scaffold(
      appBar: AppBar(
        title: const Text('マイプロフィール'),
      ),
      body: Consumer<ProfileProvider>(
        builder: (context, profileProvider, loginProvider, _) {
          return Padding(
              padding: const EdgeInsets.all(32.0),
              child: SingleChildScrollView(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    profileProvider.uploadTopImage != null ? 
                      Image.file(profileProvider.uploadTopImage!)  :
                         rofileProvider.myProfile.topImage != null ?
                            Image.network('${profileProvider.myProfile.topImage?.replaceFirst('http://127.0.0.1:8000/', _uriHost.toString())}',
                              width: 100, fit: BoxFit.fill,
                              errorBuilder: (context, error, stackTrace) {
                                return Image.asset('images/nophotos.png',width: 100, fit: BoxFit.fill,);
                              },
                            ) :
                            Image.asset('images/nophotos.png', width: 100, fit: BoxFit.fill,),
                    TextButton(
                      child: const Text('画像変更'),
                      onPressed: () => profileProvider.pickTopImage(),
                    ),
                    Padding(
                      padding: const EdgeInsets.only(top: 16.0),
                      child: ElevatedButton(
                        child: profileProvider.myProfile.user == null ? const Text('プロフィールを作成する') : const Text('プロフィールを更新する'),
                        style: ElevatedButton.styleFrom(
                          fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32),
                        ),
                        onPressed: () {
                          if (profileProvider.myProfile.user == null) {
                            profileProvider.createMyProfile(id).then((isSuccess) {
                              if (isSuccess) {
                                Navigator.pushReplacementNamed(context, '/my-profile');
                                showSnackBar(context, 'プロフィールが新規作成されました');
                              } else {
                                showSnackBar(context, 'エラーが発生しました');
                              }
                            });
                          }
                          else {
                            profileProvider.updateMyProfile(id).then((isSuccess) {
                              if (isSuccess) {
                                Navigator.pushReplacementNamed(context, '/my-profile');
                                showSnackBar(context, 'プロフィール更新が完了しました');
                              } else {
                                showSnackBar(context, 'エラーが発生しました');
                              }
                            });
                          }
                        },
                      ),
                    ),
                  ],
                ),
              ),
          );
        },
      ),
    );
  }
}

```


ネットワークの負荷を減らしたいなどで画像圧縮の処理を加える場合は以下のように実装します
provider.dart
```dart
import 'package:image/image.dart';

Future pickTopImage() async {
    _isSuccess = false;
    final ImagePicker _picker = ImagePicker();
    try {
      final XFile? image = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 25);

      if (image != null) {
        final _imageDecode = decodeImage(File(image.path).readAsBytesSync());
        if (_imageDecode != null) {
          var _imageResize;
          const int _imageLongSide = 720;
          if (_imageDecode.width > _imageDecode.height) {
            if (_imageDecode.width > _imageLongSide) {
              _imageResize = copyResize(
                  _imageDecode,
                  width: _imageLongSide,
                  height: _imageLongSide * _imageDecode.height ~/ _imageDecode.width
              );
            }
          } else {
            if (_imageDecode.height > _imageLongSide) {
              _imageResize = copyResize(
                  _imageDecode,
                  width: _imageLongSide * _imageDecode.width ~/ _imageDecode.height,
                  height: _imageLongSide
              );
            }
          }
          if (_imageResize != null) {
            File(image.path).writeAsBytesSync(encodePng(_imageResize));
          }
        }
        uploadTopImage = File(image.path);
      }
      _isSuccess = true;
    } catch (error) {
      print("エラーが発生しました");
      _isSuccess = false;
    }
    notifyListeners();
    return _isSuccess;
  }
```

画像圧縮やコーデック変換に以下のパッケージを利用しています
https://pub.dev/packages/image


ちなみに今回、リサイズがどの程度うまくいっているか自分の環境で試したところ、5.6MBの画像を何もリサイズの処理を施さないでアップロードした場合は5.3MBの画像だったのに対し、リサイズの処理を施すと232KBにまで圧縮されていました


これでDjangoなどの自作のバックエンドを使用している時に画像をアップロードすることができることが確認できました

ところでFlutter×自作のバックエンドで開発しているような記事をあまり（というか全く？）見かけないですが、やはりFlutterで開発するなら素直にFirebaseかそのほかのBaaS使えってことなんですかね



# より実用的なアプリにおける実装方法

より実用的なアプリにおける画像アップロードの実装方法を知りたい方はこちらの記事をご覧ください
マッチングを一から作りながら諸々の実装を解説しています

https://happy-daikicchi.com/archives/28