Flutter 2 商城App实战攻略(支撑Null safety)

  • 项目自定义主题:创立自定义主题类,设定项目UI色彩,自定义各类文字款式,便利一致项目UI,灵敏布局。
  • Flutter 路由:项目不可或缺的部分,各页面之间跳转。
  • SVG图标的运用:运用精巧的svg图标,多一份酷爱,多一份色彩。
  • 瀑布流布局:产品列表运用瀑布流布局,完美和谐列表项。
  • Flutter 数据模型:让数据可用性更好,JSON与数据模型转化,以便在项目中运用各类数据。
  • 购物车功用:买不买就看购物车爽不爽。购物车增修正查的完成,购物车产品本地耐久化存储。
  • 项目App打包:运用打包和其他注意事项。
  • 一个完好的商城App开发才能

Flutter 开发环境建立

项目运用 Flutter 2.10.2 版别,您可运用 >=2.x 的版别:

Flutter 2.10.2 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 097d3313d8
Engine • revision a83ed0e5e3
Tools • Dart 2.16.1 • DevTools 2.9.2
  1. 挑选要装置 Flutter 的操作体系,并依照官方操作流程进行 Flutter 环境的装置及验证:

    • Flutter 官网:docs.flutter.dev/get-started…
    • Flutter 中文官网:flutter.cn/docs/get-st…
  2. 在 VS Code 中开发(个人主张运用轻量的 vscode 开发)

    • 装备编辑器:flutter.cn/docs/get-st…

项目创立与运转

项目创立:

在您的作业目录中运用命令行东西:

flutter create flutter_demo

flutter_demo 是项目称号(替换为你的项目称号),官方主张文件称号以下划线命名办法。

项目目录结构:

├─assets             // 静态文件
├─lib                // 首要作业目录,编写大部分代码放在这儿
│  ├─apis            // api 文件
│  ├─components      // 公共组件
│  ├─models          // 数据模型
│  ├─router          // 项目路由
│  ├─utils           // 东西类办法
│  ├─views           // 商城页面UI
│  ├─widgets         // 事务相关组件
│  ├─app_theme.dart  // 自定义主题文件
│  └─mian.js         // 项目进口文件
├─android            // android 编译目录
├─ios                // ios 编译目录
├─pubspec.yaml       // 项目装备文件

运转项目:

  • 准备虚拟机

    • mac 电脑可在终端履行 $ open -a Simulator 翻开 iphone 虚拟机
    • 装置有 Android Studio 的同学能够在 Android Studio 中翻开 安卓虚拟机
    • 无虚拟机,也能够运用 web 浏览器形式运转开发
  • 在终端履行指令 flutter run 运转项目

自定义项目主题

创立自定义主题类,设定项目UI色彩,自定义各类文字款式,便利一致项目UI,灵敏布局。

编写主题类:

创立文件 lib/app_theme.dart, 创立 AppTheme类并规划主题色彩和文字款式

import 'package:flutter/material.dart';
class AppTheme {
  AppTheme._();
  // 色彩设置
  static const Color notWhite = Color(0xFFECF0EF);
  static const Color nearlyWhite = Color(0xFFFFFFFF);
  // 文字款式
  static const TextTheme textTheme = TextTheme(
    headline4: display1,
    headline5: headline,
    headline6: title,
    subtitle2: subtitle,
    bodyText1: body1,
    bodyText2: body2, // bodyText2: flutter默许文字款式
    caption: caption,
  );
  static const TextStyle display1 = TextStyle(
    fontFamily: fontName,
    fontWeight: FontWeight.bold,
    fontSize: 36,
    letterSpacing: 0.5,
    height: 0.9,
    color: darkerText,
  );
  ...
}

引进并运用主题: main.dart 进口文件中引进主题类并运用

import 'package:shopping_mall/app_theme.dart';
@override
Widget build(BuildContext context) {
    return MaterialApp(
      title: '青山商城',
      debugShowCheckedModeBanner: false,
      // 主题装备
      theme: ThemeData(
        primarySwatch: Colors.teal,
        fontFamily: AppTheme.fontName, // 默许字体
        textTheme: AppTheme.textTheme, // 文字主题
      ),
      onGenerateRoute: onGenerateRoute,
      initialRoute: '/',
      home: const AppHomeScreen(),
    );
}

