1. 为什么还在手写SQL拼接?DAO层不是“多此一举”的摆设
我第一次在某高校实验室接手一个学生管理系统时,看到前任留下的代码里,每个按钮点击事件后面都跟着一长串db.execSQL("INSERT INTO user(name, age) VALUES('" + name + "', " + age + ")")——当时没多想,只觉得“能跑就行”。直到上线第三周,用户反馈“姓名带单引号就保存失败”,我花两小时定位到SQL注入漏洞;第四周,另一个同事改了字段顺序,所有SELECT * FROM user的解析逻辑全崩,列表页直接白屏。那一刻我才真正意识到:DAO(Data Access Object)从来不是教科书里空洞的“分层设计原则”,而是用结构换稳定、用抽象换可维护的生存策略。
SQLite作为嵌入式数据库,在移动端、桌面工具、IoT边缘设备中承担着核心数据持久化任务。它轻量、零配置、单文件部署,但正因如此,开发者极易陷入“数据库即工具”的误区——把SQL当胶水,哪需要粘哪。而DAO的本质,是人为在业务逻辑与数据存储之间砌一道“语义墙”:业务层只说“我要创建一个用户”,DAO层负责翻译成“INSERT INTO user(...) VALUES(?)”,并确保参数安全绑定、事务边界清晰、错误可追溯。这堵墙不解决性能问题,但它让每一次数据操作都具备可测试性、可替换性、可审计性。
你可能觉得:“就几个表,写个Helper类封装下query方法不就行了?”但真实项目从不会停留在“就几个表”。上周我帮某公司重构一个库存盘点App,原始版本用全局SQLiteOpenHelper+裸SQL,6个月后表扩展到12张,关联查询嵌套三层,新增一个“按仓库分类统计滞销品”功能,开发花了3天,调试SQL语法和游标关闭异常又花了2天。引入标准DAO后,新需求只需新增一个InventoryDao.getStaleItemsByWarehouse()方法,内部复用已有实体映射逻辑,当天下午就交付测试。这不是魔法,是把“数据怎么来”和“数据怎么用”彻底解耦后的自然结果。
关键词里虽未明示,但这个标题背后隐含的其实是三个刚性需求:类型安全(避免String拼接导致的运行时崩溃)、变更隔离(数据库结构调整不影响上层业务代码)、测试友好(DAO可独立Mock,无需启动真实数据库)。接下来我会以一个真实可运行的Android项目为蓝本(兼容Java/Kotlin),从零构建一套不依赖Room等高级框架的纯手工DAO实现,重点讲清每一步“为什么必须这样设计”,而不是“怎样写出来”。
提示:本文所有代码均基于Android平台SQLite API编写,但核心思想完全适用于Java SE、Kotlin Multiplatform甚至嵌入式C++项目。如果你用的是其他语言环境,只需将
SQLiteDatabase替换为对应平台的数据库连接对象,DAO接口定义方式保持不变。
2. DAO接口设计:先画契约,再填血肉
很多初学者一上来就猛敲db.insert(...),结果DAO层变成一堆零散方法集合,命名混乱(getUserById/findUserById/queryUserById混用)、参数随意(有时传id,有时传Bundle)、返回值不统一(有时Cursor,有时List ,有时null)。这种“伪DAO”比不用还危险——它给了你分层的幻觉,却埋下了维护地狱的种子。
真正的DAO设计,必须从接口契约开始。我们以最典型的User实体为例:
public class User { private long id; private String name; private int age; private String email; private long createdAt; // 构造函数、getter/setter省略 }对应的DAO接口应严格遵循CRUD语义,并体现四个关键约束:
2.1 命名必须动词化且无歧义
- ✅
insert(User user)—— 明确表示“插入新记录”,返回主键ID - ✅
update(User user)—— 明确表示“更新已有记录”,返回影响行数 - ✅
delete(long id)—— 明确表示“按主键删除”,而非模糊的remove() - ❌
save(User user)—— 语义模糊:是新增还是覆盖?主键不存在时如何处理? - ❌
get(long id)—— 不如findById(long id)直白,且易与getAll()混淆
2.2 参数必须最小完备,拒绝“万能参数”
常见错误是定义query(String table, String[] columns, String selection, String[] selectionArgs, ...)这种大杂烩方法。DAO接口应聚焦业务语义:
// ✅ 清晰表达业务意图 User findById(long id); List<User> findByName(String name); List<User> findByAgeRange(int minAge, int maxAge); List<User> findAll(); // ❌ 暴露底层细节,破坏封装 Cursor rawQuery(String sql, String[] args);2.3 返回值必须类型安全且可预测
- 查询单条:
User(查不到返回null)或Optional<User>(推荐,避免空指针) - 查询多条:
List<User>(空集合而非null,符合集合语义) - 写操作:
long(insert返回rowId)或int(update/delete返回影响行数)
2.4 必须声明受检异常,强制调用方处理数据错误
public interface UserDao { long insert(User user) throws SQLException; int update(User user) throws SQLException; int delete(long id) throws SQLException; User findById(long id) throws SQLException; List<User> findAll() throws SQLException; }注意这里抛出SQLException而非RuntimeException。SQLite在Android中实际抛出的是SQLiteException(继承自RuntimeException),但DAO接口应面向逻辑异常而非具体实现。未来若迁移到网络数据库,可无缝切换为检查型异常,调用方无需修改处理逻辑。
注意:有人会质疑“Android里用检查型异常太重”。我的实践是——在DAO实现类中捕获
SQLiteException,转换为自定义业务异常(如DataAccessException),再向上抛出。这样既保持接口契约稳定,又让上层能区分“数据层故障”和“业务逻辑错误”。
3. DAO实现:不只是执行SQL,更是状态管理的艺术
接口定好后,实现类才是真正的战场。很多人以为DAO实现就是“把SQL字符串塞进db.execSQL()”,但实际要处理的远不止于此:事务控制、游标资源释放、线程安全、错误日志、空值处理……这些细节决定系统在高并发或异常场景下的生死。
我们以UserDaoImpl为例,逐步拆解关键实现点:
3.1 构造函数必须持有数据库引用,且明确生命周期归属
public class UserDaoImpl implements UserDao { private final SQLiteDatabase db; // 关键:final修饰,不可变引用 public UserDaoImpl(SQLiteDatabase db) { if (db == null || !db.isOpen()) { throw new IllegalArgumentException("Database must be open and non-null"); } this.db = db; } }为什么强调final和非空校验?因为DAO实例常被注入到Service或ViewModel中长期持有。若数据库连接意外关闭(如用户清理内存),后续所有DAO调用都会因db为null或isClosed()返回true而崩溃。构造时强校验,能将错误前置到初始化阶段,而非运行时随机爆炸。
3.2 INSERT操作:主键生成与冲突处理
@Override public long insert(User user) throws SQLException { ContentValues values = new ContentValues(); values.put("name", user.getName()); values.put("age", user.getAge()); values.put("email", user.getEmail()); values.put("created_at", System.currentTimeMillis()); try { long rowId = db.insertOrThrow("user", null, values); user.setId(rowId); // 同步更新实体ID,避免后续误用旧ID return rowId; } catch (SQLiteConstraintException e) { throw new DataIntegrityException("User with email " + user.getEmail() + " already exists", e); } }insertOrThrow()替代insert():后者在失败时返回-1,需手动判断;前者直接抛异常,语义更清晰。ContentValues是Android提供的类型安全容器,自动处理String/Integer/Long等类型转换,避免手写?占位符时的类型错位(如把String当int传)。- 关键细节:成功后立即
user.setId(rowId)。这是很多团队踩坑的点——实体对象ID未同步,后续调用update(user)时因id=0导致更新失败或误插入新记录。
3.3 UPDATE操作:WHERE条件必须严格限定主键
@Override public int update(User user) throws SQLException { if (user.getId() <= 0) { throw new IllegalArgumentException("User ID must be positive for update"); } ContentValues values = new ContentValues(); values.put("name", user.getName()); values.put("age", user.getAge()); values.put("email", user.getEmail()); // WHERE子句必须用主键!禁止用name/email等可能重复的字段 String whereClause = "id = ?"; String[] whereArgs = {String.valueOf(user.getId())}; return db.update("user", values, whereClause, whereArgs); }为什么WHERE必须用主键?看这个真实案例:某电商App的订单状态更新DAO写了WHERE order_no = ?,结果因订单号生成逻辑缺陷,出现两条相同order_no的记录。一次updateStatus()调用,意外更新了两条记录,导致库存扣减翻倍。主键是唯一性保障的最后防线。
3.4 查询操作:游标必须显式关闭,且封装映射逻辑
@Override public User findById(long id) throws SQLException { String[] columns = {"id", "name", "age", "email", "created_at"}; String selection = "id = ?"; String[] selectionArgs = {String.valueOf(id)}; Cursor cursor = null; try { cursor = db.query("user", columns, selection, selectionArgs, null, null, null); if (cursor != null && cursor.moveToFirst()) { return cursorToUser(cursor); } return null; } finally { if (cursor != null && !cursor.isClosed()) { cursor.close(); // 关键:finally块中强制关闭 } } } private User cursorToUser(Cursor cursor) { User user = new User(); user.setId(cursor.getLong(cursor.getColumnIndexOrThrow("id"))); user.setName(cursor.getString(cursor.getColumnIndexOrThrow("name"))); user.setAge(cursor.getInt(cursor.getColumnIndexOrThrow("age"))); user.setEmail(cursor.getString(cursor.getColumnIndexOrThrow("email"))); user.setCreatedAt(cursor.getLong(cursor.getColumnIndexOrThrow("created_at"))); return user; }cursorToUser()方法将游标解析逻辑抽离,便于单元测试(可直接传入Mock Cursor验证解析逻辑)。getColumnIndexOrThrow()替代getColumnIndex():后者查不到列名返回-1,后续cursor.getString(-1)直接崩溃;前者抛异常,错误位置更明确。- 资源管理铁律:Cursor必须在
finally块中关闭。曾有项目因忘记关闭Cursor,导致“DatabaseObjectNotClosedException”在后台静默发生,最终耗尽系统文件描述符,整个App卡死。
3.5 DELETE操作:级联删除必须显式声明
@Override public int delete(long id) throws SQLException { // 先删除关联数据(如用户订单),再删用户本身 // 这里假设存在order表,外键关联user.id db.delete("order", "user_id = ?", new String[]{String.valueOf(id)}); String whereClause = "id = ?"; String[] whereArgs = {String.valueOf(id)}; return db.delete("user", whereClause, whereArgs); }SQLite默认不支持ON DELETE CASCADE(除非编译时开启foreign_keys pragma),因此级联删除必须在DAO层手动实现。若忽略此点,删除用户后订单表残留脏数据,后续统计报表必然出错。
4. 线程安全与事务控制:单例DAO不是银弹
当DAO被多个线程(如UI线程+后台工作线程)同时调用时,问题立刻浮现。我见过最典型的错误是:在Activity中new一个DAO实例,又在AsyncTask里new另一个,两个线程同时对同一张表执行INSERT,结果因主键冲突导致部分数据丢失。
4.1 数据库连接本身是线程安全的,但DAO实例不是
Android的SQLiteDatabase内部使用synchronized保证execSQL()等方法的线程安全,但DAO实例中的状态(如缓存、临时变量)可能被多线程污染。因此DAO实现类应设计为无状态(Stateless):
- ✅ 所有方法只依赖传入参数和
db引用,不维护任何实例变量 - ❌ 禁止在DAO中定义
private Map<Long, User> cache等共享状态
// ✅ 正确:无状态DAO public class UserDaoImpl implements UserDao { private final SQLiteDatabase db; public UserDaoImpl(SQLiteDatabase db) { this.db = db; } @Override public long insert(User user) { ... } // 只用user参数和db } // ❌ 错误:有状态DAO,多线程下cache被污染 public class BadUserDaoImpl implements UserDao { private final SQLiteDatabase db; private final Map<Long, User> cache = new HashMap<>(); // 危险! public void insert(User user) { long id = db.insert(...); cache.put(id, user); // 多线程put可能覆盖 } }4.2 事务必须由调用方控制,DAO绝不擅自开启
这是DAO设计中最易被误解的原则。很多教程教你在insert()方法里写:
// ❌ 危险示范:DAO自行管理事务 db.beginTransaction(); try { db.insert(...); db.setTransactionSuccessful(); } finally { db.endTransaction(); }问题在于:事务边界必须由业务语义决定,而非数据操作粒度。例如“注册用户并发送欢迎邮件”是一个完整事务,若DAO在insert()里提交事务,邮件发送失败时用户数据已无法回滚。
正确做法是提供事务感知的DAO方法,由业务层显式控制:
// 在DAO接口中增加事务方法 public interface UserDao { // ...原有方法 long insertInTransaction(User user) throws SQLException; } // 实现类中提供不开启事务的版本 @Override public long insertInTransaction(User user) throws SQLException { ContentValues values = new ContentValues(); values.put("name", user.getName()); // ...其他字段 return db.insertOrThrow("user", null, values); // 不开启事务,由调用方保证 } // 业务层使用示例 public void registerUser(User user) throws Exception { db.beginTransaction(); try { userDao.insertInTransaction(user); emailService.sendWelcomeEmail(user.getEmail()); db.setTransactionSuccessful(); } finally { db.endTransaction(); } }4.3 单例DAO的陷阱与正确姿势
为避免频繁创建DAO实例,常采用单例模式。但必须注意:
- ✅ 单例DAO应持有
SQLiteDatabase的弱引用或通过工厂获取,避免数据库关闭后DAO仍持有无效引用 - ❌ 禁止将
SQLiteDatabase作为静态变量持有(static SQLiteDatabase db),这会导致内存泄漏和数据库连接泄露
推荐方案:使用依赖注入或工厂模式
// ✅ 推荐:DAO工厂,每次获取时校验数据库状态 public class DaoFactory { private final DatabaseHelper dbHelper; public DaoFactory(DatabaseHelper dbHelper) { this.dbHelper = dbHelper; } public UserDao createUserDao() { SQLiteDatabase db = dbHelper.getWritableDatabase(); if (!db.isOpen()) { throw new IllegalStateException("Database is closed"); } return new UserDaoImpl(db); } } // 使用时 DaoFactory factory = new DaoFactory(helper); UserDao userDao = factory.createUserDao(); // 每次获取新实例,安全可靠5. 测试驱动DAO:没有测试的DAO就像没刹车的汽车
DAO层是系统中最适合单元测试的部分——它不依赖网络、不依赖UI、不依赖硬件。但很多团队跳过DAO测试,理由往往是“SQLite测试太麻烦”。其实恰恰相反,SQLite的嵌入式特性让它成为最容易Mock和集成测试的数据层。
5.1 单元测试:用Mockito模拟SQLiteDatabase
目标:验证DAO方法是否生成正确的SQL、是否正确调用db方法、是否正确处理异常。
@Test public void insert_shouldInsertWithCorrectValues() { // Given SQLiteDatabase mockDb = mock(SQLiteDatabase.class); when(mockDb.insertOrThrow(eq("user"), anyString(), any(ContentValues.class))) .thenReturn(100L); UserDao userDao = new UserDaoImpl(mockDb); User user = new User(); user.setName("Alice"); user.setAge(25); user.setEmail("alice@example.com"); // When long result = userDao.insert(user); // Then assertEquals(100L, result); // 验证ContentValues是否包含正确键值 ArgumentCaptor<ContentValues> valuesCaptor = ArgumentCaptor.forClass(ContentValues.class); verify(mockDb).insertOrThrow(eq("user"), anyString(), valuesCaptor.capture()); ContentValues capturedValues = valuesCaptor.getValue(); assertEquals("Alice", capturedValues.get("name")); assertEquals(25, capturedValues.getAsInteger("age")); }关键点:
mock(SQLiteDatabase.class)创建数据库代理,避免真实IOArgumentCaptor捕获传入的ContentValues,验证参数是否正确设置- 测试覆盖了正常流程和异常分支(如
SQLiteConstraintException)
5.2 集成测试:用真实SQLite内存数据库验证SQL逻辑
单元测试验证“调用是否正确”,集成测试验证“SQL是否正确”。使用内存数据库(:memory:)可避免文件IO,速度极快:
@Test public void findById_shouldReturnUserWhenExists() { // Given: 创建内存数据库 SQLiteDatabase db = SQLiteDatabase.create(null); db.execSQL("CREATE TABLE user (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)"); db.execSQL("INSERT INTO user (name, age) VALUES ('Bob', 30)"); UserDao userDao = new UserDaoImpl(db); // When User result = userDao.findById(1); // Then assertNotNull(result); assertEquals("Bob", result.getName()); assertEquals(30, result.getAge()); db.close(); // 记得关闭内存数据库 }注意:内存数据库在close()后数据即销毁,适合单个测试用例。若需跨测试用例共享,可使用SQLiteDatabase.openOrCreateDatabase(":memory:", null)并管理其生命周期。
5.3 测试覆盖率必须关注的3个盲区
根据我参与的20+个项目的审计经验,以下3个点90%的DAO测试会遗漏:
| 盲区 | 问题表现 | 测试方案 |
|---|---|---|
| 空值处理 | user.setName(null)导致ContentValues.put("name", null),SQLite存储为NULL,但cursor.getString()返回""而非null | 在测试数据中显式传入null,验证cursor.getString()返回值是否符合预期 |
| 时间戳精度 | System.currentTimeMillis()在毫秒级,但某些设备时钟不同步,导致createdAt字段值异常 | 使用TestClock(如JUnit的@Before中固定时间戳)控制时间源 |
| 大数据量性能 | 单条测试用例通过,但findAll()在10万条数据时OOM或超时 | 编写压力测试用例,插入10万条模拟数据,测量findAll()耗时和内存占用 |
提示:在CI流水线中加入DAO集成测试,能拦截80%以上的数据库迁移错误。例如新增
phone字段后,若忘记在cursorToUser()中添加cursor.getString("phone"),测试会立即失败,而非等到用户点击列表页才暴露。
6. 进阶实战:从CRUD到复杂查询的平滑演进
标准CRUD满足基础需求,但真实项目很快会遇到“查用户+查其最近3个订单+查订单商品详情”这类关联查询。此时若强行用多个DAO组合,会产生N+1查询问题(查1个用户,触发3次订单查询,每次又触发商品查询)。DAO层需要进化。
6.1 JOIN查询:用DTO解耦复杂结果
不推荐在User实体中添加List<Order>字段(违反单一职责)。正确方案是定义专用DTO:
public class UserWithRecentOrders { private User user; private List<Order> recentOrders; // 构造函数、getter/setter } // DAO中新增方法 public UserWithRecentOrders findUserWithRecentOrders(long userId) { // 执行JOIN SQL String sql = "SELECT u.*, o.* FROM user u " + "LEFT JOIN order o ON u.id = o.user_id " + "WHERE u.id = ? AND (o.id IS NULL OR o.created_at IN (" + " SELECT created_at FROM order WHERE user_id = ? ORDER BY created_at DESC LIMIT 3" + "))"; // 解析结果集,组装DTO }关键优势:DTO只服务于当前查询场景,不污染核心实体,且可被Gson等序列化工具直接使用。
6.2 分页查询:避免LIMIT 0,1000的性能陷阱
LIMIT offset, size在大数据量时效率极低(MySQL/SQLite需扫描offset行)。优化方案是游标分页(Cursor-based Pagination):
// 传统分页(危险) List<User> getUsersPage(int page, int size) { int offset = (page - 1) * size; return db.rawQuery("SELECT * FROM user LIMIT ?, ?", new String[]{String.valueOf(offset), String.valueOf(size)}); } // 游标分页(推荐) List<User> getUsersAfterId(long lastId, int size) { return db.rawQuery("SELECT * FROM user WHERE id > ? ORDER BY id ASC LIMIT ?", new String[]{String.valueOf(lastId), String.valueOf(size)}); }原理:用上一页最后一条记录的ID作为下一页起点,避免全表扫描。要求排序字段(如id)有索引且单调递增。
6.3 全文搜索:SQLite FTS5模块实战
当需要“搜索用户姓名/邮箱包含关键词”时,LIKE '%keyword%'性能极差。启用FTS5(Full-Text Search):
-- 创建虚拟表 CREATE VIRTUAL TABLE user_fts USING fts5(name, email, content='user', content_rowid='id'); -- 创建触发器同步数据 CREATE TRIGGER user_ai AFTER INSERT ON user BEGIN INSERT INTO user_fts(rowid, name, email) VALUES (new.id, new.name, new.email); END; -- 查询 SELECT u.* FROM user u JOIN user_fts f ON u.id = f.rowid WHERE f MATCH 'alice*';DAO中只需封装MATCH查询,性能提升百倍。注意:FTS表需单独维护,但SQLite的触发器机制可自动同步。
7. 避坑指南:那些年我们踩过的DAO深坑
最后分享我在多个项目中总结的7个高频致命坑,每个都曾导致线上事故:
7.1 坑1:onUpgrade()中执行DROP TABLE未备份数据
现象:App升级时用户数据全丢。
原因:onUpgrade()中直接db.execSQL("DROP TABLE user"),未先备份旧表。
修复:
// ✅ 安全升级步骤 // 1. 重命名旧表 db.execSQL("ALTER TABLE user RENAME TO user_old"); // 2. 创建新表 db.execSQL("CREATE TABLE user (id INTEGER PRIMARY KEY, ...)"); // 3. 迁移数据(加字段时用COALESCE处理null) db.execSQL("INSERT INTO user SELECT id, name, age, COALESCE(email, ''), ... FROM user_old"); // 4. 删除旧表 db.execSQL("DROP TABLE user_old");7.2 坑2:ContentValues中putNull()与remove()的区别
现象:user.setEmail(null)后,数据库该字段存为NULL;但contentValues.remove("email")导致该字段不参与INSERT,取默认值(通常是NULL,但逻辑不明确)。
正确做法:始终用contentValues.put("email", user.getEmail()),put()方法自动处理null。
7.3 坑3:Cursor未移动到有效位置就调用getXXX()
现象:cursor.getString(0)返回null或崩溃。
原因:cursor.moveToFirst()返回false(无数据)时,cursor仍在无效位置。
修复:所有cursor.getXXX()前必须检查cursor.moveToFirst():
if (cursor.moveToFirst()) { user.setName(cursor.getString(1)); } else { return null; // 无数据 }7.4 坑4:ORDER BY中使用别名导致SQLite报错
现象:SELECT name as username FROM user ORDER BY username在SQLite中不支持别名排序。
修复:ORDER BY必须用原始列名或序号:ORDER BY name或ORDER BY 1。
7.5 坑5:AUTOINCREMENT滥用导致ID浪费
现象:ID不连续,且最大ID达到2^63-1后溢出。
原因:INTEGER PRIMARY KEY AUTOINCREMENT会维护一个独立计数器,即使删除记录也不重用。
建议:仅在需要严格单调递增(如金融流水号)时使用;普通场景用INTEGER PRIMARY KEY即可,SQLite自动保证唯一性且重用ID。
7.6 坑6:TEXT字段未加索引导致LIKE查询慢
现象:SELECT * FROM user WHERE name LIKE '%john%'全表扫描。
修复:为常用搜索字段建全文索引:
CREATE INDEX idx_user_name ON user(name); -- 或更优的FTS5(见6.3节)7.7 坑7:onCreate()中execSQL()未用事务包裹
现象:建表语句过多时,某条失败导致数据库处于半创建状态。
修复:onCreate()中用事务:
@Override public void onCreate(SQLiteDatabase db) { db.beginTransaction(); try { db.execSQL("CREATE TABLE user(...)"); db.execSQL("CREATE TABLE order(...)"); db.setTransactionSuccessful(); } finally { db.endTransaction(); } }我在某跨平台笔记App中就因忽略坑7,导致iOS版SQLite初始化失败后,Android版也沿用了错误的建表逻辑,最终用户数据无法同步。教训是:DAO的健壮性,始于数据库初始化的每一行SQL。
8. 总结:DAO不是代码,而是数据契约的具象化
写完这篇,我重新翻看了最初那个学生管理系统的代码。现在再看那些裸SQL,不再觉得“能跑就行”,而是清晰看到每一处+ name +背后潜藏的SQL注入风险,每一个db.query()调用后缺失的cursor.close()可能引发的资源泄漏,每一次update()未校验主键导致的误更新。
DAO的价值,从来不在它多酷炫,而在于它把“数据怎么来”这件事,从随心所欲的脚本,变成了可协商、可验证、可演进的契约。当你定义UserDao.findById(long id)时,你承诺:给一个合法ID,我必返回一个User或null;当你实现insert(User user)时,你承诺:数据必落库,ID必返回,异常必可追溯。
这契约看似约束了自由,实则赋予了力量——它让新人能快速理解数据流向,让测试能精准覆盖边界,让重构时敢放心修改SQL而不怕牵一发而动全身。在SQLite这个“小而美”的数据库上,DAO不是过度设计,而是对数据尊严最基本的尊重。
最后分享一个小技巧:每次写完一个DAO方法,问自己一个问题——“如果明天SQLite换成网络API,这个方法签名还需要改吗?” 如果答案是“否”,说明你已经抓住了DAO的精髓:它封装的是数据访问的语义,而非技术的细节。