news 2026/10/20 19:08:10

安卓入门练手项目:SQLite记事本源码的增删改查与列表刷新

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
安卓入门练手项目:SQLite记事本源码的增删改查与列表刷新

最近几年老有人问我,说“安卓入门练手项目到底做什么好”。我给的答案一直没变过:做一个本地SQLite的记事本。倒不是因为它简单,而是因为它把安卓开发里最绕不开的那几件事全串起来了——界面布局、数据库增删改查、列表刷新、页面跳转传值。这个“Android studio源码,记事本,可做备忘录”的定位,看起来确实是纯安卓项目再加上本地数据库SQLite,但真正吃透这套代码,比你去做十个云同步笔记要有价值得多。

这篇文章就打算从源码实现的角度,把这个记事本项目拆开讲一遍。适合三种人看:刚学完安卓基础、想找个完整项目练手的同学;需要交课程设计、想拿记事本做底子的同学;以及那些想搞清楚SQLite到底怎么用得体、不想只会跑通Demo的开发者。

1. 技术选型的底气:为什么偏要本地SQLite,而不是直接上Room或云同步

1.1 这个项目要解决的其实是三类场景

拿到一个记事本需求,很多人第一反应是“这不就四个操作吗,新增、修改、删除、查询”。没错,业务逻辑确实很简单,但简单不代表没有设计空间。真实场景里,记事本要承担的事情比表面看起来多一层。

第一类场景是随手记录。打开应用、写一句话、保存,然后关掉。这个场景要求的是启动快、保存快、不弹什么乱七八糟的登录框。第二类场景是把记事本当备忘录用,写下待办事项、灵感片段、购物清单,过几天再翻出来看。这要求列表有序、内容不丢、能改能删。第三类场景是把它当学习项目,拿来研究代码结构。这一点其实最容易被忽略——一个结构清晰、命名规范、CRUD完整的本地数据库项目,比一百个只贴了截图的博客教程都有用。

SQLite在以上所有场景里都站得住。数据存在应用私有目录下,不需要网络权限,不会出现“服务器挂了笔记全没了”这种尴尬。单文件数据库对桌面端开发者来说可能觉得寒酸,但在移动端,单机能搞定的事,真没必要绕一圈上云。

1.2 把“纯安卓 + SQLite”当成一种选择哲学

有人会问:现在官方都推荐Room了,为什么还要去写原生SQLiteOpenHelper?

这个问题不奇怪,但问的人多半没经历过从零搭项目的完整链路。Room是在SQLite之上封了一层ORM,它能帮你省掉大量模板代码,没错。可正因为省掉了模板代码,很多人写完一个Room项目,对底层到底发生了什么完全没概念——不知道Cursor是什么,不明白selectionArgs为什么要用问号占位,更不清楚为什么不能在主线程直接执行数据库操作。

纯SQLite项目把这些“被遮住的东西”全部摊开在面前。你会亲手写CREATE TABLE语句,会亲手拼SQL(好的写法是用占位符),会亲手解析Cursor拿到的每一行数据。正因为如此,当有一天你切换到Room或者别的ORM框架时,你看到报错信息是能读懂的,因为你知道它在底下最终就是把你的操作翻译成SQL。

另外,“纯安卓”这个定语也值得琢磨。没有跨平台框架、没有网络依赖、没有第三方库夹带,拿到代码之后,只要Android Studio能打开就能编译运行。作为教学项目和课程设计来说,可控性非常重要。评审老师问一句“你讲讲这个项目的架构”,你能从数据库一路讲到Activity,而不是只能说“我调用了一个第三方库”。

2. 数据库骨架:表结构、字段设计与升级思路

2.1 SQLiteOpenHelper:整个项目里最不能偷懒的类

记事本源码里,数据库层的核心一般只有一个类,继承SQLiteOpenHelper。很多初学者会把所有SQL语句都写在这个类里,这不算错,但项目稍微大一点就会乱。比较合理的做法是:这个类只管数据库本身的创建和升级,具体到某张表的增删改查,单独抽一个数据库操作类出来。

先看数据库创建这块最基本的样子:

