20、位置数据存储:SQLite存储位置记录、Room数据库集成、位置历史查询

位置数据拿到手了,总得存下来吧?

说实话,早期我做定位开发时,最头疼的就是数据存储。那时候没有Room,大家清一色用原生SQLite。写SQL语句写到吐,还得自己处理线程安全问题。嗯,今天我们就来聊聊位置数据怎么存、怎么查。

20.1 为什么需要本地存储位置数据

你想想看,用户的位置信息是实时变化的。如果每次都要从网络获取历史轨迹,那体验肯定很差。本地存储有几个好处:

  • 离线可用:没网也能查看历史位置
  • 节省流量:不用反复请求服务器
  • 响应快:本地查询毫秒级返回
  • 隐私可控:敏感数据留在本地

我个人习惯把位置数据分为两类:实时轨迹点签到记录。前者是连续的坐标流,后者是用户主动标记的位置。存储策略上略有不同。

20.2 原生SQLite存储位置记录

先说说最基础的方式。Android自带的SQLite,轻量级,不需要额外依赖。我在早期项目里就用它存过骑行轨迹数据。

20.2.1 创建位置表

public class LocationDbHelper extends SQLiteOpenHelper {
    private static final String DB_NAME = "location_history.db";
    private static final int DB_VERSION = 1;

    public static final String TABLE_LOCATIONS = "locations";
    public static final String COL_ID = "_id";
    public static final String COL_LATITUDE = "latitude";
    public static final String COL_LONGITUDE = "longitude";
    public static final String COL_ALTITUDE = "altitude";
    public static final String COL_ACCURACY = "accuracy";
    public static final String COL_SPEED = "speed";
    public static final String COL_BEARING = "bearing";
    public static final String COL_TIMESTAMP = "timestamp";
    public static final String COL_PROVIDER = "provider";

    private static final String CREATE_TABLE =
        "CREATE TABLE " + TABLE_LOCATIONS + " (" +
        COL_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
        COL_LATITUDE + " REAL NOT NULL, " +
        COL_LONGITUDE + " REAL NOT NULL, " +
        COL_ALTITUDE + " REAL, " +
        COL_ACCURACY + " REAL, " +
        COL_SPEED + " REAL, " +
        COL_BEARING + " REAL, " +
        COL_TIMESTAMP + " INTEGER NOT NULL, " +
        COL_PROVIDER + " TEXT" +
        ");";

    public LocationDbHelper(Context context) {
        super(context, DB_NAME, null, DB_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(CREATE_TABLE);
        // 给时间戳加索引,查询会快很多
        db.execSQL("CREATE INDEX idx_timestamp ON " + TABLE_LOCATIONS + "(" + COL_TIMESTAMP + ")");
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // 生产环境要做迁移,不能直接drop
        db.execSQL("DROP TABLE IF EXISTS " + TABLE_LOCATIONS);
        onCreate(db);
    }
}
我的经验:一定要给时间戳字段加索引。有一次我查一个月的数据,没索引时等了十几秒,加了索引后秒出。索引不是越多越好,但时间戳这种查询条件,必须加。

20.2.2 插入位置数据

public long insertLocation(Location location) {
    SQLiteDatabase db = this.getWritableDatabase();
    ContentValues values = new ContentValues();
    values.put(COL_LATITUDE, location.getLatitude());
    values.put(COL_LONGITUDE, location.getLongitude());
    values.put(COL_ALTITUDE, location.hasAltitude() ? location.getAltitude() : null);
    values.put(COL_ACCURACY, location.hasAccuracy() ? location.getAccuracy() : null);
    values.put(COL_SPEED, location.hasSpeed() ? location.getSpeed() : null);
    values.put(COL_BEARING, location.hasBearing() ? location.getBearing() : null);
    values.put(COL_TIMESTAMP, location.getTime());
    values.put(COL_PROVIDER, location.getProvider());
    return db.insert(TABLE_LOCATIONS, null, values);
}

这里有个坑:hasAltitude()hasAccuracy()这些方法一定要判断。我曾经遇到过GPS信号弱时,海拔返回的是0,但实际是无效数据。存进去后查询出来的海拔全是0,用户还以为自己在海平面跑步呢。

20.2.3 查询位置历史

public List<Location> getLocationsBetween(long startTime, long endTime) {
    List<Location> locations = new ArrayList<>();
    SQLiteDatabase db = this.getReadableDatabase();

    Cursor cursor = db.query(
        TABLE_LOCATIONS,
        null,
        COL_TIMESTAMP + " BETWEEN ? AND ?",
        new String[]{String.valueOf(startTime), String.valueOf(endTime)},
        null, null,
        COL_TIMESTAMP + " ASC"
    );

    while (cursor.moveToNext()) {
        Location location = new Location(cursor.getString(
            cursor.getColumnIndexOrThrow(COL_PROVIDER)));
        location.setLatitude(cursor.getDouble(
            cursor.getColumnIndexOrThrow(COL_LATITUDE)));
        location.setLongitude(cursor.getDouble(
            cursor.getColumnIndexOrThrow(COL_LONGITUDE)));
        // ... 其他字段赋值
        locations.add(location);
    }
    cursor.close();
    return locations;
}
注意:Cursor用完后一定要关闭。我见过太多因为忘记close()导致的内存泄漏。建议用try-finally或者Kotlin的use块来保证释放。

20.3 Room数据库集成

原生SQLite写起来太啰嗦了。Google后来推出了Room,说白了就是SQLite的ORM框架。我个人非常推荐,尤其是新项目,直接用Room。

20.3.1 添加依赖

// build.gradle (app)
dependencies {
    def room_version = "2.6.1"
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
    // 如果是Kotlin项目,用kapt
    // kapt "androidx.room:room-compiler:$room_version"
}

20.3.2 定义实体类

@Entity(tableName = "locations")
public class LocationRecord {
    @PrimaryKey(autoGenerate = true)
    private long id;