设置状况栏款式: main.dart 进口文件中运用 setSystemUIOverlayStyle

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // 设置状况栏
  SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
    // 顶部状况栏色彩
    statusBarColor: Colors.transparent,
    // 顶部状况栏图标的亮度
    statusBarIconBrightness: Brightness.dark,
    // 顶部状况栏的亮度
    statusBarBrightness:
        !kIsWeb && Platform.isAndroid ? Brightness.dark : Brightness.light,
    // 体系底部导航栏的色彩
    systemNavigationBarColor: Colors.white,
    // 体系底部导航栏分割线色彩
    systemNavigationBarDividerColor: Colors.transparent,
    // 体系导航栏图标的亮度
    systemNavigationBarIconBrightness: Brightness.dark,
  ));
  // 设置设备方向
  await SystemChrome.setPreferredOrientations(<DeviceOrientation>[
    DeviceOrientation.portraitUp,
    DeviceOrientation.portraitDown
  ]).then((_) => runApp(const ProviderScope(child: MyApp())));
}

Flutter 路由装备

新建 lib/routers/routes.dart 文件:

import 'package:flutter/material.dart';
import 'package:shopping_mall/views/detail/detail_index.dart';
import 'package:shopping_mall/views/goods/goods_list_index.dart';
/// 路由生成器
Route onGenerateRoute(RouteSettings settings) {
  switch (settings.name) {
    case '/detail':
      return MaterialPageRoute(
          builder: (_) => const DetailIndex(), settings: settings);
    case '/goods':
      return MaterialPageRoute(
          builder: (_) => const GoodsListIndex(), settings: settings);
    default:
  }
}

mian.dart 中运用路由生成器特点 onGenerateRoute:

return MaterialApp(
  title: '青山商城',
  // 路由生成器
  onGenerateRoute: onGenerateRoute,
  // 初始路由途径
  initialRoute: '/',
  home: const AppHomeScreen(),
);

路由跳转与传参:

// 跳转到详情页,传参产品id
Navigator.of(context).pushNamed('/detail', arguments: {'id': goods.id});
// 跳转到主页,传参产品tab,并移除前史路由
Navigator.of(context).pushNamedAndRemoveUntil(
    '/', (route) => false,
    arguments: {'tab': 3});
// 页面获取传过来的参数
Map args = ModalRoute.of(context)?.settings.arguments as Map;
int id = args['id'];

网络恳求与数据模型

Http 恳求

运用 dio 库,dio 封装查看: flutter_dio_util

// 简略运用
import 'package:dio/dio.dart';
void main() async {
  var dio = Dio();
  final response = await dio.get('https://google.com');
  print(response.data);  
}
// response.data 这儿回来的是json字符串,可运用 data['name'] 获取值
// 要想像 Object 相同运用 data.name 获取。需求转化为Dart模型(Json to Dart)

Json to Dart

假如有如下 Person Json数据

{
  "name": "jsdawn",
  "age": 18,
  "gender": ""
}

运用 quicktype 将 Json 快速转化 为 Dart 模型 person.dart

// To parse this JSON data, do
//     final person = personFromJson(jsonString);
import 'dart:convert';
// 字符串转为`Person`模型
Person personFromJson(String str) => Person.fromJson(json.decode(str));
// `Person`数据转为字符串
String personToJson(Person data) => json.encode(data.toJson());
class Person {
    Person({
        this.name,
        this.age,
        this.gender,
    });
    String name;
    int age;
    String gender;
    // json中字段不可少,不然需求判空:json["name"] ?? ""
    factory Person.fromJson(Map<String, dynamic> json) => Person(
        name: json["name"],
        age: json["age"],
        gender: json["gender"],
    );
    Map<String, dynamic> toJson() => {
        "name": name,
        "age": age,
        "gender": gender,
    };
}

在 flutter 中运用:

// 这儿 DefaultAssetBundle 的 loadString 加载 Json 文件,模拟接口调用
String jsonString = await DefaultAssetBundle.of(context)
    .loadString('assets/json/data.json');
// 字符串转为 Dart Model
final person = personFromJson(jsonString);
print(person.name); // jsdawn

SVG图标的运用

运用精巧的svg图标,多一份酷爱,多一份色彩。

引进图标资源:

