美文网首页
Flutter:仿京东商城APP的完整开发指南(一)

Flutter:仿京东商城APP的完整开发指南(一)

作者: 时光啊混蛋_97boy | 来源:发表于2020-12-11 18:35 被阅读0次

原创:有趣知识点摸索型文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

目录

  • 一、Flutter简介
    • 1、开源跨平台UI框架
    • 2、Flutter的优缺点
    • 3、Flutter的理念架构
    • 4、Flutter的绘制流程
    • 5、Flutter 与原生Android、iOS进行通信的原理
  • 二、Flutter的环境配置和常用命令
    • 1、配置环境
    • 2、常用命令
  • 三、Flutter的资源文件和依赖库
    • 1、配置资源文件
    • 2、配置依赖库
  • 四、全局辅助功能文件
    • 1、路由跳转传值
    • 2、网络请求接口数据
    • 3、屏幕适配
    • 4、存储数据
    • 5、状态共享
    • 6、提示框
    • 7、广播
  • 五、京东商城APP的首页
    • 1、配置底部 tab
    • 2、广告轮播图
    • 3、首页商品内容 View
    • 4、顶部导航栏
    • 5、初始化商品首页页面
  • Demo
  • 学习资料

一、Flutter简介

1、开源跨平台UI框架

Flutter是Google推出的一套开源跨平台UI框架,可以快速地在Android、iOS和Web平台上构建高质量的原生用户界面。同时,Flutter还是Google新研发的Fuchsia操作系统的默认开发套件。在全世界,Flutter正在被越来越多的开发者和组织使用,并且Flutter是完全免费、开源的。

Flutter采用现代响应式框架构建,其中心思想是使用组件来构建应用的UI。当组件的状态发生改变时,组件会重构它的描述,Flutter会对比之前的描述,以确定底层渲染树从当前状态转换到下一个状态所需要的最小更改。


2、Flutter的优缺点

优点
  • 热重载(Hot Reload):利用 Android Studio 直接一个ctrl+r就可以重载,模拟器立马就可以看见效果,相比原生冗长的编译过程强很多
  • 一切皆为 Widget 的理念:对于Flutter来说,手机应用里的所有东西都是Widget,通过可组合的空间集合、丰富的动画库、分层扩展的架构,实现了富有感染力的灵活界面设计
  • 良好的运行效率:利用 Flutter 构建的应用在运行效率上会和原生应用差不多。
缺点
  • 不支持热更新
  • 第三方库有限,需要自己造轮子
  • Dart 语言编写,增加了学习难度,并且学习了 Dart 之后无其他用处。

3、Flutter的理念架构

Widget

在 Flutter 中,几乎所有东西都是Widget。将一个Widget想象为一个可视化的组件,当你需要构建与布局相关的任何内容时,你就需要使用Widget

Widget树

Widget以树结构进行组织。包含其他Widgetwidget被称为父Widget(或widget容器)。

Context

仅仅是已创建的所有Widget树结构中的某个Widget的位置引用。简而言之,将context作为widget树的一部分,其中context所对应的widget被添加到此树中。一个context只从属于一个widget,它和widget一样是链接在一起的,并且会形成一个context树。

State

应用于State的任何更改都会强制重建Widget

StatelessWidget

一旦创建就不关心任何变化,在下次构建之前都不会改变。它们除了依赖于自身的配置信息(在父节点构建时提供)外不再依赖于任何其他信息。比如典型的TextRowColumnContainer等,都是StatelessWidget。它的生命周期相当简单:初始化、通过build()渲染。

StatefulWidget

在生命周期内,该类Widget所持有的数据可能会发生变化,这样的数据被称为State,这些拥有动态内部数据的Widget被称为StatefulWidget。比如复选框、Button等。State会与Context相关联,并且此关联是永久性的。


4、Flutter的绘制流程

a、渲染机制

Flutter 只关心向 GPU提供视图数据,GPUVSync信号同步到 UI线程,UI线程使用 Dart来构建抽象的视图结构,这份数据结构在 GPU线程进行图层合成,视图数据提供给 Skia引擎渲染为 GPU数据,这些数据通过 OpenGL提供给 GPU

Flutter 既不使用WebView,也不使用系统的原生控件,而是通过高性能的渲染引擎来画控件,它只有C/C++代码编写的单一层,这样开发者更容易控制系统。

Flutter用什么技术构建的? C, C++, Dart, and Skia (2D 渲染引擎)。Flutter 和 React Native 不同主要在于 Flutter 是直接通过skia渲染的 ,而 React Native 是将js中的控件转化为原生控件,通过原生去渲染的。

b、热重载

热加载是注入源代码到运行中的Dart虚拟机,包括增加新的类,和给已有类增加新的方法和变量以及修改已有方法。热加载功能是状态保持的(stateful),可以快速重复屏幕内容而不需要从主屏幕开始加载。

热重载的流程可以分为 5 步,包括:扫描工程改动、增量编译、推送更新、代码合并、Widget 重建。Flutter在接收到代码变更后,并不会让 App 重新启动执行,而只会触发 Widget树的重新绘制,因此可以保持改动前的状态,大大缩短了从代码修改到看到修改产生的变化之间所需要的时间。

另一方面,由于涉及到状态的保存与恢复,涉及状态兼容与状态初始化的场景,热重载是无法支持的,如改动前后 Widget状态无法兼容、全局变量与静态属性的更改、main 方法里的更改、initState方法里的更改、枚举和泛型的更改等。

可以发现,热重载提高了调试 UI 的效率,非常适合写界面样式这样需要反复查看修改效果的场景。


5、Flutter 与原生Android、iOS进行通信的原理

Flutter 通过PlatformChannel与原生进行交互,其中 PlatformChannel分为三种:

  • BasicMessageChannel :用于传递字符串和半结构化的信息。
  • MethodChannel :用于传递方法调用(method invocation)。
  • EventChannel: 用于数据流(event streams)的通信。
Flutter如何将代码运行在Android上?

引擎的C/C++代码使用AndroidNDK编译的,并且框架的大部分和APP代码作为本地代码(由Dart编译器编译的)运行的。

Flutter如何将代码运行在iOS上?

引擎的C/C++代码使用LLVM编译,并且任何Dart代码都是AOT编译成本地代码。

可以嵌入Flutter视图到你已经存在的AndroidiOSAPP中。


