康心伴Logo
康心伴WellAlly
Health

React Native WatermelonDB离线睡眠追踪应用开发 | WellAlly康心伴

5 分钟阅读

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

关键要点

  1. WatermelonDB专为离线设计:本地优先,后台同步
  2. 响应式查询自动更新UI:数据变化立即反映
  3. 关系查询简化开发:children/relation装饰器
  4. 批量操作高性能:每秒可处理10000+记录
  5. 网络检测自动同步:在线时自动同步数据

常见问题

WatermelonDB vs AsyncStorage?

特性WatermelonDBAsyncStorage
复杂查询✅ 支持❌ 不支持
关系数据✅ 支持❌ 需手动处理
性能✅ 极高⚠️ 一般
响应式✅ 自动❌ 需手动实现

如何处理数据迁移?

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日

免责声明: 本内容仅供教育参考,不能替代专业医疗建议。请咨询医生获取个性化诊断和治疗方案。

#

文章标签

React Native
WatermelonDB
离线数据库
睡眠追踪
本地存储

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