从iconfont.cn下载图标的svg文件,放入assets/svg目录。在 pubspec.yaml 中装备静态资源:

assets:
  - assets/svg/

引进flutter_svg插件并运用:

在项目终端履行指令 flutter pub add flutter_svg 下载插件。在页面中运用,以主页产品类型为例

import 'package:flutter_svg/svg.dart';
Padding(
    padding: const EdgeInsets.symmetric(vertical: 4),
    // SvgPicture.asset 加载svg静态资源
    child: SvgPicture.asset('assets/svg/category_phone.svg', height: 35),
),

瀑布流布局

产品列表项的高度或许不同,比方标题有一行也有两行。运用瀑布流布局,可完美和谐各块的高度。

怎么挑选插件:

在flutter插件商场 pub.dev 中搜索瀑布流布局插件,你找到的或许会许多,怎么挑选最适合当时项目的呢?

比方咱们项目开发环境是 flutter v2.x 版别,该版别最大的特点是支撑 Null Safety。所以首要需求支撑 Null Safety,然后挑选 LIKES 数量多而且契合瀑布流需求的插件。

引进插件并运用:

在项目终端履行指令 flutter pub add flutter_staggered_grid_view 下载插件。在项目中运用瀑布流布局编写产品列表组件

import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
@override
Widget build(BuildContext context) {
    // 运用插件的 MasonryGridView 类编写瀑布流布局
    return MasonryGridView.count(
      controller: _scrollController,
      padding: widget.list.isNotEmpty
          ? const EdgeInsets.symmetric(vertical: 12)
          : null,
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      crossAxisCount: 2, // 列的数量
      mainAxisSpacing: 20, // 主轴项之间的距离
      crossAxisSpacing: 12, // 穿插轴项之间的距离
      itemCount: widget.list.length,
      // 列表项生成器
      itemBuilder: (context, index) {
        return GoodsListItem(widget.list[index]);
      },
    );
}

购物车功用

购物车算是商城的中心功用之一,也有较杂乱的逻辑。

  1. 唯一性:同一件产品在色彩和尺度等标准都相同的情况下,在购物车里才算同一件物品;
  2. 本地存储:购物车中的产品需求存储到本地,用户未结算封闭了运用,当下次翻开运用时,购物车产品仍然在;
  3. 大局状况:购物车需求大局读取,在产品详情页显现数量和参加购物车,在购物车中加减数量等。

创立购物车数据模型

Flutter 2 商城App实战攻略(支撑Null safety)

购物车列表项UI如图所示,产品特点结合类型拟定Json数据:

{
    "id": 46,
    "title": "能方研影",
    "price": 61.96,
    "cover": "http://dummyimage.com/300x300/B9C6C3&text=shopping%20mall",
    "count": 3,
    "color": "蓝色",
    "size": "XS",
}

Json to Dart:前往quicktype,将Json数据复制进去转化为 Dart 数据模型(当然也能够手动编写)

class CartInfoModel {
  CartInfoModel({
    required this.id,
    required this.title,
    required this.price,
    required this.cover,
    required this.count,
    required this.color,
    required this.size,
  });
  int id;
  String title;
  double price;
  String cover;
  int count;
  String color;
  String size;
  ...
}

购物车状况办理

项目运用 Flutter 官方引荐的 riverpod (一种反应式缓存和数据绑定结构) 进行购物车状况办理,让数据能够跨组件运用。

引进插件并运用:

在项目终端履行指令flutter pub add flutter_riverpod下载插件。创立 lib/providers/cart_provider.dart文件:

import 'package:flutter_riverpod/flutter_riverpod.dart';
...
const cartPrefsKey = 'cartList';
class CartNotifier extends ChangeNotifier {
  // 购物车列表
  final List<CartInfoModel> cartList = <CartInfoModel>[];
  // 总数量
  int get totalCount {
    int total = 0;
    for (var item in cartList) {
      total += item.count;
    }
    return total;
  }
  // 总价格
  double get totalPrice {
    double total = 0;
    for (var item in cartList) {
      total += item.price * item.count;
    }
    return total;
  }
  // 参加购物车
  // 更新购物车数量
  // 移除购物车指定项
  // 查询/初始化购物车
}
// ChangeNotifierProvider 用法
final cartProvider = ChangeNotifierProvider<CartNotifier>((ref) {
  return CartNotifier();
});