二、Flutter的环境配置和常用命令

1、配置环境

a、在bash文件中配置Flutter环境变量
export PATH=/Users/xiejiapei/flutter/bin:$PATH
export PUB_HOSTED_URL=https://mirrors.tuna.tsinghua.edu.cn/dart-pub
export FLUTTER_STORAGE_BASE_URL=https://mirrors.tuna.tsinghua.edu.cn/flutter
export FLUTTER_ROOT=/Users/xiejiapei/flutter
export ANDROID_HOME=~/Library/Android/sdk
export PATH=${PATH}:${ANDROID_HOME}/emulator
export PATH=${PATH}:${ANDROID_HOME}/tools
export PATH=${PATH}:${ANDROID_HOME}/platform-tools
export PATH=/usr/local/Cellar/node@10/10.15.2/bin:$PATH 

b、MacOS 的 zsh 和 bash 切换

bash 的环境变量是.bash_profile文件。zsh 的环境变量是.zshrc文件。如果从 bash切换到 zsh,但想保留 bash 所设置的环境变量,可在 .zshrc文件末尾添加 source ~/.bash_profile 保存退出,并重启终端即可使用 bash 的环境变量。

zsh: command not found: flutter
xiejiapei@xiejiapeis-iMac ~ % source ~/.bash_profile

zsh 切换回 bash

chsh -s /bin/bash

使用系统自带的 zsh,输入密码成功切换,重启终端即可使用 zsh

chsh -s /bin/zsh

c、android studio 显示 No Device

在终端运行以下命令,配置android sdk路径。

flutter config --android-sdk /Users/xiejiapei/Library/Android/sdk

d、其他问题

在终端运行以下命令进行检查,按照错误提示进行更正。

flutter doctor --android-licenses

老实说,Flutter里面稀奇古怪的问题太他妈多了,我写这份教程中大约有1/3的时间都是解决各种环境配置问题、IOS端和安卓端环境适配,SDK和Dart路径寻找,SDK版本升级,找不到模拟器,真机无法调适,版本不适配......还能说出一大堆。真的很讨厌Flutter,本来语法就长得丑,连自己的开发环境都没有,借用android studioVisual Studio Code导致产生一大堆毛病。本来开发者的核心是开发功能,结果配置环境这种细枝末节就耗费了大量精力,真的是和本来的目的南辕北辙了,捡了芝麻丢了西瓜🍉,劝退在坐各位。


2、常用命令

a、创建Flutter项目

不要使用android studio创建项目,国内环境下会卡在创建界面无法显示。在终端使用以下命令行进行创建项目

flutter create my_flutter_app
b、运行Flutter项目

可以使用android studio打开项目

也可以使用以下命令行打开项目

flutter run -v
c、安装Flutter依赖包
flutter run 或者 flutter packages get

三、Flutter的资源文件和依赖库

1、配置资源文件

a、ICON 图标

在Flutter官方的 Icons 图标设计网站搜索图标名称。

b、导入图标依赖库
cupertino_icons: ^0.1.2

2、配置依赖库

a、寻找需要的库

在Flutter官方的依赖库网站 pub.dev 搜索你需要的库,以网络请求库Dio为例。

搜索结果如下

b、研读官网的安装和使用方式

如果需要更为详细的使用方式可以查看example页面。

c、在项目中安装依赖库

首先需要将需要安装的依赖库添加到配置文件中

然后在终端运行Flutter依赖库的安装命令即可

flutter run 或者 flutter packages get

接下来就等它慢慢安装完成。Flutter在国内安装一些依赖库比较缓慢需要耐心等待,但是当发现没有响应的时候可以尝试关闭掉项目后再重新打开进行安装。

(base) xiejiapei@xiejiapeis-iMac jdshop_app % flutter packages get  
Downloading Dart SDK from Flutter engine 2c956a31c0a3d350827aee6c56bb63337c5b4e6e...
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  3  172M    3 6255k    0     0   311k      0  0:09:26  0:00:20  0:09:06  452k
d、在项目中使用依赖库

首先需要导入依赖库头文件

import 'package:dio/dio.dart';

接着便可以使用从官网上学到的语法进行配置了,以APP首页请求轮播图的广告数据为例。

// 轮播图Model
List _focusData = [];
_getFocusData() async {
  var apiURL = "${Config.domain}api/focus";
  var result = await Dio().get(apiURL);
  print(result.data is Map);// String 还需要转化为Map
  var focusList = FocusModel.fromJson(result.data);

  /*
  focusList.result.forEach((item) {
    print(item.pic);
  });
  */

  setState(() {
    this._focusData = focusList.result;
  });
}

四、全局辅助功能文件

1、路由跳转传值

a、引入头文件

引入所有需要跳转到的页面的头文件

import 'package:flutter/material.dart';
import 'package:jdshop_app/address/AddressAdd.dart';
import 'package:jdshop_app/address/AddressEdit.dart';
import 'package:jdshop_app/address/AddressList.dart';
import 'package:jdshop_app/pages/login/Login.dart';
import 'package:jdshop_app/pages/login/RegisterFirst.dart';
import 'package:jdshop_app/pages/login/RegisterSecond.dart';
import 'package:jdshop_app/pages/login/RegisterThird.dart';
import 'package:jdshop_app/pages/productContent/CheckOut.dart';
import 'package:jdshop_app/pages/productContent/Order.dart';
import 'package:jdshop_app/pages/productContent/OrderInfo.dart';
import 'package:jdshop_app/pages/productContent/Pay.dart';
import 'package:jdshop_app/pages/productContent/ProductContent.dart';
import 'package:jdshop_app/pages/tabs/Cart.dart';
import 'package:jdshop_app/pages/tabs/ProductList.dart';
import 'package:jdshop_app/pages/tabs/Search.dart';
import 'package:jdshop_app/pages/tabs/Tabs.dart';
b、配置路由

当通过路由跳转页面时,需要分为两种情况,一种是直接跳转,另外一种是需要向跳转的页面传入参数,两种写法如下。

