JSBox 学习路线:从入门到实践

JSBox 学习路线:从入门到实践 (Gemini版)

前言

这份学习材料根据我们之前互动学习的成果,并结合你提供的专业审阅意见修订而成。它旨在提供一个更准确、更详尽、更符合当前 JSBox API 最佳实践的系统化学习路径。JSBox 是一款强大的 iOS 脚本工具,它允许你使用 JavaScript 语言与 iOS 原生功能深度交互,实现自动化、自定义 UI 和各种实用工具。

本材料将按照以下八个主要阶段展开:

  1. 快速开始: 了解 JSBox 的基本哲学和代码运行方式。

  2. 基础接口: 掌握与应用、设备、网络和数据存储相关的核心 API。

  3. 构建界面: 学习如何使用 JavaScript 定义和布局原生 iOS UI。

  4. 控件列表: 深入探索 JSBox 提供的各种 UI 控件及其特定用法。

  5. 数据类型与内置函数: 理解 JavaScript 与 Native 数据转换,并掌握常用辅助函数。

  6. Promise 与高级特性: 学习异步编程的最佳实践,以及更多强大的扩展功能。

  7. 包管理: 了解如何组织大型、模块化、可维护的 JSBox 项目。

  8. 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 示例

以下示例展示了最基本的交互和输出。

  1. 弹出简单提示:

    通过 $ui.alert() 可以在屏幕上弹出一个原生的提示框。

    
    // 弹出文本提示
    $ui.alert("Hello, JSBox Learner!");
    
    // 弹出带标题和消息的提示
    $ui.alert({
      title: "欢迎学习",
      message: "这是你的第一个 JSBox 提示框。",
      actions: ["好的"] // 可以自定义按钮文本
    });
    
  2. 打印日志到控制台:

    使用 console.log()console.info() 等标准 JavaScript 控制台方法,可以在 JSBox 应用的内置控制台(通常在编辑器界面左上角的“虫子”图标处)查看输出,这对于调试非常重要。

    
    // 打印普通信息
    console.log("这是一个普通的日志信息。");
    console.info("应用启动成功。");
    // 打印警告和错误
    console.warn("注意:某个参数可能为空。");
    console.error("发生了一个错误!");
    
  3. 获取剪贴板内容并预览:

    $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 示例

  1. $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://");
    
  2. $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("设备轻微振动了一下。");
    
  3. $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("剪贴板中没有图片链接,跳过下载示例。");
    }
    */
    
  4. $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("文件已删除。");
    
  5. $cache - 对象缓存:

    • 设置缓存: $cache.set()

    • 获取缓存: $cache.get()

    • 清除缓存: $cache.clear()

    • 重要提示: $cacheset/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("所有缓存已清除。");
    
  6. $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("剪贴板已清空。");
    
  7. $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();
    
  8. $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");
    
  9. $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("密码已移除。");
    
  10. $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 示例

  1. 基本页面渲染:

    一个最简单的页面包含标题和视图。

    
    $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("你点击了标签!");
            }
          }
        }
      ]
    });
    
  2. 页面跳转 ($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
                  }
                ]
              });
            }
          }
        }
      ]
    });
    
  3. 动态视图操作 ($(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("文本已更新!");
            }
          }
        }
      ]
    });
    
  4. 复杂布局与链式调用:

    使用 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);
          }
        }
      ]
    });
    
  5. 手势与事件处理:

    除了 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("触摸结束。");
            }
          }
        }
      ]
    });
    
  6. 上下文菜单 (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 的通用属性和事件,同时拥有自己独有的 propsevents 来实现特定功能。

常用 API 示例

  1. 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
    }
    
  2. 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
    }
    
  3. 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
    }
    
  4. 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
    }
    
  5. 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
    }
    
  6. 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
    }
    
  7. 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
    }
    
  8. 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("取消通用选择。");
              }
            }
          }
        }
      ]
    });
    

实践与思考

  • 选择几个你感兴趣的控件,创建独立脚本尝试其核心功能。

  • 特别是 listmatrix,它们的数据源 (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 示例

  1. 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);
          }
        }
      ]
    });
    
  2. 数据 ($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);
          }
        }
      ]
    });
    
  3. 索引与范围 ($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}`);
    
  4. 延时 ($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 示例

  1. 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);
              }
            }
          }
        }
      ]
    });
    
  2. $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);
    })();
    
  3. $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("取消扫描。");
              }
            }
          }
        }
      ]
    });
    
  4. $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("文件压缩失败!");
        }
      }
    });
    
  5. 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
  
  1. 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;
    })();
    
  2. 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
    };
    
  3. strings/en.strings: 英文本地化。

    
    "APP_TITLE" = "My Package App";
    "WELCOME_MSG" = "Welcome!";
    "LOAD_DATA" = "Load Data";
    "SAVE_DATA" = "Save Data";
    "COUNT" = "Count: ";
    "OK" = "OK";
    
  4. 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 示例

  1. 调用原生方法:

    
    // 导入常用类 (标准方式:逐个导入)
    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%");
    
  2. 动态创建 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 为空
              );
            }
          }
        }
      ]
    });
    
  3. 动态创建原生 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
  

文件内容:

  1. config.json:

    
    {
      "info": {
        "name": "MyReminderApp",
        "version": "1.0.0",
        "author": "YourName",
        "icon": "icon.png",
        "category": "工具"
      },
      "settings": {
        "theme": "auto",
        "minSDKVer": "2.12.0"
      }
    }
    
  2. 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.";
    
  3. 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" = "日历权限被拒绝。无法访问日历。";
    
  4. 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 权限
    };
    
  5. 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
    };
    
  6. 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
    };
    
  7. main.js:

    
    // main.js
    const reminderView = require('./scripts/reminder_view');
    
    // 定义应用入口
    reminderView.renderMainView();
    

综合案例实践

  1. 在电脑上:按照上述结构创建 MyReminderApp 文件夹,并创建所有文件和目录,填充代码。

  2. 获取图标: 找一张合适的正方形图片(例如 100x100 像素),命名为 icon.png 放到 assets/ 文件夹。

  3. 打包: 将 MyReminderApp 文件夹压缩成 MyReminderApp.zip

  4. 安装: 通过 AirDrop 或其他方式将 MyReminderApp.zip 分享到你的 iOS 设备,选择 JSBox 打开并安装。

  5. 运行: 运行安装好的“MyReminderApp”脚本。

  6. 测试功能:

    • 点击导航栏右上角的 + 号添加提醒。

    • 输入提醒内容,选择日期,点击“保存”。

    • 在列表页下拉刷新。

    • 点击列表项进入编辑页,修改内容或删除。

    • 尝试左滑删除列表项。

    • 尝试点击提醒旁的开关,标记完成/未完成。

    • 切换 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 文件,或者通过 $fileinbox:// 协议导入。

  • 快捷指令集成: 学习如何通过 URL Scheme (jsbox://run?name=xxx&query=yyy) 或 Intent 暴露脚本功能给 iOS 快捷指令。

  • 调试技巧: 熟练使用 console.log 进行调试,对于复杂问题,Mac 上的 Safari 网页检查器 (docs/debug/inspector.md) 是一个强大的工具。

希望这份详尽的学习材料能成为你 JSBox 学习道路上的宝贵资源。如果你在未来的探索中遇到任何问题,我仍会在这里为你提供帮助。祝你在 JSBox 的世界中创作愉快,发现无限可能!