public class NoteDbHelper extends SQLiteOpenHelper { private static final String DB_NAME = "note.db"; private static final int DB_VERSION = 1; public NoteDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE IF NOT EXISTS note (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "title TEXT, " + "content TEXT, " + "created_time TEXT, " + "updated_time TEXT)"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 开发期常见的偷懒写法:直接删表重建 // db.execSQL("DROP TABLE IF EXISTS note"); // onCreate(db); } }

这段代码有几个地方值得细说。首先是数据库版本号DB_VERSION,正式项目里这个变量非常关键,因为它直接决定onUpgrade什么时候触发。只要版本号变大,系统就会自动调用onUpgrade,把旧版本数据迁移到新版本。

其次是IF NOT EXISTS。开发期反复装测试包时,如果表已经存在,这句SQL不会报错。很多人第一次写SQLite代码时没加这个,结果重装应用后崩溃,还以为是代码逻辑问题,其实只是建表语句重复执行了。

2.2 字段为什么这样定:主键自增、时间双字段、内容不设长度

表结构设计这块,不同源码的写法差别很大。但我个人建议在记事本项目里保持四个字段起步:id、title、content、created_time,再加一个updated_time。为什么不是只存一个时间?

核心原因是编辑场景。记事本里的内容不是一次写死就再不动了,用户改一次,时间就该更新一次。如果只保存created_time,列表排序的时候永远按创建时间排,用户把一条旧笔记改了一版翻新内容,它还是沉在底下,体验很违和。另存一个updated_time之后,列表按updated_time倒序排列,刚改过的笔记自动浮到最上面,这才符合真实的使用直觉。

主键id用INTEGER PRIMARY KEY AUTOINCREMENT,这是SQLite里最省心的自增主键写法。每次插入一条数据,这个字段会自动加一,完全不用手动管。很多人写代码时会在插入语句里把id也传进去,这是新手很常见的错误。要知道自增主键的含义,就是让数据库自己决定编号,你非要指定一个值,反而可能撞上已存在的id导致插入失败。

content字段我建议直接TEXT,不要写成VARCHAR(500)或者VARCHAR(1000)。SQLite的TEXT类型本身几乎不限制长度,记事本的内容长度谁也说不准,可能是一句话,也可能是一整篇文章。你在建表时给内容预设一个上限,等于给自己埋了一个以后必踩的坑。

2.3 onUpgrade的取舍:大多数源码都不做的事

再说一下onUpgrade。很多拿来练手的源码,在升级数据库版本时会直接执行DROP TABLE再重建,注释里写着“开发期无所谓”,然后把这段代码原样丢给你。

如果只是自己练习,这种写法确实方便。但既然标题里写了“源码”,就意味着有人会拿去当成正式项目的地基。我建议至少把结构改成这样:当数据库结构确实发生改变时,在onUpgrade里对表做ALTER TABLE或者数据迁移,而不是无脑删表。

@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE note ADD COLUMN tag TEXT DEFAULT ''"); } }

这样做的好处是用户升级App后,之前写过的笔记一条都不会丢。本地记事本类应用的命根子就是那几十上百条笔记数据,升级一次全清空,用户不骂你才怪。虽然课程设计里评审老师未必会升级你的App,但这一行代码体现的工程意识,是能把项目档次拉开的关键。

3. 把界面和数据连起来:列表页、编辑页与点击交互链路

3.1 主界面列表加载顺序

记事本项目一般只有两个页面,一个用来展示笔记列表,一个用来编辑单条笔记。列表页的加载链路看起来简单,写的时候却经常出问题。

标准流程是:进入MainActivity → 在onCreate里先setContentView → 找到RecyclerView的实例 → 查询数据库拿到全部笔记的List → 创建Adapter并setAdapter。代码大致长这样:

public class MainActivity extends AppCompatActivity { private RecyclerView recyclerView; private NoteAdapter adapter; private NoteDao noteDao; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); noteDao = new NoteDao(this); recyclerView = findViewById(R.id.recyclerView); recyclerView.setLayoutManager(new LinearLayoutManager(this)); loadNoteList(); } private void loadNoteList() { List<Note> noteList = noteDao.queryAll(); adapter = new NoteAdapter(noteList); recyclerView.setAdapter(adapter); } }