final routes = {
  '/': (context) => Tabs(),
  '/cart':(context) => CartPage(),
  '/login':(context) => LoginPage(),
  '/registerFirst':(context) => RegisterFirstPage(),
  '/registerSecond': (context,{arguments}) => RegisterSecondPage(arguments: arguments),
  '/registerThird':(context,{arguments}) => RegisterThirdPage(arguments: arguments),
  '/search': (context) => SearchPage(),
  '/productList': (context,{arguments}) => ProductListPage(arguments:arguments),
  '/productContent': (context,{arguments}) => ProductContentPage(arguments:arguments),
  '/checkOut': (context) => CheckOutPage(),
  '/addressAdd': (context) => AddressAddPage(),
  '/addressEdit': (context,{arguments}) => AddressEditPage(arguments:arguments),
  '/addressList': (context) => AddressListPage(),
  '/pay': (context) => PayPage(),
  '/order': (context) => OrderPage(),
  '/orderinfo': (context) => OrderInfoPage(),
};
c、创建路由器统一处理跳转功能

写法固定,不用深究,套用模版即可。

var onGenerateRoute = (RouteSettings settings) {
// 统一处理
  final String name = settings.name;
  final Function pageContentBuilder = routes[name];
  if (pageContentBuilder != null) {
    if (settings.arguments != null) {
      final Route route = MaterialPageRoute(
          builder: (context) =>
              pageContentBuilder(context, arguments: settings.arguments));
      return route;
    } else {
      final Route route =
      MaterialPageRoute(builder: (context) => pageContentBuilder(context));
      return route;
    }
  }
};
d、跳转传值

以注册页面为例,我们需要将用户输入的手机号传入到下一个页面去。

通过路由器向下个页面传入参数值的方式如下,registerSecond指的是下一个页面的名称,arguments里面使用字典形式传入你的参数,可以有多个,逗号隔开。

Navigator.pushNamed(context, '/registerSecond', arguments: {
  "tel":this._tel
});
e、接收参数

收到来自上个页面传入的手机号将其显示出来。

通过路由器接收来自上个页面的参数值的方式如下

class RegisterSecondPage extends StatefulWidget {
  final Map arguments;
  RegisterSecondPage({Key key, this.arguments}) : super(key: key);

  _RegisterSecondPageState createState() => _RegisterSecondPageState();
}

class _RegisterSecondPageState extends State<RegisterSecondPage> {
  // 手机号码
  String _tel;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    this._tel = widget.arguments["tel"];
  }
}

2、网络请求接口数据

a、配置接口URL的base地址

单独创建一个新类Config,其包含一个静态属性domain,值为接口URLbase地址,可以将其简单理解为一个全局单例。

class Config {
  // 静态属性,直接通过类名访问
  static String domain = "http://jd.itying.com/";
}

这样使用的时候直接通过类名访问即可,然后在base地址后添加上具体页面的实际地址即可得到最终的接口URL

var apiURL = "${Config.domain}api/focus";
b、在模型类中序列化 JSON

在模型类中序列化 JSON 的好处包括:

  • 类型安全:如20看不出int还是string,而在模型类中规定了属性的类型
  • 自动补全:可以写成people.name而不是people["name"],这样防止了编译时异常,因为假如people["neme"]打错了没有发现,编译了就会报错。

以首页的商品列表数据为例,创建模型类的方式如下。这是一种固定的写法,针对不同的模型类只需要修改类名即可,即这里的FocusModelFocusItem

class FocusModel {
  List<FocusItem> result;

  FocusModel({this.result});

  FocusModel.fromJson(Map<String, dynamic> json) {
    if (json['result'] != null) {
      result = new List<FocusItem>();
      json['result'].forEach((v) {
        result.add(new FocusItem.fromJson(v));
      });
    }
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    if (this.result != null) {
      data['result'] = this.result.map((v) => v.toJson()).toList();
    }
    return data;
  }
}

FocusModel指的是整个列表的数据,而列表中每个item的数据FocusItem的模型类如下。

class FocusItem {
  String sId;// 商品id
  String title;// 标题
  String status;
  String pic;// 图片地址
  String url;// 跳转地址

  // 可选构造函数
  FocusItem({this.sId, this.title, this.status, this.pic, this.url});

  // 命名构造函数
  FocusItem.fromJson(Map<String, dynamic> json) {
    sId = json['_id'];
    title = json['title'];
    status = json['status'];
    pic = json['pic'];
    url = json['url'];
  }

  // 类里面的属性转化为Map类型
  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data['_id'] = this.sId;
    data['title'] = this.title;
    data['status'] = this.status;
    data['pic'] = this.pic;
    data['url'] = this.url;
    return data;
  }
}

FocusItem里添加后端接口提供的属性。既可以手动一条条添加,也可以使用工具 JSON to Dart 迅速地将JSON数据转化为模型类对应条目。

Flutter 中一般 json 数据从 String转为 Object 的过程中都需要先经过 Map 类型。

c、使用Dio依赖库进行网络请求

以APP首页请求广告轮播图的图片数据为例,其网络请求方式如下

  • _focusData是个数组用来存储请求到的图片数据列表。
  • 使用get方式进行网络请求
  • 将请求到的JSON数据转化为列表
  • 打印图片数据出来看看是否请求成功
// 轮播图Model
List _focusData = [];
_getFocusData() async {
  var apiURL = "${Config.domain}api/focus";
  var result = await Dio().get(apiURL);

  print(result.data is Map);// String 还需要转化为 Map
  var focusList = FocusModel.fromJson(result.data);

  // 打印图片数据出来看看是否请求成功
  focusList.result.forEach((item) {
    print(item.pic);
  });

  setState(() {
    this._focusData = focusList.result;
  });
}

3、屏幕适配

a、导入依赖库 flutter_screenutil

flutter_ScreenUtil 可以解决Flutter 不同终端屏幕适配问题,传入context和设计稿子上的宽高即可在不同设备上实现相同的UI效果。

flutter_screenutil: ^1.1.0
import 'package:jdshop_app/services/ScreenAdaper.dart';
b、封装静态宽、高方法,直接通过类名称来访问

封装成静态方法后,每次就可以非常方便地使用,而不用再按照依赖库的使用说明填写一长串代码。

class ScreenAdaper {

  // 解决Flutter 不同终端屏幕适配问题,传入context和设计稿子上的宽高
  static init(context) {
    ScreenUtil.init(context, width: 750, height: 1334);
  }

  // 获取计算后的高度: 传入int 转化为 double
  static height(double value) {
    return ScreenUtil().setHeight(value);
  }

  // 获取计算后的宽度
  static width(double value) {
    return ScreenUtil().setWidth(value);
  }

