资讯动态

从Android书法学习APP源码看SQLite、自定义View与笔画评分实现

发布时间:2026/9/12 0:41:35 来源:尧图企业网站定制
简介一套基于安卓平台的书法学习APP项目资源面向书法爱好者、移动开发学习者及毕业设计选题学生整体属于Java技术栈的管理系统类应用。应用覆盖不同书体的基础知识、笔画技巧、名家作品欣赏等内容配有互动式教程、个性化学习计划与进度跟踪功能用户能根据自身水平设定目标在移动端系统完成书法入门与进阶学习过程直观且可量化。资源共5个文件、约11.8MB包括3个zip压缩包分别对应项目源码、设计开发文档和答辩PPT另有1个sql数据库脚本与1个mp4演示视频形成从数据库表设计、Android功能实现到演示说明的完整闭环sql脚本便于快速建表并导入初始数据演示视频清晰呈现APP界面与核心操作流程。目前已有81人学习下载借助这些内容读者既可深入学习Android客户端开发、数据库交互与管理功能实现也可借鉴项目的模块划分、文档结构及答辩演示思路用于课程设计、毕业设计或实际二次开发。1. 从一份Android书法学习APP源码聊起拿到这个zip的时候我以为是普通的课设——里面躺着shufa.sql、shufa.zip代码、PPT和演示视频。真跑起来才发现它把书法课拆成了“看、练、测”三个闭环看名家作品和笔画演示跟着轨迹用触屏练字再由进度模块告诉你下一步该学什么。对刚接触Android开发的人来说这是一份能看清SQLite数据流、自定义View和媒体播放如何协作的完整样例对做在线素质教育的团队它也可以充当学习工具的底稿。工程量不大但涉及文件组织、数据库设计、事件触摸和状态持久化值得拆开读。下面先把工程跑通再深入代码。2. 项目结构拆解与 Android Studio 下的快速复现2.1 工程目录与模块划分解压shufa.zip后最能说明问题的是app/src/main下的目录划分。常见做法是按照“入口—数据—视图”来组织包名shufa/ ├── app/ │ ├── build.gradle │ └── src/main/ │ ├── java/com/example/shufa/ │ │ ├── activity/ # 主界面、教学页面和学习计划页 │ │ ├── adapter/ # RecyclerView和ViewPager的适配器 │ │ ├── database/ # SQLiteOpenHelper与DAO │ │ ├── view/ # 自定义笔画View和进度View │ │ └── util/ # 视频路径解析、资源读取等工具 │ ├── res/ │ │ ├── layout/ # XML布局 │ │ └── assets/ # 字体、作品图片、笔画演示视频 │ └── AndroidManifest.xml ├── shufa.sql # 数据库初始化脚本 └── build.gradle这个结构不算复杂但边界清楚activity负责跳转和生命周期adapter只做数据到视图的绑定database里封装所有SQL操作view里放需要手动绘制的控件。我从老项目上总结的经验是千万别在Activity里直接写SQLiteDatabase否则后面加一个“继续学习”功能就得翻遍整个类。这个工程里DAO抽得不错读起来不累。2.2 导入Android Studio与Gradle对齐把工程导入Android Studio时最容易卡住的是Gradle版本不匹配。我一般先打开gradle/wrapper/gradle-wrapper.properties把distributionUrl改成自己本机的版本如果不想动本地Gradle也可以直接改。对应地app/build.gradle里的AGP版本要跟Gradle版本配合比如我常用的组合// app/build.gradle android { compileSdkVersion 33 defaultConfig { applicationId com.example.shufa minSdkVersion 21 targetSdkVersion 33 versionCode 1 versionName 1.0 } } dependencies { implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 implementation androidx.recyclerview:recyclerview:1.3.0 }配置里compileSdkVersion要跟本机已安装的SDK平台一致否则AS会提示下载或报错。minSdkVersion用了21这意味着项目在Android 5.0以上都能跑也代表代码里没有用太新的API。如果你在Android 13上看不到某些文件不要奇怪那是targetSdkVersion33下的分区存储行为跟工程本身无关。同步完成后按下面这个表验证环境是否跑通步骤操作预期结果1用Android Studio打开解压后的shufa目录右上角没有红色Sync错误2选择模拟器或开启USB调试的真机Run按钮变成可点击3点击Run等待安装首页出现书体列表而不是白屏4点击任意书体进入教学页能显示作品图片并播放笔画视频如果步骤3失败先看Logcat里的异常再动手。最常见的两类一是ClassNotFoundException说明某个类没有打包进去二是java.io.FileNotFoundException多半是assets里的视频路径写错导致MediaPlayer没有找到资源。如果你遇到的是“SDK location not found”那说明工程里缺少local.properties文件。在AS里解决最快的方式是手动在这个文件里指定SDK路径sdk.dirC\:\\Users\\yourname\\AppData\\Local\\Android\\Sdk注意Windows下需要把反斜杠转义成双反斜杠或者直接使用正斜杠C:/Users/yourname/AppData/Local/Android/Sdk。把这个文件放到工程根目录后重新SyncAS就能找到SDK了。2.3 权限与资源文件注意事项主页能够跑起来不代表播放视频时不出问题。Android 6.0以后如果APP要读外部存储或访问网络需要在运行时申请权限。这个项目的教学资源大多放在assets目录所以不需要额外权限但个别演示页会把资源路径存在SQLite里然后拼接成android.resourceURI这时候要注意路径格式。uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 /maxSdkVersion32的意思是Android 12及以下才需要这个权限Android 13里替代方案是READ_MEDIA_IMAGES。这个工程的演示视频是MP4、H.264编码大部分模拟器都能硬解。你要是换成其他格式记得转码后再丢进assets否则部分真机上的VLC内核支持不好画面会卡在首帧。3. 数据库设计从shufa.sql到SQLite的数据流3.1 建表语句与字段设计书法学习APP的数据库核心是“作品—笔画—进度”三张表。用学生交上来的shufa.sql作为起点我一般会保留如下结构CREATE TABLE calligraphy_work ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT, dynasty TEXT, image_path TEXT, video_path TEXT ); CREATE TABLE stroke_lesson ( id INTEGER PRIMARY KEY AUTOINCREMENT, work_id INTEGER, step_no INTEGER, stroke_name TEXT, video_path TEXT, desc TEXT, FOREIGN KEY (work_id) REFERENCES calligraphy_work(id) ); CREATE TABLE user_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER DEFAULT 1, lesson_id INTEGER, completed INTEGER DEFAULT 0, score REAL, update_time INTEGER );calligraphy_work存某位书法家的作品信息stroke_lesson存每个字的笔画教学视频和顺序user_progress则记录用户看到哪一课、练得怎么样。为什么把图片和视频路径直接存数据库因为路径只是一个字符串将来想换成网络URL只需要改数据库里的内容不动Java代码。3.2 SQLiteOpenHelper与DAO层的封装在Android里直接执行.sql文件并不方便常见做法是把shufa.sql里的建表语句整理成db.execSQL(...)在onCreate时执行。public class ShufaDbHelper extends SQLiteOpenHelper { private static final String DB_NAME shufa.db; private static final int DB_VERSION 1; public ShufaDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE IF NOT EXISTS calligraphy_work (...)); db.execSQL(CREATE TABLE IF NOT EXISTS stroke_lesson (...)); db.execSQL(CREATE TABLE IF NOT EXISTS user_progress (...)); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 这里不能直接DROP用户进度会被清掉 if (oldVersion 2) { db.execSQL(ALTER TABLE user_progress ADD COLUMN practice_count INTEGER DEFAULT 0); } } }上面的onUpgrade只做增量升级保留历史数据。我们实际做版本升级时最怕就是拍脑袋DROP TABLE用户辛辛苦苦练到第18课一更新就回到起点。正确姿势是先判断oldVersion再逐列添加或迁移。如果你更想让首屏启动快一点可以把已经生成好的shufa.db文件放进assets目录在启动时复制到私有目录。这个做法适合数据量较大、不想逐条插入的情况File dst new File(getDatabasePath(shufa.db).getPath()); if (!dst.exists()) { AssetManager am getAssets(); InputStream is am.open(shufa.db); FileOutputStream fos new FileOutputStream(dst); byte[] buffer new byte[8192]; int len; while ((len is.read(buffer)) ! -1) { fos.write(buffer, 0, len); } fos.close(); is.close(); }复制完后继续使用getDatabasePathSQLiteOpenHelper会用这个已存在的文件。这里有个小坑复制操作要尽量放在子线程不然首次启动时读MP4和图片容易在主线程卡出ANR。查询列表的代码同样集中在DAO里public ListCalligraphyWork getWorks() { ListCalligraphyWork list new ArrayList(); SQLiteDatabase db getReadableDatabase(); Cursor cursor db.rawQuery(SELECT * FROM calligraphy_work, null); while (cursor.moveToNext()) { CalligraphyWork work new CalligraphyWork(); work.id cursor.getLong(cursor.getColumnIndexOrThrow(id)); work.title cursor.getString(cursor.getColumnIndexOrThrow(title)); work.imagePath cursor.getString(cursor.getColumnIndexOrThrow(image_path)); list.add(work); } cursor.close(); db.close(); return list; }cursor用完后必须close否则在多次复用时会看到“attempt to re-open an already-closed object”的崩溃。dao里不要长期持有cursor返回强类型的List更干净。如果你用的是getColumnIndex而不是getColumnIndexOrThrow遇到重名字段会返回-1拿不到数据先检查这里。3.3 assets资源与数据库的衔接数据库里的image_path、video_path一般存的是相对路径比如assets/works/lanting.jpg或直接works/lanting.jpg。在Activity里读取时要明确路径前缀// 从assets读取视频而不是直接new File() AssetManager am getAssets(); AssetFileDescriptor afd am.openFd(works/lanting.mp4); MediaPlayer player new MediaPlayer(); player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); player.prepare();注意assets目录下的文件会压缩使用openFd前需要确认视频未经过压缩处理否则会报VLC is not supported之类的底层错误。一个折中方案是把大视频放到res/raw下用Uri.parse(android.resource:// getPackageName() / R.raw.lanting)来播放。两者选一即可项目里已经用assets就不要再混用res/raw。4. 笔画展示、练习与进度跟踪的实现4.1 自定义笔画View与路径绘制书法学习APP最核心的展示是笔画动画。工程里用自定义View实现思路是把一笔画拆成一组坐标点在onDraw中用Path逐点连起来。public class StrokeStrokeView extends View { private Paint paint new Paint(); private Path path new Path(); public void setStrokePoints(float[] points) { path.reset(); path.moveTo(points[0], points[1]); for (int i 2; i points.length; i 2) { path.lineTo(points[i], points[i 1]); } invalidate(); } Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); paint.setAntiAlias(true); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(8f); paint.setStrokeCap(Paint.Cap.ROUND); canvas.drawPath(path, paint); } }points数组按x、y交替存储第一对坐标是起笔后面依次是运笔经过的位置。这个方法的巧妙之处在于它不关心坐标来自数据库还是用户手指只要喂进来一组点就能画。真正用于生产时我建议把坐标归一化到0到1之间再在onMeasure里按View宽高缩放避免不同屏幕分辨率下笔画错位。4.2 触屏采集用户轨迹与练习评分有了标准笔画展示练习模式就需要采集手势。通常在onTouchEvent里记录Move事件的位置并且做降采样不然每帧几十个点会导致后续计算太慢。Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: path.moveTo(event.getX(), event.getY()); break; case MotionEvent.ACTION_MOVE: path.lineTo(event.getX(), event.getY()); break; case MotionEvent.ACTION_UP: saveUserTrajectory(); break; } invalidate(); return true; }ACTION_DOWN是落笔ACTION_MOVE是运笔ACTION_UP是收笔。return true表示消费掉整个手势流否则后续Move收不到。收笔时需要把整条轨迹交给评分逻辑也就是下面要说的进度和分数。评分的第一步是重采样让用户轨迹和标准轨迹具有相同数量的点。不重采样直接计算距离会受笔速影响写得快采样点稀疏写得慢采样点密集。我一般会把每条轨迹统一重采样成128个点再做归一化然后计算平均距离private double computeError(Point[] standard, Point[] user) { double sum 0; for (int i 0; i standard.length; i) { sum Math.hypot(standard[i].x - user[i].x, standard[i].y - user[i].y); } return sum / standard.length; }Math.hypot是对sqrt(x^2 y^2)的稳定实现精度比直接开方更高。这个误差值可以映射成0到100分比如误差小于20像素给90分小于50给70分否则给50分以下。实际阈值要看View的尺寸最好的方式是在真机上多测几次再定。4.3 个性化学习计划与进度存储用户完成练习后分数和完成状态写入user_progress表ContentValues values new ContentValues(); values.put(completed, 1); values.put(score, score); values.put(update_time, System.currentTimeMillis()); db.update(user_progress, values, lesson_id? AND user_id?, new String[]{String.valueOf(lessonId), String.valueOf(userId)});这里用update而不是先查再插入是因为user_progress在用户点开课程时就会先插入一条空白记录练习完成时更新比分。若存在并发写入的场景比如从两个页面同时更新进度需要开启事务。常见做法是SQLiteDatabase db helper.getWritableDatabase(); db.beginTransaction(); try { // 多个update操作 db.setTransactionSuccessful(); } finally { db.endTransaction(); }个性化学习计划的逻辑是基于未完成列表按step_no排序取课程。比如用户选择“行书”后查询calligraphy_work里title匹配行书的所有课再左连接user_progress过滤掉已完成项。SQL大致是SELECT * FROM stroke_lesson WHERE work_id ? AND id NOT IN ( SELECT lesson_id FROM user_progress WHERE user_id ? AND completed 1 ) ORDER BY step_no ASC;这个查询在数据量不大时完全够用。如果以后课程超过1000节就要给user_progress(lesson_id, user_id)建联合索引否则每次计划页都会走全表扫描。5. 进阶笔画相似度验证与后台任务技巧想要让APP真正评价“写得像不像”不能只判断用户画了多少个点。常见做法是先把标准笔画和用户轨迹都重采样成固定长度再用动态时间规整DTW计算两段轨迹的差异。DTW在书法评分里表现稳定而且Java实现只要十几行public static double dtwDistance(double[] a, double[] b) { int n a.length, m b.length; double[][] dp new double[n 1][m 1]; for (int i 0; i n; i) { Arrays.fill(dp[i], Double.MAX_VALUE); } dp[0][0] 0; for (int i 1; i n; i) { for (int j 1; j m; j) { double cost Math.abs(a[i - 1] - b[j - 1]); dp[i][j] cost Math.min(dp[i - 1][j], Math.min(dp[i][j - 1], dp[i - 1][j - 1])); } } return dp[n][m]; }使用前要把轨迹坐标去除均值重心归零再统一缩放到相同范围否则同样的字写大一点、写小一点分数都会偏差很大。实际项目里我会分别对x、y序列计算DTW距离然后加权合并成0到100的得分。比如归一化后整体距离小于3.0时给85分以上3.0到5.0之间给70分超过5.0提示重写。这个阈值不是拍脑袋定的需要拿几组不同水平的轨迹去试。另一个实用技巧是用WorkManager做进度防丢。如果用户练完一个笔画直接锁屏Activity里的保存动作可能执行不完。用WorkManager可以保证任务会在系统合适的时候执行OneTimeWorkRequest upload new OneTimeWorkRequest.Builder(ProgressWorker.class) .setInputData(new Data.Builder() .putLong(lessonId, lessonId) .build()) .build(); WorkManager.getInstance(context).enqueue(upload);后台做完同步工作进度表不易遗漏同时也可以把进度备份到云端。验证相似度算法是否合理可以录几段自己用不同速度写的字观察得分是否符合直觉——写得快但结构规整分数应该高于潦草的快速轨迹。最后一个交付细节别忘了在混淆规则里保留SQLite实体类和自定义View的字段名否则发布版会丢失数据映射出现点击“继续学习”白屏的问题。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价