注册大局状况:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
    runApp(
        // ProviderScope包裹进口组件,供给大局状况
        ProviderScope(child: MyApp())
    );
}

在组件中读取:

// 读取购物车列表
List<CartInfoModel> list =
    ref.watch(cartProvider.select((value) => value.cartList));
// 读取结算价格
double totalPrice =
    ref.watch(cartProvider.select((value) => value.totalPrice));
// 读取总数量
int totalCount =
    ref.watch(cartProvider.select((value) => value.totalCount));
// 调用办法
ref.read(cartProvider.notifier).pushCart(_cartInfo);

参加购物车

首要查找同一标准的产品,有则更新数量,无则参加购物车。参加购物车的 cartInfo 需求深复制防止影响再次挑选该产品其他标准的时分改动购物车数据。购物车数据改变后需求调用 notifyListeners 告诉状况办理器更新状况。

/// 参加购物车
void pushCart(CartInfoModel cartInfo) {
    // 查找 id/color/size 都相同的购物车产品
    int idx = cartList.indexWhere((item) => (item.id == cartInfo.id &&
        item.color == cartInfo.color &&
        item.size == cartInfo.size));
    if (idx > -1) {
      // 存在则数量+1
      cartList[idx].count += cartInfo.count;
    } else {
      // 不存在则增加(这儿深复制cartInfo)
      cartList.add(cartInfoModelFromJson(cartInfoModelToJson(cartInfo)));
    }
    // 告诉 CartNotifier 状况更新
    notifyListeners();
    // 更新数据存储到本地
    updCartPrefs();
}
/// 更新购物车数量
void updCartCount(CartInfoModel cartInfo, int count) {
    // 查找 id/color/size 都相同的购物车产品
    int idx = cartList.indexWhere((item) => (item.id == cartInfo.id &&
        item.color == cartInfo.color &&
        item.size == cartInfo.size));
    if (idx > -1) {
      // 存在则更新数量
      cartList[idx].count = count;
    }
    notifyListeners();
    // 更新数据存储到本地
    updCartPrefs();
}
/// 移除购物车指定产品
void renmoveCart(CartInfoModel cartInfo) {
    // 查找 id/color/size 都相同的购物车产品
    int idx = cartList.indexWhere((item) => (item.id == cartInfo.id &&
        item.color == cartInfo.color &&
        item.size == cartInfo.size));
    if (idx > -1) {
      // 存在则移除该项
      cartList.removeAt(idx);
    }
    notifyListeners();
    // 更新数据存储到本地
    updCartPrefs();
}

购物车本地存储

需求: 产品参加到购物车,还未结算并退出运用,下次进来购物车产品还在。
思路: 每次购物车数据有改动需求把数据更新到本地存储,下次翻开App的时分从本地康复数据到购物车。
耐久化存储: 项目运用 shared_preferences 插件进行本地耐久化存储。以key-value的形式存储字符串到本地。

引进插件并运用:

在项目终端履行指令 flutter pub add shared_preferences 下载插件,在 cart_provider.dart 购物车状况办理类中运用:

import 'package:shared_preferences/shared_preferences.dart';
class CartNotifier extends ChangeNotifier {
  final List<CartInfoModel> cartList = <CartInfoModel>[];
  ...
  /// 更新购物车数据到本地耐久化存储
  void updCartPrefs() async {
    final prefs = await SharedPreferences.getInstance();
    // 购物车数据转为字符串
    String cartString = json.encode(cartList).toString();
    // `setString`存储到本地
    prefs.setString('cartList', cartString);
  }
  /// 查询/初始化 - 从本地读取数据康复到购物车状况
  Future<List<CartInfoModel>> getCartList() async {
    // 获取耐久化实例
    final prefs = await SharedPreferences.getInstance();
    // 调用`getString`办法读取本地字符串数据
    String? _cartString = prefs.getString('cartList');
    cartList.clear();
    // 字符串数据转化为`CartInfoModel`初始化到购物车列表
    if (_cartString != null) {
      List<CartInfoModel> _list = cartInfoModelListFromJson(_cartString);
      cartList.addAll(_list);
    }
    notifyListeners();
    return cartList;
  }
}

初始化购物车:

