做地图类应用时,兴趣点收藏(POI收藏)几乎是绕不开的功能。用户看到一家心仪的餐厅、一个值得再去景点,点一下收藏按钮,数据就要永久保存下来,换手机、卸载重装甚至离线状态下都不能丢。这时候很多开发者会直接想到SQLite——它单文件存储、无需服务端、支持事务,天然适合这种本地持久化场景。但真到动手实现时,怎么设计表结构、怎么高效查询附近的收藏点、怎么处理数据同步,都有一堆细节需要琢磨。本文就围绕这些实际问题,完整讲一遍基于SQLite的兴趣点收藏功能实现。

一、收藏表结构设计与索引优化
表结构设计是整个功能的地基。兴趣点收藏的核心字段包括:唯一标识、名称、经纬度、地址描述、分类标签、收藏时间以及一些业务扩展字段。很多人图省事只存一个JSON字符串,后期想做分类筛选或附近搜索时就会叫苦不迭。
先看一个经过实践检验的建表语句,以Android平台常用的SQLite语法为例:
CREATE TABLE IF NOT EXISTS favorite_poi (
id INTEGER PRIMARY KEY AUTOINCREMENT,
poi_id TEXT NOT NULL UNIQUE, -- 服务端兴趣点唯一ID
name TEXT NOT NULL, -- POI名称
latitude REAL NOT NULL, -- 纬度
longitude REAL NOT NULL, -- 经度
address TEXT, -- 详细地址
category TEXT DEFAULT 'default', -- 自定义分类,如 food、hotel、scenic
remark TEXT, -- 用户备注
created_at INTEGER NOT NULL -- 收藏时间戳,毫秒
);
-- 分类筛选高频,为category建索引
CREATE INDEX IF NOT EXISTS idx_poi_category ON favorite_poi(category);
-- 按时间排序展示,为created_at建索引
CREATE INDEX IF NOT EXISTS idx_poi_created ON favorite_poi(created_at DESC);
这里有几个设计要点值得展开。第一,poi_id加了UNIQUE约束,防止用户重复收藏同一个点,业务层再做一次防御性判断更稳妥。第二,经纬度用REAL类型而不是TEXT,SQLite是动态类型数据库,虽然存字符串也能跑,但数值类型在范围查询时可以利用索引且省去隐式转换。第三,不建议在这个阶段就上空间索引扩展,绝大多数个人收藏场景数据量在几百到几千条,B树索引配合经纬度边界查询完全够用。
如果你的应用支持收藏分组(比如“周末去哪”、“美食清单”),可以额外加一张分组表,用多对多关系关联,避免在主表里用逗号拼接分组ID这种反范式做法:
CREATE TABLE IF NOT EXISTS favorite_group (
group_id INTEGER PRIMARY KEY AUTOINCREMENT,
group_name TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS poi_group_relation (
poi_id TEXT NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (poi_id, group_id)
);
二、增删改查的完整代码实现
表建好了,接下来是最常用的CRUD操作。以Android的SQLiteOpenHelper为基础封装一个DAO类,把数据库细节和业务层隔离开。下面是核心实现:
public class FavoriteDao {
private final SQLiteDatabase db;
public FavoriteDao(Context context) {
FavoriteDbHelper helper = new FavoriteDbHelper(context);
this.db = helper.getWritableDatabase();
}
// 新增收藏,CONFLICT策略保证重复收藏时自动忽略
public long addFavorite(PoiItem item) {
ContentValues cv = new ContentValues();
cv.put("poi_id", item.getId());
cv.put("name", item.getName());
cv.put("latitude", item.getLatitude());
cv.put("longitude", item.getLongitude());
cv.put("address", item.getAddress());
cv.put("category", item.getCategory());
cv.put("remark", item.getRemark());
cv.put("created_at", System.currentTimeMillis());
return db.insertWithOnConflict("favorite_poi", null,
cv, SQLiteDatabase.CONFLICT_IGNORE);
}
// 删除收藏
public int removeFavorite(String poiId) {
return db.delete("favorite_poi", "poi_id = ?",
new String[]{poiId});
}
// 判断是否已收藏,地图打点时高频调用,务必走索引
public boolean isFavorite(String poiId) {
Cursor c = db.rawQuery(
"SELECT 1 FROM favorite_poi WHERE poi_id = ? LIMIT 1",
new String[]{poiId});
boolean exists = c.moveToFirst();
c.close();
return exists;
}
}
注意insertWithOnConflict配合CONFLICT_IGNORE的用法,它比“先查询再插入”少一次数据库往返,且天然线程安全。地图渲染时每个屏幕上的POI都要判断收藏状态,isFavorite的调用频率极高,如果逐个查询会明显卡顿,更好的做法是启动时把所有收藏的poi_id一次性加载到内存的HashSet里,判断收藏状态变成O(1)操作,数据库变更时同步维护这个集合即可。
再来看查询当前地图视野内的收藏点,这是地图应用特有的需求。思路是根据屏幕边界算出经纬度的最大最小值,做一次范围查询:
public List<PoiItem> queryInBounds(double minLat, double maxLat,
double minLng, double maxLng) {
List<PoiItem> result = new ArrayList<>();
Cursor c = db.rawQuery(
"SELECT poi_id, name, latitude, longitude, category " +
"FROM favorite_poi " +
"WHERE latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?",
new String[]{String.valueOf(minLat), String.valueOf(maxLat),
String.valueOf(minLng), String.valueOf(maxLng)});
while (c.moveToNext()) {
PoiItem item = new PoiItem();
item.setId(c.getString(0));
item.setName(c.getString(1));
item.setLatitude(c.getDouble(2));
item.setLongitude(c.getDouble(3));
item.setCategory(c.getString(4));
result.add(item);
}
c.close();
return result;
}
BETWEEN查询在数据量大时如果性能不够,可以建一个复合索引(latitude, longitude),让范围筛选直接走索引扫描。对于几千条级别的收藏数据,这个方案通常在毫秒级完成,没必要引入更复杂的空间索引。
三、搜索、分组筛选与排序的组合查询
收藏列表页往往需要搜索框加分类标签的组合筛选,比如输入“咖啡”同时选中“美食”分类,按收藏时间倒序排列。这种多条件动态拼接的场景,建议用SQLiteQueryBuilder或手动拼接WHERE子句,参数化传值防止SQL注入:
public List<PoiItem> search(String keyword, String category, long startTime) {
StringBuilder where = new StringBuilder("1=1");
List<String> args = new ArrayList<>();
if (keyword != null && !keyword.isEmpty()) {
where.append(" AND (name LIKE ? OR remark LIKE ?)");
args.add("%" + keyword + "%");
args.add("%" + keyword + "%");
}
if (category != null) {
where.append(" AND category = ?");
args.add(category);
}
where.append(" AND created_at >= ?");
args.add(String.valueOf(startTime));
Cursor c = db.rawQuery(
"SELECT * FROM favorite_poi WHERE " + where +
" ORDER BY created_at DESC", args.toArray(new String[0]));
return cursorToList(c);
}
LIKE模糊匹配在中文场景下有个坑:SQLite默认的LIKE对ASCII不区分大小写,但只对英文字符生效,中文没有大小写问题所以不受影响。真正要注意的是如果用户量大、搜索频繁,可以对name字段做全文索引FTS5,不过收藏功能的数据量一般撑不起这个复杂度,普通LIKE配合前缀索引就能满足。
四、数据同步、备份与数据库升级
本地收藏最大的隐患是换机丢失,所以云端同步几乎是商业应用的标配。同步方案不必复杂:给收藏表加一个sync_status字段标记本地新增、修改、删除三种状态,网络可用时批量上报,服务端返回确认后更新状态。删除操作建议用软删除(加deleted标记),否则离线删除的数据无法同步到云端。
另一个容易被忽视的坑是数据库升级。早期版本只有5个字段,后来要加分组、加备注,直接改表结构会导致老用户升级后崩溃。正确做法是使用版本号渐进式迁移:
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion < 2) {
db.execSQL("ALTER TABLE favorite_poi ADD COLUMN remark TEXT");
db.execSQL("ALTER TABLE favorite_poi ADD COLUMN category TEXT DEFAULT 'default'");
}
if (oldVersion < 3) {
db.execSQL("ALTER TABLE favorite_poi ADD COLUMN sync_status INTEGER DEFAULT 0");
db.execSQL("CREATE INDEX IF NOT EXISTS idx_poi_category ON favorite_poi(category)");
}
}
每个版本的变更写成独立的if块,保证从任意旧版本升级到最新版本的路径都是完整的。切记不要在onUpgrade里删表重建,那等于把用户的收藏数据全清空了,属于严重事故。
最后提一下批量导入导出。SQLite数据库文件本身可以直接拷贝备份,但跨应用或跨平台迁移时,导出成JSON或CSV更通用。导出几千条数据时记得用事务包裹插入操作,逐条裸插入每条都会触发一次磁盘同步,速度能差出两个数量级:
db.beginTransaction();
try {
for (PoiItem item : importList) {
db.insertWithOnConflict("favorite_poi", null,
toContentValues(item), SQLiteDatabase.CONFLICT_IGNORE);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
总的来说,用SQLite做兴趣点收藏,核心在于前期把表结构设计扎实、把高频查询路径优化到位,后期再通过同步机制和规范的数据库版本管理保障数据安全。把这几块做稳,一个体验良好的离线收藏功能就成型了。