注意看loadNoteList()这个方法是在onCreate里被调用的。它内部干了三件事:查询全部数据、创建适配器、绑定到RecyclerView。这种写法的好处是代码逻辑一目了然,后续如果加了下拉刷新或者搜索框,只需要再调用一遍loadNoteList即可完成页面刷新。

有一点容易出错:如果你在这个方法里每次都new一个新的Adapter,那刷新页面时列表的滚动位置会回到顶部。这是因为RecyclerView认为你换了一个全新的适配器,之前的滚动状态全部丢失。更好的做法是只在第一次初始化时new Adapter,后续刷新数据直接用同一个adapter实例调notifyDataSetChanged。

3.2 Intent传参与编辑页回填

列表点击事件一般是跳转到编辑页。这里有一个常规设计:点击列表里的某条笔记时,把这个笔记的id通过Intent传给编辑页,编辑页根据id去数据库里查详细内容,再回填到输入框。传id而不是把整个Note对象传过去,好处是避免序列化整个对象的开销,而且在编辑页可以拿到数据库里最新状态的数据,而不是列表页那一刻的旧快照。

跳转写法大致如下:

Intent intent = new Intent(MainActivity.this, EditActivity.class); intent.putExtra("note_id", note.getId()); startActivity(intent);

在编辑页里,onCreate内部先取Intent里的id,然后判断id是否存在,以此决定当前是“新增模式”还是“编辑模式”。

long noteId = getIntent().getLongExtra("note_id", -1L); if (noteId != -1L) { Note note = noteDao.queryById(noteId); if (note != null) { etTitle.setText(note.getTitle()); etContent.setText(note.getContent()); } }

用-1作为默认值是个很实用的小套路。因为正常数据库里的自增id从1开始,永远不会有负数。-1这个默认值一旦出现,就说明Intent里没有带note_id,也就是用户想新建一条笔记。这样就不用额外传一个布尔值来表示新增还是编辑,少一条数据就少一个出错可能。

3.3 点击保存时到底是insert还是update的判断逻辑

保存按钮的处理逻辑是整个交互链路最关键的一环。很多新手会复制两份代码:一份是插入,一份是更新,然后通过if else去判断走哪个分支。这种做法能用,但代码冗余,而且很容易出现两个分支都漏改字段的情况。

推荐写法是把增和改统一在一段逻辑里,只针对id做分流:

