이미지 변환 앱은 google play에 널려 있지만, 문제는 어마무시한 광고들때문에 상당히 귀찮다는 단점이 있다. PC에서는 포토샵 등의 각종 툴과 얼마전 만들었던 python 스크립트로 쉽게 가능했지만, 태블릿으로 하려고 하니 마땅히 사용할 툴이 없다. 어렵지 않을 것 같아 그냥 만들어 사용하기로 한다.
webp
호환성이 썩 좋지 않은 webp 포맷으로 구지 변환을 하고자 하는 이유는 블로그에 이미지를 올리기 위함이다. 참으로 쓰잘데기 없는 짓이지만, 의미없는 최적화 점수가 신경이 쓰이기 때문이다.
착각
아주 쉬울 줄 알았다. 왜냐... webp 자체가 구글에서 만든 이미지 포맷이기 때문이다. 하지만, 어처구니 없게도, 쉽지가 않았다.
일단 flutter에서 제공하는 img 패키지가 webp 변환을 중단했고, img_v3 또한 에러가 났다. 어쩔수 없이.. native 코드를 건드려야만 했다. 그리고, 보안정책으로 인해 갤러리로 이미지를 저장하는 과정또한 거쳐야했다. 떄문에 아래 코드는 안드로이드 14버전에서만 작동을 할 것이다.
내 태블릿에서만 쓸 용도인데, 구지 버전 분기를 태울 필요까지 느끼지 못했다.
삽질
이렇게 된 이상 플러터는 거의 UI만 담당하고 나머지는 처리는 전부 native 코드로 처리를 해야하는데, 아뿔싸.. method 채널 뿐 아니라, 이미지 변환에 대한 이해가... 거의 없다.
몇번의 삽질 끝에.. 예전 같으면 stackvoerflow를 미친듯 찾아다녔겠지만, 그냥 chatGPT에서 답을 찾는다. 세상 참 편해졌다.
완료
간단하게, 플러터에서 사진을 선택하면, 해당 이미지를 kotlin으로 변환하고, 변환된 이미지를 갤러리에 저장한 뒤, 플러터에서 완료 플래그를 전달해주는 방식이다.
main.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MaterialApp(home: MyApp()));
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
File? _image;
String? _imageName;
static const platform = MethodChannel('com.example.towebp/channel');
Future<void> _pickImage() async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
setState(() {
_image = File(pickedFile.path);
_imageName = pickedFile.name;
});
}
}
Future<void> _convertToWebp({bool resizeTo700 = false}) async {
if (_image == null) return;
final imageBytes = await _image!.readAsBytes();
final fileName = 'webp_${DateTime.now().millisecondsSinceEpoch}.webp';
try {
final result = await platform.invokeMethod('convertAndSaveToWebP', {
'fileName': fileName,
'originalBytes': imageBytes,
'resizeTo700': resizeTo700,
});
if (result == true) {
setState(() {
_image = null;
_imageName = null;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('WebP 이미지 저장 완료')));
} else {
throw Exception('저장 실패');
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('오류 발생: $e')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Image to WebP Converter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_image == null
? const Text('No image selected.')
: Text('Image selected: $_imageName'),
const SizedBox(height: 10),
ElevatedButton(
onPressed: _pickImage,
child: const Text('Pick Image'),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => _convertToWebp(resizeTo700: false),
child: const Text('Convert to WebP'),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () => _convertToWebp(resizeTo700: true),
child: const Text('Convert to WebP_700px'),
),
],
),
],
),
),
);
}
}
AndroidManifest.xml
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
build.gradle.kts
ndkVersion = "27.0.12077973"
MainActivity.kt
package com.example.towebp
import android.content.ContentValues
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.example.towebp/channel"
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
call, result ->
when (call.method) {
"convertAndSaveToWebP" -> {
val fileName = call.argument<String>("fileName")
val originalBytes = call.argument<ByteArray>("originalBytes")
val resizeTo700 = call.argument<Boolean>("resizeTo700") ?: false
if (fileName != null && originalBytes != null) {
val success = saveImageAsWebP(fileName, originalBytes, resizeTo700)
result.success(success)
} else {
result.error("INVALID_ARGUMENTS", "Filename or bytes missing", null)
}
}
else -> result.notImplemented()
}
}
}
private fun saveImageAsWebP(fileName: String, originalBytes: ByteArray, resizeTo700: Boolean): Boolean {
return try {
val originalBitmap = BitmapFactory.decodeByteArray(originalBytes, 0, originalBytes.size)
val bitmap = resizeBitmapIfNeeded(originalBitmap, resizeTo700)
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/webp")
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/WebPApp")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val resolver = applicationContext.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
resolver.openOutputStream(uri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, 100, outputStream)
outputStream.flush()
}
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, contentValues, null, null)
true
} else {
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
private fun resizeBitmapIfNeeded(bitmap: Bitmap, resizeTo700: Boolean): Bitmap {
if (!resizeTo700 || bitmap.width <= 700) return bitmap
val aspectRatio = bitmap.height.toDouble() / bitmap.width
val newWidth = 700
val newHeight = (newWidth * aspectRatio).toInt()
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
}
일은 chatGPT가 했으니, 나는 UI나 다듬어 봐야겠다.