Посмотрите эту статью в моем блоге
Полный код этого поста можно посмотреть на github.
Для того чтобы познакомиться с flutter, я решил создать проект flutter 3, который позволял бы удалять фоновые изображения с фотографий.
Используя отличный апи remove bg api (ограничение на 50 вызовов апи в месяц для бесплатного уровня), я могу просто отправить изображение и затем показать его пользователю.
Из-за моего незнания экосистемы flutter я столкнулся с рядом проблем.
Первая связана с библиотекой http dio
во flutter. Вызов remove.bg api возвращает файл в байтах, но если в http-запросе не указать байты, вы получаете строку, которую нельзя использовать ни для чего/.
var formData = FormData();
var dio = Dio();
// flutter add api token
// hardcoded free access token
dio.options.headers["X-Api-Key"] = "<API_KEY>";
try {
if (kIsWeb) {
var _bytes = await image.readAsBytes();
formData.files.add(MapEntry(
"image_file",
MultipartFile.fromBytes(_bytes, filename: "pic-name.png"),
));
} else {
formData.files.add(MapEntry(
"image_file",
await MultipartFile.fromFile(image.path, filename: "pic-name.png"),
));
}
Response<List<int>> response = await dio.post(
"https://api.remove.bg/v1.0/removebg",
data: formData,
options: Options(responseType: ResponseType.bytes));
return response.data;
remove.bg api ожидает form_data со свойством image data.
Для http-клиента dio
Response<List<int>> response = await dio.post(
"https://api.remove.bg/v1.0/removebg",
data: formData,
options: Options(responseType: ResponseType.bytes));
вы должны определить тип ответа для изображений, иначе вы получите запутанную строку в двоичной кодировке, с которой сложно взаимодействовать.
Это вызывает проблемы и сбои с загруженным изображением, очень трудно отлаживать, возможно, было бы проще, если бы вы знали больше о flutter и знали, как и где отслеживать ошибки.
Еще одна интересная особенность — логика загрузки. dart:io
не поддерживается в вебе, в результате мне нужно было найти обходной путь для логики загрузки, в случае, если элемент якоря выдает ошибку для мобильного усложнения, мне нужно будет условно отрисовывать его в зависимости от ситуации или использовать динамический импорт только для веба.
downloadButton = _html.AnchorElement(
href:
"$header,$base64String")
..setAttribute("download", "file.png")
..click()
В целом, адаптация кода flutter к веб является немного сложной задачей, но я уверен, что у меня есть прочный фундамент, на который можно опираться.
Более подробную информацию можно найти на сайте https://docs.flutter.dev/cookbook/plugins/picture-using-camera.
Для тех, кому интересно, я добавил действие github для автоматического развертывания на сайте github pages.
name: Flutter Web
on:
push:
branches:
- main
jobs:
build:
name: Build Web
env:
my_secret: ${{secrets.commit_secret}}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.0.3'
- run: flutter pub get
- run: flutter build web --release
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: gh-pages # The branch the action should deploy to.
folder: build/web # The folder the action should deploy.
Это действие соберет web для flutter 3.0.3 и затем развернет его в ветке, которая будет отображаться на страницах github.
Для того чтобы иметь реализации, специфичные для конкретной платформы, лучше всего использовать вложенные импорты
import 'package:rm_img_bg/download_button_main.dart'
if (dart.library.html) 'package:rm_img_bg/download_button_web.dart';
Убедитесь, что функции и классы определены одинаково
import 'package:flutter/material.dart';
import 'dart:convert';
import 'dart:html' as _html;
import 'dart:typed_data';
class DownloadButtonProps {
List<int> imageInBytes;
DownloadButtonProps({ required this.imageInBytes});
}
class DownloadButton extends StatelessWidget {
final DownloadButtonProps data;
const DownloadButton({Key? key, required this.data}): super(key: key);
@override
Widget build(BuildContext context) {
String base64String = base64Encode(Uint8List.fromList(data.imageInBytes));
String header = "data:image/png;base64";
return ElevatedButton(
onPressed: () => {
// saveFile(uploadedImage.toString())
{
_html.AnchorElement(
href:
"$header,$base64String")
..setAttribute("download", "file.png")
..click()
}
},
child: const Text("Save File"),
);
}
}
Мобильная версия (необходимо сделать)
import 'package:flutter/material.dart';
class DownloadButtonProps {
List<int> imageInBytes;
DownloadButtonProps({ required this.imageInBytes});
}
class DownloadButton extends StatelessWidget {
final DownloadButtonProps data;
const DownloadButton({Key? key, required this.data}): super(key: key);
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => {
// saveFile(uploadedImage.toString())
{
print("DO SOMETHING HERE")
}
},
child: const Text("Save File"),
);
}
}
Я решил добавить поддержку мобильных устройств для проекта flutter.
if (kIsWeb) {
return Scaffold(
appBar: AppBar(title: const Text('Display the Picture')),
// The image is stored as a file on the device. Use the `Image.file`
// constructor with the given path to display the image.
body: Container(
child: Row(children: [
Column(children: [
Text("Original Image"),
image,
]),
Column(children: [
Text("Background Removed Image"),
otherImage,
downloadButton,
]),
])));
}
// add bigger font and padding on the item.
// extra padding on the save file item
return Scaffold(
appBar: AppBar(title: const Text('Display the Picture')),
// The image is stored as a file on the device. Use the `Image.file`
// constructor with the given path to display the image.
body: SingleChildScrollView(
child: Column(children: [
// Original Image with 16 font and padding of 16
Text("Original Image", style: const TextStyle(fontSize: 16)),
Padding(padding: EdgeInsets.symmetric(vertical: 4)),
image,
Text("Background Removed Image", style: const TextStyle(fontSize: 16)),
Padding(padding: EdgeInsets.symmetric(vertical: 4)),
otherImage,
Padding(padding: EdgeInsets.symmetric(vertical: 4)),
downloadButton,
])));
Зачастую это связано с условным рендерингом в зависимости от платформы. По сути, для настольного компьютера изображения должны располагаться рядом друг с другом, а для мобильного пользователь должен прокрутить страницу вниз, чтобы увидеть оригинал и изображение, удаленное с фона.
Я думаю, что в большинстве случаев лучше использовать сайт remove.bg, но интересно, чтобы приложение делало это на ходу.
Будущие улучшения могут включать
- добавление кнопки к изображению для его увеличения.
- изменить логику сохранения, чтобы пользователь мог выбрать имя файла.
Поскольку flutter поддерживает несколько платформ, некоторые функции не полностью поддерживаются кросс-платформенно.
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
class DownloadButtonProps {
List<int> imageInBytes;
DownloadButtonProps({ required this.imageInBytes});
}
class DownloadButton extends StatelessWidget {
final DownloadButtonProps data;
const DownloadButton({Key? key, required this.data}): super(key: key);
Future<String> getFilePath() async {
Directory? appDocumentsDirectory;
try {
appDocumentsDirectory ??= await getExternalStorageDirectory();
} catch (e) {
print(e);
}
print(appDocumentsDirectory);
appDocumentsDirectory ??= await getApplicationDocumentsDirectory();
String appDocumentsPath = appDocumentsDirectory.path;
// random file name to avoid overwriting existing files.
String fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg';
String filePath = '$appDocumentsPath/$fileName';
print(filePath);
return filePath;
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
// saveFile(uploadedImage.toString())
{
File file = File(await getFilePath());
await file.writeAsBytes(data.imageInBytes);
}
},
child: const Text("Save File"),
);
}
}
Я также обновил плавающую кнопку действия, чтобы она возвращалась, если remove.bg api возвращает строку (скорее всего, сообщение об ошибке).
floatingActionButton: FloatingActionButton(
// Provide an onPressed callback.
onPressed: () async {
// Take the Picture in a try / catch block. If anything goes wrong,
// catch the error.
try {
// Ensure that the camera is initialized.
await _initializeControllerFuture;
// Attempt to take a picture and get the file `image`
// where it was saved.
final image = await _controller.takePicture();
final uploadedImageResp = await uploadImage(image);
// If the picture was taken, display it on a new screen.
if (uploadedImageResp.runtimeType == String) {
errorMessage = "Failed to upload image";
return;
}
// if response is type string, then its an error and show, set message
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => DisplayPictureScreen(
// Pass the automatically generated path to
// the DisplayPictureScreen widget.
imagePath: image.path,
uploadedImage: uploadedImageResp),
),
);
} catch (e) {
// If an error occurs, log the error to the console.
print(e);
}
},
child: const Icon(Icons.camera_alt),
В следующей статье я расскажу о настройке fastlane для развертывания приложения в магазине google play.
Следующая статья будет доступна в блоге, подпишитесь на мою rss-ленту, чтобы узнать, когда появится следующая статья.