投了个 Golang 的后端岗位,HR 小姐姐看我这半吊子的简历里面写了在学 Flutter,让我转而去试试跨端开发。笔试要求手搓一个 app,连接到 MinIO 服务,并对里面的数据增删改查。
貌似就是一个网盘 app。

刚刚入门,完全不会,惶恐不已,ChatGPT 加持,猛学 8 小时,终于搞定。


MinIO 对象存储服务是什么:

MinIO是一个开源的对象存储服务器,专门设计用于构建高性能、可扩展和可靠的存储基础架构。它允许用户在标准硬件上构建云原生应用程序,提供了与Amazon S3兼容的API,因此可以与现有的S3应用程序和工具集成。

MinIO 有什么用:

应用场景:

  • 大数据分析:MinIO 适用于存储大规模数据集,例如用于机器学习、数据挖掘和其他大数据分析任务的数据。
  • 多媒体存储:对于需要存储和管理大量多媒体文件(如图片、音频和视频)的应用程序,MinIO 提供了高性能和可扩展的存储解决方案。
  • 备份和归档:由于 MinIO 具有可靠性和数据冗余功能,因此可以用于备份和归档数据,确保数据的安全性和可用性。
  • 云原生应用程序:MinIO 可以作为构建云原生应用程序的基础存储基础架构,与现代容器化和微服务架构无缝集成。
  • 物联网(IoT)数据存储:对于需要存储大量物联网设备生成的数据的应用程序,MinIO 提供了高性能和可扩展的存储解决方案。

Windows如何搭建本地 MinIO 服务:

1.下载载编译好的 MinIO 服务程序:

跳转下载页面
选择适合⾃⼰操作系统的可执⾏程序

2.通过命令行启动服务:

将下载好的 exe 程序放到一个文件夹中,在此文件夹中新建一个 Data 目录 作为数据存放处,在此处打开 cmd 窗口,输入以下命令启动:

minio.exe server D:\MinIO\Data --console-address ":9010" --address ":9000"

其中:

  • D:\MinIO\Data 为自己创建的数据存储目录
  • 第一个端口 9010 为 MinIO 后台的端口,可以在浏览器中通过 127.0.0.1:9010 访问 web 界面
  • 第二个端口 9000 是它对外 API 的端口,可以用于注册 naco 配置项,或者通过 api 请求访问

启动成功:

默认账号密码都为 minioadmin,可以通过cmd命令,设置环境变量修改密码:

set MINIO_ROOT_USER=admin
set MINIO_ROOT_PASSWORD=password

在 MinIO 中,通过 Bucket 存储和管理对象数据,Bucket 命名是唯一的,可以理解为 Mysql 中的数据库。通过 127.0.0.1:9010 访问 WebUI 如下:

可以在 Bucket 选项中手动创建并命名一个 Bucket 。在左侧 Access Key 中可以创建一个 acces key 用于后续客户端通过 api 访问 Bucket 中的数据。


在 Flutter 中使用 MinIO:

MinIO Dart 用法文档

1.导入工具包:

import 'package:minio/minio.dart';

2.创建 Minio 访问对象:

final minio = Minio(
  endPoint: '192.168.31.1',             //要连接服务的地址
  accessKey: 'Q3AM3UQ867SPQQA43P2F',    //配置的accessKey
  secretKey: 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG', //配置的secretKey
  port: 9000,        // api 端口
  useSSL: false,     //是否启用 ssl
);

3.愉快的使用 api 进行操作:

例如:列出 'flutter-test' 这个 Bucket 中的所有文件:

final objects = await _minio.listObjects('flutter-test', recursive: true);

其返回值是一个ListObjectsResult 对象流,通过下面这样就可以转为 List 并打印出来了:

 final _bucketContents = await objects.toList();
 print(_bucketContents);

更多 api 用法可查阅文档


应用 demo 展示:

忙忙碌碌,终于搞完。打了个 apk 出来,打包时的配置文件可真麻烦:
侧边栏允许修改要连接的 MinIO 服务,主界面中显示 Bucket 中的文件,可以批量上传、下载、删除。



