
1. 项目缘起与整体设计思路1.1 为什么选宠物生命周期管理这个方向养宠物这件事表面上看是喂食、遛弯、看病实际上背后是一条完整的时间线领养或购买、疫苗接种、驱虫、绝育、日常健康记录、体重变化、行为习惯养成、老年期慢病管理直到最后的告别。我身边养猫养狗的朋友几乎都有一个共同的痛点——信息散落在微信聊天记录、纸质疫苗本、手机备忘录、宠物医院的系统里想查一个“上次驱虫是什么时候”得翻半天。这就是我决定做这个项目的出发点用一套结构化的数据模型把宠物的整个生命周期管起来。而实现手段我选择了 Claude Code 作为主力开发工具从零开始搭建一个完整的 App。为什么不用传统的“自己写代码 偶尔问 AI”模式因为我想验证一件事在 2025 年这个时间点一个有一定编程基础但不想从零手写每一行代码的人能不能靠 AI 把项目从空目录推到可运行状态。这个项目适合几类人参考一是想学 AI 辅助开发完整流程的开发者二是想做一个自己用的小工具的产品爱好者三是对宠物健康管理有需求、又愿意折腾技术的铲屎官。哪怕你只会一点 JavaScript跟着思路走也能搭出个能跑的版本。1.2 技术选型背后的取舍逻辑在动手之前我先花了一个下午做技术选型。这不是浪费时间而是因为 AI 辅助开发有个特点你给它的技术栈越主流、文档越丰富它生成的代码质量越高。如果选一个冷门框架Claude Code 可能会给你编出根本不存在的 API。最终的技术栈是这样的层级选型选择理由前端框架React Native Expo一套代码跑 iOS 和 AndroidExpo 免去原生环境配置的坑状态管理Zustand比 Redux 轻太多AI 生成代码时不容易写出冗余样板本地存储SQLite (expo-sqlite)宠物数据是结构化关系数据SQLite 比 AsyncStorage 更合适后端服务Supabase自带 Postgres、认证、实时订阅省去自己搭服务器AI 开发工具Claude Code命令行交互能直接读写项目文件适合全流程开发版本管理Git每完成一个模块就提交方便回滚 AI 改坏的代码这里重点说两个决策。第一为什么用 Expo 而不是纯 React Native CLI因为纯 CLI 要配 Xcode 和 Android Studio光是环境就能耗掉半天而 Expo 的npx create-expo-app一条命令就能跑起来AI 生成的项目结构也更标准。第二为什么后端选 Supabase 而不是自己写 Node 服务因为宠物管理 App 的核心是数据 CRUD 和用户认证这些 Supabase 全都现成我只需要专注业务逻辑AI 生成前端代码时也不用操心接口定义。提示技术选型阶段一定要让 Claude Code 参与讨论把候选方案列给它让它分析每个方案的优缺点。我实测下来它在对比主流框架时给出的建议相当靠谱但涉及冷门库时会有幻觉需要自己核实。1.3 项目模块拆解与开发顺序一个宠物生命周期管理 App功能可以无限扩展但第一版必须聚焦核心。我把它拆成了五个模块按依赖关系排序开发数据模型层定义宠物、疫苗记录、驱虫记录、体重记录、就诊记录这几张核心表的结构本地存储层封装 SQLite 的增删改查操作基础 UI 层宠物列表页、宠物详情页、添加宠物表单记录管理层疫苗、驱虫、体重、就诊四类记录的录入与展示提醒与统计层下次疫苗到期提醒、体重趋势图、健康时间线这个顺序的逻辑是“先有数据再有界面先有基础再有扩展”。如果一上来就做漂亮的 UI后面数据模型一改所有界面都要返工。我踩过这个坑所以强烈建议按数据优先的顺序来。2. 用 Claude Code 搭建项目骨架的实操细节2.1 初始化项目与目录结构约定第一步是创建 Expo 项目。我在终端里执行npx create-expo-app pet-lifecycle --template blank-typescript cd pet-lifecycle用 TypeScript 模板而不是 JavaScript是因为 AI 生成代码时类型定义能帮它自己发现很多错误减少我后期调试的工作量。项目创建好后我没有急着写代码而是先让 Claude Code 帮我规划目录结构。我给的提示词大意是“这是一个宠物生命周期管理 App用 Expo TypeScript SQLite请给出一个清晰的目录结构按功能模块划分不要按文件类型划分。”它给出的结构我做了微调最终是这样的pet-lifecycle/ ├── src/ │ ├── db/ # 数据库连接与迁移 │ ├── models/ # 数据模型与类型定义 │ ├── repositories/ # 数据访问层 │ ├── screens/ # 页面组件 │ ├── components/ # 可复用 UI 组件 │ ├── hooks/ # 自定义 hooks │ ├── utils/ # 工具函数 │ └── constants/ # 常量与配置 ├── App.tsx └── package.json为什么按功能模块而不是按文件类型分因为当项目变大时你改一个功能只需要在一个目录里找文件而不是在 components、screens、utils 之间来回跳。这个约定我提前告诉 Claude Code它后续生成代码时就会自动把文件放到正确位置省去大量手动整理。2.2 数据模型设计的关键决策宠物生命周期管理的核心是数据模型。我让 Claude Code 先设计表结构然后我逐条审查。这里有个经验AI 设计的数据模型往往“够用但不够好”它倾向于把所有字段塞进一张表而实际业务需要拆分。最终的核心表设计如下-- 宠物主表 CREATE TABLE pets ( id TEXT PRIMARY KEY, name TEXT NOT NULL, species TEXT NOT NULL, -- 猫、狗、兔等 breed TEXT, gender TEXT, birthday TEXT, -- ISO 日期字符串 adopt_date TEXT, avatar_uri TEXT, is_neutered INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- 疫苗记录表 CREATE TABLE vaccine_records ( id TEXT PRIMARY KEY, pet_id TEXT NOT NULL, vaccine_name TEXT NOT NULL, dose_number INTEGER, vaccinated_at TEXT NOT NULL, next_due_at TEXT, vet_name TEXT, notes TEXT, FOREIGN KEY (pet_id) REFERENCES pets(id) ON DELETE CASCADE ); -- 驱虫记录表 CREATE TABLE deworm_records ( id TEXT PRIMARY KEY, pet_id TEXT NOT NULL, type TEXT NOT NULL, -- 体内 / 体外 product_name TEXT, dewormed_at TEXT NOT NULL, next_due_at TEXT, notes TEXT, FOREIGN KEY (pet_id) REFERENCES pets(id) ON DELETE CASCADE ); -- 体重记录表 CREATE TABLE weight_records ( id TEXT PRIMARY KEY, pet_id TEXT NOT NULL, weight_kg REAL NOT NULL, recorded_at TEXT NOT NULL, notes TEXT, FOREIGN KEY (pet_id) REFERENCES pets(id) ON DELETE CASCADE ); -- 就诊记录表 CREATE TABLE visit_records ( id TEXT PRIMARY KEY, pet_id TEXT NOT NULL, visit_date TEXT NOT NULL, hospital TEXT, diagnosis TEXT, treatment TEXT, cost REAL, notes TEXT, FOREIGN KEY (pet_id) REFERENCES pets(id) ON DELETE CASCADE );这里有几个设计决策值得说明。第一主键用 TEXT 存 UUID 而不是自增整数因为后续要同步到云端UUID 能避免多端冲突。第二日期统一用 ISO 字符串存储SQLite 没有原生日期类型字符串比较反而最直观。第三所有记录表都加了ON DELETE CASCADE删宠物时自动清理关联记录避免脏数据。注意让 AI 设计表结构后一定要自己检查外键和级联规则。我第一版生成时漏了级联删除测试时删了一只宠物结果它的疫苗记录全变成了孤儿数据。2.3 数据库迁移与初始化封装SQLite 在 Expo 里的使用需要一点封装。我让 Claude Code 生成一个数据库初始化模块负责建表和版本管理。核心思路是用PRAGMA user_version记录当前数据库版本每次启动时检查版本号低于当前版本就执行迁移。// src/db/index.ts import * as SQLite from expo-sqlite; const DB_NAME pet_lifecycle.db; const DB_VERSION 1; let db: SQLite.SQLiteDatabase | null null; export async function getDatabase(): PromiseSQLite.SQLiteDatabase { if (db) return db; db await SQLite.openDatabaseAsync(DB_NAME); await migrate(db); return db; } async function migrate(database: SQLite.SQLiteDatabase) { const result await database.getFirstAsync{ user_version: number }( PRAGMA user_version ); const currentVersion result?.user_version ?? 0; if (currentVersion 1) { await database.execAsync( CREATE TABLE IF NOT EXISTS pets (...); CREATE TABLE IF NOT EXISTS vaccine_records (...); -- 其余建表语句 ); await database.execAsync(PRAGMA user_version 1); } }这个迁移机制看起来简单但它是项目能持续迭代的基础。后面我加“就诊记录”表时只需要把 DB_VERSION 改成 2加一段if (currentVersion 2)的逻辑老用户的数据库就能平滑升级不用卸载重装。3. 核心功能模块的实现过程3.1 宠物列表与详情页的数据流宠物列表页是整个 App 的入口它的数据流设计直接影响后续所有页面的开发模式。我采用的模式是Repository 层封装数据库操作Hook 层管理状态Screen 层只负责渲染。Repository 层的一个典型函数长这样// src/repositories/petRepository.ts export async function getAllPets(): PromisePet[] { const database await getDatabase(); const rows await database.getAllAsyncPetRow( SELECT * FROM pets ORDER BY created_at DESC ); return rows.map(rowToPet); } export async function createPet(input: CreatePetInput): PromisePet { const database await getDatabase(); const now new Date().toISOString(); const pet: Pet { id: generateUUID(), ...input, createdAt: now, updatedAt: now, }; await database.runAsync( INSERT INTO pets (id, name, species, breed, gender, birthday, adopt_date, avatar_uri, is_neutered, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), [pet.id, pet.name, pet.species, pet.breed, pet.gender, pet.birthday, pet.adoptDate, pet.avatarUri, pet.isNeutered ? 1 : 0, pet.createdAt, pet.updatedAt] ); return pet; }这里有个细节数据库里存的是 snake_case 字段名而 TypeScript 里用 camelCase。我在 Repository 层做了转换这样上层业务代码不用关心数据库命名习惯。这个转换逻辑是我明确要求 Claude Code 加的否则它默认会直接用数据库字段名导致整个项目命名风格混乱。Hook 层用 Zustand 管理宠物列表状态// src/hooks/usePetStore.ts export const usePetStore createPetState((set, get) ({ pets: [], loading: false, async loadPets() { set({ loading: true }); const pets await getAllPets(); set({ pets, loading: false }); }, async addPet(input) { const pet await createPet(input); set({ pets: [pet, ...get().pets] }); }, }));为什么用 Zustand 而不是 React Context因为 Context 在数据频繁更新时会导致大量不必要的重渲染而 Zustand 的选择器机制能精确控制哪个组件订阅哪部分状态。宠物列表页只关心 pets 数组添加宠物表单只关心 addPet 方法互不干扰。3.2 疫苗与驱虫记录的到期提醒逻辑这是整个 App 最有价值的功能之一。宠物疫苗和驱虫都有周期性错过时间对宠物健康有实际影响。我的实现思路是每条记录存一个next_due_at字段App 启动时扫描所有记录找出未来 30 天内到期的在首页顶部显示提醒卡片。计算逻辑封装在一个工具函数里// src/utils/reminder.ts export function getUpcomingReminders( vaccines: VaccineRecord[], deworms: DewormRecord[], withinDays 30 ): Reminder[] { const now new Date(); const threshold new Date(now.getTime() withinDays * 24 * 60 * 60 * 1000); const reminders: Reminder[] []; for (const v of vaccines) { if (!v.nextDueAt) continue; const due new Date(v.nextDueAt); if (due now due threshold) { reminders.push({ id: v.id, petId: v.petId, type: vaccine, title: ${v.vaccineName} 疫苗即将到期, dueDate: v.nextDueAt, daysLeft: Math.ceil((due.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)), }); } } // 驱虫记录同理 return reminders.sort((a, b) a.daysLeft - b.daysLeft); }这里有个容易忽略的点日期比较必须用 Date 对象不能直接比较字符串。虽然 ISO 字符串在大多数情况下字典序等于时间序但一旦涉及时区就会出问题。我让 Claude Code 生成这段代码时特意强调了时区处理它给出的方案是统一用 UTC 存储、本地展示时转换这个思路是对的。实操心得提醒功能测试时不要真的等 30 天。我的做法是临时把withinDays改成 3650这样所有有 next_due_at 的记录都会显示出来方便验证 UI 和排序逻辑。测完再改回去。3.3 体重趋势图的实现与性能考量体重记录如果只是列表展示价值有限。我加了一个简单的折线图让用户直观看到宠物的体重变化趋势。图表库我选了react-native-svg手绘而不是引入重量级的图表库原因是宠物 App 的图表需求很简单就是一条折线加几个数据点用 SVG 手绘完全够用还能避免引入几百 KB 的依赖。核心绘制逻辑// src/components/WeightChart.tsx function buildPath(records: WeightRecord[], width: number, height: number): string { if (records.length 2) return ; const sorted [...records].sort( (a, b) new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime() ); const weights sorted.map(r r.weightKg); const minW Math.min(...weights); const maxW Math.max(...weights); const range maxW - minW || 1; const padding 20; const stepX (width - padding * 2) / (sorted.length - 1); return sorted.map((r, i) { const x padding i * stepX; const y height - padding - ((r.weightKg - minW) / range) * (height - padding * 2); return ${i 0 ? M : L} ${x} ${y}; }).join( ); }这段代码的关键在于坐标映射把体重值域映射到画布高度把记录索引映射到画布宽度。range || 1这个处理是为了防止所有体重相同时除以零。这些边界情况 AI 一开始没考虑到是我测试时发现图表变成一条直线才补上的。4. 常见问题与排查技巧实录4.1 Claude Code 生成代码的典型问题用 AI 辅助开发最大的坑不是它写不出代码而是它写出“看起来对但跑不通”的代码。我整理了几类高频问题问题类型具体表现排查与解决API 幻觉调用了不存在的库方法对照官方文档核实让 AI 重新生成版本不匹配用了旧版 API 语法在提示词里明确库的版本号类型错误TypeScript 报错但逻辑没错让 AI 根据报错信息修复通常一次能好遗漏边界空数组、除零、null 未处理自己审查补充边界判断命名混乱同一概念多种命名提前约定命名规范写进提示词最典型的一次是它给我生成了一个SQLite.openDatabase()的调用但 Expo 的新版本已经改成了openDatabaseAsync()。这种问题靠读代码很难发现必须实际运行才能暴露。所以我的习惯是每让 AI 生成一个模块就立刻跑一次不要攒着一起测。4.2 数据库操作的常见报错与修复SQLite 在移动端的报错信息往往很模糊我踩过的坑包括问题一database is locked。原因是同时发起了多个写操作。解决方法是把写操作串行化或者用事务包裹。我在 Repository 层加了一个简单的队列确保同一时刻只有一个写操作在执行。问题二外键约束不生效。SQLite 默认不开启外键约束需要每次连接时执行PRAGMA foreign_keys ON。这个坑很隐蔽因为不开启也不会报错只是级联删除不工作。我在getDatabase()里加上了这行。问题三日期字符串排序错误。如果存日期时格式不统一比如有的存2025-01-05有的存2025/1/5排序就会乱。我的做法是在 Repository 层强制用toISOString()统一格式写入前校验。提示调试 SQLite 时可以在开发环境加一个日志中间件把所有执行的 SQL 和参数打印出来。这样出问题时一眼就能看到是哪条语句、什么参数导致的。4.3 性能优化的几个实用技巧宠物数据量通常不大但列表页如果记录多了也会卡。我做了三件事第一列表用FlatList而不是ScrollView并且给每个 item 加了React.memo。第二数据库查询加索引在pet_id和recorded_at上建了索引查询速度明显提升。第三图片资源用expo-image而不是原生Image它有更好的缓存机制。索引的创建语句CREATE INDEX IF NOT EXISTS idx_vaccine_pet ON vaccine_records(pet_id); CREATE INDEX IF NOT EXISTS idx_weight_pet_date ON weight_records(pet_id, recorded_at);这两个索引看起来简单但在宠物详情页需要同时查多类记录时效果立竿见影。我实测下来有索引比没索引在 1000 条记录量级下查询快大约 5 到 8 倍。5. 项目扩展方向与个人体会这个项目做完第一版后我发现它的扩展空间比想象中大。比如可以加多宠物家庭共享功能让家人共同维护同一只宠物的记录可以加健康报告导出把疫苗、体重、就诊记录生成一份 PDF 给兽医看还可以接入宠物医院的预约系统把提醒和预约打通。但我想说的是不要一上来就想着做全。我第一版只做了最核心的五个模块代码量控制在 3000 行左右跑通之后才逐步加功能。AI 辅助开发最大的优势是速度快但最大的风险也是速度快——如果你方向没想清楚AI 会帮你快速堆出一堆需要重构的代码。我个人在实际操作中的体会是把 Claude Code 当成一个执行力很强但需要明确指令的初级开发者。你给它的任务越具体、约束越清晰它产出越好。模糊的需求只会得到模糊的代码。每次让它写代码前先想清楚输入是什么、输出是什么、边界情况有哪些把这些写进提示词能省掉后面大量的返工。最后分享一个小技巧每次让 AI 生成一个完整模块后让它自己写一份简短的 README说明这个模块的职责、依赖和用法。这个习惯在项目变大后特别有用因为你自己也会忘记某个文件是干什么的。