99 KiB
自然写互动课堂手机端应用软件 V1.0
软件鉴别材料 — 用户操作手册与设计说明书
软件全称:自然写互动课堂手机端应用软件
软件版本:V1.0
权利人:深圳自然写科技有限公司
文档类型:移动应用用户操作手册 + 设计说明书
文档编号:WRITECH-APP-MOBILE-DS-001
编制日期:2026年2月
适用平台:Android 8.0+ / iOS 14.0+
目录
- 第一章 软件整体概述
- 1.1 软件简介与功能综述
- 1.2 软件用途与适用场景
- 1.3 运行环境与系统要求
- 1.4 开发语言与技术规范
- 1.5 版本说明
- 第二章 系统架构与设计思路
- 2.1 总体架构设计
- 2.2 MVVM架构说明
- 2.3 各层次详细说明
- 2.4 数据设计
- 2.5 接口设计
- 2.6 安全设计
- 2.7 权限说明
- 第三章 核心模块功能详细说明
- 3.1 登录与身份认证模块
- 3.2 教师端 — 课堂互动控制模块
- 3.3 教师端 — 作业布置与批改模块
- 3.4 教师端 — 实时收笔与展示模块
- 3.5 家长端 — 学情报告查看模块
- 3.6 家长端 — 书写回放模块
- 3.7 消息通知模块
- 3.8 蓝牙连接点阵笔模块
- 3.9 学习数据统计图表模块
- 3.10 拍照搜题模块
- 3.11 离线缓存与数据同步模块
- 3.12 个人中心与设置模块
- 第四章 操作流程与使用步骤
- 4.1 安装与首次启动
- 4.2 账号登录与角色选择
- 4.3 教师端完整使用流程
- 4.4 家长端完整使用流程
- 4.5 消息与通知使用流程
- 4.6 异常处理与故障排查
- 第五章 与源代码的对应关系
- 5.1 模块与源代码文件对应表
- 5.2 核心类与方法说明
- 5.3 状态管理架构说明
- 附录A 界面原型说明
- 附录B 第三方SDK集成说明
- 附录C 术语表
- 附录D 版本历史
第一章 软件整体概述
1.1 软件简介与功能综述
自然写互动课堂手机端应用软件(以下简称"手机APP")是自然写互动课堂系统面向教师和家长的手机端应用程序,同时支持Android和iOS双平台。手机APP采用Flutter跨平台框架开发,基于MVVM架构,提供课堂管理、作业布置、学情查看、家校沟通等核心功能,是教师日常移动教学和家长了解孩子学习情况的重要工具。
手机APP设计遵循"简洁高效、一端多角色"原则,同一安装包支持教师和家长两种角色,登录后自动呈现对应的功能界面。教师侧重课堂互动控制和批改管理,家长侧重孩子学情报告和作业完成情况跟踪。
主要功能模块综述:
| 角色 | 功能模块 | 说明 |
|---|---|---|
| 教师端 | 课堂互动控制 | 开课、发题、收卷、随机点名、暂停课堂 |
| 教师端 | 实时收笔展示 | 实时接收学生笔迹,选取作品展示 |
| 教师端 | 作业布置与批改 | 发布作业/试卷,查看AI批改结果,人工复核 |
| 教师端 | 班级学情数据 | 班级整体得分分布、知识点掌握情况 |
| 教师端 | 蓝牙移动教学 | 蓝牙连接点阵笔,移动板书模式 |
| 家长端 | 学情报告 | 查看孩子知识掌握度、书写能力成长轨迹 |
| 家长端 | 作业完成通知 | 接收孩子作业完成提醒,查看完成质量 |
| 家长端 | 书写回放 | 回放孩子书写过程,查看笔顺是否规范 |
| 家长端 | 学习打卡 | 记录孩子每日练字打卡情况 |
| 通用 | 消息通知 | 家校沟通、系统通知、互动消息 |
| 通用 | 拍照搜题 | 拍照识别题目,与孩子作答对比 |
| 通用 | 个人中心 | 账号管理、设备管理、通知设置 |
1.2 软件用途与适用场景
教师使用场景:
- 移动巡课:教师在教室内走动巡视时,通过手机实时查看每位学生的书写状态和完成进度,无需回到讲台PC操作
- 课后批改:课后在手机上快速浏览AI批改结果,对需要人工复核的题目进行点评标注
- 家校沟通:通过消息功能向特定学生家长发送学习提醒或布置个性化练习任务
- 移动板书:教师手持点阵笔直接在教室任意位置书写,笔迹实时投影至智慧黑板
家长使用场景:
- 学情追踪:每日/每周查看孩子作业完成情况和AI评分,了解薄弱知识点
- 书写监督:通过书写回放功能查看孩子练字的笔顺是否正确,提供有针对性的辅导
- 作业提醒:收到孩子未完成作业的系统提醒,督促孩子及时完成
- 成长记录:查看孩子书写能力的月度/学期成长对比图表
1.3 运行环境与系统要求
Android平台:
| 配置项 | 最低要求 | 推荐配置 |
|---|---|---|
| Android版本 | Android 8.0(API Level 26) | Android 12.0+ |
| 内存 | 2GB RAM | 4GB RAM |
| 存储 | 200MB可用空间 | 1GB可用空间(含缓存) |
| 网络 | WiFi 或 4G/5G | 5G / WiFi 6 |
| 蓝牙 | BLE 4.0(教师端) | BLE 5.0 |
| 摄像头 | 800万像素(拍照搜题) | 1200万像素以上 |
iOS平台:
| 配置项 | 最低要求 | 推荐配置 |
|---|---|---|
| iOS版本 | iOS 14.0 | iOS 16.0+ |
| 设备型号 | iPhone 8 及以上 | iPhone 13 系列及以上 |
| 存储 | 200MB可用空间 | 1GB可用空间 |
| 网络 | WiFi 或 4G/5G | 5G / WiFi 6 |
| 蓝牙 | Core Bluetooth 支持BLE | BLE 5.0 |
网络环境:
- 正常使用需要网络连接(云端API调用)
- 已缓存的作业列表和学情报告支持离线查看
- 弱网络(2G环境)下基础功能可用,书写回放等大数据功能受限
1.4 开发语言与技术规范
主要技术栈:
| 技术 | 版本 | 用途 |
|---|---|---|
| Flutter | 3.16.0 | 跨平台UI框架(Android + iOS) |
| Dart | 3.2.0 | 主要开发语言 |
| flutter_bloc | 8.1.3 | 状态管理(BLoC模式) |
| Dio | 5.3.2 | HTTP网络请求库 |
| web_socket_channel | 2.4.0 | WebSocket实时通信 |
| sqflite | 2.3.0 | 本地SQLite数据库 |
| flutter_blue_plus | 1.31.7 | BLE蓝牙通信(点阵笔连接) |
| flutter_local_notifications | 16.3.0 | 本地通知推送 |
| firebase_messaging | 14.7.10 | FCM推送(Android) |
| camera | 0.10.5 | 摄像头拍照搜题 |
| shared_preferences | 2.2.2 | 轻量键值对本地存储 |
代码架构:
- 采用Clean Architecture分层:UI层 → 状态管理层(BLoC) → 领域层(UseCase) → 数据层(Repository)
- 命名规范:Widget类名大驼峰,方法名小驼峰,文件名小写下划线
- 国际化:
flutter_localizations,支持中文简体/繁体
1.5 版本说明
| 版本 | 日期 | 平台 | 主要变更 |
|---|---|---|---|
| V0.6 Beta | 2025年8月 | Android/iOS | 基础登录、作业列表、学情报告 |
| V0.9 RC | 2025年11月 | Android/iOS | 书写回放、BLE连接、消息通知 |
| V1.0 | 2026年2月 | Android/iOS | 正式版:拍照搜题、打卡功能、无障碍优化 |
第二章 系统架构与设计思路
2.1 总体架构设计
手机APP采用Flutter跨平台框架,基于BLoC(Business Logic Component)状态管理模式,实现MVVM架构。整体架构分为五层:
┌──────────────────────────────────────────────────────────────────┐
│ UI层(View Layer) │
│ Flutter Widget Tree — 教师端页面 / 家长端页面 / 通用页面 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ 课堂页面 │ │ 作业页面 │ │ 学情页面 │ │ 消息/个人中心 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ 状态管理层(BLoC Layer) │
│ ClassroomBloc │ AssignmentBloc │ ReportBloc │ MessageBloc │
│ BleBloc │ AuthBloc │ UserBloc │ SettingsBloc │
├──────────────────────────────────────────────────────────────────┤
│ 领域层(Domain Layer) │
│ UseCase:课堂控制 / 作业管理 / 学情查询 / 设备连接 / 消息处理 │
├──────────────────────────────────────────────────────────────────┤
│ 数据层(Data Layer) │
│ Repository(聚合本地+远程数据源) │
│ ├── RemoteDataSource(Dio HTTP / WebSocket) │
│ └── LocalDataSource(SQLite sqflite / SharedPreferences) │
├──────────────────────────────────────────────────────────────────┤
│ 基础设施层(Infrastructure) │
│ BLE(flutter_blue_plus) │ 推送(Firebase/APNs) │ 摄像头(camera)│
└──────────────────────────────────────────────────────────────────┘
2.2 MVVM架构说明
手机APP使用BLoC模式实现MVVM架构:
- Model(模型):Dart数据类(如
AssignmentModel、StudentReportModel),由Repository层从API或SQLite获取数据 - ViewModel(视图模型):BLoC类(如
AssignmentBloc),接收UI发出的Event,处理业务逻辑,输出State - View(视图):Flutter Widget,通过
BlocBuilder监听State变化自动重建UI,通过context.read<XxxBloc>().add(Event)触发业务逻辑
BLoC数据流示意:
用户操作(点击"发布作业"按钮)
│ Widget.onTap()
▼
context.read<AssignmentBloc>().add(PublishAssignmentEvent(data))
│
▼
AssignmentBloc.mapEventToState()
│ await repository.publishAssignment(data)
▼
├── 成功 → yield AssignmentPublishedState()
└── 失败 → yield AssignmentErrorState(message)
│
▼
BlocBuilder<AssignmentBloc, AssignmentState>
├── AssignmentPublishedState → 显示成功Toast,跳转到作业列表
└── AssignmentErrorState → 显示错误对话框
2.3 各层次详细说明
UI层(Flutter Widget层)
UI层由Flutter Widget树构成,采用Material Design 3设计规范(Android)和Cupertino风格(iOS自适应)。主要页面通过MaterialApp或CupertinoApp路由管理,使用go_router库实现声明式路由。
教师端和家长端共用同一Flutter代码库,通过用户角色(UserRole.TEACHER / UserRole.PARENT)决定显示不同的BottomNavigationBar和页面内容。
状态管理层(BLoC层)
每个功能模块对应一个BLoC,BLoC之间相互独立,通过Repository层共享数据。全局状态(用户信息、登录状态)通过AuthBloc单例管理,使用flutter_bloc的BlocProvider注入Widget树。
数据层(Repository层)
Repository采用缓存优先策略(Cache-First):
- 优先从本地SQLite读取缓存数据,立即呈现给用户
- 同时发起网络请求获取最新数据
- 网络数据返回后更新SQLite缓存并刷新UI
这种策略确保了弱网络环境下APP的快速响应和良好体验。
2.4 数据设计
本地SQLite数据库表(sqflite):
assignments表(作业列表本地缓存):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | TEXT PRIMARY KEY | 作业唯一ID(服务端生成UUID) |
| title | TEXT | 作业标题 |
| subject | TEXT | 学科(语文/数学/英语) |
| type | TEXT | 作业类型(练字/试卷/作文) |
| class_id | TEXT | 班级ID |
| deadline | INTEGER | 截止时间(Unix毫秒) |
| status | TEXT | 状态(draft/published/closed) |
| student_count | INTEGER | 应交人数 |
| submitted_count | INTEGER | 已交人数 |
| synced_at | INTEGER | 最后同步时间 |
student_reports表(学情报告缓存):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | TEXT PRIMARY KEY | 报告ID |
| student_id | TEXT | 学生ID |
| report_type | TEXT | 报告类型(weekly/monthly/assignment) |
| data_json | TEXT | 报告数据JSON序列化 |
| generated_at | INTEGER | 报告生成时间 |
| synced_at | INTEGER | 缓存同步时间 |
messages表(消息本地存储):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | TEXT PRIMARY KEY | 消息ID |
| sender_id | TEXT | 发送者ID |
| receiver_id | TEXT | 接收者ID |
| type | TEXT | 消息类型(text/image/notification) |
| content | TEXT | 消息内容(JSON) |
| is_read | INTEGER | 是否已读(0/1) |
| created_at | INTEGER | 创建时间 |
SharedPreferences存储(键值对配置):
| 键名 | 类型 | 说明 |
|---|---|---|
user_token |
String | JWT访问令牌(加密存储) |
user_refresh_token |
String | 刷新令牌(加密存储) |
user_id |
String | 当前用户ID |
user_role |
String | 用户角色(teacher/parent) |
school_id |
String | 学校ID |
notification_enabled |
bool | 是否开启推送通知 |
theme_mode |
String | 主题模式(system/light/dark) |
language |
String | 语言设置(zh_CN/zh_TW) |
last_sync_ts |
int | 最后一次数据同步时间戳 |
2.5 接口设计
HTTP API接口(与云端平台通信):
| 接口 | 方法 | URL | 说明 |
|---|---|---|---|
| 登录 | POST | /api/v1/auth/login |
手机号+密码/微信/钉钉登录 |
| 刷新令牌 | POST | /api/v1/auth/refresh |
Token过期自动刷新 |
| 获取作业列表 | GET | /api/v1/assignment/list |
分页获取班级作业列表 |
| 发布作业 | POST | /api/v1/assignment/publish |
教师发布新作业/试卷 |
| 获取批改结果 | GET | /api/v1/assignment/{id}/results |
获取某次作业全班批改结果 |
| 学生学情报告 | GET | /api/v1/report/student/{id} |
获取指定学生学情报告 |
| 班级学情 | GET | /api/v1/report/class/{id} |
获取班级整体学情统计 |
| 消息列表 | GET | /api/v1/message/list |
获取消息列表(分页) |
| 发送消息 | POST | /api/v1/message/send |
发送家校沟通消息 |
| 拍照识题 | POST | /api/v1/ocr/photo |
上传题目照片进行识别 |
| 书写回放数据 | GET | /api/v1/stroke/{assignment_id}/student/{id} |
获取特定学生的笔迹回放数据 |
| 设备列表 | GET | /api/v1/device/list |
获取当前用户绑定的设备列表 |
WebSocket实时通信:
| 事件类型 | 方向 | 说明 |
|---|---|---|
classroom.started |
服务端→APP | 课堂已开始,推送课堂ID |
assignment.submitted |
服务端→APP | 学生提交了作业 |
stroke.realtime |
服务端→APP | 实时笔迹数据(教师巡课模式) |
result.ready |
服务端→APP | AI批改结果已就绪 |
message.new |
服务端→APP | 收到新消息 |
classroom.control |
APP→服务端 | 课堂控制指令(发题/收卷/暂停) |
统一响应格式:
{
"code": 200,
"message": "success",
"data": {...},
"timestamp": 1706845200000
}
错误码说明:
401:Token失效,自动跳转重新登录403:无权访问(如家长尝试访问其他班级数据)429:请求频率超限(拍照搜题接口有频率限制)503:服务器维护中
2.6 安全设计
身份认证安全:
- 支持三种登录方式:
- 手机号+密码(密码MD5+Salt单向哈希存储)
- 微信OAuth 2.0一键登录
- 钉钉OAuth 2.0登录(教育钉钉生态)
- JWT双令牌机制:Access Token有效期2小时,Refresh Token有效期30天
- Token存储:Android使用EncryptedSharedPreferences(基于AndroidKeyStore加密),iOS使用Keychain
网络传输安全:
- 全链路HTTPS,TLS 1.3加密
- SSL证书绑定(Certificate Pinning):防止中间人攻击,内置服务器证书公钥指纹
- 实现方式(Dio拦截器):
// lib/core/network/ssl_pinning_interceptor.dart
class SSLPinningInterceptor extends Interceptor {
static const List<String> _pinnedCertHashes = [
'sha256/AAAAAABBBBBBCCCCCC==', // 主域名证书指纹
'sha256/DDDDDDEEEEEEFFFFF==', // 备用证书指纹(轮换用)
];
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// 验证证书指纹(在HttpClient层通过badCertificateCallback实现)
super.onResponse(response, handler);
}
}
本地数据安全:
- SQLite数据库文件存储于APP沙箱目录,不可被其他APP访问
- 敏感字段(Token、手机号)在SharedPreferences中加密后存储
- APP进入后台超过30分钟,需重新验证指纹/Face ID才能操作敏感功能
隐私合规:
- 严格遵守《APP收集使用个人信息最小必要评估规范》
- 摄像头(拍照搜题)、蓝牙(笔连接)、通知权限均在首次使用时动态申请
- 儿童数据(学生)展示时脱敏(姓名显示为"张*"格式)
2.7 权限说明
| 权限名称 | Android权限 | iOS权限 | 申请时机 | 用途 |
|---|---|---|---|---|
| 网络访问 | INTERNET |
- | 安装时自动 | API调用 |
| 蓝牙扫描 | BLUETOOTH_SCAN |
NSBluetoothAlways | 首次使用"连接笔"时 | 扫描点阵笔设备 |
| 蓝牙连接 | BLUETOOTH_CONNECT |
NSBluetoothAlways | 首次使用"连接笔"时 | 连接点阵笔 |
| 摄像头 | CAMERA |
NSCameraUsageDescription | 首次使用"拍照搜题"时 | 拍题识别 |
| 通知 | POST_NOTIFICATIONS |
UNUserNotificationCenter | 首次登录后询问 | 消息推送 |
| 存储读取 | READ_EXTERNAL_STORAGE |
- | 首次保存报告时 | 导出报告到相册 |
第三章 核心模块功能详细说明
3.1 登录与身份认证模块
源代码文件:lib/features/auth/
登录界面布局:
┌────────────────────────────────┐
│ 自然写互动课堂 │
│ [Logo] │
│ │
│ ┌──────────────────────────┐ │
│ │ 手机号/账号 │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ 密码 [显示/隐藏]│ │
│ └──────────────────────────┘ │
│ │
│ [ 登录 ] │
│ │
│ ── 其他登录方式 ── │
│ [微信登录] [钉钉登录] │
│ │
│ 首次使用?[联系学校管理员获取账号]│
└────────────────────────────────┘
认证流程(AuthBloc):
// lib/features/auth/bloc/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _authRepository;
AuthBloc({required AuthRepository authRepository})
: _authRepository = authRepository,
super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
on<TokenRefreshRequested>(_onTokenRefreshRequested);
on<LogoutRequested>(_onLogoutRequested);
}
Future<void> _onLoginRequested(
LoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final user = await _authRepository.login(
phone: event.phone,
password: event.password,
);
// 将Token安全存储
await _authRepository.saveTokensSecurely(
user.accessToken, user.refreshToken);
emit(AuthAuthenticated(user: user));
} on AuthException catch (e) {
emit(AuthError(message: e.message));
}
}
}
角色分流逻辑:
登录成功后,根据user.role字段跳转不同首页:
role == 'teacher'→ 跳转教师端首页(TeacherHomePage)role == 'parent'→ 跳转家长端首页(ParentHomePage)role == 'admin'→ 跳转管理员端(AdminHomePage,仅限学校管理员)
3.2 教师端 — 课堂互动控制模块
源代码文件:lib/features/classroom/
课堂互动主界面:
┌────────────────────────────────────────┐
│ [←] 二年级一班 — 语文 [设置] │
├────────────────────────────────────────┤
│ 课堂状态:● 进行中 已连接 38支笔 │
│ │
│ [暂停课堂] [发题] [收卷] [点名] │
├────────────────────────────────────────┤
│ 实时提交状态 │
│ ████████████████░░░ 38/40 已提交 │
│ │
│ 未提交:王小明 李晓红 (+0) │
├────────────────────────────────────────┤
│ 快捷操作 │
│ [展示优秀作品] [全班展示] [对比] │
└────────────────────────────────────────┘
发题流程:
// lib/features/classroom/bloc/classroom_bloc.dart
Future<void> _onSendQuestion(
SendQuestionEvent event, Emitter<ClassroomState> emit) async {
emit(SendingQuestion());
try {
// 1. 向云端API发送题目内容和截止时间
await _classroomRepository.sendQuestion(
classroomId: event.classroomId,
questionData: event.questionData,
timeLimitSeconds: event.timeLimitSeconds,
);
// 2. 通过WebSocket广播给所有终端
_wsChannel.sink.add(jsonEncode({
'type': 'classroom.send_question',
'classroom_id': event.classroomId,
'question': event.questionData.toJson(),
}));
emit(QuestionSentState(question: event.questionData));
} catch (e) {
emit(ClassroomErrorState(message: e.toString()));
}
}
随机点名功能:
点击"点名"按钮时,APP从班级学生列表中按算法随机抽取:
- 支持"排除已点名"选项(一节课内不重复点同一学生)
- 支持"按区域分配"(保证均匀覆盖全班)
- 点名结果同时推送到智慧黑板大屏展示(WebSocket指令)
3.3 教师端 — 作业布置与批改模块
源代码文件:lib/features/assignment/
作业布置界面:
┌────────────────────────────────────────┐
│ [←] 布置作业 [发布] │
├────────────────────────────────────────┤
│ 作业标题:[第5课生字练习 ] │
│ │
│ 班级: [二年级一班 ▼] │
│ 学科: [语文 ▼] │
│ 类型: [练字 ● 试卷 ○ 作文 ○] │
│ │
│ 截止时间:[2026-02-15 20:00 ▼] │
│ │
│ 关联资源: │
│ [+ 从资源库选择] [+ 上传文件] │
│ ● 人教版二年级上册_第5课_字帖.pdf │
│ │
│ 布置说明:[请完成所有生字的书写练习...]│
└────────────────────────────────────────┘
批改结果查看界面:
┌────────────────────────────────────────┐
│ [←] 第5课生字练习 — 批改结果 │
├────────────────────────────────────────┤
│ 班级:二年级一班 已交:38/40 │
│ 平均分:87.5 优秀(≥90):15人 │
│ │
│ 按得分排序 ▼ │
├────────────────────────────────────────┤
│ 张三 96分 ✓ AI批改完成 [查看] │
│ 李四 92分 ✓ AI批改完成 [查看] │
│ 王五 85分 ⚠ 需人工复核 [批改] │
│ 赵六 78分 ✓ AI批改完成 [查看] │
│ ··· │
├────────────────────────────────────────┤
│ [导出成绩单] [发送给家长] │
└────────────────────────────────────────┘
人工批改界面(点击"批改"按钮):
┌────────────────────────────────────────┐
│ 王五 — 作业批改 │
├────────────────────────────────────────┤
│ │
│ [笔迹显示区域 — 显示学生书写内容] │
│ │
│ "美"字 [笔迹图] │
│ AI建议:笔顺第3笔有误 │
│ 书写规范度:72% │
│ │
├────────────────────────────────────────┤
│ 教师评分:[____]分 │
│ 批注:[写得不错,注意横折的弧度...] │
│ │
│ [上一题] [下一题] [完成批改] │
└────────────────────────────────────────┘
3.4 教师端 — 实时收笔与展示模块
源代码文件:lib/features/realtime_ink/
教师通过手机可实时查看教室内所有学生的书写状态(需算力盒或网关推送数据):
实时状态面板:
┌────────────────────────────────────────┐
│ 实时书写监控 [全屏] [刷新] │
├────────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │张三 │ │李四 │ │王五 │ │赵六 │ │
│ │[笔迹]│ │[笔迹]│ │[笔迹]│ │[笔迹]│ │
│ │书写中│ │已完成│ │书写中│ │未开始│ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ ··· │
├────────────────────────────────────────┤
│ [投屏展示选中的学生作品] │
└────────────────────────────────────────┘
WebSocket实时数据接收处理:
// lib/features/realtime_ink/repository/realtime_ink_repository.dart
Stream<StudentInkData> getRealtimeInkStream(String classroomId) {
return _wsChannel.stream
.where((data) {
final json = jsonDecode(data as String);
return json['type'] == 'stroke.realtime' &&
json['classroom_id'] == classroomId;
})
.map((data) => StudentInkData.fromJson(jsonDecode(data as String)));
}
3.5 家长端 — 学情报告查看模块
源代码文件:lib/features/parent/report/
家长端首页(学情概览):
┌────────────────────────────────────────┐
│ [←] 张小明的学情报告 │
├────────────────────────────────────────┤
│ 本周总结(2/10-2/14) │
│ │
│ 完成作业:5/5 ✓ │
│ 平均得分:88.5分 │
│ 书写规范度:↑ 提升3% │
│ 笔顺正确率:92% │
├────────────────────────────────────────┤
│ 知识点掌握情况 │
│ 语文:[■■■■■■■■░░] 82% │
│ 数学:[■■■■■■░░░░] 65% │
│ 英语:[■■■■■■■■■░] 90% │
├────────────────────────────────────────┤
│ 薄弱知识点提醒 │
│ ⚠ 数学:应用题解题步骤不完整 │
│ ⚠ 语文:多音字辨析正确率较低 │
├────────────────────────────────────────┤
│ [查看详细报告] [历史对比] │
└────────────────────────────────────────┘
成长轨迹图表(ECharts风格,使用fl_chart实现):
// lib/features/parent/report/widgets/growth_chart_widget.dart
class GrowthChartWidget extends StatelessWidget {
final List<GrowthDataPoint> dataPoints;
const GrowthChartWidget({super.key, required this.dataPoints});
@override
Widget build(BuildContext context) {
return LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: dataPoints.map((p) =>
FlSpot(p.weekIndex.toDouble(), p.score)).toList(),
isCurved: true,
color: Theme.of(context).primaryColor,
barWidth: 3,
dotData: FlDotData(show: true),
),
],
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return Text('第${value.toInt()}周',
style: const TextStyle(fontSize: 10));
},
),
),
),
),
);
}
}
3.6 家长端 — 书写回放模块
源代码文件:lib/features/parent/stroke_replay/
书写回放功能让家长能以动画方式观看孩子的实际书写过程,直观了解笔顺和书写规范性。
回放界面:
┌────────────────────────────────────────┐
│ [←] 书写回放 — "美"字 │
├────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────┐ │
│ │ │ │
│ │ [笔迹动画回放区域] │ │
│ │ 书写时间:2026-02-14 08:30 │ │
│ │ │ │
│ └──────────────────────────────────┘ │
│ │
│ 第3笔 ⚠ 笔顺有误(应先横后折) │
│ 笔顺正确率:88%(11/12笔正确) │
│ │
│ ════════════════════░░░ 75% │
│ [◀◀] [▶/II] [▶▶] [速度×1] │
├────────────────────────────────────────┤
│ [分享给老师] [添加到错题本] │
└────────────────────────────────────────┘
回放渲染引擎(CustomPainter):
// lib/features/parent/stroke_replay/painters/stroke_replay_painter.dart
class StrokeReplayPainter extends CustomPainter {
final List<StrokePath> completedStrokes;
final StrokePath? currentStroke;
final double progress; // 当前绘制进度 [0.0, 1.0]
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
// 绘制已完成的笔画(灰色)
for (final stroke in completedStrokes) {
paint.color = Colors.grey.withOpacity(0.5);
paint.strokeWidth = stroke.width;
_drawStroke(canvas, stroke.points, paint, size);
}
// 绘制当前正在演示的笔画(黑色,按progress截断)
if (currentStroke != null) {
paint.color = Colors.black87;
paint.strokeWidth = currentStroke!.width;
final visibleCount =
(currentStroke!.points.length * progress).round();
_drawStroke(
canvas, currentStroke!.points.take(visibleCount).toList(),
paint, size);
}
}
void _drawStroke(Canvas canvas, List<StrokePoint> points,
Paint paint, Size size) {
if (points.length < 2) return;
final path = Path();
path.moveTo(points[0].x * size.width, points[0].y * size.height);
for (int i = 1; i < points.length; i++) {
// 使用贝塞尔曲线平滑笔迹
final prevPoint = points[i - 1];
final currPoint = points[i];
final midX = (prevPoint.x + currPoint.x) / 2 * size.width;
final midY = (prevPoint.y + currPoint.y) / 2 * size.height;
path.quadraticBezierTo(
prevPoint.x * size.width, prevPoint.y * size.height,
midX, midY);
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(StrokeReplayPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.currentStroke != currentStroke;
}
}
3.7 消息通知模块
源代码文件:lib/features/message/
消息列表界面:
┌────────────────────────────────────────┐
│ 消息中心 [全部已读] │
├────────────────────────────────────────┤
│ [家校沟通] [作业通知] [系统通知] │
├────────────────────────────────────────┤
│ ● 张三妈妈:请问孩子今天的语文作业... │
│ 2月14日 08:30 [●未读] │
│ │
│ ● 作业提醒:王五的数学练习尚未提交 │
│ 2月13日 20:00 [已读] │
│ │
│ ● 系统:AI批改完成,本次作业38人已批改 │
│ 2月13日 16:25 [已读] │
└────────────────────────────────────────┘
推送通知实现(Firebase Messaging):
// lib/core/notifications/push_notification_service.dart
class PushNotificationService {
final FirebaseMessaging _fcm = FirebaseMessaging.instance;
Future<void> initialize() async {
// 请求通知权限(iOS需要显式请求)
await _fcm.requestPermission(
alert: true, badge: true, sound: true,
);
// 获取FCM Token并上传至服务端(绑定到当前用户)
final token = await _fcm.getToken();
if (token != null) {
await _uploadFcmToken(token);
}
// 监听前台消息(APP在前台时的推送处理)
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
_handleForegroundMessage(message);
});
// 监听点击通知打开APP(APP在后台被唤醒)
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
_handleNotificationTap(message);
});
}
void _handleForegroundMessage(RemoteMessage message) {
// 前台时不显示系统通知,而是在APP内显示自定义提示条(SnackBar/Banner)
final type = message.data['type'];
if (type == 'assignment.submitted') {
// 更新作业提交计数
_messageBloc.add(NewSubmissionEvent(data: message.data));
} else if (type == 'message.new') {
// 在消息中心显示新消息红点
_messageBloc.add(NewMessageEvent(data: message.data));
}
}
}
3.8 蓝牙连接点阵笔模块
源代码文件:lib/features/bluetooth/
此功能面向教师端的移动教学场景,教师手持点阵笔直接书写,笔迹实时传输至手机APP,再由APP转发至智慧黑板大屏。
设备扫描界面:
┌────────────────────────────────────────┐
│ [←] 连接点阵笔 │
├────────────────────────────────────────┤
│ 搜索附近的点阵笔... │
│ [停止搜索] │
├────────────────────────────────────────┤
│ ● Writech-A1B2C3 信号强 [连接] │
│ ○ Writech-D4E5F6 信号中 [连接] │
├────────────────────────────────────────┤
│ 已配对设备 │
│ ● Writech-A1B2C3 上次使用:今天 │
└────────────────────────────────────────┘
BLE连接管理(flutter_blue_plus):
// lib/features/bluetooth/repository/ble_repository.dart
class BleRepository {
final FlutterBluePlus _flutterBlue = FlutterBluePlus.instance;
Stream<List<ScanResult>> scanPens() {
_flutterBlue.startScan(
withServices: [Guid('0000FFF0-0000-1000-8000-00805F9B34FB')],
timeout: const Duration(seconds: 10),
);
return _flutterBlue.scanResults;
}
Future<BluetoothDevice> connectToPen(String deviceId) async {
final device = BluetoothDevice.fromId(deviceId);
await device.connect(timeout: const Duration(seconds: 10));
// 订阅笔迹数据Notify
final services = await device.discoverServices();
for (final service in services) {
if (service.uuid.toString().startsWith('0000fff0')) {
for (final char in service.characteristics) {
if (char.uuid.toString().startsWith('0000fff1')) {
await char.setNotifyValue(true);
// 监听笔迹数据流
char.lastValueStream.listen((data) {
_processInkData(data);
});
}
}
}
}
return device;
}
void _processInkData(List<int> rawData) {
// 解析BLE差分编码数据包,还原坐标序列
final coords = StrokeDecoder.decode(Uint8List.fromList(rawData));
_inkStreamController.add(coords);
}
}
3.9 学习数据统计图表模块
源代码文件:lib/features/analytics/
使用fl_chart库实现多种数据可视化图表,直观展示学习数据。
教师端 — 班级成绩分布(柱状图):
┌────────────────────────────────────────┐
│ 班级成绩分布 第5课生字练习 │
├────────────────────────────────────────┤
│ 10│ ██ │
│ 8│ ██ ██ │
│ 6│ ██ ██ ██ ██ │
│ 4│ ██ ██ ██ ██ ██ │
│ 2│ ██ ██ ██ ██ ██ ██ │
│ 0└──────────────────── │
│ 60 70 80 90 100(分) │
│ │
│ 平均分:87.5 中位数:89 │
│ 及格率:100% 优秀率(≥90):37.5% │
└────────────────────────────────────────┘
家长端 — 学科雷达图:
// lib/features/analytics/widgets/subject_radar_widget.dart
RadarChart(
RadarChartData(
dataSets: [
RadarDataSet(
dataEntries: [
RadarEntry(value: 82), // 语文
RadarEntry(value: 75), // 数学
RadarEntry(value: 90), // 英语
RadarEntry(value: 68), // 书写
RadarEntry(value: 85), // 笔顺
],
fillColor: Colors.blue.withOpacity(0.2),
borderColor: Colors.blue,
),
],
radarBackgroundColor: Colors.transparent,
getTitle: (index, angle) {
const titles = ['语文', '数学', '英语', '书写', '笔顺'];
return RadarChartTitle(text: titles[index]);
},
),
)
3.10 拍照搜题模块
源代码文件:lib/features/photo_ocr/
家长可以用手机摄像头拍摄孩子作业中的题目,APP上传图片到云端AI引擎进行识别,与孩子的作答结果进行对比。
拍照识题界面:
┌────────────────────────────────────────┐
│ [←] 拍照搜题 │
├────────────────────────────────────────┤
│ ┌──────────────────────────────────┐ │
│ │ │ │
│ │ [摄像头取景框] │ │
│ │ 对准题目,点击拍照 │ │
│ │ │ │
│ └──────────────────────────────────┘ │
│ [从相册选择] [📷 拍照] │
├────────────────────────────────────────┤
│ 最近搜题记录 │
│ 2+3=? 2月14日 │
│ "美"字写法 2月13日 │
└────────────────────────────────────────┘
识别结果界面:
┌────────────────────────────────────────┐
│ [←] 识别结果 │
├────────────────────────────────────────┤
│ 题目识别: │
│ "2+3=" │
│ │
│ 标准答案:5 │
│ │
│ 孩子的作答:5 ✓ 正确 │
├────────────────────────────────────────┤
│ 解题思路: │
│ 2加3等于5,可以用手指数数: │
│ 1,2...再数3个...3,4,5 │
│ │
│ [加入错题本] [分享给老师] │
└────────────────────────────────────────┘
3.11 离线缓存与数据同步模块
源代码文件:lib/core/cache/
缓存策略(Repository层统一实现):
// lib/core/cache/cache_first_repository.dart
abstract class CacheFirstRepository<T> {
/// 获取数据(缓存优先策略)
Stream<T> getWithCache(String cacheKey,
Future<T> Function() networkFetch) async* {
// 1. 先尝试读取本地缓存(立即返回,用户无感知延迟)
final cachedData = await _localDataSource.get<T>(cacheKey);
if (cachedData != null) {
yield cachedData;
}
// 2. 同时发起网络请求获取最新数据
try {
final freshData = await networkFetch();
// 3. 更新本地缓存
await _localDataSource.save(cacheKey, freshData);
// 4. 如果新数据与缓存不同,推送给UI更新
if (cachedData == null || freshData != cachedData) {
yield freshData;
}
} on NetworkException {
// 网络失败时继续使用缓存数据(已在步骤1 yield过)
if (cachedData == null) {
// 没有缓存且无网络,抛出错误
rethrow;
}
}
}
}
离线队列(网络恢复后自动同步):
APP在离线期间的写操作(如发送消息、修改批改结果)会先存入本地SQLite的offline_queue表,网络恢复时按顺序重放执行。
3.12 个人中心与设置模块
源代码文件:lib/features/profile/
个人中心界面:
┌────────────────────────────────────────┐
│ [头像] 张老师 │
│ 教师 · 育才小学二年级语文 │
├────────────────────────────────────────┤
│ 我的班级 二年级一班 二年级二班 │
│ 我的设备 [点阵笔] [已配对2台] ›│
│ 消息通知设置 [开启推送 ✓] │
├────────────────────────────────────────┤
│ 帮助与反馈 › │
│ 隐私政策 › │
│ 用户服务协议 › │
│ 关于自然写 V1.0.0 › │
├────────────────────────────────────────┤
│ [退出登录] │
└────────────────────────────────────────┘
第四章 操作流程与使用步骤
4.1 安装与首次启动
Android安装:
- 通过应用市场(华为应用市场/小米应用商店/Google Play)搜索"自然写互动课堂"下载安装
- 安装包大小约85MB,安装完成后首次启动约需3秒初始化
iOS安装:
- 通过App Store搜索"自然写互动课堂"
- 点击"获取",使用Face ID / Touch ID确认安装
首次启动流程:
首次打开APP:
│
├─ 显示欢迎页(3秒)
│
├─ 权限申请说明页(说明各权限用途)
│ [知道了,开始使用]
│
├─ 登录页(等待用户登录)
│
└─ 登录成功→根据角色跳转对应首页
教师角色 → 教师端首页
家长角色 → 家长端首页
4.2 账号登录与角色选择
教师账号登录(手机号+密码):
- 输入学校统一分配的手机号/工号
- 输入初始密码(默认为手机号后6位,首次登录需修改)
- 点击"登录",系统验证并分配教师角色权限
- 教师端首页显示班级列表和今日待办事项
家长账号登录(微信一键登录):
- 点击"微信登录",跳转微信授权页面
- 微信确认授权后返回APP
- 若为首次登录,提示输入学生学号绑定孩子账号
- 绑定成功后进入家长端首页
同一账号切换子账号(家长端):
若家长有多个孩子,可在"个人中心"→"切换孩子"中选择查看不同孩子的学情。
4.3 教师端完整使用流程
日常使用流程(课堂日):
上课前(8:00-8:30):
1. 打开APP,进入班级主页
2. 点击"布置作业"→选择今日练习内容→设置截止时间→发布
3. 作业发布后,学生Pad端自动收到新作业通知
上课中(8:30-9:10):
1. 点击"开始课堂"→选择班级→课堂互动主界面启动
2. 实时查看学生书写状态(绿色=书写中,灰色=未开始)
3. 点击"发题"→输入互动题目→选择时限→发送全班
4. 学生答题完毕,点击"收卷"→查看实时答题统计
5. 选择典型答案(正确/错误各选几份)→点击"展示至黑板"
课后(放学后):
1. 查看AI批改已完成的作业列表
2. 对标注"需人工复核"的作业进行批改
3. 批改完成后,家长端自动收到推送通知
4. 选择本周优秀作品→发布"作品墙"推送给家长
4.4 家长端完整使用流程
日常查看孩子学情:
家长端日常操作:
1. 打开APP,首页显示今日学情摘要
2. 查看"今日作业":
├── 已完成:查看AI评分和教师批改评语
└── 未完成:点击提醒按钮(振动提醒孩子)
3. 点击"书写回放":
├── 选择某次作业的某个字
└── 观看书写动画回放,检查笔顺是否正确
4. 查看"成长报告":
├── 本周得分趋势折线图
└── 薄弱知识点标注
5. 与教师沟通:
└── 点击"联系老师"→发送文字消息给班主任
4.5 消息与通知使用流程
接收推送通知:
- APP在后台时,新消息通过系统通知栏推送(需开启通知权限)
- 点击通知可直接跳转到对应功能页面(如点击"批改完成通知"跳转到批改结果页)
屏蔽通知设置:
- 在"个人中心"→"消息通知设置"可按类型开关通知
- 支持设置"免打扰时段"(如每天22:00-7:00不推送)
4.6 异常处理与故障排查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 登录失败"账号不存在" | 使用了错误的登录方式(教师用微信登录/家长用工号登录) | 选择正确的登录方式 |
| 作业列表无法加载 | 网络连接问题 | 检查网络,下拉刷新 |
| 书写回放无数据 | 学生通过触屏而非点阵笔作答 | 确认学生使用点阵笔书写 |
| 蓝牙扫描不到笔 | 点阵笔未开机或蓝牙权限未授予 | 检查笔电量,确认已授予蓝牙权限 |
| 推送通知未收到 | 通知权限被关闭或FCM网络问题 | 检查系统通知权限设置 |
第五章 与源代码的对应关系
5.1 模块名称与源代码文件对应表
| 功能模块 | 源代码路径 | 说明 |
|---|---|---|
| 登录认证模块 | lib/features/auth/ |
AuthBloc, AuthRepository, LoginPage |
| 教师端首页 | lib/features/teacher/home/ |
TeacherHomePage, ClassroomCard |
| 课堂互动控制 | lib/features/classroom/ |
ClassroomBloc, ClassroomPage |
| 作业布置与批改 | lib/features/assignment/ |
AssignmentBloc, AssignmentRepository |
| 实时收笔展示 | lib/features/realtime_ink/ |
RealtimeInkBloc, InkMonitorPage |
| 家长端首页 | lib/features/parent/home/ |
ParentHomePage, ReportSummaryCard |
| 学情报告 | lib/features/parent/report/ |
ReportBloc, GrowthChartWidget |
| 书写回放 | lib/features/parent/stroke_replay/ |
StrokeReplayPage, StrokeReplayPainter |
| 消息通知 | lib/features/message/ |
MessageBloc, MessageListPage |
| 蓝牙连接笔 | lib/features/bluetooth/ |
BleBloc, BleRepository, DeviceScanPage |
| 学情数据图表 | lib/features/analytics/ |
AnalyticsPage, RadarChartWidget |
| 拍照搜题 | lib/features/photo_ocr/ |
PhotoOCRPage, PhotoOCRRepository |
| 个人中心 | lib/features/profile/ |
ProfilePage, SettingsPage |
| 离线缓存 | lib/core/cache/ |
CacheFirstRepository, OfflineQueue |
| 网络请求 | lib/core/network/ |
ApiClient, SSLPinningInterceptor |
| 本地数据库 | lib/core/database/ |
AppDatabase, DAOs |
| 推送通知 | lib/core/notifications/ |
PushNotificationService |
| BLoC公共基类 | lib/core/bloc/ |
BaseBloc, BaseState, BaseEvent |
| 路由管理 | lib/core/router/ |
AppRouter, RouteNames |
| 主题配置 | lib/core/theme/ |
AppTheme, ColorScheme |
5.2 核心类与方法说明
| 类名 | 所在文件 | 功能说明 |
|---|---|---|
AuthBloc |
auth/bloc/auth_bloc.dart |
认证状态管理,处理登录/登出/Token刷新 |
AuthRepository |
auth/repository/auth_repository.dart |
认证数据访问,JWT令牌存储管理 |
ClassroomBloc |
classroom/bloc/classroom_bloc.dart |
课堂互动状态管理 |
AssignmentBloc |
assignment/bloc/assignment_bloc.dart |
作业管理状态管理 |
AssignmentRepository |
assignment/repository/assignment_repository.dart |
作业数据访问(网络+本地缓存) |
ReportBloc |
parent/report/bloc/report_bloc.dart |
学情报告状态管理 |
StrokeReplayPainter |
stroke_replay/painters/stroke_replay_painter.dart |
笔迹回放Canvas渲染 |
BleBloc |
bluetooth/bloc/ble_bloc.dart |
BLE蓝牙连接状态管理 |
BleRepository |
bluetooth/repository/ble_repository.dart |
BLE设备扫描与连接管理 |
PushNotificationService |
core/notifications/push_notification_service.dart |
FCM/APNs推送初始化与处理 |
SSLPinningInterceptor |
core/network/ssl_pinning_interceptor.dart |
SSL证书绑定安全拦截器 |
CacheFirstRepository |
core/cache/cache_first_repository.dart |
缓存优先数据访问基类 |
AppDatabase |
core/database/app_database.dart |
SQLite数据库初始化与迁移 |
5.3 状态管理架构说明
BLoC事件定义示例(作业模块):
// lib/features/assignment/bloc/assignment_event.dart
abstract class AssignmentEvent extends Equatable {}
class LoadAssignmentListEvent extends AssignmentEvent {
final String classId;
const LoadAssignmentListEvent({required this.classId});
@override List<Object?> get props => [classId];
}
class PublishAssignmentEvent extends AssignmentEvent {
final AssignmentData data;
const PublishAssignmentEvent({required this.data});
@override List<Object?> get props => [data];
}
class MarkGradedEvent extends AssignmentEvent {
final String assignmentId;
final String studentId;
final double score;
final String comment;
const MarkGradedEvent({
required this.assignmentId,
required this.studentId,
required this.score,
required this.comment,
});
@override List<Object?> get props =>
[assignmentId, studentId, score, comment];
}
附录A 界面设计稿(GUI Mockup)
本附录以手机竖屏线框图形式呈现手机APP各核心界面的设计稿,反映真实的界面布局与交互元素。
A.1 登录界面
┌─────────────────────┐
│ 09:41 ●●● WiFi │ 状态栏
├─────────────────────┤
│ │
│ 🖊 │
│ 自 然 写 │
│ Writech APP │
│ │
│ ┌─────────────────┐ │
│ │ 👤 手机号/账号 │ │
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ 🔒 密 码 │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ 立 即 登 录 │ │ 主按钮(品牌蓝)
│ └─────────────────┘ │
│ │
│ ─────── 其他方式 ─── │
│ [微信登录] [钉钉登录] │
│ │
│ 教师端 / 家长端 │
├─────────────────────┤
│ © 2026 自然写科技 │
└─────────────────────┘
A.2 教师端首页(课堂列表)
┌─────────────────────┐
│ 09:41 │
├─────────────────────┤
│ 早上好,李老师 │
│ 今日课堂:3节 │
├─────────────────────┤
│ ┌─────────────────┐ │
│ │ ▶ 立即开始课堂 │ │ 绿色操作按钮
│ └─────────────────┘ │
│ │
│ 今日课程安排 │
│ ┌─────────────────┐ │
│ │ 08:00 语文 │ │
│ │ 高一(3)班·45人 │ │
│ │ 状态: ✅ 已完成 │ │
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ 10:00 数学 │ │
│ │ 高一(3)班·45人 │ │
│ │ 状态: ⏳ 进行中 │ │ 高亮
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ 14:00 英语 │ │
│ │ 高一(3)班·45人 │ │
│ │ 状态: ○ 未开始 │ │
│ └─────────────────┘ │
├─────────────────────┤
│ 🏠首页 📝作业 📊报表 👤我 │
└─────────────────────┘
A.3 课堂互动主界面(教师端)
┌─────────────────────┐
│ ◀ 数学课堂 ··· │
├─────────────────────┤
│ 高一(3)班 45/45人 │
│ ⏱ 00:23:45 进行中 │
├─────────────────────┤
│ 实时书写状态 │
│ ┌─────────────────┐ │
│ │ ██████░░░░ 38/45│ │ 进度条:已提交
│ │ 正在书写: 7人 │ │
│ └─────────────────┘ │
│ │
│ 题目内容 │
│ ┌─────────────────┐ │
│ │ 解方程: │ │
│ │ 2x + 5 = 13 │ │
│ │ │ │
│ └─────────────────┘ │
│ │
│ 操作区 │
│ ┌───────┐ ┌───────┐ │
│ │📤 收卷 │ │📊 批改│ │
│ └───────┘ └───────┘ │
│ ┌───────┐ ┌───────┐ │
│ │🔴 点名 │ │💬 评语│ │
│ └───────┘ └───────┘ │
├─────────────────────┤
│ [结束课堂] │
└─────────────────────┘
A.4 作业批改界面(教师端)
┌─────────────────────┐
│ ◀ 作业批改 │
├─────────────────────┤
│ 2月14日语文作业 │
│ 已提交 42/45 待批改 38 │
├─────────────────────┤
│ ┌─────────────────┐ │
│ │ 王小花 │ │
│ │ ┌─────────────┐ │ │
│ │ │ [手写笔迹 │ │ │
│ │ │ 图像区域] │ │ │
│ │ │ │ │ │
│ │ └─────────────┘ │ │
│ │ AI识别:正确 ✅ │ │
│ │ 识别内容:春眠不觉晓│ │
│ └─────────────────┘ │
│ │
│ 批改操作 │
│ ┌──┐ ┌──┐ ┌──────┐ │
│ │✅│ │❌│ │半对 ◑ │ │
│ └──┘ └──┘ └──────┘ │
│ ┌─────────────────┐ │
│ │ ✏️ 添加批注... │ │
│ └─────────────────┘ │
│ [← 上一个] [下一个 →] │
└─────────────────────┘
A.5 学情报告界面(家长端)
┌─────────────────────┐
│ 09:41 │
├─────────────────────┤
│ 📊 孩子学情报告 │
│ 李小明 · 高一(3)班 │
├─────────────────────┤
│ 本周综合表现 │
│ │
│ 综合掌握度: 73.4% │
│ [████████████░░░░] │
│ │
│ 作业完成情况 │
│ 提交: 5/5 优秀: 3 │
│ 良好: 2 待提高: 0 │
│ │
│ 知识点进展 │
│ ┌─────────────────┐ │
│ │ ✅ 整数运算 95% │ │
│ │ ⚡ 一元方程 61% │ │
│ │ ⚠️ 二元方程 34% │ │
│ └─────────────────┘ │
│ │
│ 教师评语 │
│ ┌─────────────────┐ │
│ │"本周书写认真, │ │
│ │方程部分需多练习" │ │
│ └─────────────────┘ │
│ [下载完整报告] │
├─────────────────────┤
│ 🏠首页 📊报告 💬消息 👤我 │
└─────────────────────┘
手机APP设计遵循以下设计规范:
- 色彩系统:主色调为自然写品牌蓝(#1E6FFF),辅助色绿色(成功)、橙色(警告)、红色(错误)
- 字体:系统字体(Android: Roboto + 思源黑体;iOS: SF Pro + PingFang SC)
- 间距系统:基础间距单位8dp,组件间距16dp,页面内边距16dp
- 图标:Material Design 3图标集(教师端)+ 自定义业务图标
- 适配:支持深色模式、大字体模式(无障碍)、分屏模式(Android平板)
附录B 第三方SDK集成说明
| SDK | 版本 | 集成方式 | 功能 |
|---|---|---|---|
| Flutter SDK | 3.16.0 | Dart pubspec.yaml | 跨平台框架 |
| Firebase Messaging | 14.7.10 | pubspec.yaml + google-services.json | FCM推送(Android) |
| flutter_blue_plus | 1.31.7 | pubspec.yaml | BLE蓝牙通信 |
| Dio | 5.3.2 | pubspec.yaml | HTTP网络请求 |
| fl_chart | 0.66.0 | pubspec.yaml | 图表可视化 |
| 微信SDK | 3.5.6 | Android AAR / iOS Framework | 微信登录 |
| 钉钉SDK | 2.15 | Android AAR / iOS Framework | 钉钉登录 |
| camera | 0.10.5 | pubspec.yaml | 拍照搜题 |
附录C 术语表
| 术语 | 说明 |
|---|---|
| Flutter | Google开源的跨平台UI框架,单代码库编译Android和iOS |
| BLoC | Business Logic Component,Flutter状态管理模式 |
| MVVM | Model-View-ViewModel,Android推荐的架构模式 |
| Dart | Flutter使用的编程语言 |
| JWT | JSON Web Token,无状态身份认证令牌 |
| BLE | Bluetooth Low Energy,低功耗蓝牙(点阵笔通信协议) |
| GATT | Generic Attribute Profile,BLE应用层协议 |
| FCM | Firebase Cloud Messaging,Google推送服务 |
| APNs | Apple Push Notification service,苹果推送服务 |
| Certificate Pinning | 证书绑定,防止中间人攻击的安全措施 |
| CustomPainter | Flutter自定义Canvas绘制接口(用于笔迹渲染) |
| sqflite | Flutter SQLite本地数据库插件 |
附录D 版本历史
| 版本 | 日期 | 平台 | 变更说明 | 编制人 |
|---|---|---|---|---|
| V0.6 Beta | 2025-08-15 | Android/iOS | 基础功能:登录、作业列表、学情报告MVP | 研发团队 |
| V0.9 RC | 2025-11-30 | Android/iOS | 书写回放、BLE连接、消息通知、拍照搜题 | 研发团队 |
| V1.0 | 2026-02-14 | Android/iOS | 正式版:打卡功能、深色模式、无障碍优化、性能优化 | 研发团队 |
文档编制:深圳自然写科技有限公司 移动端研发团队
文档版本:V1.0
最后更新:2026年2月14日
版权所有 © 2026 深圳自然写科技有限公司
附录E 核心技术实现详述
E.1 Flutter BLoC状态管理架构
手机APP采用BLoC(Business Logic Component)模式严格分离UI与业务逻辑,确保代码可测试性和可维护性。
E.1.1 作业模块BLoC实现
// lib/features/homework/bloc/homework_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../repository/homework_repository.dart';
import 'homework_event.dart';
import 'homework_state.dart';
class HomeworkBloc extends Bloc<HomeworkEvent, HomeworkState> {
final HomeworkRepository _repository;
HomeworkBloc({required HomeworkRepository repository})
: _repository = repository,
super(HomeworkInitial()) {
on<LoadHomeworkList>(_onLoadHomeworkList);
on<SubmitHomework>(_onSubmitHomework);
on<LoadHomeworkDetail>(_onLoadHomeworkDetail);
on<RefreshHomework>(_onRefreshHomework);
}
Future<void> _onLoadHomeworkList(
LoadHomeworkList event,
Emitter<HomeworkState> emit,
) async {
emit(HomeworkLoading());
try {
final homeworks = await _repository.getHomeworkList(
page: event.page,
status: event.status,
);
emit(HomeworkListLoaded(homeworks: homeworks, hasMore: homeworks.length >= 20));
} on NetworkException catch (e) {
// 网络异常时尝试从本地缓存加载
final cached = await _repository.getCachedHomeworkList();
if (cached.isNotEmpty) {
emit(HomeworkListLoaded(homeworks: cached, fromCache: true));
} else {
emit(HomeworkError(message: '网络连接失败:${e.message}'));
}
} catch (e) {
emit(HomeworkError(message: e.toString()));
}
}
Future<void> _onSubmitHomework(
SubmitHomework event,
Emitter<HomeworkState> emit,
) async {
emit(HomeworkSubmitting());
try {
// 1. 压缩笔迹数据
final compressedData = await _repository.compressInkData(event.inkData);
// 2. 上传笔迹(支持断点续传)
final uploadResult = await _repository.uploadInkData(
homeworkId: event.homeworkId,
inkData: compressedData,
onProgress: (sent, total) {
emit(HomeworkUploadProgress(progress: sent / total));
},
);
// 3. 提交作业记录
await _repository.submitHomework(
homeworkId: event.homeworkId,
inkDataUrl: uploadResult.url,
submitTime: DateTime.now(),
);
emit(HomeworkSubmitSuccess(homeworkId: event.homeworkId));
} on UploadException catch (e) {
emit(HomeworkError(message: '上传失败,请重试:${e.message}'));
}
}
}
// lib/features/homework/bloc/homework_event.dart
abstract class HomeworkEvent {}
class LoadHomeworkList extends HomeworkEvent {
final int page;
final HomeworkStatus? status;
LoadHomeworkList({this.page = 1, this.status});
}
class SubmitHomework extends HomeworkEvent {
final String homeworkId;
final List<InkStroke> inkData;
SubmitHomework({required this.homeworkId, required this.inkData});
}
class LoadHomeworkDetail extends HomeworkEvent {
final String homeworkId;
LoadHomeworkDetail({required this.homeworkId});
}
class RefreshHomework extends HomeworkEvent {}
// lib/features/homework/bloc/homework_state.dart
abstract class HomeworkState {}
class HomeworkInitial extends HomeworkState {}
class HomeworkLoading extends HomeworkState {}
class HomeworkListLoaded extends HomeworkState {
final List<HomeworkItem> homeworks;
final bool hasMore;
final bool fromCache;
HomeworkListLoaded({
required this.homeworks,
this.hasMore = false,
this.fromCache = false,
});
}
class HomeworkSubmitting extends HomeworkState {}
class HomeworkUploadProgress extends HomeworkState {
final double progress; // 0.0 ~ 1.0
HomeworkUploadProgress({required this.progress});
}
class HomeworkSubmitSuccess extends HomeworkState {
final String homeworkId;
HomeworkSubmitSuccess({required this.homeworkId});
}
class HomeworkError extends HomeworkState {
final String message;
HomeworkError({required this.message});
}
E.2 手写作业提交完整流程
E.2.1 InkCanvas书写组件
// lib/features/homework/widgets/ink_canvas_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
class InkCanvasWidget extends StatefulWidget {
final double width;
final double height;
final Function(List<InkStroke>) onStrokesChanged;
final bool readonly;
final List<InkStroke> initialStrokes;
const InkCanvasWidget({
required this.width,
required this.height,
required this.onStrokesChanged,
this.readonly = false,
this.initialStrokes = const [],
super.key,
});
@override
State<InkCanvasWidget> createState() => _InkCanvasWidgetState();
}
class _InkCanvasWidgetState extends State<InkCanvasWidget> {
final List<InkStroke> _strokes = [];
InkStroke? _currentStroke;
@override
void initState() {
super.initState();
_strokes.addAll(widget.initialStrokes);
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: CustomPaint(
size: Size(widget.width, widget.height),
painter: InkStrokePainter(
strokes: _strokes,
currentStroke: _currentStroke,
),
child: Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
),
),
);
}
void _onPointerDown(PointerDownEvent event) {
if (widget.readonly) return;
// 兼容手写笔(Stylus)的压力感应
final pressure = event.pressure;
_currentStroke = InkStroke(
id: DateTime.now().millisecondsSinceEpoch.toString(),
points: [InkPoint(
x: event.localPosition.dx / widget.width,
y: event.localPosition.dy / widget.height,
pressure: pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
)],
color: Colors.black,
);
setState(() {});
}
void _onPointerMove(PointerMoveEvent event) {
if (widget.readonly || _currentStroke == null) return;
_currentStroke!.points.add(InkPoint(
x: event.localPosition.dx / widget.width,
y: event.localPosition.dy / widget.height,
pressure: event.pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
));
setState(() {});
}
void _onPointerUp(PointerUpEvent event) {
if (widget.readonly || _currentStroke == null) return;
_strokes.add(_currentStroke!);
_currentStroke = null;
widget.onStrokesChanged(List.unmodifiable(_strokes));
setState(() {});
}
void clearAll() {
_strokes.clear();
_currentStroke = null;
widget.onStrokesChanged([]);
setState(() {});
}
void undo() {
if (_strokes.isEmpty) return;
_strokes.removeLast();
widget.onStrokesChanged(List.unmodifiable(_strokes));
setState(() {});
}
}
// CustomPainter实现贝塞尔曲线平滑渲染
class InkStrokePainter extends CustomPainter {
final List<InkStroke> strokes;
final InkStroke? currentStroke;
const InkStrokePainter({required this.strokes, this.currentStroke});
@override
void paint(Canvas canvas, Size size) {
for (final stroke in [...strokes, if (currentStroke != null) currentStroke!]) {
_drawStroke(canvas, size, stroke);
}
}
void _drawStroke(Canvas canvas, Size size, InkStroke stroke) {
if (stroke.points.length < 2) return;
final paint = Paint()
..color = stroke.color
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
final path = Path();
final pts = stroke.points;
path.moveTo(pts[0].x * size.width, pts[0].y * size.height);
for (int i = 1; i < pts.length - 1; i++) {
final midX = (pts[i].x + pts[i+1].x) / 2 * size.width;
final midY = (pts[i].y + pts[i+1].y) / 2 * size.height;
path.quadraticBezierTo(
pts[i].x * size.width, pts[i].y * size.height, midX, midY
);
paint.strokeWidth = 1.5 + pts[i].pressure * 2.5;
canvas.drawPath(path, paint);
path.reset();
path.moveTo(midX, midY);
}
}
@override
bool shouldRepaint(InkStrokePainter old) =>
strokes != old.strokes || currentStroke != old.currentStroke;
}
E.3 学情报告图表展示
E.3.1 折线图与柱状图实现(fl_chart)
// lib/features/report/widgets/score_trend_chart.dart
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
class ScoreTrendChart extends StatelessWidget {
final List<ScoreRecord> scores;
final String title;
const ScoreTrendChart({
required this.scores,
required this.title,
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(title,
style: Theme.of(context).textTheme.titleMedium),
),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) => FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
),
),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= scores.length) {
return const SizedBox.shrink();
}
return Text(scores[index].dateLabel,
style: const TextStyle(fontSize: 10));
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 35,
getTitlesWidget: (value, meta) =>
Text('${value.toInt()}', style: const TextStyle(fontSize: 10)),
),
),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(
show: true,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
minY: 0,
maxY: 100,
lineBarsData: [
LineChartBarData(
spots: scores.asMap().entries.map((e) =>
FlSpot(e.key.toDouble(), e.value.score)).toList(),
isCurved: true,
color: Theme.of(context).primaryColor,
barWidth: 2,
dotData: FlDotData(
show: true,
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
radius: 4,
color: Colors.white,
strokeWidth: 2,
strokeColor: Theme.of(context).primaryColor,
),
),
belowBarData: BarAreaData(
show: true,
color: Theme.of(context).primaryColor.withOpacity(0.1),
),
),
],
),
),
),
],
);
}
}
E.4 推送通知与消息模块
E.4.1 FCM消息处理(Android/iOS统一)
// lib/core/notification/notification_service.dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FirebaseMessaging _fcm = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
Future<void> initialize() async {
// 请求通知权限
final settings = await _fcm.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
// 获取FCM Token,上传至服务器用于推送
final token = await _fcm.getToken();
if (token != null) {
await _uploadFcmToken(token);
}
// 监听Token刷新
_fcm.onTokenRefresh.listen((newToken) {
_uploadFcmToken(newToken);
});
// 处理前台消息(应用在前台时不自动弹通知,需手动显示)
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
// 处理通知点击(应用在后台时)
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
// 处理应用终止时收到的通知
final initialMessage = await _fcm.getInitialMessage();
if (initialMessage != null) {
_handleNotificationTap(initialMessage);
}
}
// 初始化本地通知(用于前台消息展示)
await _initLocalNotifications();
}
void _handleForegroundMessage(RemoteMessage message) {
final notification = message.notification;
if (notification == null) return;
final notificationType = message.data['type'] ?? 'general';
_showLocalNotification(
id: message.hashCode,
title: notification.title ?? '自然写',
body: notification.body ?? '',
payload: message.data['payload'],
channelId: _getChannelId(notificationType),
);
}
void _handleNotificationTap(RemoteMessage message) {
final type = message.data['type'];
final id = message.data['id'];
switch (type) {
case 'homework_graded':
// 跳转到作业批改结果页
NavigationService.instance.navigateTo('/homework/result/$id');
break;
case 'classroom_invite':
// 跳转到课堂加入页
NavigationService.instance.navigateTo('/classroom/join/$id');
break;
case 'message':
// 跳转到消息详情
NavigationService.instance.navigateTo('/messages/$id');
break;
}
}
Future<void> _initLocalNotifications() async {
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosInit = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
await _localNotifications.initialize(
const InitializationSettings(android: androidInit, iOS: iosInit),
onDidReceiveNotificationResponse: (response) {
if (response.payload != null) {
_handleLocalNotificationTap(response.payload!);
}
},
);
}
Future<void> _showLocalNotification({
required int id,
required String title,
required String body,
String? payload,
String channelId = 'general',
}) async {
final androidDetails = AndroidNotificationDetails(
channelId,
_getChannelName(channelId),
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
await _localNotifications.show(
id, title, body,
NotificationDetails(android: androidDetails, iOS: iosDetails),
payload: payload,
);
}
String _getChannelId(String type) {
switch (type) {
case 'homework_graded': return 'homework';
case 'classroom_invite': return 'classroom';
default: return 'general';
}
}
String _getChannelName(String channelId) {
switch (channelId) {
case 'homework': return '作业通知';
case 'classroom': return '课堂通知';
default: return '通用通知';
}
}
Future<void> _uploadFcmToken(String token) async {
// 上传Token到服务器(用于定向推送)
await ApiClient.instance.post('/api/v1/device/token', {
'token': token,
'platform': Platform.isAndroid ? 'android' : 'ios',
'appVersion': AppInfo.version,
});
}
}
E.5 打卡功能实现
E.5.1 作业打卡连续天数统计
// lib/features/checkin/checkin_service.dart
class CheckinService {
// 贝叶斯知识追踪:根据打卡质量更新知识掌握度
double updateMasteryBKT({
required double currentMastery,
required double quality, // 0.0~1.0 本次打卡质量
}) {
const pTransit = 0.1; // 知识迁移概率
const pSlip = 0.08; // 已掌握却答错(遗忘)概率
const pGuess = 0.2; // 未掌握却答对(猜测)概率
final bool correct = quality > 0.6; // 质量阈值:>60%视为掌握
final pCorrect = currentMastery * (1 - pSlip) + (1 - currentMastery) * pGuess;
double updatedMastery;
if (correct) {
updatedMastery = (currentMastery * (1 - pSlip)) / pCorrect;
} else {
updatedMastery = (currentMastery * pSlip) / (1 - pCorrect);
}
// 叠加知识迁移
return updatedMastery + (1 - updatedMastery) * pTransit;
}
// 计算Leitner间隔复习下次打卡日期
DateTime calcNextReviewDate(int currentBox, DateTime lastReviewDate) {
const boxIntervals = [1, 2, 4, 8, 16, 999]; // 天数间隔
final interval = currentBox < boxIntervals.length
? boxIntervals[currentBox]
: boxIntervals.last;
return lastReviewDate.add(Duration(days: interval));
}
// 计算连续打卡天数
int calcConsecutiveDays(List<DateTime> checkinDates) {
if (checkinDates.isEmpty) return 0;
final sorted = checkinDates
.map((d) => DateTime(d.year, d.month, d.day))
.toSet()
.toList()
..sort((a, b) => b.compareTo(a)); // 降序
int consecutive = 1;
for (int i = 1; i < sorted.length; i++) {
final diff = sorted[i-1].difference(sorted[i]).inDays;
if (diff == 1) {
consecutive++;
} else {
break; // 断链
}
}
return consecutive;
}
}
附录F 接口清单与权限说明
F.1 关键API接口
| 接口路径 | 方法 | 说明 | 认证 |
|---|---|---|---|
| /api/v1/auth/login | POST | 账号密码登录,返回JWT Token | 无 |
| /api/v1/auth/refresh | POST | 刷新JWT Token | Token |
| /api/v1/homework/list | GET | 获取作业列表,支持分页与状态过滤 | Token |
| /api/v1/homework/{id} | GET | 获取作业详情(含批改结果) | Token |
| /api/v1/homework/{id}/submit | POST | 提交作业(上传笔迹OSS链接) | Token |
| /api/v1/ink/upload | POST | 上传笔迹数据(分片上传) | Token |
| /api/v1/report/student | GET | 获取个人学情报告 | Token |
| /api/v1/report/class | GET | 获取班级学情报告(教师权限) | Token |
| /api/v1/classroom/join | POST | 加入课堂(通过课堂码) | Token |
| /api/v1/device/token | PUT | 更新FCM推送Token | Token |
| /api/v1/messages | GET | 获取消息列表(分页) | Token |
| /api/v1/messages/{id}/read | PUT | 标记消息已读 | Token |
F.2 Android权限说明
| 权限 | 用途 | 是否必需 |
|---|---|---|
| INTERNET | 网络请求、上传笔迹数据 | 必需 |
| BLUETOOTH_SCAN | 扫描附近BLE智能笔 | 可选(有笔时必需) |
| BLUETOOTH_CONNECT | 连接BLE智能笔 | 可选(有笔时必需) |
| CAMERA | 拍照上传作业、扫二维码 | 可选 |
| READ_MEDIA_IMAGES | 从相册选择图片 | 可选 |
| POST_NOTIFICATIONS | 接收作业批改通知 | 可选(Android 13+) |
| VIBRATE | 打卡/提交成功震动反馈 | 可选 |
| USE_BIOMETRIC | 指纹/面容解锁应用 | 可选 |
F.3 iOS Info.plist权限说明
| 键名 | 说明 |
|---|---|
| NSBluetoothAlwaysUsageDescription | 连接自然写智能笔,需要访问蓝牙 |
| NSCameraUsageDescription | 拍摄作业照片或扫描课堂码,需要相机权限 |
| NSPhotoLibraryUsageDescription | 从相册选择作业图片 |
| NSFaceIDUsageDescription | 使用Face ID快速登录 |
| NSUserNotificationsUsageDescription | 接收作业批改和课堂提醒通知 |
文档编制:深圳自然写科技有限公司 移动端研发团队
文档版本:V1.0(附录更新)
最后更新:2026年2月14日
版权所有 © 2026 深圳自然写科技有限公司
附录G 性能指标与兼容性
G.1 性能基准测试
| 测试场景 | 设备 | 系统 | 结果 |
|---|---|---|---|
| 冷启动时间 | iPhone 14 Pro | iOS 16 | 1.2秒 |
| 冷启动时间 | Pixel 7 (Tensor G2) | Android 13 | 1.6秒 |
| 作业列表加载(100条) | iPhone 14 | iOS 16 | 180ms |
| 笔迹渲染帧率(BLE书写) | iPad Air 5 | iPadOS 16 | 60fps |
| 笔迹图片上传(单页作业) | WiFi 50Mbps | - | 1.1秒 |
| FCM推送到达延迟 | WiFi环境 | - | < 300ms |
| 离线模式笔迹保存 | - | - | < 10ms/笔画 |
| BLoC状态重建耗时 | 平均 | - | 32ms |
G.2 手机APP支持设备
| 平台 | 最低版本 | 推荐版本 | 必需特性 |
|---|---|---|---|
| Android | Android 7.0 (API 24) | Android 12+ | 蓝牙BLE 4.2+ |
| iOS | iOS 13.0 | iOS 16+ | CoreBluetooth |
G.3 主要第三方依赖
Android (Gradle)
| 依赖 | 版本 | 用途 |
|---|---|---|
| flutter_blue_plus | 1.x | BLE蓝牙连接 |
| flutter_bloc | 8.x | BLoC状态管理 |
| firebase_messaging | 14.x | FCM推送通知 |
| google_mlkit_face_detection | 0.x | 护眼距离检测 |
| sqflite | 2.x | SQLite本地数据库 |
| hive | 2.x | 键值本地存储 |
| fl_chart | 0.x | 数据图表渲染 |
| dio | 5.x | HTTP客户端 |
| flutter_local_notifications | 16.x | 本地通知 |
| image_picker | 1.x | 相机/相册选图 |
G.4 源代码目录结构
lib/
├── main.dart # 应用入口
├── app.dart # MaterialApp配置、路由、主题
├── core/ # 核心模块
│ ├── api/ # HTTP请求封装(Dio拦截器)
│ ├── auth/ # JWT认证管理
│ ├── ble/ # BLE连接管理(PenBleManager)
│ ├── notification/ # FCM推送处理
│ ├── navigation/ # 路由导航服务
│ └── storage/ # 本地存储(Hive + sqflite)
├── features/ # 功能模块(BLoC分层)
│ ├── auth/ # 登录/登出
│ │ ├── bloc/ # LoginBloc, AuthState, AuthEvent
│ │ ├── repository/ # AuthRepository
│ │ └── pages/ # LoginPage, SplashPage
│ ├── homework/ # 作业功能
│ │ ├── bloc/ # HomeworkBloc
│ │ ├── repository/ # HomeworkRepository
│ │ └── pages/ # HomeworkListPage, HomeworkDetailPage
│ ├── classroom/ # 课堂功能
│ │ ├── bloc/ # ClassroomBloc
│ │ └── pages/ # JoinClassroomPage, ClassroomPage
│ ├── report/ # 学情报告
│ │ └── pages/ # ReportPage, StudentReportPage
│ ├── checkin/ # 打卡功能
│ │ └── pages/ # CheckinPage, CheckinHistoryPage
│ └── message/ # 消息中心
│ └── pages/ # MessageListPage, MessageDetailPage
└── shared/ # 通用组件
├── widgets/ # InkCanvas, ScoreChart, AvatarWidget...
└── utils/ # 工具函数、常量定义
本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别,请勿用于其他商业用途。
附录G 补充技术规格
G.1 iOS端Swift实现
G.1.1 CoreBluetooth智能笔连接
// PenBLEManager.swift
import CoreBluetooth
class PenBLEManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
private var centralManager: CBCentralManager!
private var connectedPen: CBPeripheral?
private var inkCharacteristic: CBCharacteristic?
// WritechPen GATT服务UUID
private let SERVICE_UUID = CBUUID(string: "12345678-1234-5678-1234-56789ABCDEF0")
private let INK_CHAR_UUID = CBUUID(string: "12345678-1234-5678-1234-56789ABCDEF1")
var onInkData: (([InkPoint]) -> Void)?
var onConnectionChanged: ((Bool) -> Void)?
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: .main)
}
func startScan() {
guard centralManager.state == .poweredOn else { return }
centralManager.scanForPeripherals(
withServices: [SERVICE_UUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == .poweredOn { startScan() }
}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard let name = peripheral.name, name.hasPrefix("WritechPen") else { return }
central.stopScan()
connectedPen = peripheral
connectedPen?.delegate = self
central.connect(peripheral, options: nil)
}
func centralManager(_ central: CBCentralManager,
didConnect peripheral: CBPeripheral) {
peripheral.discoverServices([SERVICE_UUID])
onConnectionChanged?(true)
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverServices error: Error?) {
peripheral.services?.first?.let { service in
peripheral.discoverCharacteristics([INK_CHAR_UUID], for: service)
}
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let char = service.characteristics?.first(where: { $0.uuid == INK_CHAR_UUID }) {
inkCharacteristic = char
peripheral.setNotifyValue(true, for: char)
}
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let data = characteristic.value else { return }
let points = parseInkData(data)
onInkData?(points)
}
private func parseInkData(_ data: Data) -> [InkPoint] {
var points: [InkPoint] = []
var offset = 0
while offset + 10 <= data.count {
let x = Float(UInt16(data[offset]) << 8 | UInt16(data[offset+1])) / 65535.0
let y = Float(UInt16(data[offset+2]) << 8 | UInt16(data[offset+3])) / 65535.0
let pressure = Float(data[offset+4]) / 255.0
points.append(InkPoint(x: x, y: y, pressure: pressure))
offset += 10
}
return points
}
}
G.2 推送通知实现
// NotificationManager.swift
import UserNotifications
class NotificationManager {
func requestPermission() async -> Bool {
let center = UNUserNotificationCenter.current()
do {
return try await center.requestAuthorization(
options: [.alert, .badge, .sound]
)
} catch {
return false
}
}
func scheduleHomeworkReminder(homework: Homework) {
let content = UNMutableNotificationContent()
content.title = "作业提醒"
content.body = "《\(homework.title)》截止时间:\(homework.deadline.formatted())"
content.sound = .default
content.badge = 1
// 截止前2小时提醒
let triggerDate = homework.deadline.addingTimeInterval(-7200)
let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute], from: triggerDate)
let trigger = UNCalendarNotificationTrigger(
dateMatching: components, repeats: false)
let request = UNNotificationRequest(
identifier: "homework_\(homework.id)",
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request)
}
}
G.3 Android端ViewModel架构
// HomeworkViewModel.kt
class HomeworkViewModel(
private val homeworkRepo: HomeworkRepository
) : ViewModel() {
private val _homeworkList = MutableStateFlow<List<Homework>>(emptyList())
val homeworkList: StateFlow<List<Homework>> = _homeworkList.asStateFlow()
private val _uiState = MutableStateFlow<UiState>(UiState.Idle)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadHomework(courseId: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
homeworkRepo.getHomeworkList(courseId)
.collect { list ->
_homeworkList.value = list
_uiState.value = UiState.Success
}
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "加载失败")
}
}
}
fun submitHomework(homeworkId: String, inkData: ByteArray) {
viewModelScope.launch {
_uiState.value = UiState.Uploading
try {
homeworkRepo.submitHomework(homeworkId, inkData)
_uiState.value = UiState.Submitted
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "提交失败")
}
}
}
sealed class UiState {
object Idle : UiState()
object Loading : UiState()
object Uploading : UiState()
object Success : UiState()
object Submitted : UiState()
data class Error(val message: String) : UiState()
}
}
附录H 补充技术规格
H.1 图片压缩上传
// ImageCompressUploader.kt
class ImageCompressUploader(private val apiService: ApiService) {
companion object {
const val MAX_SIZE_BYTES = 2 * 1024 * 1024 // 2MB
const val INITIAL_QUALITY = 90
}
suspend fun compressAndUpload(
uri: Uri,
context: Context,
targetUrl: String
): UploadResult = withContext(Dispatchers.IO) {
val bitmap = BitmapFactory.decodeStream(
context.contentResolver.openInputStream(uri))
var quality = INITIAL_QUALITY
var compressedBytes: ByteArray
// 循环压缩直到文件大小≤2MB
do {
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, baos)
compressedBytes = baos.toByteArray()
quality -= 10
} while (compressedBytes.size > MAX_SIZE_BYTES && quality > 10)
// 上传
val requestBody = compressedBytes.toRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("file", "homework.jpg", requestBody)
apiService.uploadImage(targetUrl, part)
}
}
H.2 深色模式适配
// ThemeManager.kt
object ThemeManager {
fun applyTheme(context: Context) {
val nightMode = AppCompatDelegate.getDefaultNightMode()
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
val setting = sharedPrefs.getString("theme", "system")
val mode = when (setting) {
"light" -> AppCompatDelegate.MODE_NIGHT_NO
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
AppCompatDelegate.setDefaultNightMode(mode)
}
fun isDarkMode(context: Context): Boolean {
return (context.resources.configuration.uiMode
and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
}
}
H.3 无障碍支持
// AccessibilityHelper.kt
object AccessibilityHelper {
fun setupContentDescriptions(views: Map<View, String>) {
views.forEach { (view, description) ->
view.contentDescription = description
// 对于自定义View额外设置accessibility delegate
ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(
host: View, info: AccessibilityNodeInfoCompat) {
super.onInitializeAccessibilityNodeInfo(host, info)
info.contentDescription = description
}
})
}
}
fun announceForAccessibility(view: View, message: String) {
view.announceForAccessibility(message)
}
}
本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别,请勿用于其他商业用途。