【Flutter】ZIPファイル操作完全ガイド with archive

対象者

  • Dart/Flutterでファイル操作や圧縮・解凍を行いたい方
  • archiveパッケージの使い方を知りたい方
  • ZIPファイルの操作方法を学びたい方

はじめに

ファイルの圧縮や解凍は、データの保存や転送において重要な機能です。本記事では、Dart/Flutterを使用してファイルの書き込みからZIP圧縮、ZIPファイルの内容確認、そして解凍までの一連の流れを解説します。これにより、アプリケーションでのデータ管理が効率化し、ユーザー体験の向上につながります。

インストール

fltuter pub add archive

個人的にarchiveを使用しているので、こちらで解説します。
「Flutter で Zip を扱うパッケージを3つ試した」によると、200MB程度で失敗するようになるらしいですが、そんなデカいファイル使わんし。(大きなファイルを扱う場合は、動作検証が必要かも)
Tar、GZIP、BIzip2などでも圧縮、解凍可能みたいです。

ZIPファイルで圧縮

圧縮対象のファイルを作成

まず、圧縮するファイルを作成します。

import 'dart:io';
import 'package:archive/archive.dart';
import 'package:path/path.dart' as path;

void main() async {
  const fileSize = 1000;

  // ファイル作成
  final file1 = File('a.txt');
  final directory = Directory('b');
  await directory.create();
  final file2 = File(path.join(directory.path, 'c.txt'));

  final stream1 = file1.openWrite();
  final stream2 = file2.openWrite();
  for (int i = 0; i < fileSize; i++) {
    stream1.write('a');
    stream2.write('A');
  }
  await stream1.close();
  await stream2.close();

  final archive = Archive();

  // 圧縮
  for (final file in [file1, file2]) {
    archive.addFile(ArchiveFile(
        file.path, await file.length(), await file.readAsBytes()));
  }

  final encodedArchive = ZipEncoder().encode(archive);
  final zipFile = await File('test.zip').writeAsBytes(encodedArchive!);
}

ここでは、a.txtb/c.txtの2つのファイルを作成し、それぞれに文字を1000回書き込みます。

ZIPで圧縮

次に、これらのファイルをZIPアーカイブに追加します。

final archive = Archive();

// 圧縮
for (final file in [file1, file2]) {
  archive.addFile(ArchiveFile(
      file.path, await file.length(), await file.readAsBytes()));
}

final encodedArchive = ZipEncoder().encode(archive);
final zipFile = await File('test.zip').writeAsBytes(encodedArchive!);

Archiveオブジェクトにファイルを追加し、ZipEncoderでエンコードしてtest.zipというZIPファイルを作成します。

ZIPファイルの中身を見る

作成したZIPファイルの内容を確認します。

final input = InputFileStream(zipFile.path);
final zipArchive = ZipDecoder().decodeBuffer(input);

expect(zipArchive.files.length, equals(2));

ZipDecoderを使用してZIPファイルを読み込み、含まれるファイルの数を確認します。

特定のファイルの情報を取得することも可能です。

final file1InZip = zipArchive.files.firstWhere((e) => e.name == 'a.txt');
expect(file1InZip.name, equals('a.txt'));
expect(file1InZip.size, equals(fileSize));

ファイルの内容を取得して確認することもできます。

final file2InZip = zipArchive.files.firstWhere((e) => e.name == 'b/c.txt');
final content = utf8.decode(file2InZip.content!);
expect(content, equals('A' * fileSize));

ファイルをcloseすると読めなくなります。contentがnullになります。必要な場合は、Archive: ZipDecoder().decodeBufferを作成し直しましょう。(3年くらい前に初めて使ったときも、ここでつまずいたorz)

expect(file1InZip.content, isNotNull);
expect(file1InZip.content, isNotNull); // 2回目も読み込める
await file1InZip.close();

// 一度閉じると後でアクセスしても、データが読み込めない
expect(file1InZip.size, equals(fileSize));
expect(file1InZip.content, isNull);

final file1InZip2 = zipArchive.files.firstWhere((e) => e.name == 'a.txt');
expect(file1InZip2.content, isNull);
await file1InZip2.close();

ZIPファイルを解凍する

ZIPファイルからファイルを解凍する方法です。

特定のファイルを解凍する場合:

for (final fileInZip in zipArchive.files) {
  final outputStream = OutputFileStream(fileInZip.name);
  fileInZip.writeContent(outputStream);
  await outputStream.close();
}

全てのファイルを解凍する場合:

extractFileToDisk(zipFile.path, '.');

extractFileToDisk関数を使用すると、ZIPファイル内の全てのファイルを指定したディレクトリに解凍できます。

Q&A

Q1: lengthはあるのに、contentnullになるのはなぜですか?

A: 一度ファイルをclose()すると、そのファイルのcontentnullになります。具体的な例を以下に示します。

final file1InZip = zipArchive.files.firstWhere((e) => e.name == 'a.txt');
expect(file1InZip.content, isNotNull);
expect(file1InZip.content, isNotNull);
await file1InZip.close();

// 一度閉じると後でアクセスしても、データが読み込めない
expect(file1InZip.size, equals(fileSize));
expect(file1InZip.content, isNull);

この例では、file1InZipcontentにアクセスする前はnullではありませんが、close()を呼び出した後はcontentnullになります。再度アクセスする必要がある場合は、close()しないようにしましょう。