功能较少,没有什么设计模式,说实话我不太懂如何让一个项目变得足够可扩展化和代码模块解耦化,总之挺虚无缥缈的,网上的各种教学也是抽象的不得了、公式化严重。

dart代码:

//main.dart
import 'package:flutter/material.dart';
import 'package:minio_demo/MinIoExampleState.dart';


void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MinIO文件管理器',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),          
        useMaterial3: true,
      ),
      home: MinIOExample(),
    );
  }
}
//MinIoExampleState.dart
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:minio/minio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:minio/models.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:intl/intl.dart';
import 'package:minio_demo/minio_settings_drawer.dart';


class MinIOExample extends StatefulWidget {
  @override
  _MinIOExampleState createState() => _MinIOExampleState();
}



class _MinIOExampleState extends State<MinIOExample> {
  late Minio _minio = Minio(
    endPoint: '192.168.31.151',
    accessKey: '12345678',
    secretKey: '12345678',
    port: 9090,
    useSSL: false,
  );

  final _uploadQueue = <File>[];
  final _downloadQueue = <String>[];
  List<ListObjectsResult> _bucketContents = [];
  List<String> fileNames = [];
  List<Object> _file = [];
  List<bool> _isSelected = [];
  bool _showProgressBar = false;
  final List<String> _FILETYPE=['gif','jpg','mp4','pdf','png','ppt','pptx','rar','svg','video','doc','docx','xlsx','xls','zip'];

  final _maxConcurrentTasks = 3;
  int _uploadTasksRunning = 0;
  int _downloadTasksRunning = 0;

  @override
  void initState() {
    super.initState();
    _listBucketContents();

  }

  // 该方法列出 'flutter-test' 桶的内容
 Future<void> _listBucketContents() async {
    try {
      // 使用 `listObjects` 方法获取 `ListObjectsResult` 对象的流
      final objects = await _minio.listObjects('flutter-test', recursive: true);
      // 将流转换为 List,并更新 _bucketContents 和 _isSelected 状态
      _bucketContents = await objects.toList();
      _file=_bucketContents[0].objects;
      print(_bucketContents);
      for(var i in _file){
        print(i.key);
      }
      _isSelected = List.generate(_file.length, (_) => false);
      setState(() {});
    } catch (e) {
      print('列出桶内容时出错: $e');
      await Fluttertoast.showToast(
        msg: '连接数据库失败!',
        toastLength: Toast.LENGTH_SHORT,
        gravity: ToastGravity.CENTER,
        timeInSecForIosWeb: 1,
        backgroundColor: Colors.grey,
        textColor: Colors.black,
        fontSize: 16.0,
      );

    }
  }

  // 该方法将文件上传到 'flutter-test' 桶
 Future<void> _uploadFile(File file) async {
  try {
    _modifiProgressBar(true);
    // 将 File 对象转换为 Stream<Uint8List>
    final stream = file.openRead().map((data) => Uint8List.fromList(data));
    await _minio.putObject('flutter-test', file.path.split('/').last, stream);
    Fluttertoast.showToast(
      msg: '文件上传成功: ${file.path.split('/').last}',
      toastLength: Toast.LENGTH_SHORT,
      gravity: ToastGravity.CENTER,
      timeInSecForIosWeb: 1,
      backgroundColor: Colors.grey,
      textColor: Colors.black,
      fontSize: 16.0,
    );
    print('文件上传成功: ${file.path.split('/').last}');
    // 更新 _file 和 _isSelected 列表
    await _listBucketContents();
    _modifiProgressBar(false);
  } catch (e) {
    print('上传文件时出错: $e');
  }
 }

  // 该方法从 'flutter-test' 桶下载文件
  Future<void> _downloadFile(String objectName) async {
    try {
      _modifiProgressBar(true);
      // 获取文件数据
      final stream = await _minio.getObject('flutter-test', objectName);
      final bytes = await stream.toList();
      final file = File('/sdcard/Download/$objectName');
      // 将文件数据写入磁盘
      await file.writeAsBytes(bytes[0] as List<int>);
      await Fluttertoast.showToast(
        msg: '下载成功: $objectName,保存到目录:/sdcard/Download/',
        toastLength: Toast.LENGTH_SHORT,
        gravity: ToastGravity.CENTER,
        timeInSecForIosWeb: 1,
        backgroundColor: Colors.grey,
        textColor: Colors.black,
        fontSize: 16.0,
      );
      _modifiProgressBar(false);
      print('文件下载成功: $objectName');
    } catch (e) {
      print('下载文件时出错: $e');
      await Fluttertoast.showToast(
        msg: '下载文件时出错: $e',
        toastLength: Toast.LENGTH_SHORT,
        gravity: ToastGravity.CENTER,
        timeInSecForIosWeb: 1,
        backgroundColor: Colors.grey,
        textColor: Colors.black,
        fontSize: 16.0,
      );
    }
  }

