目前终止开发 FLUTTER 版「法语记忆」APP,原因:懒。若要使用,请移步 微信小程序「法语记忆:学背单词动词变位」

这篇文章的内容和题目觉得挺难取,主要是记录了一些使用Flutter做项目的方法和一些坑。由于没做过练手项目,直接参照 微信小程序「法语记忆:学背单词动词变位」的界面和功能开搞。说是第一篇,但是可能随时因为项目不想做了或者没时间做或者脑袋卡住了而烂尾。

官方推荐 Udemy 上的 The Complete 2020 Flutter Development Bootcamp with Dart 教程,目前就学了前面的一丢丢。下方所涉及的部分代码可能不是最佳实践、界面并非最终界面,但是能达到截图中的功能和界面。

什么是 Flutter ?

Flutter 是 Google 推出的移动应用开发框架,也是编写手机 App 的著名框架之一,主要的特色是跨平台、高性能。

它的开发语言在此之前我都没听说过,叫做 Dart 语言。开发出来的一套代码可以同时运行在 iOS 、 Android、 Web (目前还是Beta版本)甚至PC平台,对于个人开发者来说这无疑减少了不少工作量。

Flutter 使用自己的渲染引擎来绘制界面,所以在布局过程中不需要像 React Native 那样要在 JavaScript 和 Native 之间通信,在使用过程中会比这些跨平台开发方案的 App 更流畅。

此外,虽然 Flutter 相对于其他开发方式较新,但是官网、中文开发文档、各种常用插件、博文已经非常详细。

所以基于以上几点,作为一个菜鸡业余开发者,「法语记忆」安卓和苹果的手机端 App 将使用 Flutter 进行开发。由于国内各类应用市场上架有一定要求,目前暂时搞不定且我又没有 mac ,所以本“实验室”的第一个手机 App 很有可能会以多语言版本首发在 Google Play 以及以 apk 安装包下载方式放在本公众号上。

第一个 app 也将着重编写「法语记忆」其中的一个功能:也就是它的前身:动词变位。除了基本的动词变位练习之外,还将加入更多可以自定义的内容:比如自定义单词列表、自定义时态;更详细的统计信息等。

Flutter 开篇必备

  • SafeArea:适配手机屏幕,避免内容被屏幕上方的状态栏或者刘海挡住。
  • Center:包裹的所有内容上下居中、左右居中。
  • SingleChildScrollView:可以使页面滚动。
  • Container:容器,里面可以塞一些其他的小部件,一个拥有绘制、定位、调整大小的 widget。
  • Column:在垂直方向上排列子 widget 的列表。(Row 与之均相反,在水平方向上排列子widget的列表。)。
    • mainAxisAlignment 主轴(形象一点:y轴。对于 Row 来说:为 x轴)方向上的对齐。(子widge 上下移动)
    • crossAxisAlignment 交叉轴(形象一点:x轴。对于 Row 来说:为 y轴)方向上的对齐。(子widge 左右移动)
  • EdgeInsets:四周方向的偏移量
    • all:四周都用一个值,都是一样的距离。
    • fromLTRB :左 left,上 Top,右 Right,下 Bottom (顺时针)。
    • only:仅配置一个方向的。
 SafeArea(
        child: Center(
          child: SingleChildScrollView(
            child: Container(
                padding: EdgeInsets.fromLTRB(0, 10, 0, 0),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    CardTitle(),
                  ],
                )),
          ),
        ),
      ),

动词变位默写界面

该界面和微信小程序「法语记忆:学背单词动词变位」略有区别,这里使用了选择答案的方式进行答题,使用了DropdownButton 组件,当所有的答案都完成之后自动判断对错。此外,预计还将加入默写的选项。上述截图并非最终界面和样式,仅为示意。

DropdownButton 组件:

  • value为 DropdownButton 的初始值,这里均为 null,即不显示。
  • hint 提示内容。
  • iconSize 最右侧的 icon 大小和样式。
  • style 样式。
  • underline 下划线。
  • onChanged 当 DropdownButton 被操作之后的动作。
    • 更改组件内容状态,始终注意需要使用 setState,并且使用的是 Stateful 组件。
  • items 这里是下拉菜单中的选项,confusedAnswerList 中包含了该人称变位中一个正确答案和一到五个错误答案。