  // 获取屏幕高度
  static getScreenHeight() {
    return ScreenUtil.screenHeightDp;
  }

  // 获取屏幕宽度
  static getScreenWidth(){
    return ScreenUtil.screenWidthDp;
  }

  // 获取字体大小
  static fontSize(double value) {
    return ScreenUtil().setSp(value);
  }
}
c、使用方式

首先需要在使用的页面传入该页面的context,让适配器知道是在适配哪个页面。

@override
Widget build(BuildContext context) {
  // 解决Flutter 不同终端屏幕适配问题,传入context和设计稿子上的宽高
  ScreenAdaper.init(context);
}

接着就可以直接使用了,以下面的代码为例,设置了容器的适配高度,左边距和内边距。

return Container(
    height: ScreenAdaper.height(32),
    // 左侧边框右移一点
    margin: EdgeInsets.only(left: ScreenAdaper.width(20)),
    // 内部组件之间有间距 
    padding: EdgeInsets.only(left: ScreenAdaper.width(20)),
)

4、存储数据

a、shared_preferences依赖库 简介

为简单数据包装特定于平台的持久存储(iOS和macOS上的NSUserDefaults,Android上的SharedPreferences等)。

导入方式如下

shared_preferences: ^0.5.7
import 'package:shared_preferences/shared_preferences.dart';

官网上的使用教程如下

void main() {
  runApp(MaterialApp(
    home: Scaffold(
      body: Center(
      child: RaisedButton(
        onPressed: _incrementCounter,
        child: Text('Increment Counter'),
        ),
      ),
    ),
  ));
}

_incrementCounter() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  int counter = (prefs.getInt('counter') ?? 0) + 1;
  print('Pressed $counter times.');
  await prefs.setInt('counter', counter);
}
b、将shared_preferences依赖库封装成工具类

Flutter中的异步其实就是用的Dart里面的Futurethen函数,回调catchError这些东西。Dart代码在单线程中执行,所以如果代码在运行线程中阻塞的话,会使程序冻结。 Future对象(futures)表示异步操作的结果,进程或者IO会延迟完成。在async函数中使用await来挂起执行,直到future完成为止(或者使用then)。在async函数中使用try-catch来捕获异常(或者使用catchError())。Future相当于40米大砍刀,dart提供了关键字async(异步)和await(延迟执行),相当于普通的便捷的小匕首,而小匕首是我们平时经常用到的。当遇到有需要延迟的运算(async)时,将其放入到延迟运算的队列(await)中去,把不需要延迟运算的部分先执行掉,最后再来处理延迟运算的部分。

class Storage{

  // 存储新数据
  static Future<void> setString(key,value) async{
    SharedPreferences sp=await SharedPreferences.getInstance();
    sp.setString(key, value);
  }

  // 获取存储的数据
  static Future<String> getString(key) async{
    SharedPreferences sp=await SharedPreferences.getInstance();
    return sp.getString(key);
  }

  // 移除存储的数据
  static Future<void> remove(key) async{
    SharedPreferences sp=await SharedPreferences.getInstance();
    sp.remove(key);
  }

  // 清空存储的所有数据
  static Future<void> clear() async{
    SharedPreferences sp=await SharedPreferences.getInstance();
    sp.clear();
  }

}
c、使用封装好的工具库Storage

获取数据

// 初始化商品列表数据
init() async {
  // 判断有无数据
  try {
    this._cartList = json.decode(await Storage.getString("cartList"));
  } catch (error) {
    // 无数据则置为空
    this._cartList = [];
  }
}

更新数据

// 增减商品
itemChange() async {
  await Storage.setString("cartList", json.encode(this._cartList));
}

移除数据

// 退出登陆
static loginOut() async {
  Storage.remove("userInfo");
}

5、状态共享

a、provider依赖库

当我们想在多个页面(组件/Widget)之间共享状态(数据),或者一个页面(组 件/Widget)中的多个子组件之间共享状态(数据),这个时候我们就可以用 Flutter 中的状态管理来管理统一的状态(数据),实现不同组件直接的传值和数据共享。

Flutter 中 InheritedWidget一般用于状态共享,如ThemeLocalizationsMediaQuery等,都是通过它实现共享状态,这样我们可以通过 context去获取共享的状态,比如ThemeData theme = Theme.of(context);providerInheritedWidget组件的上层封装, 使其更易用, 更易复用。

导入provider依赖库

provider: ^4.0.5
import 'package:provider/provider.dart';
b、为Provider依赖库提供服务的工具类

以购物车中创建的CartProvider为例,CartProvider用来存放购物车数据和全选状态。

购物车
提供的属性
// 商品列表
List _cartList = [];
// 及时更新商品列表
List get cartList=>this._cartList;
// 及时更新商品列表的数量
int get cartNum=>this._cartList.length;

// 全选状态
bool _isCheckAll = false;
// 及时更新全选状态
bool get isCheckAll=>this._isCheckAll;

// 商品总价
double _allPrice = 0;
// 及时更新商品总价
double get allPrice => this._allPrice;
初始化赋值,获取购物车列表数据
CartProvider(){
  this.init();
}
更新购物车列表数据

当我们在购物车界面点击了结算按钮支付后就应该将购物车列表中选中的已经结算的商品给删除掉,此时就需要更新购物车列表数据。同样当我们向购物车列表中添加了新商品后也需要更新购物车列表数据。

updateCartList() {
  init();
}
  • Storage.getString("cartList")):因为每次在购物车列表中增添商品都会将数据存储到Storage中,所以当下次启动APP的时候就可以从Storage中获取之前存储的商品列表数据
  • this.isFirstCheckAll:需要获取刚启动APP的时候的全选状态,这样才知道第一次进入购物车界面下方的全选按钮是否应该选中
  • computeAllPrice():计算商品总价用于购物车界面下方显示总价
  • notifyListeners:通知其他组件更新数据,拿到新数据去显示