  // 该方法从 'flutter-test' 桶删除文件
 Future<void> _deleteFile(String objectName) async {
  try {
    _modifiProgressBar(true);
    await _minio.removeObject('flutter-test', objectName);
    await Fluttertoast.showToast(
      msg: '已删除$objectName',
      toastLength: Toast.LENGTH_SHORT,
      gravity: ToastGravity.CENTER,
      timeInSecForIosWeb: 1,
      backgroundColor: Colors.grey,
      textColor: Colors.black,
      fontSize: 16.0,
    );
    _modifiProgressBar(false);
    print('文件删除成功: $objectName');
    // 从 _file 和 _isSelected 中移除已删除的对象
    await _listBucketContents();
  } catch (e) {
    print('删除文件时出错: $e');
  }
 }

  // 该方法将文件添加到上传队列并处理队列
  void _addToUploadQueue(File file) {

    _uploadQueue.add(file);
    _processUploadQueue();

  }

  // 该方法将对象名称添加到下载队列并处理队列
  void _addToDownloadQueue(String objectName) {
    _downloadQueue.add(objectName);
    _processDownloadQueue();
  }

  // 该方法处理上传队列,限制同时进行的任务数量
  void _processUploadQueue() {
    while (_uploadTasksRunning < _maxConcurrentTasks && _uploadQueue.isNotEmpty) {
      final file = _uploadQueue.removeAt(0);
      _uploadTasksRunning++;
      _uploadFile(file).then((_) {
        _uploadTasksRunning--;
        _processUploadQueue();
      }).catchError((e) {
        _uploadTasksRunning--;
        _processUploadQueue();
      });
    }
  }

  // 该方法处理下载队列,限制同时进行的任务数量
  void _processDownloadQueue() {
    while (_downloadTasksRunning < _maxConcurrentTasks && _downloadQueue.isNotEmpty) {
      final objectName = _downloadQueue.removeAt(0);
      _downloadTasksRunning++;
      _downloadFile(objectName).then((_) {
        _downloadTasksRunning--;
        _processDownloadQueue();
      }).catchError((e) {
        _downloadTasksRunning--;
        _processDownloadQueue();
      });
    }
  }

  // 该方法控制进度条的显隐
  void _modifiProgressBar(bool status){
    _showProgressBar = status;
    setState(() {});
  }