DropdownButton<String>(
  // value 为 DropdownButton 的初始值,
  // 这里均为 null,即不显示。
  value: dropdownValue[i],
  hint: Text("点击选择合适变位"),
  iconSize: 15,Drop
  style: TextStyle(color: Colors.deepPurple),
  underline: Container(
    height: 0.5,
    color: Colors.deepPurpleAccent,
  ),
  onChanged: (String newValue) {
    // 更改组件内容状态,始终注意需要使用 setState
    // 并且使用的是 Stateful 组件。
    setState(() {
       dropdownValue[i] = newValue;
    });
    // 一开始所有的 dropdownValue 都为 null
    // 当用户选择一个答案后,dropdownValue 将会有所变化
    // 当 dropdownValue 中没有 null 后,判断对错。
    if (dropdownValue.indexOf(null) == -1) {
       verifyAnswer(dropdownValue);
    }
       print(dropdownValue);
  },
    // 这里是下拉菜单中的选项,
    // confusedAnswerList 中包含了该人称变位中
    // 一个正确答案和一到五个错误答案。
   items: confusedAnswerList[i]
           .cast<String>() //注意这里的写法
           .map<DropdownMenuItem<String>>((String value) {
               return DropdownMenuItem<String>(
                  value: value,
                   child: Text(value),
               );
            }).toList(),
),

Flutter 中使用 Sqlite 数据持久化

和小程序不同

所有的动词变位数据都保存在了 assets 文件夹中,当在该文件夹中增加文件之后,需要在 pubspec.yaml 文件中声明并点击 Pub get 生效。关于 Flutter 与 Sqlite 的详细用法可以参考官方的这篇文章 用 SQLite 做数据持久化

Sqlite 的数据处理可以使用免费的 GUI 工具 SQLiteStudio 进行处理,我使用了之前整理的 csv 文件导入到该软件中生成了一个 db 文件。

这个 db 文件在用户使用过程中并不会发生任何更改,在 app 使用时,应当先检查数据库是否已从 assets 文件夹拷贝到缓存目录,用户对数据库的更改将会存入缓存数据库中。拷贝数据的过程通常只进行一次(即第一次使用时),所以需要加入一个判断:如果是初次使用或者缓存数据库丢失时,重新拷贝 assets 文件夹中的数据库。

Sqlite 数据库的初始化和查询数据可以参加下方代码:

class ConjSetUpSqliteQuery {
  // 检查数据库是否已从assets文件夹拷贝到缓存目录
  // 对数据库的更改将会存入缓存数据库中
  // 拷贝数据的过程通常只进行一次(即第一次使用时)
  check() async {
    var databasesPath = await getDatabasesPath();
    var path = join(databasesPath, "user_data.db");
// Check if the database exists
    var exists = await databaseExists(path);
    if (!exists) {
      // Should happen only the first time you launch your application
      print("Creating new copy from asset");
      // Make sure the parent directory exists
      try {
        await Directory(dirname(path)).create(recursive: true);
      } catch (_) {}
      // Copy from asset
      ByteData data = await rootBundle.load(join("assets", "data/user_data.db"));
      List<int> bytes =
      data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
      // Write and flush the bytes written
      await File(path).writeAsBytes(bytes, flush: true);
    } else {
      print("Opening existing database");
    }
  }
  // 查找
  Future getUserSetUp() async {
    // 打开数据库
    final Future<Database> database = openDatabase(
      join(await getDatabasesPath(), 'user_data.db'),
    );
    final Database db = await database;
    
    List<Map> maps = await db.query('basic_setup'); //直接查询整张表格
    // 若有查询结果
    if (maps.length > 0) {
    // 关闭数据库
      await db.close();
      return mapsTemp;
    }
    await db.close();
    return null;
  }
  // 更新
  // 下方的 userSetUp 是一个 Map 类型的数据
  // 长这样:{'conjWord50':true,
  // 'conjWord100':false,
  // 'conjWord230':false}
  Future insertUserSetUp(userSetUp) async {
    List userSetUpName = [
      'conjWord50',
      'conjWord100',
      'conjWord230',
    ];
    // 打开数据库
    final Future<Database> database = openDatabase(
      join(await getDatabasesPath(), 'user_data.db'),
    );
    final Database db = await database;
    
    // 更新数据
    for (var i = 0; i < userSetUp.length; i++) {
      var userSetUpValue = userSetUp[userSetUpName[i]].toString();
      await db.update(
        'basic_setup',
      {'basic_setup_name':userSetUpName[i],'basic_setup_value':userSetUpValue},
        where: "basic_setup_name = ?",
        whereArgs: [userSetUpName[i]],
      );
    }
    // 关闭数据库
    await db.close();
  }
}