init() async {
  // 判断有无数据
  try {
    this._cartList = json.decode(await Storage.getString("cartList"));
  } catch (error) {
    // 无数据则置为空
    this._cartList = [];
  }

  // 需要获取刚启动APP的时候的全选状态
  this._isCheckAll = this.isFirstCheckAll();

  // 计算商品总价
  this.computeAllPrice();

  // 通知其他组件更新数据
  notifyListeners();
}
全选反选
  • this._cartList.forEach((item):遍历商品列表,根据valuetrue或者false来对每个商品进行选中或者不选中,达成全选或者全不选效果
  • this.computeAllPrice():购物车选中的商品列表因为全选/全不选发生了改变,所有需要重新计算商品总价。此时的总价要么是所有的商品价格和(全选中),要么为0(全不选)
  • Storage.setString:需要将全选/全不选的商品列表存储到Storage
  • notifyListeners:通知其他组件更新数据
checkAll(value) async {
  // 遍历商品列表,根据value为true或者false来对每个商品进行选中或者不选中
  this._cartList.forEach((item) {
    item["checked"] = value;
  });
  this._isCheckAll = value;

  //计算总价
  this.computeAllPrice();
  await Storage.setString("cartList", json.encode(this._cartList));
  notifyListeners();
}

遍历商品列表中所有的商品,只要有一个未选中,即可直接返回false,表示不是全选状态。

// 判断刚进入页面时候是否处于全选状态
bool isFirstCheckAll() {
  if (this._cartList.length > 0) {
    for (var i = 0; i < this._cartList.length; i++) {
      if (this._cartList[i]["checked"] == false) {
        return false;
      }
    }
    return true;
  } else {
    return false;
  }
}
商品改变

这个方法是改变了商品列表中商品的种类,当我们将新商品添加到购物车或者从购物车中删除了不喜欢的商品的时候才会调用,其会改变this._cartList数组的数目。

  • this.isFirstCheckAll:监听商品列表的增减商品,使商品列表是否全部选中的状态和底部全选按钮保持同步
  • this.computeAllPrice:当商品列表增加或者减少商品时,都需要重新计算底部的商品总价
  • Storage.setString:将更新后的商品列表重新存储
// + - 后将数据保存到本地
itemChange() async {
  // 监听每一项的选中事件,使列表和全选按钮保持同步
  if (this.isFirstCheckAll()) {
    this._isCheckAll = true;
  } else {
    this._isCheckAll = false;
  }
  // 计算总价
  this.computeAllPrice();
  await Storage.setString("cartList", json.encode(this._cartList));
  // 通知其他页面更新
  notifyListeners();
}

这个方法是改变了商品列表中某件商品的数量,比如+1或者-1的时候才会调用,其并不会改变this._cartList数组的数目,但是会改变cartList中某个商品的数目this._itemData["count"] + 1。同样也需要将其存储,计算总价,通知其他组件知道。

itemCountChange() {
  Storage.setString("cartList", json.encode(this._cartList));
  //计算总价
  this.computeAllPrice();

  notifyListeners();
}
删除商品
  • tempList.add(this._cartList[i]):删除购物车列表中的商品可以理解为将那些在编辑模式中选中的商品全部删除掉而保留那些未选中的,所有可以创建一个临时数组用来存储那些遗留下来的商品,再将该列表存储,计算总价,通知其他页面更新
  • this._cartList.removeAt(i):不可以使用这种方式删除商品列表中的数据,因为数组删除后会向前移动一位,假如数组为12345,我们想要的效果是依次删除2和3(索引1和2),删除后数组变为145,但是因为数组会自动前移,导致删除2(索引1)后,数组变为1345,此时再删除(索引2)就会变成删除4,删除后数组变为135和我们想要的效果不符合
removeItem() async {
  List tempList = [];
  for (var i = 0; i < this._cartList.length; i++) {
    if (this._cartList[i]["checked"] == false) {
      // ['1111','2222','333333333','4444444444']
      // 数组删除后会向前移动一位,导致删除2222(索引1)后,再删除(索引2)就会变成删除444444
      // this._cartList.removeAt(i);

      tempList.add(this._cartList[I]);
    }
  }
  this._cartList = tempList;
  // 计算总价
  this.computeAllPrice();
  await Storage.setString("cartList", json.encode(this._cartList));
  // 通知其他页面更新
  notifyListeners();
}
计算总价

假如商品列表中该商品选中,则将其数量*单价即可获得该商品的总价,再将所有商品的总价相加即是最终的结算价格

computeAllPrice() {
  double tempAllPrice = 0;
  for (var i = 0; i < this._cartList.length; i++) {
    if (this._cartList[i]["checked"] == true) {
      tempAllPrice += this._cartList[i]["price"] * this._cartList[i]["count"];
    }
  }
  this._allPrice = tempAllPrice;

  notifyListeners();
}
c、通过Provider依赖库使用工具类

通过ProviderContext中获取到共享的状态CartProvider

CartProvider cartProvider = Provider.of<CartProvider>(context);

购物车页面判断列表数量是否大于0

body: cartProvider.cartList.length > 0

购物车页面展示商品列表

Column(
  children: cartProvider.cartList.map((value) {
    return CartItem(value);
  }).toList(),
),

购物车页面全选按钮是否选中

child: Checkbox(
  value: cartProvider.isCheckAll,
  activeColor: Colors.pink,
  onChanged: (value) {
    // 实现全选反选
    cartProvider.checkAll(value);
  },
),

购物车页面底部的商品总价

this._isEdit == false
    ? Text("¥ ${cartProvider.allPrice}",
        style: TextStyle(color: Colors.red))
    : Text("")

删除购物车里面的商品

child: RaisedButton(
  child: Text("删除",
      style: TextStyle(color: Colors.white)),
  color: Colors.red,
  onPressed: () {
    // 删除数据
    cartProvider.removeItem();
  },
),

6、提示框

a、导入提示框依赖库
fluttertoast: ^4.0.1
import 'package:fluttertoast/fluttertoast.dart';
b、提示框依赖库的使用方式
Fluttertoast.showToast(msg: "用户名格式不正确",toastLength: Toast.LENGTH_SHORT,gravity: ToastGravity.CENTER);

7、广播

a、导入广播依赖库

事件总线遵循发布/订阅模式。event_bus允许侦听器订阅事件,并允许发布者触发事件,这就使对象能够交互。

event_bus: ^1.1.1
import 'package:event_bus/event_bus.dart';

官网上的使用方式如下

// 创建eventBus
EventBus eventBus = EventBus();

// 定义事件
class UserLoggedInEvent {
  User user;

  UserLoggedInEvent(this.user);
}

// 注册监听某个事件
eventBus.on<UserLoggedInEvent>().listen((event) {
  // All events are of type UserLoggedInEvent (or subtypes of it).
  print(event.user);
});

// 触发事件
User myUser = User('Mickey');
eventBus.fire(UserLoggedInEvent(myUser));
b、将event_bus依赖库封装成工具类

创建eventBus

EventBus eventBus = EventBus();

购物车广播事件

class ProductContentEvent {
  String str;
  ProductContentEvent(String str) {
    this.str = str;
  }
}

用户中心广播事件

class UserEvent {
  String str;
  UserEvent(String str) {
    this.str = str;
  }
}

增加地址广播事件

class AddressEvent {
  String str;
  AddressEvent(String str) {
    this.str = str;
  }
}

默认地址广播事件

class DefaultAddressEvent {
  String str;
  DefaultAddressEvent(String str) {
    this.str = str;
  }
}
c、使用广播工具类注册监听某个事件

注册监听登陆界面的广播事件

void initState() {
  // TODO: implement initState
  super.initState();
  this._getUserInfo();

  // 监听登陆界面的广播事件
  eventBus.on<UserEvent>().listen((event){
    print("object");
    print(event.str);
    // 重新获取用户信息,因为子页面返回不会触发init
    this._getUserInfo();
  });
}
d、使用广播工具类触发事件

广播:登陆页面退出的时候,通知用户中心刷新页面

void dispose() {
  // TODO: implement dispose
  super.dispose();

  // 广播:登陆页面退出的时候,通知用户中心刷新页面
  eventBus.fire(new UserEvent("登陆成功..."));
}

五、京东商城APP的首页

商城首页

1、配置底部 tab

配置底部 tab
a、引入头文件

引入所需tab选项对应的页面文件,包括首页、分类、购物车、我的四个页面。

import 'package:jdshop_app/pages/tabs/Cart.dart';
import 'package:jdshop_app/pages/tabs/Category.dart';
import 'package:jdshop_app/pages/tabs/Home.dart';
import 'package:jdshop_app/pages/tabs/User.dart';
b、添加底部导航栏按钮
  • currentIndex:设置默认选中页面,这里使用了_currentIndex参数,使默认选中页面方便修改。
  • onTap:当选中tab选项时候的回调函数,需要及时修改_currentIndex即当前页面参数,再跳到选中的页面实现页面切换。
  • items:配置了首页、分类、购物车、我的四个页面的按钮,只需要设置图标和文字即可。图标使用的都是系统自带的。
bottomNavigationBar: BottomNavigationBar(
  currentIndex: this._currentIndex, // 默认选中第几个
  onTap: (index) { // 选中变化回调函数
    setState(() {
      this._currentIndex = index;
      // 实现页面切换
      this._pageController.jumpToPage(index);
    });
  },
  type: BottomNavigationBarType.fixed,
  fixedColor: Colors.red, // 选中的颜色
  items: [ // 底部导航条按钮集合

    BottomNavigationBarItem(
      icon: Icon(Icons.home),
      title: Text("首页")
    ),
    BottomNavigationBarItem(
        icon: Icon(Icons.category),
        title: Text("分类")
    ),
    BottomNavigationBarItem(
        icon: Icon(Icons.shopping_cart),
        title: Text("购物车")
    ),
    BottomNavigationBarItem(
        icon: Icon(Icons.people),
        title: Text("我的")
    )
  ],
),
c、点击底部导航栏按钮时实现对应页面加载

需要用到的变量

// 默认加载分类页面
int _currentIndex = 0;

List<Widget> _pageList = [
  HomePage(),
  CategoryPage(),
  CartPage(),
  UserPage(),
];

实现页面加载

body: IndexedStack(
  index: this._currentIndex,
  children: this._pageList
),

上面这种页面加载方式的优点是切换TAB时候不用重新请求接口,实现状态保持,不会重新刷新页面,即你当前在首页下滑浏览了一些商品后切换到分类页面,此后再回到首页仍然会停留在之前浏览商品的位置而不会重新刷新页面。

停留在之前浏览商品的位置

缺点也很明显,刚开始会一次性加载四个页面,根据_currentIndex决定显示哪个,所以切换到购物车时候不会重新请求刷新数据,导致我们添加了商品后切换到购物车,购物车并没有添加到该商品。

购买商品

只会显示启动APP的时候的数据而不会重新刷新页面显示上图添加到购物车的商品。

购物车

解决方案是使用另外一种页面加载方式,即部分页面缓存,而部分页面重新加载。其创建方式如下

var _pageController;

void initState()
{
  super.initState();
    
  // 初始化
  this._pageController = new PageController(initialPage: this._currentIndex);
}

// 部分页面缓存部分页面重新加载
body: PageView(// 实现页面加载
  controller: this._pageController,
  children: this._pageList,
  onPageChanged: (index){// 手左右滑动的索引值
    // 让底部Tab也对应选中
    setState(() {
      this._currentIndex = index;
    });
  },
  // 禁止手左右滑动
  // physics: NeverScrollableScrollPhysics(),
),

当需要页面缓存的时候,指明需要缓存的页面即可(这里是首页),使用方式如下

class _HomePageState extends State<HomePage> with AutomaticKeepAliveClientMixin {// 缓存页面

  // 缓存当前页面,保持之前状态
  @override
  // TODO: implement wantKeepAlive
  bool get wantKeepAlive => true;

}

2、广告轮播图

很多商品APP的首页都有类似的广告轮播图,自动切换到下一页广告或者活动,到底了又回到第一页,无限循环。

广告轮播图
a、轮播图 Model

网络请求广告轮播图的图片数据

List _focusData = [];
_getFocusData() async {
  var apiURL = "${Config.domain}api/focus";
  var result = await Dio().get(apiURL);

  print(result.data is Map);// String 还需要转化为 Map
  var focusList = FocusModel.fromJson(result.data);

  // 打印图片数据出来看看是否请求成功
  focusList.result.forEach((item) {
    print(item.pic);
  });

  setState(() {
    this._focusData = focusList.result;
  });
}
b、轮播图 View

添加轮播图依赖库

flutter_swiper: ^1.1.6
import 'package:flutter_swiper/flutter_swiper.dart';

在官网查询到其使用语法如下

body: new Swiper(
  itemBuilder: (BuildContext context, int index) {
    return new Image.asset(
      images[index],
      fit: BoxFit.fill,
    );
  },

  indicatorLayout: PageIndicatorLayout.COLOR,
  autoplay: true,
  itemCount: images.length,
  pagination: new SwiperPagination(),
  control: new SwiperControl(),
));

