本节摘要:本地数据存储让应用"关掉再打开,数据还在"。RN 用 AsyncStorage,Flutter 用 shared_preferences,都是键值对形式的轻量存储。本节讲清两者的用法(存、读、删、清)、JSON 序列化的处理、数据迁移的版本意识,以及"何时用键值存储、何时上数据库"的分层判断。
阅读完本节,你应当能够:
想一想:一个笔记应用,用户写了一条笔记,关掉应用再打开,笔记还在吗?如果不做本地存储,答案是不在——应用一重启,内存里的数据全部清空。本地存储就是解决"数据在应用之外存活"的问题。
一个反直觉的点先讲清楚:本地存储不是万能的。它擅长存"少量、简单、键值对"的数据——用户偏好、登录状态、轻量配置;不擅长存"大量、结构化、需要查询"的数据——那要交给数据库。很多新手把复杂数据硬塞进键值存储,用"自己约定的 JSON 结构"模拟查询,最后维护成一团乱麻。理解这个分层,是本节最重要的认知。
另一个容易被忽略的点是读写的异步性。键值存储的读写都是异步操作(RN 的 Promise、Flutter 的 Future)。这意味着"存完立刻读"要按异步顺序写代码,而不是顺序执行两条语句。新手常在这上面栽跟头:存数据的代码还没执行完,读数据的代码已经开始跑了。养成"存完再读、读结果再渲染"的习惯,存储逻辑就稳了。