    @ColumnInfo(name = "latitude")
    private double latitude;

    @ColumnInfo(name = "longitude")
    private double longitude;

    @ColumnInfo(name = "altitude")
    private Double altitude;

    @ColumnInfo(name = "accuracy")
    private Float accuracy;

    @ColumnInfo(name = "speed")
    private Float speed;

    @ColumnInfo(name = "bearing")
    private Float bearing;

    @ColumnInfo(name = "timestamp")
    private long timestamp;

    @ColumnInfo(name = "provider")
    private String provider;

    // 构造函数、getter、setter省略
    // 注意:Room要求字段要么public,要么有getter/setter
}

20.3.3 定义DAO接口

@Dao
public interface LocationDao {
    @Insert
    long insert(LocationRecord record);

    @Insert
    void insertAll(List<LocationRecord> records);

    @Query("SELECT * FROM locations WHERE timestamp BETWEEN :startTime AND :endTime ORDER BY timestamp ASC")
    List<LocationRecord> getLocationsBetween(long startTime, long endTime);

    @Query("SELECT * FROM locations ORDER BY timestamp DESC LIMIT 1")
    LiveData<LocationRecord> getLatestLocation();

    @Query("DELETE FROM locations WHERE timestamp < :beforeTime")
    int deleteOldLocations(long beforeTime);

    @Query("SELECT COUNT(*) FROM locations")
    int getLocationCount();
}

为什么用LiveData?

Room天然支持LiveData,数据变化时自动通知UI更新。比如地图上实时显示当前位置,用LiveData<LocationRecord>就非常方便。不用手动刷新,数据一变,UI跟着变。

20.3.4 创建数据库

@Database(entities = {LocationRecord.class}, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
    public abstract LocationDao locationDao();

    private static volatile AppDatabase INSTANCE;

    public static AppDatabase getInstance(Context context) {
        if (INSTANCE == null) {
            synchronized (AppDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = Room.databaseBuilder(
                        context.getApplicationContext(),
                        AppDatabase.class,
                        "location_history.db"
                    )
                    .fallbackToDestructiveMigration()
                    .build();
                }
            }
        }
        return INSTANCE;
    }
}

这里用了单例模式,避免多次创建数据库实例。我见过有人每次调用都new一个数据库,结果内存里好几个实例,数据还不同步。

20.4 位置历史查询实战

光会存不行,还得会查。实际项目中,查询场景五花八门。我总结了几种常见需求:

20.4.1 按时间段查询轨迹

// 查询今天的位置记录
long todayStart = getStartOfDay(System.currentTimeMillis());
long todayEnd = getEndOfDay(System.currentTimeMillis());
List<LocationRecord> todayRecords = locationDao.getLocationsBetween(todayStart, todayEnd);

20.4.2 分页查询

@Query("SELECT * FROM locations ORDER BY timestamp DESC LIMIT :limit OFFSET :offset")
List<LocationRecord> getLocationsPaged(int limit, int offset);

// 使用示例:每页20条,加载第3页
List<LocationRecord> page3 = locationDao.getLocationsPaged(20, 40);

20.4.3 聚合查询

@Query("SELECT COUNT(*) as count, " +
       "AVG(speed) as avg_speed, " +
       "MAX(speed) as max_speed, " +
       "MIN(timestamp) as start_time, " +
       "MAX(timestamp) as end_time " +
       "FROM locations WHERE timestamp BETWEEN :startTime AND :endTime")
LocationStats getLocationStats(long startTime, long endTime);

这个聚合查询特别有用。比如用户想看看今天跑了多远、最高速度多少,一条SQL就搞定了。

20.5 数据清理策略

位置数据越积越多,不清理的话,数据库会越来越大。我建议定期清理:

  • 按时间清理:保留最近30天的数据,删除更早的
  • 按数量清理:最多保留10万条记录,超出则删除最旧的
  • 按精度清理:精度差的数据(比如accuracy > 100米)可以删除
// 每天凌晨清理一次
public void cleanOldData() {
    long thirtyDaysAgo = System.currentTimeMillis() - 30L * 24 * 60 * 60 * 1000;
    int deleted = locationDao.deleteOldLocations(thirtyDaysAgo);
    Log.d("LocationClean", "清理了 " + deleted + " 条旧数据");
}
我的建议:清理操作放在后台线程执行,别在主线程跑。可以用WorkManager定时执行,或者每次启动App时检查一次。

20.6 知识体系总览

下面这张图,把位置数据存储的核心脉络梳理清楚了:

位置数据存储知识体系 位置数据来源 原生SQLite Room数据库 建表/实体定义 插入/更新 查询/分页 清理/聚合 按时间段查询 分页加载 聚合统计 实时监听(LiveData) 最佳实践:索引优化 + 定期清理 + 后台线程

从这张图可以看出来,位置数据存储其实就两条路:要么用原生SQLite自己造轮子,要么用Room省心省力。我个人强烈推荐Room,它帮你处理了大部分繁琐的事情,比如线程安全、类型转换、编译时SQL校验。

好了,位置数据存储这块就聊到这儿。记住一点:存数据不是目的,查得快、用得稳才是关键。下次你写位置相关的功能时,不妨试试Room,相信你会爱上它的。


公众号:蓝海资料掘金营,微信deep3321