将轮播图的View做成Widget。因为抽离成方法便于维护,而多个页面都用到这个的话,还可以抽离成文件。

  • AspectRatio:添加容器用于设置宽高比例
  • Swiper:轮播图依赖库,其使用语法可以在官网查询
  • pic:广告图片的url地址,需要注意的是将域名中的\\替换为斜杠/
  • Image.network:根据广告图片的url地址请求该图片
  • itemCount:图片数量
  • pagination:分页器
  • autoplay:自动轮播
Widget _swiperWidget() {
  if (this._focusData.length > 0) {
    return Container(
      // 添加容器用于设置宽高
      child: AspectRatio(
        // 不同设备宽高不同,只能设置为宽高比例
        aspectRatio: 2 / 1, // 宽2 高1
        child: Swiper(
          itemBuilder: (BuildContext context, int index) {
            String pic = this._focusData[index].pic;
            // ➕域名 替换斜杠
            pic = Config.domain + pic.replaceAll('\\', '/');
            return new Image.network(
              "${pic}",
              fit: BoxFit.fill,
            );
          },
          itemCount: this._focusData.length,
          pagination: new SwiperPagination(), // 分页器
          autoplay: true, // 自动轮播
        ),
      ),
    );
  } else {
    return Text("加载中...");
  }
}

