React Native WatermelonDB离线睡眠追踪应用开发
概述
睡眠追踪应用需要可靠的离线支持——用户可能在飞行模式下、睡眠中或网络不稳定时记录数据。
WatermelonDB是一个专为React Native设计的响应式数据库,具有:
- 超高性能:每秒10000+条记录
- 离线优先:本地优先,后台同步
- 响应式查询:自动更新UI
- 类型安全:完整TypeScript支持
技术栈
code
React Native App
├── WatermelonDB # 离线数据库
│ ├── Schema # 数据模型定义
│ ├── Actions # 批量操作
│ └── Relations # 关系管理
├── React Navigation # 路由管理
├── Recharts # 图表可视化
└── NetInfo # 网络状态检测
Code collapsed
项目设置
安装依赖
code
# 安装WatermelonDB
npm install @nozbe/watermelondb
npm install @nozbe/with-observables
# 安装其他依赖
npm install @react-navigation/native
npm install @react-navigation/stack
npm install recharts
npm install @react-native-community/netinfo
# iOS额外依赖
cd ios && pod install
Code collapsed
配置Babel
code
// babel.config.js
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
'react-native-reanimated/plugin',
],
};
Code collapsed
数据模型设计
1. 定义Schema
code
// database/schema.ts
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 1,
tables: [
tableSchema({
name: 'sleep_records',
columns: [
{ name: 'bed_time', type: 'number' }, // 就寝时间戳
{ name: 'wake_time', type: 'number' }, // 起床时间戳
{ name: 'sleep_quality', type: 'number' }, // 睡眠质量 (1-5)
{ name: 'notes', type: 'string', isOptional: true },
{ name: 'created_at', type: 'number' },
]
}),
tableSchema({
name: 'sleep_interruptions',
columns: [
{ name: 'sleep_record_id', type: 'string', isIndexed: true },
{ name: 'timestamp', type: 'number' },
{ name: 'duration', type: 'number' }, // 持续时间(分钟)
{ name: 'reason', type: 'string' }, // 原因
]
}),
tableSchema({
name: 'sleep_factors',
columns: [
{ name: 'sleep_record_id', type: 'string', isIndexed: true },
{ name: 'caffeine', type: 'number' }, // 咖啡因摄入量
{ name: 'alcohol', type: 'number' }, // 酒精摄入量
{ name: 'exercise', type: 'number' }, // 运动时长
{ name: 'screen_time', type: 'number' }, // 屏幕时间
]
})
]
});
Code collapsed
2. 创建模型
code
// database/models/SleepRecord.ts
import { Model } from '@nozbe/watermelondb';
import { field, date, children, relation } from '@nozbe/watermelondb/decorators';
import SleepInterruption from './SleepInterruption';
import SleepFactor from './SleepFactor';
export default class SleepRecord extends Model {
static table = 'sleep_records';
@field('bed_time') bedTime;
@field('wake_time') wakeTime;
@field('sleep_quality') sleepQuality;
@field('notes') notes;
@field('created_at') createdAt;
// 关系定义
@children('sleep_interruptions') interruptions;
@children('sleep_factors') factors;
// 计算属性:睡眠时长
get sleepDuration() {
return (this.wakeTime - this.bedTime) / (1000 * 60); // 分钟
}
// 计算属性:睡眠效率
get sleepEfficiency() {
const totalInterruption = this.interruptions.reduce(
(sum, i) => sum + i.duration, 0
);
return ((this.sleepDuration - totalInterruption) / this.sleepDuration * 100).toFixed(1);
}
}
// database/models/SleepInterruption.ts
import { Model } from '@nozbe/watermelondb';
import { field, relation } from '@nozbe/watermelondb/decorators';
import SleepRecord from './SleepRecord';
export default class SleepInterruption extends Model {
static table = 'sleep_interruptions';
static associations = {
sleep_records: { type: 'belongs_to', key: 'sleep_record_id' }
};
@field('timestamp') timestamp;
@field('duration') duration;
@field('reason') reason;
@relation('sleep_records', 'sleep_record_id') sleepRecord;
}
// database/models/SleepFactor.ts
import { Model } from '@nozbe/watermelondb';
import { field, relation } from '@nozbe/watermelondb/decorators';
import SleepRecord from './SleepRecord';
export default class SleepFactor extends Model {
static table = 'sleep_factors';
static associations = {
sleep_records: { type: 'belongs_to', key: 'sleep_record_id' }
};
@field('caffeine') caffeine;
@field('alcohol') alcohol;
@field('exercise') exercise;
@field('screen_time') screenTime;
@relation('sleep_records', 'sleep_record_id') sleepRecord;
}
Code collapsed
数据库初始化
配置数据库
code
// database/index.ts
import WatermelonDB from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import { schema } from './schema';
import SleepRecord from './models/SleepRecord';
import SleepInterruption from './models/SleepInterruption';
import SleepFactor from './models/SleepFactor';
const adapter = new SQLiteAdapter({
schema,
dbName: 'SleepTracker',
jsi: true, // 使用JSI提升性能
onSetUpError: (error) => {
console.error('数据库设置失败:', error);
}
});
export const database = new WatermelonDB({
adapter,
modelClasses: [SleepRecord, SleepInterruption, SleepFactor],
actionsEnabled: true,
});
// 数据库操作帮助类
export class SleepDatabase {
static async createRecord(recordData) {
await database.write(async () => {
const record = await database.get('sleep_records').create(record => {
record.bedTime = recordData.bedTime;
record.wakeTime = recordData.wakeTime;
record.sleepQuality = recordData.sleepQuality;
record.notes = recordData.notes || '';
record.createdAt = Date.now();
});
// 创建关联的睡眠因素
if (recordData.factors) {
await record.createFactor(factor => {
factor.caffeine = recordData.factors.caffeine || 0;
factor.alcohol = recordData.factors.alcohol || 0;
factor.exercise = recordData.factors.exercise || 0;
factor.screenTime = recordData.factors.screenTime || 0;
});
}
return record;
});
}
static async getRecentRecords(days = 30) {
const records = await database
.get<SleepRecord>('sleep_records')
.query()
.sort('bedTime', 'desc')
.fetch();
const cutoffDate = Date.now() - days * 24 * 60 * 60 * 1000;
return records.filter(r => r.bedTime >= cutoffDate);
}
static async getStatistics(days = 30) {
const records = await this.getRecentRecords(days);
if (records.length === 0) {
return null;
}
const totalDuration = records.reduce((sum, r) => sum + r.sleepDuration, 0);
const avgDuration = totalDuration / records.length;
const avgQuality = records.reduce((sum, r) => sum + r.sleepQuality, 0) / records.length;
const avgBedTime = records.reduce((sum, r) => sum + r.bedTime, 0) / records.length;
const avgWakeTime = records.reduce((sum, r) => sum + r.wakeTime, 0) / records.length;
return {
avgDuration: avgDuration,
avgQuality: avgQuality,
avgBedTime: new Date(avgBedTime),
avgWakeTime: new Date(avgWakeTime),
totalRecords: records.length
};
}
static async updateRecord(id, updates) {
await database.write(async () => {
const record = await database.get<SleepRecord>('sleep_records').find(id);
await record.update(record => {
Object.assign(record, updates);
});
});
}
static async deleteRecord(id) {
await database.write(async () => {
const record = await database.get<SleepRecord>('sleep_records').find(id);
await record.markAsDeleted(); // 软删除
await record.destroyPermanently(); // 永久删除
});
}
}
Code collapsed
UI组件
1. 睡眠记录表单
code
// components/SleepRecordForm.tsx
import React, { useState } from 'react';
import { View, Text, TextInput, Button, StyleSheet } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Slider } from '@react-native-assets/slider';
import { SleepDatabase } from '../database';
export function SleepRecordForm({ onSave, onCancel }) {
const [bedTime, setBedTime] = useState(new Date());
const [wakeTime, setWakeTime] = useState(new Date());
const [quality, setQuality] = useState(3);
const [notes, setNotes] = useState('');
const [factors, setFactors] = useState({
caffeine: 0,
alcohol: 0,
exercise: 0,
screenTime: 0
});
const handleSave = async () => {
try {
await SleepDatabase.createRecord({
bedTime: bedTime.getTime(),
wakeTime: wakeTime.getTime(),
sleepQuality: quality,
notes,
factors
});
onSave?.();
} catch (error) {
console.error('保存失败:', error);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>记录睡眠</Text>
{/* 就寝时间 */}
<DateTimePicker
value={bedTime}
mode: "datetime"
onChange={(event, date) => setBedTime(date || bedTime)}
/>
<Text>就寝: {bedTime.toLocaleString('zh-CN')}</Text>
{/* 起床时间 */}
<DateTimePicker
value={wakeTime}
mode: "datetime"
onChange={(event, date) => setWakeTime(date || wakeTime)}
/>
<Text>起床: {wakeTime.toLocaleString('zh-CN')}</Text>
{/* 睡眠质量 */}
<Text style={styles.label}>睡眠质量: {quality}</Text>
<Slider
minimumValue={1}
maximumValue={5}
step={1}
value={quality}
onValueChange={setQuality}
/>
{/* 影响因素 */}
<Text style={styles.label}>咖啡因摄入量</Text>
<Slider
minimumValue={0}
maximumValue={5}
step={0.5}
value={factors.caffeine}
onValueChange={(val) => setFactors({...factors, caffeine: val})}
/>
{/* 其他影响因素... */}
{/* 备注 */}
<TextInput
style={styles.notes}
placeholder: "添加备注..."
value={notes}
onChangeText={setNotes}
multiline
/>
{/* 按钮 */}
<View style={styles.buttons}>
<Button title: "取消" onPress={onCancel} />
<Button title: "保存" onPress={handleSave} />
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
},
label: {
fontSize: 16,
marginTop: 15,
marginBottom: 5,
},
notes: {
height: 100,
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 5,
padding: 10,
marginTop: 15,
},
buttons: {
flexDirection: 'row',
justifyContent: 'space-around',
marginTop: 20,
},
});
Code collapsed
2. 睡眠统计仪表板
code
// components/SleepDashboard.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { LineChart, BarChart } from 'react-native-chart-kit';
import { withDatabase } from '@nozbe/watermelondb/DatabaseProvider';
import { useQuery } from '@nozbe/watermelondb/hooks';
import { SleepDatabase } from '../database';
import SleepRecord from '../database/models/SleepRecord';
function SleepDashboardComponent({ database }) {
const [stats, setStats] = useState(null);
const [chartData, setChartData] = useState(null);
// 响应式查询
const records = useQuery(
database.collections.get<SleepRecord>('sleep_records').query().fetch()
);
useEffect(() => {
loadStatistics();
}, [records]);
const loadStatistics = async () => {
const statistics = await SleepDatabase.getStatistics(30);
setStats(statistics);
// 准备图表数据
const recentRecords = await SleepDatabase.getRecentRecords(7);
const durations = recentRecords.map(r => r.sleepDuration);
const dates = recentRecords.map(r =>
new Date(r.bedTime).toLocaleDateString('zh-CN', { weekday: 'short' })
);
setChartData({
labels: dates.reverse(),
datasets: [{
data: durations.reverse()
}]
});
};
if (!stats) {
return <Text>加载中...</Text>;
}
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>睡眠仪表板</Text>
{/* 统计卡片 */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Text style={styles.statLabel}>平均时长</Text>
<Text style={styles.statValue}>
{Math.round(stats.avgDuration)} 分钟
</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statLabel}>平均质量</Text>
<Text style={styles.statValue}>
{stats.avgQuality.toFixed(1)} / 5
</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statLabel}>平均就寝</Text>
<Text style={styles.statValue}>
{stats.avgBedTime.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})}
</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statLabel}>平均起床</Text>
<Text style={styles.statValue}>
{stats.avgWakeTime.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})}
</Text>
</View>
</View>
{/* 图表 */}
{chartData && (
<View style={styles.chartContainer}>
<Text style={styles.chartTitle}>本周睡眠时长</Text>
<LineChart
data={chartData}
width={350}
height={220}
chartConfig={{
backgroundColor: '#1e2923',
backgroundGradientFrom: '#08130D',
backgroundGradientTo: '#1f2937',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(134, 65, 244, ${opacity})`,
}}
/>
</View>
)}
</ScrollView>
);
}
export const SleepDashboard = withDatabase(SleepDashboardComponent);
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 28,
fontWeight: 'bold',
padding: 20,
},
statsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
padding: 10,
},
statCard: {
width: '45%',
backgroundColor: '#fff',
borderRadius: 10,
padding: 15,
margin: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
statLabel: {
fontSize: 14,
color: '#666',
},
statValue: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
marginTop: 5,
},
chartContainer: {
backgroundColor: '#fff',
margin: 15,
borderRadius: 10,
padding: 15,
},
chartTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 10,
},
});
Code collapsed
离线同步
同步服务
code
// services/SyncService.ts
import NetInfo from '@react-native-community/netinfo';
import { SyncDatabase } from '@nozbe/watermelondb/sync';
import { database } from '../database';
class SyncService {
private isOnline = false;
private syncInProgress = false;
async initialize() {
// 监听网络状态
NetInfo.addEventListener(state => {
this.isOnline = state.isConnected ?? false;
if (this.isOnline) {
this.sync();
}
});
// 定期同步(每5分钟)
setInterval(() => {
if (this.isOnline) {
this.sync();
}
}, 5 * 60 * 1000);
}
async sync() {
if (this.syncInProgress) {
return;
}
this.syncInProgress = true;
try {
await database.write(async () => {
await this.markLocalChangesForSync();
});
// 同步到服务器
await this.syncWithServer();
} catch (error) {
console.error('同步失败:', error);
} finally {
this.syncInProgress = false;
}
}
private async markLocalChangesForSync() {
const collections = ['sleep_records', 'sleep_interruptions', 'sleep_factors'];
for (const collectionName of collections) {
const collection = database.collections.get(collectionName);
const unsyncedRecords = await collection.query().fetch();
for (const record of unsyncedRecords) {
// 标记为需要同步
// 实际实现取决于后端API
}
}
}
private async syncWithServer() {
const timestamp = await this.getLastSyncTimestamp();
// 1. 上传本地更改
await this.pushLocalChanges();
// 2. 拉取服务器更改
await this.pullRemoteChanges(timestamp);
// 3. 更新同步时间戳
await this.updateSyncTimestamp();
}
private async pushLocalChanges() {
// 获取所有未同步的记录
// POST到服务器
// 标记为已同步
}
private async pullRemoteChanges(timestamp: number) {
// GET /api/sync?since={timestamp}
// 合并到本地数据库
// 处理冲突
}
private async getLastSyncTimestamp(): Promise<number> {
// 从本地存储获取最后同步时间
return 0;
}
private async updateSyncTimestamp() {
// 更新最后同步时间
}
}
export const syncService = new SyncService();
Code collapsed
关键要点
- WatermelonDB专为离线设计:本地优先,后台同步
- 响应式查询自动更新UI:数据变化立即反映
- 关系查询简化开发:children/relation装饰器
- 批量操作高性能:每秒可处理10000+记录
- 网络检测自动同步:在线时自动同步数据
常见问题
WatermelonDB vs AsyncStorage?
| 特性 | WatermelonDB | AsyncStorage |
|---|---|---|
| 复杂查询 | ✅ 支持 | ❌ 不支持 |
| 关系数据 | ✅ 支持 | ❌ 需手动处理 |
| 性能 | ✅ 极高 | ⚠️ 一般 |
| 响应式 | ✅ 自动 | ❌ 需手动实现 |
如何处理数据迁移?
code
// schema更新时添加迁移
export const schema = appSchema({
version: 2, // 升级版本号
tables: [/* ... */],
});
// 在app启动时执行迁移
import { migrations } from '@nozbe/watermelondb';
migrations.set(1, async (database) => {
// 迁移逻辑
});
Code collapsed
iOS构建失败?
确保在Podfile中启用了JSI:
code
# Podfile
pod 'WatermelonDB', :path => '../node_modules/@nozbe/watermelondb'
Code collapsed
参考资料
- WatermelonDB官方文档
- React Navigation文档
- Recharts文档
- NetInfo文档
发布日期:2026年3月8日 最后更新:2026年3月8日