  //组件
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,//Colors.grey[200],
        title: Text('MinIO文件管理器'),
        centerTitle: true,
      ),
      drawer:MinioSettingsDrawer(
          onSave: (minio) {
            _minio = minio; // 将持久化的 Minio 设置赋值给 _minio
            _listBucketContents();
            print('endPoint: ${_minio.endPoint}');
            print('accessKey: ${_minio.accessKey}');
            print('secretKey: ${_minio.secretKey}');
            print('port: ${_minio.port}');
            print('useSSL: ${_minio.useSSL}');
          }
      ),
      body: Column(
        children: [
          //1.进度条组件
          Visibility(
            visible: _showProgressBar, // 根据需要设置为true或false
            child: LinearProgressIndicator(
              backgroundColor: Colors.grey[200],
              valueColor: AlwaysStoppedAnimation(Colors.blue),
            ),
          ),
          //2.文件列表组件
          Expanded(
            child: ListView.builder(
              itemCount: _file.length,
              itemBuilder: (context, index) {
                final obj = _file[index].key;
                final modifiTime = _file[index].lastModified;
                print(modifiTime.runtimeType);
                String iconPath = 'assets/icons/unknown.png';
                if (_FILETYPE.contains(_file[index].key!.split('.').last.toLowerCase())){
                  iconPath = 'assets/icons/'+_file[index].key!.split('.').last.toLowerCase()+'.png';
                }
                return Container(
                  decoration:BoxDecoration(
                    color: Colors.grey[100],borderRadius: BorderRadius.circular(12)) ,
                  margin: EdgeInsets.symmetric(vertical: 0.5), // 设置垂直方向上的间距
                  child: ListTile(
                    leading: Image.asset(
                      iconPath,
                      width: 28, // 设置图标宽度
                      height: 28, // 设置图标高度
                    ),
                    title: Text(obj.toString()),
                    subtitle: Text('${DateFormat('yyyy-MM-dd HH:mm').format(modifiTime ?? DateTime.now())}',style: TextStyle(fontSize: 11,color: Colors.grey[600])), // 格式化显示上传时间,并调整大小颜色
                    trailing: Checkbox(
                      value: _isSelected[index],
                      shape: CircleBorder(), // 将多选框形状设置为圆形
                      onChanged: (value) {
                        setState(() {
                          _isSelected[index] = value!;
                        });
                      },
                    ),
                  ),
                );
              },
            ),
          ),
          //3.上传、下载、删除按钮组件
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              ElevatedButton.icon(
                icon: Icon(Icons.send),
                label: Text("上传文件"),
                style: ButtonStyle(
                  shape: MaterialStateProperty.all<RoundedRectangleBorder>(
                    RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(15),
                    ),
                  ),
                ),                
                onPressed: () async {
                  // 让用户选择要上传的文件
                  final result = await FilePicker.platform.pickFiles(allowMultiple: true); // 选择上传多个文件
                  if (result != null && result.files.isNotEmpty) {
                    for (final file in result.files) { // 遍历选择的文件列表
                      final fileObject = File(file.path!);
                      _addToUploadQueue(fileObject);
                    }
                  }
                },
              ),
              ElevatedButton.icon(
                icon: Icon(Icons.download),
                label: Text("下载文件"),
                style: ButtonStyle(
                  shape: MaterialStateProperty.all<RoundedRectangleBorder>(
                    RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(15),
                    ),
                  ),
                ),
                onPressed: () {
                  // 下载选中的文件
                  for (int i = 0; i < _isSelected.length; i++) {
                    if (_isSelected[i]) {
                      _addToDownloadQueue(_file[i].key??"");
                    }
                  }
                },
              ),
              ElevatedButton.icon(
                icon: Icon(Icons.delete),
                label: Text("删除文件"),
                style: ButtonStyle(
                  shape: MaterialStateProperty.all<RoundedRectangleBorder>(
                    RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(15),
                    ),
                  ),
                ),                
                onPressed: () {
                  // 删除选中的文件
                  for (int i = 0; i < _isSelected.length; i++) {
                    if (_isSelected[i]) {
                      _deleteFile(_file[i].key??"");
                    }
                  }
                },
              ),        
            ],
          ),
        ],
      ),
    );
  }
}
//minio_settings_drawer.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:minio/minio.dart';

class MinioSettingsDrawer extends StatefulWidget {
  final Function(Minio) onSave;

  MinioSettingsDrawer({required this.onSave});

  @override
  _MinioSettingsDrawerState createState() => _MinioSettingsDrawerState();
}

class _MinioSettingsDrawerState extends State<MinioSettingsDrawer> {
  late TextEditingController _endPointController;
  late TextEditingController _accessKeyController;
  late TextEditingController _secretKeyController;
  late TextEditingController _portController;
  late TextEditingController _useSSLController;

  @override
  void initState() {
    super.initState();
    _endPointController = TextEditingController();
    _accessKeyController = TextEditingController();
    _secretKeyController = TextEditingController();
    _portController = TextEditingController();
    _useSSLController = TextEditingController();
    _loadMinioSettings(); // 加载保存的 Minio 设置
  }

  @override
  void dispose() {
    _endPointController.dispose();
    _accessKeyController.dispose();
    _secretKeyController.dispose();
    _portController.dispose();
    _useSSLController.dispose();
    super.dispose();
  }