3、首页商品内容 View

a、标题栏
标题栏
  • Widget:因为存在多个标题栏,所以做成了Widget方便重复使用
  • value:外界传入的参数,作为Text显示的值,即标题文本
  • decoration:左侧红色边框,距离屏幕边缘偏右一点,和标题文本之间存在间距
Widget _titleWidget(value) {
  return Container(
    height: ScreenAdaper.height(32),
    child: Text(value, style: TextStyle(color: Colors.black54)), // 标题文本
    // 左侧红色边框
    decoration: BoxDecoration(
        border: Border(
            left: BorderSide(
      color: Colors.red,
      width: ScreenAdaper.width(10),
    ))),
    // 左侧边框右移一点
    margin: EdgeInsets.only(left: ScreenAdaper.width(20)),
    // 内部组件之间有间距, 好像只对child起作用
    padding: EdgeInsets.only(left: ScreenAdaper.width(20)),
  );
}
b、水平列表(猜你喜欢) Model

请求方式和之前请求轮播图一样,就不重复赘述了。

List _hotProductListData = [];
_getHotProductData() async {
  var apiURL = "${Config.domain}api/plist?is_hot=1";
  var result = await Dio().get(apiURL);
  var hotProductList = ProductListModel.fromJson(result.data);
  setState(() {
    this._hotProductListData = hotProductList.result;
  });
}
c、水平列表(猜你喜欢) View
猜你喜欢
  • ListView:需包装在Container中,指定宽高实现水平滑动
  • padding:左右第一个元素不能挨边,需要和屏幕左右边缘各自保持一定间隔
Widget _horizontalProductListWidget() {
  if (this._hotProductListData.length > 0) {
    // ListView不能嵌套,需包装在Container中,指定宽高实现水平滑动
    return Container(
      height: ScreenAdaper.height(234),
      // 左右第一个元素不能挨边
      padding: EdgeInsets.all(ScreenAdaper.width(20)),
      child: ListView.builder(
        ........
        },
    );
  } else {
    return Text("");
  }
}
  • itemCount:列表里面的数据条数
  • itemBuilder:每个列表item的样式,设置了一个item样式之后,其他item会仿照该样式进行显示。这里的列表样式是上面是商品图片,下面是商品价格
  • sPic:商品图片的url地址
  • Column:上面是商品图片,下面是商品价格,所以需要使用列(Column)进行上下布局
child: ListView.builder(

  itemCount: this._hotProductListData.length,
  itemBuilder: (context, index) {

    // 获得小图
    String sPic = this._hotProductListData[index].sPic;
    sPic = Config.domain + sPic.replaceAll('\\', '/');

    return Column(
        .......
    );
  },
  // 水平滑动
  scrollDirection: Axis.horizontal,
),
  • Container:使用容器的目的是可以设置图片的宽高,也可以调整外边距、内边距、间距
  • Image.network:根据url请求商品图片并展示
  • Text:根据this._hotProductListData[index].price获取图片对应的商品价格
return Column(
  // 列:上图下文
  children: <Widget>[
    Container(
      // 设置图片的宽高
      height: ScreenAdaper.height(140),
      width: ScreenAdaper.width(140),
      // 设置间距
      margin: EdgeInsets.only(right: ScreenAdaper.width(20)),
      // index 从0开始,服务器图片从1开始
      child: Image.network(
        "${sPic}",
        fit: BoxFit.cover,
      ),
    ),

    Container(// 可设置文本宽高,便于计算整体宽高
      padding: EdgeInsets.only(top: ScreenAdaper.height(10)),
      height: ScreenAdaper.height(44),
      child: Text("${this._hotProductListData[index].price}",style: TextStyle(color: Colors.red)),
    )
  ],
);
d、纵向列表(热门推荐) Model

一样的套路。

List _bestProductListData = [];
_getBestProductData() async {
  var apiURL = "${Config.domain}api/plist?is_best=1";
  var result = await Dio().get(apiURL);
  var bestProductList = ProductListModel.fromJson(result.data);
  setState(() {
    this._bestProductListData = bestProductList.result;
  });
}
e、纵向列表(热门推荐) View
热门推荐
  • Wrap:使用GridView只能设置宽高比,无法设置Item高度,所以需要替换为Wrap,其相当于CollectionView
  • this._bestProductListData.map((value):其中的value为每一个Item,对其进行样式设置后再通过.toList()将其转化为Wrapitem列表。
Widget _recProductListWidget() {
  
  // 20是屏幕左右边缘间距,10是两个商品中间间距,除以2是因为有两个商品
  var itemWidth = (ScreenAdaper.getScreenWidth() - 20 - 10) / 2;

  return Container(
    // 左右间距
    padding: EdgeInsets.all(10),
    child: Wrap(// GridView 只能设置宽高比,无法设置Item高度
      spacing: 10,// 水平间距(中间的10)
      runSpacing: 10,// 纵间距(上下的10)
      children: this._bestProductListData.map((value){// value为每一个Item
        .......
        );
      }).toList()
    ),
  );
}
  • InkWell:相当于Button按钮,提供点击回调事件。
  • onTap:点击首页商品后通过路由跳转到商品详情页面,并为商品详情页面传入参数商品id,通过商品id即可以知道显示哪个商品的详情页面。