public void onSave(View view) { String title = etTitle.getText().toString().trim(); String content = etContent.getText().toString().trim(); String now = getCurrentTime(); long noteId = getIntent().getLongExtra("note_id", -1L); if (noteId == -1L) { // 新增模式 Note note = new Note(); note.setTitle(title); note.setContent(content); note.setCreatedTime(now); note.setUpdatedTime(now); noteDao.insert(note); } else { // 编辑模式 Note note = noteDao.queryById(noteId); note.setTitle(title); note.setContent(content); note.setUpdatedTime(now); noteDao.update(note); } finish(); }

看到没有,新增和编辑都是在构造一个Note对象然后调DAO,区别只在于新增要额外设置创建时间,编辑会保留原来的创建时间。这个设计背后的逻辑是:编辑不能让一条笔记的“出生时间”变掉,只能改“最近修改时间”。很多人做编辑页时把created_time也一起update了,这属于逻辑理解错误,虽然测试时不容易发现,但数据一对比就露馅。

4. CRUD的代码级拆解:从DAO方法到UI调用的每一环

4.1 插入和编辑共用一份代码

DAO(Database Access Object)这个层在小型记事本项目里不是必须的,但有了它,整个项目的结构会清爽很多。NoteDao负责所有SQLite操作,Activity页面只关心业务跳转和界面刷新,两者各干各的活,互不干扰。

插入方法的实现,要用ContentValues而不是写死SQL字符串:

public long insert(Note note) { SQLiteDatabase db = dbHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("title", note.getTitle()); values.put("content", note.getContent()); values.put("created_time", note.getCreatedTime()); values.put("updated_time", note.getUpdatedTime()); long rowId = db.insert("note", null, values); db.close(); return rowId; }

这里为什么用db.insert而不是db.execSQL?因为execSQL需要你把SQL语句拼成字符串,而手工拼接SQL是SQL注入和语法错误的重灾区。ContentValues本质上是一个键值对容器,insert方法会把它转成安全的SQL语句,字段名对应key,值对应value,不需要自己去处理引号转义。

db.close()这句在简单项目里通常可以省略,因为SQLiteDatabase在应用退出时会自动释放连接。但如果在长列表页频繁开关数据库,而不手动close,连接会累积到内存里不释放。严谨的项目里,用完即关是一个好习惯。

4.2 查询列表时最容易写的低效代码

查询全部笔记,大多数源码里长这样:

public List<Note> queryAll() { List<Note> noteList = new ArrayList<>(); SQLiteDatabase db = dbHelper.getReadableDatabase(); Cursor cursor = db.query("note", null, null, null, null, null, "updated_time DESC"); while (cursor.moveToNext()) { Note note = new Note(); note.setId(cursor.getLong(cursor.getColumnIndexOrThrow("id"))); note.setTitle(cursor.getString(cursor.getColumnIndexOrThrow("title"))); note.setContent(cursor.getString(cursor.getColumnIndexOrThrow("content"))); note.setCreatedTime(cursor.getString(cursor.getColumnIndexOrThrow("created_time"))); note.setUpdatedTime(cursor.getString(cursor.getColumnIndexOrThrow("updated_time"))); noteList.add(note); } cursor.close(); db.close(); return noteList; }

注意query方法的最后一个参数"updated_time DESC",意思是按最后修改时间倒序排列。这个参数虽小,但决定了列表页的信息流展示逻辑。

新手最容易写出的替代方案是:先把数据全查出来放进List,然后自己写一个Collections.sort比较器,或者更夸张的,在Page里双层for循环做排序。这些都能实现,但要么代码啰嗦,要么效率差。SQLite索引排序的性能比你Java代码层排序快,而且一行参数就能搞定,为什么不直接用。

Cursor用完了一定要关闭。这句话我在不同项目里说了很多遍,因为Cursor持有的是数据库底层读取结果的引用,不关闭会导致内存泄漏。写代码时可能感觉不到,但当你反复进出列表页时,内存占用会稳定爬升,直到某一次触发OOM。

4.3 删除时的物理删除与软删除之争

记事本项目的删除操作,最直接的实现是:

public int deleteById(long id) { SQLiteDatabase db = dbHelper.getWritableDatabase(); int rows = db.delete("note", "id = ?", new String[]{String.valueOf(id)}); db.close(); return rows; }

"id = ?"配合new String[]{...}是一个典型的预编译写法,问号是占位符,后面数组里的值和问号按顺序对应。这样做的好处是防止SQL注入,因为用户输入的内容永远不会被直接拼接进SQL语句。

不过这里我想说一个容易被忽略的点:纯物理删除会把用户所有痕迹清干净,对记事本来说勉强可以接受,但如果是备忘录性质的应用,建议增加一个is_delete字段做软删除。软删除的意思是,删除操作只做一个标记,数据行还在数据库里,只是列表页查询时默认过滤掉已删除的数据。好处是用户误删之后,还有机会恢复数据。很多成熟的云笔记产品里,“最近删除”功能就是这么实现的。纯本地SQLite项目里同样可以做,只需要在SELECT语句里加一个WHERE is_delete = 0,代码工作量不大,但功能完整度会高一个档次。

5. 这段源码里最容易翻车的几个位置

5.1 EditText自动弹键盘和焦点抢夺

记事本项目最大的交互问题是,一进入编辑页,输入框会自动获得焦点,然后软键盘立刻弹出来。如果你在看一篇长文案,或者只是想回看一下之前的内容,这个自动弹出的键盘就非常烦人。

原因是XML布局里EditText如果写了focusable相关的属性,系统会在页面渲染完成后,默认把焦点给第一个可输入控件。解决办法是在根布局里加一行配置:

<LinearLayout android:focusable="true" android:focusableInTouchMode="true"> </LinearLayout>

原理很直接:布局加载时,根布局先抢到焦点,EditText就不会自动聚焦。等用户真正想输入时,点一下输入框,焦点自然转移过去。这一行代码在官方模板里很容易被忽略,却是记事本类App体验感差异最明显的地方。

5.2 数据改完列表不刷新:notifyDataSetChanged的失效场景

从编辑页保存返回后,列表页没有刷新,是记事本类项目最常见的“低级bug”。按直觉想,onCreate只会在首次创建时执行一次,你在编辑页即使把数据改了,返回主界面时Activity已经存在,不会重新走onCreate,所以如果不在合适的时机刷新数据,列表永远显示旧内容。

正确的做法是在MainActivity的onResume里重新查询数据。onResume会在页面从后台回到前台时被调用,比onCreate更频繁,正好覆盖了“从编辑页返回”的场景:

@Override protected void onResume() { super.onResume(); loadNoteList(); adapter.notifyDataSetChanged(); }

如果loadNoteList每次都会new一个新adapter,那notifyDataSetChanged这一行反而没必要写,因为列表已经换了一套全新的适配器。但如果你像我上面建议的那样,在onCreate只new一次adapter并setAdapter,那么在onResume里只查询数据然后notifyDataSetChanged就行。两种写法都能达到目的,但后者不会把滚动位置重置回顶部,体验更好。

这里有一个容易迷惑的小问题:onResume在onCreate之后紧接着就会执行一次,那是不是onCreate里就不用再加载列表了?没错,如果你已经在onResume里做了查询和刷新,onCreate里完全可以不调用loadNoteList,逻辑会简化不少。很多人刚开始学时会重复调用,导致列表数据被查两次,虽然不是大问题,但数据库操作多了一次无谓消耗。

5.3 时间格式化和SQLite排序不一致

关于时间字段,有两个隐藏坑特别容易踩。

第一个是时间格式的选择。记事本项目里如果直接new Date().toString(),存进数据库的字符串杂乱无章,排序时完全不是按时间顺序来的。为了方便排序,时间应该统一成固定格式,最推荐的是yyyy-MM-dd HH:mm:ss。这种格式下的字符串,按字典序排列时,正好也等于按时间先后排列,因为年是高位、时是低位,大小关系天然和自然时间顺序一致。

第二个坑是用SimpleDateFormat还是java.time。旧写法用SimpleDateFormat确实能跑,但如果你把字符串解析回Date对象时,忘了处理Locale和时区,在部分模拟器上可能会出问题。如果你的minSdkVersion在26以上,直接用LocalDateTime和DateTimeFormatter更省心。不过这里给个提醒:如果项目字节码要保持低版本兼容,java.time需要额外加desugaring配置,所以很多课程设计源码坚持用SimpleDateFormat也是有现实原因的。

6. 从记事本到真正的备忘录:后续扩展的方向

6.1 用搜索框把源码再往前推一步

记事本做完整套增删改查,已经可以交差了。但如果想让它更像“备忘录”,第一个建议就是加搜索框。

搜索功能不需要新建表,不需要迁移数据库,只需要在查询时加一段模糊匹配:

SELECT * FROM note WHERE title LIKE '%关键词%' OR content LIKE '%关键词%' ORDER BY updated_time DESC

对应到DAO方法里大概是这样:

public List<Note> queryByKeyword(String keyword) { SQLiteDatabase db = dbHelper.getReadableDatabase(); String[] selectionArgs = new String[]{"%" + keyword + "%", "%" + keyword + "%"}; Cursor cursor = db.query("note", null, "title LIKE ? OR content LIKE ?", selectionArgs, null, null, "updated_time DESC"); // 遍历cursor,组装List }

注意到这里虽然用了百分号拼接,但具体关键词还是通过占位符传进去的,避免了用户输一个单引号进来就导致整个SQL崩溃的问题。这个功能做完后,整个项目就不再是单纯的“列表+编辑”Demo,而是一个真正能日常使用的工具。

6.2 导出成文本文件:给数据上一道保险

本地SQLite数据库很可靠,但并不意味着永远不会出问题。手机恢复出厂设置、应用被卸载、系统崩溃,任何一个意外都可能导致数据库文件被清掉。给记事本加一个“导出备份”功能,是成本最低、价值最高的扩展。

实现思路也非常直白:把数据库文件直接复制一份到公共存储目录,或者用代码遍历所有笔记,逐条写入文本文件。前者更省事,后者导出的内容更可读,适合放到电脑上查看。代码如下:

File dbFile = new File(getApplicationContext().getDatabasePath("note.db").getAbsolutePath()); File backupFile = new File(getExternalFilesDir(null), "note_backup.db"); // 用FileInputStream和FileOutputStream把dbFile内容拷贝到backupFile

这里的getExternalFilesDir拿到的是应用专属外部存储目录,不需要额外申请存储权限,而且应用被卸载后系统会顺带清理,不会产生垃圾文件。

6.3 本地提醒与列表分组:备忘属性拉满

备忘录和记事本最大的区别,在于“提醒”。如果要给项目加上本地提醒功能,思路是利用系统闹钟机制,在用户保存笔记时设置一个定时触发的广播。这个需求牵涉到权限声明、广播接收器注册、以及Android不同版本对后台广播的限制,工作量大很多。作为扩展方向,技术上是完全可行的,但需要注意:如果只是交课程设计,本地提醒未必值得投入太多精力,因为评审老师对功能完整度的打分,通常远小于对代码结构清晰度的看重。

列表分组也是一个性价比较高的扩展。给note表加一个tag字段,列表页用标签筛选,配合之前提到的字段升级,正好用上onUpgrade里的ALTER TABLE逻辑。这一步可以让你的源码故事从“我写了个记事本”变成“我设计了一个带分类和数据库升级策略的备忘录”,在项目汇报时的说服力完全不同。

说实话,我每次拿到一份记事本源码,第一眼就看它的UPDATE和DELETE写得对不对,第二眼看它数据刷新逻辑放在哪,这两个位置基本能看出写代码的人有没有想清楚。你如果手头正好有这样一个SQLite记事本项目,建议优先把这两处抠明白,再去堆功能。等你哪天把列表页改成了分组展示、把删除改成了软删除、把时间显示改成了“今天/昨天”这种人性化格式,你会发现自己对安卓项目的掌控感和一个月前已经完全不一样了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 19:08:08

ASP.NET Core视图组件:从运行机制到实战避坑指南

在MVC项目的母版页里放一段需要"自己找数据"的小部件&#xff0c;大概是让我最搓火的事之一。比如电商网站的购物车摘要、论坛的未读消息、后台的待办提醒——这些区块在几乎每个页面上都要出现&#xff0c;但它们的数据不在当前页面的Model里&#xff0c;更不是每个…

作者头像 李华
网站建设 2026/10/20 19:07:38

Spring Boot手写JSON-RPC Server:不引依赖实现轻量RPC服务

简介&#xff1a;面向 Java 开发者的 Spring Boot 集成 JSON-RPC 2.0 服务端工程示例&#xff0c;演示了如何通过标准 JSON-RPC 协议对外暴露方法。工程内置 multiplier 乘法方法&#xff0c;客户端发送包含 id、jsonrpc、method、params 字段的 JSON 请求&#xff0c;例如指定…

作者头像 李华
网站建设 2026/10/20 19:07:14

WPF MVVM窗口管理实战:从接口注入到消息总线的解耦方案

1. 从一次窗口卡死的排查说起做WPF开发的人&#xff0c;早晚都会遇到这样一个场景&#xff1a;主界面点一个按钮&#xff0c;弹出一个新窗口&#xff0c;用户在新窗口里填一堆数据&#xff0c;点确定&#xff0c;数据回填到主界面。听起来简单得不能再简单&#xff0c;但我见过…

作者头像 李华
网站建设 2026/10/20 19:06:44

基于Fisher准则的线性分类器设计:从原理到numpy实现

刚从实验室回来&#xff0c;把“基于Fisher准则的线性分类器设计”这个题目跑完最后一组实验。说实话&#xff0c;这个题目在模式识别课里出现频率很高&#xff0c;但大多数人在课上都能听懂Fisher线性判别的公式推导&#xff0c;真正动手时却卡在各种地方&#xff1a;投影方向…

作者头像 李华
网站建设 2026/10/20 19:05:16

基于Linux环境使用ogg19版本从oracle 19c ADG备库远程同步数据

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 19:05:12

构建可配置的金融K线图形比对系统:架构、索引与实时分类

1. 为什么需要一套独立的图形比对系统先说清楚我做这件事的起因。去年在某市场分析平台做技术顾问时&#xff0c;团队里几位研究型用户每天最耗时的工作之一&#xff0c;就是手动翻K线图找“历史上有没有出现过类似的走势结构”。他们的口径五花八门&#xff1a;有的说“要找20…

作者头像 李华