Q2: 圧縮時に特定のファイルのみを除外するにはどうすれば良いですか?

A: ファイルを追加する際に、条件を設けて特定のファイルを除外することができます。例えば、拡張子が.tmpのファイルを除外する場合は以下のようにします。

for (final file in [file1, file2, temporaryFile]) {
  if (!file.path.endsWith('.tmp')) {
    archive.addFile(ArchiveFile(
        file.path, await file.length(), await file.readAsBytes()));
  }
}

これにより、.tmp拡張子のファイルはZIPアーカイブに含まれなくなります。

Q3: 解凍先のディレクトリが存在しない場合、どう対処すれば良いですか?

A: 解凍先のディレクトリが存在しない場合は、割と適切に再帰的にディレクトリを作ってくれます。

まとめ

Dart/Flutterでのファイル操作からZIP圧縮、内容確認、解凍までを解説しました。archiveパッケージを活用することで、これらの機能を簡単に実装できます。ファイル管理が必要なアプリケーション開発において、ぜひ参考にしてみてください。

参考

ソース(main.dartにコピペして動作確認用)

import 'dart:convert';
import 'dart:io';

import 'package:archive/archive.dart';
import 'package:archive/archive_io.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as path;

main() {
  test('ファイル書き込み', () async {
    final file = File('a.txt');

    final stream = file.openWrite();
    stream.write('test');
    await stream.close();

    expect(file.readAsStringSync(), 'test');
    file.delete();
  });

  test('圧縮・解凍', () async {
    const fileSize = 1000;

    // ファイル作成
    final file1 = File('a.txt');
    final directory = Directory('b');
    await directory.create();
    final file2 = File(path.join(directory.path, 'c.txt'));

    final files = [file1, file2, directory];

    final stream1 = file1.openWrite();
    final stream2 = file2.openWrite();
    for (int i = 0; i < fileSize; i++) {
      stream1.write('a');
      stream2.write('A');
    }
    await stream1.close();
    await stream2.close();

    expect(await file1.length(), fileSize);
    expect(await file2.length(), fileSize);

    final archive = Archive();

    // 圧縮
    for (final file in [file1, file2]) {
      archive.addFile(ArchiveFile(
          file.path, await file.length(), await file.readAsBytes()));
    }

    final encodedArchive = ZipEncoder().encode(archive);
    final zipFile = await File('test.zip').writeAsBytes(encodedArchive!);

    expect(zipFile.existsSync(), true);

    // 表示
    final input = InputFileStream(zipFile.path);
    final zipArchive = ZipDecoder().decodeBuffer(input);

    expect(zipArchive.files.length, equals(2));

    final file1InZip = zipArchive.files.firstWhere((e) => e.name == 'a.txt');
    expect(file1InZip.isFile, isTrue);
    expect(file1InZip.name, equals('a.txt'));
    expect(file1InZip.compress, isTrue);
    expect(file1InZip.size, equals(fileSize));
    expect(file1InZip.content, isNotNull);
    expect(file1InZip.content, isNotNull); // 2回目も読み込める
    await file1InZip.close();

    // 一度閉じると後でアクセスしても、データが読み込めない
    expect(file1InZip.size, equals(fileSize));
    expect(file1InZip.content, isNull);

    final file1InZip2 = zipArchive.files.firstWhere((e) => e.name == 'a.txt');
    expect(file1InZip2.content, isNull);
    await file1InZip2.close();

    final file2InZip =
        zipArchive.files.firstWhere((e) => e.name.endsWith('c.txt'));
    expect(file2InZip.isFile, isTrue);
    expect(file2InZip.name, equals('b/c.txt'));
    expect(file2InZip.compress, isTrue);
    expect(file2InZip.size, equals(fileSize));

    expect(file2InZip.content, isNotNull);
    expect(file2InZip.content, isA<List<int>>());

    // zipファイルの中のファイルをメモリに展開して確認: 0x41=>A
    expect(file2InZip.content, [for (int i = 0; i < fileSize; i++) 0x41]);

    // zipファイルの中のファイルをメモリに展開して、文字列に変換
    final content = utf8.decode(file2InZip.content);
    expect(RegExp(r'^A{1000}$').hasMatch(content), isTrue);
    expect(content, equals('A' * fileSize));

    await file2InZip.close();
    expect(file2InZip.content, isNull);

    for (var file in files) {
      file.deleteSync();
    }
    expect(files.any((e) => e.existsSync()), isFalse);

    // zip内の特定のファイルを解凍
    final zipArchive2 =
        ZipDecoder().decodeBuffer(InputFileStream(zipFile.path));

    for (final fileInZip in zipArchive2.files) {
      expect(fileInZip.size, equals(fileSize));

      final outputStream = OutputFileStream(fileInZip.name);
      fileInZip.writeContent(outputStream);
      await outputStream.close();
    }
    expect(files.every((e) => e.existsSync()), isTrue);

    expect(await file1.length(), equals(fileSize));
    expect(await file2.length(), equals(fileSize));

    for (var file in files) {
      file.deleteSync();
    }

    // zipを全部解凍
    expect(files.every((e) => e.existsSync()), isFalse);
    extractFileToDisk(zipFile.path, '.');
    expect(files.every((e) => e.existsSync()), isTrue);

    // 後処理
    for (var file in files) {
      file.deleteSync();
    }
    zipFile.delete();
  });
}