用户每次翻开运用,康复购物车数据:在进口文件 main.dart 中调用 getCartList 办法初始化购物车数据。

import 'package:flutter_riverpod/flutter_riverpod.dart';
// flutter_riverpod 中的 `ConsumerStatefulWidget` 类
// 在状况组件中供给 ref 以运用状况办理器
class MyApp extends ConsumerStatefulWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  ConsumerState<MyApp> createState() => _MyAppState();
}
class _MyAppState extends ConsumerState<MyApp> {
  @override
  void initState() {
    super.initState();
    // 调用`getCartList`办法初始化购物车
    ref.read(cartProvider).getCartList();
  }
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '青山商城',
    );
  }
}

项目App打包

代码写的好不如产品包装的好,打包也是至关重要的一步。由于设备有限,项目只亲测了安卓apk打包,其他终端的打包办法参阅 官方打包布置

打包apk指令

项目终端履行指令,等候打包结束后提示apk方位。--split-per-abi 按abi拆分apk,减小包体积

flutter build apk --split-per-abi

运用称号修正

  • Android:在 android/app/src/main/AndroidManifest.xml 中修正android:label="运用称号"

  • iOS:在 ios/Runner/Info.plist 中修正 CFBundleName 对应的Value

运用图标修正

  • Android: 在 android/app/src/res/mipmap 文件夹中替换相应图片

  • iOS:在 ios/Runner/Assets.xcassets/AppIcon.appiconset 文件夹中替换相应尺度的图片。假如运用不同的文件名,还需更新同一目录中的 Contents.json 文件中的 filename

发动页图片

  • Android:在 android/app/src/res/drawable/launch_background.xml 文件完成自定义发动界面,这儿的是xml语法。也能够运用默许模版,在 android/app/src/res/mipmap 相应尺度文件下放入发动图片launch_image

    <item>
      <bitmap
        android:gravity="center"
        android:src="@mipmap/launch_image" />
    </item>
    
  • iOS:在 ios/Runner/Assets.xcassets/LaunchImage.imageset 文件夹中替换相应尺度的图片。假如运用不同的文件名,还需更新同一目录中的 Contents.json 文件中的 filename

安卓App签名

若需求将 apk 发布到各大运用商场,比方运用宝等,需求对app进行签名。

  1. 终端履行下方指令获取秘钥文件,文件默许在当时目录,将生成的 key.jks 复制到项目的 android/app/ 目录下:
keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key
  1. 修正 android/key.properties 文件(没有则新建)
storePassword=<上一过程中的暗码>
keyPassword=<上一过程中的暗码>
keyAlias=key
storeFile=key.jks
  1. android/app/build.gradle 中,android 代码块之前新增内容
// 新增的内容
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
// android 代码块
android {
    ...
}

修正 signingConfigsbuildTypes 代码块:

// 新增内容
signingConfigs {
    release {
        keyAlias keystoreProperties['keyAlias']
        keyPassword keystoreProperties['keyPassword']
        storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
        storePassword keystoreProperties['storePassword']
    }
}
// 替换的内容
buildTypes {
    release {
        signingConfig signingConfigs.release
    }
}

其他打包注意事项

apk打包后网络图片不显现:

android/app/src/profile/AndroidManifest.xml 中答应网络权限(若无效则需求在 android/app/src/main/AndroidManifest.xml也装备一份):

<uses-permission android:name="android.permission.INTERNET"/>

空安全过错(flutter v2):

假如你运用的是 flutter v2 版别或许更高版别,运转或打包时报空安全过错(null safety)。是由于你在项目中的部分写法或许插件不支撑空安全标准,主张替换支撑 Null Safety 的插件。或许运用以下指令 --no-sound-null-safety 跳过 Null Safety 检测。

flutter build apk --no-sound-null-safety

结语

能够看到,文章最初的 Flutter商城APP实战知识点图,项目知识点大大小小有几十个,由于精力有限,抽取了几个首要知识点解说。假如咱们想要一同学习图中 其他的知识点,能够在谈论区 留言, 每周会抽取时刻 更新文章

博观而约取,厚积而薄发
记得 点赞 + ❤️保藏,➕重视不走失

项目Gitee:gitee.com/jsdawn/shop…

转载声明: 请注明作者,注明原文链接,有疑问致邮 kingwyh1993@163.com