children: this._bestProductListData.map((value){// value为每一个Item

   String sPic = value.sPic;
   sPic = Config.domain + sPic.replaceAll('\\', '/');

   return InkWell(
        .......
       ),
     ),
     // 跳转到详情页面
     onTap: () {
       Navigator.pushNamed(context, '/productContent', arguments: {
         "id" : value.sId
       });
     },
   );
}).toList()
  • itemWidth:屏幕减去左右中间后的一半,高度会自适应,所以不用设置
  • padding:每个item的左右上下间距均为10
  • decoration:每个商品item均添加了宽为1点的黑色边框
  • Column:商品item布局为上图中文下价格
  • double.infinity:将商品图片包裹在Container中,设置其宽度铺满Container
  • AspectRatio:为防止服务器图片宽高不一致,将所有商品图片的宽高比例设置为1:1,这样由于宽度固定,则高度也固定
  • padding:中间的描述文本和上面的图片间隔为10
  • TextOverflow.ellipsis:商品描述文本最多显示2行,多余的则显示......
  • Stack:布局为左边显示打折价格,右边显示原价,结合Align进行布局设置
  • TextDecoration.lineThrough:优惠价格的中间划线
child: Container(// Item 宽高
  // 屏幕减去左右中间后的一半, 高度会自适应
  width: itemWidth,
  // 左右上下间距
  padding: EdgeInsets.all(10),
  // 边框
  decoration: BoxDecoration(
      border: Border.all(
          color: Colors.black12,
          width: 1
      )
  ),
  child: Column(// 上图中文下价格
    children: <Widget>[

      Container(// 防止Image以外层Container实现cover平铺
        // 铺满外层Container(除pading外部分)
          width: double.infinity,
          child: AspectRatio(// 防止服务器图片宽高不一致
              aspectRatio: 1/1,// 宽度固定的,1:1则高度也固定
              child: Image.network("${sPic}",fit: BoxFit.cover)
          )
      ),

      Padding(
        // 和图片相距10
        padding: EdgeInsets.only(top: ScreenAdaper.height(10)),
        child: Text("${value.title}",
          maxLines: 2,
          // ...溢出
          overflow: TextOverflow.ellipsis,
          style: TextStyle(
              color: Colors.black54
          ),
        ),
      ),

      Padding(
        // 和标题相距10
        padding: EdgeInsets.only(top: ScreenAdaper.height(10)),
        child: Stack(// 左边打折价格 右边原价
          children: <Widget>[
            Align(// 中间偏左
              alignment: Alignment.centerLeft,
              child: Text("¥${value.price}",style: TextStyle(color: Colors.red,fontSize: 16)),
            ),
            Align(// 中间偏右
              alignment: Alignment.centerRight,
              // 价格中间划线
              child: Text("¥${value.oldPrice}",style: TextStyle(color: Colors.black54,fontSize: 14,decoration: TextDecoration.lineThrough)),
            )
          ],

        ),
      )
    ],
  ),
),

4、顶部导航栏

a、扫描按钮
  • leading:导航栏左边按钮
leading: IconButton( 
  icon: Icon(Icons.center_focus_weak,size: 28,color: Colors.black),
  onPressed: (){

  },
),
b、消息按钮
  • actions:导航栏右边按钮,可以有多个
actions: <Widget>[//消息按钮
  IconButton(
    icon: Icon(Icons.message,size: 28,color: Colors.black),
    onPressed: (){

    },
  )
],
c、搜索框按钮

支持点击,效果是跳转到搜索商品页面。页面布局没啥特别的,就是外面一个边框,里面左边是搜索🔍图标,右边是“搜索”文本。

title: InkWell(// 搜索框支持点击
  child:Container(
    height: ScreenAdaper.height(70),
    padding: EdgeInsets.only(left: 10),
    decoration: BoxDecoration(
        color: Color.fromRGBO(233, 233, 233, 0.8),
        borderRadius: BorderRadius.circular(30)
    ),
    child: Row(// 左🔍右提示
      crossAxisAlignment: CrossAxisAlignment.center,
      children: <Widget>[
        Icon(Icons.search),
        Text("搜索",style: TextStyle(fontSize: ScreenAdaper.fontSize(28)))
      ],
    ),
  ),
  onTap: (){
    // 跳转到搜索页面
    Navigator.pushNamed(context, '/search');
  },
)

5、初始化商品首页页面

a、初始化时先请求数据源
@override
void initState() {
  // TODO: implement initState
  super.initState();

  // 获取轮播图数据
  _getFocusData();
  // 获取猜你喜欢数据
  _getHotProductData();
  // 获取热门推荐数据
  _getBestProductData();
}
b、创建商品首页页面
  • appBar:为界面设置导航
  • ListView:让整个商品首页界面支持上下滑动
  • SizedBox:空白间隔,让两个View之间存在距离,不至于紧贴在一起
@override
Widget build(BuildContext context) {
  // 解决Flutter 不同终端屏幕适配问题,传入context和设计稿子上的宽高
  ScreenAdaper.init(context);

  return Scaffold(
    // 为界面设置导航
    appBar: AppBar(
    .......
    ),

    body: ListView(
      // 支持上下滑动
      children: <Widget>[
        _swiperWidget(),
        SizedBox(height: ScreenAdaper.height(20)),
        _titleWidget("猜你喜欢"),
        SizedBox(height: ScreenAdaper.height(20)),
        _horizontalProductListWidget(),
        _titleWidget("热门推荐"),
        _recProductListWidget(),
      ],
    ),
  );
}

续文见下篇:Flutter:仿京东商城APP的完整开发指南(二)


Demo

Demo在我的Github上,欢迎下载。
JDShop_Flutter

推荐的Flutter项目,可以模仿学习
豆瓣 APP

学习资料

相关文章

网友评论

      本文标题:Flutter:仿京东商城APP的完整开发指南(一)

      本文链接:https://www.haomeiwen.com/subject/jlqsgktx.html