storages 持久化存储
storages 持久化存储
storages 提供的是 ScriptX 里的轻量持久化能力,底层基于 SharedPreferences。它很适合保存:
- 开关状态
- 表单配置
- 用户选项
- 少量缓存
- 一些“下次启动还要记住”的 JSON 数据
但它并不是数据库,也不适合拿大文件、复杂二进制内容、超大结构去硬塞。
当前版本还有一个很重要的变化要先说清楚:
- 现在
storages的主存储后端,是“当前运行时Context对应的本地SharedPreferences” - 旧版本留下来的远端共享存储,不再是主写入位置,只会在打开空间时作为迁移来源
- 所以你会感觉它“好像不再全局共享了”,这不是错觉,是实现确实改了
这一页会重点把几个特别容易搞混的地方讲透:
storages和storage到底是什么关系storage.get()在 key 不存在时到底返回什么storage.put()的值到底哪些能存、哪些不能存undefined、null、函数、循环引用对象各自会怎样clear()和storages.remove()到底删的是哪一层- 同名空间为什么现在不一定跨宿主、跨入口共享
- 旧版共享数据现在会怎么迁移、什么时候会重新冒出来
先记住这几件事
storages.create(name)返回的是一个“命名存储空间实例”,不是普通对象。storages只负责“创建 / 删除命名空间”,真正读写数据的是storage.get()、storage.put()这些实例方法。storage.get(key)在 key 不存在时返回undefined;只有你传了第二个参数,才会返回默认值。storage.put(key, value)支持的是 JSON 兼容值,不是任意 JS 值。storage.put(key, undefined)、storage.put(key, function () {})这种顶层值会直接报错。- 对象属性里的
undefined/ 函数会被跳过;数组里的undefined/ 函数会被写成null。 NaN、Infinity、-Infinity这种非有限数字不能存。- 循环引用对象不能存,源码会直接抛异常。
storage.clear()只是清空当前空间里的全部键值;storages.remove(name)才是删除整个命名空间。storages.create("")或者不传名字,也会落到一个真实的命名空间上,只是不推荐这样用。- 同名空间只会在“同一份当前本地存储后端”里复用;脚本如果跑在不同宿主、不同运行入口里,就可能各存各的。
- 旧版远端共享存储现在只作为迁移来源使用,本地已经存在的 key 不会被它覆盖。
- 无宿主运行时不会做这层旧远端迁移。
先分清 storages 和 storage
storages 是命名空间管理器
它只负责两件事:
storages.create(name):创建或打开一个命名空间storages.remove(name):删除整个命名空间
storage 是单个空间实例
storages.create(name) 返回的实例才负责这些事:
storage.get(key, defaultValue?)storage.put(key, value)storage.remove(key)storage.contains(key)storage.clear()
最小心智模型
你可以把它理解成这样:
storages像“仓库管理员”storage像“某一个具体仓库”
例如:
const userStore = storages.create("user_settings");
const cacheStore = storages.create("cache");
这里就已经有两个完全独立的空间了:
user_settingscache
它们之间不会互相串值。
现在还是不是“共享存储”
先说结论
如果你以前把 storages 理解成“同名空间到处都能共用的一份存储”,那这个理解现在需要更新一下。
当前实现里:
- 真正写入的是当前运行时
Context.applicationContext下的本地SharedPreferences - 存储文件名是
scriptx.storage.<name> - 还有一份本地注册表
scriptx.storage.registry,用来记录这个命名空间是否存在过
这意味着:
- 同一个脚本、同一个运行入口里,多次
storages.create("demo")当然还是同一份数据 - 但如果脚本分别跑在不同宿主、不同进程上下文,或者一个跑在宿主里、一个跑在 ScriptX 自己界面里,同名空间就不保证互通
为什么你会感觉“它不共享了”
因为旧版本那种“远端共享存储”现在已经不是主后端了。
源码现在的处理顺序是:
- 先拿到当前运行时的
Context - 基于这个
Context打开本地SharedPreferences - 如果检测到旧版远端存储存在,就把“本地缺失的项”补迁移过来
- 后续读写都走这份本地存储
所以现实效果就是:
- 新写进去的数据,默认只留在当前这份本地后端里
- 不同上下文下,同名空间可能看到的不是同一份数据
- 旧版远端数据只会影响迁移,不再承担“统一共享主存储”的角色
哪些场景最容易看出这个变化
最常见的是下面这类对比:
// 场景 A:在某个宿主应用里运行
const storeA = storages.create("demo");
storeA.put("from", "host-app");
// 场景 B:在 ScriptX 自己的编辑器、项目页或别的运行入口里运行
const storeB = storages.create("demo");
log(storeB.get("from"));
第二段不一定能读到 host-app。
这不是 get() 出问题,而是它们可能根本不在同一份本地存储里。
旧版共享数据现在怎么处理
这一点也很关键。
当前源码仍然会尝试打开旧版远端存储,但用途只剩下“迁移”:
- 旧远端里有、本地没有的 key,会被复制到本地
- 本地已经有的 key,不会被旧远端值覆盖
- 旧注册表里有这个命名空间、本地注册表还没有时,会把这个“存在过”的标记迁过来
换句话说,现在的优先级是:
- 当前本地存储
- 旧远端存储里的缺失项补迁移
而不是“远端和本地同时共享一份主数据”。
无宿主运行时有什么不同
无宿主运行时,源码会直接跳过旧远端迁移。
所以在无宿主运行里:
storages只看当前本地存储- 不会自动去旧远端把缺的 key 补回来
- 这时你看到的数据,更容易和宿主脚本里的结果不一致
storages.create(name)
参数、返回值与默认行为
| 项目 | 内容 |
|---|---|
| 参数 | name: string |
| 返回值 | storage 实例 |
| 不传参数时 | 会把名字当成空字符串 "" |
| 空字符串时 | 仍然会打开一个真实命名空间,不会报错 |
源码里拿名字的方式本质上是:
const name = args.firstOrNull()?.toString().orEmpty();
所以这几种写法都会成立:
storages.create("demo")
storages.create("")
storages.create()
但从项目维护角度看,后两种都不推荐,因为你后面很难一眼看出这个空间到底是干什么的。
最基本的用法
const store = storages.create("demo-config");
它到底存到哪里
如果你想从实现层面把它记准,可以直接记这两件事:
| 项目 | 当前实现 |
|---|---|
| 命名空间数据 | currentContext().applicationContext.getSharedPreferences("scriptx.storage." + name, MODE_PRIVATE) |
| 命名空间注册表 | currentContext().applicationContext.getSharedPreferences("scriptx.storage.registry", MODE_PRIVATE) |
这里的关键点不是“文件名像什么”,而是它取决于 currentContext().applicationContext。
也就是说,真正决定你写到哪里的,是当前脚本运行时拿到的那个 Android Context。
空字符串会落到哪个名字
如果你写的是:
storages.create("");
storages.create();
那最终命中的存储文件名会非常接近:
scriptx.storage.
它不是虚拟空间,也不是临时对象,仍然是一份真实的本地存储。
只是这个名字可读性太差,后面排查问题很不友好,所以不推荐。
多个模块分开存的例子
const settingStore = storages.create("settings");
const networkStore = storages.create("network");
const cacheStore = storages.create("cache");
推荐怎样给名字
建议命名空间名满足这几个特点:
- 稳定
- 可读
- 能看出用途
例如:
user_settingsnetwork_configdraft_cachepanel_state
不太建议:
ademo2temp_new_final- 空字符串
同名空间会怎样
在同一份当前本地存储后端里,同一个名字再次 create(name) 时,打开的是同一个存储空间,不会自动新建一份副本。
const store1 = storages.create("demo-config");
const store2 = storages.create("demo-config");
store1.put("name", "rose");
log(store2.get("name")); // rose
但这句话一定要带上前提去理解:
- 同一个宿主、同一个运行入口里,这个结论通常成立
- 不同宿主、不同入口,或者无宿主运行和宿主运行混着看时,就不保证还是同一份数据
例如:
// 宿主脚本
storages.create("demo-config").put("owner", "host");
// 另一个运行入口
log(storages.create("demo-config").get("owner"));
// 这里可能是 "host",也可能是 undefined
如果你正在排查“为什么同名空间读出来不一样”,优先先怀疑是不是脚本跑在不同 Context 下了。
storages.remove(name)
参数、返回值与删除层级
| 项目 | 内容 |
|---|---|
| 参数 | name: string |
| 返回值 | boolean |
| 删除对象 | 整个命名空间 |
| 不存在时 | 返回 false |
这个 API 删除的是“整个命名空间”,不是命名空间里的某一个 key。
最小例子
const ok = storages.remove("demo-config");
log(ok);
返回值会在什么情况下是 true
源码会先判断这个命名空间是否“存在过”,只要满足下面任意一种情况,就会认为它存在过:
- 注册表里有这个名字
- 这个空间里当前还有数据
然后才会尝试:
- 清空这个空间里的全部键值
- 从命名空间注册表里删掉这个名字
只有这两步都成功,才会返回 true。
返回值会在什么情况下是 false
常见有两种:
- 这个命名空间从来没创建过,也没有任何数据
- 底层删除动作失败
删除后为什么旧值有时会“回来”
这个现象只看表面会很诡异,但结合源码就很好理解。
storages.remove(name) 删除的是“当前本地后端里的这个命名空间”,它会做两件事:
- 清空当前本地存储里的全部键值
- 删除当前本地注册表里的命名空间记录
但如果你设备上还留着旧版本产生的远端共享存储,那么下次再次 storages.create(name) 时,源码还会执行一次“缺失项迁移”:
- 本地没有的 key,会从旧远端补回来
- 本地已经有的 key,不会被覆盖
于是就会出现一种现象:
storages.remove("demo-config");
const store = storages.create("demo-config");
log(store.get("old_key"));
如果 old_key 只存在于历史遗留的旧远端存储里,那么它下一次可能又会被迁回本地。
这个“删了又回来”的现象什么时候常见
通常要同时满足这几个条件才更容易遇到:
- 你以前用过旧版实现,留下过远端共享数据
- 当前不是无宿主运行
- 你现在删除的是本地空间,但旧远端那份历史数据还在
如果你一直只在新实现下使用 storages,没有旧数据包袱,这个现象通常不会出现。
它和 storage.clear() 的区别
这个区别一定要记住:
| 操作 | 删掉什么 |
|---|---|
storage.clear() | 只清空当前空间里的全部键值 |
storages.remove(name) | 删除整个命名空间记录 |
看例子最清楚:
const store = storages.create("network");
store.clear(); // network 这个空间还在,只是里面没内容了
storages.remove("network"); // network 这个命名空间整体删掉
什么时候该用它
- 做“恢复出厂设置”
- 删除某个模块的全部配置
- 清掉某块缓存并且不想保留命名空间痕迹
storage.get(key, defaultValue?)
参数、返回值与缺失行为
| 项目 | 内容 |
|---|---|
| 参数 1 | key: string |
| 参数 2 | defaultValue?: any |
| 返回值 | 任意已存入的 JSON 兼容值,或默认值,或 undefined |
| key 不存在且传了默认值 | 返回默认值 |
| key 不存在且没传默认值 | 返回 undefined |
这是最关键的一条规则
storage.get("missing") 和 storage.get("missing", null) 不是一回事。
| 写法 | 结果 |
|---|---|
store.get("missing") | undefined |
store.get("missing", null) | null |
store.get("missing", false) | false |
store.get("missing", []) | [] |
所以如果你想区分:
- key 根本不存在
- key 存在但值就是
null
那就不要只看 get() 的返回值,最好配合 storage.contains(key) 一起用。
读取布尔值的例子
const enabled = store.get("enabled", false);
读取数字的例子
const retryCount = store.get("retry_count", 3);
读取对象的例子
const profile = store.get("profile", {
id: 0,
name: ""
});
读取数组的例子
const history = store.get("history", []);
明确判断“有没有这个 key”的例子
const value = store.get("missing_key");
if (value === undefined) {
log("missing");
}
默认值参数本身不会自动写回
这一点也很容易误会。
const count = store.get("count", 0);
上面只是“读取不到时返回 0”,并不会顺便把 count=0 写进存储。
如果你要真正补默认值,得自己再写一次:
if (!store.contains("count")) {
store.put("count", 0);
}
storage.put(key, value)
参数、返回值与失败方式
| 项目 | 内容 |
|---|---|
| 参数 1 | key: string |
| 参数 2 | value: any |
| 返回值 | undefined |
| 失败方式 | 不符合规则时直接抛异常 |
这是这组 API 里最需要讲细的一个方法。
顶层值支持哪些类型
最稳妥的理解是:支持 JSON 兼容值,以及能自然转换成 JSON 兼容值的结构。
当前源码支持这些顶层值:
| 顶层值类型 | 是否支持 | 说明 |
|---|---|---|
null | 支持 | 存成 JSON null |
string | 支持 | 原样存 |
number | 支持 | 但必须是有限数字 |
boolean | 支持 | 原样存 |
| 普通对象 | 支持 | 会转成 JSON 对象 |
| 数组 | 支持 | 会转成 JSON 数组 |
Map 风格对象 | 支持 | key 会转字符串 |
Iterable | 支持 | 会转成 JSON 数组 |
| Java 数组 | 支持 | 会转成 JSON 数组 |
CharSequence | 支持 | 会先转成字符串 |
JSONObject / JSONArray | 支持 | 原样当 JSON 结构处理 |
顶层值不支持哪些类型
这些值直接 put() 会抛异常:
| 顶层值 | 是否支持 | 失败原因 |
|---|---|---|
undefined | 不支持 | 顶层 undefined 被明确禁止 |
function () {} | 不支持 | 顶层函数被明确禁止 |
NaN | 不支持 | 不是有限数字 |
Infinity / -Infinity | 不支持 | 不是有限数字 |
| 循环引用对象 | 不支持 | 会触发循环引用检查 |
| 任意复杂 Java 对象 | 不支持 | 不属于 JSON 兼容值 |
例如下面这些都会出问题:
store.put("bad1", undefined);
store.put("bad2", function () {});
store.put("bad3", NaN);
const a = {};
a.self = a;
store.put("bad4", a);
对象属性和数组元素的规则要分开看
这一点是很多人第一次用时最意外的地方。
对象属性里的 undefined / 函数
对象属性中的这类值不会报错,而是会被跳过,不写进去。
store.put("obj", {
a: 1,
b: undefined,
c() {}
});
再读出来时更接近:
store.get("obj"); // { a: 1 }
数组里的 undefined / 函数
数组里的这类值不会被跳过,而是会变成 null。
store.put("arr", [1, undefined, function () {}]);
再读出来时更接近:
store.get("arr"); // [1, null, null]
数字到底能填什么
| 数值 | 是否支持 | 说明 |
|---|---|---|
0、1、3.14、-10 | 支持 | 有限数字 |
NaN | 不支持 | 会抛出 only supports finite numbers |
Infinity | 不支持 | 同上 |
-Infinity | 不支持 | 同上 |
最常见的写入例子
store.put("enabled", true);
store.put("retry_count", 3);
store.put("base_url", "https://example.com");
store.put("profile", {
id: 1,
name: "jsxhook",
vip: true
});
store.put("history", ["a", "b", "c"]);
store.put("nullable", null);
做“读 - 改 - 写”时的标准写法
对象或数组不会自动跟存储同步,所以修改后要手动 put() 回去。
const profile = store.get("profile", {});
profile.vip = true;
store.put("profile", profile);
const history = store.get("history", []);
history.push("new-item");
store.put("history", history);
两条很实用的建议
- 想持久化的数据,尽量先整理成“纯 JSON 结构”再存。
- 如果你不确定值稳不稳,先
JSON.stringify(value)自检一下心里会更有数。
storage.remove(key)
参数、返回值与删除范围
| 项目 | 内容 |
|---|---|
| 参数 | key: string |
| 返回值 | undefined |
| 删除范围 | 只删除当前命名空间里的单个 key |
删除某个配置项的例子
store.remove("temp_token");
删除某个缓存项的例子
store.remove("last_result");
它不会做什么
- 不会删除整个命名空间
- 不会影响其他
storage - 不会返回“删没删成功”的布尔值
所以如果你需要“删完确认还有没有”,请再配合 contains() 看:
store.remove("enabled");
log(store.contains("enabled"));
storage.contains(key)
参数、返回值与适用场景
| 项目 | 内容 |
|---|---|
| 参数 | key: string |
| 返回值 | boolean |
| 作用 | 判断这个 key 是否真实存在 |
为什么它有时比 get() 更靠谱
因为 get() 有默认值逻辑,也可能读到 null,所以在你需要精确区分下面两种情况时,contains() 更稳:
- key 根本不存在
- key 存在,但值就是
null
最常见的写法
if (store.contains("enabled")) {
log("enabled exists");
}
区分“缺失”和“值为空”的例子
if (store.contains("profile")) {
const profile = store.get("profile");
log(JSON.stringify(profile));
} else {
log("profile 不存在");
}
storage.clear()
参数、返回值与清空范围
| 项目 | 内容 |
|---|---|
| 参数 | 无 |
| 返回值 | undefined |
| 清空范围 | 当前命名空间里的全部键值 |
最小例子
store.clear();
它清掉的是什么
它会清空当前空间里的所有 key,但不会把这个空间的名字本身从命名空间记录里删除。
所以你下次再这样写:
const store = storages.create("demo");
store.clear();
const sameStore = storages.create("demo");
sameStore 依然会打开这个叫 demo 的空间,只是内容已经是空的了。
适合哪些场景
- 退出登录后清掉会话数据
- 重置某个模块的全部配置
- 清缓存但保留空间名称
和 storages.remove(name) 再对比一次
| 需求 | 应该用哪个 |
|---|---|
| 保留空间名,只清内容 | storage.clear() |
| 连命名空间一起删 | storages.remove(name) |
常见的“设置 / 获取 / 修改”套路
设置一个布尔开关
store.put("enabled", true);
读取一个布尔开关
const enabled = store.get("enabled", false);
反转布尔开关
store.put("enabled", !store.get("enabled", false));
设置一个字符串
store.put("base_url", "https://example.com");
读取并修改字符串
const host = store.get("base_url", "https://example.com");
store.put("base_url", `${host}/v1`);
设置一个对象
store.put("profile", {
id: 1,
name: "jsxhook",
vip: true
});
读取对象并修改后写回
const profile = store.get("profile", {});
profile.vip = true;
store.put("profile", profile);
设置一个数组
store.put("history", ["a", "b", "c"]);
读取数组并追加后写回
const history = store.get("history", []);
history.push("d");
store.put("history", history);
重置当前空间
store.clear();
storages 模块完整实战例子
这个例子基本把手册里的常见用法都串起来了。
const store = storages.create("demo-config");
const sameStore = storages.create("demo-config");
// 写入 number / string / boolean / null / object / array
store.put("count", 1);
store.put("name", "rose");
store.put("enabled", true);
store.put("empty", null);
store.put("profile", {
nickname: "ScriptX",
tags: ["autojs", "storages"],
level: 5
});
// 读取与默认值
log(store.get("count"));
log(store.get("name", "default"));
log(typeof store.get("missing"));
log(store.get("missing", "fallback"));
log(JSON.stringify(sameStore.get("profile"), null, 2));
// contains / remove
log(store.contains("enabled"));
store.remove("enabled");
log(store.contains("enabled"));
// clear 只清空当前 storage 的全部键值
store.clear();
log(typeof store.get("name"));
// remove 删除整个命名 storage
log(storages.remove("demo-config"));
log(storages.remove("demo-config"));
常见误区与排查
误区 1:storage.get("x", 0) 会自动把 x=0 写进去
不会。它只是“读不到时返回 0”,不等于写回存储。
误区 2:storage.clear() 和 storages.remove(name) 一样
不一样:
clear()清的是内容remove(name)删的是整个命名空间
误区 3:什么 JS 值都能 put()
不能。最稳的思路就是:
- 字符串、数字、布尔、
null - 纯对象
- 纯数组
其余值先怀疑,再验证。
误区 4:对象改完会自动同步回存储
不会。get() 读出来后,你拿到的是脚本层数据;改完还得自己 put() 回去。
误区 5:undefined 和 null 是同一种“空值”
在这组 API 里不是:
- key 不存在且没传默认值:
undefined - key 存在且你存进去的是
null:null
误区 6:同名空间天然就是“全局共享”的
现在不能这样想了。
更准确的说法是:
- 同名空间会在同一份当前本地后端里复用
- 但跨宿主、跨入口、跨运行上下文时,不保证看到同一份数据
如果你需要排查“为什么这边写了那边看不到”,先确认脚本是不是跑在同一个运行环境里。
误区 7:storages.remove(name) 一删就绝对干净
对纯新数据来说,通常是这样。
但如果设备里还留着旧版远端共享存储,下一次打开同名空间时,历史缺失项可能又会被迁回来。
最后一条实战建议
如果你准备把一个数据长期放进 storages,先问自己一句:
“这份数据能不能被我老老实实写成 JSON?”
如果答案是“不能”,那这份数据大概率就不适合直接塞进 storages。
