flutter小白是如何在一周内用chatGPT开发一款App的
创作初衷
这篇文章创作的初衷,只是为了写一个有关日历类的软件供自己使用,考虑到自己从来还没有使用flutter正式创作一个app,因此磨刀霍霍想试一试。
至于为什么要做一款日历软件,因为发现市面上的关于万年历的软件都有很多广告,想着自己也能做,就做个给自己用。同时里面包含了额外的模块,包括万年历、天气以及小常识等等。。。
创作过程
由于自己是flutter小白,对Dart语言也是一知半解,因此想在快速的时间内去完成一款app,就可能得翻破flutter官网相关的文档,效率不见得很高,因此主要结合chatGPT给我做知识扫盲以及方案选型建议。
比如我让chatGPT给我生成一段日历的核心逻辑:
然后不断加以修正,比如可以支持从星期日开始:
虽然不是很熟悉dart语法,但是并不是很影响我读懂代码。一般做过React或者声明式语言(android compose/swift)语言的人,上手flutter会相当快。
chatGPT在问答的过程中,也会说一些胡话,比如我在做天气模块的时候,需要实现一个向上滚动,标题部分自动缩小,并保证滚动条在标题下方滚动的功能,但是chatGPT并不能给我正确的回答,准确的说,它能给我回答,但是大多都是它胡诌的。
所以在使用这类AI工具的时候,需要自己识别它给出的到底是不是一个正确的答案,可以不断去试错,切不可一条路走到黑,无脑去相信。
关于如何精准使用chatGPT做问答、搜索、创作,以及源码解析,我司邮件每天都有讨论,欢迎加入探讨。
说(遇)说(到)重(的)点(坑)
几个重要的库或选择
至于为什么这么选择?我都是在chatGPT中问出来的,毕竟小白首先得知道方向在哪里,然后根据给出的提示去官方文档进行比较。
比如在选择使用哪个天气时,我首先从chatGPT给我的推荐中去官网查看,看是否能够满足我的需求
- 免费API (或者说调用次数在多少次内免费)
- 是否提供当天的详细天气情况
- 是否提供一天24小时的天气走势
- 是否提供7天之内的详细情况
经过比较之后,我发现上述的都不是特别合适,基本上提供7天以上的就不能免费订阅了,所以在此基础上,我就会再加上一些关键词,比如 "免费API", "7天天气详情"等等。
底部导航栏动画
原本采用的是flutter默认提供的导航栏,后来想想怎么也的折腾一番。但是这一折腾不打紧,导致我后面路由的设计全改变了。
页面有4个导航tab,所以我最开始采用了4个路由,分别对应4个tab
class Routes {
static String calendar = "/calendar";
static String weather = "/weather";
static String sense = "/sense";
static String settings = "/settings";
// ...
}
这样安于现状老老实实切换是木有问题的,但是我想在切换的时候加点动画,类似与这样的,就不work了:
原因是这个组件在路由切换的时候,都会重新渲染一份,所以动画肯定是没有的,无奈之下,就提取了一个公共页,采用分支逻辑hide/show,来做tab页面的切换
Scaffold(
appBar: getAppBar(selectedIndex, context),
body: getBody(selectedIndex, senseState),
bottomNavigationBar: renderBottomNavigationBar(
context,
selectedIndex,
(index) {
setState(() {
selectedIndex = index;
});
},
),
floatingActionButton: getFloatingActionButton(selectedIndex, homeState),
);
Widget getBody(int index, SenseState senseState) {
switch (index) {
case 0:
return const Calendar();
case 1:
return const Weather();
case 2:
return CommonSense(senseState: senseState);
case 3:
return const Settings();
default:
return const Calendar();
}
}
数据预加载
我做的这个demo里面,由于需要展示天气信息,所以在显示日历的时候,就可以进行天气信息的预加载了。
我的具体做法是在main.dart
中,在weatherState
初始化后就立即将天气信息获取然后塞入state中,这样在我切换到天气页面的时候,就可以获取到详细的数据了。【可能有更加好的办法💐】
// main.dart
final position = await _determinePosition();
final weatherState = WeatherState(position);
weatherState.getWeatherInfo();
// weather_state.dart
Future<void> getWeatherInfo() async {
final location = "${position.longitude},${position.latitude}";
final responses = await loadAllWeatherData(location);
if (responses.isNotEmpty && responses.length == 4) {
final weatherLocation = responses[0] as WeatherLocation;
final weatherNow = responses[1] as WeatherNow;
final weatherHourly = responses[2] as WeatherTwentyFourHours;
final weatherDaily = responses[3] as WeatherSevenDays;
setWeatherInfo(
weatherLocation.location[0],
weatherNow.now,
weatherHourly.hourly,
weatherDaily.daily,
);
}
}
日历月份切换
采用了flutter_swiper这个组件来做左右日历的滑动,但是要想很丝滑(当滑动下一个月的时候,能够立马看到数据),就需要把提前将下一个月的日历详情全部生成出来,最开始想直接生成几年的数据,想想还是太粗暴了,所以只是生成了前一个月以及后一个月的数据。
var list = [prevCalendarDates, calendarDates, nextCalendarDates];
Swiper(
index: 1,
loop: false,
duration: 1,
itemCount: list.length,
onIndexChanged: (int index) {},
itemBuilder: (BuildContext context, int index) {}
)
可以看到,我默认在swiper中显示的索引是1,这样显示的就是当前月份的日历信息。但是这样也有一个问题,由于这个swiper组件自带从左到右的动画,滑到上个月还好,但是滑到下一个月,就会有一个先向左再向右的动画突兀,所以我将duration
的值改为了1,就是避免使用swiper的动画。
关于本地存储
最开始其实没有打算用到服务器来进行api请求,毕竟最开始的打算只是做一个简简单单的万年历,所以所有的事件、提醒信息都打算存储在本地,采用sqlite
关系型数据库来解决。
后来需求膨胀(加了常识模块),发现这玩意就不好使了,因为常识模块需要添加的字段比较多,并不像日历部分只需要加几个简单的字段,而且也不会特别多,所以不得已又迫使搞出个后台来。
其间纠结了很久,要不要就统一使用本地数据库呢?常识这块搞一个本地后台管理就好了,连接到august.db
文件,然后进行增删查改也不是不能接受,后来发现有点虚,毕竟我是想在自己的手机上run的,难道每次同步还得把自己电脑后台服务打开,想想都有点麻烦。
所以后来还是把常识这块部署到了生产环境,日历事件部分采用的本地数据库,这样会快一点进行每天日历事件的初始化。所以整个一块的改动也是反反复复的。
日历事件采用本地sqlite
class DatabaseProvider {
// ...
Future<Database> _initDatabase() async {
final databasesPath = await getDatabasesPath();
final path = join(databasesPath, 'august.db');
Logger.d("database path: $path");
return await openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS ${CalendarDB.calendarEvent} (
id TEXT,
dateId TEXT,
title TEXT,
content TEXT,
date INTEGER,
lunarDate TEXT,
isCycle INTEGER,
cycleBy INTEGER,
createTime INTEGER,
modifyTime INTEGER,
deleted INTEGER
)
''');
},
);
}
}
常识部分调远端api
final baseUrl = "${dotenv.env['SENSE_BACKEND_URL']}/api/senses";
Future<List<CommonSense>> getCommonSenseByPage(
{int page = 1, int pageSize = 20}) async {
final response = await Http.get(
"$baseUrl/",
params: {'page': page, 'pageSize': pageSize},
);
return SenseResponse.fromJson(response.data).data;
}
然后至于本地的事件提醒
数据,打算定期备份,即把本地的数据库文件上传至服务器。【TODO】
天气滑动动画
为了实现上面的动画,chatGPT多少是在这块犯浑了,尽管给我指引了采用sliverAppBar
来实现此功能;
但是当向上滑动时,滚动条默认会从屏幕的最顶端开始滑动,这就导致了滑动的内容会透过缩小后的文字 [贴图中 -> 旧金山 多云 13°C]
显示在下面,再次询问如何解决时,给我的总是错误的答案,看来还是不能轻信啊😁
后来google了解决办法,采用了CustomClipper
,这里贴一下:
import 'package:flutter/material.dart';
import 'dart:math' as math;
class CustomClipperContainer extends StatelessWidget {
final Widget child;
const CustomClipperContainer({super.key, required this.child});
@override
Widget build(BuildContext context) {
return ClipRect(
clipper: MyCustomClipper(
clipHeight: MediaQuery.of(context).size.height - 220,
),
child: child,
);
}
}
class MyCustomClipper extends CustomClipper<Rect> {
final double clipHeight;
MyCustomClipper({required this.clipHeight});
@override
getClip(Size size) {
double top = math.max(size.height - clipHeight, 0);
Rect rect = Rect.fromLTRB(0.0, top, size.width, size.height);
return rect;
}
@override
bool shouldReclip(CustomClipper oldClipper) {
return false;
}
}
// 使用
CustomClipperContainer(
child: ListView(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: const [
HourlyForecast(),
SevenDayForecast(),
CurrentDetail(),
],
),
)
天气背景映射
由于天气背景我采用了flutter_weather_bg这个库,里面包括了一系列的天气背景动画,比如下雨、雷电、下雪等等动画场景,但是由于我使用了和风天气,返回的api里面并不能很好的和这个库搭配起来,所以这里不得不做映射处理。
WeatherType getWeatherTypeBy(String weatherText, String icon) {
if (weatherText == '晴') {
if (icon == '100') {
return WeatherType.sunny;
} else {
return WeatherType.sunnyNight;
}
} else if (weatherText.contains('云')) {
if (icon == '101' || icon == '102' || icon == '103') {
return WeatherType.cloudy;
} else {
return WeatherType.cloudyNight;
}
} else if (weatherText == '阴') {
// ...
}
// ...
}
按照道理讲,关于天气这一块所有的api请求,最好还是要走一层后端,如果再做厚一点,应该有个BFF层来专门处理数据的组装、转发等场景。比如类似这样的mapping,以及获取天气数据的信息等请求就可以由BFF给我返回了,这样做的好处是,将更多的细节封装到了内部,前端只需要更加纯粹地显示数据就好了,如果后续有改动,比如我的天气从和风API转成了XXX API,前端部分可以完全不用再改动了。
但是由于我是后来才想起我要做个常识模块,那个时候才引入了一个后台,所以前面的就懒得整了。【TODO】
滑动后退失效了
当我快要完成我的demo时,我突然想起来,试试滑动后退,发现怎么也不起作用。后来想想问题应该是出在了路由上,于是去网上扒了扒
找到个issue
将默认的TransitionType
设为TransitionType.cupertino
就解决了。
主题部分
准备了两套颜色,明亮色以及暗黑色【颜色部分可能还是得有设计师来,这块真是搞得我头痛】,然后使用ThemeData
进行封装,然后在MaterialApp
上进行设置。
MaterialApp(
debugShowCheckedModeBanner: false,
theme: globalState.isDarkMode ? darkTheme : lightTheme,
onGenerateRoute: Application.router.generator,
);
将用户的偏好存储在sharedPreferences
中,这样当用户下次再次进入app时,就能记住上次是选择了哪个主题。
// user_preference.dart
class UserPreference {
static Future<bool> getThemeMode() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
var isDarkMode = prefs.getBool(isDarkModeText);
return isDarkMode ?? false;
}
static Future<void> updateThemeMode(bool isDarkMode) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setBool(isDarkModeText, isDarkMode);
}
}
// global_state.dart
class GlobalState extends ChangeNotifier {
bool isDarkMode = true;
GlobalState(this.isDarkMode);
void toggleTheme() async {
isDarkMode = !isDarkMode;
UserPreference.updateThemeMode(isDarkMode);
notifyListeners();
}
}
还有一些可以讲讲
使用dotenv获取环境变量
final apiKey = dotenv.env['WEATHER_API_KEY'];
好处是配置与使用隔离,这样也安全一点。
使用Json To Dart插件生成model
网上也有使用json_serializable
来实现序列化与反与反序列化的,但我个人觉得小项目还是这个插件好用,因为这个库会将文件分割成两个部分。
flutter_native_splash生成splash页面
使用这个库flutter_native_splash,详细用法参看官方文档。
# 更新splash页面,更新玩颜色以及背景图片后,运行以下命令
flutter clean && flutter pub get && flutter pub run flutter_native_splash:create
后端部分
分为august-server
以及august-admin
,server主要提供api服务,admin提供后台数据管理,admin的模版是从网上嫖的。感兴趣可以自己去看看 vue-manage-system
数据库采用了postgres
,使用docker-compose做了服务编排,这里贴一下,感兴趣自己看看
version: '3.8'
services:
postgresdb:
image: postgres:14.8
restart: unless-stopped
env_file: ./.env
environment:
- POSTGRES_DB=$POSTGRES_DATABASE
- POSTGRES_USER=$POSTGRES_USER
- POSTGRES_PASSWORD=$POSTGRES_PASSWORD
healthcheck:
test: pg_isready -U postgres
ports:
- $POSTGRES_LOCAL_PORT:$POSTGRES_DOCKER_PORT
volumes:
- ./data:/var/lib/postgresql/data
app:
depends_on:
postgresdb:
condition: service_healthy
build: ./august-server
restart: unless-stopped
env_file: ./.env
ports:
- $NODE_LOCAL_PORT:$NODE_DOCKER_PORT
environment:
- DB_HOST=postgresdb
- DB_USER=$POSTGRES_USER
- DB_PASSWORD=$POSTGRES_PASSWORD
- DB_NAME=$POSTGRES_DATABASE
- DB_PORT=$POSTGRES_DOCKER_PORT
stdin_open: true
tty: true
admin:
depends_on:
- app
build: ./august-admin
restart: unless-stopped
env_file: ./.env
ports:
- $ADMIN_LOCAL_PORT:$ADMIN_LOCAL_PORT
environment:
- PROXY_PROT=$NODE_DOCKER_PORT
需要提一点的是,app服务需要完全等数据库服务启动之后,才能请求数据,否则直接报错。所以这块,我加了healthcheck
(最开始我一直以为是mysql的问题,后来发现切换成postgres后依然有问题😁😄)。
总结
好了至此为止,想说的就已经说完了,整个功能来说相对简单,当然也躺了不少的坑,仅此供学习交流。
另外,针对一门新的技术,chatGPT能给你很好的入门指导,虽然胡说的不一定准,但是不说肯定是啥都不知道😂😂
最后贴贴代码仓库:
- august:八月 flutter app
- august-backend:后台server以及管理
仅供学习交流,勿商用!!!