【Flutter】SJISでファイルを出力する


はじめに

裏で昔のシステムが動いていたり、Windowsが絡んできたりすると往々にして「アプリから送信するファイルは SJIS で」ということがありますよね。
dart:convertEncodingというクラスがあったのでサクッと対応できると思いきや Dart の Encoding では SJIS が未対応でした。
いくつかプラグインがあるようでしたが、このためにプラグイン入れるのもなあと思ったので MethodChannelを使ってAndroid/iOSネイティブ側にやってもらうことにしました。

実装する

  1. 同一プロジェクト内にプラグインのプロジェクトを作成
flutter create --template=plugin --platforms=ios,android flutter_sjis
  1. 生成されたMethodChannelを使うDartのクラスを変更
import 'dart:async';

import 'package:flutter/services.dart';

class FlutterSjis {
  static const MethodChannel _channel = MethodChannel('flutter_sjis');

  /// 文字列を受け取りSJISに変換
  static Future<List<int>> encode(String value) async {
    return await _channel.invokeMethod('encode', value);
  }

  /// SJISのバイト配列を受け取り文字列に変換
  static Future<String> decode(List<int> source) async {
    return await _channel.invokeMethod('decode', source);
  }
}

3-1. Android側の実装

package com.github.kiyosuke.flutter_sjis

import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import java.nio.charset.Charset

class FlutterSjisPlugin : FlutterPlugin, MethodCallHandler {

    private lateinit var channel: MethodChannel

    override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        channel = MethodChannel(flutterPluginBinding.binaryMessenger, "flutter_sjis")
        channel.setMethodCallHandler(this)
    }

    override fun onMethodCall(call: MethodCall, result: Result) {
        when (call.method) {
            "encode" -> encode(call, result)
            "decode" -> decode(call, result)
            else -> result.notImplemented()
        }
    }

    private fun encode(call: MethodCall, result: Result) {
        val value = call.arguments as String
        val encoded = value.toByteArray(charset = Charset.forName("SJIS"))
        result.success(encoded)
    }

    private fun decode(call: MethodCall, result: Result) {
        val source = call.arguments as ByteArray
        val decoded = String(source, charset = Charset.forName("SJIS"))
        result.success(decoded)
    }

    override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
        channel.setMethodCallHandler(null)
    }
}

3-2. iOS側の実装

import Flutter
import UIKit

public class SwiftFlutterSjisPlugin: NSObject, FlutterPlugin {
    public static func register(with registrar: FlutterPluginRegistrar) {
        let channel = FlutterMethodChannel(name: "flutter_sjis", binaryMessenger: registrar.messenger())
        let instance = SwiftFlutterSjisPlugin()
        registrar.addMethodCallDelegate(instance, channel: channel)
    }
    
    public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        switch call.method {
        case "encode":
            encode(call, result: result)
            break
        case "decode":
            decode(call, result: result)
            break
        default:
            result(FlutterMethodNotImplemented)
            break
        }
    }
    
    private func encode(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        let value = call.arguments as! String
        let encoded = value.data(using: String.Encoding.shiftJIS, allowLossyConversion: true)!
        let data = FlutterStandardTypedData.init(bytes: encoded)
        result(data)
    }
    
    private func decode(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        let source = call.arguments as! FlutterStandardTypedData
        let decoded = String.init(bytes: source.data, encoding: String.Encoding.shiftJIS)!
        result(decoded)
    }
}
  1. メインプロジェクトの pubspec.yaml -> dependencies に作成したプラグインを追加
dependencies:
  flutter:
    sdk: flutter

  # パス指定でローカルのプラグインを利用できる
  flutter_sjis:
    path: ./flutter_sjis

利用例

ボタンをタップしたら文字列をSJISにエンコードしファイル保存する例です。
保存先のディレクトリ取得には path_provider を使用します。

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_sjis/flutter_sjis.dart';
import 'package:path_provider/path_provider.dart';

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    Key? key,
  }) : super(key: key);

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  bool isLoading = false;
  File? savedFile;
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('FlutterSjis'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ElevatedButton.icon(
              onPressed: () async {
                setState(() => isLoading = true);
                try {
                  savedFile = await saveToFile(testData);
                  setState(() {});
                } finally {
                  setState(() => isLoading = false);
                }
              },
              icon: const Icon(Icons.save),
              label: const Text('保存'),
            ),
          ],
        ),
      ),
    );
  }

  Future<File> saveToFile(String data) async {
    final filesdir = await getApplicationSupportDirectory();
    final timestamp = DateTime.now().millisecondsSinceEpoch;
    final filename = '$timestamp.csv';
    final file = File('${filesdir.path}/$filename');
    final encodedData = await FlutterSjis.encode(data);
    return await file.writeAsBytes(encodedData);
  }
}

const testData = """
id,name,birthday,age,gender
1,佐藤,2000-01-01,22,男
2,鈴木,2002-01-01,20,男
3,亀田,2004-01-01,18,女
""";

解説

MethodChannel

MethodChannelはFlutterとAndroid/iOSといったプラットフォームの間を橋渡ししてくれる仕組みです。
Flutter側からはMethodChannel#invokeMethodを実行することでネイティブで実装した処理を呼び出すことができます。
第一引数にメソッド名、第二引数に渡したいデータをとります。

static Future<List<int>> encode(String value) async {
  return await _channel.invokeMethod('encode', value);
}

MethodChannelを介してやりとりできるデータの型には以下のものです。

Flutter(Dart) Android(Kotlin) iOS(Swift)
null null nil
bool Boolean NSNumber(value: Bool)
int Int NSNumber(value: Int32)
int, if 32 bits not enough Long NSNumber(value: Int)
double Double NSNumber(value: Double)
String String String
Uint8List ByteArray FlutterStandardTypedData(bytes: Data)
Int32List IntArray FlutterStandardTypedData(int32: Data)
Int64List LongArray FlutterStandardTypedData(int64: Data)
Float32List FloatArray FlutterStandardTypedData(float32: Data)
Float64List DoubleArray FlutterStandardTypedData(float64: Data)
List List Array
Map HashMap Dictionary

Android
iOS

プリミティブな型は一通りサポートされているので、大体のケースで利用できると思います。

備考

  • iOSネイティブの実装に詳しくないので、これが正しい実装なのか不明
  • この実装だと UTF-8 -> SJIS の変換に失敗する文字列もありますがそれは本記事の主題ではないので記載していません。
  • 本記事のように別途プラグイン用のプロジェクトを作るべきか、プラグインを作成せずプロジェクト内に直接実装するべきなのか。。
    個人的にプラグインで分けた方がわかりやすくて好きですが、何か問題が出てきたらまた考えようと思います。