这张图的分层一句话:键值存储管"偏好",数据库管"数据"。分清"这只是个偏好"还是"这是用户的数据",存储方案自然选对。
键值存储的操作本身很简单,把"存、读、删"画成流程,一眼就能看清:
AsyncStorage 是基于 Promise 的异步键值存储,底层在 iOS 用 UserDefaults、Android 用 SharedPreferences。
存数据:
import AsyncStorage from '@react-native-async-storage/async-storage'; const saveUser = async (id, name) => { try { await AsyncStorage.setItem('user_id', id); await AsyncStorage.setItem('user_name', name); } catch (error) { console.error('保存失败:', error); } };
读数据:
const loadUser = async () => { try { const id = await AsyncStorage.getItem('user_id'); const name = await AsyncStorage.getItem('user_name'); return id && name ? { id, name } : null; } catch (error) { console.error('读取失败:', error); return null; } };
删数据:
await AsyncStorage.removeItem('user_id');
读数据代码里有一句 id && name ? ... : null,这个判空不是可有可无的防御,而是必须的。getItem 对不存在的 key 返回 null,对存在但值为空字符串的 key 返回空串。如果不判空直接使用,id.length 这类调用会直接崩掉。
判空的另一个作用是区分"没存过"与"存了空值"。这是键值存储的常见语义陷阱:判断"用户是否登录过",不能只看 key 是否存在,还要看值是否有效。统一处理方式是在读取后做一次显式校验,返回 null 或默认值。这个习惯在本地存储里叫"防御式读取",是数据可靠性的第一道防线。
shared_preferences 是 Flutter 官方的轻量存储插件,API 风格更直接:
import 'package:shared_preferences/shared_preferences.dart'; // 存 final prefs = await SharedPreferences.getInstance(); await prefs.setString('user_name', '张三'); // 读 final name = prefs.getString('user_name'); // 删 await prefs.remove('user_name');
注意 shared_preferences 的读写是异步的(Future),但 API 设计比 AsyncStorage 更贴近"直接操作"的直觉。
shared_preferences 有个新手容易忽略的点:getInstance 是异步获取实例,每次读写前都要拿到这个单例。而且它按数据类型提供了多组 API:setString、setInt、setDouble、setBool、setStringList。选对类型,读取时不用手动转换。
final prefs = await SharedPreferences.getInstance(); // 按类型存 await prefs.setBool('is_logged_in', true); await prefs.setInt('visit_count', 3); await prefs.setStringList('recent_searches', ['flutter', 'dart']); // 按类型读 final loggedIn = prefs.getBool('is_logged_in');
这组类型化 API 是键值存储"便捷性"的体现:你声明"我要存一个布尔",框架就保证读回来还是布尔,类型转换由库替你完成。相比之下 AsyncStorage 只有字符串一个通道,对象转换必须自己处理。两套 API 风格不同,但底层的"键值对"本质一致。
键值存储只能存基本类型。要存对象或数组,必须先用 JSON 序列化:
// RN:对象转字符串存储 const saveTodos = async (todos) => { await AsyncStorage.setItem('todos', JSON.stringify(todos)); }; const loadTodos = async () => { const raw = await AsyncStorage.getItem('todos'); return raw ? JSON.parse(raw) : []; };
// Flutter:同款逻辑 final prefs = await SharedPreferences.getInstance(); await prefs.setString('todos', jsonEncode(todos)); final raw = prefs.getString('todos'); final todos = raw != null ? jsonDecode(raw) : [];
序列化是键值存储处理复杂数据的标准方式。记住"存字符串、取回来解析"这对操作,就掌握了键值存储的全部诀窍。
存储的键名一旦定下来,后续改动有版本问题。比如字段名从 user_name 改成 username,旧数据还在旧键下,新代码读不到——用户的数据"丢了"。这是键值存储的经典坑。
解决思路是版本化存储:存一个版本号,读取时先看版本,按版本决定数据迁移逻辑。版本号可以这样设计:存 schema_version 键,值从 1 递增;升级数据格式时,旧版本读到先迁移再使用。
对初学者,这个问题的现实意义是:上线前想清楚键名与结构,别频繁改名。一旦应用发布、用户产生数据,改键名就要写迁移逻辑。设计阶段多花一分钟想结构,上线后少写十段迁移代码。
| 操作 | React Native | Flutter |
|---|---|---|
| 存 | setItem(key, value) | setString / setInt 等 |
| 读 | getItem(key) | getString / getInt 等 |
| 删 | removeItem(key) | remove(key) |
| 清空 | clear() | clear() |
| 对象存取 | JSON.stringify / parse | jsonEncode / jsonDecode |
坑一:忘序列化直接存对象。AsyncStorage 只接受字符串,传对象会静默失败或存成无效值。对象必须先 stringify。
坑二:读取结果没判空。key 不存在时 getItem 返回 null、getString 返回 null。不判空直接使用会崩。
坑三:存储体积无节制。键值存储适合小数据,塞大列表会拖慢读写。数据变大的信号出现时,考虑数据库。
⚠️ 常见坑:把登录凭证存进 AsyncStorage 或 shared_preferences 明文存储。这些是普通存储,没有加密,敏感信息应使用安全存储方案(如 iOS 的 Keychain、Android 的 Keystore 封装)。
💡 关键直觉:把键值存储想成"应用的口袋"——放得下的小东西随身带,放不下的大东西用背包(数据库)。口袋塞不下时换背包,而不是把背包塞进口袋。
给待办应用加持久化:添加待办时存进本地,启动应用时读出来恢复列表。做完后关掉应用再打开,待办列表还在——这正是本地存储最直观的体验。如果后续数据变复杂(比如加搜索、排序),再考虑升级数据库方案。
问:什么时候该从键值存储升级到数据库? 出现三个信号时考虑升级:数据量超过几百条且持续增长、需要按条件查询或排序、数据有明确的关联结构(如用户与订单)。升级是"需求驱动"的,不是"提前规划"的——小项目用键值存储足够了,别过早引入数据库。
问:AsyncStorage 和 shared_preferences 数据会丢吗? 正常情况下不会,但要理解它们的存储位置:iOS 用 UserDefaults、Android 用 SharedPreferences,都是系统级持久化。卸载应用会清除数据;系统清理缓存一般不触碰这类存储。真正的风险是误用——比如把大体积数据塞进去导致写入失败。
问:存图片、音频这类大文件怎么办? 键值存储不适合存大文件。大文件应该用文件系统:RN 与 Flutter 都有文件操作库(路径获取、读写文件)。存储选型是个光谱:小数据用键值、中数据用文件、大数据用数据库。理解这条光谱,按数据规模选方案即可。
问:多设备同步数据用什么? 本地存储是"单设备"的。多设备同步需要云服务(后端 API 加数据库),本地存储只做离线缓存。设计时要分清"哪些数据属于设备本地"(偏好设置)、"哪些属于用户云端"(业务数据)。这个边界想清楚,架构才清晰。
设计本地存储时,用"数据生命周期"来思考比"数据存在哪"更有用。一份数据有三种生命周期:会话级(应用运行期间,用内存)、设备级(关应用还在,用键值存储或文件)、云端级(多设备同步,用服务器数据库)。先判断数据属于哪个级别,再选存储方案,顺序不会错。最常见的错误是"拿设备级存储存会话数据"或"拿云端数据库解决一切"——前者浪费资源,后者过度设计。这个框架在真实项目中能帮你快速定位存储需求。
本地存储与应用的启动流程有个常见配合:应用启动时读取本地数据,用于"恢复上次状态"。这个流程要处理好"读取是异步的"——启动时界面先渲染,数据到了再更新。RN 里用 useEffect 加异步读取、Flutter 里在 initState 发起读取、数据到达后 setState。常见错误是在启动时就同步等待数据,导致白屏。正确做法是界面先用默认值渲染,数据到达后无缝更新。这个"先渲染后填充"的模式,与第 6.2 节网络加载的三态管理是同一个思路,两处可以互相印证。
本地存储与后端数据不是"二选一",而是"分工合作"。典型模式是缓存回退:优先展示本地缓存(快),同时请求后端(新),数据到达后更新界面并刷新缓存。这个模式让应用在弱网或离线时仍可用,联网后自动同步。实现时要注意版本一致性与冲突处理——本地与云端数据不一致时以谁为准。入门阶段先把"本地存、本地读"做扎实,缓存回退是进阶方向。但知道这个模式存在,你对"为什么既要本地又要后端"的理解会更完整。
本地存储的数据都在用户设备上,凡是涉及隐私(登录凭证、支付信息、个人资料),都不能用明文键值存储。正确的做法是使用系统安全存储:iOS 的钥匙串、Android 的加密存储,或使用封装了安全存储的库。判断"这条数据要不要加密"有个简单标准:如果它泄露出去会让用户受损,就加密。业务数据(待办、偏好)明文存储问题不大,但凭证类数据必须走安全通道。这个红线从一开始就守住,能避免很多事后补课。
数据能落地了,下一步让界面"能玩"——手势与用户交互,从点击到滑动拖拽。