福州大学 202601 软件工程第二次结对作业
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/fzu/202601SofwareEngineering/homework/16743 |
| 这个作业的目标 | 结对实现“校园失物招领小程序” |
| 项目 GitHub 仓库 | https://github.com/rt265/102401215-102401119 |
| 结对队伍 | 102401215 李义东;102401119 林祺皓 |
| 结对队伍成员博客 | watermelonabc;LLLqh |
需求背景与功能概览
校园里丢失物品后,信息通常散落在班群、年级群和社交平台中,搜索困难、有效期短,也不方便更新“已经找回”的状态。因此我们实现了一个本地闭环的校园失物招领应用,让用户可以完成:
发布失物/招领信息 → 浏览或搜索 → 查看详情 → 联系发布者 → 更新为已找到/已归还
主要功能如下:
- 首页浏览:浏览失物和招领卡片,按类型、分类筛选,并按时间排序。
- 关键词搜索:按物品名称、地点、描述和分类搜索;搜索条件按空白拆分,多个词需要全部命中。
- 详情查看:查看地点、发生时间、描述、联系方式和图片,图片支持轮播。
- 发布信息:校验必填项,支持从相册选图或拍照,最多保存 9 张图片。
- 我的:管理当前本机账户发布的信息,编辑、删除、标记“已找到”或“已归还”。
- 设置:切换跟随系统、浅色或深色模式,自定义 Material 3 主题色,管理账户并查看关于页面。
- 本地持久化:信息、账户、设置、搜索记录保存在本机 SQLite;图片保存在应用私有目录。
本项目暂不实现远程服务器、实名认证、即时聊天和地图定位。它首先解决的是单机环境下的完整交互闭环。
结对分工
| 成员 | 主要工作 |
|---|---|
| 李义东 | Flutter 项目基础搭建;首页、发布页、详情页;ItemPost、PostQuery 和 PostStore;表单校验;部分 widget 测试 |
| 林祺皓 | “我的”和设置页面;SQLite 数据层与数据库迁移;图片文件存储与选图抽象;搜索历史、多用户隔离;对应测试和构建配置 |
| 共同完成 | 需求讨论、UI 走查、问题定位、代码审查、测试修复、README 与开发文档整理 |
涉及数据模型、页面接口和测试脚手架的改动会先沟通,再由一人实现、另一人验证。
PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 60 | 75 |
| Estimate | 估计任务规模 | 45 | 60 |
| Development | 开发 | — | — |
| Analysis | 需求分析 | 300 | 360 |
| Design Spec | 生成设计文档 | 120 | 150 |
| Design Review | 设计复审 | 60 | 90 |
| Coding Standard | 代码规范 | 45 | 45 |
| Design | 具体设计 | 180 | 240 |
| Coding | 具体编码 | 1500 | 1800 |
| Code Review | 代码复审 | 180 | 210 |
| Test | 测试 | 450 | 540 |
| Reporting | 报告 | — | — |
| Test Report | 测试报告 | 120 | 150 |
| Size Measurement | 计算工作量 | 30 | 30 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 150 | 180 |
| 合计 | 具体子阶段合计 | 3240 | 3930 |
解题思路与设计实现
从用户流程拆分页面
我们没有先从数据库表开始,而是先把用户任务拆成页面:
首页
├── 搜索页
└── 详情页
发布页
└── 发布成功提示
我的
├── 我的发布 → 详情页
├── 编辑页
└── 设置
├── 外观
├── 账户
└── 关于
三大主界面由 MainShell 统一管理。窄屏使用 NavigationBar,宽屏使用 NavigationRail;三个页面放在 IndexedStack 中,切换标签时保留输入内容和滚动位置。
分层架构
项目采用“页面/组件—Store—Repository—SQLite/文件系统”的结构:
models/保存ItemPost、UserAccount、PostQuery等领域对象。pages/负责页面状态和导航。widgets/放置发布表单、卡片、筛选栏和图片显示等可复用控件。data/*Store保存页面需要的内存快照并通过ChangeNotifier通知 UI。data/*Repository隔离 SQLite 细节,使页面不直接拼 SQL。services/photo_picker.dart抽象平台选图能力,测试时可以替换成假的 picker。
信息读写流程
应用启动时先打开数据库,再把数据库仓储注入 Store 并加载数据。页面始终同步读取 Store,这样筛选和排序不必每次等待异步查询。
写操作采用“先更新内存、再持久化”的写穿策略:
PostStore.addPost/updatePost/removePost先更新内存并通知界面。- 页面立即显示新状态,用户不必等待磁盘 I/O。
- Store 调用 Repository 写入 SQLite。
- 写库失败不会被静默吞掉,而是从返回的
Future抛出,便于后续接入统一错误提示。
关键代码片段(PostStore):
Future<void> addPost(ItemPost post) async {
_posts.insert(0, post);
notifyListeners();
await _persist(
(ItemRepository repository) => repository.insertPost(post),
);
}
PostScope 使用 Flutter 自带的 InheritedNotifier 下发 Store,避免引入额外状态管理框架。详情页只保存 postId,每次从 Store 查找信息;因此同一条信息被编辑或标记后,已经打开的详情页也能看到最新状态。
SQLite 数据设计与迁移
核心数据表包括:
posts:类型、标题、分类、地点、发生时间、联系方式、描述、状态、作者 ID 和图片文件名数组。user_account:本机账户的稳定 ID、显示名称、联系方式和创建时间。app_settings:主题模式和主题种子色。search_history:关键词主键和最近搜索时间。
枚举值存储为 name,时间存储为毫秒时间戳,图片列存储 JSON 数组。数据库通过版本号逐级迁移:旧库从 v1 升级时依次补设置表、搜索历史表、作者列和多用户账户结构,而不是删除重建用户数据。
首次建库才写入示例数据。用户删除示例信息后再次启动,不会被重新播种。
搜索有两套实现:内存列表使用 PostQuery.apply(),SQLite 使用 search_text LIKE。两套实现保持相同规则:关键词全部命中、忽略大小写,并对 %、_ 和反斜杠进行 LIKE 转义。测试中把同一组查询同时交给 Dart 和 SQLite,比较返回的 ID 列表,避免两边结果不一致。
图片存储
数据库不保存绝对路径,只保存图片文件名,例如 时间戳-序号.jpg。应用启动时把文件名解析为当前设备上的绝对路径,从而避免重新安装后应用目录变化导致路径失效。
表单会话会追踪“已选择但尚未提交”的文件。点击清空、退出编辑或删除信息时,会删除无归属图片;应用启动时还会根据数据库引用关系清理孤儿文件。图片读取失败时统一回退到物品分类图标,避免单张坏图破坏整个列表。
附加特点设计与展示
响应式导航与 Material 3 主题
项目不仅面向手机竖屏,也考虑了平板、折叠屏和横屏:
- 可用宽度小于断点时使用底部导航栏。
- 可用宽度达到断点时使用左侧导航栏。
- 页面和标签定义只保留一份,避免两套导航出现顺序或选中态不一致。
- 使用 Material 3
ColorScheme,设置页支持自动、浅色、深色和自定义主题色。
这个设计的意义是:同一套功能不需要为平板重新做一套页面,宽屏只是重新安排导航空间,正文仍保持一致。
关键实现:
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool wide =
constraints.maxWidth >= AppLayout.navigationRailBreakpoint;
return Scaffold(
body: wide
? Row(children: <Widget>[
_buildNavigationRail(),
Expanded(child: content),
])
: content,
bottomNavigationBar: wide ? null : _buildNavigationBar(),
);
},
);
搜索历史
搜索历史只在用户明确提交搜索、点击历史词或点击示例词时记录,不在每次键盘输入时记录。相同关键词忽略大小写,最多保存 10 条,重搜会移动到最前;清空全部记录需要二次确认,单条删除不打断用户。
目录结构与使用说明
目录组织
lib/
├── main.dart # 初始化数据库、Store 和应用
├── models/ # 领域模型与查询对象
├── pages/ # 页面级 UI
├── widgets/ # 可复用控件
├── data/ # SQLite、Repository、Store、图片数据层
├── services/ # 平台能力抽象,例如选图
├── theme/ # Material 3 主题与布局断点
└── utils/ # 通用工具
test/ # widget、数据层和集成式测试
docs/developer/ # 开发者文档
docs/agents/ # 开发过程记录
测试人员运行方式
环境建议:Flutter 3.47.5 stable、Dart 3.13.4。先确认设备或模拟器可用:
flutter doctor -v
flutter pub get
flutter devices
运行开发版:
flutter run
执行静态分析和全部测试:
flutter analyze
flutter test
构建 Android release 包:
flutter build apk --release
首次启动会在应用私有目录创建 SQLite 数据库,并写入示例信息。测试过程中可以使用“我的”登记本机账户,发布一条信息,然后在首页搜索、进入详情、编辑、删除或标记状态,验证完整闭环。
单元测试
工具与学习方式
项目使用 Flutter 官方的 flutter_test:
test()测试纯 Dart 的模型、仓储和文件逻辑。testWidgets()构造 widget 树,模拟点击、输入、滚动和导航。WidgetTester的pumpWidget、tap、enterText、pumpAndSettle分别用于挂载、点击、输入和等待界面稳定。sqflite_common_ffi为测试提供可控的 SQLite 数据库工厂,测试结束后关闭数据库。
一个最小 widget 测试示例:
testWidgets('发布页校验必填字段', (WidgetTester tester) async {
await tester.pumpWidget(buildPublishHost());
await tester.tap(find.byKey(const Key('publish-submit-button')));
await tester.pump();
expect(find.text('请输入物品名称'), findsOneWidget);
expect(find.byKey(const Key('publish-success-dialog')), findsNothing);
});
学习单元测试时,我们先从一个页面的可见行为入手,再逐步把数据库、时间、文件和平台选图替换为可注入的实现。这样测试不是依赖私有方法,而是验证用户能观察到的结果。
测试范围
| 测试文件 | 测试内容 |
|---|---|
sqlite_storage_test.dart |
建库、迁移、信息 CRUD、查询对拍、账户隔离 |
photo_storage_test.dart |
文件保存、文件名解析、删除、迁移和孤儿清理 |
post_form_photo_test.dart |
相册/拍照选图、删除图片、9 张上限和取消操作 |
publish_page_test.dart |
表单校验、清空、发布成功提示 |
search_page_test.dart |
关键词搜索、筛选、空结果和返回 |
search_history_test.dart |
搜索历史的去重、排序、上限和持久化 |
post_detail_page_test.dart |
详情展示、图片轮播和状态操作 |
post_edit_page_test.dart |
编辑保存、返回拦截和图片会话 |
settings_page_test.dart |
设置目录、外观、账户和关于页面 |
appearance_page_test.dart |
主题模式和主题色切换 |
测试数据设计
测试数据同时覆盖正常、空值、边界和异常情况:
- 正常信息:包含失物、招领、不同分类、时间和联系方式。
- 空状态:无搜索结果、未登记账户、没有历史记录、没有图片。
- 边界值:关键词前后空格、大小写重复、包含
%和_、9 张图片上限。 - 状态变化:待处理 → 已找到/已归还,编辑后详情同步更新,删除后详情页能正确返回。
- 数据隔离:账户 A 不能修改或删除账户 B 发布的信息,但公开浏览仍能看到全部信息。
- 异常文件:数据库仍有图片文件名但文件已经不存在时,界面显示分类图标,不让异常扩散到整个页面。
“刁难测试人员”可能会快速连续点击、输入空白、反复搜索同一个词、删除后返回旧详情页、在编辑页选图后直接退出。针对这些情况,测试重点放在状态生命周期和资源清理,而不只检查某个按钮是否存在。
GitHub 签入记录

问题、困难与解决方法
问题一:UI 状态和数据库状态可能不同步
- 问题:如果每次筛选都直接等待数据库查询,界面会出现延迟;如果只改内存,重启应用数据又会丢失。
- 尝试:最初考虑让页面全部使用
FutureBuilder;后来发现筛选和排序是高频即时操作,不适合每次等待异步任务。 - 解决:Store 保留内存快照,写操作先通知界面再写库;启动时统一
load()。Repository 负责持久化,页面不直接接触 SQL。 - 收获:异步持久化和同步 UI 并不冲突,关键是明确数据的权威来源和失败传播方式。
问题二:图片绝对路径在重装后失效
- 问题:直接把临时图片路径写入数据库,应用目录改变后图片无法读取。
- 尝试:尝试保存完整路径,后来发现路径与平台、安装实例相关,不能作为稳定数据。
- 解决:数据库只保存文件名,路径解析集中在
ImageFileStore;启动时迁移旧路径、清理孤儿文件,读图失败时统一降级。 - 收获:数据库中的业务标识应与运行环境路径解耦。
问题三:内存查询与 SQL 查询口径不一致
- 问题:Dart 列表查询和 SQL
LIKE处理空格、大小写和通配符的方式不同。 - 尝试:只测试页面显示结果,无法覆盖所有关键词边界。
- 解决:抽出统一的
PostQuery规则,并在 SQLite 测试中把同一批查询分别执行两次,比较 ID 顺序;额外覆盖%、_和反斜杠转义。 - 收获:当一个规则存在两份实现时,应使用对拍测试,而不是只测试其中一份。
结对协作困难
最大的问题是双方的沟通问题。我在仓库内留下的文档仍不能让一个人从零开始构建起整个项目,而我似乎也预估错误了其他人的工具使用和协作水平。这需要更加详细的文档,和更频繁的沟通交流。
队友评价
我的结对伙伴是林祺皓。参与问题比较积极,但是工具使用经验不足,导致不能很好地和我协作。我留下的文档也没有完全阅读,导致实际提交质量和上游仓库的要求不一致。

浙公网安备 33010602011771号