JSBox 学习路线:从入门到实践 (Gemini版)
前言
这份学习材料根据我们之前互动学习的成果,并结合你提供的专业审阅意见修订而成。它旨在提供一个更准确、更详尽、更符合当前 JSBox API 最佳实践的系统化学习路径。JSBox 是一款强大的 iOS 脚本工具,它允许你使用 JavaScript 语言与 iOS 原生功能深度交互,实现自动化、自定义 UI 和各种实用工具。
本材料将按照以下八个主要阶段展开:
-
快速开始: 了解 JSBox 的基本哲学和代码运行方式。
-
基础接口: 掌握与应用、设备、网络和数据存储相关的核心 API。
-
构建界面: 学习如何使用 JavaScript 定义和布局原生 iOS UI。
-
控件列表: 深入探索 JSBox 提供的各种 UI 控件及其特定用法。
-
数据类型与内置函数: 理解 JavaScript 与 Native 数据转换,并掌握常用辅助函数。
-
Promise 与高级特性: 学习异步编程的最佳实践,以及更多强大的扩展功能。
-
包管理: 了解如何组织大型、模块化、可维护的 JSBox 项目。
-
Objective-C Runtime: 探索 JSBox 的终极武器,直接与 iOS 原生底层交互。
最后,我们将通过一个完整的“提醒应用小程序”案例来综合运用所有知识。
阶段一:快速开始 (Quick Start)
本阶段是 JSBox 学习的起点,旨在让你对 JSBox 的基本哲学、代码风格和运行方式有一个初步的认识。
核心概念
-
JavaScript 驱动: JSBox 脚本基于 JavaScript (支持 ES6 标准语法),如果你对 JavaScript 基础不熟悉,建议先补充相关知识。
-
API 风格: JSBox 提供的所有 API 都以
$符号开头(例如$ui,$http),这是一种约定,便于区分原生 JavaScript 和 JSBox 扩展功能。 -
轻量化与移动优先: JSBox 的 API 设计追求简洁短小,以适应在移动设备上编写代码的便利性。
-
沙盒环境: 每个 JSBox 脚本都在独立的沙盒环境中运行,确保代码的隔离性和安全性。
-
运行方式: 可以在 JSBox 应用内直接编写、通过 URL Scheme 在线安装、通过 VSCode 插件同步编辑,或通过 AirDrop 传输。
常用 API 示例
以下示例展示了最基本的交互和输出。
-
弹出简单提示:
通过
$ui.alert()可以在屏幕上弹出一个原生的提示框。// 弹出文本提示 $ui.alert("Hello, JSBox Learner!"); // 弹出带标题和消息的提示 $ui.alert({ title: "欢迎学习", message: "这是你的第一个 JSBox 提示框。", actions: ["好的"] // 可以自定义按钮文本 }); -
打印日志到控制台:
使用
console.log()或console.info()等标准 JavaScript 控制台方法,可以在 JSBox 应用的内置控制台(通常在编辑器界面左上角的“虫子”图标处)查看输出,这对于调试非常重要。// 打印普通信息 console.log("这是一个普通的日志信息。"); console.info("应用启动成功。"); // 打印警告和错误 console.warn("注意:某个参数可能为空。"); console.error("发生了一个错误!"); -
获取剪贴板内容并预览:
$clipboard.text用于获取系统剪贴板中的文本内容。$ui.preview()可以快速预览数据。// 获取剪贴板文本 const clipboardText = $clipboard.text; if (clipboardText) { // 预览剪贴板内容 $ui.preview({ title: "剪贴板内容", text: clipboardText }); $ui.toast("已获取剪贴板文本并预览。"); } else { $ui.toast("剪贴板中没有文本内容。"); } // 获取剪贴板所有项目(可能包含图片、链接等)并转为 JSON 字符串预览 const clipboardItems = $clipboard.items; if (clipboardItems && clipboardItems.length > 0) { $ui.preview({ title: "剪贴板所有项目", text: JSON.stringify(clipboardItems, null, 2) // 格式化 JSON 输出 }); }
实践与思考
-
在 JSBox 中创建一个新脚本。
-
复制并运行上述示例代码,观察它们的效果和控制台输出。
-
尝试修改提示框的文本,或者在剪贴板中复制不同的内容,再次运行脚本看变化。
阶段二:基础接口 (Foundation APIs)
本阶段深入 JSBox 的核心能力,学习与应用程序、设备、文件系统、网络、数据持久化等相关的基础 API。这些是构建任何实用工具的基石。
核心概念
本阶段涵盖以下主要模块:$app, $device, $http, $cache, $clipboard, $file, $thread, $system, $keychain, $l10n。理解它们各自的职责和常用方法是关键。
常用 API 示例
-
$app- 应用与脚本自身相关:-
获取应用信息:
$app.info获取 JSBox 应用版本、构建号等。 -
控制屏幕休眠:
$app.idleTimerDisabled防止屏幕自动锁定。 -
打开外部应用/URL:
$app.openURL()打开其他应用的 URL Scheme 或网页。 -
脚本环境判断:
$app.env判断脚本当前运行的环境(主应用、Widget、Action Extension 等)。
console.log("JSBox 应用信息:", $app.info); console.log("当前脚本运行环境:", $app.env); // 输出 $env.app, $env.today 等常量 $app.idleTimerDisabled = true; // 禁止屏幕自动锁定 $ui.toast("屏幕将保持常亮。"); // 打开 Safari 访问 Apple 官网 $app.openURL("https://www.apple.com"); // 尝试打开微信 (如果已安装) $app.openURL("weixin://"); -
-
$device- 设备信息与交互:-
获取设备信息:
$device.info获取设备型号、语言、屏幕尺寸、电池状态等。 -
网络类型:
$device.networkType判断当前网络连接类型。const nt = $device.networkType; const mapNetworkType = { 0: "无网络", 1: "Wi‑Fi", 2: "蜂窝" }; console.log("当前网络类型:", mapNetworkType[nt] ?? "未知"); -
触感反馈:
$device.taptic()触发设备振动。 -
深色模式判断:
$device.isDarkMode判断设备当前是否处于深色模式。
const deviceInfo = $device.info; $ui.alert({ title: "设备信息", message: ` 型号: ${deviceInfo.model} 系统版本: ${deviceInfo.version} 屏幕宽度: ${deviceInfo.screen.width} px 屏幕高度: ${deviceInfo.screen.height} px 电池电量: ${(deviceInfo.battery.level * 100).toFixed(0)}% 网络类型: ${(function() { const nt = $device.networkType; const map = { 0: "无网络", 1: "Wi‑Fi", 2: "蜂窝" }; return map[nt] ?? "未知"; })()} 当前是否深色模式: ${$device.isDarkMode ? "是" : "否"} ` }); $device.taptic(0); // 触发轻微振动 $ui.toast("设备轻微振动了一下。"); -
-
$http- 网络请求:-
GET 请求:
$http.get()发送 GET 请求,获取 JSON 或文本数据。 -
POST 请求:
$http.post()发送 POST 请求。 -
文件下载:
$http.download()下载二进制文件。 -
错误处理:
resp.error对象用于判断请求是否成功及获取错误信息。
// 提示:网络请求会异步进行,这里使用 $ui.toast 进行轻提示,避免阻塞式 $ui.alert 抢占焦点 $ui.toast("将发起网络请求获取今日诗词..."); $ui.loading(true); // 显示加载提示 $http.get({ url: "https://v1.hitokoto.cn/?c=a&c=b&c=c&c=d&c=e&c=f&c=g&c=h&c=i&c=j&c=k&encode=json", handler: function(resp) { $ui.loading(false); // 隐藏加载提示 if (resp.error) { $ui.alert("请求失败: " + resp.error.localizedDescription); console.error("HTTP 请求错误:", resp.error); return; } const data = resp.data; // 自动解析为 JavaScript 对象 if (data && data.hitokoto) { $ui.alert({ title: "一言", message: `${data.hitokoto}\n—— ${data.from}` }); console.log("今日一言数据:", data); } else { $ui.alert("未获取到有效数据。"); } } }); // 假设下载一个图片并分享 (此API通常结合 $share) // 运行此代码需要确保剪贴板中有图片 URL,或者直接替换为已知图片 URL /* const imageUrl = $clipboard.link || "https://images.apple.com/v/iphone/home/v/images/home/iphone_15_pro_product_og.png"; if (imageUrl) { $ui.toast("将下载图片并尝试分享..."); $ui.loading(true); // 显示加载提示 $http.download({ url: imageUrl, showsProgress: true, // 显示进度条 message: "下载图片中...", handler: function(resp) { $ui.loading(false); // 隐藏加载提示 if (resp.error) { $ui.alert("图片下载失败: " + resp.error.localizedDescription); return; } if (resp.data) { $share.sheet(resp.data); // 将下载的二进制数据分享 $ui.toast("图片下载完成并已调起分享。"); } } }); } else { $ui.toast("剪贴板中没有图片链接,跳过下载示例。"); } */ -
-
$file- 文件操作:-
读写文件:
$file.read(),$file.write()。 -
列出目录:
$file.list()。 -
创建目录:
$file.mkdir()。 -
判断是否存在:
$file.exists()。 -
特殊路径:
shared://(共享目录),drive://(iCloud Drive),inbox://(AirDrop 导入文件)。 -
重要提示:
$file.read()和$file.write()是同步方法,它们会立即返回结果,不接受handler回调。
const filename = "my_temp_log.txt"; const foldername = "my_data_folder"; // 写入文件 (同步操作) const writeSuccess = $file.write({ data: $data({string: "日志内容:" + new Date().toLocaleString() + "\n"}), path: filename }); if (writeSuccess) { $ui.toast("文件写入成功: " + filename); console.log("文件写入成功,路径示例:", $file.absolutePath(filename)); } else { $ui.alert("文件写入失败!"); } // 读取文件 (同步操作) if ($file.exists(filename)) { const fileData = $file.read(filename); if (fileData && fileData.string) { $ui.alert({title: "文件内容", message: fileData.string}); console.log("文件内容:", fileData.string); } } // 创建文件夹 (同步操作) if (!$file.exists(foldername)) { const mkdirSuccess = $file.mkdir(foldername); if (mkdirSuccess) { $ui.toast("文件夹创建成功: " + foldername); } else { $ui.alert("文件夹创建失败!"); } } // 列出当前脚本沙盒下的所有文件和文件夹 const currentFiles = $file.list("./"); console.log("当前脚本沙盒内容:", currentFiles); // 列出共享目录下的文件 // console.log("共享目录内容:", $file.list("shared://")); // 删除文件 (谨慎使用) // $file.delete(filename); // $ui.toast("文件已删除。"); -
-
$cache- 对象缓存:-
设置缓存:
$cache.set()。 -
获取缓存:
$cache.get()。 -
清除缓存:
$cache.clear()。 -
重要提示:
$cache的set/get/remove/clear方法都是同步的,不接受Async后缀和handler回调。若需异步,请自行用$thread.background包装。
const userSettings = { username: "JSBoxUser", theme: "dark", fontSize: 16 }; // 设置缓存 (同步操作) $cache.set("appSettings", userSettings); $ui.toast("设置已缓存!"); console.log("设置已缓存:", userSettings); // 获取缓存 (同步操作) const data = $cache.get("appSettings"); if (data) { console.log("从缓存中读取:", data); } else { console.log("缓存中未找到数据。"); } // 清除所有缓存 (谨慎使用) // $cache.clear(); // $ui.toast("所有缓存已清除。"); -
-
$clipboard- 剪贴板:-
获取/设置文本:
$clipboard.text。 -
获取/设置图片:
$clipboard.image。 -
清除:
$clipboard.clear()。 -
获取特定类型内容:
$clipboard.link,$clipboard.phoneNumber等。
const currentText = $clipboard.text; $ui.alert({ title: "当前剪贴板文本", message: currentText || "剪贴板为空" }); $clipboard.text = "这段文字是从 JSBox 设置的!"; $ui.toast("剪贴板文本已更新。"); // 尝试获取剪贴板中的链接和电话号码 console.log("剪贴板中的链接:", $clipboard.link); console.log("剪贴板中的电话号码:", $clipboard.phoneNumber); // 清除剪贴板 (谨慎使用) // $clipboard.clear(); // $ui.toast("剪贴板已清空。"); -
-
$thread- 线程与延时:-
主线程:
$thread.main()用于执行 UI 更新等必须在主线程的操作。 -
子线程:
$thread.background()用于执行长时间运行的网络请求、文件操作等,避免阻塞 UI。 -
延时执行:
$delay(),$wait()。 -
最佳实践:
delay不应作为$thread.background或$thread.main的直接参数使用,而是用独立的$delay()函数控制时机。
$ui.loading(true); // 显示加载提示 $ui.toast("后台任务进行中..."); $delay(0.1, () => { // 稍作延时,确保 UI 加载提示显示 $thread.background(function() { console.log("后台线程:任务开始执行..."); // 模拟一个耗时操作 for (let i = 0; i < 10000000; i++) { // 耗时计算 } console.log("后台线程:任务执行完毕。"); // 切换回主线程更新 UI $thread.main(function() { $ui.loading(false); $ui.toast("后台任务完成!"); }); }); }); // $delay 示例 $delay(2, function() { $ui.toast("2 秒后执行的任务。"); }); // $wait (Promise) 示例 async function myAsyncFunction() { $ui.toast("等待 3 秒..."); await $wait(3); $ui.alert("等待结束,弹出 Alert。"); } // myAsyncFunction(); -
-
$system- 系统级别功能:-
屏幕亮度/音量:
$system.brightness,$system.volume。 -
提示: 设置系统音量在 iOS 上通常受限,可能无效或需要用户交互。亮度通常可行。
-
发送短信/邮件: 推荐使用
$message.sms或$message.mail。 -
打开 mailto 链接: 推荐使用
$app.openURL("mailto:...")。
// 设置亮度为 0.5 // $system.brightness = 0.5; // $ui.toast("屏幕亮度已设置为 50%。"); // 设置音量为 0.8 (可能受 iOS 限制而无效) // $system.volume = 0.8; // $ui.toast("系统音量已设置为 80%。"); // 拨打电话 (会弹出确认框) // $system.call("10086"); // 发送短信 (推荐使用 $message.sms) // await $message.sms({ recipients: ["10086"], body: "你好,我想查询话费。" }); // 发送邮件 (推荐使用 $message.mail) // await $message.mail({ to: ["log.e@qq.com"], subject: "测试邮件", body: "这是来自 JSBox 的测试邮件。" }); // 通过 URL Scheme 打开邮件 (推荐) // $app.openURL("mailto:log.e@qq.com?subject=测试&body=来自JSBox"); -
-
$keychain- 钥匙串:- 安全存储敏感数据,如密码。
const KEY = "mySecretPassword"; const DOMAIN = "com.myapp.domain"; // 建议使用 domain 进行隔离 // 设置密码 (同步操作) const setSucceeded = $keychain.set(KEY, "your_secure_password_123", DOMAIN); if (setSucceeded) { $ui.toast("密码已安全存储。"); } else { $ui.alert("密码存储失败。"); } // 获取密码 (同步操作) const password = $keychain.get(KEY, DOMAIN); if (password) { $ui.alert({ title: "获取到密码", message: password }); } else { $ui.toast("未找到密码。"); } // 移除密码 (谨慎使用,同步操作) // $keychain.remove(KEY, DOMAIN); // $ui.toast("密码已移除。"); -
$l10n- 本地化:-
通过
$app.strings定义多语言文本。 -
通过
$l10n()获取当前设备语言对应的文本。 -
注意:
strings/目录下的.strings文件优先级高于$app.strings。
// 假设这是你的脚本入口处定义的本地化字符串 $app.strings = { "en": { "APP_NAME": "My Applet", "GREETING": "Hello, how are you?", "OK": "OK" }, "zh-Hans": { "APP_NAME": "我的小程序", "GREETING": "你好,你好吗?", "OK": "好的" }, "ja": { "APP_NAME": "私のアプリ", "GREETING": "こんにちは、お元気ですか?", "OK": "はい" } }; $ui.alert({ title: $l10n("APP_NAME"), message: $l10n("GREETING"), actions: [$l10n("OK")] }); -
实践与思考
-
选择上述几个你感兴趣的 API 示例,在 JSBox 中创建新脚本并逐个运行。
-
特别是网络请求和文件操作,理解它们同步/异步的特性和错误处理方式。
-
尝试修改示例中的参数,例如改变
$http.get的 URL,或者改变$system.brightness的值。
阶段三:构建界面 (Build UI)
本阶段是 JSBox 视觉编程的核心,学习如何使用 JavaScript 描述和渲染原生 iOS 用户界面。
核心概念
-
UI 树结构: JSBox UI 由嵌套的视图(
view)组成,形成一个树状结构。 -
$ui.render(object): 用于绘制主页面,它的object参数定义了整个页面的结构和属性。 -
$ui.push(object): 用于在导航栈中推入一个新的页面,实现页面跳转和返回。 -
props: 视图的属性,如背景色、圆角、标题等。 -
layout: 使用类似 Masonry 的链式语法(make对象)定义视图的位置和尺寸约束。 -
events: 视图的事件回调,如点击 (tapped)、长按 (longPressed) 等。 -
$(id): 通过视图的id属性,在运行时获取视图实例以便进行操作。 -
Dark Mode适配: 使用动态颜色 ($color({light:..., dark:...})) 和动态图片 ($image({light:..., dark:...})) 自动适应深浅模式。 -
背景色设置: 视图的背景色通过
props.bgcolor设置。如果需要动态修改当前页面的背景色,可以通过$ui.window.bgcolor或$ui.controller.view.bgcolor进行。
常用 API 示例
-
基本页面渲染:
一个最简单的页面包含标题和视图。
$ui.render({ props: { title: "我的第一个 UI 页面", // 页面标题 navBarHidden: false, // 导航栏是否隐藏 bgcolor: $color("systemBackground"), // 页面背景色,推荐使用系统语义色 theme: "auto" // 自动适配深色模式 }, views: [ { type: "label", // 控件类型:标签 props: { text: "Hello, UI World!", // 文本内容 textColor: $color("label"), // 文本颜色,推荐使用系统语义色 font: $font("bold", 28), // 字体:粗体,28号 align: $align.center // 文本居中对齐 }, layout: function(make, view) { make.centerX.equalTo(view.super); // x 轴居中 make.centerY.equalTo(view.super).offset(-50); // y 轴向上偏移 50 make.width.equalTo(view.super).multipliedBy(0.8); // 宽度是父视图的 80% make.height.equalTo(50); // 高度固定 50 }, events: { tapped: function(sender) { $ui.toast("你点击了标签!"); } } } ] }); -
页面跳转 (
$ui.push):实现从一个页面推入另一个页面,通常用于详情页。
$ui.render({ props: { title: "主页面" }, views: [ { type: "button", props: { title: "进入详情" }, layout: $layout.center, // 使用内置布局常量,居中 events: { tapped: function(sender) { $ui.push({ props: { title: "详情页面", bgcolor: $color("systemBackground") }, // 推荐使用系统语义色 views: [ { type: "label", props: { text: "这是详情内容", align: $align.center }, layout: $layout.fill } ] }); } } } ] }); -
动态视图操作 (
$(id)):通过 ID 获取视图实例,并在事件回调中修改其属性。
$ui.render({ views: [ { type: "label", props: { id: "myDynamicLabel", // 设定 ID text: "点击按钮改变我!", textColor: $color("primaryText"), font: $font(20), align: $align.center }, layout: function(make) { make.centerX.equalTo(make.super); make.top.inset(100); make.width.equalTo(250); make.height.equalTo(40); } }, { type: "button", props: { title: "改变文本" }, layout: function(make) { make.centerX.equalTo(make.super); make.top.equalTo($("myDynamicLabel").bottom).offset(30); make.size.equalTo($size(120, 40)); }, events: { tapped: function() { const label = $("myDynamicLabel"); // 通过 ID 获取标签实例 label.text = "文本已改变!" + new Date().getSeconds(); // 修改文本 label.textColor = $color($rgb(Math.random()*255, Math.random()*255, Math.random()*255)); // 随机颜色 $ui.toast("文本已更新!"); } } } ] }); -
复杂布局与链式调用:
使用
make对象进行复杂的相对布局。$ui.render({ views: [ { type: "view", props: { id: "redView", // 为视图添加 ID 以便引用 bgcolor: $color("red"), cornerRadius: 10 }, layout: function(make) { make.left.top.inset(20); // 距父视图左上角各 20 make.width.equalTo(100); make.height.equalTo(100); } }, { type: "view", props: { id: "blueView", bgcolor: $color("blue"), cornerRadius: 10 }, layout: function(make, view) { make.top.equalTo($("redView").top); // 顶部与 redView 对齐 make.left.equalTo($("redView").right).offset(10); // 距 redView 右侧 10 make.size.equalTo($("redView")); // 尺寸与 redView 相同 } }, { type: "view", props: { bgcolor: $color("green"), cornerRadius: 10 }, layout: function(make, view) { make.top.equalTo($("blueView").bottom).offset(10); // 距 blueView 底部 10 make.right.inset(20); // 距父视图右侧 20 make.width.equalTo(view.super).multipliedBy(0.5).offset(-20); // 宽度是父视图的 50% 减去 20 make.height.equalTo(50); } } ] }); -
手势与事件处理:
除了
tapped,还支持longPressed,doubleTapped等,以及底层的touchesBegan/Moved/Ended/Cancelled。$ui.render({ views: [ { type: "view", props: { bgcolor: $color("purple"), userInteractionEnabled: true // 确保视图可以响应用户交互 }, layout: $layout.center, events: { longPressed: function(info) { // 长按事件 $ui.toast(`你长按了!位置: (${info.location.x.toFixed(0)}, ${info.location.y.toFixed(0)})`); }, doubleTapped: function(sender) { // 双击事件 sender.alpha = sender.alpha === 1 ? 0.5 : 1; // 切换透明度 $ui.toast("你双击了!透明度已切换。"); }, touchesBegan: function(sender, location, locations) { // 触摸开始 console.log("触摸开始,触点数量:", locations.length); }, touchesMoved: function(sender, location, locations) { // 触摸移动 // console.log("触摸移动,当前位置:", location); }, touchesEnded: function(sender, location, locations) { // 触摸结束 console.log("触摸结束。"); } } } ] }); -
上下文菜单 (
Context Menu) 与下拉菜单 (Pull-Down Menu):-
上下文菜单: 长按视图弹出。iOS 14+ 推荐直接在
props.menu中定义。 -
下拉菜单: 按钮或导航栏按钮的下拉列表(iOS 14+)。
$ui.render({ props: { title: "菜单示例", navButtons: [ { symbol: "ellipsis.circle", // 使用 SF Symbols tintColor: $color("tintColor"), menu: { // 导航栏按钮的下拉菜单 title: "更多操作", pullDown: true, // 启用 Pull-Down 样式 items: [ { title: "刷新", symbol: "arrow.clockwise", handler: () => $ui.toast("刷新中...") }, { title: "分享", symbol: "square.and.arrow.up", handler: () => $share.sheet("分享内容") }, { title: "高级", items: [ // 子菜单 { title: "设置", symbol: "gearshape", handler: () => $ui.toast("打开设置") }, { title: "关于", symbol: "info.circle", handler: () => $ui.toast("关于脚本") } ] }, { title: "删除数据", symbol: "trash", destructive: true, // 危险样式 handler: () => $ui.alert("确认删除?") } ] } } ] }, views: [ { type: "label", props: { text: "长按我查看上下文菜单\n或点击导航栏按钮", align: $align.center, lines: 0, // 允许多行 textColor: $color("primaryText"), menu: { // 直接在 props 中定义上下文菜单 (iOS 14+ 推荐) title: "上下文操作", items: [ { title: "复制", symbol: "doc.on.doc", handler: () => { $clipboard.text = "长按菜单复制的内容"; $ui.toast("已复制到剪贴板"); }}, { title: "分享", symbol: "square.and.arrow.up", handler: () => $share.sheet("通过上下文菜单分享的内容") } ] } }, layout: $layout.center } ] }); -
实践与思考
-
尝试创建包含多种 UI 元素的页面。
-
练习使用
layout函数精确控制视图的位置和大小。 -
通过
$(id)动态修改视图属性,感受 UI 交互性。 -
在实际设备上切换深色/浅色模式,观察自动适配效果。
阶段四:控件列表 (Components)
本阶段将深入 JSBox 提供的各种具体 UI 控件,了解它们的特有属性和事件,以及如何组合使用它们来构建复杂界面。
核心概念
每个控件都是 view 的子类,继承了 view 的通用属性和事件,同时拥有自己独有的 props 和 events 来实现特定功能。
常用 API 示例
-
label- 标签: 显示不可编辑文本。{ type: "label", props: { text: "这是一个标签", font: $font("ChalkboardSE-Bold", 24), // 自定义字体 textColor: $color("red"), shadowColor: $color("systemGray"), // 文本阴影 align: $align.left, lines: 2 // 最多显示 2 行 }, layout: $layout.fill } -
button- 按钮: 响应点击事件。{ type: "button", props: { title: "点击我", titleColor: $color("white"), bgcolor: $color("blue"), icon: $icon("007", $color("yellow"), $size(20, 20)), // 内置图标 symbol: "folder.fill", // SF Symbols imageEdgeInsets: $insets(0, 0, 0, 10) // 图片边距 }, events: { tapped: (sender) => $ui.toast("按钮被点击了!") }, layout: $layout.center } -
input- 单行输入框: 接受单行文本输入。{ type: "input", props: { placeholder: "请输入文本", type: $kbType.default, // 默认键盘 darkKeyboard: true, // 黑色键盘 text: "初始文本", secure: true // 密码框 }, events: { changed: (sender) => console.log("输入内容变化:", sender.text), returned: (sender) => $ui.alert(`你输入了: ${sender.text}`) }, layout: $layout.fill } -
text- 多行文本框: 接受多行文本输入,支持富文本 (styledText,html)。{ type: "text", props: { placeholder: "请输入多行文本...", editable: true, // 可编辑 selectable: true, // 可选择 insets: $insets(10, 10, 10, 10), // 内边距 styledText: "**粗体** *斜体* [链接](https://jsbox.app)", // Markdown 格式的富文本 // html: "<h1>HTML 内容</h1><p>这是一个 <b>HTML</b> 段落。</p>", // HTML 内容 font: $font(16) }, events: { didChange: (sender) => console.log("文本区域内容变化:", sender.text), didChangeSelection: (sender) => console.log("选中区域变化:", sender.selectedRange) }, layout: $layout.fill } -
list- 列表: 显示垂直滚动的列表数据,支持多区和自定义行模板。{ type: "list", props: { id: "list", // 为列表添加 ID 以便引用 rowHeight: 70, // 固定行高 (推荐在 autoRowHeight 为 false 时设置) autoRowHeight: false, // 设为 true 则需要配合 template 内部的布局约束 // estimatedRowHeight: 60, // 估算行高(与 autoRowHeight 搭配) separatorHidden: false, // 显示分割线 separatorColor: $color("separatorColor"), // 分割线颜色 reorder: true, // 允许长按排序 data: [ { title: "水果", // Section 标题 rows: [ { label: { text: "苹果" }, icon: { symbol: "apple.logo" } }, // 模板数据 { label: { text: "香蕉" }, icon: { symbol: "leaf.fill" } } // SF Symbols 库中存在的符号 ] }, { title: "动物", rows: [ { label: { text: "猫" }, icon: { symbol: "pawprint.fill" } }, // SF Symbols 库中存在的符号 { label: { text: "狗" }, icon: { symbol: "hare.fill" } } // SF Symbols 库中存在的符号 ] } ], template: { // 定义列表每行的模板 views: [ { type: "image", props: { id: "icon", tintColor: $color("tintColor") }, layout: (make) => { make.left.inset(15); make.centerY.equalTo(make.super); make.size.equalTo($size(30, 30)); } }, { type: "label", props: { id: "label" }, layout: (make) => { make.left.equalTo($("icon").right).offset(10); make.right.inset(15); make.centerY.equalTo(make.super); } } ] }, actions: [ // 左滑操作 { title: "删除", color: $color("red"), handler: (sender, indexPath) => { sender.delete(indexPath); // 删除数据 } }, { title: "编辑", color: $color("blue"), handler: (sender, indexPath) => { $ui.toast(`编辑 ${sender.data[indexPath.section].rows[indexPath.row].label.text}`); } } ] }, events: { didSelect: (sender, indexPath, data) => $ui.toast(`选择了: ${data.label.text}`), reorderMoved: (fromPath, toPath) => { // 长按排序时数据源更新 // 你的数据源数组需要在这里进行实际的重新排序操作 console.log(`从 ${fromPath.section}-${fromPath.row} 移动到 ${toPath.section}-${toPath.row}`); }, pulled: (sender) => { // 下拉刷新 $ui.toast("加载更多数据..."); $ui.loading(true); $delay(2, () => { const currentData = $("list").data; // 获取当前数据 const newData = Array(5).fill(0).map((_, i) => ({ label: { text: `新项目 ${currentData[0].rows.length + i + 1}` } })); currentData[0].rows.push(...newData); // 追加到第一个 section $("list").data = currentData; // 重新设置数据 sender.endRefreshing(); // 结束下拉刷新状态 $ui.loading(false); }); }, didReachBottom: (sender) => { // 滚动到底部加载更多 $ui.toast("加载更多数据..."); $ui.loading(true); $delay(1.5, () => { // 模拟网络请求 const currentData = $("list").data; // 注意:如果 data 是纯数组,则 currentData 是 [row1, row2...] // 如果是 sections 模式,则 currentData 是 [{title:..., rows:[...]}] // 这里假设是 sections 模式且只有一个 section const newItems = Array(5).fill(0).map((_, i) => ({ label: { text: `更多项 ${currentData[0].rows.length + i + 1}` }, icon: { symbol: "cloud.fill" } })); currentData[0].rows.push(...newItems); $("list").data = currentData; // 更新数据源 sender.endFetchingMore(); // 结束加载更多状态 $ui.loading(false); }); } }, layout: $layout.fill } -
matrix- 网格: 显示可滚动的网格数据,通常用于图片瀑布流或应用图标。{ type: "matrix", props: { columns: 3, // 3 列 itemHeight: 120, // 固定项高 spacing: 5, // 项间距 data: Array(10).fill(0).map((_, i) => ({ label: { text: `Item ${i + 1}` }, image: { symbol: `star.fill` } // SF Symbols 库中存在的符号 })), template: { views: [ { type: "image", props: { id: "image", tintColor: $color("systemYellow") }, layout: (make) => { make.centerX.equalTo(make.super); make.top.inset(10); make.size.equalTo($size(60, 60)); } }, { type: "label", props: { id: "label", align: $align.center, font: $font(14), textColor: $color("primaryText") }, layout: (make) => { make.centerX.equalTo(make.super); make.top.equalTo($("image").bottom).offset(5); make.width.equalTo(make.super); make.height.equalTo(20); } } ] } }, events: { didSelect: (sender, indexPath, data) => $ui.toast(`点击了: ${data.label.text}`) }, layout: $layout.fill } -
web- 网页视图: 嵌入网页内容,支持 JavaScript 注入和 Native-JS 交互。{ type: "web", props: { url: "https://www.apple.com/cn/", // 加载远程 URL // html: "<h1>本地 HTML</h1><p>你好,世界!</p>", // 或加载本地 HTML 字符串 showsProgress: true, // 显示加载进度条 toolbar: true, // 显示工具栏(前进/后退/刷新) scrollEnabled: true, // JS 注入:在网页加载完成后执行 script: function() { // 这个函数内部的代码会在网页的上下文执行,可以访问 DOM const heading = document.querySelector('h1'); if (heading) { // 通过 $notify 将数据从网页发送到 JSBox Native 端 $notify("webContentLoaded", { text: heading.innerText }); } } }, events: { didFinish: (sender, navigation) => $ui.toast("网页加载完成!"), didFail: (sender, navigation, error) => $ui.alert("网页加载失败: " + error.localizedDescription), decideNavigation: (sender, action) => { // 拦截导航 // 检查 action.requestURL 是否在当前 JSBox 版本中可用,或尝试 action.URL / action.url if (action.requestURL && action.requestURL.startsWith("https://apple.com/cn/iphone/")) { console.log("拦截到 iPhone 页面跳转,阻止!"); return false; // 阻止跳转 } return true; // 允许跳转 }, webContentLoaded: (object) => { // 接收来自网页 $notify 发送的数据 $ui.alert(`来自网页的消息:${object.text}`); } }, layout: $layout.fill } -
picker- 通用选择器 &$picker.date,$picker.color: 弹出滚轮选择器或颜色选择器。$ui.render({ views: [ { type: "button", props: { title: "选择日期" }, layout: (make) => { make.centerX.equalTo(make.super); make.top.inset(50); make.size.equalTo($size(120, 40)); }, events: { tapped: async function() { // 弹出日期选择器 const selectedDate = await $picker.date({ mode: 0, // UIDatePickerMode.DateAndTime,使用 0 或 $datePickerMode.DateAndTime 常量 date: new Date() }); if (selectedDate) { $ui.alert(`你选择了日期: ${selectedDate.toLocaleString()}`); // 使用 toLocaleString 显示更详细 } else { $ui.toast("取消日期选择。"); } } } }, { type: "button", props: { title: "选择颜色" }, layout: (make) => { make.centerX.equalTo(make.super); make.top.equalTo($("button").bottom).offset(20); make.size.equalTo($size(120, 40)); }, events: { tapped: async function() { // 弹出颜色选择器 const selectedColor = await $picker.color({ color: $color("red") }); if (selectedColor) { $ui.alert(`你选择了颜色: ${selectedColor.hexCode}`); $ui.controller.view.bgcolor = selectedColor; // 修改当前页面的背景色 } else { $ui.toast("取消颜色选择。"); } } } }, { type: "button", props: { title: "通用选择器" }, layout: (make) => { make.centerX.equalTo(make.super); make.top.equalTo($("button").bottom).offset(20); make.size.equalTo($size(150, 40)); }, events: { tapped: async function() { // 弹出通用选择器 const result = await $picker.data({ items: [ ["选项 A1", "选项 A2", "选项 A3"], // 第一列数据 ["选项 B1", "选项 B2"] // 第二列数据 ] }); if (result) { $ui.alert(`你选择了: ${result.data[0]} 和 ${result.data[1]} (索引: ${result.selectedRows[0]}, ${result.selectedRows[1]})`); } else { $ui.toast("取消通用选择。"); } } } } ] });
实践与思考
-
选择几个你感兴趣的控件,创建独立脚本尝试其核心功能。
-
特别是
list和matrix,它们的数据源 (data) 和模板 (template) 概念非常重要。 -
通过
$(id)动态修改视图属性,感受 UI 交互性。 -
在实际设备上切换深色/浅色模式,观察自动适配效果。
阶段五:数据类型与内置函数 (Data Types & Built-in Functions)
本阶段旨在让你更深入地理解 JavaScript 值与 Native 值之间的转换机制,以及 JSBox 全局提供的各种便捷辅助函数和常量。
核心概念
-
数据类型构造: JSBox 提供了
$rect,$size,$point,$insets,$color,$font,$data,$image,$icon,$indexPath等函数,用于创建 Native API 所需的特定数据类型。 -
语义化颜色/动态图片:
$color()和$image()支持传入{light:..., dark:...}对象或两个参数来自动适配 Dark Mode。 -
全局常量:
$align,$env,$blurStyle,$contentMode,$kbType,$mediaType,$imgPicker,$alertActionType,$popoverDirection等常量,提供清晰可读的枚举值。 -
通用辅助函数:
$l10n(本地化),$delay(延时),$wait(Promise 延时),$props(获取对象属性名),$desc(获取对象结构) 等。
常用 API 示例
-
UI 几何与颜色:
$ui.render({ props: { title: "几何与颜色", bgcolor: $color("systemBackground"), theme: "auto" }, views: [ { type: "view", props: { // 使用 $rect 定义 frame frame: $rect(20, 20, 100, 100), // 使用 $rgba 定义颜色,带透明度 bgcolor: $rgba(255, 0, 0, 0.7), cornerRadius: 10, borderWidth: 2, borderColor: $color({ light: "#0000FF", dark: "#00FFFF" }) // 动态边框颜色 } }, { type: "label", props: { text: "动态颜色文本", // 使用语义化颜色,自动适配系统深浅模式 textColor: $color("label"), // 推荐使用 systemLabel/label font: $font("bold", 20) }, layout: function(make) { make.centerX.equalTo(make.super); make.top.inset(150); } } ] }); -
数据 (
$data) 与图片 ($image,$icon):// 创建一个包含 Base64 数据的 Data 对象 const base64String = "SGVsbG8sIFdvcmxkIQ=="; // "Hello, World!" 的 Base64 编码 const textData = $data({ base64: base64String, encoding: 4 }); // 4 是 UTF8 编码 console.log("Base64 解码文本:", textData.string); // 从 Data 对象创建 Image (如果它是图片数据) // 假设你有图片文件的 base64 编码,这里用一个很小的透明 GIF 占位 const transparentGifBase64 = "R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="; const imageData = $data({ base64: transparentGifBase64 }); const imageFromData = imageData.image; $ui.render({ views: [ { type: "image", props: { // 从文件路径创建图片(需要确保 assets/test.png 存在) // image: $image("assets/test.png"), // 从 Base64 字符串创建图片 image: $image("data:image/gif;base64," + transparentGifBase64), // 使用 SF Symbols 创建图片 symbol: "person.circle.fill", tintColor: $color("systemGreen"), contentMode: $contentMode.scaleAspectFit // 使用 $contentMode 常量 }, layout: function(make) { make.centerX.equalTo(make.super); make.top.inset(50); make.size.equalTo($size(80, 80)); } }, { type: "label", props: { // 使用 JSBox 内置图标,绿色,尺寸 24x24 icon: $icon("005", $color("systemGreen"), $size(24, 24)), // 推荐使用 systemGreen text: " JSBox 内置图标", font: $font(18), textColor: $color("label") // 推荐使用 label }, layout: function(make) { make.centerX.equalTo(make.super); make.top.equalTo($("image").bottom).offset(20); } } ] }); -
索引与范围 (
$indexPath,$range):在列表和文本操作中非常有用。
const myIndexPath = $indexPath(1, 5); // 第 2 区的第 6 行 console.log(`区: ${myIndexPath.section}, 行: ${myIndexPath.row}`); const myRange = $range(10, 5); // 从索引 10 开始,长度为 5 console.log(`范围起始: ${myRange.location}, 长度: ${myRange.length}`); -
延时 (
$delay,$wait):$ui.toast("将在 1 秒后消失..."); $delay(1, () => { // $ui.clearToast(); // $ui.clearToast 不在所有版本中稳定可用,toast 会自动消失 $ui.alert("提示已消失。"); }); // Promise 方式的延时 async function showDelayedAlert() { $ui.toast("等待 2 秒..."); await $wait(2); $ui.alert("等待结束,弹出 Alert。"); } // showDelayedAlert();
实践与思考
-
尝试使用不同的参数创建颜色和图片,观察它们的显示效果。
-
特别注意
$color和$image的动态适配 Dark Mode 的用法。 -
理解
$data是二进制数据的抽象,而string,image只是它的不同表现形式。
阶段六:Promise 与高级特性 (Promise & Advanced Features)
本阶段是提升代码可维护性和功能复杂度的关键。Promise 和 async/await 解决了异步操作的“回调地狱”问题,而各种扩展 API 和原生 SDK 封装则大大拓宽了 JSBox 的能力边界。
核心概念
-
Promise 基础: 异步操作返回的代表未来值的对象,可使用
.then()和.catch()处理成功和失败。 -
async/await: 基于 Promise 的语法糖,让异步代码看起来像同步代码一样直观。 -
Promise 模式: JSBox API 对 Promise 的支持有两种:
-
required: API 必须处理回调,可直接await或.then()。 -
optional: API 可省略回调,此时需加async: true参数才能await或.then()。
-
-
扩展 API:
$text(文本处理),$qrcode(二维码),$archiver(压缩),$browser(Web 环境),$detector(数据检测),$share(分享),$push(本地通知)。 -
原生 SDK 封装:
$calendar(日历),$reminder(提醒事项),$contact(通讯录),$location(地理位置),$photo(相册/相机),$message(短信/邮件),$safari(Safari 视图控制器)。
常用 API 示例
-
Promise 与
async/await实践:结合 HTTP 请求、用户输入和相册选取。
$ui.render({ props: { title: "Async/Await 演示", theme: "auto" }, views: [ { type: "button", props: { title: "开始异步链" }, layout: $layout.center, events: { tapped: async function() { // 标记为 async 函数 $ui.loading(true); // 显示加载提示 $ui.toast("步骤 1/3: 请求数据..."); try { // 1. 发起网络请求 (required 模式,直接 await) const httpResp = await $http.get("https://fanyi.youdao.com/openapi.do?keyfrom=JSBox_Test&key=139365261&type=data&doctype=json&version=1.1&q=hello"); $ui.loading(false); // 隐藏加载提示 if (httpResp.error) throw new Error("网络请求失败: " + httpResp.error.localizedDescription); const translation = httpResp.data.translation[0]; $ui.toast("步骤 1 完成:翻译成功!"); console.log("翻译结果:", translation); $ui.loading(true); // 重新显示加载提示 $ui.toast("步骤 2/3: 请输入文本,结果将 Base64 编码..."); // 2. 获取用户输入 (required 模式,直接 await) const inputText = await $input.text({ placeholder: "请输入一个短语进行 Base64 编码" }); $ui.loading(false); // 隐藏加载提示 if (!inputText) { $ui.toast("取消输入,停止操作。"); return; } const encodedText = $text.base64Encode(inputText); $ui.toast("步骤 2 完成:文本已编码!"); console.log(`编码前:${inputText}\n编码后:${encodedText}`); $ui.loading(true); // 重新显示加载提示 $ui.toast("步骤 3/3: 请从相册选取一张图片..."); // 3. 从相册选取图片 (optional 模式,需要 async: true) const photoResp = await $photo.pick({ mediaTypes: [$mediaType.image], // quality: $imgPicker.quality.medium, // $imgPicker.quality.medium 不在所有版本中存在 async: true // 关键:告知此调用是 Promise 模式 }); $ui.loading(false); // 隐藏加载提示 if (photoResp && photoResp.image) { $ui.alert({ title: "所有步骤完成!", message: ` 原文: ${inputText} -> 翻译: ${translation} 编码后: ${encodedText} 图片选取成功! ` }); } else { $ui.toast("步骤 3 取消或未选取图片。"); } } catch (e) { // 捕获 Promise 链中的任何错误 $ui.loading(false); $ui.alert("操作失败: " + e.message); // 替换 $ui.error console.error("异步链中捕获到错误:", e); } } } } ] }); -
$text- 文本处理:$text.uuid,base64Encode/Decode,URLEncode/Decode,MD5/SHA,markdownToHtml/htmlToMarkdown。console.log("UUID:", $text.uuid); const originalUrl = "https://www.example.com?param=中文内容&key=value"; console.log("URL 编码:", $text.URLEncode(originalUrl)); console.log("URL 解码:", $text.URLDecode($text.URLEncode(originalUrl))); console.log("MD5:", $text.MD5("JSBox")); console.log("SHA1:", $text.SHA1("JSBox")); console.log("SHA256:", $text.SHA256("JSBox")); const markdownText = "# 标题\n**粗体文本** *斜体文本*"; const htmlText = "<h1>标题</h1><b>粗体文本</b><i>斜体文本</i>"; // Markdown 转 HTML (同步) const convertedHtml = $text.markdownToHtml(markdownText); console.log("Markdown 转 HTML:", convertedHtml); // HTML 转 Markdown (异步) // $text.htmlToMarkdown 是 required 模式,可以直接 await (async () => { const markdown = await $text.htmlToMarkdown({ html: htmlText }); console.log("HTML 转 Markdown:", markdown); })(); -
$qrcode- 二维码操作:encode,decode,scan。$ui.render({ props: { title: "二维码示例" }, views: [ { type: "image", props: { id: "qrcodeImage", // 生成二维码图片 image: $qrcode.encode("https://jsbox.app"), contentMode: $contentMode.scaleAspectFit }, layout: (make) => { make.center.equalTo(make.super); make.size.equalTo($size(150, 150)); } }, { type: "button", props: { title: "扫描二维码" }, layout: (make) => { make.centerX.equalTo(make.super); make.top.equalTo($("qrcodeImage").bottom).offset(30); make.size.equalTo($size(120, 40)); }, events: { tapped: async function() { // 扫描二维码 (required 模式,直接 await) const scanResult = await $qrcode.scan(); if (scanResult) { $ui.alert(`扫描结果: ${scanResult}`); } else { $ui.toast("取消扫描。"); } } } } ] }); -
$archiver- 压缩/解压缩:zip,unzip。// 确保你有文件 'test.txt' 在脚本沙盒根目录 $file.write({ data: $data({string: "Hello world for zip test!"}), path: "test.txt" }); $ui.loading(true); // 显示加载提示 $ui.toast("文件压缩中..."); $archiver.zip({ paths: ["test.txt"], // 要压缩的文件路径数组 dest: "myarchive.zip", // 目标 zip 文件名 handler: async (success) => { // handler 是 required 模式,所以可以直接 async $ui.loading(false); // 隐藏加载提示 if (success) { $ui.toast("文件压缩成功!"); $ui.loading(true); // 重新显示加载提示 $ui.toast("文件解压中..."); // 解压缩 (required 模式,直接 await) const unzipSuccess = await $archiver.unzip({ path: "myarchive.zip", // 压缩包路径 dest: "unzipped_folder" // 解压目标文件夹 }); $ui.loading(false); // 隐藏加载提示 if (unzipSuccess) { $ui.toast("文件解压成功!"); console.log("解压后的文件:", $file.list("unzipped_folder")); } else { $ui.alert("文件解压失败!"); } } else { $ui.alert("文件压缩失败!"); } } }); -
Native SDK 示例 (
$photo,$calendar等):这些 API 提供了与 iOS 系统原生服务交互的能力,通常都需要用户授权。
$ui.render({ props: { title: "Native SDK 示例" }, views: [ { type: "button", props: { title: "选取照片" }, layout: (make) => { make.centerX.equalTo(make.super); make.top.inset(50); make.size.equalTo($size(120, 40)); }, events: { tapped: async function() { $ui.loading(true); try { // $photo.pick 是 optional 模式,需要 async: true const resp = await $photo.pick({ mediaTypes: [$mediaType.image], async: true }); $ui.loading(false); if (resp && resp.image) { $ui.alert("选取照片成功!"); } else { $ui.toast("未选取照片或取消。"); } } catch (e) { $ui.loading(false); $ui.alert("选取照片失败: " + e.message); // 权限拒绝或其它错误 console.error("选取照片错误:", e); } } } }, { type: "button", props: { title: "创建日历事件" }, layout: (make) => { make.centerX.equalTo(make.super); make.top.equalTo($("button").bottom).offset(20); make.size.equalTo($size(150, 40)); }, events: { tapped: async function() { $ui.loading(true); try { const now = new Date(); const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1); // $calendar.create 是 optional 模式,需要 async: true // 这里假设 util.requestPermission 可以正确请求日历权限 // 推荐直接调用 $calendar.create,JSBox 会在首次调用时自动请求权限 const resp = await $calendar.create({ title: "JSBox 学习任务", startDate: now, endDate: tomorrow, notes: "完成 JSBox 学习材料的阅读和实践。", url: "https://jsbox.app", async: true }); $ui.loading(false); if (resp && resp.status) { // 检查 status 属性 $ui.toast("日历事件创建成功!"); } else { $ui.alert("日历事件创建失败或取消。"); } } catch (e) { $ui.loading(false); $ui.alert("创建日历事件失败: " + e.message); // 权限拒绝或其它错误 console.error("创建日历事件错误:", e); } } } } ] });
实践与思考
-
重点练习
async/await,理解其工作原理和错误处理 (try...catch)。 -
选择你感兴趣的扩展 API 或 Native SDK,查阅其完整文档并编写示例。
-
注意需要用户授权的 API,在 JSBox 运行时会弹出授权提示。
阶段七:包管理 (Package Management)
包管理是 JSBox 组织大型、模块化、可维护项目的基础。它允许你将脚本、资源、配置和本地化字符串打包成一个.box文件(本质是zip)。
核心概念
-
包结构: JSBox 包通常包含
main.js,scripts/,assets/,strings/,config.json等目录和文件。 -
模块化 (
require/module.exports): 使用require('path')引入其他脚本模块,并使用module.exports导出模块功能。这有助于代码复用和避免全局污染。 -
资源引用:
assets/目录中的资源可以直接通过assets/filename.png路径引用。 -
本地化文件:
strings/目录中的.strings文件用于定义多语言文本,配合$l10n()使用。 -
配置文件 (
config.json): 定义脚本的元数据(如名称、版本、作者)和一些全局设置。 -
安装方式: 支持通过 AirDrop、文件分享、URL Scheme 等多种方式安装。
示例:一个简单的模块化项目
假设你的项目结构如下(在电脑上创建):
MyPackageScript/
├── main.js
├── config.json
├── strings/
│ ├── en.strings
│ └── zh-Hans.strings
├── scripts/
│ └── data_manager.js
└── assets/
└── icon.png
-
main.js: 主入口文件,负责 UI 渲染和加载模块。// main.js const dataManager = require('./scripts/data_manager'); // 引入数据管理模块 // 定义本地化字符串 ($app.strings 的优先级低于 strings/ 目录下的 .strings 文件) $app.strings = { // 保留此处是为了示例 $app.strings 的用法 "en": { "APP_TITLE": "My Applet", "WELCOME_MSG": "Welcome to Applet!", "LOAD_DATA": "Load Data", "SAVE_DATA": "Save Data", "COUNT": "Count: ", "OK": "OK" }, "zh-Hans": { "APP_TITLE": "我的小程序", "WELCOME_MSG": "欢迎使用小程序!", "LOAD_DATA": "加载数据", "SAVE_DATA": "保存数据", "COUNT": "数量:", "OK": "好的" } }; $ui.render({ props: { title: $l10n("APP_TITLE"), bgcolor: $color("systemBackground"), theme: "auto" }, views: [ { type: "image", props: { id: "image", // 为 image 添加 ID src: "assets/icon.png", // 引用 assets 目录下的图片 contentMode: $contentMode.scaleAspectFit }, layout: (make) => { make.centerX.equalTo(make.super); make.top.inset(50); make.size.equalTo($size(80, 80)); } }, { type: "label", props: { id: "countLabel", text: $l10n("COUNT") + "0", align: $align.center, font: $font(20), textColor: $color("label") }, layout: (make) => { make.centerX.equalTo(make.super); make.top.equalTo($("image").bottom).offset(30); make.width.equalTo(250); make.height.equalTo(30); } }, { type: "button", props: { title: $l10n("LOAD_DATA") }, layout: (make) => { make.centerX.equalTo(make.super); make.top.equalTo($("countLabel").bottom).offset(30); make.size.equalTo($size(150, 40)); }, events: { tapped: async function() { $ui.loading(true); const data = await dataManager.loadData(); // 调用模块方法 $ui.loading(false); $("countLabel").text = $l10n("COUNT") + data.length; $ui.toast("数据加载完成!"); console.log("加载到的数据:", data); } } }, { type: "button", props: { title: $l10n("SAVE_DATA") }, layout: (make) => { make.centerX.equalTo(make.super); make.top.equalTo($("button").bottom).offset(20); make.size.equalTo($size(150, 40)); }, events: { tapped: async function() { $ui.loading(true); const currentData = await dataManager.loadData(); currentData.push({ timestamp: Date.now() }); // 添加新数据 const success = await dataManager.saveData(currentData); // 调用模块方法 $ui.loading(false); if (success) { $("countLabel").text = $l10n("COUNT") + currentData.length; $ui.toast("数据保存成功!"); } else { $ui.alert("数据保存失败!"); } } } } ] }); // 初始加载数据数量 (async () => { const initialData = await dataManager.loadData(); $("countLabel").text = $l10n("COUNT") + initialData.length; })(); -
scripts/data_manager.js: 数据管理模块。// scripts/data_manager.js const DATA_FILE = "my_app_data.json"; /** * 从文件加载数据 * @returns {Promise<Array>} 数据数组 */ async function loadData() { if (!$file.exists(DATA_FILE)) { return []; } try { // $file.read 是同步的,这里 await 仅为统一 async/await 风格 const data = $file.read(DATA_FILE); if (data && data.string) { return JSON.parse(data.string); } } catch (e) { console.error("Error parsing data file:", e); $ui.alert("加载数据失败!"); // 替换 $ui.error } return []; } /** * 保存数据到文件 * @param {Array} data - 要保存的数据数组 * @returns {Promise<boolean>} 是否保存成功 */ async function saveData(data) { try { const dataString = JSON.stringify(data, null, 2); // $file.write 是同步的,这里 await 仅为统一 async/await 风格 const success = $file.write({ data: $data({ string: dataString, encoding: 4 }), // UTF8 path: DATA_FILE }); return success; } catch (e) { console.error("Error saving data file:", e); $ui.alert("保存数据失败!"); // 替换 $ui.error return false; } } // 导出模块的函数 module.exports = { loadData, saveData }; -
strings/en.strings: 英文本地化。"APP_TITLE" = "My Package App"; "WELCOME_MSG" = "Welcome!"; "LOAD_DATA" = "Load Data"; "SAVE_DATA" = "Save Data"; "COUNT" = "Count: "; "OK" = "OK"; -
strings/zh-Hans.strings: 简体中文本地化。"APP_TITLE" = "我的打包应用"; "WELCOME_MSG" = "欢迎!"; "LOAD_DATA" = "加载数据"; "SAVE_DATA" = "保存数据"; "COUNT" = "数量:"; "OK" = "好的";
实践与思考
-
在电脑上按照上述结构创建文件夹和文件,并填充内容。
-
找一张合适的正方形图片(例如 100x100 像素),命名为
icon.png放到assets/文件夹。 -
将整个
MyPackageScript文件夹压缩为MyPackageScript.zip。 -
通过 AirDrop 或其他方式将
MyPackageScript.zip分享到你的 iOS 设备,选择 JSBox 打开并安装。 -
运行该脚本包,测试加载和保存功能,并切换系统语言观察本地化效果。
-
理解
main.js如何作为入口协调各个模块的工作。
阶段八:Objective-C Runtime (Runtime)
Runtime 是 JSBox 最强大的能力,它允许 JavaScript 直接与 iOS 底层的 Objective-C 代码交互,动态调用方法,甚至创建原生类。通常在 JSBox 封装的 API 无法满足需求时使用。
核心概念
-
动态交互:
$objc(className)获取 Objective-C 类,.$methodName()或invoke()调用方法。 -
类型转换:
jsValue()将原生对象转为 JS 对象,ocValue()将 JS 对象转为原生对象。 -
动态创建:
$define()动态创建 Objective-C 类,$delegate()动态创建委托。 -
Block:
$block()将 JavaScript 函数封装为 Objective-C Block。 -
批量导入修正:
const ClassName = $objc("ClassName");是标准的导入方式,不应使用逗号分隔的字符串批量导入。
常用 API 示例
-
调用原生方法:
// 导入常用类 (标准方式:逐个导入) const UIColor = $objc("UIColor"); const UIApplication = $objc("UIApplication"); const NSURL = $objc("NSURL"); // 获取应用程序单例并打开 URL const app = UIApplication.$sharedApplication(); const url = NSURL.$URLWithString("https://www.google.com"); if (app.$canOpenURL(url)) { // 先判断是否能打开 app.$openURL(url); $ui.toast("已尝试打开 Google"); } else { $ui.alert("无法打开 Google"); } // 获取并设置当前屏幕亮度(使用原生 UIScreen API) // const UIScreen = $objc("UIScreen"); // 导入 UIScreen // const screen = UIScreen.$mainScreen(); // const currentBrightness = screen.$brightness(); // $ui.alert(`当前屏幕亮度: ${(currentBrightness * 100).toFixed(0)}%`); // screen.$setBrightness(0.5); // 设置为 50% // $ui.toast("屏幕亮度已修改为 50%"); -
动态创建 Alert Controller (结合 Block):
$ui.render({ props: { title: "原生 Alert 演示" }, views: [ { type: "button", props: { title: "显示原生 Alert" }, layout: $layout.center, events: { tapped: function() { // 创建原生的 UIAlertController const alertController = $objc("UIAlertController").$alertControllerWithTitle_message_preferredStyle_( "原生 Alert 标题", "这个 Alert 是通过 Objective-C Runtime API 创建的!", 1 // UIAlertControllerStyleAlert (常量值 1) ); // 创建原生的 UIAlertAction (按钮) const defaultAction = $objc("UIAlertAction").$actionWithTitle_style_handler_( "好的", 0, // UIAlertActionStyleDefault (常量值 0) $block("void, id", (action) => { // handler 是一个 Block console.log("原生 Alert 的 '好的' 按钮被点击了!"); $ui.toast("你点击了 '好的'。"); }) ); const cancelAction = $objc("UIAlertAction").$actionWithTitle_style_handler_( "取消", 1, // UIAlertActionStyleCancel (常量值 1) $block("void, id", (action) => { console.log("原生 Alert 的 '取消' 按钮被点击了!"); $ui.toast("你点击了 '取消'。"); }) ); // 添加 Action 到 Alert Controller alertController.$addAction(defaultAction); alertController.$addAction(cancelAction); // 获取当前视图控制器 (UIViewController) 并呈现 Alert const currentVC = $ui.controller.ocValue(); // $ui.controller 是 JSBox 对象,需要 .ocValue() 转换为 Objective-C 对象 currentVC.$presentViewController_animated_completion_( alertController, true, null // completion block 为空 ); } } } ] }); -
动态创建原生 UILabel 并添加到视图层级:
这展示了 JSBox 视图与原生视图的混合。
$ui.render({ props: { title: "原生标签混合显示", bgcolor: $color("systemBackground"), theme: "auto" }, views: [ { type: "label", props: { id: "jsLabel", text: "这是 JSBox UI 定义的标签", align: $align.center, font: $font("bold", 18), textColor: $color("primaryText") }, layout: function(make) { make.centerX.equalTo(make.super); make.top.inset(50); make.width.equalTo(280); make.height.equalTo(30); } }, { type: "button", props: { id: "runtimeTargetButton", // 添加 ID 用于原生标签的布局参照 title: "点击查看 Runtime 效果", bgcolor: $color("systemOrange"), titleColor: $color("white"), cornerRadius: 8 }, layout: function(make) { make.centerX.equalTo(make.super); make.top.equalTo($("jsLabel").bottom).offset(30); make.size.equalTo($size(200, 45)); } } ] }); // 在 $ui.render 完成后,添加原生标签 $thread.main(() => { // 确保在主线程执行 UI 操作 // 获取 JSBox 根视图控制器所管理的视图(Objective-C UIView 对象) const rootView = $ui.controller.view.ocValue(); // 1. 创建原生 UILabel 实例 (Objective-C 对象) const nativeLabel = $objc("UILabel").$new(); // 2. 使用 Runtime 方法设置原生 UILabel 的属性 nativeLabel.$setText("这是由 Runtime 创建的原生标签"); nativeLabel.$setTextColor($objc("UIColor").$systemBlueColor()); // 使用系统蓝色 nativeLabel.$setTextAlignment(1); // NSTextAlignmentCenter (1: 居中) nativeLabel.$setFont($objc("UIFont").$systemFontOfSize(18)); // 3. 将原生 UILabel (Objective-C 对象) 转换为 JSBox 可管理的 JavaScript 视图对象 const nativeLabelJSView = nativeLabel.jsValue(); // 4. 为这个 JSBox 视图对象定义布局。 // JSBox 会识别这个关联,并在原生视图被添加到视图层级后,应用这个布局。 nativeLabelJSView.layout = function(make) { make.centerX.equalTo(make.super); // 使用 "runtimeTargetButton" 作为参照物进行布局 make.top.equalTo($("runtimeTargetButton").bottom).offset(30); make.width.equalTo(300); make.height.equalTo(30); }; // 5. 将原生 UILabel (Objective-C 对象 `nativeLabel`) 添加到 Objective-C 的视图层级中。 rootView.$addSubview(nativeLabel); });
实践与思考
-
Runtime 是双刃剑,它强大但需要对 Objective-C 有一定了解,且调试相对困难。
-
优先使用 JSBox 封装的 API,只有在无法满足需求时才考虑 Runtime。
-
理解
jsValue()和ocValue()在 JS 和 Native 对象之间转换的重要性。
综合应用案例:一个简单的提醒应用小程序 (增强版)
这个案例将综合运用我们所学的 UI、数据持久化、Promise、控件、本地化等知识,创建一个具备提醒列表、添加/编辑提醒、删除提醒功能的小程序。
功能概览:
-
显示提醒列表。
-
添加新的提醒。
-
编辑现有提醒。
-
删除提醒。
-
数据持久化(使用文件存储 JSON)。
-
页面跳转。
-
基本本地化。
-
新增: 在内存中维护提醒数据,减少文件 IO。
-
新增: 列表项状态切换(完成/未完成)。
-
新增: 列表下拉刷新。
-
新增: 权限请求与统一错误处理范式。
-
新增: (可选)触发本地通知。
项目结构 (在电脑上创建文件夹并填充):
MyReminderApp/
├── main.js
├── config.json
├── strings/
│ ├── en.strings
│ └── zh-Hans.strings
├── scripts/
│ ├── reminder_model.js
│ ├── reminder_view.js
│ └── util.js // 新增通用工具模块
└── assets/
└── icon.png
文件内容:
-
config.json:{ "info": { "name": "MyReminderApp", "version": "1.0.0", "author": "YourName", "icon": "icon.png", "category": "工具" }, "settings": { "theme": "auto", "minSDKVer": "2.12.0" } } -
strings/en.strings: (与之前相同,确保文件存在于strings/目录下)"APP_TITLE" = "My Reminders"; "ADD_REMINDER" = "Add Reminder"; "EDIT_REMINDER" = "Edit Reminder"; "NEW_REMINDER" = "New Reminder"; "REMINDER_TEXT_PLACEHOLDER" = "Reminder Text"; "REMINDER_DATE" = "Reminder Date"; "SAVE" = "Save"; "DELETE" = "Delete"; "CANCEL" = "Cancel"; "REMINDER_SAVED_SUCCESS" = "Reminder saved!"; "REMINDER_DELETED_SUCCESS" = "Reminder deleted!"; "REMINDER_FETCH_FAILED" = "Failed to load reminders."; "NO_REMINDERS" = "No reminders yet. Add one!"; "OK" = "OK"; "CONFIRM_DELETE_TITLE" = "Confirm Delete?"; "CONFIRM_DELETE_MESSAGE" = "Are you sure you want to delete this reminder?"; "NOTIFICATION_PERMISSION_DENIED" = "Notification permission denied. Cannot schedule reminder."; "CALENDAR_PERMISSION_DENIED" = "Calendar permission denied. Cannot access calendar."; -
strings/zh-Hans.strings: (修正键名笔误,确保文件存在于strings/目录下)"APP_TITLE" = "我的提醒"; "ADD_REMINDER" = "添加提醒"; "EDIT_REMINDER" = "编辑提醒"; "NEW_REMINDER" = "新提醒"; "REMINDER_TEXT_PLACEHOLDER" = "提醒内容"; // 修正键名笔误 "REMINDER_DATE" = "提醒日期"; "SAVE" = "保存"; "DELETE" = "删除"; "CANCEL" = "取消"; "REMINDER_SAVED_SUCCESS" = "提醒已保存!"; "REMINDER_DELETED_SUCCESS" = "提醒已删除!"; "REMINDER_FETCH_FAILED" = "加载提醒失败。"; "NO_REMINDERS" = "暂无提醒,点击添加!"; "OK" = "好的"; "CONFIRM_DELETE_TITLE" = "确认删除?"; "CONFIRM_DELETE_MESSAGE" = "你确定要删除这条提醒吗?"; "NOTIFICATION_PERMISSION_DENIED" = "通知权限被拒绝。无法调度提醒。"; "CALENDAR_PERMISSION_DENIED" = "日历权限被拒绝。无法访问日历。"; -
scripts/util.js: 通用工具模块 (新增,修正 EventKit 权限请求逻辑)// scripts/util.js /** * 统一的错误处理和提示函数 * @param {string} message - 错误信息 * @param {Error} [error] - 原始错误对象 (可选) */ function handleError(message, error) { $ui.loading(false); $ui.alert(message); if (error) { console.error("Error:", message, error); } else { console.error("Error:", message); } } /** * 请求日历或提醒事项权限(EventKit) * @param {string} entity - "calendar" | "reminder" * @returns {Promise<boolean>} 是否获得权限 */ async function requestEventKitAccess(entity) { const EKEventStore = $objc("EKEventStore").$new(); // $new() 获取实例 const EKEntityType = entity === "reminder" ? 1 : 0; // 0: Event, 1: Reminder const authStatus = EKEventStore.$authorizationStatusForEntityType(EKEntityType); if (authStatus === 3) { // EKAuthorizationStatusAuthorized (3) return true; } else if (authStatus === 2) { // EKAuthorizationStatusDenied (2) handleError( entity === "calendar" ? $l10n("CALENDAR_PERMISSION_DENIED") : $l10n("NOTIFICATION_PERMISSION_DENIED") ); return false; } else if (authStatus === 0 || authStatus === 1) { // NotDetermined (0) or Restricted (1) // 使用 Promise 包装原生回调,更稳定 return await new Promise((resolve) => { const completion = $block("void, BOOL, id", (granted, error) => { if (error) { console.error("EventKit permission error:", error); resolve(false); } else { resolve(!!granted); } }); EKEventStore.$requestAccessToEntityType_completion_(EKEntityType, completion); }); } return false; // Fallback for Restricted status or other unhandled cases } module.exports = { handleError, requestEventKitAccess // 修正为请求 EventKit 权限 }; -
scripts/reminder_model.js: 数据模型和持久化逻辑。// scripts/reminder_model.js const util = require('./util'); // 引入通用工具模块 const DATA_FILE = "reminders.json"; let remindersInMemory = []; // 在内存中维护提醒数据 /** * @typedef {Object} Reminder * @property {string} id - Unique ID for the reminder * @property {string} text - Reminder text * @property {string} date - ISO string of reminder date * @property {boolean} completed - Whether the reminder is completed */ /** * Loads reminders from file and updates in-memory cache. * @returns {Promise<Reminder[]>} 提醒数组 */ async function loadReminders() { if (!$file.exists(DATA_FILE)) { remindersInMemory = []; return []; } try { // $file.read 是同步的,这里 await 仅为统一 async/await 风格 const data = $file.read(DATA_FILE); if (data && data.string) { remindersInMemory = JSON.parse(data.string); return remindersInMemory; } } catch (e) { console.error("Error parsing reminders.json:", e); util.handleError($l10n("REMINDER_FETCH_FAILED"), e); } remindersInMemory = []; return []; } /** * Saves current in-memory reminders to file. * @returns {Promise<boolean>} 是否保存成功 */ async function saveReminders() { try { const dataString = JSON.stringify(remindersInMemory, null, 2); // $file.write 是同步的,这里 await 仅为统一 async/await 风格 const success = $file.write({ data: $data({ string: dataString, encoding: 4 }), // UTF8 path: DATA_FILE }); return success; } catch (e) { console.error("Error saving reminders.json:", e); util.handleError("保存提醒失败!", e); return false; } } /** * Adds or updates a reminder in memory and saves. * @param {Reminder} reminder - The reminder object. * @param {boolean} isEditing - True if updating existing. * @param {number} [index] - Index if editing. * @returns {Promise<boolean>} */ async function addOrUpdateReminder(reminder, isEditing, index) { if (isEditing) { remindersInMemory[index] = reminder; } else { remindersInMemory.push(reminder); } return await saveReminders(); } /** * Deletes a reminder from memory and saves. * @param {number} index - Index of the reminder to delete. * @returns {Promise<boolean>} */ async function deleteReminder(index) { // 确保在删除前取消通知 // if (remindersInMemory[index]) { // await cancelNotification(remindersInMemory[index].id); // } remindersInMemory.splice(index, 1); return await saveReminders(); } /** * Gets all reminders currently in memory. * @returns {Reminder[]} */ function getRemindersInMemory() { return remindersInMemory; } // Optional: Schedule local notification for a reminder // 注意:调度通知无需显式请求权限,iOS 会在首次调度时自动弹窗 // 如果用户拒绝,后续调度将静默失败,或需要引导用户手动去系统设置开启。 function scheduleNotification(reminder) { const when = new Date(reminder.date); if (when > new Date()) { // 只调度未来的提醒 $push.schedule({ id: reminder.id, // 唯一标识符 title: $l10n("APP_TITLE"), body: reminder.text, date: when, renew: true // 如果已存在相同 ID 的通知,则更新它 }); console.log("Notification scheduled for reminder:", reminder.text); } } function cancelNotification(reminderId) { $push.cancel({ id: reminderId }); console.log("Notification cancelled for id:", reminderId); } module.exports = { loadReminders, saveReminders, // Still expose this for explicit file writes getRemindersInMemory, addOrUpdateReminder, deleteReminder, scheduleNotification, cancelNotification }; -
scripts/reminder_view.js: 界面逻辑和交互。// scripts/reminder_view.js const reminderModel = require('./reminder_model'); const util = require('./util'); /** * Renders the main reminder list view. */ async function renderMainView() { $ui.render({ props: { title: $l10n("APP_TITLE"), bgcolor: $color("systemBackground"), theme: "auto", navButtons: [ { symbol: "plus.circle", tintColor: $color("tintColor"), handler: () => pushEditView() // 添加提醒 } ] }, views: [ { type: "list", props: { id: "reminderList", rowHeight: 70, template: { views: [ { type: "label", props: { id: "reminderText", font: $font(18), lines: 0, textColor: $color("label") // 推荐使用 label }, layout: (make, view) => { make.left.right.inset(15); make.top.inset(10); make.height.lessThanOrEqualTo(40); // 最多两行 } }, { type: "label", props: { id: "reminderDate", font: $font(13), textColor: $color("secondaryLabel") // 推荐使用 secondaryLabel }, layout: (make, view) => { make.left.right.inset(15); make.top.equalTo($("reminderText").bottom).offset(5); make.height.equalTo(20); } }, { type: "switch", props: { id: "completedSwitch", onColor: $color("systemGreen") }, layout: (make, view) => { make.right.inset(15); make.centerY.equalTo(make.super); }, events: { changed: async (sender) => { $ui.loading(true); try { // 从 sender.info 获取 index const index = sender.info.index; // index 直接在 switch 的 info 中 const reminders = reminderModel.getRemindersInMemory(); // 从内存获取 const reminder = reminders[index]; reminder.completed = sender.on; const success = await reminderModel.addOrUpdateReminder(reminder, true, index); // 更新并保存 $ui.loading(false); if (success) { $ui.toast(sender.on ? "已完成" : "未完成"); // 这里可以考虑优化,只更新单行,但为了简单演示,依然刷新整个列表 await loadRemindersToList(); } else { $ui.alert("更新提醒失败。"); sender.on = !sender.on; // 状态回滚 } } catch (e) { util.handleError("更新提醒状态失败", e); sender.on = !sender.on; // 状态回滚 } } } } ] }, actions: [ { title: $l10n("DELETE"), color: $color("red"), handler: (sender, indexPath) => confirmDeleteReminder(indexPath.row) // 只传递 row index } ] }, layout: $layout.fill, events: { didSelect: (sender, indexPath, data) => pushEditView(indexPath.row), // 传递 row index pulled: async (sender) => { await loadRemindersToList(); sender.endRefreshing(); // 结束下拉刷新状态 } } } ] }); // 首次加载列表数据 await loadRemindersToList(); // 应用启动时注册后台保存 // $app.listen({ // pause: async () => { // console.log("App entering background, attempting to save reminders..."); // await reminderModel.saveReminders(); // 尝试在进入后台时保存一次 // }, // exit: async () => { // console.log("App exiting, attempting to save reminders..."); // await reminderModel.saveReminders(); // 尝试在退出时保存一次 // } // }); } /** * Loads reminders into the list view. */ async function loadRemindersToList() { $ui.loading(true); await reminderModel.loadReminders(); // 确保内存数据是最新的 const reminders = reminderModel.getRemindersInMemory(); $ui.loading(false); if (reminders.length === 0) { $("reminderList").data = [{ rows: [{ reminderText: { text: $l10n("NO_REMINDERS") }, completedSwitch: { hidden: true }, reminderDate: { hidden: true } }] }]; $("reminderList").rowHeight = 100; $("reminderList").selectable = false; // 不可选择 } else { const listData = reminders.map((r, index) => ({ reminderText: { text: r.text, textColor: r.completed ? $color("secondaryLabel") : $color("label") }, // 语义色 reminderDate: { text: new Date(r.date).toLocaleString(), textColor: r.completed ? $color("secondaryLabel") : $color("secondaryText") }, // 语义色 completedSwitch: { on: r.completed, info: { index: index } }, // 将索引传递到 switch 的 info info: { id: r.id, index: index } // 传递 info 到行数据 })); $("reminderList").data = [{ rows: listData }]; $("reminderList").rowHeight = 70; $("reminderList").selectable = true; } } /** * Pushes the edit/add reminder view. * @param {number} [index] - Optional, if editing an existing reminder, its index in the array. */ async function pushEditView(index) { let currentReminder = null; let isEditing = false; const reminders = reminderModel.getRemindersInMemory(); if (index !== undefined) { isEditing = true; currentReminder = { ...reminders[index] }; // 复制一份,避免直接修改内存数据 } else { currentReminder = { id: Date.now().toString(), // 确保 ID 唯一 text: "", date: new Date().toISOString(), completed: false }; } $ui.push({ props: { title: isEditing ? $l10n("EDIT_REMINDER") : $l10n("NEW_REMINDER"), bgcolor: $color("systemBackground"), theme: "auto" }, views: [ { type: "input", props: { id: "reminderTextInput", placeholder: $l10n("REMINDER_TEXT_PLACEHOLDER"), text: currentReminder.text, font: $font(17), textColor: $color("label"), cornerRadius: 5, bgcolor: $color("secondarySystemBackground") // 语义色 }, layout: (make) => { make.left.right.inset(15); make.top.inset(20); make.height.equalTo(40); } }, { type: "label", props: { id: "reminderDate", // 为日期标签添加 ID text: $l10n("REMINDER_DATE"), textColor: $color("label"), font: $font(15) }, layout: (make) => { make.left.inset(15); make.top.equalTo($("reminderTextInput").bottom).offset(20); } }, { type: "date-picker", props: { id: "reminderDatePicker", mode: 0, // UIDatePickerMode.DateAndTime date: new Date(currentReminder.date) }, layout: (make) => { make.left.right.inset(15); make.top.equalTo($("reminderDate").bottom).offset(5); make.height.equalTo(180); } }, { type: "button", props: { title: $l10n("SAVE"), bgcolor: $color("systemBlue"), titleColor: $color("white"), cornerRadius: 8 }, layout: (make) => { make.left.right.inset(15); make.top.equalTo($("reminderDatePicker").bottom).offset(30); make.height.equalTo(45); }, events: { tapped: () => saveReminder(currentReminder, isEditing, index) } }, ...(isEditing ? [{ // 只有在编辑模式才显示删除按钮 type: "button", props: { title: $l10n("DELETE"), bgcolor: $color("systemRed"), titleColor: $color("white"), cornerRadius: 8 }, layout: (make) => { make.left.right.inset(15); make.top.equalTo($("button").bottom).offset(15); make.height.equalTo(45); }, events: { tapped: () => confirmDeleteReminder(index, true) // 标记为从编辑页删除 } }] : []) ] }); } /** * Saves a reminder. * @param {Reminder} reminder - The reminder object to save/update. * @param {boolean} isEditing - True if editing an existing reminder. * @param {number} [index] - Optional, if editing. */ async function saveReminder(reminder, isEditing, index) { const textInput = $("reminderTextInput").text; const datePicker = $("reminderDatePicker").date; if (!textInput.trim()) { $ui.alert($l10n("REMINDER_TEXT_PLACEHOLDER")); return; } if (Number.isNaN(datePicker.getTime())) { // 检查日期是否有效 $ui.alert("无效日期"); return; } reminder.text = textInput.trim(); reminder.date = datePicker.toISOString(); // Convert Date object to ISO string for storage // reminder.completed 状态由列表页控制,这里不修改 $ui.loading(true); try { const success = await reminderModel.addOrUpdateReminder(reminder, isEditing, index); if (success) { $ui.toast($l10n("REMINDER_SAVED_SUCCESS")); // Optional: Schedule local notification // 如果需要调度通知,取消下方注释 // await reminderModel.scheduleNotification(reminder); $ui.pop(); // 返回列表页 await loadRemindersToList(); // 刷新列表 } else { util.handleError("保存提醒失败!"); } } catch (e) { util.handleError("保存提醒失败!", e); } finally { $ui.loading(false); } } /** * Confirms and deletes a reminder. * @param {number} index - Index of the reminder to delete. * @param {boolean} [fromEditPage=false] - True if deleting from edit page. */ async function confirmDeleteReminder(index, fromEditPage = false) { const confirm = await $ui.alert({ title: $l10n("CONFIRM_DELETE_TITLE"), message: $l10n("CONFIRM_DELETE_MESSAGE"), actions: [{ title: $l10n("DELETE"), style: $alertActionType.destructive }, { title: $l10n("CANCEL") }] }); if (confirm.index === 0) { // Clicked DELETE $ui.loading(true); try { const reminders = reminderModel.getRemindersInMemory(); // Optional: Cancel local notification // if (reminders[index]) { // await reminderModel.cancelNotification(reminders[index].id); // } const success = await reminderModel.deleteReminder(index); if (success) { $ui.toast($l10n("REMINDER_DELETED_SUCCESS")); if (fromEditPage) { $ui.pop(); // 如果从编辑页删除,返回主列表 } await loadRemindersToList(); // 刷新列表 } else { util.handleError("删除提醒失败!"); } } catch (e) { util.handleError("删除提醒失败!", e); } finally { $ui.loading(false); } } } module.exports = { renderMainView }; -
main.js:// main.js const reminderView = require('./scripts/reminder_view'); // 定义应用入口 reminderView.renderMainView();
综合案例实践
-
在电脑上:按照上述结构创建
MyReminderApp文件夹,并创建所有文件和目录,填充代码。 -
获取图标: 找一张合适的正方形图片(例如 100x100 像素),命名为
icon.png放到assets/文件夹。 -
打包: 将
MyReminderApp文件夹压缩成MyReminderApp.zip。 -
安装: 通过 AirDrop 或其他方式将
MyReminderApp.zip分享到你的 iOS 设备,选择 JSBox 打开并安装。 -
运行: 运行安装好的“MyReminderApp”脚本。
-
测试功能:
-
点击导航栏右上角的
+号添加提醒。 -
输入提醒内容,选择日期,点击“保存”。
-
在列表页下拉刷新。
-
点击列表项进入编辑页,修改内容或删除。
-
尝试左滑删除列表项。
-
尝试点击提醒旁的开关,标记完成/未完成。
-
切换 iOS 系统语言和深浅模式,观察 UI 和文本的变化。
-
学习总结与展望
恭喜你,你已经完成了 JSBox 的所有系统学习阶段!你现在已经具备了:
-
全面的 JSBox API 知识: 熟悉各个模块的功能。
-
扎实的 UI 编程能力: 可以构建复杂且交互丰富的界面。
-
模块化开发思维: 能够组织和维护大型项目。
-
异步编程实践: 熟练运用 Promise 和
async/await处理数据流。 -
Native 交互能力: 了解如何与 iOS 底层系统服务和 Runtime 交互。
展望与后续优化建议:
-
权限管理: 对于涉及到
Native SDK的功能(如日历、提醒、照片、通知),始终要考虑权限请求和用户拒绝的情况。在util.js中已提供了基础示例。 -
错误处理: 采用统一的
try...catch结构和错误提示机制,提高代码健壮性。util.js中的handleError是一个开始。 -
状态管理: 在内存中维护核心数据 (
remindersInMemory) 是一个重要优化,减少了频繁的文件读写,提高了响应速度。你也可以考虑在 App 进入后台 ($app.listen({ pause: ... })) 或退出 ($app.listen({ exit: ... })) 时自动触发数据保存。 -
用户体验: 考虑添加加载指示器 (
$ui.loading),操作成功/失败的提示 ($ui.toast,$ui.alert)。 -
高级 UI/交互:
-
在列表顶部添加搜索框,实现提醒的实时过滤。
-
为提醒添加本地通知功能(已在
reminder_model.js留下注释)。 -
支持提醒的重复设置(例如每日、每周)。
-
-
数据备份/导入导出: 利用
$share.sheet导出提醒数据为 JSON 文件,或者通过$file和inbox://协议导入。 -
快捷指令集成: 学习如何通过 URL Scheme (
jsbox://run?name=xxx&query=yyy) 或 Intent 暴露脚本功能给 iOS 快捷指令。 -
调试技巧: 熟练使用
console.log进行调试,对于复杂问题,Mac 上的 Safari 网页检查器 (docs/debug/inspector.md) 是一个强大的工具。
希望这份详尽的学习材料能成为你 JSBox 学习道路上的宝贵资源。如果你在未来的探索中遇到任何问题,我仍会在这里为你提供帮助。祝你在 JSBox 的世界中创作愉快,发现无限可能!