软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

第一个Flutter程序

以前没有真正学过Android或iOS客户端开发,只使用React开发过H5页面,但是H5页面的性能远比不上Native,即使使用React Native也达不到60 FPS。

刚好Flutter可以实现一套代码在Android与iOS上运行,性能是最接近Native。于是参考微信的布局,使用Flutter实现一个Demo:主屏4个页面、一个子页面、网络请求。

1. 创建Flutter项目

参考Flutter.cn安装环境:Flutter、Android Studio,再参考“编写第一个Flutter应用”创建项目。

2. 目录结构

参考:flutter_architecture_samplesFlutter-Movie。对小、中型项目,我更倾向于Flutter-Movie这样的目录结构,没有必要像大型项目那样先按领域划分目录。

本样例的目录结构,见 flutter_demo_app:

├─android
├─ios
├─lib
│  ├─app.dart
│  ├─main.dart
│  ├─constants
│  │  └─base_api
│  ├─globalbasestate  # Redux跨页面数据
│  ├─models           # 业务模型类型定义与基本操作
│  │  └─base_api      # 来源于后端的业务模型
│  ├─pages
│  │  ├─contacts
│  │  │  ├─model.dart # 页面内部的Redux相关的数据
│  │  │  └─...
│  │  ├─discover
│  │  ├─home
│  │  ├─msg
│  │  ├─personal
│  │  └─search
│  ├─routes            # 路由定义,参考 MaterialApp的routes参数
│  ├─services
│  │  ├─xxx.dart       # 前端自有数据操作
│  │  └─api            # 与后端的API交互
│  ├─utils
│  └─widgets           # 视觉组件,VM即可确定视觉效果
│     ├─book_list.dart
│     └─msg.dart
└─test

与Web或H5类似,widgets等价于components。

3. 主屏

主屏

import 'package:flutter/material.dart';
import '../../constants/route_path.dart';

import '../contacts/contacts.dart';
import '../discover/discover.dart';
import '../msg/msg.dart';
import '../personal/personal.dart';

class HomePage extends StatefulWidget {
  HomePage({Key key, this.title}) : super(key: key);

  final String title;

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

const navSelMsg = 0;
const navSelContacts = 1;
const navSelDiscover = 2;
const navSelPersonal = 3;

class _HomePageState extends State<HomePage> {
  int _selectedIndex = 0;

  static const List<String> _navTitles = [
    '信息',
    '联系人',
    '发现',
    '我',
  ];

  static List<Widget> _widgetOptions = <Widget>[
    MsgWidget(),
    ContactsWidget(),
    DiscoverWidget(),
    PersonalWidget(),
  ];

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: _selectedIndex == navSelPersonal ? AppBar() : AppBar(
        title: Text(_navTitles[_selectedIndex]),
        centerTitle: true,
        actions: <Widget>[
          IconButton(
            icon: const Icon(Icons.search),
            tooltip: '搜索',
            onPressed: () {
              Navigator.pushNamed(context, RoutePath.searchPage);
            },
          ),
          _AddIconBtn(),
        ],
      ),
      body: _widgetOptions.elementAt(_selectedIndex),
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.message),
            label: '信息',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person_add_alt),
            label: '联系人',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.assistant_navigation),
            label: '发现',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person),
            label: '我',
          ),
        ],
        type: BottomNavigationBarType.fixed,
        currentIndex: _selectedIndex,
        unselectedItemColor: Colors.grey,
        selectedItemColor: Colors.amber[800],
        onTap: _onItemTapped,
      ),
    );
  }
}

class _AddIconBtn extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: const Icon(Icons.add_circle_outline),
      tooltip: '添加',
      onPressed: () {
        final snackBar = SnackBar(
          content: Text('Yay! Clicked "Add" button!'),
          action: SnackBarAction(
            label: 'Undo',
            onPressed: () {
              // Some code to undo the change.
            },
          ),
        );

        // Find the Scaffold in the widget tree and use
        // it to show a SnackBar.
        Scaffold.of(context).showSnackBar(snackBar);
      },
    );
  }
}

有一个坑:在AppBar的按钮事件响应中,需要访问context向上获取第一个父Scaffold( Scaffold.of(context)),必须新建_AddIconBtn类。

4. 子页面

新建书籍列表子页面,还是使用 Scaffold 来创建统一风格的子页面,同时带有返回箭头。

子屏

import 'package:flutter/material.dart';
import '../../models/base_api/book.dart';
import '../../widgets/book_list.dart';
import '../../services/api/book_api.dart';

class BooksPage extends StatefulWidget {
  @override
  _BooksPageState createState() => _BooksPageState();
}

class _BooksPageState extends State<BooksPage> {
  List<Book> books = <Book>[
    Book(id: 1, name: 'C-1', price: 12.3),
    Book(id: 2, name: 'C-2', price: 23.4),
    Book(id: 3, name: 'C-3', price: 34.5),
  ];

  @override
  void initState() {
    super.initState();
    updateBooks();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Books'),
      ),
      body: Center(
        child: BookListWidget(books),
      ),
    );
  }

  void updateBooks() async {
    final books = await fetchBooks();

    if (books != null) {
      setState(() {
        this.books = books;
      });
    }
  }
}

5. 路由

5.1. 路由定义


import 'package:flutter/material.dart';

import '../constants/route_path.dart';
import '../pages/home/home.dart';
import '../pages/search/search.dart';
import '../pages/personal/books.dart';

final routes = <String, WidgetBuilder>{
  '${RoutePath.homePage}': (BuildContext context) {
    return HomePage();
  },
  '${RoutePath.searchPage}': (BuildContext context) {
    return SearchPage();
  },
  '${RoutePath.booksPage}': (BuildContext context) {
    return BooksPage();
  },
};

5.2. 路由切换

使用 Navigator.pushNamed() 切换路由非常方便:

import 'package:flutter/material.dart';
import '../../constants/route_path.dart';

class PersonalWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView(
      children: <Widget>[
        TextButton.icon(
          icon: Icon(
            Icons.favorite,
            color: Colors.pink,
            size: 24.0,
            semanticLabel: 'Text to announce in accessibility modes',
          ),
          label: Text("My Books"),
          onPressed: () {
            Navigator.pushNamed(context, RoutePath.booksPage);
          },
        )
      ],
    );
  }
}

6. 网络请求与JSON转换

网络请求使用dio非常方便。JSON转换到数组时,需要自己使用 List.map() 函数转换每个元素。

import 'package:dio/dio.dart';

import '../../models/base_api/book.dart';
import 'request.dart';

Future<List<Book>> fetchBooks() async {
  try {
    Response<List> response = await dio.get("http://192.168.80.1:18080");
    if (response.data != null) {
      return response.data.map((e) => Book.fromJson(e)).toList();
    }
  } catch (e) {
    print(e);
  }

  return null;
}