  Future<void> _loadMinioSettings() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    setState(() {
      _endPointController.text = prefs.getString('endPoint') ?? '';
      _accessKeyController.text = prefs.getString('accessKey') ?? '';
      _secretKeyController.text = prefs.getString('secretKey') ?? '';
      _portController.text = prefs.getInt('port').toString() ?? '';
      _useSSLController.text = prefs.getBool('useSSL').toString() ?? '';
    });
  }

  Future<void> _saveMinioSettings() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    await prefs.setString('endPoint', _endPointController.text);
    await prefs.setString('accessKey', _accessKeyController.text);
    await prefs.setString('secretKey', _secretKeyController.text);
    await prefs.setInt('port', int.parse(_portController.text));
    await prefs.setBool('useSSL', _useSSLController.text.toLowerCase() == 'true');
    // 构建 Minio 对象并传递给回调函数
    Minio minio = Minio(
      endPoint: _endPointController.text,
      accessKey: _accessKeyController.text,
      secretKey: _secretKeyController.text,
      port: int.parse(_portController.text),
      useSSL: _useSSLController.text.toLowerCase() == 'true',
    );
    widget.onSave(minio);
  }

  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: ListView(
        padding: EdgeInsets.zero,
        children: [
          DrawerHeader(
            decoration: BoxDecoration(
              color: Colors.blue,
            ),
            child: Text(
              'MinIO设置',
              style: TextStyle(
                color: Colors.white,
                fontSize: 24,
              ),
            ),
          ),
          ListTile(
            title: TextField(
              controller: _endPointController,
              decoration: InputDecoration(labelText: 'End Point'),
            ),
          ),
          ListTile(
            title: TextField(
              controller: _accessKeyController,
              decoration: InputDecoration(labelText: 'Access Key'),
            ),
          ),
          ListTile(
            title: TextField(
              controller: _secretKeyController,
              decoration: InputDecoration(labelText: 'Secret Key'),
            ),
          ),
          ListTile(
            title: TextField(
              controller: _portController,
              decoration: InputDecoration(labelText: 'Port'),
            ),
          ),
          ListTile(
            title: TextField(
              controller: _useSSLController,
              decoration: InputDecoration(labelText: 'Use SSL'),
            ),
          ),
          ListTile(
            title: ElevatedButton(
              onPressed: () async {
                await _saveMinioSettings();
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('MinIO设置已保存')),
                );
                Navigator.pop(context); // 点击保存按钮后关闭侧边栏
              },
              child: Text('保存'),
            ),
          ),
        ],
      ),
    );
  }
}

补充 解决flutter编译慢的问题:

看本文之前,默认你已经配置好了 Android-SDK,运行 flutter doctor 没有报错了,并且在.bashrc文件中配置好了下面两个环境变量:

flutter 编译安卓程序卡死在 running gradle task 'assembleRelease' 状态的原因有2个:

  1. 下载 gradle 速度太慢,会不断失败、重新下载,陷入死循环;
  2. 下载jar包失败,编译时需要下载成百上千的jar包,但是官方服务器太慢,下载会反复失败。

一、解决下载 gradle 失败的问题

先到 https://services.gradle.org/distributions/ 下载适当版本的 gradle ,然后修改 flutter project 下面的 android/gradle/wrapper/gradle-wrapper.properties 文件,把最后一行改成这样:

distributionUrl=file:///D:/yorpath/gradle-7.6.3-all.zip

此时编译时 flutter 会复制本地的 gradle-7.6.3-all.zip,不会再从官网下载。

二、解决下载jar包失败的问题

用国内的 jcenter 镜像服务器替换官方服务器,具体是修改 flutter project 下面的 android/build.gradle,修改2段下面这样的代码:

repositories {
        google()
        mavenCentral()
    }

插入阿里云的镜像服务器地址,改成下面这样:

repositories {
        maven { url 'https://maven.aliyun.com/repository/central' }
        google()
        mavenCentral()
    }

额外:解决编译为linux桌面程序失败的问题

修改 flutter project 下面的 linux/CMakeLists.txt,在第一行代码 cmake_minimum_required(VERSION 3.10) 后面插入一句:

set(CMAKE_CXX_COMPILER "/usr/bin/g++")