1. 数据存储方案选型:为什么 Kivy 项目最终选了 SQLite3
1.1 那些年踩过的存储坑
做 Kivy 跨平台应用,最先遇到的不是界面写不出来,而是数据不知道该往哪儿放。我做过好几个练手项目,最开始图省事,直接用一个 Python 列表或者字典在内存里存数据,程序一关,数据全没了。后来改进成写 JSON 文件,用json.dump和json.load读写,结果数据量一大,每次启动要把整个文件全部读进内存,界面上做个搜索都要遍历一整遍,越来越卡。更要命的是,多用户同时写的时候,文件会互相覆盖,我亲眼见过辛苦记了一周的账目被一次异常退出清空。
后来又试过用 CSV 存数据,更麻烦。字段一旦有换行或者逗号,解析就会出问题,而且查一条记录要把文件从头到尾读一遍,效率低得离谱。那段时间我最大的感受就是:桌面应用随便折腾,文件存储勉强凑合,但 Kivy 应用是要上手机的,手机的存储写入频率和稳定性要求比电脑高得多,文件方案根本不靠谱。
1.2 SQLite3 的优势与适用边界
SQLite3 是我绕了一圈之后定下来的方案,原因很简单:它是 Python 标准库自带的,import sqlite3就能用,不需要额外装第三方依赖,这对做 APK 打包来说是巨大的优势,少一个纯 C 扩展就少一堆打包报错。它的核心是一个嵌入式的、单文件的关系型数据库,整个数据库就是磁盘上的一个文件,但你可以在它上面执行标准的 SQL 语句,拥有完整的表结构、索引、事务支持。
打个比方,把 JSON 文件方案比作在一个巨大的纸箱里翻找东西,每次都要把纸箱倒出来找;那 SQLite3 就是把东西整理进带标签的档案柜,每个柜子有独立的抽屉(表),每个抽屉里有索引目录,你想找某一条记录,直接按条件去取,速度快一个数量级。而且它支持事务,批量写入的时候要么全部成功、要么全部回滚,不会再出现写了一半文件损坏的情况。
对 Kivy 项目来说,SQLite3 的适用场景非常明确:本地单机数据持久化,比如记账本、待办清单、日记、收藏夹、离线缓存。Kivy 是客户端框架,大部分应用的数据都是用户自己产生的本地数据,不需要真正的网络数据库服务,SQLite3 恰恰是这种场景的标准答案。在我这个某账单管理 Demo(项目编号 025)里,我只是用它记录用户每天的收入支出,几十条到几千条的量级完全够用。
1.3 什么情况下不建议用 SQLite3
SCLite3 也不是万能药,先说清楚边界,免得有人盲目照搬。如果你的应用数据量达到百万级、需要多用户并发写入、需要精确的权限控制,SQLite3 就不合适了,它本质上还是单机文件数据库,并发写同一个数据库文件时会碰到锁机制,吞吐量远不如真正的数据库服务。另外,如果你的数据是结构化的松耦合数据、需要跨平台同步,那 SQLite3 只能做本地缓存,云端同步还是要后端数据库配合。
在这个项目 025 里,我把判断标准写得很简单:数据只在这台设备上用、不需要多人协作、单次查询目标是少量行,只要满足这三个条件,SQLite3 就是最优解。
2. 环境准备与项目骨架搭建
2.1 依赖安装与版本组合
Kivy 项目跑 SQLite3 其实不需要额外安装数据库驱动,因为 Python 标准库自带,这一点对新手特别友好。我做这个项目时的环境是 Python 3.10、Kivy 2.2.1、Buildozer 1.4.0,这几者组合在 Windows 和 Ubuntu 上跑得都很稳。装 Kivy 的常规做法是创建虚拟环境后执行pip install kivy,需要装打包工具再执行pip install buildozer。
有个细节值得注意:Kivy 官方包在 Android 上是自带sqlite3模块的,因为 Python-for-Android (p4a) 默认就把 SQLite 编译进去了。但如果你用的是某些精简版 Python 环境,可能要确认一下sqlite3是否可用,简单验证方式是跑一句代码:
import sqlite3 print(sqlite3.sqlite_version)能输出版本号就说明没问题。我在项目开头就写了这么一段自检代码,省得后面 debug 半天发现是环境缺模块。
2.2 项目目录结构
整个项目 025 的结构我是这样安排的,分工清晰,数据库相关代码单独放一个模块,界面代码与数据库代码互相隔离:
kivy025_sqlite/ ├── main.py # Kivy 入口,UI 界面逻辑 ├── database.py # SQLite3 数据库管理类 ├── kv/ │ └── main.kv # Kivy 布局文件 ├── data/ │ └── app.db # 数据库文件(运行时自动生成) └── buildozer.spec # APK 打包配置这么做最大的好处是:如果以后想把 SQLite3 换成别的存储方案,只需要改database.py,界面代码完全不动。UI 层和持久化层分离,这在 Kivy 小项目里尤其容易被忽略,但一旦项目复杂起来,你就会感谢当时的分层设计。
2.3 数据库文件放置路径
新手踩过最多的地方就是文件路径。Kivy 应用在电脑上跑的时候,当前工作目录是项目根目录,但在 Android 手机上,应用的私有数据目录是在/data/data/包名/files之类的地方,你直接把数据库文件放在项目根目录,手机上跑起来文件写不进去或者找不到。
正确的做法是使用 Kivy 提供的App.get_running_app().user_data_dir,它会自动返回应用专属的数据目录,适配了各个平台。我代码里是这样写的:
import os from kivy.app import App def get_db_path(): app = App.get_running_app() data_dir = app.user_data_dir if not os.path.exists(data_dir): os.makedirs(data_dir) return os.path.join(data_dir, "bill.db")这段代码在桌面端会指向用户主目录下的某个应用文件夹,在 Android 端会自动映射到应用私有目录,一条代码解决跨平台路径问题。我在项目 025 里所有数据库连接都通过这个函数拿路径,没有再手写过一次硬编码的绝对路径。
3. 数据层设计与 CRUD 实现
3.1 表结构设计
项目 025 是一个账单流水记录应用,我设计的表结构很简单,但字段的取舍是认真想过的:
CREATE TABLE IF NOT EXISTS bills ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, amount REAL NOT NULL, note TEXT DEFAULT '', created_at TIMESTAMP DEFAULT (datetime('now', 'localtime')) );id是自增主键,用来唯一标识每条流水;category存账单分类,比如吃饭、交通、购物;amount存金额,用 REAL 类型,这个选择后面会细说;note存备注,允许为空;created_at存创建时间,默认值用 SQLite 的datetime('now', 'localtime'),自动记录本地时间,省得在代码里手动传时间戳。
这里我有个教训:金额字段千万不要用 FLOAT 直接累加,会出精度问题,0.1 加 0.2 可能变成 0.30000000000000004。为什么?因为浮点数在计算机里本身就是近似存储。如果这个项目要精确计算,应该把金额单位设计成“分”,用 INTEGER 存整数,显示的时候再除以 100。项目 025 我偷懒用了 REAL,实际跑下来日常记录问题不大,但如果你做的是财务类应用,建议直接用整数分,不要走弯路。
3.2 数据库连接管理类
写一个数据库管理类,比在界面代码里到处散落sqlite3.connect要科学得多。我的database.py长这样:
import sqlite3 import threading class BillDatabase: def __init__(self, db_path): self.db_path = db_path self._init_table() def _get_conn(self): conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row return conn def _init_table(self): conn = self._get_conn() try: conn.execute("CREATE TABLE IF NOT EXISTS bills (...)") conn.commit() finally: conn.close()有个关键点我要单独拎出来说:_get_conn()里设置了row_factory = sqlite3.Row,这样查询结果可以像字典一样用列名取值,比如row["amount"]而不是row[0],代码可读性高非常多。还有,每个方法内部都自行连接、自行关闭,不共享连接对象,因为 SQLite3 连接对象在多线程环境下共享是很容易出问题的,后文会专门讲。
3.3 查询与参数化防注入
增删改查我全封装成了方法,比如添加一条账单:
def add_bill(self, category, amount, note): conn = self._get_conn() try: conn.execute( "INSERT INTO bills (category, amount, note) VALUES (?, ?, ?)", (category, amount, note) ) conn.commit() return conn.execute("SELECT last_insert_rowid()").fetchone()[0] finally: conn.close()注意这里用了?占位符,把值作为参数传进去,而不是用字符串拼接拼 SQL。这是我反复强调的安全底线:任何用户输入直接拼进 SQL 语句里,等于把门敞开了让人注入,比如输入一句恶意 SQL,你的整个表都可能被删掉。用参数化查询之后,SQLite 会自动把输入转义成纯数据,不再被认为是 SQL 指令。
查询和删除也是类似的写法,无非是SELECT ... WHERE和DELETE FROM ... WHERE。需要特别注意的是每次commit()都要写在finally之前的正确位置,写完数据不 commit,下次连接关闭的时候改动会丢失。我见过好几个新手在这一点上困惑很久,明明 insert 了,重开程序数据又没了,十有八九是没 commit 或者 commit 了但连接没对上。
4. Kivy 界面与数据库联动实战
4.1 界面布局与数据展示
界面层我用main.py搭配main.kv文件写,UI 分成两个区域:上方是一个输入区,包含分类、金额、备注三个输入框和一个“添加”按钮;下方是一个RecyclerView或带滚动效果的列表,实时展示数据库里的所有流水。这个布局就是记账类 App 的典型结构,代码不复杂,但 UI 和数据之间的联动是关键。
main.kv里的大体结构是:
BoxLayout: orientation: 'vertical' BoxLayout: size_hint_y: 0.2 TextInput: id: category_input hint_text: '分类' TextInput: id: amount_input hint_text: '金额' TextInput: id: note_input hint_text: '备注' Button: text: '添加' on_release: app.add_bill() RecyclerView: id: bill_list界面是 UI 线程的活,数据库是 IO 活,两者不能混在一起写。我所有的 UI 控件只负责收集输入和显示结果,真正的数据库操作全部在app.add_bill()方法里调用database.py的接口,界面和持久化用事件方法连接起来。
4.2 事件绑定与增删改查联动
写一个界面方法,接收TextInput的内容,调用数据库接口,再刷新列表数据:
def add_bill(self): db = BillDatabase(get_db_path()) category = self.root.ids.category_input.text.strip() amount_text = self.root.ids.amount_input.text.strip() note = self.root.ids.note_input.text.strip() if category and amount_text: try: amount = float(amount_text) db.add_bill(category, amount, note) self.refresh_bill_list() except ValueError: # 金额输入了非数字 pass刷新列表时,查询全部记录,把结果转成 Kivy 的列表数据:
def refresh_bill_list(self): db = BillDatabase(get_db_path()) rows = db.get_all_bills() data = [] for row in rows: data.append({ "text": f"{row['category']} - {row['amount']}元 - {row['note']}", }) self.root.ids.bill_list.adapter.data = data如果用的不是RecyclerView而是ListView,要注意adapter.data赋值后要重新触发视图刷新,否则界面上不会变。我最开始写的时候漏了这个,数据库里明明 insert 了,界面死活不更新,还以为是没写进去,直到我 printf 调试发现数据库确实有数据,才想到是视图没刷新。
4.3 实时统计与删除功能的扩展
只有添加没有删除的记账本是不完整的,我在项目 025 里加了长按删除,RecyclerView的条目被长按时,弹出一个确认框,用户点了确认就按记录 id 删除:
def delete_bill(self, bill_id): db = BillDatabase(get_db_path()) db.delete_bill(bill_id) self.refresh_bill_list()这个功能看着简单,但触发逻辑有个细节:列表的条目事件回调里要能拿到当前条目的 id,所以我在刷新列表时把id也塞进了数据项里,显示的时候只展示文字,事件触发时拿 id 去删记录,避免用删除按钮自己的绑定数据去拼删除条件。
另外我还加了总金额统计,使用 SQL 聚合函数:
SELECT COALESCE(SUM(amount), 0) AS total FROM billsCOALESCE是防止表是空的,这样查出来是 0 而不是 NULL,省得界面上显示一个奇怪的空值。这个统计方法每次刷新列表时一起调用,金额变化后统计数字同步变化,效果很直观。
5. 打包与多线程避坑指南
5.1 Android 打包时 SQLite3 模块的处理
Kivy 应用最终要打包成 APK 安装到手机上,Buildozer 在打包时会通过 Python-for-Android 编译 Python 解释器和依赖模块,SQLite3 是 p4a 内置编译的模块,正常情况下不需要额外配置。但如果你的代码里用了某些不常见的 SQLite 特性,比如扩展函数、FTS 全文搜索,要确认 p4a 的 SQLite 编译开关是否打开,默认的编译参数可能不包含这些扩展。
以前我试过在 Android 端直接打开电脑端生成的数据库文件,结果报错提示 database disk image is malformed,后来发现是文件没有正确复制过去。正确做法是:数据库文件第一次启动时在应用私有目录创建,如果要预置数据,打包前把数据库文件放进 Kivy 的资源目录,首次启动时做一次性拷贝,不要直接去打开打包在 APK 里的只读文件。
5.2 多线程访问 Shared 连接与锁机制
Kivy 界面跑在主线程,如果你在后台线程里做数据库操作,要注意 SQLite 连接不能简单共享。SQLite3 默认的连接对象check_same_thread=True,意味着这个连接只能被创建它的线程使用,其他线程一调就会抛出异常。
我把这个问题拆成两层解决:第一层,每个线程都创建自己的连接,用完就关;第二层,如果实在要共享连接,创建时传check_same_thread=False,但这时你必须自己做好锁控制:
conn = sqlite3.connect(db_path, check_same_thread=False)不过说实话,对项目 025 这种量级的应用,完全没有必要引入多线程。Kivy 的界面刷新和数据库操作都在主线程执行,最坏情况就是大数据量插入时界面稍微卡顿一下,本地数据量小,体验并不差。给 SQLite 加线程只会引入更多踩坑点,比如两个线程同时写一条记录,你得处理database is locked异常,得不偿失。
5.3 耗时操作与 UI 响应
但如果你做的是批量导入几百条数据,主线程执行确实会卡住界面,这时可以用 Kivy 的Clock.schedule_once分块执行,或者用kivy.clock.Clock的调度机制把大的数据库操作拆成小批次,每次处理几十条,给界面留出刷新时间。我在一个批量导入功能里就是这么干的,实测下来界面掉帧从原来的好几秒降到几乎没有。
另一个常见坑是异步回调里更新 UI。用了线程或异步操作,回调函数运行在后台线程,直接修改 Kivy 控件属性会引发界面状态不一致,必须通过Clock.schedule_once把 UI 更新动作切回主线程。原则很简单:数据库操作可以放后台,UI 更新永远回主线程。
6. 常见问题速查与排查技巧实录
6.1 database is locked 是什么情况
SQLite 的锁机制是应对并发读写的,database is locked这个错误最常见的原因有两个:一是另一个连接持有了写锁没有释放,比如你开了个事务执行到一半忘记 commit 或 rollback;二是有个长查询没读完就尝试写入了。排查思路是检查所有execute后是否都关闭了连接或提交了事务,我习惯写成try/finally确保任何异常下连接都能关闭。
还有一种隐蔽场景:在 Windows 上,杀毒软件可能会短暂锁定新创建的数据库文件,如果应用启动时正好在初始化表结构,就会偶发报锁。遇到这种情况,重试机制是有效的,网上有封装好的重试装饰器,遇到锁异常就等待几十毫秒再试一次。
6.2 列表不刷新或者数据混乱
这个问题我在 4.2 提到过,是最容易被忽略的。你 insert 了数据库,但列表还是旧的,大概率是adapter.data更新后没有触发数据刷新。RecyclerView的 adapter 有data属性和notify_data_changed()方法,数据的增删改查完成后,记得正确赋值或者主动调用刷新接口。
如果数据出现重复,比如刷新后原来的一条记录变成了两条,多半是界面初始化时序的问题:on_start和__init__里都调了refresh_bill_list(),或者on_enter和on_start重复执行。解决办法是统一只保留一个数据加载入口,比如只在on_start里刷新,其他地方都通过事件通知来触发刷新。
6.3 字段类型不匹配与查询失效
用TextInput拿到的全是字符串,直接拿去和数据库里的 INTEGER 或 REAL 比较,SQLite 的类型亲和性可能导致无法命中索引或匹配失败。比如你用WHERE id = '3'通常没问题,因为 SQLite 会做类型转换,但如果你存的是 TEXT 的日期字符串,用日期函数去查询就可能匹配不上。稳妥做法是在代码层把输入转换成目标类型,再传给 SQL 参数。
还有一个真实的坑:created_at用了datetime('now', 'localtime'),如果你查询时用日期字符串去比较,要注意格式完全一致,差一个空格或者时区分隔符都会查不出来。我排查过一次“明明按日期查不到数据”的问题,最后发现是本地时间和 UTC 的时区偏移没对齐,浪费了两个小时。
6.4 升级表结构时旧数据丢失
项目做到一半,你会遇到需要加字段的情况,比如给账单表加一个“是否已同步”的标记。CREATE TABLE IF NOT EXISTS不会改变已有表的结构,直接执行ALTER TABLE才能加字段:
ALTER TABLE bills ADD COLUMN sync_status INTEGER DEFAULT 0ALTER TABLE不支持的场景需要另想办法,比如删掉字段或改约束条件。我的建议是写一个小的迁移管理机制:在数据库类里记录当前表结构版本号,每次打开数据库时检查版本,按序执行迁移 SQL,版本号存在一个PRAGMA user_version字段里,这样以后更新表结构不会丢数据。
6.5 文件损坏与备份恢复
SQLite 单文件方案如果写入中断,有可能损坏文件。概率不高,但手机上用着用着突然没电,或者应用被强制杀掉,理论上都有风险。我会在每次启动时做个简单的完整性检查:
conn.execute("PRAGMA integrity_check")如果发现异常,就把损坏文件备份成带时间戳的坏文件,再新建一个干净的库。同时每周往存储里导出一份 JSON 备份,算是廉价但实用的灾难恢复方案。
写在最后的经验汇总
这个项目 025 做完,我自己最大的感受是:Kivy 的 UI 部分反而比数据库简单,真正容易出问题的是两者结合的工程细节,比如路径、线程、刷新时机。如果你也在做类似的应用,我给几个优先级最高的建议:数据库操作用参数化 SQL,不要拼字符串;数据库路径用user_data_dir,不要硬编码;每个连接用try/finally保证关闭;列表刷新记得触发 adapter 更新;打包前先确认目标平台的sqlite3可用。把这些基础打牢,SQLite3 在你的 Kivy 项目里会变成一个非常可靠的后端,让你把精力放在功能本身而不是补数据漏洞上。