软件工程第二次结对作业
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 首页 - H202601软件工程与软件工程实践 - 福州大学 - 班级博客 - 博客园 |
| 这个作业要求在哪里 | 2026秋软件工程结对作业(第二次之程序实现)- H202601软件工程与软件工程实践 - 班级博客 - 博客园 |
| 这个作业的目标 | 实现"校园失物招领"核心功能 |
| 姓名 | 严科儒 |
| 学号 | 102401633 |
| 合作伙伴 | 翁斌(102401632) |
| GitHub 仓库 | https://github.com/Clu3y/102401632-102401633 |
| APK 下载(v1.0.0) | https://github.com/Clu3y/102401632-102401633/releases/download/v1.0.0/app.apk |
一、具体分工
| 成员 | 主要分工 |
|---|---|
| 严科儒 | 原型走查与需求梳理;本地数据层(seed.js / db.js);列表、搜索、详情页;单元测试用例设计与测试框架搭建;Android WebView 壳工程与 APK 打包、真机安装验证;项目结构整理 |
| 翁斌 | 发布、发布成功、我的发布、个人资料页;状态流转与权限校验;图片本地压缩;样式与交互联调;回归测试与 README;Web 端响应式改造(桌面/平板/手机布局)、登录注册与会话门禁;博客撰写 |
两人采用结对编程 + 模块认领结合:数据结构、接口契约、状态机、双端目录约定等核心设计一起讨论确定,各自负责的页面与端再分头实现,最后交叉走查、合并、补测试。
二、项目展示
项目同时交付两种呈现形式,二者共用同一套本地数据层与业务规则:
- Web 端(
web/):桌面 / 平板 / 手机自适应,Chrome 直接运行; - Android App 端(
mobile/,安装包mobile/apk/app.apk):由最初的手机形态页面经 WebView 壳打包,真机离线可运行。
2.1 Web 端
2.2 Android App 端(真机截图)
三、PSP 表格
| PSP | 阶段 | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Planning | 计划 | 20 | 25 |
| Estimate | 估计任务所需时间 | 20 | 20 |
| Analysis | 需求分析(含学习新技术) | 90 | 110 |
| Design Spec | 生成设计文档 | 60 | 70 |
| Design Review | 设计复审 | 30 | 35 |
| Coding Standard | 制定代码规范 | 25 | 20 |
| Design | 具体设计(数据结构/接口契约/状态机) | 90 | 120 |
| Coding | 具体编码 | 300 | 360 |
| Code Review | 代码复审 | 60 | 80 |
| Test | 测试(自测、改代码、提交) | 120 | 180 |
| Test Report | 测试报告 | 40 | 50 |
| Size Measurement | 计算工作量 | 20 | 20 |
| Postmortem & Process Improvement Plan | 事后总结与改进计划 | 30 | 40 |
| 合计 | 905 | 1130 |
偏差分析:实际比预估多约 225 分钟,主要超在三处——① 具体设计阶段,为了让"前端也能完整跑通后端逻辑",重新设计了本地数据层与接口契约;② 编码阶段处理图片本地压缩、状态机边界等比预想细;③ 测试阶段搭建可在 Node 中加载浏览器脚本的测试环境(vm 沙箱)以及排查跨 realm 断言问题明显超时。
四、解题思路与设计实现
4.1 代码实现思路
题目要求实现"发布信息 → 浏览或搜索 → 查看详情 → 联系发布者 → 更新状态"的闭环。我们沿用第一次作业的原型,做成一个移动端风格的 Web 单页应用(hash 路由),页面包括:首页、搜索、发布、详情、发布成功、我的发布。
- 页面、样式、交互、以及对后端接口的调用方式完全按真实前后端分离项目来写(统一的
LF.api、Promise、统一返回结构、camelCase、分页结构、状态码语义); - "后端"以一个浏览器内本地数据层的形式实现:两端目录下的
js/data/local-api.js承载全部业务规则,js/data/db.js用localStorage持久化,js/data/seed.js提供开箱即用的 14 条演示数据; - 页面只依赖
LF.api的方法签名与返回结构,不感知数据来自网络还是本地。将来接回真实后端时,只需把js/utils/api.js的转发目标从localApi换回 HTTP 请求,页面零改动。
双端演进:先用 Web 页面模拟手机 App(mobile/,桌面浏览器里是居中的手机外壳);随后用一个极薄的原生 Android WebView 壳工程把 mobile/ 的前端源码原样打包成可安装、离线可运行的 APK(产物 mobile/apk/app.apk);最后又在同一套数据层与业务规则之上,完善出桌面 / 平板 / 手机自适应的响应式 Web 端(web/),并补充了独立登录注册页、会话管理与更丰富的素材。两端业务核心同构,差异集中在视图层与外壳。
整体分层如下(两端一致,仅根目录前缀不同):
页面 pages/*.js(首页/搜索/发布/详情/我的发布)
│ 只调用 LF.api.xxx(),返回 Promise
▼
js/utils/api.js(接口集合,适配层)
│ 当前转发给本地实现;接后端时转发给 fetch
▼
js/data/local-api.js(业务规则:校验/状态机/分页/搜索/统计/联系方式)
▼
js/data/db.js(localStorage 数据库:主键、编号、持久化)
▼
js/data/seed.js(首次打开写入的 14 条演示数据)
4.2 流程图
(1)总体数据流(分层与依赖)

(2)核心业务流程:发布 → 浏览/搜索 → 详情 → 联系 → 更新状态

(3)信息状态机(状态迁移测试的依据)

4.3 重要代码片段
① 适配层:页面不感知数据来源(两端 js/utils/api.js)
(function (LF) {
var api = LF.localApi;
// 本地数据服务的方法签名与原后端接口一一对应,直接转发即可。
LF.api = {
login: api.login,
getItems: api.getItems,
searchItems: api.searchItems,
getItemDetail: api.getItemDetail,
getItemContact: api.getItemContact,
createItem: api.createItem,
updateItemStatus: api.updateItemStatus,
getMyItems: api.getMyItems,
getCurrentUser: api.getCurrentUser,
updateCurrentUser: api.updateCurrentUser,
uploadImage: api.uploadImage
};
})(window.LF);
意义:页面代码与真实前后端分离项目完全一致,本地实现、HTTP 后端、WebView 壳三种宿主可平滑替换,符合"面向接口编程"。
② 状态机 + 所有权校验(js/data/local-api.js,对应接口 PATCH /items/{id}/status)
var ALLOWED_STATUS = {
lost: ['searching', 'recovered'],
found: ['pending_claim', 'returned']
};
function updateItemStatus(id, status) {
return delay(function () {
var me = requireUser(); // 必须登录
var item = findItem(id);
if (!item) throw new Error('信息不存在或已被删除');
if (item.publisherId !== me.id) throw new Error('只能操作本人发布的信息'); // 越权拦截
if (ALLOWED_STATUS[item.type].indexOf(status) === -1) {
throw new Error('非法的状态变更,请刷新后重试'); // 跨类型状态拦截
}
item.status = status; // 幂等:重复设置同一状态也成功
LF.db.save();
return { id: item.id, status: item.status,
statusText: STATUS_TEXT[item.status], updatedAt: LF.db.formatTs(new Date()) };
});
}
③ 搜索:关键词同时匹配名称、地点、描述、编号(对应 GET /items/search)
var keyword = (params.keyword || '').trim().toLowerCase(); // 先 trim
var list = currentState().items.filter(function (it) {
if (params.type && params.type !== 'all' && it.type !== params.type) return false;
if (!keyword) return true; // 空关键词 = 全部
var hay = [it.name, it.location, it.description || '', it.itemNo].join(' ').toLowerCase();
return hay.indexOf(keyword) > -1;
});
④ 信息编号生成(js/data/db.js):寻物 L+yyyyMMdd+序号,招领 F 前缀,按天计数
function nextItemNo(st, type, date) {
var prefix = type === 'lost' ? 'L' : 'F';
var key = prefix + ymd(date);
st.itemNoSeq[key] = (st.itemNoSeq[key] || 0) + 1;
return key + pad2(st.itemNoSeq[key]); // 例如 L2026100201
}
⑤ 图片不上传服务器:本地读取 + Canvas 压缩为 dataURL(local-api.js)
function fileToDataUrl(file, maxEdge, quality) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () {
var img = new Image();
img.onload = function () {
/* 等比缩放到最长边 900,铺白底后转 JPEG,避免 localStorage 被大图撑爆 */
var w = img.width, h = img.height;
if (w > h && w > maxEdge) { h = Math.round(h * maxEdge / w); w = maxEdge; }
else if (h >= w && h > maxEdge) { w = Math.round(w * maxEdge / h); h = maxEdge; }
var canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
var ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
4.4 Android App:把零后端网页装进 APK
App 端没有再写一套原生业务,而是用一个极薄的原生 WebView 壳承载同一份前端代码:
- 壳工程只有一个全屏
MainActivity,其中的WebView直接加载打包在内的本地资源file:///android_asset/www/index.html,并开启domStorageEnabled(localStorage 依赖它); - 构建时把
mobile/下的index.html、assets/、css/、js/复制到壳工程的app/src/main/assets/www/(不复制apk/、server.js、单元测试/),再用 Gradle 构建出 APK; - 因为页面与数据都在本机(localStorage),App 安装后离线即可运行,不依赖任何服务器;
- 安装包已发布到 GitHub Release(tag
v1.0.0,约 97 KB):在安卓手机上打开 app.apk 下载链接即可下载安装,无需克隆仓库。
五、附加特点设计
| # | 特点 | 设计意义 | 实现方式 |
|---|---|---|---|
| 1 | 零后端、零安装、双击即跑 | 无需 JDK/MySQL/改配置,彻底消除环境差异导致的复现失败 | 浏览器内本地数据层 + localStorage,接口契约与真实后端一致,可平滑切回 |
| 2 | 一套核心、双端交付(Web + 离线 APK) | Chrome 打开是网页,装到手机是 App,且业务行为一致 | web/ 响应式网页;mobile/ 经原生 WebView 壳打包为 apk/app.apk,共用 LF.api 与本地数据层 |
| 3 | 图片本地压缩 | 不上传服务器也能发图,且避免大图撑爆浏览器 / WebView 存储 | FileReader + Canvas 等比缩放至 900px、转 JPEG dataURL |
| 4 | 联系方式一键复制 / 拨打、且按需可见 | 隐私保护:列表与详情都不暴露联系方式,主动点击"联系发布者"才拉取;微信一键复制、手机一键 tel: 拨号 |
getItemContact 独立接口 + Clipboard API(含降级方案) |
| 5 | 证件类隐私提示 + 敏感信息不直接展示 | 呼应原型隐私设计,降低学号/卡号泄露风险 | 详情页对 id_card 类别显示专门隐私提示,种子数据对卡号做了脱敏 |
| 6 | 演示数据一键重置 + 相对时间 | 任何时候打开都有完整可演示数据;"刚刚/x 分钟前"随时间自然 | seed 时间用"距当前分钟数"动态生成;提供 LF.localApi.resetDemo() |
| 7 | 完整的表单 / 状态校验与空结果引导 | 不只走"顺利路径":必填、未来时间、图片超限、无搜索结果、越权、非法状态都有提示 | 本地服务端二次校验 + 前端引导(推荐搜索、去发布) |
| 8 | Web 端登录注册与会话门禁(响应式) | 桌面端账号体验完整,受保护操作不丢失原目标页,会话过期有清理 | 独立 auth 页 + 路由门禁 + 原地登录弹窗 + 会话定时检查 + 草稿恢复 |
六、目录说明与使用说明
6.1 项目结构
lost-found/
├─ README.md # 双端总说明(目录与运行方式以它为准)
├─ LICENSE # MIT
├─ docs/
│ └─ screenshots/
│ ├─ web/ # Web 端展示截图
│ └─ mobile/ # App 真机截图
├─ web/ # ===== Web 端(响应式)=====
│ ├─ index.html # 单页应用入口
│ ├─ server.js # 零依赖静态服务器(可选,默认 8090)
│ ├─ README.md / LICENSE
│ ├─ assets/ # tabbar 图标、分类图、lucide 图标、实物配图与插画
│ ├─ css/ # global + 页面样式 + responsive/media/auth
│ ├─ js/
│ │ ├─ config.js / app.js # 配置 / hash 路由与启动
│ │ ├─ data/ # seed 演示数据 / db(localStorage) / accounts 账号 / local-api
│ │ ├─ utils/ # api 适配层、auth、ui、icons、media、draft、query、format
│ │ ├─ components/tabbar.js # 底部导航
│ │ └─ pages/ # auth 登录注册 / home / search / publish / detail / success / my-posts
│ ├─ docs/ # 开发与验证过程记录、回归截图(过程材料)
│ └─ 单元测试/ # 7 个测试文件
└─ mobile/ # ===== Android App 端(手机形态)=====
├─ index.html # 入口(桌面浏览器含手机状态栏外壳)
├─ server.js # 浏览器预览用静态服务器(建议 8091)
├─ README.md / LICENSE
├─ apk/
│ └─ app.apk # Android 安装包
├─ assets/ # tabbar 图标、6 类分类图
├─ css/ # global(含手机外壳)+ 页面样式
├─ js/
│ ├─ config.js / app.js # 配置 / 路由与启动(首启匿名静默登录)
│ ├─ data/ # seed / db(localStorage) / local-api
│ ├─ utils/ # api 适配层、auth、ui、format、url
│ ├─ components/tabbar.js
│ └─ pages/ # home / search / publish / detail / success / my-posts
└─ 单元测试/ # 3 个测试文件
6.2 如何运行
Web 端(统一用 Google Chrome):
- 直接用 Chrome 打开
web/index.html; - 或在
web目录执行node server.js,浏览器访问http://localhost:8090。
App 端(二选一):
- 真机安装(作业 APP 交付物):在安卓手机上点击下载安装包 app.apk(v1.0.0),允许"安装未知来源应用"后安装,桌面出现「校园失物招领」图标,离线即可运行(仓库内
mobile/apk/app.apk为同一份安装包); - 电脑浏览器预览手机界面:在
mobile目录执行node server.js 8091(用 8091 避免与 Web 端 8090 冲突),访问http://localhost:8091,或直接双击mobile/index.html。
七、单元测试
7.1 测试工具选型、学习过程与简易教程
单元测试框架的选型、测试环境搭建与核心业务用例设计由我负责。
选型:作业附录推荐了 Mocha。我先学习并对比了常见 JS 测试方案(Mocha、Jest、Node 内置 test runner),最终选择 Node.js 内置测试运行器 node:test + 内置断言 node:assert/strict,理由:
- 零第三方依赖、零安装:Node ≥ 18 自带,
npm test一定能跑起来,不会因为网络装不上 Mocha/Jest 而让测试"在我电脑上能跑"; - 天然自动化:一条命令递归运行、退出码反映成败,可放进每日构建;
- 语法与 Mocha 一致(
describe / it / beforeEach,BDD 风格),我从 Mocha 教程学到的用例组织、钩子、断言思想完全通用;将来迁移到 Mocha 只需改导入方式。
我的学习路径:先读《测试框架 Mocha 实例教程》理解断言、测试套件、钩子、异步测试、用例隔离;再查 Node 官方文档 node:test,确认其 describe/it/beforeEach 与 assert 用法;先用一个小例子跑通,再迁移到本项目,最后把它封装成两端共用的 tests/helpers/env.js 环境垫片。
简易教程:
-
环境:安装 Node 18+(命令行
node -v能看到版本即可),测试框架不用npm install。 -
写测试:新建
tests/xxx.test.js,文件名以.test.js结尾,Node 会识别为测试文件:const { describe, it, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); describe('被测模块', () => { it('应当满足某性质', () => { assert.equal(1 + 1, 2); // 同步断言 }); it('异步接口用 await', async () => { const data = await someAsync(); assert.ok(Array.isArray(data.records)); }); }); -
跑测试:
node --test tests/a.test.js tests/b.test.js,或在package.json配
"scripts": { "test": "node --test tests/*.test.js" }后执行npm test。 -
看结果:
# pass / # fail汇总,失败会给出期望值/实际值与代码位置;进程退出码非 0 表示有失败,可被 CI/每日构建识别。 -
用例隔离:每个用例在
beforeEach里构造全新的内存数据库,互不污染、可重复运行。
7.2 单元测试代码展示与被测函数说明
| 端 | 位置 | 测试文件数 | 用例数 | 结果 |
|---|---|---|---|---|
| App 端(手机形态) | mobile/单元测试/tests/ |
3 | 58 | # pass 58 / # fail 0 |
| Web 端(响应式) | web/单元测试/tests/ |
7 | 100 | # pass 100 / # fail 0 |
运行命令:
cd mobile/单元测试 && npm test # App 端:local-api / db / utils
cd web/单元测试 && npm test # Web 端:另含 accounts / auth-navigation / media / responsive-features
被测函数与对应文件:
| 测试文件 | 被测对象 | 覆盖的函数/接口 |
|---|---|---|
local-api.test.js(两端都有) |
本地业务服务 | login、getItems、searchItems、getItemDetail、getItemContact、createItem、updateItemStatus、getMyItems、getCurrentUser、updateCurrentUser、uploadImage |
db.test.js(两端都有) |
数据库工具 | detectContactType、formatTs、nextItemNo、save/load/resetDemo |
utils.test.js(两端都有) |
纯工具 | format.parseDate/formatDateTime*/formatRelative、urlUtil.resolveImageUrl(s) |
accounts.test.js(仅 Web) |
账号库 | 注册 / 登录 / 登出、重复账号与错误口令 |
auth-navigation.test.js(仅 Web) |
鉴权与导航门禁 | 未登录拦截、登录后回到目标页、会话过期 |
media.test.js(仅 Web) |
图片前置校验 | 图片类型 / 数量 / 大小限制 |
responsive-features.test.js(仅 Web) |
Web 增强逻辑 | 响应式相关纯逻辑与工具 |
示例 1:状态迁移 + 权限(白盒)
it('发布者可把寻物标记为已找回,文案正确并带更新时间', async () => {
const created = await LF.api.createItem(validPayload(LF));
const res = await LF.api.updateItemStatus(created.id, 'recovered');
assert.equal(res.status, 'recovered');
assert.equal(res.statusText, '已找回');
assert.ok(res.updatedAt);
});
it('权限:不能修改他人发布的信息', async () => {
await assert.rejects(LF.api.updateItemStatus(2, 'returned'), /本人|权限/);
});
it('非法状态迁移:寻物不能置为“已归还”', async () => {
const lost = await LF.api.createItem(validPayload(LF, { type: 'lost' }));
await assert.rejects(LF.api.updateItemStatus(lost.id, 'returned'), /状态/);
});
示例 2:分页边界值(pageSize 钳制 1~20、非法页码归一)
it('pageSize 超过 20 被钳制为 20,非法页码归一到第 1 页', async () => {
const big = await LF.api.getItems({ page: 1, pageSize: 999 });
assert.equal(big.pageSize, 20);
assert.equal(big.records.length, 14);
const zero = await LF.api.getItems({ page: 0, pageSize: 0 });
assert.equal(zero.page, 1);
assert.equal(zero.pageSize, 10);
});
示例 3:测试环境垫片(让浏览器脚本跑在 Node 里)
const sandbox = { console, Date, Math, JSON, Promise, localStorage: memoryStorage() };
sandbox.setTimeout = (fn) => { fn(); return 0; }; // 去掉模拟网络延时,保留 Promise 语义
sandbox.window = sandbox;
vm.createContext(sandbox);
FILES.forEach(f => vm.runInContext(fs.readFileSync(path.join(JS_ROOT, f),'utf8'), sandbox));
return { LF: sandbox.LF };
实际执行输出:
# App 端(mobile/单元测试)
# tests 58
# pass 58
# fail 0
# Web 端(web/单元测试)
# tests 100
# pass 100
# fail 0
7.3 测试数据构造思路
以白盒设计方法为主来构造用例:
- 等价类划分:类型(失物/招领)、登录态(本人/他人/匿名)、联系方式(微信/手机号)、搜索(命中名称/地点/描述/无命中)。
- 边界值分析:分页
pageSize取 0、1、10、20、999;图片恰好/超过 3 张与 5MB;发生时间卡在"现在 +5 分钟"附近;编号同一天跨第 1、2 条;文本长度上下限。 - 状态迁移测试:沿状态机四条合法迁移各测一次,再故意走两条非法跨类型迁移;并验证重复设置同一状态的幂等性。
- 权限/安全测试:匿名拉联系方式、非发布者改状态都必须被拒绝;详情接口断言不返回
contactValue。 - 错误推测:关键词首尾空格、空关键词、搜不到结果、不存在的 id、漏填每一个必填项、不勾协议、未来时间、非法类别枚举、非法图片类型、刷新后数据是否还在。
- Web 端新增用例:重复注册 / 错误口令、未登录访问受保护页被拦截且登录后回到原目标页、会话过期清理、图片类型与大小前置校验。
数据策略:在 seed.js 中固定了 14 条种子数据,刻意覆盖 6 个类别、2 种类型、4 种状态(含已结束)和有无图片两种情况,保证列表 / 筛选 / 统计 / 推荐都有确定性结果;动态新增的数据则用 validPayload() 工厂函数生成、按需覆盖字段。每个用例在 beforeEach 中拿到全新的内存数据库,互不干扰、可反复运行,不依赖真实浏览器数据。
八、GitHub 签入记录

九、遇到的异常 / 结对困难与解决
问题 1:演示数据既要"随时打开都完整",又要让测试结果确定。
- 问题描述:我负责的
seed.js如果写死绝对时间,隔几天打开列表全是"几个月前",演示效果差;如果全部按当前时间动态生成,"进行中 / 已结束"的数量又会随时间漂移,测试断言不稳定。 - 尝试:第一版把时间写死,后来演示时发现记录都变成了很久以前;改成全动态后,筛选和统计的期望值每天都不一样。
- 解决:时间统一用"距当前分钟数"相对生成,而类别、类型、状态、有无图片这些覆盖维度固定写死;测试侧每个用例在
beforeEach构造全新的内存数据库,断言只依赖固定字段。 - 是否解决:已解决,任何时候打开都有新鲜且完整的演示数据,列表 / 筛选 / 统计的测试结果也保持确定。
- 收获:演示数据本身也是产品的一部分;把"易变的时间"和"不变的覆盖维度"分开,才能同时兼顾演示效果与可测性。
问题 2:单元测试在 Node 里加载不了浏览器脚本。
- 尝试:直接
require前端文件会报window/localStorage is not defined;引入 jsdom 又要联网安装、违背"零依赖可跑"。 - 解决:用 Node 内置
vm造隔离全局,提供内存版localStorage,按index.html顺序加载脚本。 - 收获:分清"依赖 DOM 的视图层"和"不依赖 DOM 的业务层",把业务逻辑写成可在任意宿主运行的纯逻辑,是可测试性的关键。
问题 3:测试断言报"结构相同但引用不等"。
- 尝试:
deepStrictEqual比较 vm 沙箱里产生的数组/对象与测试里的字面量,值一样却失败。 - 定位:vm 是独立的 JS realm,沙箱内对象的原型(
Array.prototype/Object.prototype)与测试主上下文不同,严格深比较会校验原型。 - 解决:对跨 realm 的复合值,用
Array.from(...)转回主上下文数组、或逐字段assert.equal比较,而非整体deepStrictEqual。 - 收获:理解了 JS realm 与原型身份,断言失败时要读 expected/actual 与 operator,而不是怀疑业务逻辑。
问题 4:零后端网页怎么装进 APK,并保证离线可用、包与源码一致?
- 问题描述:作业允许提交 APK,而我们没有后端服务器;需要确认纯前端页面装进 APK 后能否离线运行,以及发出去的安装包和仓库源码是不是同一份。
- 尝试:让 WebView 直接加载远程 URL 仍需要一台服务器,违背"零后端";改为加载本地资源后,又逐一验证了
file:///android_asset下 hash 路由、localStorage、Canvas 压缩是否可用。 - 解决:写一个只有全屏 WebView 的原生壳(
MainActivity加载file:///android_asset/www/index.html,显式开启domStorageEnabled),把mobile/的index.html、assets/、css/、js/复制进assets/www/后用 AGP 8.7.2 构建;打包后把 APK 内assets/www/解出与mobile/源码逐文件哈希比对(全部一致),再装到真机走查发布→搜索→详情→联系→改状态的完整闭环,最后发布到 GitHub Release v1.0.0 并在说明中附上 SHA-256。 - 是否解决:已解决,真机离线运行正常,Release 安装包与仓库内 APK 的大小、SHA-256 完全一致。
- 收获:WebView 本质是浏览器内核,纯前端应用几乎可以零改造复用;发布二进制产物要做"产物-源码一致性"校验,附带校验和也能让下载方自行核对。
十、评价队友
-
值得学习的地方:
- 业务边界意识强:非法跨类型状态迁移、非发布者越权改状态、重复设置同一状态的幂等等容易漏掉的分支,他在发布和"我的发布"模块里逐条拦住并给出提示,和我设计的状态迁移 / 权限测试用例正好互相印证,联调时几乎没有在这些规则上返工。
- 联调耐心、回归扎实:样式与交互在手机和桌面两种形态下反复走查,空结果、校验失败等异常路径都补了引导;Web 端响应式布局和原地登录门禁把"不打断用户正在做的事"考虑得很完整。
-
需要改进的地方:
- 早期有个别页面先按自己假设的字段来写、再回头和数据层对齐,如果一开始就严格按一起冻结的契约写,可以少一些返工;
- 样式微调容易"越改越久",建议给这类打磨工作设一个时间盒,先保证主流程闭环再抠细节;
十一、小结
收获:一是真正体会到分层和"面向接口编程"的价值——业务规则集中之后,换存储、换宿主、换后端都变得容易;二是理解了可测试性要在设计阶段争取,把不依赖 DOM 的纯逻辑与视图层分开,测试自然好写;三是完整走了一遍"需求梳理 → 数据层 → 单元测试 → 打包发布 → 产物校验"的流程,也学会了 WebView 打包和用校验和证明"发出去的就是这份代码";四是在结对中体会到契约先行是两人并行开发不互相阻塞的前提。
不足与改进:在 CSS 和响应式上的投入还不够;Canvas 压缩等依赖 DOM 的成功路径仍靠人工走查,自动化覆盖不足。

浙公网安备 33010602011771号