制作一个设置菜单

下方界面模仿了微信小程序「法语记忆:学背单词动词变位」的设置界面,其中黑夜模式为 Switch 按钮,而后方皆为提供跳转的 GestureDetector (手势检测,相当于按钮的作用)。

上方的设置菜单分为两部分:上方的标题“个性化”、“同步设置”(以下记作A)和下方的菜单列表(以下记作B)分别为两个 Container。A 和 B 的白色背景可以使用循环写出,但是注意循环的写法并非最佳写法,创建可复用的组件可能才是最佳实践,目前同样能够实现该界面。

  • Container 分别包裹了 A 和 B 。
    • margin:设置一个元素的外边距。
    • padding:设置一个元素的内边距,padding 区域指一个元素的内容和其边界之间的空间。
  • BoxDecoration: 盒子装饰器,例如可以美化一个Container:加圆角、颜色、边框等。
  • BorderRadius:设置圆角。
  • Radius:圆形或椭圆形的半径。
  • Text:显示文字,必填项为一个字符串。
    • style:用于设置文字样式。此处的 AppTheme 为单独的样式文件,目的是减少非必要的代码重复。
class CardTitle extends StatelessWidget {
  final cardTitleName = ['个性化', '同步设置'];
  final cardComponents = [SetUpList(), Messages()];
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        for (var i = 0; i < cardTitleName.length ; i++)
          Container(
            margin: EdgeInsets.fromLTRB(20, 5, 20, 5), 
            // margin:设置一个元素的外边距。
            // padding:设置一个元素的内边距,
            // padding 区域指一个元素的内容和其边界之间的空间。
            child: Column(
              children: <Widget>[
                // Card的上半部分
                Container(
                  // BoxDecoration: 盒子装饰器,
                  // 例如可以美化一个Container:
                  //加圆角、颜色、边框等。
                  decoration: BoxDecoration( 
                    // BorderRadius:设置圆角。
                    borderRadius: BorderRadius.vertical(
                      top: Radius.circular(15.0), 
                    ),
                    color: AppTheme.secondaryColor,
                  ),
                  padding: EdgeInsets.fromLTRB(20, 20, 20, 20),
                  child: Row(children: [
                    // Text:显示文字,必填项为一个字符串。
                    Text(
                      cardTitleName[i], 
                      // style:用于设置文字样式。
                      //此处的 AppTheme 为单独的样式文件,
                      // 目的是减少非必要的代码重复。
                      style: AppTheme.headline2, 
                    ),
                  ]),
                ),
                // Card的下半部分
                Container(
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.vertical(
                      bottom: Radius.circular(15.0),
                    ),
                    color: Colors.white,
                  ),
                  padding: EdgeInsets.fromLTRB(20, 20, 20, 20),
                  child: cardComponents[i],
                ),
              ],
            ),
          ),
      ],
    );
  }
}

B 菜单列表中黑夜模式的 Switch 单独列出,下方的六个菜单使用以循环写出。

class SetUpList extends StatefulWidget {
  @override
  _SetUpListState createState() => _SetUpListState();
}
class _SetUpListState extends State<SetUpList> {
  List setUpName = [
    "📚 动词变位设置",
    "🔮 背单词设置",
    "❓ 使用帮助",
    "📨 报错与建议",
    "🎈 小程序主页",
    "👦 关于",
  ];
  List setUpPage = [
    ConjSetUpConj(),
    ConjSetUpVocabulary(),
    ConjSetUpHelp(),
    ConjSetUpFeedback(),
    ConjSetUpOfficialSite(),
    ConjSetUpAbout(),
  ];
  bool isSwitched = false; //黑夜模式
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          padding: EdgeInsets.fromLTRB(15, 0, 0, 0),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(
                "🌜 黑夜模式",
                style: AppTheme.headline2,
              ),
              Switch(
                value: isSwitched,
                onChanged: (value) {
                  setState(() {
                    isSwitched = value;
                    print(isSwitched);
                  });
                },
                activeTrackColor: Colors.lightGreenAccent,
                activeColor: Colors.green,
              ),
            ],
          ),
        ),
        // 循环 setUpName.length 次,做出列表。
        for (var i = 0; i < setUpName.length; i++)
          Column(
            children: [
              // 灰色分割线
              Divider(
                color: Colors.grey,
                thickness: 0.2,
              ),
              // 每一个列表都是一个按钮,
              // 按了之后将会跳转至 setUpPage 中的类
              FlatButton(
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text(
                      setUpName[i],
                      style: AppTheme.headline2,
                    ),
                    // 箭头图标
                    Icon(
                      Icons.keyboard_arrow_right,
                      color: Colors.grey,
                    )
                  ],
                ),
                onPressed: () {
                  // 跳转页面
                  Navigator.push(context, MaterialPageRoute(builder: (_) {
                    return setUpPage[i];
                  }));
                },
              ),
            ],
          )
      ],
    );
  }
}

背单词和动词变位设置菜单

此处的写法和上一节大同小异,主要是多了一个 Slider 组件:

  • Slider 提供了一个滑动的数字选择器。
    • value 初始值:这里为30。
    • onChanged 当组件发生操作后,数值发生变化。
    • min max 最大最小值,此处的数据类型是 double ,可以使用 round() 进行取整使用。
    • divisions 将整个轴几等分?
    • label 滑动时,显示的标签
    • activeColor inactiveColor 不同状态的下的颜色设置。
// conjSetUpVocabulary 的设置数据
// 下方是初始样板数据(不然会有短暂的红屏)
Map conjSetUpVocabulary = {
  'vocabularyNumber': 30.0,
  'wordListA1': true,
  'wordListA2': false,
  'wordListA3': false,
};
// 单词数量设置
// 这是一个 Stateful 组件
class ConjSetUpVocabularyNumber extends StatefulWidget {
  @override
  _ConjSetUpVocabularyNumberState createState() =>
      _ConjSetUpVocabularyNumberState();
}
class _ConjSetUpVocabularyNumberState extends State<ConjSetUpVocabularyNumber> {
  List setUpName = ['每天新词'];
  List setUpAbbreviation = [
    'vocabularyNumber',
  ];
  // 此处描述有误,但为了和上方截图相符就不改了
  List setUpDescription = [
    '开启此处可以使用50个基础动词。',
  ];
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        for (var i = 0; i < setUpName.length; i++)
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Container(
                padding: EdgeInsets.fromLTRB(15, 0, 0, 0),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text(
                      setUpName[i],
                      style: AppTheme.headline2,
                    ),
                    Text(
                 conjSetUpVocabulary[setUpAbbreviation[i]].round().toString(),
                      style: AppTheme.headline2,
                    )
                  ],
                ),
              ),
              Slider(
                value: conjSetUpVocabulary[setUpAbbreviation[i]],
                onChanged: (value) {
                  setState(() {
                    conjSetUpVocabulary[setUpAbbreviation[i]] = value;
                  });
                },
                min: 15.0,
                max: 100.0,
                divisions: 50,
                label: '${conjSetUpVocabulary[setUpAbbreviation[i]].round()}',
                activeColor: Colors.green,
                inactiveColor: Colors.grey,
              ),
              Text(
                setUpDescription[i],
                style: AppTheme.description,
              ),
            ],
          ),
      ],
    );
  }
}

为什么要开发 App 版的「法语记忆」?微信小程序虽然基本上可以满足需求,但是某些方面的体验仍然逊于原生的 App。比如在背单词时每次的单词释义和例句需要从云端获取,有一定的等待时间。此外,体验一下 Flutter 、制作第一个手机原生 App、增加一些新功能也是一些因素。

类似文章

发表评论

您的电子邮箱地址不会被公开。