不用仪式感也能开始
打开文件,写一行,运行,然后继续。环境应该支持你的节奏,而不是先要求一套复杂配置。
diff --git a/.github/workflows/approve_submission.yml b/.github/workflows/approve_submission.yml deleted file mode 100644 index 9da2312..0000000 --- a/.github/workflows/approve_submission.yml +++ /dev/null @@ -1,213 +0,0 @@ -name: Rebuild open submission PRs after merge - -on: - pull_request: - types: [closed] - branches: [main] - -concurrency: - group: rebuild-submissions - cancel-in-progress: false - -jobs: - rebuild: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 1 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Rebuild open PRs onto latest main - uses: actions/github-script@v7 - with: - script: | - const { execSync } = require('child_process'); - const fs = require('fs'); - const path = require('path'); - - execSync('git config user.name "github-actions[bot]"'); - execSync('git config user.email "github-actions[bot]@users.noreply.github.com"'); - - // 获取所有 open 的 PR - const { data: openPRs } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - base: 'main', - sort: 'created', - direction: 'asc' - }); - - if (openPRs.length === 0) { - core.info('No open PRs to rebuild.'); - return; - } - - core.info(`Found ${openPRs.length} open PR(s) to rebuild.`); - - for (const pr of openPRs) { - const branch = pr.head.ref; - core.info(`\nRebuilding PR #${pr.number}: ${pr.title} (branch: ${branch})`); - - try { - // 1. 获取 PR 的文件变更列表 - const { data: files } = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number - }); - - // 2. 找到新增的脚本文件(排除 index.json) - const scriptFiles = files.filter(f => - f.status === 'added' && f.filename !== 'script_library/index.json' - ); - - if (scriptFiles.length === 0) { - core.warning(`PR #${pr.number}: No new script files found, skipping.`); - continue; - } - - // 3. 从 PR 分支获取每个脚本文件的内容 - const scriptContents = []; - for (const sf of scriptFiles) { - try { - const { data: fileData } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: sf.filename, - ref: branch - }); - const content = Buffer.from(fileData.content, 'base64').toString('utf8'); - scriptContents.push({ path: sf.filename, content }); - } catch (e) { - core.warning(`PR #${pr.number}: Failed to read ${sf.filename}: ${e.message}`); - } - } - - if (scriptContents.length === 0) { - core.warning(`PR #${pr.number}: Could not read script content, skipping.`); - continue; - } - - // 4. 从 PR body 中提取元数据 - const body = pr.body || ''; - const field = (key) => { - const re = new RegExp(`${key}[::]\\s*(.+)`, 'i'); - const m = body.match(re); - return m ? m[1].trim() : ''; - }; - const author = field('作者') || field('Author') || '社区投稿'; - const category = field('分类') || field('Category') || 'basic'; - const desc = field('描述') || field('Description') || pr.title; - const scriptName = pr.title.replace(/^\[投稿[::]\s*\w+\]\s*/, '').replace(/^\[投稿\]\s*/, '').trim() || 'untitled'; - - const catMap = { - '基础脚本': 'basic', 'basic': 'basic', - 'UI 界面': 'ui', 'UI': 'ui', 'ui': 'ui', - '游戏': 'games', 'Games': 'games', 'games': 'games', - '小组件': 'widgets', 'Widgets': 'widgets', 'widgets': 'widgets', - '其他': 'other', 'Other': 'other', 'other': 'other' - }; - let catId = catMap[category] || 'basic'; - - // 4b. 基于代码关键词自动修正分类 - const codeForClassify = scriptContents.map(s => s.content).join('\n'); - if (/\bimport\s+scene\b|from\s+scene\s+import\b|scene\.run\s*\(/.test(codeForClassify)) { - catId = 'games'; - core.info(`PR #${pr.number}: Auto-classified as "games" (detected scene module)`); - } else if (/\bimport\s+ui\b|from\s+ui\s+import\b|\.present\s*\(/.test(codeForClassify)) { - catId = 'ui'; - core.info(`PR #${pr.number}: Auto-classified as "ui" (detected ui module)`); - } else if (/from\s+widget\s+import\b|__WIDGET_LAYOUT__|Widget\s*\(/.test(codeForClassify)) { - catId = 'widgets'; - core.info(`PR #${pr.number}: Auto-classified as "widgets" (detected widget module)`); - } - - // 5. 基于最新 main 重建分支 - execSync('git checkout main', { stdio: 'pipe' }); - execSync('git pull origin main', { stdio: 'pipe' }); - - try { - execSync(`git branch -D ${branch}`, { stdio: 'pipe' }); - } catch (_) {} - execSync(`git checkout -b ${branch}`, { stdio: 'pipe' }); - - // 6. 写入脚本文件 - for (const sc of scriptContents) { - const dir = path.dirname(sc.path); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(sc.path, sc.content, 'utf8'); - } - - // 7. 读取最新 index.json 并添加条目 - const indexPath = 'script_library/index.json'; - const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); - - const safeId = scriptName - .toLowerCase() - .replace(/[\s]+/g, '_') - .replace(/[^a-z0-9_\u4e00-\u9fff]/g, '') - .substring(0, 40) || `submission_${pr.number}`; - const scriptId = `community_${safeId}`; - - // 去重:如果已存在则跳过 - if (!index.scripts.some(s => s.id === scriptId)) { - const mainScript = scriptContents[0]; - const lineCount = mainScript.content.split('\n').length; - const fileType = path.extname(mainScript.path).replace('.', '') || 'py'; - - index.data_version += 1; - index.updated = new Date().toISOString().replace(/\.\d+Z$/, 'Z'); - index.scripts.push({ - id: scriptId, - name: scriptName, - name_en: scriptName, - desc: desc, - desc_en: desc, - category: catId, - file: mainScript.path.replace('script_library/', ''), - thumbnail: null, - version: 1, - file_type: fileType, - author: author, - author_en: author, - tags: ['community', catId], - requires: [], - min_app_version: '1.5.0', - added: new Date().toISOString().split('T')[0], - updated: null, - status: 'active', - lines: lineCount - }); - fs.writeFileSync(indexPath, JSON.stringify(index, null, 2) + '\n', 'utf8'); - } - - // 8. 提交并 force push - execSync(`git add -A`, { stdio: 'pipe' }); - execSync(`git commit -m "投稿: ${scriptName}" --allow-empty`, { stdio: 'pipe' }); - execSync(`git push origin ${branch} --force`, { stdio: 'pipe' }); - - core.info(`✅ PR #${pr.number} rebuilt successfully on latest main.`); - - // 切回 main - execSync('git checkout main', { stdio: 'pipe' }); - - } catch (e) { - core.warning(`❌ PR #${pr.number} rebuild failed: ${e.message}`); - try { execSync('git checkout main', { stdio: 'pipe' }); } catch (_) {} - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: '⚠️ 自动重建失败,可能需要手动处理。' - }); - } - } diff --git a/README.md b/README.md index 4f624ec..f124a2a 100644 --- a/README.md +++ b/README.md @@ -1,698 +1,131 @@
-
+
- 让编程从电脑走到手机与平板 · Write, Run, Debug on iOS
+ 在 iPhone 和 iPad 上编写、运行并交付 Python 工作流
+ Python 3.14.6、科学计算、Notebook、AI Agent、MiniApp、Widget、SSH、Git 与 iOS 原生能力。
-
-
-
-
-
-
+
+
+
+
|
|
-| 文件管理、颜色标记、置顶、搜索、批量操作、回收站 | 语法高亮、智能补全、快捷输入栏、查找替换、分屏 |
-
-| 控制台 | AI 助手 |
-|:---:|:---:|
-|
|
|
-| Rich 彩色输出、进度条、多控制台切换、交互式 input() | Diff 差异对比、逐条采纳/拒绝、一键修复报错、SSH 远程运维 |
-
-| 库管理 | 工具箱 |
-|:---:|:---:|
-|
|
|
-| PyPI 搜索安装、热门库分类、进度条、版本管理、一键卸载 | 编解码、JSON、API 调试、二维码、时间戳、正则、直链下载 |
-
-| HTML 预览 | Markdown 渲染 |
-|:---:|:---:|
-|
|
|
-| 全屏网页渲染、双指缩放、alert/console 桥接 | 实时渲染,支持标题、列表、代码块、表格、任务列表 |
-
-| 新建文件 | 设置 |
-|:---:|:---:|
-|
|
|
-| 支持 py/js/html/css/md/json 等多种格式,可设置颜色标记 | 外观、编辑器字体、AI 配置、应用锁定、快捷指令帮助 |
-
----
-
-## 📥 安装 / Install
-
-
-
-
-
-
- 
- 扫码赞赏 · Thank you for your support ♥
-
- PythonIDE —— 本地、纯粹、实用的移动端编程环境 -
+系统要求:iOS/iPadOS 16.2 或更高版本。 ---
-
- Topics · ios · python · javascript · ide · ssh · sftp · server-management · numpy · pillow · cryptography · app-intents · siri · shortcuts · live-activity · mobile-development · swift · scripting · developer-tools · game-engine · widgets
-
+ PythonIDE
+ Python on iPhone and iPad, connected to real workflows.
链接可能不完整、已失效或已经移动。你可以返回首页,或打开支持中心。
关于 PythonIDE
PythonIDE 希望让严肃的 Python 创作在 Apple 设备上依然自然,而不是把桌面界面压缩到一块更小的屏幕里。
为什么做它
许多有用的脚本、课程、自动化与界面,都从一个很小的念头开始。PythonIDE 想缩短这个念头与“真正跑起来”之间的距离。
打开文件,写一行,运行,然后继续。环境应该支持你的节奏,而不是先要求一套复杂配置。
AppUI 与原生能力桥接让脚本可以继续长成界面、小组件、自动化或实用工具,同时保持 Python 优先。
文档、示例、AI 协作、诊断与审核社区,都围绕完成一份可理解的真实作品组织。
设计原则
导航、文件、权限、弹窗、小组件、快捷指令与系统行为,应当符合 Apple 设备的使用直觉。
作品尽量留在设备;账户、社区、购买与在线 AI 使用明确的服务边界。
Python 保持为创作语言,同时通过权限与安全边界连接有用的 Apple 系统能力。
包限制、AI 数据路径、权限、购买、社区审核与失败,应在让人意外之前先被说明白。
它包含什么
独立产品
PythonIDE 由张文禄开发。除非明确说明,PythonIDE 与 Python 软件基金会、Apple、OpenAI、DeepSeek 或其他提供方不存在隶属或背书关系。Python、Apple、各提供方名称及相关标识归其各自权利人所有。
PythonIDE
账户控制
PythonIDE 在 App 内提供永久账户删除,并在不可撤销操作前要求再次通过 Apple 验证身份。
完成后不能通过退出登录或恢复购买找回账户。以后重新使用时可能需要创建新账户,原社区身份与内容可能无法恢复。
App 内流程最快也最安全,因为会通过 Apple 验证归属。如 App 崩溃、账户无法访问或删除反复失败,请尽量使用与账户相关的邮箱联系支持。我们可能请求有限信息验证归属;切勿发送 Apple 密码、验证码、API 密钥或完整付款资料。
邀请活动
新用户绑定邀请码、使用核心功能并在次日再次打开后,才成为有效邀请。资格以后台记录为准,不以截图为准。
如 Apple 将兑换的 Pro 月卡标识为自动续订,奖励期结束后可能续订,除非你通过 Apple 账户取消。具体优惠与价格以 App Store 确认界面为准。
以下条件必须全部由 PythonIDE 后台记录:
网络延迟、离线使用、App 版本差异、账户复核或防滥用检查可能延迟状态更新,以邀请面板与最终后台记录为准。
对于自邀、重复设备或身份、伪造行为、自动化、脚本或设备农场、购买或交换账户、误导性激励、强制绑定、未经授权收集个人信息、篡改或其他操纵资格的行为,PythonIDE 可以排除、撤销、延迟或调查相关邀请与奖励。
我们可能请求合理核验、保留相关防滥用记录、暂停兑换、限制账户,或在技术与法律允许时收回错误发放的权益。真诚分享与家庭正常设备使用会结合背景判断,不会只凭单一信号决定。
这些属于独立促销活动,并非自动邀请档位。是否通过、数量、时间、平台要求与兑换方式由官方支持逐项确认;未获书面确认前,不得把活动表述为官方赞助。
PythonIDE 可能因产品、防滥用、成本、法律或平台原因调整或结束活动。重大变化会在本页或 App 公布。除非另有说明,变更不会移除已合法领取并发放的奖励;待领取或未来资格适用相应规则与核验记录。
PythonIDE 后台记录是邀请码绑定、核心行为、留存、有效数量、申请状态与兑换的最终依据;截图、聊天记录与第三方统计仅可作为辅助材料。
AI 与数据
PythonIDE 支持平台 AI、你配置的模型服务与符合条件的端侧模型。你选择的路径决定请求在哪里处理。
发送机密代码、个人信息、凭证或受监管数据前,请确认当前模型与提供方。自定义接口由其运营方控制,并非 PythonIDE 控制。
PythonIDE 只会发送为你主动发起的请求组装的上下文,但上下文可能包含较多内容。根据功能与选择,可能包括:
提供方会按支持范围返回生成文本、代码、推理或工具指令、用量信息与错误;PythonIDE 会展示或使用这些结果完成你请求的流程。
PythonIDE 会把 AI 对话保存在本地应用支持目录,使会话可在重启后继续。保存内容可能包括消息、附件预览、工具调用、Agent 步骤、候选回复与相关结构化结果。App 会限制保留的会话与消息数量以管理存储,但你仍应删除不再需要的对话。
运行层面的 AI 分析与执行轨迹也会在本地记录,用于呈现状态变化、Token 估算、验证结果与失败原因。分析记录中的文件路径以本地摘要表示,而非保存原始路径。这些本地记录不是广告分析数据流。
当你发起后续请求时,选中的历史消息或摘要可能成为新的在线请求的一部分。“本地保存”不代表消息从未被传输;如果最初使用在线模型,它当时已经发送。
PythonIDE 在受支持的平台上使用 Apple 钥匙串保存你配置的 API 密钥、OAuth 客户端密钥与令牌、自定义认证请求头。基础地址、模型 ID、API 格式与预设名称等非秘密配置可能保存在 App 偏好设置中。
AI 可能以肯定语气生成错误事实、不安全代码、不存在的 API、不兼容的包说明、破坏性命令或存在许可问题的内容。生成代码在运行时也可能访问文件、网络、传感器、账户或外部系统。
请阅读差异、理解命令、保留备份、在安全范围测试,并在访问系统或数据前取得授权。不要把 AI 当作合格法律、医疗、金融、安全或人身安全建议的替代品。
PythonIDE 可能应用请求限额、安全检查、工具权限或拒绝行为;不同提供方的安全机制可能不同,并可能独立变化。
在线提供方的隐私政策与条款在本说明之外同时适用。PythonIDE 可能因可用性、质量、安全、成本或提供方要求而增加、移除或调整受支持模型。PythonIDE 数据路径的重要变化会更新在本页,并在适当时通过 App 提示。
社区
PythonIDE 会在社区作品发布前进行审核。本规范说明什么适合分享、什么不允许,以及如何举报或申诉。
有用的脚本可能合理使用文件、网络、传感器或系统集成,但投稿必须让这些影响清楚可理解,并与声明用途相称。
审核可以降低明显风险,但不能保证代码无错误或适用于所有环境。用户运行前仍应检查源码、权限、网络行为与依赖。
审核时间会因数量、复杂度与风险而不同。提交不保证一定发布、获得排名或流量,也不保证永久可用。
在专用 App 内举报入口覆盖所有受支持版本前,邮件是正式举报渠道。请提供社区作品链接或 ID、可见时的作者名称、举报原因、支持材料及联系你的方式。不要发送秘密信息或无关个人数据。
涉及即时安全、未成年人、恶意软件、凭证泄露、隐私或正在发生的侵权时,我们会优先处理可信举报。我们可能保留证据、临时隐藏内容、请求补充信息、通知作者,或在合法且必要时转交适当服务商或主管机关。
如你的投稿被拒绝、下架或社区访问受限,可以申请人工复核。请提供:
复核会由人员结合完整记录进行,不会在存在自动信号时只依赖原自动信号。我们可能维持、调整或撤销原决定。没有新信息的重复申诉可能被关闭;涉及紧急安全的限制可在复核期间继续有效。
根据严重程度、背景、历史与风险,处理措施可包括警告、要求修改、拒绝投稿、临时隐藏、下架、取消发布权限、限流、限制或暂停账户、保留证据,或转交服务商与主管机关。严重伤害、恶意软件、儿童安全、可信威胁或故意规避可在不事先警告的情况下立即处理。
处理原因与管理员操作等审核记录可能为审计、安全、一致性、申诉与争议处理而保留。公开内容下架后会保持不可用,但有限记录可能继续保存。
本规范会随社区、法律、平台要求与风险变化而更新,重大更新会修改页面日期。如中英文版本存在差异,在法律允许范围内以中文版本为准。
PythonIDE
+正在打开邀请奖励与社区支持。如未自动跳转,请点击下方按钮。
+ +返回文档首页,或使用搜索查找 API、模块和指南。
+此页面已迁移至 Agent 开发。
+ + diff --git a/docs/docs/assets/brand-mark.svg b/docs/docs/assets/brand-mark.svg new file mode 100644 index 0000000..42e16d1 --- /dev/null +++ b/docs/docs/assets/brand-mark.svg @@ -0,0 +1,8 @@ + diff --git a/docs/docs/assets/docs-v2.css b/docs/docs/assets/docs-v2.css new file mode 100644 index 0000000..b1e1102 --- /dev/null +++ b/docs/docs/assets/docs-v2.css @@ -0,0 +1,962 @@ +:root { + color-scheme: light; + --orange: #e54b22; + --ink: #171717; + --pill: #111111; + --warm: #fff0eb; + --card-line: #efe6e1; + --cmd-bg: #faf7f5; + --bg: #f3f4f6; + --surface: #ffffff; + --surface-2: #f5f5f5; + --surface-3: #ececec; + --sidebar-bg: #fafafa; + --text: var(--ink); + --muted: #6b7280; + --muted-2: #9ca3af; + --line: rgba(17, 17, 17, .08); + --line-strong: rgba(17, 17, 17, .14); + --accent: var(--orange); + --accent-soft: var(--warm); + --code-bg: #f6f6f6; + --code-text: var(--ink); + --btn-copy-bg: var(--pill); + --btn-copy-fg: #ffffff; + --btn-copy-toast-bg: var(--pill); + --btn-copy-toast-fg: #ffffff; + --shadow: 0 1px 2px rgba(17, 17, 17, .04); + --shadow-card: 0 12px 36px rgba(17, 17, 17, .08); + --radius: 12px; + --radius-lg: 18px; + --radius-card: 22px; + --radius-pill: 999px; + --font-text: Inter, "Helvetica Neue", ui-sans-serif, -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + --font-mono: "JetBrains Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace; + --ease: cubic-bezier(0.19, 1, 0.22, 1); + --sidebar-width: 252px; + --toc-width: 214px; + --topbar-height: 56px; +} +[data-theme="dark"] { + color-scheme: dark; + --bg: #141414; + --surface: #1b1b1b; + --surface-2: #242424; + --surface-3: #2e2e2e; + --sidebar-bg: #181818; + --text: #f5f5f5; + --muted: #a3a3a3; + --muted-2: #737373; + --line: rgba(255, 255, 255, .1); + --line-strong: rgba(255, 255, 255, .16); + --accent: #ff6b47; + --accent-soft: rgba(229, 75, 34, .16); + --card-line: rgba(255, 255, 255, .1); + --cmd-bg: #262626; + --code-bg: #262626; + --code-text: #f3f4f6; + --btn-copy-bg: #f5f5f5; + --btn-copy-fg: #171717; + --btn-copy-toast-bg: #f5f5f5; + --btn-copy-toast-fg: #171717; + --shadow: 0 1px 2px rgba(0, 0, 0, .22); + --shadow-card: 0 14px 40px rgba(0, 0, 0, .28); +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +html, +body, +.topbar, +.sidebar, +.toc, +.content, +.code-block, +.agent-install-panel, +.install-cmd, +.search-results, +.mobile-backdrop, +.icon-button, +.theme-toggle { + transition: + background-color .26s ease, + color .26s ease, + border-color .26s ease, + box-shadow .26s ease; +} +@media (prefers-reduced-motion: no-preference) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: .32s; + animation-timing-function: ease; + } +} +body { + margin: 0; + background: var(--surface); + color: var(--text); + font: 14.5px/1.7 var(--font-text); + letter-spacing: 0; +} +a { color: var(--accent); text-decoration: none; } +a:hover { color: color-mix(in srgb, var(--accent) 84%, var(--text)); text-decoration: underline; } +.markdown-body a { + color: var(--accent); + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; +} +.markdown-body a:hover { + color: color-mix(in srgb, var(--accent) 78%, var(--text)); + text-decoration: underline; +} +.topbar { + height: var(--topbar-height); + position: sticky; + top: 0; + z-index: 50; + display: flex; + align-items: center; + gap: 12px; + padding: 0 18px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface) 94%, transparent); + backdrop-filter: blur(12px); +} +a.brand { + display: inline-flex; + align-items: center; + gap: 9px; + color: var(--text); + font-weight: 760; + font-size: 20px; + letter-spacing: -.01em; + text-decoration: none; +} +a.brand:hover, +a.brand:focus-visible { + color: var(--text); + text-decoration: none; +} +.brand-mark { + width: 30px; + height: 30px; + display: block; + flex: 0 0 auto; + border: 0; + background: transparent; + transition: transform .18s ease, opacity .18s ease; +} +.brand:hover .brand-mark { transform: translateY(-1px) scale(1.03); } +.top-nav { + display: flex; + align-items: center; + gap: 4px; + margin-left: 38px; +} +.top-nav a { + color: var(--muted); + padding: 6px 12px; + border-radius: 999px; + font-size: 13px; + font-weight: 650; + text-decoration: none; + transition: background-color .16s ease, color .16s ease, transform .16s ease; +} +.top-nav a:hover, +.top-nav a:focus-visible { + background: transparent; + color: var(--text); + text-decoration: none; +} +.top-nav a.active { + background: transparent; + color: var(--accent); + font-weight: 700; + text-decoration: none; +} +.top-nav a:hover { transform: translateY(-1px); } +.search-trigger { + width: 100%; + height: 38px; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: var(--surface-2); + color: var(--muted); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px 0 12px; + font: inherit; + cursor: pointer; + box-shadow: var(--shadow); + transition: background-color .16s ease, border-color .16s ease, transform .16s ease; +} +.search-trigger:hover { + border-color: var(--line-strong); + transform: translateY(-1px); +} +kbd { + border: 1px solid var(--line-strong); + border-bottom-width: 2px; + border-radius: 6px; + padding: 1px 6px; + font-size: 11px; + color: var(--muted); + background: var(--surface); +} +.icon-button, .theme-toggle { + width: 36px; + height: 36px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: transparent; + color: var(--text); + cursor: pointer; + transition: background-color .16s ease, border-color .16s ease, transform .16s ease; +} +.icon-button:hover, .theme-toggle:hover { + background: var(--surface-2); + border-color: var(--line-strong); + transform: translateY(-1px); +} +.language-menu { + position: relative; + margin-left: auto; +} +.language-menu summary { + list-style: none; + height: 34px; + min-width: 68px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--text); + background: transparent; + font-size: 13px; + font-weight: 680; + cursor: pointer; + transition: background-color .16s ease, border-color .16s ease, transform .16s ease; +} +.language-menu summary::-webkit-details-marker { display: none; } +.language-menu summary::after { + content: ""; + width: 5px; + height: 5px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg) translateY(-1px); + opacity: .65; + transition: transform .16s ease, opacity .16s ease; +} +.language-menu[open] summary { + background: var(--surface-2); + border-color: var(--line-strong); +} +.language-menu[open] summary::after { + transform: rotate(225deg) translate(-1px, -1px); + opacity: .9; +} +.language-menu summary:hover { + background: var(--surface-2); + border-color: var(--line-strong); + transform: translateY(-1px); +} +.language-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 80; + width: 158px; + padding: 6px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-card); + transform-origin: top right; + animation: menuIn .14s ease both; +} +[data-theme="dark"] .language-panel { + box-shadow: 0 16px 40px rgba(0, 0, 0, .22); +} +.language-panel span, +.language-panel button { + width: 100%; + min-height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + border: 0; + border-radius: 7px; + padding: 0 9px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 13px; + text-align: left; +} +.language-panel .active { + background: var(--accent-soft); + color: var(--text); + font-weight: 700; +} +.language-panel button:disabled { + cursor: not-allowed; + opacity: .72; +} +.language-panel em { + color: var(--muted-2); + font-style: normal; + font-size: 12px; +} +.menu-toggle { display: none; } +.shell { + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--toc-width); + gap: 0; + width: 100%; + max-width: none; + margin: 0; + padding: 0; + background: var(--surface); +} +.sidebar, .toc { + position: sticky; + top: var(--topbar-height); + height: calc(100vh - var(--topbar-height)); + overflow: auto; + padding: 20px 0; +} +.sidebar { + background: var(--sidebar-bg); + border-right: 1px solid var(--line); + padding-left: 14px; + padding-right: 10px; +} +.content { + min-width: 0; + padding: 36px 32px 76px; + background: var(--surface); + border-right: 1px solid var(--line); + animation: pageIn .22s ease both; +} +.sidebar-nav { + padding: 0; +} +.sidebar-search { margin-bottom: 16px; } +.nav-collection { + border-top: 0; + padding: 2px 0 10px; +} +.nav-collection summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + padding: 8px 8px; + color: var(--text); + font-size: 13px; + font-weight: 760; +} +.nav-collection summary::-webkit-details-marker { display: none; } +.nav-collection summary a { color: var(--text); } +.nav-group { margin: 4px 0 12px; } +.nav-group-title { + padding: 11px 8px 5px; + color: var(--muted); + font-size: 12px; + font-weight: 760; +} +.nav-subgroup-title { + padding: 6px 8px 2px 14px; + color: color-mix(in srgb, var(--muted) 82%, transparent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; +} +.collection-subgroup-title { + margin: 18px 0 8px; + color: var(--muted); + font-size: 14px; + font-weight: 700; +} +.collection-subgroup-title:first-child { margin-top: 0; } +a.nav-link { + display: block; + color: var(--muted); + border-left: 0; + border-radius: var(--radius); + padding: 6px 9px; + font-size: 13px; + line-height: 1.4; + text-decoration: none; + transition: color .14s ease; +} +a.nav-link:hover, +a.nav-link:focus-visible { + background: transparent; + color: var(--text); + text-decoration: none; +} +a.nav-link.active { + background: transparent; + color: var(--accent); + font-weight: 700; +} +.toc { + padding: 38px 18px 20px 12px; + background: var(--surface); +} +.toc-title { + color: var(--text); + font-weight: 760; + margin-bottom: 10px; + font-size: 12px; + text-transform: uppercase; +} +a.toc-link { + display: block; + color: var(--muted); + border-left: 2px solid transparent; + padding: 5px 0 5px 12px; + font-size: 13px; + line-height: 1.35; + text-decoration: none; + transition: color .14s ease, border-color .14s ease, transform .14s ease; +} +a.toc-link.child { padding-left: 22px; font-size: 12.5px; } +a.toc-link.active { + color: var(--accent); + border-left-color: var(--accent); + font-weight: 700; +} +a.toc-link:hover, +a.toc-link:focus-visible { + color: var(--text); + text-decoration: none; + transform: translateX(2px); +} +.toc-empty { + color: var(--muted); + font-size: 13px; +} +.article, .home-intro, .collection-list { + max-width: 780px; + margin: 0 auto; +} +.article-intro, .home-intro { + padding: 12px 0 24px; +} +.breadcrumb, .eyebrow, .link-kicker { + color: var(--muted); + font-weight: 780; + font-size: 12px; + text-transform: uppercase; +} +h1 { + margin: 10px 0 10px; + font-size: clamp(30px, 3.6vw, 40px); + line-height: 1.12; + font-weight: 760; + letter-spacing: 0; +} +.article-intro p, .home-intro p { + margin: 0; + color: var(--muted); + max-width: 720px; +} +.markdown-body { + margin-top: 14px; +} +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4 { + letter-spacing: 0; + line-height: 1.22; +} +.markdown-body h1 { font-size: 30px; margin: 34px 0 12px; } +.markdown-body h2 { font-size: 22px; margin: 34px 0 10px; font-weight: 740; } +.markdown-body h3 { font-size: 17px; margin: 26px 0 8px; font-weight: 740; } +.markdown-body h4 { font-size: 16px; margin: 22px 0 8px; } +.heading-anchor { + opacity: 0; + margin-left: -20px; + padding-right: 6px; + color: var(--muted); +} +h1:hover .heading-anchor, h2:hover .heading-anchor, h3:hover .heading-anchor, h4:hover .heading-anchor { + opacity: 1; + text-decoration: none; +} +.markdown-body p, +.markdown-body li { + color: var(--text); +} +.markdown-body p { + margin: 0 0 14px; +} +.markdown-body ul, .markdown-body ol { + padding-left: 22px; + margin: 12px 0 18px; +} +.markdown-body li + li { margin-top: 6px; } +.markdown-body code:not(pre code) { + color: var(--accent); + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + font-size: .92em; + font-family: var(--font-mono); +} +.table-scroll { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + margin: 16px 0 24px; + background: color-mix(in srgb, var(--surface) 72%, transparent); +} +table { + width: 100%; + min-width: 680px; + border-collapse: collapse; +} +th, td { + border-bottom: 1px solid var(--line); + padding: 11px 13px; + text-align: left; + vertical-align: top; +} +th { + background: color-mix(in srgb, var(--surface-2) 42%, transparent); + color: var(--text); + font-size: 13px; +} +td { color: var(--muted); } +tr:last-child td { border-bottom: 0; } +.code-block { + margin: 18px 0 26px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--code-bg); + color: var(--code-text); + overflow: visible; + box-shadow: none; +} +.code-block figcaption { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--line); + border-radius: calc(var(--radius-lg) - 1px) calc(var(--radius-lg) - 1px) 0 0; + background: color-mix(in srgb, var(--code-bg) 72%, var(--surface)); + padding: 8px 10px 8px 16px; + color: var(--muted); + font-size: 12px; + font-weight: 680; + overflow: visible; +} +.code-copy-actions { + position: relative; + flex-shrink: 0; + overflow: visible; +} +.copy-code, +.install-cmd-copy { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--radius-pill); + background: var(--btn-copy-bg); + color: var(--btn-copy-fg); + padding: 0; + cursor: pointer; + transition: transform .15s ease, opacity .15s ease, background-color .15s ease, color .15s ease; +} +.copy-code:hover, +.install-cmd-copy:hover { + transform: scale(1.04); + opacity: .88; +} +.copy-code svg, +.install-cmd-copy svg { + width: 15px; + height: 15px; +} +.copy-code-toast, +.install-cmd-toast { + position: absolute; + right: 0; + top: calc(100% + 8px); + bottom: auto; + z-index: 40; + padding: 6px 10px; + border-radius: var(--radius-pill); + background: var(--btn-copy-toast-bg); + color: var(--btn-copy-toast-fg); + font-family: var(--font-text); + font-size: 11px; + font-weight: 650; + line-height: 1.2; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 6px 18px rgba(17, 17, 17, .12); + animation: install-toast-in .18s var(--ease); +} +[data-theme="dark"] .copy-code-toast, +[data-theme="dark"] .install-cmd-toast { + box-shadow: 0 8px 22px rgba(0, 0, 0, .34); +} +.copy-code-toast[hidden], +.install-cmd-toast[hidden] { + display: none !important; +} +.docs-copy-feedback { + position: fixed; + z-index: 120; + padding: 6px 12px; + border-radius: var(--radius-pill); + background: var(--btn-copy-toast-bg); + color: var(--btn-copy-toast-fg); + font-family: var(--font-text); + font-size: 11px; + font-weight: 650; + line-height: 1.2; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 6px 18px rgba(17, 17, 17, .12); + animation: install-toast-in .18s var(--ease); +} +.docs-copy-feedback[hidden] { + display: none !important; +} +[data-theme="dark"] .docs-copy-feedback { + box-shadow: 0 8px 22px rgba(0, 0, 0, .34); +} +.agent-install-panel { + margin: 0 0 24px; +} +.install-cmd { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + max-width: 100%; + min-height: 46px; + padding: 5px 5px 5px 16px; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: var(--code-bg); + color: var(--text); + box-shadow: none; +} +.install-cmd-prompt { + flex-shrink: 0; + color: var(--muted); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1; + user-select: none; +} +.install-cmd-text { + flex: 1; + min-width: 0; + overflow-x: auto; + white-space: nowrap; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.4; + color: inherit; + background: transparent; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.install-cmd-text::-webkit-scrollbar { display: none; } +.install-cmd-actions { + position: relative; + flex-shrink: 0; +} +@keyframes install-toast-in { + from { opacity: 0; transform: translateY(4px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.copy-code:active, +.language-menu summary:active { + transform: translateY(0) scale(.985); +} +.code-block pre { + margin: 0; + overflow: auto; + max-height: 430px; + border-radius: 0 0 calc(var(--radius-lg) - 1px) calc(var(--radius-lg) - 1px); + background: var(--code-bg); +} +.code-block pre code { + display: block; + padding: 15px 17px; + white-space: pre; + font: 13px/1.65 var(--font-mono); +} +pre { + margin: 0; + overflow: auto; + max-height: 430px; +} +pre code { + display: block; + padding: 15px 17px; + white-space: pre; + font: 13px/1.65 var(--font-mono); +} +.tok-keyword { color: #c2410c; font-weight: 680; } +.tok-string { color: #15803d; } +.tok-comment { color: var(--muted-2); font-style: italic; } +.tok-number { color: #7c3aed; } +.tok-function { color: #b45309; } +.tok-property { color: #e54b22; } +[data-theme="dark"] .tok-keyword { color: #ff8a65; } +[data-theme="dark"] .tok-string { color: #86efac; } +[data-theme="dark"] .tok-number { color: #c4b5fd; } +[data-theme="dark"] .tok-function { color: #fdba74; } +[data-theme="dark"] .tok-property { color: #ff8a65; } +.callout { + border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--line)); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--accent-soft) 72%, var(--surface)); + padding: 13px 15px; + color: var(--text); +} +.callout.warning { border-color: color-mix(in srgb, #f59e0b 34%, var(--line)); background: rgba(245, 158, 11, .08); } +.callout.tip { border-color: color-mix(in srgb, #16a34a 30%, var(--line)); background: rgba(22, 163, 74, .08); } +.callout.important { border-color: color-mix(in srgb, var(--accent) 36%, var(--line)); background: color-mix(in srgb, var(--accent-soft) 88%, var(--surface)); } +hr { + border: 0; + border-top: 1px solid var(--line); + margin: 26px 0; +} +.related-section { + margin-top: 42px; + padding-top: 4px; +} +.related-list, .doc-link-list, .collection-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +a.related-link, +a.doc-link, +a.collection-link { + display: block; + color: var(--text); + border: 1px solid var(--card-line); + border-radius: var(--radius-card); + background: var(--surface); + padding: 18px 20px; + box-shadow: var(--shadow-card); + text-decoration: none; + transition: none; + transform: none; +} +a.related-link:hover, +a.related-link:focus-visible, +a.doc-link:hover, +a.doc-link:focus-visible, +a.collection-link:hover, +a.collection-link:focus-visible { + color: var(--text); + text-decoration: none; + border-color: var(--card-line); + background: var(--surface); + box-shadow: var(--shadow-card); + transform: none; +} +a.related-link strong, +a.doc-link strong, +a.collection-link strong { + display: block; + color: var(--text); + font-size: 16px; + margin-bottom: 5px; +} +a.related-link:hover strong, +a.related-link:focus-visible strong, +a.doc-link:hover strong, +a.doc-link:focus-visible strong, +a.collection-link:hover strong, +a.collection-link:focus-visible strong { + color: var(--text); +} +.related-link span, .doc-link span, .collection-link span, +.collection-link em { + display: block; + color: var(--muted); + font-style: normal; + font-size: 13px; +} +.home-intro { + max-width: 830px; +} +.collection-list { + max-width: 830px; + margin-top: 22px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.collection-link strong { font-size: 20px; margin: 8px 0 6px; } +.collection-link em { margin-top: 12px; color: var(--accent); font-weight: 700; } +.collection-group { + margin-top: 36px; +} +.collection-group > p { + color: var(--muted); + margin-top: -6px; +} +.search-panel { + position: fixed; + inset: 0; + z-index: 100; + background: rgba(2, 6, 23, .42); + opacity: 0; + visibility: hidden; + pointer-events: none; + padding: 9vh min(6vw, 72px); + transition: opacity .18s ease, visibility .18s ease; +} +.search-panel.open { + opacity: 1; + visibility: visible; + pointer-events: auto; +} +.search-box, .search-results { + max-width: 760px; + margin: 0 auto; + opacity: 0; + transform: translateY(8px) scale(.99); + transition: opacity .18s ease, transform .18s ease; +} +.search-panel.open .search-box, +.search-panel.open .search-results { + opacity: 1; + transform: translateY(0) scale(1); +} +.search-box { + display: flex; + gap: 10px; + background: var(--surface); + border: 1px solid var(--card-line); + border-radius: var(--radius-lg); + padding: 9px 10px; + box-shadow: var(--shadow-card); +} +#docs-search-input { + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font: inherit; + font-size: 17px; +} +.search-close { + border: 1px solid var(--line); + background: transparent; + color: var(--text); +} +.search-results { + margin-top: 10px; + background: var(--surface); + border: 1px solid var(--card-line); + border-radius: var(--radius-lg); + overflow: hidden; + max-height: 62vh; + overflow-y: auto; + transition-delay: .03s; +} +.search-result { + display: block; + padding: 14px 16px; + border-bottom: 1px solid var(--line); + transition: background-color .14s ease, transform .14s ease; +} +.search-result:hover { + background: color-mix(in srgb, var(--surface-2) 54%, transparent); + text-decoration: none; + transform: translateX(2px); +} +.search-result strong { display: block; color: var(--text); } +.search-result span { display: block; color: var(--muted); font-size: 13px; } +@keyframes pageIn { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes menuIn { + from { opacity: 0; transform: translateY(-4px) scale(.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { + animation-duration: .001ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: .001ms !important; + } +} +@media (max-width: 1180px) { + .shell { grid-template-columns: var(--sidebar-width) minmax(0, 1fr); } + .toc { display: none; } + .content { border-right: 0; } +} +@media (max-width: 860px) { + .menu-toggle { display: inline-block; } + .top-nav { display: none; } + .language-menu { margin-left: auto; } + .sidebar-search { width: 100%; } + .shell { display: block; padding: 0 14px; } + .sidebar { + position: fixed; + z-index: 80; + top: var(--topbar-height); + bottom: 0; + left: 0; + width: min(88vw, 340px); + height: auto; + background: var(--sidebar-bg); + transform: translateX(-110%); + transition: transform .2s ease; + padding: 14px; + border-right: 1px solid var(--line); + } + body.sidebar-open .sidebar { transform: translateX(0); } + .mobile-backdrop { + position: fixed; + inset: var(--topbar-height) 0 0; + background: rgba(2, 6, 23, .35); + z-index: 70; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity .18s ease, visibility .18s ease; + } + body.sidebar-open .mobile-backdrop { + opacity: 1; + visibility: visible; + pointer-events: auto; + } + .content { padding-top: 18px; } + .article-intro, .home-intro { padding: 14px 0 22px; } + .related-list, .doc-link-list, .collection-list { grid-template-columns: 1fr; } + h1 { font-size: 34px; } +} +@media (max-width: 520px) { + .brand { gap: 7px; } + .brand-mark { width: 28px; height: 28px; } + .brand-text { display: none; } + .language-menu summary { min-width: 58px; } +} diff --git a/docs/docs/assets/docs-v2.js b/docs/docs/assets/docs-v2.js new file mode 100644 index 0000000..adae470 --- /dev/null +++ b/docs/docs/assets/docs-v2.js @@ -0,0 +1,230 @@ +(() => { + const root = document.documentElement; + const savedTheme = localStorage.getItem("docs-v2-theme"); + if (savedTheme) root.dataset.theme = savedTheme; + + const prefix = window.PYTHONIDE_DOCS_PREFIX || ""; + const themeToggle = document.querySelector(".theme-toggle"); + const applyTheme = (next) => { + root.dataset.theme = next; + localStorage.setItem("docs-v2-theme", next); + }; + themeToggle?.addEventListener("click", () => { + const next = root.dataset.theme === "dark" ? "light" : "dark"; + if (typeof document.startViewTransition === "function") { + document.startViewTransition(() => applyTheme(next)); + return; + } + applyTheme(next); + }); + + const COPY_FEEDBACK_ID = "docs-v2-copy-feedback"; + let copyFeedbackTimer = 0; + const showCopyFeedback = (button, message) => { + let toast = document.getElementById(COPY_FEEDBACK_ID); + if (!toast) { + toast = document.createElement("div"); + toast.id = COPY_FEEDBACK_ID; + toast.className = "docs-copy-feedback"; + toast.setAttribute("role", "status"); + toast.setAttribute("aria-live", "polite"); + toast.hidden = true; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.hidden = false; + const rect = button.getBoundingClientRect(); + toast.style.top = `${Math.round(rect.bottom + 8)}px`; + toast.style.right = `${Math.round(window.innerWidth - rect.right)}px`; + toast.style.left = "auto"; + window.clearTimeout(copyFeedbackTimer); + copyFeedbackTimer = window.setTimeout(() => { + toast.hidden = true; + }, 1400); + }; + + const menuToggle = document.querySelector(".menu-toggle"); + const backdrop = document.querySelector(".mobile-backdrop"); + const languageMenu = document.querySelector(".language-menu"); + const closeSidebar = () => document.body.classList.remove("sidebar-open"); + menuToggle?.addEventListener("click", () => document.body.classList.toggle("sidebar-open")); + backdrop?.addEventListener("click", closeSidebar); + document.querySelectorAll(".nav-link").forEach((link) => link.addEventListener("click", closeSidebar)); + document.addEventListener("click", (event) => { + if (languageMenu && !languageMenu.contains(event.target)) { + languageMenu.open = false; + } + }); + + document.querySelectorAll(".copy-code, .install-cmd-copy").forEach((button) => { + button.addEventListener("click", async () => { + const id = button.getAttribute("data-copy-code"); + const code = document.getElementById(`code-${id}`)?.textContent || ""; + try { + await navigator.clipboard.writeText(code); + const actions = button.parentElement; + const inlineToast = actions?.querySelector(".install-cmd-toast, .copy-code-toast"); + const oldLabel = button.getAttribute("aria-label") || "复制"; + const copiedLabel = inlineToast?.textContent?.trim() || "已复制"; + showCopyFeedback(button, copiedLabel); + button.setAttribute("aria-label", copiedLabel); + setTimeout(() => button.setAttribute("aria-label", oldLabel), 1400); + } catch (_) { + window.prompt("请手动复制:", code); + } + }); + }); + + highlightCodeBlocks(); + + const tocLinks = [...document.querySelectorAll(".toc-link")]; + const headings = tocLinks + .map((link) => document.getElementById(decodeURIComponent((link.getAttribute("href") || "").slice(1)))) + .filter(Boolean); + if (headings.length) { + const observer = new IntersectionObserver((entries) => { + const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; + if (!visible) return; + tocLinks.forEach((link) => link.classList.toggle("active", link.getAttribute("href") === `#${visible.target.id}`)); + }, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] }); + headings.forEach((heading) => observer.observe(heading)); + } + + const panel = document.querySelector(".search-panel"); + const input = document.getElementById("docs-search-input"); + const results = document.getElementById("docs-search-results"); + let searchIndex = []; + let loaded = false; + + async function loadSearch() { + if (loaded) return; + loaded = true; + try { + const response = await fetch(`${prefix}search-index.json`); + searchIndex = await response.json(); + } catch { + searchIndex = []; + } + } + + async function openSearch() { + await loadSearch(); + panel?.classList.add("open"); + input?.focus(); + renderSearch(""); + } + + function closeSearch() { + panel?.classList.remove("open"); + } + + function renderSearch(query) { + if (!results) return; + const q = query.trim().toLowerCase(); + const tokens = q.split(/[\s,,。;;::/|()[\]{}"'`]+/).filter(Boolean); + const matches = q.length === 0 + ? searchIndex.slice(0, 12) + : searchIndex + .map((item) => { + const title = item.title.toLowerCase(); + const subtitle = (item.subtitle || "").toLowerCase(); + const section = item.section.toLowerCase(); + const text = item.text.toLowerCase(); + let score = text.includes(q) ? 80 : 0; + for (const token of tokens) { + if (title.includes(token)) score += 40; + else if (subtitle.includes(token)) score += 18; + else if (section.includes(token)) score += 12; + else if (text.includes(token)) score += 6; + } + return { item, score }; + }) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score || a.item.title.localeCompare(b.item.title)) + .slice(0, 24) + .map((entry) => entry.item); + results.innerHTML = matches.map((item) => ` + + ${escapeHtml(item.title)} + ${escapeHtml(item.section)} · ${escapeHtml(item.subtitle || "")} + + `).join("") || '在编码 Agent 中安装 Skills,并正确编写 AppUI、Widget 与原生能力代码。
+安装 Agent Skills,并了解与在线文档的配合方式。
声明式原生界面、状态、布局、导航、控件、API 参考与示例。
+先建立 appui 的基本写法和质量标准。
按真实界面结构学习状态、布局、导航、交互和性能。
按任务和组件查找公开 AppUI API。
可预览、可点击、布局稳定的完整页面样板。
颜色、系统图标和从 ui 迁移的对照表。
快捷指令、编辑器键盘栏、JS API 与底层 Runtime 扩展。
+先看系统自动化和脚本运行时入口。
快捷指令、编辑器工具栏与底层扩展。
完整模块表与历史入口(主文档已合并到 iOS 原生能力)。
权限、存储、设备、媒体、连接、通信与端侧智能。
+原生模块总入口:入口模型、Python 模块表、AppUI 桥接、专题页与权限矩阵。
权限、持久化、钥匙串、剪贴板与弹窗反馈。
设备信息、定位、运动、触觉、生物认证与健康数据。
文件选择、分享、通知、通讯录、日历与 Live Activity。
相册、录制、播放、语音与音视频合成。
OCR、Vision 检测、Core ML、PDF 与二维码。
HTTP、WebSocket、天气、蓝牙、后台任务与本地服务。
邮件、短信、翻译、NFC、闹钟、内购与端侧 AI。
Pythonista 风格 2D 场景:scene.run、精灵节点、Action、物理与 Classic 绘图。
+先看 scene 的运行模型和最小示例。
按类族、生命周期、Action 和 Physics 查询 API。
Pythonista 风格原生 UI、手动布局、绘图和控件参考。
+先看 ui 的使用边界和最小示例。
按类族和签名查询 ui API。
实时快路径 实时系统和高频更新工具。
用 Python 编写原生主屏、锁屏和 StandBy 小组件。
+从最小脚本到布局、参数、交互、时间线、外观和 API 参考。
学习使用 Python 构建界面、小组件、自动化脚本,并调用 iOS 原生能力。文档按开发路径组织,提供入门指南、示例和 API 参考。
+一行命令安装 Skills,配合 AppUI、Widget 与 iOS 原生文档使用。
+npx skills add Python-IDE/pythonide-skills -g在 编码 Agent(如 Codex、Claude Code、Gemini CLI 等)中编写 PythonIDE 代码时,先安装本站的 Agent Skills。Skills 提供执行规则;完整 API 仍以左侧文档与公开 schema 为准。
+边界:这是给 电脑上的编码 Agent 用的安装包,不是 PythonIDE App 内的 AI 助手,也不是替代在线文档的第二套手册。+
一次安装会带上 6 个互补 Skill:
+| Skill | 何时生效 |
|---|---|
pythonide | 先判断该用 AppUI、Widget、scene 还是原生模块 |
pythonide-appui | 编写或修改 AppUI MiniApp |
pythonide-native | 选型并调用 iOS 原生模块、权限与存储 |
pythonide-widget | 编写 iOS 主屏幕小组件 |
pythonide-scene | 2D 游戏、场景与 turtle |
pythonide-automation | 快捷指令、legacy ui、纯 Python 自动化 |
带相机、定位、通知的 AppUI 应用会同时用到 pythonide-appui 与 pythonide-native(Skills 内已互链)。
| 文档路径 | 相关 Skill |
|---|---|
| appui | pythonide-appui |
| widget | pythonide-widget |
| scene | pythonide-scene |
| iOS 原生模块 | pythonide-native |
| 自动化与扩展 | pythonide-automation |
| 不确定 runtime | pythonide |
| 层级 | 作用 |
|---|---|
| Agent Skills | 怎么写、禁止什么、何时配对哪个 skill |
| 本站中文文档 | 模块说明、示例、场景配方 |
| schema / stubs | API 签名与原生能力路由(机器可读) |
编写时:先遵守 Skill 规则,再查模块文档,再用 schema 校验 API 名称。
+body() 里直接申请权限、定位、拍照或发网络请求;放在命名回调里。PhotoPicker、CameraPicker、MapView、FileImporter、ShareLink、VideoPlayer。npx skills add Python-IDE/pythonide-skills
+预期效果:编码 Agent 工作区出现 pythonide、pythonide-appui 等 Skills,可在写 MiniApp 前自动加载规则。
系统闹钟调度(AlarmKit,iOS 18+)。
+系统闹钟调度(AlarmKit):创建、列出和取消闹钟。
+边界:需要 iOS 26+ 且设备支持 AlarmKit;先用+is_available()判断。与 notification 本地通知不同——闹钟走系统闹钟体验。调度放在用户操作回调里,不要在body()中调用。
| 项 | 说明 |
|---|---|
| 导入 | import alarm |
| 适合做什么 | 起床提醒、定时闹钟、重复闹钟 |
| 调用时机 | schedule() / cancel() 放在按钮回调 |
| 推荐顺序 | is_available() → request_authorization() → schedule() → list_alarms() |
| 标识符 | schedule() 返回 alarm_id,用于 cancel() |
下面脚本检查可用性、申请权限、创建并列出闹钟:
+import alarm
+
+if not alarm.is_available():
+ print("当前系统不支持 AlarmKit(需 iOS 26+)")
+else:
+ try:
+ if alarm.request_authorization():
+ alarm_id = alarm.schedule("起床", 7, 30, repeats=True)
+ print("已创建:", alarm_id)
+ print(alarm.list_alarms())
+ alarm.cancel(alarm_id)
+ else:
+ print("未获得闹钟权限")
+ except alarm.AlarmError as exc:
+ print("闹钟操作失败:", exc, f"code={exc.code}")
+授权和调度放在按钮回调;列表展示当前闹钟。
+import appui
+import alarm
+
+state = appui.State(
+ available=alarm.is_available(),
+ auth="未查询",
+ alarms=[],
+ status="点击按钮管理闹钟",
+)
+
+
+def refresh_list():
+ try:
+ state.alarms = alarm.list_alarms() or []
+ except alarm.AlarmError as exc:
+ state.status = f"读取失败: {exc}"
+
+
+def request_access():
+ if not state.available:
+ state.status = "当前系统不支持 AlarmKit"
+ return
+ try:
+ ok = alarm.request_authorization()
+ state.auth = "已授权" if ok else "未授权"
+ state.status = state.auth
+ if ok:
+ refresh_list()
+ except alarm.AlarmError as exc:
+ state.status = f"授权失败: {exc}"
+
+
+def add_demo_alarm():
+ if not state.available:
+ state.status = "系统不支持"
+ return
+ try:
+ if not alarm.request_authorization():
+ state.status = "请先授权"
+ return
+ alarm.schedule("演示闹钟", 8, 0, repeats=False)
+ refresh_list()
+ state.status = "已添加 08:00 闹钟"
+ except alarm.AlarmError as exc:
+ state.status = f"创建失败: {exc}"
+
+
+def cancel_first():
+ if not state.alarms:
+ state.status = "没有可取消的闹钟"
+ return
+ try:
+ alarm_id = state.alarms[0].get("alarm_id")
+ if alarm_id:
+ alarm.cancel(alarm_id)
+ refresh_list()
+ state.status = f"已取消 {alarm_id[:8]}…"
+ except alarm.AlarmError as exc:
+ state.status = f"取消失败: {exc}"
+
+
+def body():
+ rows = []
+ for item in state.alarms:
+ title = item.get("title", "闹钟")
+ hour = item.get("hour", 0)
+ minute = item.get("minute", 0)
+ repeats = "每天" if item.get("repeats") else "一次"
+ rows.append(
+ appui.LabeledContent(
+ title,
+ value=f"{hour:02d}:{minute:02d} · {repeats}",
+ )
+ )
+
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("环境", [
+ appui.LabeledContent(
+ "AlarmKit",
+ value="可用" if state.available else "不可用(需 iOS 26+)",
+ ),
+ appui.LabeledContent("授权", value=state.auth),
+ ]),
+ appui.Section("闹钟列表", rows or [
+ appui.ContentUnavailableView(
+ "暂无闹钟",
+ system_image="alarm",
+ description="点击下方按钮添加",
+ )
+ ]),
+ appui.Section("操作", [
+ appui.Button("申请权限", action=request_access),
+ appui.Button("添加 08:00 闹钟", action=add_demo_alarm)
+ .button_style("bordered_prominent"),
+ appui.Button("取消第一个", action=cancel_first, role="destructive"),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="真机 + iOS 26+ 测试;与本地通知是不同能力。"),
+ ]).navigation_title("闹钟")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_available() | 是否支持 AlarmKit |
request_authorization() | 申请闹钟权限 → bool |
schedule(title, hour, minute, repeats) | 创建闹钟 → alarm_id |
cancel(alarm_id) | 取消指定闹钟 |
list_alarms() | 已调度闹钟列表 |
AlarmError | 操作失败时抛出 |
import alarm
+
+if alarm.is_available():
+ ok = alarm.request_authorization()
+is_available() 在低于 iOS 26 或 Bridge 不可用时返回 False。
schedule(title, hour, minute, repeats=False)
alarm_id = alarm.schedule("会议提醒", 9, 15)
+alarm_id = alarm.schedule("每日起床", 7, 0, repeats=True)
+| 参数 | 说明 |
|---|---|
title | 闹钟标题 |
hour | 0–23 |
minute | 0–59 |
repeats | 是否每天重复 |
返回字符串 alarm_id,传给 cancel() 使用。
list_alarms() — 返回 [{alarm_id, title, hour, minute, repeats, state}, ...]。
cancel(alarm_id) — 取消并移除记录。
code | 含义 |
|---|---|
unavailable | 系统版本不支持 AlarmKit |
invalid_input | 参数缺失(如没有 title) |
timeout | 异步授权超时 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
不检查 is_available() | 低版本直接崩溃 | 先判断再调度 |
| 当成 notification 用 | API 与体验不同 | 按闹钟场景选模块 |
在 body() 里 schedule() | 刷新时重复创建 | 放进按钮回调 |
丢失 alarm_id | 无法取消 | 保存 schedule() 返回值 |
| 文档 | 用途 |
|---|---|
| notification | 本地通知与角标 |
| permission | 其他权限查询 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+语义色、十六进制、RGB/RGBA 和深色模式建议。
+appui 支持系统语义色、十六进制颜色、RGB 元组和灰度值。理解这些规则有助于在 Text、Shape、Chart 与修饰符中一致地配色。
| 形式 | 示例 | 说明 |
|---|---|---|
| 命名字符串 | "systemBlue"、"label" | 与 iOS 语义色、系统色名称一致(由 系统解析)。 |
| 十六进制 | "#FF5533"、"#22AAFFAA" | 支持 #RRGGBB 或带透明度的 8 位形式。 |
| RGB / RGBA 元组 | (1.0, 0.2, 0.0)、(0, 0.5, 1, 0.4) | 分量范围为 0.0–1.0,转换为 #RRGGBB / #RRGGBBAA。 |
| 灰度标量 | 0.35 | 数值会转换为对应灰度颜色。 |
Color 视图 | appui.Color(value="systemTeal") | 用于在布局中放置一块纯色区域;亦可用 red / green / blue 分量构造。 |
下列名称常用于阅读层次与背景分层(浅色模式下对比更明显;深色模式下系统会自动调整对比度):
+label、secondaryLabel、tertiaryLabelsystemBackground、secondarySystemBackground、tertiarySystemBackgroundseparator、systemFill、secondarySystemFill、tertiarySystemFill(若当前 SDK 映射存在)适合图表、图标与强调文案:systemBlue、systemRed、systemGreen、systemOrange、systemPurple、systemPink、systemYellow、systemTeal、systemCyan、systemIndigo、systemGray、systemBrown、systemMint
label 配 systemBackground,次级说明用 secondaryLabel,避免硬编码纯黑纯白。secondarySystemBackground 或 tertiarySystemBackground,分隔用 separator 或细线 Divider()。systemBlue 或品牌主色十六进制;警告用 systemOrange / systemRed。.preferred_color_scheme('light') 或 .preferred_color_scheme('dark')(参见下方示例)。import appui
+
+state = appui.State(
+ preview_dark=True,
+ accent_hex="#5AC8FA",
+)
+
+
+def swatch_row(title, color_token):
+ return appui.HStack(
+ [
+ appui.Text(title).frame(max_width=appui.infinity, alignment="leading"),
+ appui.Rectangle()
+ .fill(color_token)
+ .frame(width=52, height=30)
+ .corner_radius(6)
+ .overlay(appui.Rectangle().stroke("separator", line_width=1).corner_radius(6)),
+ ],
+ spacing=12,
+ )
+
+
+def set_preview_dark(value):
+ state.preview_dark = bool(value)
+
+
+def set_accent_hex(value):
+ state.accent_hex = value
+
+
+def body():
+ scheme = "dark" if state.preview_dark else "light"
+ palette = appui.Form(
+ [
+ appui.Section(
+ content=[
+ appui.Toggle(
+ "深色预览",
+ is_on=state.preview_dark,
+ on_change=set_preview_dark,
+ ),
+ appui.LabeledContent(
+ "自定义强调色",
+ content=appui.ColorPicker(
+ label="",
+ selection=state.accent_hex,
+ on_change=set_accent_hex,
+ ),
+ ),
+ ],
+ header="外观",
+ ),
+ appui.Section(
+ content=[
+ swatch_row("label", "label"),
+ swatch_row("secondaryLabel", "secondaryLabel"),
+ swatch_row("systemBackground", "systemBackground"),
+ swatch_row("secondarySystemBackground", "secondarySystemBackground"),
+ ],
+ header="语义色",
+ ),
+ appui.Section(
+ content=[
+ swatch_row("systemBlue", "systemBlue"),
+ swatch_row("systemGreen", "systemGreen"),
+ swatch_row("systemOrange", "systemOrange"),
+ swatch_row("RGB 元组红", (0.95, 0.2, 0.25)),
+ swatch_row("十六进制", state.accent_hex),
+ ],
+ header="强调与自定义",
+ ),
+ ]
+ ).navigation_title("颜色参考")
+ return appui.NavigationStack(palette).preferred_color_scheme(scheme)
+
+
+appui.run(body, state=state, presentation="sheet")
+Shape / Chart 的配合Rectangle().fill("systemIndigo") 与 RoundedRectangle(corner_radius=8).fill((0.2, 0.6, 0.9)) 都支持同样的颜色表达。Chart(..., color="systemTeal") 的 color 参数同样支持上述写法。#RRGGBB 字符串。语义色并非固定 RGB,而是随 traitCollection 与界面层级自动映射:
systemBackground 趋向明亮底,label 趋向近黑文本,层次靠 secondaryLabel 等拉开。.preferred_color_scheme('light' | 'dark') 固定外观;发布给用户时通常移除该修饰符以尊重系统设置。foreground_color / foreground_style / tint 的分工foreground_color:多用于 Text、Image 等着色前景内容。foreground_style:可承载更复杂的前景色描述,如渐变名或材质名。tint:作用于控件强调色(如按钮、开关),与 Button(...).button_style("bordered_prominent") 等组合时影响默认着色。下面是一段不依赖 NavigationStack 的极简布局,用于快速验证十六进制、元组和语义色是否能正常渲染。
import appui
+
+state = appui.State()
+
+
+def body():
+ return appui.VStack(
+ [
+ appui.Text("Hex 与元组").font("headline"),
+ appui.HStack(
+ [
+ appui.Rectangle().fill("#34C759").frame(width=60, height=32).corner_radius(6),
+ (
+ appui.Rectangle()
+ .fill((0.95, 0.45, 0.1))
+ .frame(width=60, height=32)
+ .corner_radius(6)
+ ),
+ (
+ appui.Rectangle()
+ .fill("systemIndigo")
+ .frame(width=60, height=32)
+ .corner_radius(6)
+ ),
+ ],
+ spacing=12,
+ ),
+ ],
+ spacing=16,
+ ).padding(24)
+
+
+appui.run(body, state=state, presentation="sheet")
+下列名称均可直接传给 foreground_color、background(color=...)、tint、Chart(..., color=...) 等接受颜色值的 API。
文本与灰阶
+label, secondaryLabel, tertiaryLabel背景层次
+systemBackground, secondarySystemBackground, tertiarySystemBackground分隔与填充
+separator, systemFill, secondarySystemFill, tertiarySystemFill系统强调色(摘录)
+systemBlue, systemRed, systemGreen, systemOrange, systemPurple, systemPink, systemYellow, systemTeal, systemCyan, systemIndigo, systemGray, systemBrown, systemMint在深浅色之间切换时,上述 token 的相对对比度由系统维护;自定义十六进制色不会自动随模式反转,需要自行准备两套色值或在运行时根据 preferred_color_scheme 切换。
label 而非硬编码 "black" / "white"。secondarySystemBackground 对比 systemBackground)。.preferred_color_scheme,以尊重用户系统设置。命令式 旧版手动布局 API 写法迁移到声明式 AppUI。
+ui 是命令式控件树,appui 是声明式 View 树。迁移时不要逐行翻译控件操作;先把界面状态整理出来,再用 body() 描述当前状态下的界面。
| ui 写法 | appui 写法 |
|---|---|
| 创建控件后设置属性 | 在构造函数和修饰符链里声明属性 |
view.add_subview(...) | VStack/HStack/ZStack/List/Form 组合子 View |
| 回调里直接改控件 | 回调里改 State |
| 手动 push/present | NavigationStack、sheet、alert |
| 保存控件实例用于后续修改 | 保存状态或 Ref |
| frame 手算布局 | Stack/Grid/frame/padding |
旧 ui 思路:
import ui
+
+count = 0
+label = ui.Label(text="0")
+
+def add(sender):
+ global count
+ count += 1
+ label.text = str(count)
+
+button = ui.Button(title="Add")
+button.action = add
+appui 写法:
import appui
+
+state = appui.State(count=0)
+
+
+def increment():
+ state.count += 1
+
+
+def body():
+ return appui.VStack([
+ appui.Text(str(state.count)).font("largeTitle").bold(),
+ appui.Button("Add", action=increment),
+ ], spacing=16).padding()
+
+
+appui.run(body, state=state)
+重点不是 Label -> Text,而是“控件文本”变成了 state.count 的投影。
旧写法通常是维护 table 数据源并手动刷新。appui 里直接让列表来自状态。
import appui
+
+state = appui.State(items=["Milk", "Coffee"])
+
+
+def add_item():
+ state.items = list(state.items) + [f"Item {len(state.items) + 1}"]
+
+
+def item_key(item):
+ return item
+
+
+def row_view(item):
+ return appui.Text(item)
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Items", [
+ appui.ForEach(state.items, row_builder=row_view, key=item_key)
+ ])
+ ])
+ .navigation_title("List")
+ .toolbar([
+ appui.ToolbarItem(
+ placement="navigation_bar_trailing",
+ content=appui.Button("Add", action=add_item),
+ )
+ ])
+ )
+
+
+appui.run(body, state=state)
+需要增删改的列表要么整体替换 state.items,要么使用 ObservableList;不要原地修改普通 list 后期待 UI 自动刷新。
ui.TextField 的 action / delegate 通常迁移成绑定。
import appui
+
+state = appui.State(name="", notifications=True)
+
+
+def set_name(value):
+ state.name = value
+
+
+def set_notifications(value):
+ state.notifications = value
+
+
+def body():
+ return appui.Form([
+ appui.Section("Profile", [
+ appui.TextField("Name", text=state.name, on_change=set_name),
+ appui.Toggle("Notifications", is_on=state.notifications, on_change=set_notifications),
+ ]),
+ appui.Section("Preview", [
+ appui.Text(f"Hello, {state.name or 'Guest'}"),
+ ]),
+ ]).navigation_title("Profile")
+
+
+appui.run(body, state=state)
+表单字段用当前值加 on_change 写回;不要在多个地方维护同一个字段的副本。
命令式 present/push 迁移到数据化导航:
+import appui
+
+path = appui.NavigationPath()
+state = appui.State(selected="A")
+
+
+def open_detail():
+ state.selected = "A"
+ path.append("detail")
+
+
+def go_back():
+ path.pop()
+
+
+def detail_destination(value):
+ return appui.VStack([
+ appui.Text(f"Detail {state.selected}").font("title2"),
+ appui.Button("Back", action=go_back),
+ ], spacing=12).padding()
+
+
+def body():
+ return appui.NavigationStack(
+ content=appui.VStack([
+ appui.Text("Home"),
+ appui.Button("Open detail", action=open_detail),
+ ], spacing=16).padding().navigation_title("Home"),
+ path=path,
+ destinations={
+ "detail": detail_destination,
+ },
+ )
+
+
+appui.run(body, state=state)
+如果只是固定页面跳转,也可以用 NavigationLink。
body() 返回的 View 树。| 旧习惯 | 问题 | appui 改法 |
|---|---|---|
label.text = ... | 绕过状态源 | Text(f"{state.value}") |
| 全局保存控件对象 | 重建后对象可能失效 | 保存 State / Ref |
| 手动设置每个 frame | 不适配设备 | Stack + frame + padding |
| 回调里同时改很多字段 | 中间状态闪动 | state.batch_update(...) |
| 复制其他框架参数名 | Python API 可能不同 | 查 appui API 参考 |
Image、Label、Menu 中使用系统符号。
+SF Symbols 与 appui.Image(system_name=...)、appui.Label(title, system_image=...) 配合使用。下列名称均来自公开符号集(实际可用性随 iOS 版本略有差异;若单个符号缺失,请替换为同义图标)。
chevron.left, chevron.right, chevron.up, chevron.down, chevron.compact.left, chevron.compact.right, arrow.left, arrow.right, arrow.up, arrow.down, arrow.turn.up.left, arrow.turn.down.right, house, house.fill, gear, gearshape, gearshape.fill, sidebar.left, sidebar.right, line.3.horizontal, line.3.horizontal.decrease, ellipsis, ellipsis.circle, xmark, xmark.circle, xmark.circle.fill, plus, plus.circle, minus, menucard, square.grid.2x2, list.bullet, list.number
play, play.fill, play.circle, play.circle.fill, pause, pause.fill, stop, stop.fill, forward, forward.fill, backward, backward.fill, goforward, gobackward, speaker, speaker.fill, speaker.wave.2, speaker.wave.2.fill, speaker.slash, music.note, music.note.list, waveform, mic, mic.fill, camera, camera.fill, video, video.fill, film, film.fill
envelope, envelope.fill, envelope.open.fill, phone, phone.fill, message, message.fill, bubble.left, bubble.left.fill, bubble.right, bubble.right.fill, paperplane, paperplane.fill, person.crop.circle, person.2.fill
doc, doc.fill, doc.text, doc.text.fill, doc.on.doc, doc.plaintext, folder, folder.fill, tray, tray.fill, tray.and.arrow.up, tray.and.arrow.down, archivebox, archivebox.fill, book, book.fill, books.vertical.fill, bookmark, bookmark.fill, tag, tag.fill, paperclip, link, link.circle
pencil, pencil.circle, square.and.pencil, trash, trash.fill, square.and.arrow.up, square.and.arrow.down, scissors, paintbrush, paintbrush.fill, wrench.and.screwdriver, hammer, eyedropper, crop, slider.horizontal.3
checkmark, checkmark.circle, checkmark.circle.fill, xmark.seal, exclamationmark.triangle, exclamationmark.triangle.fill, info.circle, info.circle.fill, bell, bell.fill, bell.badge, star, star.fill, heart, heart.fill, flag, flag.fill, hand.thumbsup.fill, hand.thumbsdown.fill
sun.max, sun.max.fill, moon, moon.fill, cloud, cloud.fill, cloud.sun.fill, cloud.rain, cloud.rain.fill, cloud.bolt.rain.fill, wind, snowflake, tornado, humidity, thermometer.sun, thermometer.snowflake, thermometer.medium
wifi, wifi.slash, antenna.radiowaves.left.and.right, battery.100, battery.25, bolt.horizontal, lock, lock.fill, lock.open.fill, key, key.fill, person, person.fill, person.crop.circle.fill, faceid, touchid, mappin, mappin.and.ellipse, map, map.fill, location, location.fill, network, cpu, memorychip
plus, plus.circle.fill, minus, minus.circle.fill, multiply, multiply.circle.fill, divide, divide.circle.fill, equal, percent, function, sum, number, numbers.rectangle, x.squareroot
circle, circle.fill, square, square.fill, triangle, triangle.fill, diamond, diamond.fill, hexagon, hexagon.fill, octagon, octagon.fill, capsule, capsule.fill, oval, oval.fill, rhombus, rhombus.fill
Image 与 Label 示例import appui
+
+state = appui.State(highlight=False)
+
+
+def toggle_highlight():
+ state.highlight = not state.highlight
+
+
+def body():
+ icon = "star.fill" if state.highlight else "star"
+ return appui.NavigationStack(
+ appui.VStack(
+ [
+ appui.HStack(
+ [
+ appui.Image(system_name=icon)
+ .foreground_color("systemYellow")
+ .image_scale("large"),
+ appui.Label("收藏", system_image=icon).foreground_color("label"),
+ ],
+ spacing=16,
+ ),
+ appui.Button(
+ "切换",
+ action=toggle_highlight,
+ ).button_style("bordered"),
+ ],
+ spacing=24,
+ )
+ .padding(24)
+ .navigation_title("SF Symbols")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+Image;需要标题+图标组合时用 Label。.symbol_rendering_mode("palette") 等(参见 修饰符 API)。.accessibility_label("…")。Labelimport appui
+
+state = appui.State(items=["下载", "设置", "关于"])
+
+
+def row_key(title):
+ return title
+
+
+def row_view(title):
+ return appui.Label(title, system_image="folder.fill")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("项目", [
+ appui.ForEach(state.items, row_builder=row_view, key=row_key)
+ ])
+ ]).navigation_title("符号行")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+heart 与 heart.fill);列表与工具栏中填充变体更易辨认。Menu 的 content 中使用 Button(..., content=Label(...)) 可构建带图标的菜单项,与 Image 单独放置相比更利于对齐系统样式。下列分组与示例和主文档中常用命名一致,可与上文「按类别速览」交叉对照。若符号在某 iOS 版本不可用,请以系统 SF Symbols 目录为准替换为同义图标。
+chevron.left, chevron.right, arrow.left, arrow.right, house.fill, gear, sidebar.left, line.3.horizontal
play.fill, pause.fill, stop.fill, forward.fill, backward.fill, speaker.wave.2.fill, mic.fill, camera.fill, photo.fill
envelope.fill, phone.fill, message.fill, bubble.left.fill, paperplane.fill
doc.fill, doc.text.fill, folder.fill, tray.fill, archivebox.fill, book.fill, bookmark.fill
pencil, trash.fill, doc.on.doc, square.and.arrow.up, square.and.arrow.down, scissors, paintbrush.fill
checkmark, xmark, exclamationmark.triangle.fill, info.circle.fill, bell.fill, star.fill, heart.fill, flag.fill
sun.max.fill, moon.fill, cloud.fill, cloud.rain.fill, wind, snowflake, thermometer
wifi, antenna.radiowaves.left.and.right, battery.100, lock.fill, key.fill, person.fill, mappin
plus, minus, multiply, divide, equal, number
circle.fill, square.fill, triangle.fill, diamond.fill, hexagon.fill
Image(system_name=...) 默认走模板渲染;需要保留多色符号时,使用链式 .symbol_rendering_mode("multicolor")。ToolbarItem 或 List 行内,Label 通常比裸 Image + Text 更省代码,且与系统间距对齐更一致。Menu + Label 最小示例import appui
+
+state = appui.State(picked="")
+
+
+def pick_copy():
+ state.picked = "copy"
+
+
+def pick_trash():
+ state.picked = "trash"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack(
+ [
+ appui.Menu(
+ title="更多",
+ content=[
+ appui.Button(
+ action=pick_copy,
+ content=appui.Label("复制", system_image="doc.on.doc"),
+ ),
+ appui.Button(
+ action=pick_trash,
+ role="destructive",
+ content=appui.Label("删除", system_image="trash.fill"),
+ ),
+ ],
+ ),
+ appui.Text(f"上次操作: {state.picked or '无'}").padding(top=12),
+ ],
+ spacing=16,
+ )
+ .padding(24)
+ .navigation_title("菜单与符号")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+注意:Menu 的 content 为 Button 列表;带图标的条目使用 Button(..., content=Label(...))。
在 macOS 上打开 SF Symbols 应用,复制名称到 system_image=;不要凭记忆手写符号名。对同一语义准备 主符号 + 备用符号(例如 doc.text.fill 不可用时退回 doc.fill),可减少用户设备差异带来的空白图标。
Chart、Canvas、DrawingContext 和阈值可视化。
+演示 Chart 与 Canvas/DrawingContext:统计图优先 Chart,自定义示意图再用 Canvas。
运行后会出现图表与画布实验室,图表类型、强调色和阈值会联动更新。
+import appui
+
+state = appui.State(
+ chart_type="bar",
+ threshold=55.0,
+ accent="systemBlue",
+ rows=[
+ {"month": "Jan", "value": 42},
+ {"month": "Feb", "value": 64},
+ {"month": "Mar", "value": 53},
+ {"month": "Apr", "value": 78},
+ {"month": "May", "value": 69},
+ ],
+)
+
+
+def set_chart_type(value):
+ state.chart_type = value
+
+
+def set_threshold(value):
+ state.threshold = float(value)
+
+
+def set_accent(value):
+ state.accent = value
+
+
+def drawing_context():
+ ctx = appui.DrawingContext()
+ ctx.rounded_rect(
+ 10, 10, 260, 140,
+ corner_radius=14,
+ color="secondarySystemBackground",
+ fill=True,
+ )
+ ctx.line(24, 118, 250, 118, color="separator", line_width=1)
+ ctx.line(24, 28, 24, 118, color="separator", line_width=1)
+ max_value = max(row["value"] for row in state.rows)
+ for index, row in enumerate(state.rows):
+ x = 42 + index * 42
+ height = 80 * row["value"] / max_value
+ color = "systemRed" if row["value"] >= state.threshold else state.accent
+ ctx.rounded_rect(x, 118 - height, 24, height, corner_radius=5, color=color, fill=True)
+ ctx.fill_text(row["month"], x - 3, 132, color="secondaryLabel", font_size=10)
+ y = 118 - 80 * state.threshold / max_value
+ ctx.line(24, y, 250, y, color="systemOrange", line_width=2)
+ ctx.fill_text("阈值", 212, y - 6, color="systemOrange", font_size=11)
+ return ctx
+
+
+def body():
+ return appui.NavigationStack(
+ appui.ScrollView(
+ appui.VStack(
+ [
+ appui.Form(
+ [
+ appui.Section(
+ [
+ appui.Picker(
+ "图表类型",
+ selection=state.chart_type,
+ options=["bar", "line", "area", "point"],
+ on_change=set_chart_type,
+ ),
+ appui.ColorPicker(
+ "强调色",
+ selection=state.accent,
+ on_change=set_accent,
+ ),
+ appui.Slider(
+ value=state.threshold,
+ minimum=30,
+ maximum=90,
+ step=1,
+ label="阈值",
+ on_change=set_threshold,
+ ),
+ appui.LabeledContent("当前阈值", value=f"{state.threshold:.0f}"),
+ ],
+ header="配置",
+ )
+ ]
+ ),
+ appui.VStack(
+ [
+ appui.Text("系统图表").font("headline"),
+ appui.Chart(
+ state.rows,
+ x="month",
+ y="value",
+ type=state.chart_type,
+ color=state.accent,
+ ).frame(height=220),
+ ],
+ spacing=8,
+ ).padding(horizontal=16),
+ appui.VStack(
+ [
+ appui.Text("Canvas 自定义阈值图").font("headline"),
+ appui.Canvas(width=280, height=160, context=drawing_context()),
+ ],
+ spacing=8,
+ ).padding(horizontal=16),
+ ],
+ spacing=14,
+ )
+ ).navigation_title("可视化")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+Chart 数据为字典列表,x/y 与字段名一致。DrawingContext 在渲染函数里按需构造,不要整段上下文存进 State;命令需可 JSON 序列化。Canvas 手绘可维护性差,优先 Chart。消息列表、底部输入、安全区和滚动定位。
+本页演示:ScrollViewReader + LazyVStack 构建消息列表、TextField 的 on_submit 与 .submit_label("send") 发送消息、.focused 管理键盘焦点、.animation 在列表变化时做过渡,以及滚动到底部锚点。
运行后会出现聊天页面,消息流、底部输入、安全区和发送反馈保持稳定。
+on_submit 造成的操作单一路径。Group 并添加 .accessibility_label,将「谁说了什么」拼成完整句子(本示例为缩短篇幅未展示)。keyboard_dismiss("interactive") 与 ScrollViewReader 同屏出现时,注意手势冲突:若发现滚动被键盘手势抢占,可尝试改为 keyboard_dismiss("immediately") 做 A/B。ScrollViewReader 通过 scroll_to 传入目标子视图的 .id() 字符串;anchor 常用 top / bottom。TextField(..., on_submit=...) 与视图级 .on_submit(...) 均在 appui 中存在;本例使用构造函数内的 on_submit。.focused(field_id, equals=...):field_id 为字符串时与 equals 比较以决定焦点。LazyVStack 末尾放置一个极矮的占位视图并标记 .id("bottom"),便于「滚到最新消息」。import appui
+
+state = appui.State(
+ next_id=1,
+ messages=[{"id": 0, "who": "bot", "text": "你好,这是可运行的聊天示例。"}],
+ input_text="",
+ scroll_to="",
+ focus_field="input",
+)
+
+
+def send_message():
+ text = (state.input_text or "").strip()
+ if not text:
+ return
+ mid = state.next_id
+ state.batch_update(
+ messages=list(state.messages) + [{"id": mid, "who": "me", "text": text}],
+ next_id=mid + 1,
+ input_text="",
+ scroll_to="bottom",
+ focus_field="input",
+ )
+
+
+def bubble(msg):
+ mine = msg["who"] == "me"
+ bubble_bg = "systemBlue" if mine else "secondarySystemBackground"
+ fg = "white" if mine else "label"
+ align = "trailing" if mine else "leading"
+ return appui.HStack(
+ [
+ appui.Text(msg["text"])
+ .padding(10)
+ .background(color=bubble_bg, corner_radius=14)
+ .foreground_color(fg),
+ ],
+ alignment=align,
+ ).frame(max_width=appui.infinity)
+
+
+def message_key(msg):
+ return msg["id"]
+
+
+def set_input_text(value):
+ state.input_text = value
+
+
+def body():
+ reader = appui.ScrollViewReader(
+ scroll_to=state.scroll_to or None,
+ anchor="bottom",
+ content=[
+ appui.LazyVStack(
+ [
+ appui.ForEach(
+ state.messages,
+ row_builder=bubble,
+ key=message_key,
+ ).animation("spring", value=len(state.messages)),
+ appui.Color("clear").frame(height=1).id("bottom"),
+ ],
+ spacing=10,
+ )
+ .padding(12)
+ .keyboard_dismiss("interactive"),
+ ],
+ )
+ composer = appui.HStack(
+ [
+ appui.TextField(
+ "输入消息…",
+ text=state.input_text,
+ on_change=set_input_text,
+ on_submit=send_message,
+ )
+ .submit_label("send")
+ .focused("input", equals=state.focus_field)
+ .text_field_style("rounded_border")
+ .frame(max_width=appui.infinity),
+ appui.Button("发送", action=send_message).button_style("bordered_prominent"),
+ ],
+ spacing=8,
+ ).padding(12)
+ return appui.NavigationStack(
+ appui.VStack(
+ [
+ reader,
+ composer,
+ ],
+ spacing=0,
+ ).navigation_title("聊天")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+state.scroll_to 设为 "bottom",与列表末尾占位视图的 .id("bottom") 对齐;如需连续发送仍触发滚动,可在下一次构建前把 scroll_to 清空再由业务写入。focus_field 与 TextField 上的 .focused("input", equals=state.focus_field) 搭配;若需多段输入框,可为不同字段使用不同字符串标识。.animation("spring", value=len(state.messages)) 将隐式动画绑定到消息条数变化;亦可在 send_message 外层使用 appui.animate(参见主文档)。ScrollViewReader 参数摘要| 参数 | 作用 |
|---|---|
content | 滚动区域的子视图列表,通常包含 LazyVStack。 |
axes | 'vertical'(默认)或 'horizontal' 控制滚动方向。 |
shows_indicators | 是否显示滚动指示条。 |
scroll_to | 设置为与某个子视图 .id("…") 相同的字符串时,驱动滚动定位。 |
anchor | 与目标视图对齐的锚点,例如 'bottom' 便于对齐最新消息。 |
send_message 已 strip() 并忽略空串,避免插入空白气泡。keyboard_dismiss("interactive") 绑在消息区域的 LazyVStack 上,便于用户向下轻扫收起键盘;若希望轻扫即发送,可结合 .on_submit 自定义。LazyVStack 只构建可视区域;若单条消息文本极长,可配合 .line_limit(20) 等修饰符防止单格过高。send_with_animation() 包住 send_message(),再传给 appui.animate(send_with_animation, type="spring")。scroll_to 与 anchor='bottom')。border)。示例中的 messages 使用 dict 承载 id / who / text 三字段,便于 ForEach 的 key 去重与左右气泡分支。接入真实业务时常见演进方式如下(仍使用已存在的 State / List / Text 等 API,不引入虚构类型):
ts 整数或 ISO 字符串,在 bubble 内用较小字号 Text 显示在气泡下方。status 字段(如 sending / sent / failed),在 HStack 尾部追加 ProgressView 或 Image(system_name="checkmark")。state.messages 拆为「已加载窗口」与「游标」,在 ScrollView 的 on_appear 或列表顶部 Button("加载更多") 中向列表前端插入历史项;LazyVStack 保持懒构建特性。state.messages 做 extend 或整表替换;若更新频率高,可评估将输入框绑定迁移到 ReactiveState 以减少重建范围。| 现象 | 排查方向 |
|---|---|
| 发送后界面不刷新 | 确认 appui.run(body, state=state, ...) 已传入同一 state 实例;避免在 body 外重新构造新的 State。 |
scroll_to 无效 | 确认目标 .id 字符串与 scroll_to 完全一致;占位条高度不宜为 0(示例使用 frame(height=1))。 |
| 键盘「发送」无响应 | 检查是否链式 .submit_label("send");若使用自定义 return 键文案,需与 iOS 本地化键名一致。 |
| 焦点无法切换 | 多字段场景下为每个 TextField 使用不同的 field_id 字符串,并统一由 state.focus_field 驱动 equals。 |
示例用一次 state.batch_update(...) 同时写入新消息、递增 next_id、清空 input_text 并设置 scroll_to。这样比先 append 再改输入框更稳定,能避免用户看到「消息已发送但输入框仍保留旧文本」的中间状态。
如果用户可能连续快速点击发送,可增加 sending 布尔字段并在发送期间禁用按钮;写法上仍只需要 State 字段与 Button(..., action=...)。
看板卡片、网格布局和真实交互。
+适合做首页概览、运营面板、学习进度页。这个样板使用 ScrollView + LazyVGrid,卡片高度稳定,点击卡片后会更新选中状态。
运行后会出现紧凑看板,指标卡、筛选表单和明细导航会随搜索和状态切换更新。
+import appui
+
+state = appui.State(selected="Revenue")
+
+metrics = [
+ {
+ "id": "revenue",
+ "name": "Revenue",
+ "value": "$48.2K",
+ "delta": "+12%",
+ "color": "systemGreen",
+ },
+ {"id": "users", "name": "Users", "value": "8,420", "delta": "+5%", "color": "systemBlue"},
+ {"id": "errors", "name": "Errors", "value": "14", "delta": "-3%", "color": "systemOrange"},
+ {"id": "uptime", "name": "Uptime", "value": "99.98%", "delta": "stable", "color": "systemTeal"},
+]
+
+
+def metric_key(item):
+ return item["id"]
+
+
+def select_metric(name):
+ state.selected = name
+
+
+def metric_card(item):
+ def open_metric():
+ select_metric(item["name"])
+
+ content = appui.VStack([
+ appui.HStack([
+ appui.Text(item["name"]).font("caption").foreground_color("secondaryLabel"),
+ appui.Spacer(),
+ appui.Text(item["delta"]).font("caption").foreground_color(item["color"]),
+ ]),
+ appui.Text(item["value"]).font("title2").bold(),
+ ], alignment="leading", spacing=8)
+
+ return (
+ appui.Button(action=open_metric, content=content)
+ .button_style("plain")
+ .frame(max_width=appui.infinity, min_height=104, alignment="leading")
+ .padding(14)
+ .background("secondarySystemBackground", corner_radius=8)
+ )
+
+
+def body():
+ grid = appui.LazyVGrid(
+ columns=[appui.flexible(minimum=140), appui.flexible(minimum=140)],
+ spacing=12,
+ content=[appui.ForEach(metrics, row_builder=metric_card, key=metric_key)],
+ )
+
+ header = appui.VStack([
+ appui.Text("Operations").font("largeTitle").bold()
+ .frame(max_width=appui.infinity, alignment="leading"),
+ appui.Text(f"Selected: {state.selected}")
+ .foreground_color("secondaryLabel")
+ .frame(max_width=appui.infinity, alignment="leading"),
+ ], alignment="leading", spacing=4)
+
+ return appui.NavigationStack(
+ appui.ScrollView(
+ appui.VStack([header, grid], alignment="leading", spacing=16).padding(16)
+ )
+ .navigation_title("Dashboard")
+ )
+
+
+appui.run(body, state=state)
+运营类仪表盘如果有搜索、筛选和明细页,优先用 List + Section + NavigationLink。空结果用 ContentUnavailableView,不要只留空白页面。
import appui
+
+state = appui.State(query="", status="All")
+
+rows = [
+ {"id": "revenue", "name": "Revenue", "status": "Good", "value": "$48.2K"},
+ {"id": "errors", "name": "Errors", "status": "Needs Review", "value": "14"},
+ {"id": "uptime", "name": "Uptime", "status": "Good", "value": "99.98%"},
+]
+
+
+def row_key(row):
+ return row["id"]
+
+
+def set_query(value):
+ state.query = value
+
+
+def set_status(value):
+ state.status = value
+
+
+def filtered_rows():
+ query = state.query.strip().lower()
+ result = rows
+ if state.status != "All":
+ result = [row for row in result if row["status"] == state.status]
+ if query:
+ result = [row for row in result if query in row["name"].lower()]
+ return result
+
+
+def detail_view(row):
+ return appui.Form([
+ appui.Section("Metric", [
+ appui.LabeledContent("Name", value=row["name"]),
+ appui.LabeledContent("Status", value=row["status"]),
+ appui.LabeledContent("Value", value=row["value"]),
+ ])
+ ]).navigation_title(row["name"])
+
+
+def row_view(row):
+ return appui.NavigationLink(
+ destination=detail_view(row),
+ label=appui.LabeledContent(row["name"], value=row["value"]),
+ )
+
+
+def body():
+ visible = filtered_rows()
+ content = (
+ appui.ContentUnavailableView(
+ "No Metrics",
+ system_image="chart.bar",
+ description="Change the search or filter.",
+ )
+ if not visible
+ else appui.ForEach(visible, row_builder=row_view, key=row_key)
+ )
+
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Filter", [
+ appui.Picker(
+ "Status",
+ selection=state.status,
+ options=["All", "Good", "Needs Review"],
+ on_change=set_status,
+ )
+ .picker_style("segmented")
+ ]),
+ appui.Section("Metrics", [content]),
+ ])
+ .searchable(text=state.query, on_change=set_query)
+ .navigation_title("Dashboard")
+ )
+
+
+appui.run(body, state=state)
+ScrollView + LazyVGrid,但普通数据列表仍优先用 List。Button 包裹,点击后必须更新状态或进入详情。appui.infinity,不要写字符串 ".infinity"。min_height 固定,避免不同内容导致网格跳动。Form、Section、输入控件、颜色选择和 storage 持久化。
+演示 Form + Section 与各类控件绑定 State,并用 storage 持久化偏好。
运行后会出现完整表单设置页,文本、开关、日期、颜色和保存状态会同步更新。
+import appui
+import haptics
+import storage
+
+SETTINGS_KEY = "demo.native.settings"
+
+defaults = {
+ "display_name": "MiniApp 用户",
+ "api_token": "",
+ "mode": "自动",
+ "refresh_minutes": 15,
+ "volume": 0.55,
+ "accent": "systemBlue",
+ "deadline": "2026-05-01 09:00",
+ "sync_enabled": True,
+ "wifi_only": True,
+}
+
+state = appui.State(**defaults, status="未保存", loaded=False)
+
+
+def load_saved_settings():
+ if state.loaded:
+ return
+ state.loaded = True
+ saved = storage.get_json(SETTINGS_KEY, default={}) or {}
+ for key, fallback in defaults.items():
+ setattr(state, key, saved.get(key, fallback))
+ if saved:
+ state.status = "已加载保存设置"
+
+
+def update(field):
+ def setter(value):
+ setattr(state, field, value)
+ return setter
+
+
+def save_settings():
+ payload = {
+ "display_name": state.display_name,
+ "api_token": state.api_token,
+ "mode": state.mode,
+ "refresh_minutes": int(state.refresh_minutes),
+ "volume": float(state.volume),
+ "accent": state.accent,
+ "deadline": state.deadline,
+ "sync_enabled": bool(state.sync_enabled),
+ "wifi_only": bool(state.wifi_only),
+ }
+ storage.set_json(SETTINGS_KEY, payload)
+ state.status = "已保存"
+ haptics.notification("success")
+
+
+def reset_settings():
+ for key, value in defaults.items():
+ setattr(state, key, value)
+ state.status = "已恢复默认"
+ haptics.notification("warning")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form(
+ [
+ appui.Section(
+ [
+ appui.TextField(
+ "显示名称",
+ text=state.display_name,
+ on_change=update("display_name"),
+ ),
+ appui.SecureField(
+ "API Token",
+ text=state.api_token,
+ on_change=update("api_token"),
+ ),
+ ],
+ header="账号",
+ footer="Token 只用于演示表单输入;生产场景请保存到 keychain。",
+ ),
+ appui.Section(
+ [
+ appui.Toggle(
+ "启用同步",
+ is_on=state.sync_enabled,
+ on_change=update("sync_enabled"),
+ ),
+ appui.Toggle(
+ "仅 Wi-Fi 同步",
+ is_on=state.wifi_only,
+ on_change=update("wifi_only"),
+ ),
+ appui.Picker(
+ "同步模式",
+ selection=state.mode,
+ options=["自动", "手动", "低电量"],
+ on_change=update("mode"),
+ ),
+ ],
+ header="同步",
+ ),
+ appui.Section(
+ [
+ appui.Stepper(
+ "刷新间隔",
+ value=state.refresh_minutes,
+ minimum=5,
+ maximum=120,
+ step=5,
+ on_change=update("refresh_minutes"),
+ ),
+ appui.LabeledContent("当前间隔", value=f"{int(state.refresh_minutes)} 分钟"),
+ appui.Slider(
+ value=state.volume,
+ minimum=0,
+ maximum=1,
+ step=0.05,
+ label="提示音量",
+ on_change=update("volume"),
+ ),
+ appui.ProgressView(value=state.volume),
+ ],
+ header="数值",
+ ),
+ appui.Section(
+ [
+ appui.DatePicker(
+ "提醒时间",
+ selection=state.deadline,
+ components="date",
+ on_change=update("deadline"),
+ ),
+ appui.ColorPicker(
+ "强调色",
+ selection=state.accent,
+ on_change=update("accent"),
+ ),
+ ],
+ header="外观与时间",
+ ),
+ appui.Section(
+ [
+ appui.LabeledContent("状态", value=state.status),
+ (
+ appui.Button("保存设置", action=save_settings)
+ .button_style("bordered_prominent")
+ ),
+ appui.Button("恢复默认", role="destructive", action=reset_settings),
+ ],
+ header="操作",
+ ),
+ ]
+ ).navigation_title("设置")
+ ).on_appear(load_saved_settings)
+
+
+appui.run(body, state=state, presentation="sheet")
+Form + Section;控件值绑定 State 并在 on_change 写回。storage;真密钥用 keychain。on_appear,不要在模块加载时访问原生存储,预览和首次渲染会更稳定。Toggle 的 on_change 只更新状态,避免顺带做 Tab 切换等导航副作用。LazyVGrid、AsyncImage、PhotoPicker、详情导航和全屏预览。
+本页演示:LazyVGrid + AsyncImage 展示远程缩略图、PhotoPicker 将相册资源加入列表、NavigationStack + NavigationLink 进入详情,以及用 .sheet 做全屏感预览。
运行后会出现相册网格,支持添加图片、打开详情、全屏查看和处理空状态。
+| 字段 | 类型意图 | 更新来源 |
|---|---|---|
urls | 字符串列表,元素为 https 或 file:// | 初始内置远程图;PhotoPicker 回调追加本地资源。 |
sheet_open | 布尔 | 用户打开/关闭全屏预览。 |
sheet_url | 字符串 | 当前预览指向的 URL;与 AsyncImage 的 url 参数一致。 |
保持 sheet_url 仅在 sheet_open 为真时读取,可避免旧 URL 在模态关闭后仍参与布局的短暂闪烁(示例未强制清空,以便演示简单)。
PhotoPicker(selection_limit, filter, on_picked, label):filter 取 images、videos 或 all;on_picked 收到本地路径字符串列表。AsyncImage(url, placeholder, error_view, content_mode):content_mode 为 fit 或 fill。NavigationLink 可用 label 传入任意 View,适合「缩略图即入口」。.sheet(is_presented=..., content=..., on_dismiss=...)。import appui
+
+state = appui.State(
+ urls=[
+ "https://picsum.photos/id/64/600/600",
+ "https://picsum.photos/id/65/600/600",
+ "https://picsum.photos/id/66/600/600",
+ ],
+ sheet_open=False,
+ sheet_url="",
+)
+
+
+def _normalize_url(path_or_url):
+ s = str(path_or_url)
+ if s.startswith(("http://", "https://", "file://")):
+ return s
+ return "file://" + s
+
+
+def on_picked(paths):
+ next_urls = list(state.urls)
+ for p in paths or []:
+ u = _normalize_url(p)
+ if u not in next_urls:
+ next_urls.append(u)
+ state.urls = next_urls
+
+
+def open_sheet(url):
+ state.batch_update(sheet_open=True, sheet_url=url)
+
+
+def close_sheet():
+ state.batch_update(sheet_open=False, sheet_url="")
+
+
+def thumb(url):
+ return appui.AsyncImage(
+ url=url,
+ placeholder=appui.ProgressView(),
+ error_view=appui.Image(system_name="photo"),
+ content_mode="fill",
+ ).frame(height=120).clip_shape("rounded_rect").corner_radius(10)
+
+
+def detail(url):
+ def preview_current():
+ open_sheet(url)
+
+ return appui.ScrollView(
+ appui.VStack(
+ [
+ appui.AsyncImage(
+ url=url,
+ placeholder=appui.ProgressView(),
+ error_view=appui.Image(system_name="exclamationmark.triangle"),
+ content_mode="fit",
+ ).frame(max_width=appui.infinity),
+ appui.Button(
+ "全屏预览",
+ action=preview_current,
+ ).button_style("bordered_prominent"),
+ ],
+ spacing=12,
+ ).padding(16)
+ ).navigation_title("详情")
+
+
+def sheet_root():
+ return appui.ZStack(
+ [
+ appui.Color("systemBackground"),
+ appui.VStack(
+ [
+ appui.HStack(
+ [
+ appui.Button("关闭", action=close_sheet).button_style("bordered"),
+ appui.Spacer(),
+ ]
+ ).padding(12),
+ appui.AsyncImage(
+ url=state.sheet_url,
+ placeholder=appui.ProgressView(),
+ content_mode="fit",
+ )
+ .frame(max_width=appui.infinity, max_height=appui.infinity)
+ .padding(12),
+ appui.Spacer(min_length=0),
+ ]
+ ),
+ ]
+ )
+
+
+def body():
+ col = appui.adaptive(minimum=104, maximum=160)
+ grid = appui.LazyVGrid(
+ columns=[col],
+ spacing=10,
+ content=[
+ appui.NavigationLink(
+ destination=detail(u),
+ label=thumb(u),
+ )
+ for u in state.urls
+ ],
+ )
+ root = appui.NavigationStack(
+ appui.ScrollView(
+ appui.VStack(
+ [
+ appui.HStack(
+ [
+ appui.PhotoPicker(
+ selection_limit=6,
+ filter="images",
+ on_picked=on_picked,
+ label=appui.Label("从相册添加", system_image="photo.fill"),
+ ),
+ appui.Spacer(),
+ ]
+ ).padding(horizontal=16),
+ grid.padding(horizontal=12),
+ ],
+ spacing=12,
+ )
+ ).navigation_title("相册浏览器")
+ )
+ return root.sheet(
+ is_presented=state.sheet_open,
+ on_dismiss=close_sheet,
+ content=sheet_root(),
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+file:// 前缀拼成 URL 形式字符串,便于与远程 https 资源统一存入 state.urls。NavigationLink 负责栈内详情;sheet 适合「沉浸式预览」或临时操作面板,两者可同时存在。LazyVGrid 仅构建可见区域的子视图,适合大量缩略图;仍建议控制 selection_limit 与列表长度。PhotoPicker 依赖系统照片权限;首次使用若未授权,回调可能为空列表。示例在 on_picked 中对 paths 做空值容错(for p in paths or [])。AsyncImage 使用网络 URL 时需允许应用访问对应域;演示域名 picsum.photos 仅作占位,可替换为你自己的 CDN。file:// 路径:不同运行环境返回的路径前缀可能不同;_normalize_url 仅做最小归一化。若遇到加载失败,请检查当前环境是否允许读取该路径。NavigationStack 替换为 ScrollView + on_tap 打开 sheet,适合全屏单页工具。detail(url) 内为工具栏添加 Menu(title, content=[Button(...)]),将删除、分享等操作收纳到菜单中。AsyncImage 与占位策略placeholder 与 error_view 均为可选 View;常见组合是 ProgressView() + Image(system_name="exclamationmark.triangle")。content_mode:fill 适合缩略图铺满单元格(配合 clip_shape);详情页大图多用 fit 保留完整画面。on_failure 回调可用于打点或递增计数器;若需 UI 级「重试」按钮,可在 State 中增加整型字段(例如 reload_nonce),在按钮 action 中自增该字段,并让 AsyncImage 的 url 字符串拼接该值,以便在下次 body() 重建时强制重新请求(仍只用已有 API)。NavigationLink):适合「进入详情后仍希望保留返回手势与标题栈上下文」的路径。sheet:适合「临时预览、一次性操作」;关闭时务必在 on_dismiss 中复位 is_presented 关联储,避免下次无法再次弹出。HealthKit 授权、血压查询、摘要统计和趋势图。
+演示 health 授权、query_blood_pressure 与 appui 列表、分段 Picker、Chart 展示趋势与记录。
运行后会出现血压分析页,授权、最新读数、趋势图和异常空状态都有对应展示。
+import time
+import appui
+import health
+
+READ_TYPES = ["blood_pressure_systolic", "blood_pressure_diastolic"]
+
+
+def sample_records():
+ now = time.time()
+ rows = []
+ values = [(118, 76), (121, 78), (116, 74), (124, 80), (119, 77), (122, 79), (117, 75)]
+ for index, pair in enumerate(values):
+ ts = now - (len(values) - index) * 86400
+ rows.append({
+ "systolic": pair[0],
+ "diastolic": pair[1],
+ "unit": "mmHg",
+ "start": ts,
+ "end": ts,
+ })
+ return rows
+
+
+initial_records = sample_records()
+
+state = appui.State(
+ days="30",
+ records=initial_records,
+ summary=health.summarize_blood_pressure(initial_records),
+ source="示例数据",
+ loading=False,
+ message="真机授权后可读取 HealthKit 血压数据",
+)
+
+
+def date_label(timestamp):
+ return time.strftime("%m/%d %H:%M", time.localtime(float(timestamp)))
+
+
+def load_health_data():
+ state.loading = True
+ end = time.time()
+ start = end - int(state.days) * 86400
+ records = []
+
+ if health.is_available():
+ health.request_authorization(read=READ_TYPES)
+ records = health.query_blood_pressure(start=start, end=end)
+
+ if records:
+ source = "HealthKit"
+ message = f"读取 {len(records)} 条血压记录"
+ else:
+ records = sample_records()
+ source = "示例数据"
+ message = "未读取到 HealthKit 血压记录,当前显示示例数据"
+
+ state.batch_update(
+ records=records,
+ summary=health.summarize_blood_pressure(records),
+ source=source,
+ message=message,
+ loading=False,
+ )
+
+
+def set_days(value):
+ state.days = value
+ load_health_data()
+
+
+def record_start(item):
+ return float(item.get("start", 0))
+
+
+def chart_rows(field):
+ rows = sorted(state.records, key=record_start)[-14:]
+ return [
+ {
+ "day": time.strftime("%m/%d", time.localtime(float(item.get("start", 0)))),
+ "value": float(item.get(field, 0)),
+ }
+ for item in rows
+ ]
+
+
+def stat_text(field, label):
+ summary = state.summary or {}
+ stats = summary.get(field, {})
+ if not stats:
+ return appui.LabeledContent(label, value="--")
+ return appui.LabeledContent(
+ label,
+ value=f'{stats["avg"]:.0f} / {stats["min"]:.0f}-{stats["max"]:.0f} mmHg',
+ )
+
+
+def record_row(item):
+ return appui.HStack(
+ [
+ appui.VStack(
+ [
+ appui.Text(date_label(item.get("start", 0))).font("headline"),
+ (
+ appui.Text(item.get("unit", "mmHg"))
+ .font("caption")
+ .foreground_color("secondaryLabel")
+ ),
+ ],
+ alignment="leading",
+ ),
+ appui.Spacer(),
+ (
+ appui.Text(
+ f'{item.get("systolic", 0):.0f}/'
+ f'{item.get("diastolic", 0):.0f}'
+ )
+ .font("title3")
+ .bold()
+ ),
+ ],
+ spacing=12,
+ )
+
+
+def record_key(item):
+ return item.get("start", 0)
+
+
+def body():
+ latest = (state.summary or {}).get("latest") or {}
+ return appui.NavigationStack(
+ appui.List(
+ [
+ appui.Section(
+ [
+ appui.LabeledContent("数据源", value=state.source),
+ appui.LabeledContent("状态", value=state.message),
+ appui.Picker(
+ "时间范围",
+ selection=state.days,
+ options=["7", "30", "90"],
+ on_change=set_days,
+ ).picker_style("segmented"),
+ appui.Button("刷新 HealthKit", action=load_health_data)
+ .button_style("bordered_prominent"),
+ ],
+ header="同步",
+ footer="本示例只做个人数据统计和可视化,不提供医疗诊断。",
+ ),
+ appui.Section(
+ [
+ appui.LabeledContent(
+ "最近一次",
+ value=(
+ f'{latest.get("systolic", 0):.0f}/'
+ f'{latest.get("diastolic", 0):.0f} mmHg'
+ )
+ if latest else "--",
+ ),
+ stat_text("systolic", "收缩压 均值/范围"),
+ stat_text("diastolic", "舒张压 均值/范围"),
+ ],
+ header="摘要",
+ ),
+ appui.Section(
+ [
+ appui.Chart(
+ chart_rows("systolic"),
+ x="day",
+ y="value",
+ type="line",
+ color="systemRed",
+ )
+ .frame(height=180),
+ appui.Chart(
+ chart_rows("diastolic"),
+ x="day",
+ y="value",
+ type="line",
+ color="systemBlue",
+ )
+ .frame(height=180),
+ ],
+ header="趋势",
+ ),
+ appui.Section(
+ [appui.ForEach(state.records[:20], record_row, key=record_key)],
+ header="记录",
+ ),
+ ]
+ )
+ .refreshable(load_health_data)
+ .navigation_title("血压")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+blood_pressure_systolic 与 blood_pressure_diastolic,避免依赖组合类型授权表现。query_blood_pressure 返回配对 mmHg;无数据时可回落示例集合并标明数据源。summarize_blood_pressure 仅作统计展示,非诊断依据。经过统一布局和预览校验的完整页面样板。
+这里收录可以直接复制到 AppUI 预览里运行的页面样板。示例不是 API 清单,而是完整页面:有状态、有真实按钮动作、有稳定布局,并且代码尽量贴近实际 App。
+示例骨架运行后会出现一个原生导航页,包含状态文字、主按钮和表单反馈,用于验证 cookbook 的基础结构。
+| 页面 | 优先学习点 | 适合改成 |
|---|---|---|
| 待办列表 | List、ForEach、搜索、稳定 id | 任务清单、收藏列表、消息列表 |
| 设置表单 | Form、Section、输入控件、toolbar | 设置页、资料编辑、偏好配置 |
| 紧凑仪表盘 | ScrollView、LazyVGrid、卡片按钮、固定尺寸 | 数据面板、运营看板、学习进度 |
| 原生列表详情 | NavigationStack、NavigationLink、列表详情 | 项目列表、课程目录、文件浏览 |
| 网络列表 | 加载状态、刷新、错误展示 | API 列表、搜索结果、远程内容 |
| 表格数据审核 | Table、筛选、批量动作 | 数据审核、订单表、日志表 |
| 聊天界面 | 底部输入、安全区、消息滚动 | AI 对话、客服、评论流 |
| 媒体采集相册 | 图片采集、网格、预览状态 | 相册选择、素材库、头像上传 |
| 相册浏览器 | LazyVGrid、AsyncImage、详情导航、.sheet | 素材库、作品集、商品图片 |
| 原生表单设置 | 表单控件、颜色选择、storage 持久化 | 偏好设置、资料编辑、控制面板 |
| 图表与画布实验室 | Chart、Canvas、DrawingContext | 指标图、阈值图、可视化实验 |
| 定位地图 | location、权限、MapView 标记 | 签到、位置记录、门店地图 |
| 运动传感器仪表盘 | motion、haptics、姿态图表 | 传感器工具、运动记录、硬件测试 |
| 通知提醒 | 通知授权、安排/取消提醒、表单保存 | 喝水提醒、待办提醒、定时通知 |
| 血压分析 | HealthKit 血压、摘要、趋势图 | 健康记录、个人指标看板 |
| 安全凭据库 | keychain、biometric、SecureField | Token 管理、私密笔记、账号保险箱 |
| 实时活动与后台任务 | Live Activity、后台时间、完成通知 | 同步进度、倒计时、上传任务 |
| 快捷指令启动器 | run_shortcut、open_url、系统设置入口 | 自动化面板、深链入口、调试工具 |
| 视频播放器 | VideoPlayer、片源搜索、播放设置 | 课程播放、素材预览、媒体工具 |
| WebView 与系统分享 | WebView、本地 HTML、ShareLink | 报告预览、帮助页、导出分享 |
List + Section + ForEach;只有仪表盘、画廊、聊天等特殊布局才使用 ScrollView。新示例优先按这个骨架写:状态在顶部,动作函数独立命名,视图函数只负责组装 View。
+import appui
+
+state = appui.State(title="Demo", saved=False)
+
+
+def mark_saved():
+ state.saved = True
+
+
+def status_row():
+ icon = "checkmark.circle.fill" if state.saved else "circle"
+ label = "Saved" if state.saved else "Not saved"
+ return appui.Label(label, system_image=icon)
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Status", [
+ status_row(),
+ appui.Button("Save", action=mark_saved).button_style("bordered_prominent"),
+ ])
+ ]).navigation_title(state.title)
+ )
+
+
+appui.run(body, state=state)
+Live Activity、有限后台时间、后台刷新和完成通知。
+演示 live_activity 启停与更新、background 有限后台时间与 notification 收尾提醒。
运行后会出现实时活动与后台任务控制页,进度、后台刷新和完成通知状态会同步显示。
+import time
+
+import appui
+import background
+import haptics
+import live_activity
+import notification
+
+state = appui.State(
+ title="同步任务",
+ message="准备开始",
+ progress=0.0,
+ status="未启动",
+ remaining="--",
+ supported=False,
+)
+
+
+def set_title(value):
+ state.title = value
+
+
+def set_message(value):
+ state.message = value
+
+
+def set_progress(value):
+ progress = max(0.0, min(1.0, float(value)))
+ state.batch_update(progress=progress, status=f"进度草稿:{progress:.0%}")
+
+
+def refresh_support():
+ supported = live_activity.is_supported()
+ state.batch_update(
+ supported=supported,
+ status="支持 Live Activity" if supported else "当前设备或系统不支持 Live Activity",
+ remaining=f"{background.remaining_time():.0f} 秒",
+ )
+
+
+def start_activity():
+ if not live_activity.is_supported():
+ state.status = "当前设备不支持 Live Activity"
+ haptics.notification("warning")
+ return
+ live_activity.start(
+ title=state.title,
+ message=state.message,
+ progress=state.progress,
+ icon="arrow.triangle.2.circlepath",
+ compact_text=f"{state.progress:.0%}",
+ )
+ state.status = "Live Activity 已启动"
+ haptics.notification("success")
+
+
+def update_activity():
+ live_activity.update(
+ title=state.title,
+ message=state.message,
+ progress=state.progress,
+ compact_text=f"{state.progress:.0%}",
+ )
+ state.status = "已更新 Live Activity"
+ haptics.selection()
+
+
+def begin_background_window():
+ ok = background.begin_task()
+ state.batch_update(
+ status="已申请有限后台时间" if ok else "无法申请后台时间",
+ remaining=f"{background.remaining_time():.0f} 秒",
+ )
+ haptics.notification("success" if ok else "error")
+
+
+def end_workflow():
+ live_activity.end(message="任务完成", dismiss_delay=2.0)
+ background.end_task()
+ notification.request_permission()
+ notification.schedule(
+ "live-activity-demo-complete",
+ state.title,
+ "任务已完成",
+ delay=1,
+ )
+ state.batch_update(status="已结束并安排通知", progress=1.0)
+ haptics.notification("success")
+
+
+def schedule_refresh():
+ ok = background.schedule_refresh("app.pythonide.demo.refresh", time.time() + 1800)
+ state.status = "已提交后台刷新请求" if ok else "后台刷新请求提交失败"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form(
+ [
+ appui.Section(
+ [
+ appui.LabeledContent(
+ "Live Activity",
+ value="支持" if state.supported else "未知",
+ ),
+ appui.LabeledContent("剩余后台时间", value=state.remaining),
+ appui.Button("刷新状态", action=refresh_support),
+ ],
+ header="能力",
+ ),
+ appui.Section(
+ [
+ appui.TextField("标题", text=state.title, on_change=set_title),
+ appui.TextField("状态文案", text=state.message, on_change=set_message),
+ appui.Slider(
+ value=state.progress,
+ minimum=0,
+ maximum=1,
+ on_change=set_progress,
+ ),
+ appui.ProgressView("进度", value=state.progress),
+ ],
+ header="任务",
+ ),
+ appui.Section(
+ [
+ appui.Button("启动 Live Activity", action=start_activity)
+ .button_style("bordered_prominent"),
+ appui.Button("更新 Live Activity", action=update_activity),
+ appui.Button("申请后台时间", action=begin_background_window),
+ appui.Button("提交后台刷新", action=schedule_refresh),
+ appui.Button("结束任务并通知", action=end_workflow)
+ .foreground_color("systemGreen"),
+ ],
+ header="操作",
+ footer="后台任务由系统调度,不能保证立即执行或永久运行。",
+ ),
+ appui.Section(
+ [appui.LabeledContent("状态", value=state.status)],
+ header="结果",
+ ),
+ ]
+ ).navigation_title("实时任务")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+begin_task 与 end_task 成对使用;schedule_refresh 由系统决定实际执行时机。location 权限、坐标采样和 MapView 标记当前位置。
+演示 location 权限、start_updates/get_location 与 appui.MapView 标记当前位置。
运行后会出现定位地图页,授权、坐标采样、地图标记和状态摘要在同一屏展示。
+import time
+
+import appui
+import location
+import permission
+
+
+DEFAULT_LAT = 31.2304
+DEFAULT_LON = 121.4737
+
+state = appui.State(
+ status="未定位",
+ latitude=DEFAULT_LAT,
+ longitude=DEFAULT_LON,
+ accuracy="--",
+ authorized=False,
+)
+
+
+def refresh_location():
+ timeout = 10.0
+ state.status = "正在请求定位权限..."
+
+ auth = location.authorization_status()
+ if auth in ("denied", "restricted"):
+ state.authorized = False
+ state.status = "定位权限不可用,请到系统设置允许定位"
+ return
+
+ location.request_access()
+
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ auth = location.authorization_status()
+ if auth in ("denied", "restricted"):
+ state.authorized = False
+ state.status = "定位权限不可用,请到系统设置允许定位"
+ return
+ if auth not in ("not_determined", "unknown"):
+ break
+ time.sleep(0.25)
+
+ state.status = "正在定位..."
+ location.start_updates()
+ time.sleep(0.5)
+ try:
+ while time.time() < deadline:
+ loc = location.get_location()
+ if loc and loc.get("latitude") is not None:
+ state.authorized = True
+ state.latitude = float(loc.get("latitude", DEFAULT_LAT))
+ state.longitude = float(loc.get("longitude", DEFAULT_LON))
+ state.accuracy = f"{float(loc.get('accuracy', 0.0)):.0f} m"
+ state.status = "定位成功"
+ return
+ time.sleep(0.35)
+ finally:
+ location.stop_updates()
+
+ state.authorized = False
+ state.status = "暂未获得坐标,请稍后重试"
+
+
+def body():
+ marker = {
+ "latitude": state.latitude,
+ "longitude": state.longitude,
+ "title": "当前位置",
+ }
+ return appui.NavigationStack(
+ appui.List(
+ [
+ appui.Section(
+ [
+ appui.MapView(
+ latitude=state.latitude,
+ longitude=state.longitude,
+ span=0.02,
+ markers=[marker],
+ ).frame(height=280),
+ ],
+ header="地图",
+ ),
+ appui.Section(
+ [
+ appui.LabeledContent("状态", value=state.status),
+ appui.LabeledContent("纬度", value=f"{state.latitude:.5f}"),
+ appui.LabeledContent("经度", value=f"{state.longitude:.5f}"),
+ appui.LabeledContent("精度", value=state.accuracy),
+ ],
+ header="当前位置",
+ ),
+ appui.Section(
+ [
+ appui.Button("刷新定位", action=refresh_location)
+ .button_style("bordered_prominent"),
+ ],
+ footer="真机测试最可靠;模拟器需要先设置模拟位置。",
+ ),
+ ]
+ ).navigation_title("定位地图")
+ )
+
+
+appui.run(body, state=state, presentation="fullscreen_with_close")
+request_access 后需 start_updates() 再轮询 get_location();读完后记得 stop_updates()。get_location() 常为 None,用状态文案提示“正在定位”并可重试。MapView,不必接 location 模块。PhotoPicker、CameraPicker、AsyncImage 和 ShareLink。
+演示 PhotoPicker、CameraPicker、AsyncImage 与 ShareLink 的媒体采集与分享流。
运行后会出现媒体采集相册,选择照片、拍照和分享入口会把结果写入列表。
+| 区域 | 结构 | 作用 |
|---|---|---|
| 封面 | AsyncImage | 展示远程缩略图和加载/失败占位。 |
| 导入 | PhotoPicker + CameraPicker | 由系统处理相册选择和拍照。 |
| 文件列表 | ForEach + NavigationLink | 查看每个媒体路径并进入分享页。 |
import appui
+import haptics
+
+state = appui.State(
+ cover_url="https://images.unsplash.com/photo-1515879218367-8466d910aaa4?w=900",
+ picked=[],
+ captured="",
+ status="请选择照片或拍照",
+)
+
+
+def on_picked(paths):
+ state.picked = list(paths or [])
+ state.status = f"已选择 {len(state.picked)} 个文件"
+ haptics.notification("success")
+
+
+def on_captured(path):
+ state.captured = path or ""
+ state.status = "已拍摄" if path else "未获得照片"
+ haptics.notification("success" if path else "warning")
+
+
+def clear_media():
+ state.batch_update(picked=[], captured="", status="已清空")
+ haptics.selection()
+
+
+def media_rows():
+ rows = [{"title": "拍摄照片", "path": state.captured}] if state.captured else []
+ for index, path in enumerate(state.picked, start=1):
+ rows.append({"title": f"相册文件 {index}", "path": path})
+ return rows
+
+
+def media_detail(row):
+ return appui.Form(
+ [
+ appui.Section(
+ [
+ appui.LabeledContent("名称", value=row["title"]),
+ appui.Text(row["path"]).font("caption").foreground_color("secondaryLabel"),
+ appui.ShareLink(
+ item=row["path"],
+ subject=row["title"],
+ message="来自 Python IDE MiniApp",
+ ),
+ ],
+ header="文件",
+ )
+ ]
+ ).navigation_title(row["title"])
+
+
+def media_row_key(row):
+ return row["path"]
+
+
+def media_row_link(row):
+ return appui.NavigationLink(
+ destination=media_detail(row),
+ label=appui.Label(row["title"], system_image="photo"),
+ )
+
+
+def body():
+ rows = media_rows()
+ return appui.NavigationStack(
+ appui.List(
+ [
+ appui.Section(
+ [
+ appui.AsyncImage(
+ url=state.cover_url,
+ placeholder=appui.ProgressView("加载封面"),
+ error_view=appui.Image(system_name="photo"),
+ content_mode="fill",
+ )
+ .frame(height=180)
+ .corner_radius(12)
+ .clipped(),
+ appui.LabeledContent("状态", value=state.status),
+ ],
+ header="封面",
+ ),
+ appui.Section(
+ [
+ appui.PhotoPicker(
+ selection_limit=3,
+ filter="images",
+ on_picked=on_picked,
+ label=appui.Label("选择照片", system_image="photo.on.rectangle"),
+ ),
+ appui.CameraPicker(
+ source="camera",
+ media_type="photo",
+ on_captured=on_captured,
+ label=appui.Label("拍照", system_image="camera.fill"),
+ ),
+ appui.Button("清空", role="destructive", action=clear_media),
+ ],
+ header="导入",
+ ),
+ appui.Section(
+ [
+ appui.ContentUnavailableView(
+ "暂无媒体",
+ system_image="photo",
+ description="选择照片或拍照后显示",
+ )
+ if not rows
+ else appui.ForEach(
+ rows,
+ row_builder=media_row_link,
+ key=media_row_key,
+ )
+ ],
+ header="媒体文件",
+ ),
+ ]
+ ).navigation_title("媒体采集")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+PhotoPicker,拍照用 CameraPicker,由系统处理权限与选取 UI。ShareLink;远程缩略图用 AsyncImage。motion 姿态采样、气压高度、haptics 反馈和 Chart。
+演示 motion 姿态与气压高度采样、haptics 反馈,以及 Chart 展示姿态角。
运行后会出现运动传感器仪表盘,姿态采样、图表和触觉反馈按钮都有明确状态。
+import math
+import time
+
+import appui
+import haptics
+import motion
+
+state = appui.State(
+ status="点击采样读取传感器",
+ roll=0.0,
+ pitch=0.0,
+ yaw=0.0,
+ gravity="--",
+ rotation="--",
+ altitude="--",
+ samples=[
+ {"axis": "roll", "value": 0},
+ {"axis": "pitch", "value": 0},
+ {"axis": "yaw", "value": 0},
+ ],
+)
+
+
+def degrees(value):
+ return float(value or 0) * 180.0 / math.pi
+
+
+def format_vec(data):
+ if not data:
+ return "--"
+ return f'x {data.get("x", 0):.2f}, y {data.get("y", 0):.2f}, z {data.get("z", 0):.2f}'
+
+
+def sample_motion():
+ if not motion.is_available():
+ state.status = "当前设备不支持运动传感器"
+ haptics.notification("error")
+ return
+
+ motion.start_updates(interval=1 / 30)
+ time.sleep(0.15)
+ attitude = motion.get_attitude() or {}
+ gravity = motion.get_gravity()
+ rotation = motion.get_rotation_rate()
+ motion.stop_updates()
+
+ roll = degrees(attitude.get("roll", 0))
+ pitch = degrees(attitude.get("pitch", 0))
+ yaw = degrees(attitude.get("yaw", 0))
+
+ state.batch_update(
+ status="采样完成",
+ roll=roll,
+ pitch=pitch,
+ yaw=yaw,
+ gravity=format_vec(gravity),
+ rotation=format_vec(rotation),
+ samples=[
+ {"axis": "roll", "value": roll},
+ {"axis": "pitch", "value": pitch},
+ {"axis": "yaw", "value": yaw},
+ ],
+ )
+ haptics.selection()
+
+
+def sample_altitude():
+ motion.start_altimeter()
+ time.sleep(0.2)
+ alt = motion.get_altitude()
+ motion.stop_altimeter()
+ if not alt:
+ state.altitude = "未读取到气压高度"
+ haptics.notification("warning")
+ return
+ state.altitude = f'{alt.get("relative_altitude", 0):.2f} m / {alt.get("pressure", 0):.2f} kPa'
+ haptics.notification("success")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List(
+ [
+ appui.Section(
+ [
+ appui.LabeledContent("状态", value=state.status),
+ appui.Button("采样姿态", action=sample_motion)
+ .button_style("bordered_prominent"),
+ appui.Button("采样气压高度", action=sample_altitude),
+ ],
+ header="采样",
+ ),
+ appui.Section(
+ [
+ appui.LabeledContent("Roll", value=f"{state.roll:.1f}°"),
+ appui.LabeledContent("Pitch", value=f"{state.pitch:.1f}°"),
+ appui.LabeledContent("Yaw", value=f"{state.yaw:.1f}°"),
+ appui.LabeledContent("重力", value=state.gravity),
+ appui.LabeledContent("旋转", value=state.rotation),
+ appui.LabeledContent("气压高度", value=state.altitude),
+ ],
+ header="读数",
+ ),
+ appui.Section(
+ [
+ appui.Chart(
+ state.samples,
+ x="axis",
+ y="value",
+ type="bar",
+ color="systemTeal",
+ )
+ .frame(height=220),
+ ],
+ header="姿态角",
+ ),
+ ]
+ ).navigation_title("运动")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+motion.start_updates() 启动采样,读 get_attitude() 等,再尽快 motion.stop_updates(),避免长时间占用传感器;需要控制采样率时可像示例一样传 interval=1 / 30。start_altimeter / get_altitude / stop_altimeter。State.batch_update 一次写回;反馈用 haptics API。NavigationStack、NavigationLink、搜索、刷新和滑动操作。
+演示 List + searchable、NavigationLink 详情、swipe_actions 与 refreshable 的标准列表流。
运行后会出现原生列表详情页,搜索、分类、导航详情和行级操作都能产生可见反馈。
+| 区域 | 结构 | 作用 |
|---|---|---|
| 顶层 | NavigationStack | 承载列表标题和详情页返回栈。 |
| 筛选区 | Section + Picker | 切换分类并展示当前状态。 |
| 内容区 | List + ForEach + NavigationLink | 稳定渲染动态行并进入详情。 |
| 行操作 | .swipe_actions(...) | 完成、删除等高频动作。 |
import appui
+import haptics
+
+state = appui.State(
+ query="",
+ filter="全部",
+ status="就绪",
+ items=[
+ {"id": "inbox", "title": "收件箱整理", "subtitle": "今天完成 12 条消息归档", "tag": "工作", "done": False},
+ {"id": "clip", "title": "剪贴板素材", "subtitle": "保存常用文案和链接", "tag": "工具", "done": True},
+ {
+ "id": "video",
+ "title": "视频片源检查",
+ "subtitle": "确认 HLS 和 MP4 是否可播放",
+ "tag": "媒体",
+ "done": False,
+ },
+ {"id": "health", "title": "血压周报", "subtitle": "统计最近 7 天平均值", "tag": "健康", "done": True},
+ ],
+)
+
+
+def set_query(value):
+ state.query = value
+
+
+def set_filter(value):
+ state.filter = value
+
+
+def visible_items():
+ query = state.query.strip().lower()
+ rows = []
+ for item in state.items:
+ if state.filter != "全部" and item["tag"] != state.filter:
+ continue
+ text = f"{item['title']} {item['subtitle']} {item['tag']}".lower()
+ if query and query not in text:
+ continue
+ rows.append(item)
+ return rows
+
+
+def toggle_done(item_id):
+ rows = []
+ for item in state.items:
+ copy = dict(item)
+ if copy["id"] == item_id:
+ copy["done"] = not copy["done"]
+ rows.append(copy)
+ state.items = rows
+ haptics.selection()
+
+
+def delete_item(item_id):
+ state.items = [item for item in state.items if item["id"] != item_id]
+ state.status = "已删除"
+ haptics.notification("success")
+
+
+def reload_items():
+ state.status = "已刷新"
+ haptics.selection()
+
+
+def detail_view(item):
+ def toggle_current():
+ toggle_done(item["id"])
+
+ return appui.Form(
+ [
+ appui.Section(
+ [
+ appui.LabeledContent("标题", value=item["title"]),
+ appui.LabeledContent("分类", value=item["tag"]),
+ appui.LabeledContent("状态", value="完成" if item["done"] else "进行中"),
+ ],
+ header="信息",
+ ),
+ appui.Section(
+ [
+ appui.Text(item["subtitle"]).font("body"),
+ appui.Button("切换完成状态", action=toggle_current),
+ ],
+ header="操作",
+ ),
+ ]
+ ).navigation_title(item["title"])
+
+
+def row_view(item):
+ def toggle_current():
+ toggle_done(item["id"])
+
+ def delete_current():
+ delete_item(item["id"])
+
+ status_icon = "checkmark.circle.fill" if item["done"] else "circle"
+ status_color = "systemGreen" if item["done"] else "secondaryLabel"
+ return appui.NavigationLink(
+ destination=detail_view(item),
+ label=appui.HStack(
+ [
+ appui.Image(system_name=status_icon).foreground_color(status_color),
+ appui.VStack(
+ [
+ appui.Text(item["title"]).font("headline"),
+ (
+ appui.Text(item["subtitle"])
+ .font("caption")
+ .foreground_color("secondaryLabel")
+ ),
+ ],
+ alignment="leading",
+ spacing=3,
+ ),
+ appui.Spacer(),
+ appui.Text(item["tag"]).font("caption").foreground_color("systemBlue"),
+ ],
+ spacing=10,
+ ),
+ ).swipe_actions(
+ actions=[
+ appui.Button("完成", action=toggle_current),
+ appui.Button("删除", role="destructive", action=delete_current),
+ ]
+ )
+
+
+def row_key(item):
+ return item["id"]
+
+
+def body():
+ rows = visible_items()
+ content = (
+ appui.ContentUnavailableView(
+ "没有结果",
+ system_image="magnifyingglass",
+ description="换个关键词或分类再试",
+ )
+ if not rows
+ else appui.ForEach(rows, row_view, key=row_key)
+ )
+ return appui.NavigationStack(
+ appui.List(
+ [
+ appui.Section(
+ [
+ appui.Picker(
+ "分类",
+ selection=state.filter,
+ options=["全部", "工作", "工具", "媒体", "健康"],
+ on_change=set_filter,
+ ),
+ appui.LabeledContent("状态", value=state.status),
+ ],
+ header="筛选",
+ ),
+ appui.Section("事项", [content]),
+ ]
+ )
+ .searchable(text=state.query, on_change=set_query)
+ .refreshable(reload_items)
+ .navigation_title("事项")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+state.items 只存纯数据(字典列表),不要存入 Text/Button 等视图对象。List(...).searchable(...),不要用自绘 TextField 条顶替系统搜索栏。NavigationLink;行级操作放 swipe_actions;下拉刷新用 refreshable。ContentUnavailableView,不要让列表区域空白。state.status,用户能看见刚才发生了什么。| 当前写法 | 可替换为 |
|---|---|
静态 state.items | storage.get_json(...) 或网络请求结果 |
haptics.selection() | 普通状态文本、toast 或 alert |
分类 Picker | 搜索栏、分段控件或多个筛选字段 |
加载状态、刷新、错误展示和远端数据列表。
+演示 network.get 拉取 JSON、加载/错误状态与可搜索列表展示。
运行后会出现带加载状态的网络列表,刷新、错误展示和空状态都在同一页面闭环。
+| 区域 | 结构 | 作用 |
|---|---|---|
| 顶层 | NavigationStack | 页面标题和系统搜索栏。 |
| 操作区 | Section + Button | 用户主动触发网络请求。 |
| 结果区 | Section + ForEach | 稳定渲染远程数据。 |
| 失败/空状态 | ContentUnavailableView | 避免请求失败后空白。 |
import appui
+import network
+
+state = appui.State(
+ loading=False,
+ error="",
+ query="",
+ items=[
+ {"id": "sample-1", "title": "示例数据", "subtitle": "点击刷新后替换为接口结果"},
+ ],
+)
+
+
+def set_query(value):
+ state.query = str(value)
+
+
+def load_data():
+ state.loading = True
+ state.error = ""
+ try:
+ if not network.is_connected():
+ state.error = "当前没有网络连接"
+ return
+ resp = network.get("https://jsonplaceholder.typicode.com/posts", timeout=15)
+ if not resp.ok:
+ state.error = f"HTTP {resp.status}"
+ return
+ data = resp.json()
+ rows = []
+ for item in data[:20]:
+ rows.append({
+ "id": str(item.get("id", len(rows))),
+ "title": str(item.get("title", "Untitled")),
+ "subtitle": str(item.get("body", ""))[:120],
+ })
+ state.items = rows
+ except Exception as exc:
+ state.error = str(exc)
+ finally:
+ state.loading = False
+
+
+def filtered_items():
+ q = state.query.strip().lower()
+ if not q:
+ return state.items
+ return [
+ item for item in state.items
+ if q in item["title"].lower() or q in item["subtitle"].lower()
+ ]
+
+
+def item_key(item):
+ return item["id"]
+
+
+def row(item):
+ return appui.VStack(
+ [
+ appui.Text(item["title"]).font("headline"),
+ (
+ appui.Text(item["subtitle"])
+ .font("subheadline")
+ .foreground_color("secondaryLabel")
+ .line_limit(2)
+ ),
+ ],
+ alignment="leading",
+ spacing=4,
+ )
+
+
+def body():
+ result_items = filtered_items()
+ result_content = []
+ if state.loading:
+ result_content.append(appui.ProgressView("正在加载..."))
+ elif state.error:
+ result_content.append(
+ appui.ContentUnavailableView(
+ "请求失败",
+ system_image="wifi.exclamationmark",
+ description=state.error,
+ )
+ )
+ elif not result_items:
+ result_content.append(
+ appui.ContentUnavailableView(
+ "没有结果",
+ system_image="doc.text.magnifyingglass",
+ description="换个关键词或点击刷新数据。",
+ )
+ )
+ else:
+ result_content.append(appui.ForEach(result_items, row_builder=row, key=item_key))
+
+ return appui.NavigationStack(
+ appui.List(
+ [
+ appui.Section(
+ [
+ appui.Button("刷新数据", action=load_data)
+ .button_style("bordered_prominent"),
+ ],
+ header="API",
+ footer="示例接口仅用于演示,请替换为自己的 API。",
+ ),
+ appui.Section(result_content, header="结果"),
+ ]
+ )
+ .searchable(text=state.query, on_change=set_query)
+ .refreshable(load_data)
+ .navigation_title("网络列表")
+ )
+
+
+appui.run(body, state=state, presentation="fullscreen_with_close")
+body() 里直接请求。resp.ok 只表示 HTTP 成功,业务字段仍需自行校验;失败时把 error 文案显示出来避免空白页。ForEach(..., key=...),不要直接把筛选后的下标当身份。| 当前写法 | 可替换为 |
|---|---|
| 演示接口 | 你的业务 API |
network.get(...) | network.request(...) / network.post(...) |
state.items | storage.get_json(...) 作为离线缓存 |
notification 权限、本地提醒、取消操作和 storage 表单偏好。
+演示 notification 请求权限与 schedule,并用 storage 持久化表单偏好。
运行后会出现通知提醒表单,权限、延迟分钟、本地提醒和取消操作都有可见反馈。
+import appui
+import notification
+import storage
+
+STORE_KEY = "reminder_demo_settings"
+
+state = appui.State(
+ title="喝水提醒",
+ body="该休息一下了",
+ minutes=30,
+ sound=True,
+ status="未安排",
+ loaded=False,
+)
+
+
+def load_saved_settings():
+ if state.loaded:
+ return
+ state.loaded = True
+ saved = storage.get_json(STORE_KEY, default={}) or {}
+ state.title = saved.get("title", state.title)
+ state.body = saved.get("body", state.body)
+ state.minutes = int(saved.get("minutes", state.minutes))
+ state.sound = bool(saved.get("sound", state.sound))
+
+
+def save_settings():
+ storage.set_json(STORE_KEY, {
+ "title": state.title,
+ "body": state.body,
+ "minutes": state.minutes,
+ "sound": state.sound,
+ })
+
+
+def set_title(value):
+ state.title = str(value)
+ save_settings()
+
+
+def set_body(value):
+ state.body = str(value)
+ save_settings()
+
+
+def set_minutes(value):
+ state.minutes = int(value)
+ save_settings()
+
+
+def set_sound(value):
+ state.sound = bool(value)
+ save_settings()
+
+
+def schedule_reminder():
+ perm = notification.request_permission()
+ if not isinstance(perm, dict) or not perm.get("granted"):
+ state.status = "通知权限未开启"
+ return
+
+ delay = max(1, int(state.minutes)) * 60
+ result = notification.schedule(
+ "demo-reminder",
+ state.title,
+ state.body,
+ delay=delay,
+ sound=state.sound,
+ )
+ if isinstance(result, dict) and result.get("success"):
+ state.status = f"已安排:{state.minutes} 分钟后提醒"
+ else:
+ state.status = f"安排失败:{result}"
+
+
+def cancel_reminder():
+ notification.remove_pending("demo-reminder")
+ state.status = "已取消待发提醒"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form(
+ [
+ appui.Section(
+ [
+ appui.TextField("标题", text=state.title, on_change=set_title),
+ appui.TextField("内容", text=state.body, on_change=set_body),
+ appui.Stepper(
+ "延迟分钟",
+ value=state.minutes,
+ minimum=1,
+ maximum=240,
+ on_change=set_minutes,
+ ),
+ appui.Toggle("声音", is_on=state.sound, on_change=set_sound),
+ ],
+ header="提醒内容",
+ ),
+ appui.Section(
+ [
+ appui.Button("安排提醒", action=schedule_reminder)
+ .button_style("bordered_prominent"),
+ appui.Button("取消提醒", action=cancel_reminder)
+ .button_style("bordered"),
+ appui.LabeledContent("状态", value=state.status),
+ ],
+ header="操作",
+ ),
+ ]
+ ).navigation_title("通知提醒")
+ ).on_appear(load_saved_settings)
+
+
+appui.run(body, state=state, presentation="fullscreen_with_close")
+request_permission(),再用稳定 identifier 调用 schedule / remove_pending 便于覆盖或取消。request_permission() 返回 dict,无需 json.loads。storage.get_json / set_json 在 on_appear 与 on_change 中同步。Keychain 存储敏感字段,读取前使用系统验证。
+演示 keychain 存储敏感字段,读取前用 biometric.authenticate_with_passcode 校验。
运行后会出现安全凭据库,敏感字段保存到 Keychain,读取前会先走系统验证。
+import appui
+import biometric
+import haptics
+import keychain
+
+state = appui.State(
+ service="pythonide.demo",
+ account="default",
+ secret="",
+ revealed="",
+ status="未保存",
+ auth_type="",
+)
+
+
+def update_service(value):
+ state.service = value
+
+
+def update_account(value):
+ state.account = value
+
+
+def update_secret(value):
+ state.secret = value
+
+
+def refresh_auth_type():
+ state.auth_type = biometric.biometric_type()
+
+
+def save_secret():
+ if not state.service.strip() or not state.account.strip() or not state.secret:
+ state.status = "请填写服务名、账号和 Token"
+ haptics.notification("warning")
+ return
+ keychain.set_password(state.service.strip(), state.account.strip(), state.secret)
+ state.batch_update(status="已保存到 Keychain", secret="", revealed="")
+ haptics.notification("success")
+
+
+def reveal_secret():
+ result = biometric.authenticate_with_passcode("验证身份以读取 Keychain 凭据")
+ if not result.get("success"):
+ state.status = f"验证失败:{result.get('error', 'canceled')}"
+ haptics.notification("error")
+ return
+ value = keychain.get_password(state.service.strip(), state.account.strip())
+ if value is None:
+ state.batch_update(status="未找到对应凭据", revealed="")
+ haptics.notification("warning")
+ return
+ state.batch_update(status="读取成功", revealed=value)
+ haptics.notification("success")
+
+
+def delete_secret():
+ keychain.delete_password(state.service.strip(), state.account.strip())
+ state.batch_update(status="已删除", secret="", revealed="")
+ haptics.notification("success")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form(
+ [
+ appui.Section(
+ [
+ appui.LabeledContent("当前验证方式", value=state.auth_type or "未知"),
+ appui.Button("刷新验证能力", action=refresh_auth_type),
+ ],
+ header="设备验证",
+ footer="读取敏感凭据前使用系统验证;不要自己实现密码弹窗替代系统验证。",
+ ),
+ appui.Section(
+ [
+ appui.TextField("服务名", text=state.service, on_change=update_service),
+ appui.TextField("账号", text=state.account, on_change=update_account),
+ appui.SecureField("Token 或密码", text=state.secret, on_change=update_secret),
+ ],
+ header="凭据",
+ ),
+ appui.Section(
+ [
+ appui.Button("保存到 Keychain", action=save_secret)
+ .button_style("bordered_prominent"),
+ appui.Button("验证并读取", action=reveal_secret),
+ appui.Button("删除", action=delete_secret).foreground_color("systemRed"),
+ ],
+ header="操作",
+ ),
+ appui.Section(
+ [
+ appui.LabeledContent("状态", value=state.status),
+ appui.Text(state.revealed or "验证后显示读取结果")
+ .font("callout")
+ .foreground_color("secondaryLabel"),
+ ],
+ header="结果",
+ ),
+ ]
+ ).navigation_title("凭据库")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+keychain,不要写入 storage。get_password 返回 None 时要能恢复 UI 状态。SecureField;展示前走系统生物识别/密码验证。原生 Form、Section、输入控件和保存按钮。
+适合做偏好设置、账号资料、功能开关页。表单使用 Form + Section,保存按钮放在 toolbar,所有输入控件都绑定命名回调。
运行后会出现原生设置表单,修改字段会显示未保存状态,点击保存后状态反馈更新。
+import appui
+
+state = appui.State(
+ name="Ada",
+ notifications=True,
+ theme="System",
+ volume=45,
+ last_saved="Never",
+)
+
+
+def set_name(value):
+ state.name = value
+
+
+def set_notifications(value):
+ state.notifications = value
+
+
+def set_theme(value):
+ state.theme = value
+
+
+def set_volume(value):
+ state.volume = value
+
+
+def save_settings():
+ state.last_saved = f"Saved for {state.name}"
+
+
+def body():
+ account = appui.Section("Account", [
+ appui.TextField("Display name", text=state.name, on_change=set_name),
+ appui.Toggle("Notifications", is_on=state.notifications, on_change=set_notifications),
+ ])
+ appearance = appui.Section("Appearance", [
+ appui.Picker(
+ "Theme",
+ selection=state.theme,
+ options=["System", "Light", "Dark"],
+ on_change=set_theme,
+ ).picker_style("segmented"),
+ appui.Slider(
+ value=state.volume,
+ minimum=0,
+ maximum=100,
+ step=5,
+ label=f"Volume {state.volume}",
+ on_change=set_volume,
+ ),
+ ], footer="使用语义色,页面会自动适配深色模式。")
+
+ summary = appui.Section("Summary", [
+ appui.LabeledContent("Theme", value=state.theme),
+ appui.LabeledContent("Last saved", value=state.last_saved),
+ ])
+
+ form = appui.Form([account, appearance, summary]).navigation_title("Settings")
+
+ return appui.NavigationStack(
+ form.toolbar([
+ appui.ToolbarItem(
+ placement="navigation_bar_trailing",
+ content=appui.Button(
+ action=save_settings,
+ content=appui.Label("Save", system_image="checkmark"),
+ ).button_style("bordered_prominent"),
+ )
+ ])
+ )
+
+
+appui.run(body, state=state)
+Form + Section。on_change 传命名函数。ToolbarItem 里,并且点击后要有可见状态变化。.picker_style("segmented"),不要手写一排按钮代替。运行快捷指令、打开 URL、系统设置入口和复制链接。
+演示 shortcuts.run_shortcut、open_url、open_settings 与自动化入口列表。
运行后会出现快捷指令启动器,运行快捷指令、打开 URL、复制链接和状态反馈都在列表中完成。
+import appui
+import clipboard
+import haptics
+import shortcuts
+
+state = appui.State(
+ shortcut_name="",
+ url="https://apple.com",
+ status="等待操作",
+ entries=[
+ {"title": "打开 Apple", "url": "https://apple.com", "icon": "safari.fill"},
+ {"title": "打开 Pythonista Scheme", "url": "pythonista://", "icon": "terminal.fill"},
+ {
+ "title": "复制快捷指令库链接",
+ "url": "https://www.icloud.com/shortcuts/",
+ "icon": "doc.on.doc.fill",
+ },
+ ],
+)
+
+
+def set_shortcut_name(value):
+ state.shortcut_name = value
+
+
+def set_url(value):
+ state.url = value
+
+
+def run_named_shortcut():
+ name = state.shortcut_name.strip()
+ if not name:
+ state.status = "请输入快捷指令名称"
+ haptics.notification("warning")
+ return
+ ok = shortcuts.run_shortcut(name)
+ state.status = f"已发起:{name}" if ok else f"无法运行:{name}"
+ haptics.notification("success" if ok else "error")
+
+
+def open_current_url():
+ url = state.url.strip()
+ if not url:
+ state.status = "请输入 URL"
+ haptics.notification("warning")
+ return
+ ok = shortcuts.open_url(url)
+ state.status = f"已打开:{url}" if ok else f"无法打开:{url}"
+ haptics.notification("success" if ok else "error")
+
+
+def open_settings():
+ ok = shortcuts.open_settings()
+ state.status = "已打开系统设置" if ok else "无法打开系统设置"
+
+
+def copy_url(url):
+ clipboard.set(url)
+ state.status = "已复制链接"
+ haptics.selection()
+
+
+def entry_row(item):
+ def open_current():
+ shortcuts.open_url(item["url"])
+
+ def copy_current():
+ copy_url(item["url"])
+
+ return appui.HStack(
+ [
+ appui.Image(system_name=item["icon"]).foreground_color("systemBlue"),
+ appui.VStack(
+ [
+ appui.Text(item["title"]).font("headline"),
+ appui.Text(item["url"]).font("caption").foreground_color("secondaryLabel"),
+ ],
+ alignment="leading",
+ ),
+ appui.Spacer(),
+ appui.Button("打开", action=open_current),
+ appui.Button("复制", action=copy_current),
+ ],
+ spacing=10,
+ )
+
+
+def entry_key(item):
+ return item["url"]
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List(
+ [
+ appui.Section(
+ [
+ appui.TextField(
+ "快捷指令名称",
+ text=state.shortcut_name,
+ on_change=set_shortcut_name,
+ ),
+ appui.Button("运行快捷指令", action=run_named_shortcut)
+ .button_style("bordered_prominent"),
+ ],
+ header="快捷指令",
+ ),
+ appui.Section(
+ [
+ appui.TextField("URL 或 Scheme", text=state.url, on_change=set_url),
+ appui.Button("打开 URL", action=open_current_url),
+ appui.Button("打开当前 App 设置", action=open_settings),
+ ],
+ header="系统入口",
+ ),
+ appui.Section(
+ [appui.ForEach(state.entries, entry_row, key=entry_key)],
+ header="自动化入口",
+ ),
+ appui.Section(
+ [appui.LabeledContent("状态", value=state.status)],
+ header="结果",
+ ),
+ ]
+ ).navigation_title("自动化")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+run_shortcut 只负责拉起快捷指令,不返回快捷指令的执行结果。open_url / open_settings 放在按钮回调里,勿在 body() 构建时调用。strip 再打开。Table、筛选、批量动作和 iPhone fallback。
+演示 Table 多列展示与 Picker 在列表模式之间的切换,共用同一数据源。
运行后会出现可筛选的数据审核表,支持选择行、查看摘要,并在窄屏上退化为列表体验。
+| 区域 | 结构 | 作用 |
|---|---|---|
| 模式切换 | Picker + segmented | 在列表和表格之间切换。 |
| 列表模式 | List + ForEach + NavigationLink | 手机上查看详情。 |
| 表格模式 | Table | iPad 上快速扫描多列数据。 |
import appui
+
+state = appui.State(
+ mode="列表",
+ selected="",
+ rows=[
+ {"name": "TextField", "area": "输入", "status": "OK", "owner": "Form"},
+ {"name": "Slider", "area": "控制", "status": "OK", "owner": "Controls"},
+ {"name": "VideoPlayer", "area": "媒体", "status": "观察", "owner": "Media"},
+ {"name": "Table", "area": "数据", "status": "iPad 优先", "owner": "Data"},
+ ],
+)
+
+columns = [
+ {"title": "组件", "key": "name"},
+ {"title": "区域", "key": "area"},
+ {"title": "状态", "key": "status"},
+]
+
+
+def set_mode(value):
+ state.mode = value
+
+
+def select_row(row):
+ state.selected = f"{row.get('name', '')} / {row.get('status', '')}"
+
+
+def row_detail(row):
+ return appui.Form(
+ [
+ appui.Section(
+ [
+ appui.LabeledContent("组件", value=row["name"]),
+ appui.LabeledContent("区域", value=row["area"]),
+ appui.LabeledContent("状态", value=row["status"]),
+ appui.LabeledContent("负责人", value=row["owner"]),
+ ],
+ header="详情",
+ )
+ ]
+ ).navigation_title(row["name"])
+
+
+def row_key(row):
+ return row["name"]
+
+
+def row_link(row):
+ return appui.NavigationLink(
+ destination=row_detail(row),
+ label=appui.HStack(
+ [
+ appui.VStack(
+ [
+ appui.Text(row["name"]).font("headline"),
+ appui.Text(row["area"]).font("caption").foreground_color("secondaryLabel"),
+ ],
+ alignment="leading",
+ ),
+ appui.Spacer(),
+ appui.Text(row["status"]).font("caption").foreground_color("systemBlue"),
+ ]
+ ),
+ )
+
+
+def list_mode():
+ return appui.List(
+ [
+ appui.Section("组件", [
+ appui.ForEach(
+ state.rows,
+ row_builder=row_link,
+ key=row_key,
+ )
+ ])
+ ]
+ )
+
+
+def table_mode():
+ return appui.VStack(
+ [
+ appui.Table(data=state.rows, columns=columns, on_select=select_row).frame(height=260),
+ appui.Text(state.selected or "点选表格行查看选择结果")
+ .font("caption")
+ .foreground_color("secondaryLabel"),
+ ],
+ spacing=12,
+ ).padding()
+
+
+def body():
+ content = table_mode() if state.mode == "表格" else list_mode()
+ return appui.NavigationStack(
+ appui.VStack(
+ [
+ appui.Picker("显示方式", selection=state.mode, options=["列表", "表格"], on_change=set_mode)
+ .picker_style("segmented")
+ .padding(horizontal=16),
+ content,
+ ],
+ spacing=8,
+ ).navigation_title("数据审阅")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+Table 的 columns 使用 {"title", "key"} 字典列表;data 为字典列表。on_select 接收选中行字典。state.rows。state.selected,否则用户不知道点选是否生效。ContentUnavailableView,表格模式应显示说明文本。原生列表、搜索、稳定 id 和行级按钮。
+适合做任务清单、收藏列表、轻量消息列表。这个样板使用 List + Section + ForEach,支持搜索、新增、完成切换和删除。
运行后会出现可搜索的待办列表,支持新增、完成切换和滑动删除,行身份由 id 保持稳定。
+import appui
+
+state = appui.State(
+ query="",
+ next_id=4,
+ items=[
+ {"id": 1, "title": "Review layout", "done": False},
+ {"id": 2, "title": "Check interactions", "done": True},
+ {"id": 3, "title": "Polish examples", "done": False},
+ ],
+)
+
+
+def visible_items():
+ query = state.query.lower().strip()
+ if not query:
+ return list(state.items)
+ return [item for item in state.items if query in item["title"].lower()]
+
+
+def item_key(item):
+ return item["id"]
+
+
+def add_item():
+ item = {"id": state.next_id, "title": f"Task {state.next_id}", "done": False}
+ state.items = [item] + list(state.items)
+ state.next_id += 1
+
+
+def set_query(value):
+ state.query = value
+
+
+def toggle_by_id(item_id):
+ updated = []
+ for item in state.items:
+ if item["id"] == item_id:
+ updated.append({**item, "done": not item["done"]})
+ else:
+ updated.append(item)
+ state.items = updated
+
+
+def delete_by_id(item_id):
+ state.items = [item for item in state.items if item["id"] != item_id]
+
+
+def row_view(item):
+ def toggle_item():
+ toggle_by_id(item["id"])
+
+ def delete_item():
+ delete_by_id(item["id"])
+
+ title = item["title"]
+ symbol = "checkmark.circle.fill" if item["done"] else "circle"
+ color = "systemGreen" if item["done"] else "secondaryLabel"
+
+ return (
+ appui.Button(
+ action=toggle_item,
+ content=appui.HStack([
+ appui.Image(system_name=symbol).foreground_color(color),
+ appui.Text(title).frame(max_width=appui.infinity, alignment="leading"),
+ ], spacing=10),
+ )
+ .button_style("plain")
+ .swipe_actions(actions=[
+ appui.Button("Delete", action=delete_item, role="destructive")
+ ])
+ )
+
+
+def body():
+ rows = appui.ForEach(visible_items(), row_builder=row_view, key=item_key)
+ task_list = (
+ appui.List([
+ appui.Section(f"{len(visible_items())} tasks", [rows])
+ ])
+ .navigation_title("Todo")
+ .searchable(text=state.query, on_change=set_query)
+ .toolbar([
+ appui.ToolbarItem(
+ placement="navigation_bar_trailing",
+ content=appui.Button(action=add_item, content=appui.Image(system_name="plus")),
+ )
+ ])
+ )
+
+ return appui.NavigationStack(
+ task_list
+ )
+
+
+appui.run(body, state=state)
+state.items,避免原地修改导致刷新语义不清。VideoPlayer、片源搜索、播放状态和播放设置。
+演示 appui.VideoPlayer 与 TabView:播放页、可搜索片源列表与播放设置表单。
运行后会出现视频播放器页面,片源搜索、播放区域和播放设置保持分区清晰。
+import appui
+
+DEFAULT_SOURCES = [
+ {
+ "id": "sintel",
+ "title": "Sintel Trailer",
+ "url": "https://media.w3.org/2010/05/sintel/trailer.mp4",
+ "tag": "默认",
+ },
+ {
+ "id": "bbb",
+ "title": "Big Buck Bunny",
+ "url": (
+ "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/"
+ "Big_Buck_Bunny_720_10s_1MB.mp4"
+ ),
+ "tag": "测试",
+ },
+]
+
+state = appui.State(
+ selected_id="sintel",
+ query="",
+ autoplay=False,
+ loop=False,
+ show_controls=True,
+ status="正在播放 Sintel Trailer",
+)
+
+
+def selected_source():
+ for item in DEFAULT_SOURCES:
+ if item["id"] == state.selected_id:
+ return item
+ return DEFAULT_SOURCES[0]
+
+
+def set_query(value):
+ state.query = str(value)
+
+
+def select_source(source_id):
+ item = next((row for row in DEFAULT_SOURCES if row["id"] == source_id), DEFAULT_SOURCES[0])
+ state.batch_update(selected_id=source_id, status=f"已切换到 {item['title']}")
+
+
+def toggle_autoplay(value):
+ state.batch_update(autoplay=bool(value), status="已更新自动播放设置")
+
+
+def toggle_loop(value):
+ state.batch_update(loop=bool(value), status="已更新循环播放设置")
+
+
+def toggle_controls(value):
+ state.batch_update(show_controls=bool(value), status="已更新控制条设置")
+
+
+def source_row(item):
+ def choose():
+ select_source(item["id"])
+
+ active = item["id"] == state.selected_id
+ icon = "play.circle.fill" if active else "play.circle"
+ return appui.Button(
+ action=choose,
+ content=appui.HStack(
+ [
+ appui.Image(system_name=icon).foreground_color("systemBlue"),
+ appui.VStack(
+ [
+ appui.Text(item["title"]).font("headline"),
+ (
+ appui.Text(item["url"])
+ .font("caption")
+ .foreground_color("secondaryLabel")
+ .line_limit(1)
+ ),
+ ],
+ alignment="leading",
+ spacing=3,
+ ),
+ appui.Spacer(),
+ appui.Text(item["tag"]).font("caption").foreground_color("secondaryLabel"),
+ ],
+ spacing=10,
+ ),
+ )
+
+
+def filtered_sources():
+ q = state.query.strip().lower()
+ if not q:
+ return DEFAULT_SOURCES
+ return [
+ item for item in DEFAULT_SOURCES
+ if q in item["title"].lower() or q in item["url"].lower() or q in item["tag"].lower()
+ ]
+
+
+def source_key(item):
+ return item["id"]
+
+
+def source_list_rows():
+ sources = filtered_sources()
+ if not sources:
+ return [
+ appui.ContentUnavailableView(
+ "没有匹配片源",
+ system_image="magnifyingglass",
+ description="换个关键词,或清空搜索条件。",
+ )
+ ]
+ return [appui.ForEach(sources, row_builder=source_row, key=source_key)]
+
+
+def player_page():
+ item = selected_source()
+ player = appui.VideoPlayer(
+ url=item["url"],
+ autoplay=state.autoplay,
+ loop=state.loop,
+ show_controls=state.show_controls,
+ presentation="inline",
+ allows_fullscreen=True,
+ allows_pip=True,
+ allows_airplay=True,
+ ).frame(height=240)
+ details = appui.Form(
+ [
+ appui.Section(
+ [
+ appui.LabeledContent("标题", value=item["title"]),
+ appui.LabeledContent("标签", value=item["tag"]),
+ appui.LabeledContent("状态", value=state.status),
+ appui.Text(item["url"]).font("caption").foreground_color("secondaryLabel"),
+ ],
+ header="当前片源",
+ ),
+ ]
+ )
+ return appui.NavigationStack(
+ appui.VStack([player, details], spacing=0)
+ .navigation_title("播放器")
+ )
+
+
+def sources_page():
+ rows = source_list_rows()
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section(
+ rows,
+ header="片源",
+ footer="点击片源会立即更新播放页。",
+ )
+ ])
+ .searchable(text=state.query, on_change=set_query)
+ .navigation_title("片源")
+ )
+
+
+def settings_page():
+ return appui.NavigationStack(
+ appui.Form(
+ [
+ appui.Section(
+ [
+ appui.Toggle("自动播放", is_on=state.autoplay, on_change=toggle_autoplay),
+ appui.Toggle("循环播放", is_on=state.loop, on_change=toggle_loop),
+ appui.Toggle("显示控制条", is_on=state.show_controls, on_change=toggle_controls),
+ ],
+ header="播放设置",
+ ),
+ ]
+ ).navigation_title("设置")
+ )
+
+
+def body():
+ return appui.TabView(
+ [
+ appui.Tab("播放", system_image="play.rectangle", tag=0, content=player_page()),
+ appui.Tab("片源", system_image="list.bullet", tag=1, content=sources_page()),
+ appui.Tab("设置", system_image="gearshape", tag=2, content=settings_page()),
+ ],
+ selection=0,
+ )
+
+
+appui.run(body, state=state, presentation="fullscreen_with_close")
+VStack 把 VideoPlayer 固定在页面上方,下方再用 Form/List,避免把播放器塞进普通列表段落。List(...).searchable(...),动态行通过 ForEach(..., key=...) 保持稳定身份;无匹配时展示空状态。Form/List 控件。VideoPlayer 可配 presentation、allows_fullscreen、allows_pip、allows_airplay 等。WebView URL/HTML 预览、TextEditor 编辑和 ShareLink 分享。
+演示 WebView(URL 与本地 HTML)、TextEditor 编辑与 ShareLink 分享文本。
运行后会出现 WebView 预览与分享页,编辑文本、生成预览和系统分享入口保持同步。
+import html
+
+import appui
+import haptics
+
+state = appui.State(
+ title="MiniApp 使用说明",
+ body_text="这里可以放 Markdown 转换后的说明、报告摘要或接口返回的富文本预览。",
+ preview_mode="本地 HTML",
+ status="可预览",
+)
+
+
+def set_title(value):
+ state.batch_update(title=value, status="内容已修改,预览会自动更新")
+
+
+def set_body(value):
+ state.batch_update(body_text=value, status="内容已修改,预览会自动更新")
+
+
+def set_mode(value):
+ state.batch_update(preview_mode=value, status=f"已切换到 {value}")
+
+
+def html_preview():
+ safe_title = html.escape(state.title)
+ safe_body = html.escape(state.body_text).replace("\n", "<br>")
+ return f"""
+ <html>
+ <head>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <style>
+ body {{ font: -apple-system-body; padding: 22px; background: #f7f7f7; color: #111; }}
+ article {{ background: white; border-radius: 16px; padding: 18px; }}
+ h1 {{ font-size: 26px; margin: 0 0 12px; }}
+ p {{ line-height: 1.45; }}
+ </style>
+ </head>
+ <body>
+ <article>
+ <h1>{safe_title}</h1>
+ <p>{safe_body}</p>
+ </article>
+ </body>
+ </html>
+ """
+
+
+def mark_ready():
+ state.status = "已生成预览"
+ haptics.notification("success")
+
+
+def native_file_export():
+ return f"{state.title}\n\n{state.body_text}"
+
+
+def preview_view():
+ if state.preview_mode == "网页 URL":
+ return appui.WebView(url="https://www.apple.com")
+ return appui.WebView(html=html_preview())
+
+
+def body():
+ share_text = native_file_export()
+ return appui.NavigationStack(
+ appui.TabView(
+ [
+ appui.Tab(
+ "编辑",
+ system_image="square.and.pencil",
+ content=appui.Form(
+ [
+ appui.Section(
+ [
+ appui.TextField("标题", text=state.title, on_change=set_title),
+ appui.TextEditor(
+ text=state.body_text,
+ on_change=set_body,
+ ).frame(height=160),
+ appui.Picker(
+ "预览来源",
+ selection=state.preview_mode,
+ options=["本地 HTML", "网页 URL"],
+ on_change=set_mode,
+ ),
+ ],
+ header="内容",
+ ),
+ appui.Section(
+ [
+ appui.LabeledContent("状态", value=state.status),
+ (
+ appui.Button("生成预览", action=mark_ready)
+ .button_style("bordered_prominent")
+ ),
+ appui.ShareLink(
+ item=share_text,
+ subject=state.title,
+ message="来自 MiniApp",
+ ),
+ ],
+ header="操作",
+ ),
+ ]
+ ),
+ ),
+ appui.Tab(
+ "预览",
+ system_image="safari",
+ content=preview_view(),
+ ),
+ ]
+ ).navigation_title("网页预览")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+WebView(url=...) 打开网页;WebView(html=...) 渲染本地 HTML 字符串。State 字段,不要把 WebView 实例放进状态。ShareLink(item=..., subject=..., message=...);设置类界面仍用原生 Form/List。html.escape,避免把正文当作原始 HTML 执行。animate、animation、transition、matched geometry 和 phase animator。
+appui 提供两类动画能力:隐式动画(.animation(type, value) 在依赖变化时插值)与 显式动画(appui.animate(action, type) 包裹一次状态更新)。此外还有 过渡(.transition)、变换(.scale_effect、.rotation_effect、.rotation_3d_effect)、共享几何(.matched_geometry_effect)、内容过渡(.content_transition)与 相位动画(.phase_animator)。
从 AppUI Presentation Engine 起,show_*、*_presented 等呈现状态字段变化会自动触发 spring 动画;sheet / alert / cover 的进出通常不必再手写 appui.animate()。
第四档 PresentationCoordinator 会在注册表有效时跳过 body() 与整树 JSON,直接更新原生 presentation binding;可用 appui.presentation_present / appui.presentation_dismiss_all 显式控制。完整规范见 Presentation Engine Spec。
示例会展示隐式动画、显式动画、转场和共享几何效果如何响应状态变化。
+| 能力 | API |
|---|---|
| 隐式动画 | .animation(type='spring', value=...),type 可为 default、linear、easeIn、easeOut、easeInOut、spring、interpolatingSpring |
| 显式动画 | appui.animate(action, type) |
| 出现 / 消失 | .transition(type):opacity、slide、scale、move、push、identity |
| 2D / 3D 变换 | .scale_effect、.rotation_effect、.rotation_3d_effect |
| 共享元素 | .matched_geometry_effect(ns_id, namespace, is_source) |
| 文本或数字切换 | .content_transition(type) |
| 循环呼吸效果 | .phase_animator(phases) |
.animation(..., value=state.x) 放在 依赖该字段的子树 上。State 同步更新(例如先收起面板再改标题)。把更新逻辑放进命名函数,再用 appui.animate(update_state, "spring") 保证同一帧动画曲线一致。animation 的 type 取值default、linear、easeIn、easeOut、easeInOut、spring、interpolatingSpring。未列出的字符串请避免使用,以免系统忽略。
用 appui.animate 更新计数,并对数字附加 .animation('spring', value=...)。
import appui
+
+state = appui.State(score=0)
+
+
+def decrease_value():
+ state.score -= 1
+
+
+def increase_value():
+ state.score += 1
+
+
+def decrease_score():
+ appui.animate(decrease_value, "easeOut")
+
+
+def increase_score():
+ appui.animate(increase_value, "spring")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack(
+ [
+ appui.Text(f"得分: {state.score}")
+ .font("largeTitle")
+ .bold()
+ .animation("spring", value=state.score),
+ appui.HStack(
+ [
+ appui.Button(
+ "−",
+ action=decrease_score,
+ ).button_style("bordered"),
+ appui.Button(
+ "+",
+ action=increase_score,
+ ).button_style("bordered_prominent"),
+ ],
+ spacing=24,
+ ),
+ ],
+ spacing=24,
+ )
+ .padding()
+ .navigation_title("显式动画")
+ )
+
+
+appui.run(body, state=state)
+transition + scale_effect + rotation_effect + rotation_3d_effect + matched_geometry_effect + content_transition + phase_animator。
import appui
+
+state = appui.State(show_panel=False, label="轻点切换")
+
+
+def toggle_panel():
+ state.batch_update(
+ show_panel=not state.show_panel,
+ label="面板已打开" if not state.show_panel else "面板已关闭",
+ )
+
+
+def body():
+ panel = (
+ appui.RoundedRectangle(corner_radius=18)
+ .fill("systemTeal")
+ .frame(width=120, height=120)
+ .scale_effect(0.92)
+ .rotation_effect(6)
+ .matched_geometry_effect(ns_id="card", namespace="demo", is_source=True)
+ .transition("scale")
+ )
+ placeholder = (
+ appui.Circle()
+ .fill("systemGray4")
+ .frame(width=120, height=120)
+ .matched_geometry_effect(ns_id="card", namespace="demo", is_source=False)
+ .transition("opacity")
+ )
+ return appui.NavigationStack(
+ appui.ScrollView(
+ [
+ appui.VStack(
+ [
+ appui.Text(state.label)
+ .font("title2")
+ .content_transition("interpolate"),
+ appui.Button(
+ action=toggle_panel,
+ content=appui.Text("切换面板").padding(),
+ ).button_style("bordered_prominent"),
+ panel if state.show_panel else placeholder,
+ appui.Text("3D 倾斜")
+ .font("footnote")
+ .foreground_color("secondaryLabel"),
+ appui.Rectangle()
+ .fill("systemOrange")
+ .frame(width=100, height=44)
+ .rotation_3d_effect(18, x=0, y=1, z=0),
+ appui.Text("相位动画")
+ .font("footnote")
+ .foreground_color("secondaryLabel"),
+ appui.Capsule()
+ .fill("systemPink")
+ .frame(width=160, height=36)
+ .phase_animator([0, 0.4, 1.0, 0.4, 0]),
+ ],
+ spacing=20,
+ )
+ .padding()
+ ]
+ )
+ .navigation_title("动画组合")
+ )
+
+
+appui.run(body, state=state)
+value 与隐式动画:.animation(..., value=state.x) 仅在 value 引用发生变化时触发动画;若传入常量则不会按预期刷新。appui.animate 的第一个参数:必须是 无参可调用对象(推荐命名函数里批量改 State),不要写成 appui.animate(state.count + 1)。matched_geometry_effect:成对视图需 相同的 namespace 与 ns_id,且通常配合显式布局;在复杂导航栈里要注意 is_source 切换时机。phase_animator:依赖 iOS 17+ 能力,在旧系统上可能降级或无动画。transition 与条件渲染:过渡只在视图插入/移除时生效;若仅修改子视图文字,应优先 .content_transition。interpolatingSpring 与 spring:二者曲线不同;若出现振荡或回弹过大,优先改回 default 或 easeInOut 验证是否由曲线本身引起。rotation_3d_effect 与透视:极端角度可能导致子视图不可读;建议限制在 ±45° 内做 UI 提示级动效。ZStack 叠放两个 Text,通过 state 切换 id(...),并分别设置 .transition('slide') 与 .transition('move')。.sensory_feedback(style='success', trigger=str(state.score)),观察与 animate 联动的手感。batch_update。animation、transition、phase_animator 等签名。transition 与 opacity 最小片段import appui
+
+flag = True
+
+
+def toggle():
+ global flag
+ flag = not flag
+
+
+root = appui.Group(
+ [
+ appui.Text("A").opacity(1).transition("opacity"),
+ appui.Text("B").transition("push"),
+ ]
+)
+assert root is not None
+若动画「时有时无」,先在浏览器式调试思路下 二分法 排查:去掉 matched_geometry_effect 与 phase_animator 等高级修饰符,仅保留 .animation(..., value=...),确认基础路径正常后再逐项加回。
DrawingContext 与动画的关系本章专注视图修饰符与 animate();若需要在画布上做连续帧动画,常见做法是:用 State 保存相位或采样数组,在 Timer 或后台线程里更新 State,由 Canvas(..., commands=...) 或 DrawingContext 重建命令列表。此类模式属于「数据驱动重绘」,而不是 transition 插值。
ReactiveState / 实时属性通道高频控件(如 Slider)在 ReactiveState 场景下可能走实时属性通道;此时 .animation(..., value=...) 仍绑定在普通 Text 上即可。避免同一字段既高频写入,又驱动整棵页面频繁重建。若动画曲线和预期不一致,先拆分「需要动画的只读展示」与「高频写控件」,再查看 状态 API。
Chart、Canvas、DrawingContext 和 Path 的使用模式。
+Chart 提供系统图表视图,用字典列表作为数据源,指定 x / y 键与 type。Canvas 用绘制命令渲染自定义图形,可用 DrawingContext 链式生成命令,或直接传入 commands 字典列表。Path 则用于矢量路径(move / line / close 等)。
示例会展示柱状/折线图、Canvas 绘制和 Path 图形在页面中的结果。
+| 能力 | API |
|---|---|
| 统计图 | Chart(data, x, y, type='bar', color=None, series=None) |
| 画布绘制 | DrawingContext().fill_rect(...)... 与 Canvas(width, height, context=ctx) |
| 原始命令 | Canvas(width, height, commands=[{'op': 'fill_rect', ...}, ...]) |
| 矢量路径 | Path(commands=[...], fill=..., stroke=..., line_width=...) |
Chart 类型的直观含义bar:离散类别对比,适合少量枚举横轴(如月份、地区)。line:连续或有序横轴上的趋势;与 series 组合可绘制多条线。area:与折线类似,但填充下方区域,强调「累积感」或占比。point:强调散点分布,适合样本量不大时的离群观察。rule:水平或垂直参考线,适合阈值、平均线等「标线」场景(数据需自行构造为常数 y 或 x)。Canvas 两条路径DrawingContext 链式调用,最后交给 Canvas(..., context=ctx);可读性高,适合中等数量图元。commands 列表,适合波形、频谱等高频追加场景。同一组销售数据,切换 Chart 的 type。
import appui
+
+state = appui.State(chart_kind="bar")
+
+SALES = [
+ {"m": "1月", "v": 12},
+ {"m": "2月", "v": 19},
+ {"m": "3月", "v": 15},
+ {"m": "4月", "v": 22},
+]
+
+
+def set_chart_kind(value):
+ state.chart_kind = value
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack(
+ [
+ appui.Picker(
+ label="图表类型",
+ selection=state.chart_kind,
+ options=["bar", "line", "area", "point", "rule"],
+ on_change=set_chart_kind,
+ ).picker_style("segmented"),
+ appui.Chart(
+ data=SALES,
+ x="m",
+ y="v",
+ type=state.chart_kind,
+ color="systemBlue",
+ ).frame(height=220),
+ ],
+ spacing=16,
+ )
+ .padding()
+ .navigation_title("Chart 类型")
+ )
+
+
+appui.run(body, state=state)
+多系列折线(series 键)、完整 DrawingContext 演示、Canvas 原始 commands、Path 三角形。
import appui
+
+state = appui.State()
+
+MULTI = [
+ {"day": "周一", "amount": 3, "team": "A"},
+ {"day": "周二", "amount": 5, "team": "A"},
+ {"day": "周一", "amount": 4, "team": "B"},
+ {"day": "周二", "amount": 2, "team": "B"},
+]
+
+
+def body():
+ ctx = appui.DrawingContext()
+ ctx.fill_rect(0, 0, 300, 120, color="secondarySystemBackground")
+ ctx.stroke_rect(8, 8, 100, 60, color="label", line_width=1)
+ ctx.fill_circle(150, 40, 22, color="systemRed")
+ ctx.stroke_circle(150, 40, 28, color="systemOrange", line_width=2)
+ ctx.fill_ellipse(190, 15, 50, 50, color="systemPurple")
+ ctx.stroke_ellipse(190, 15, 50, 50, color="systemYellow", line_width=2)
+ ctx.line(10, 100, 290, 100, color="separator", line_width=1)
+ ctx.fill_text("DrawingContext", 12, 88, color="label", font_size=14)
+ ctx.fill_path([(200, 75), (260, 110), (170, 110)], color="systemGreen", close=True)
+ ctx.stroke_path([(20, 70), (60, 95), (40, 50)], color="systemBlue", line_width=2, close=True)
+ ctx.arc(80, 95, 18, start_angle=0, end_angle=270, color="systemTeal", line_width=2, fill=False)
+ ctx.rounded_rect(
+ 230, 70, 60, 40,
+ corner_radius=10,
+ color="systemIndigo",
+ line_width=2,
+ fill=False,
+ )
+ ctx.gradient_rect(120, 72, 90, 36, colors=["systemPink", "systemMint"], vertical=True)
+
+ wave_commands = [
+ {"op": "fill_rect", "x": 0, "y": 0, "w": 300, "h": 80, "c": "systemGray6"},
+ ]
+ for i in range(40):
+ wave_commands.append(
+ {
+ "op": "fill_rect",
+ "x": i * 7.5,
+ "y": 40 + (i % 5) * 6,
+ "w": 5,
+ "h": 12 + (i % 7) * 2,
+ "c": "systemCyan",
+ }
+ )
+
+ triangle = appui.Path(
+ commands=[
+ {"move": [40, 20]},
+ {"line": [80, 20]},
+ {"line": [60, 60]},
+ {"close": True},
+ ],
+ fill="systemYellow",
+ stroke="systemOrange",
+ line_width=2,
+ )
+
+ return appui.NavigationStack(
+ appui.ScrollView(
+ [
+ appui.VStack(
+ [
+ appui.Text("多系列折线 (series)").font("headline"),
+ appui.Chart(
+ data=MULTI,
+ x="day",
+ y="amount",
+ type="line",
+ series="team",
+ color="systemBlue",
+ ).frame(height=200),
+ appui.Text("DrawingContext → Canvas").font("headline"),
+ appui.Canvas(width=300, height=120, context=ctx).corner_radius(12),
+ appui.Text("commands 列表 → Canvas").font("headline"),
+ appui.Canvas(width=300, height=80, commands=wave_commands).corner_radius(8),
+ appui.Text("Path 矢量").font("headline"),
+ triangle.frame(width=120, height=80),
+ ],
+ spacing=20,
+ )
+ .padding()
+ ]
+ )
+ .navigation_title("图表与画布")
+ )
+
+
+appui.run(body, state=state)
+Chart 使用参数名 type=:不是 mark;x / y 为数据字典中的键名字符串。Canvas 命令字典的字段:手写 commands 时使用 'op', 'x', 'y', 'w', 'h', 'c' 等键。Path 命令格式:appui 的 Path 使用 {'move': [x, y]}、{'line': [x, y]}、{'close': True} 等形式,与 DrawingContext 的 op 字典不同。Canvas + commands 列表局部重建;Chart 更适合中等规模静态或慢变数据。Chart 依赖 iOS 16+,Canvas 依赖 iOS 15+;在过低版本设备上可能空白或降级。series 与颜色:多系列时 color 仍可作为默认着色提示,具体调色策略由系统图表渲染决定;若发现图例颜色异常,应先检查数据中 series 字段是否稳定存在。Chart 的 type 改为 'rule',用水平参考线标注「目标值」常量(在数据中复制同一 y)。DrawingContext 绘制一条正弦折线(多点 line 或 stroke_path)。Path 的 commands 改为带 curve / arc 的复杂轮廓(若当前环境支持对应序列化键)。Canvas 与 Chartimport appui
+
+c = appui.Canvas(
+ width=120,
+ height=40,
+ commands=[{"op": "fill_rect", "x": 0, "y": 0, "w": 120, "h": 40, "c": "systemBlue"}],
+)
+g = appui.Chart(
+ data=[{"x": 1, "y": 2}],
+ x="x",
+ y="y",
+ type="point",
+ color="systemRed",
+)
+print(c, g)
+Chart)Chart 期望 data 为 list[dict],且每个字典至少包含你在 x=、y= 上指定的键;若使用 series=,则每个字典还应包含该键以便分组。键名建议使用 ASCII 标识符(如 month、sales),可减少跨端序列化问题。
DrawingContext 生成的 op 一览下列为 DrawingContext 常用命令,便于手写 commands 数组时对照:
| 链式方法 | op 字段 | 说明 |
|---|---|---|
fill_rect | fill_rect | 填充轴对齐矩形 |
stroke_rect | stroke_rect | 描边矩形 |
fill_circle | fill_circle | 填充圆 |
stroke_circle | stroke_circle | 描边圆 |
fill_ellipse | fill_ellipse | 填充椭圆外接矩形 |
stroke_ellipse | stroke_ellipse | 描边椭圆 |
line | line | 线段 |
fill_text | fill_text | 文本 |
fill_path | fill_path | 多边形填充 |
stroke_path | stroke_path | 折线描边 |
arc | arc | 弧或扇形 |
rounded_rect | rounded_rect | 圆角矩形 |
gradient_rect | gradient_rect | 线性渐变矩形 |
语义颜色、材质背景、强制色彩方案和符号配色。
+在 appui 中,外观由 系统语义色字符串、环境色方案 与 材质背景 共同决定。优先使用语义色和材质,让页面自动适配浅色、深色和高对比度环境。
示例会展示语义色、材质背景、强制色彩方案和符号配色在浅色/深色下的表现。
+在 foreground_color、background(color=...)、fill、stroke 等接受颜色的位置,可直接传入下列字符串(不区分大小写;部分名称支持 snake_case 别名,如 secondary_label)。
背景与分组
+systemBackground(别名:system_background、background)secondarySystemBackground(secondary_system_background、secondary_background)tertiarySystemBackground(tertiary_system_background、tertiary_background)systemGroupedBackground、secondarySystemGroupedBackground、tertiarySystemGroupedBackground标签与分隔
+label、secondaryLabel、tertiaryLabel、quaternaryLabelseparator、opaqueSeparatorplaceholderText(placeholder_text、placeholder)填充层级
+systemFill(system_fill、fill)secondarySystemFill、tertiarySystemFill、quaternarySystemFill系统调色板
+systemBlue、systemRed、systemGreen、systemOrange、systemPurplesystemPink、systemYellow、systemTeal、systemCyan、systemIndigosystemGray、systemBrown、systemMintimport appui
+
+state = appui.State()
+
+def body():
+ return (appui.VStack([
+ appui.Text("主标签色").foreground_color("label"),
+ appui.Text("次要说明").foreground_color("secondaryLabel"),
+ appui.Divider(),
+ appui.Text("强调链接").foreground_color("systemBlue"),
+ ], spacing=12)
+ .padding()
+ .frame(max_width=400)
+ .background(color="systemGroupedBackground", corner_radius=12))
+
+appui.run(body, state=state, presentation="sheet")
+对子树应用 .preferred_color_scheme(scheme),scheme 为 'light' 或 'dark'。
import appui
+
+state = appui.State(force="dark")
+
+
+def set_light():
+ state.force = "light"
+
+
+def body():
+ return (appui.VStack([
+ appui.Text("强制深色环境下的卡片").foreground_color("label"),
+ appui.Button("切换为浅色预览", action=set_light),
+ ], spacing=16)
+ .padding()
+ .background(color="secondarySystemBackground", corner_radius=16)
+ .preferred_color_scheme(state.force))
+
+appui.run(body, state=state, presentation="sheet")
+使用 模糊材质 时,不要同时传 color。material 常用取值如下:
material 参数值 | 说明 |
|---|---|
ultra_thin 或 ultra_thin_material | ultraThinMaterial |
thin 或 thin_material | thinMaterial |
regularMaterial、regular 或 regular_material | regularMaterial |
thick 或 thick_material | thickMaterial |
ultra_thick 或 ultra_thick_material | ultraThickMaterial |
import appui
+
+state = appui.State()
+
+def body():
+ return (appui.VStack([
+ appui.Text("玻璃质感背景").foreground_color("label"),
+ appui.Text("副标题").font("caption").foreground_color("secondaryLabel"),
+ ], spacing=8)
+ .padding(20)
+ .background(material="regularMaterial", corner_radius=14))
+
+appui.run(body, state=state, presentation="sheet")
+background 还可配合 gradient、opacity、corner_radius 等参数;详见 View.background 的 appui.py 文档字符串。
secondarySystemGroupedBackground、label 等,减少硬编码 #RRGGBB。preferred_color_scheme('dark') 下仍用 label / secondaryLabel 分层,而不是纯灰。background(material='regular', corner_radius=12) 在圆角矩形内裁剪材质,视觉更统一。preferred_color_scheme 固定一种方案,截图对比浅色与深色下的 systemFill 层级是否清晰。import appui
+
+# 仅构建视图树,验证修饰符链可序列化。
+def body():
+ return appui.Text("Hi").padding().background(material="thin", corner_radius=8)
+
+tree = body()
+assert tree is not None
+导航与媒体类符号在深浅背景下都清晰,例如 chevron.left、play.fill、gearshape.fill。配合 Image(system_name=...) 与 symbol_rendering_mode('hierarchical') 可得到分层着色效果。
import appui
+
+state = appui.State()
+
+def body():
+ return appui.HStack([
+ appui.Image(system_name="sun.max.fill").foreground_color("systemYellow"),
+ appui.Image(system_name="moon.fill").foreground_color("systemIndigo"),
+ ], spacing=24).padding()
+
+appui.run(body, state=state, presentation="sheet")
+更多媒体与符号用法见 appui-guide-media.md。
hot_reload 的启用方式、状态保留和调试边界。
+appui.run(body_func, state=None, hot_reload=False, presentation='sheet') 在 hot_reload=True 时监视调用脚本文件,保存后自动重新执行脚本并刷新界面,便于迭代 UI。body_func 可以写成 body(),也可以写成 body(state)。
示例会展示热重载保留 State、刷新页面结构,并在错误时保持预览可恢复。
+import appui
+
+state = appui.State(count=0)
+
+
+def increment():
+ state.count += 1
+
+
+def body():
+ return appui.VStack([
+ appui.Text(f"count = {state.count}").font("title2"),
+ appui.Button("+1", action=increment)
+ .button_style("bordered_prominent"),
+ ], spacing=16).padding()
+
+appui.run(body, state=state, hot_reload=True, presentation="sheet")
+要点:
+run() 调用栈所在文件(inspect.stack),因此热重载入口应放在真实脚本中,而不是交互式 stdin。State 自动合并热重载重新执行脚本后,若旧页面和新页面都使用 State,会按字段名保留仍然存在的状态值,从而让计数器、开关等继续保持当前值。
ReactiveState 不会自动合并到新实例;热重载后若脚本重新构造 ReactiveState(),字段会回到脚本初值,除非你在脚本里自行从文件恢复。
"""说明:热重载后 State 合并逻辑只针对 appui.State。"""
+import appui
+
+s = appui.State(a=1)
+snapshot = s.to_dict()
+assert snapshot["a"] == 1
+(在应用内以 hot_reload=True 运行时可观察计数在保存后仍连续。)
body 与小组件函数放在同一 .py 文件,run(..., hot_reload=True) 置于 if __name__ == "__main__": 中(若在应用内直接调用亦可)。body。若新代码 语法错误 或 导入失败,异常会显示在预览错误面板;进程不退出,修正后再保存即可。
+import appui
+
+state = appui.State(ok=True)
+
+def body():
+ return appui.VStack([
+ appui.Text("保存本文件即可触发热重载").font("footnote"),
+ appui.Button("关闭", action=appui.dismiss).button_style("bordered"),
+ ], spacing=12).padding()
+
+appui.run(body, state=state, hot_reload=True, presentation="sheet")
+<stdin> 作为脚本路径:无法可靠监视;请使用磁盘上的 .py 文件运行。presentation 组合:fullscreen / fullscreen_with_close 同样支持热重载;关闭仍用 appui.dismiss()。import appui, inspect
+
+def f():
+ return appui.Text("x")
+
+# 本段仅验证 run 的签名包含 hot_reload(不实际连接桥)
+sig = inspect.signature(appui.run)
+assert "hot_reload" in sig.parameters
+auto_refresh 的区别| 特性 | hot_reload=True | auto_refresh(interval) |
|---|---|---|
| 触发条件 | 源文件变更 | 定时器周期 |
| 目的 | 开发时替换代码 | 时钟、轮询仪表盘 |
| 状态 | 合并 State | 不重新加载模块 |
import appui
+import time
+
+state = appui.State()
+
+def body():
+ return appui.Text(time.strftime("%H:%M:%S")).font("title").padding()
+
+appui.auto_refresh(interval=1.0)
+appui.run(body, state=state, hot_reload=False, presentation="sheet")
+hot_reload=True:监视文件 → exec → 保留 State → 强制全量刷新。ReactiveState / 文件外缓存 需自行设计持久化策略。更多运行器参数见 appui-ref-functions.md。
按钮、输入、on_change、sheet 和行级回调的可靠写法。
+触摸、长按、拖拽与捏合等交互通过视图修饰符绑定到 Python 回调。键盘与焦点用 .focused 与 .keyboard_dismiss 控制;上下文菜单用 .context_menu;需要与系统手势共存时用 .simultaneous_gesture 或 .high_priority_gesture。
示例会展示点击、长按、焦点、键盘、拖拽和行级动作的反馈路径。
+| 能力 | API |
|---|---|
| 点击 / 长按 | .on_tap(action)、.on_long_press(...) |
| 拖移 / 捏合 / 旋转 | .on_drag、.on_magnification、.on_rotation |
| 组合手势 | .simultaneous_gesture(...)、.high_priority_gesture(...) |
| 菜单 | .context_menu(content=[...]) |
| 焦点与键盘 | .focused(...)、.keyboard_dismiss(mode) |
| 禁用 | .disabled(value) |
| 气泡菜单 | .popover(is_presented, content, on_dismiss) |
| 搜索和刷新 | .searchable(...)、.refreshable(...) |
| 列表动作 | .swipe_actions(...) |
Button)消费点击。simultaneous_gesture:与视图自身手势并行,适合在可滚动区域附加轻量检测。high_priority_gesture:优先于子视图;仅在确有需要时使用,否则容易导致按钮点不动。context_menu 的 content 是 Button 视图列表,不要用任意字符串代替 Button。
点击计数、长按提示、context_menu 弹出操作。示例使用命名回调,预览里每个动作都有状态反馈。
import appui
+
+state = appui.State(count=0, hint="")
+
+
+def add_count():
+ state.count += 1
+
+
+def reset_count():
+ state.batch_update(count=0, hint="已归零")
+
+
+def double_count():
+ state.batch_update(count=state.count * 2, hint="已加倍")
+
+
+def show_long_press_hint():
+ state.hint = "长按已触发"
+
+
+def body():
+ block = (
+ appui.RoundedRectangle(corner_radius=16)
+ .fill("systemBlue")
+ .frame(width=140, height=90)
+ .on_tap(add_count)
+ .on_long_press(action=show_long_press_hint, min_duration=0.4)
+ .context_menu(content=[
+ appui.Button("归零", action=reset_count),
+ appui.Button("加倍", action=double_count),
+ ])
+ )
+
+ return appui.NavigationStack(
+ appui.ScrollView(
+ appui.VStack([
+ appui.Text(f"计数: {state.count}").font("title"),
+ appui.Text(state.hint or "点击或长按蓝色块")
+ .font("footnote")
+ .foreground_color("secondaryLabel"),
+ block,
+ ], spacing=20).padding()
+ )
+ .keyboard_dismiss("interactive")
+ .navigation_title("交互示例")
+ )
+
+
+appui.run(body, state=state)
+focused(key=..., equals=...) 适合多个输入字段之间切换焦点。keyboard_dismiss("interactive") 通常加在 ScrollView 或列表外层。
import appui
+
+state = appui.State(
+ active_field="name",
+ name="Ada",
+ city="上海",
+ show_pop=False,
+ lock_inputs=False,
+ gesture_log="",
+)
+
+
+def set_name(value):
+ state.name = value
+
+
+def set_city(value):
+ state.city = value
+
+
+def focus_name():
+ state.active_field = "name"
+
+
+def focus_city():
+ state.active_field = "city"
+
+
+def set_lock_inputs(value):
+ state.lock_inputs = bool(value)
+
+
+def open_popover():
+ state.show_pop = True
+
+
+def close_popover():
+ state.show_pop = False
+
+
+def log_long_press():
+ state.gesture_log = "叠加长按"
+
+
+def popover_content():
+ return appui.VStack([
+ appui.Text("Popover / Sheet 内容").font("headline"),
+ appui.Button("关闭", action=close_popover),
+ ], spacing=12).padding()
+
+
+def body():
+ popover_trigger = (
+ appui.Text("点我打开 Popover")
+ .padding()
+ .background("systemGray5", corner_radius=8)
+ .popover(is_presented=state.show_pop, on_dismiss=close_popover, content=popover_content)
+ .on_tap(open_popover)
+ .simultaneous_gesture(
+ gesture="long_press",
+ callback=log_long_press,
+ min_duration=0.35,
+ )
+ .disabled(state.lock_inputs)
+ )
+
+ return appui.NavigationStack(
+ appui.ScrollView(
+ appui.VStack([
+ appui.TextField("姓名", text=state.name, on_change=set_name)
+ .text_field_style("rounded_border")
+ .focused(key="name", equals=state.active_field)
+ .on_submit(focus_city),
+ appui.TextField("城市", text=state.city, on_change=set_city)
+ .text_field_style("rounded_border")
+ .focused(key="city", equals=state.active_field),
+ appui.HStack([
+ appui.Button("编辑姓名", action=focus_name).button_style("bordered"),
+ appui.Button("编辑城市", action=focus_city).button_style("bordered"),
+ ], spacing=12),
+ appui.Toggle("锁定输入", is_on=state.lock_inputs, on_change=set_lock_inputs),
+ appui.Text(state.gesture_log or "在灰色区域长按可触发 simultaneous_gesture")
+ .font("caption")
+ .foreground_color("secondaryLabel"),
+ popover_trigger,
+ ], spacing=16).padding()
+ )
+ .keyboard_dismiss("interactive")
+ .navigation_title("焦点与手势")
+ )
+
+
+appui.run(body, state=state)
+on_drag、on_magnification、on_rotation 的回调由运行时传入测量值或字典。实际项目里先把返回值显示出来,再按需要解析字段。
def drag_changed(value):
+ state.gesture_log = str(value)
+
+
+def drag_ended(value):
+ state.gesture_log = f"ended: {value}"
+
+
+view.on_drag(on_changed=drag_changed, on_ended=drag_ended)
+如果子控件本身需要点击,谨慎使用 high_priority_gesture,它会优先于子视图手势。
列表行的交互要绑定稳定 id,不要依赖过滤后的下标。下拉刷新使用 .refreshable(action=...)。
import appui
+
+state = appui.State(
+ query="",
+ selected="None",
+ refresh_count=0,
+ rows=[
+ {"id": "api", "title": "API reference", "done": False},
+ {"id": "ui", "title": "Preview UI", "done": True},
+ {"id": "docs", "title": "Docs guide", "done": False},
+ ],
+)
+
+
+def row_key(row):
+ return row["id"]
+
+
+def set_query(value):
+ state.query = value
+
+
+def filtered_rows():
+ keyword = state.query.strip().lower()
+ if not keyword:
+ return state.rows
+ return [row for row in state.rows if keyword in row["title"].lower()]
+
+
+def refresh_rows():
+ state.refresh_count += 1
+
+
+def select_row(row_id):
+ for row in state.rows:
+ if row["id"] == row_id:
+ state.selected = row["title"]
+ break
+
+
+def toggle_row(row_id):
+ state.rows = [
+ {**row, "done": not row["done"]} if row["id"] == row_id else row
+ for row in state.rows
+ ]
+
+
+def row_view(row):
+ def select_current():
+ select_row(row["id"])
+
+ def toggle_current():
+ toggle_row(row["id"])
+
+ icon = "checkmark.circle.fill" if row["done"] else "circle"
+ return (
+ appui.Button(
+ action=select_current,
+ content=appui.HStack([
+ appui.Label(row["title"], system_image=icon),
+ appui.Spacer(),
+ appui.Text("Open").foreground_color("secondaryLabel"),
+ ]),
+ )
+ .button_style("plain")
+ .swipe_actions(actions=[
+ appui.Button("Toggle", action=toggle_current),
+ ])
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Rows", [
+ appui.ForEach(filtered_rows(), row_builder=row_view, key=row_key)
+ ]),
+ appui.Section("State", [
+ appui.LabeledContent("Selected", value=state.selected),
+ appui.LabeledContent("Refresh count", value=str(state.refresh_count)),
+ ]),
+ ])
+ .searchable(text=state.query, on_change=set_query)
+ .refreshable(action=refresh_rows)
+ .navigation_title("Callbacks")
+ )
+
+
+appui.run(body, state=state)
+on_long_press 与 context_menu 都使用长按语义;同时启用时要实测是否冲突。.focused(key=..., equals=...) 的 equals 应绑定到 State 中的当前字段名。simultaneous_gesture 的 gesture 常用 'tap'、'long_press'、'magnification'。high_priority_gesture 过度使用会导致子按钮点击失效。keyboard_dismiss 通常加在 ScrollView 或列表外层。用原生容器搭列表、表单、网格和可滚动页面。
+appui 的布局优先用组合:Stack 负责方向,ScrollView 负责滚动,Grid 负责网格,frame/padding/background 负责局部尺寸和外观。列表和设置页优先使用系统容器,复杂视觉再用自定义布局。
示例会展示 Stack、ScrollView、Grid、Form 和 GeometryReader 在不同页面结构中的可见布局结果。
+| 容器 | 适合场景 | 常用参数 |
|---|---|---|
VStack | 垂直排列 | spacing, alignment |
HStack | 水平排列 | spacing, alignment |
ZStack | 层叠覆盖 | alignment |
ScrollView | 长内容和自定义滚动页 | axes, shows_indicators |
LazyVStack / LazyHStack | 长列表自定义布局 | spacing, alignment |
LazyVGrid / LazyHGrid | 卡片网格 | columns / rows, spacing |
Grid / GridRow | 表格式二维布局 | 行列组合 |
List | 原生动态列表 | Section, ForEach |
Form | 设置页和编辑页 | Section |
GeometryReader | 需要父容器尺寸 | on_change 或 geometry 回调 |
Stack 解决 80% 的局部布局。用 VStack 组织纵向内容,用 HStack + Spacer 组织行。
import appui
+
+state = appui.State(selected="Revenue")
+
+
+def select_revenue():
+ state.selected = "Revenue"
+
+
+def select_orders():
+ state.selected = "Orders"
+
+
+def card(title, value, action):
+ content = appui.VStack([
+ appui.Text(title).font("caption").foreground_color("secondaryLabel"),
+ appui.Text(value).font("title2").bold(),
+ ], alignment="leading", spacing=6)
+
+ return (
+ appui.Button(action=action, content=content)
+ .button_style("plain")
+ .padding(14)
+ .frame(max_width=appui.infinity, alignment="leading")
+ .background("secondarySystemBackground", corner_radius=12)
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Text("Dashboard").font("largeTitle").bold(),
+ appui.HStack([
+ card("Revenue", "$12.4k", select_revenue),
+ card("Orders", "284", select_orders),
+ ], spacing=12),
+ appui.ZStack([
+ appui.Rectangle().fill("systemBlue").frame(height=120).corner_radius(16),
+ appui.Text(f"Selected: {state.selected}").foreground_color("white").bold(),
+ ]),
+ ], alignment="leading", spacing=16)
+ .padding()
+ .navigation_title("Stack")
+ )
+
+
+appui.run(body, state=state)
+只有 Stack 难以表达时,再使用 Grid 或 GeometryReader。
+长列表优先用 List;需要完全自定义外观时再用 ScrollView + LazyVStack。
import appui
+
+state = appui.State(selected="None")
+
+
+def select_row(index):
+ state.selected = f"Item {index}"
+
+
+def row(index):
+ def select_current():
+ select_row(index)
+
+ return appui.Button(
+ action=select_current,
+ content=appui.HStack([
+ appui.Text(str(index))
+ .frame(width=36, height=36)
+ .background("systemBlue", corner_radius=18)
+ .foreground_color("white"),
+ appui.VStack([
+ appui.Text(f"Item {index}").bold(),
+ appui.Text("Subtitle").font("caption").foreground_color("secondaryLabel"),
+ ], alignment="leading"),
+ appui.Spacer(),
+ ], spacing=12).padding(vertical=6),
+ ).button_style("plain")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.ScrollView(
+ appui.LazyVStack([row(i) for i in range(1, 31)], spacing=8).padding(),
+ axes="vertical",
+ )
+ .safe_area_inset(
+ edge="bottom",
+ content=appui.Text(f"Selected: {state.selected}")
+ .padding()
+ .frame(max_width=appui.infinity, alignment="leading")
+ .background(material="regularMaterial"),
+ )
+ .navigation_title("ScrollView")
+ )
+
+
+appui.run(body, state=state)
+底部操作条优先用 safe_area_inset,不要靠固定 offset 硬顶。
两三列卡片用 LazyVGrid 最直接;需要严格行列对齐时用 Grid/GridRow。
import appui
+
+state = appui.State(selected="None")
+
+
+def select_tile(index):
+ state.selected = f"Card {index}"
+
+
+def tile(index):
+ def select_current():
+ select_tile(index)
+
+ content = appui.VStack([
+ appui.Image(system_name="square.grid.2x2").font("title2"),
+ appui.Text(f"Card {index}").font("caption"),
+ ], spacing=8)
+
+ return (
+ appui.Button(action=select_current, content=content)
+ .button_style("plain")
+ .frame(max_width=appui.infinity, min_height=92)
+ .background("secondarySystemBackground", corner_radius=12)
+ )
+
+
+def body():
+ columns = [appui.flexible(), appui.flexible()]
+ return appui.NavigationStack(
+ appui.ScrollView(
+ appui.VStack([
+ appui.LabeledContent("Selected", value=state.selected),
+ appui.LazyVGrid(
+ columns=columns,
+ spacing=12,
+ content=[tile(i) for i in range(1, 9)],
+ ),
+ ], spacing=16).padding()
+ ).navigation_title("Grid")
+ )
+
+
+appui.run(body, state=state)
+普通数据列表使用 List + Section + ForEach,设置和编辑页使用 Form + Section。这两个容器负责原生滚动、分组、键盘避让和辅助功能。
import appui
+
+state = appui.State(query="", notifications=True)
+
+
+def set_query(value):
+ state.query = value
+
+
+def set_notifications(value):
+ state.notifications = value
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Search", [
+ appui.TextField("Query", text=state.query, on_change=set_query),
+ ]),
+ appui.Section("Preferences", [
+ appui.Toggle(
+ "Notifications",
+ is_on=state.notifications,
+ on_change=set_notifications,
+ ),
+ ], footer=f"Query: {state.query or '-'}"),
+ ]).navigation_title("Form")
+ )
+
+
+appui.run(body, state=state)
+不要把每个设置项包成自绘卡片,也不要把 List 放进 ScrollView 或 Section 里。
常见顺序:
+(
+ view
+ .padding()
+ .frame(max_width=appui.infinity, alignment="leading")
+ .background("secondarySystemBackground")
+ .corner_radius(12)
+)
+| 需求 | 写法 |
|---|---|
| 占满宽度 | .frame(max_width=appui.infinity) |
| 固定高度 | .frame(height=120) |
| 左对齐 | .frame(max_width=appui.infinity, alignment="leading") |
| 卡片内边距 | .padding() 放在 .background() 前 |
| 外边距 | .padding() 放在 .background() 后 |
只有在需要父容器尺寸时使用 GeometryReader 或 .on_geometry(...)。不要把整页都包进 GeometryReader,它会改变布局提案,容易让子视图占满空间。
def remember_size(info):
+ print(info["width"], info["height"])
+
+
+appui.GeometryReader(
+ content=appui.Text("Measure me"),
+ on_change=remember_size,
+)
+| 问题 | 检查 |
|---|---|
| 背景只包住文字 | .padding() 是否放在 .background() 前。 |
| 宽度没有撑开 | 是否设置了 .frame(max_width=appui.infinity)。 |
| 长内容滑不动 | 是否外层使用 ScrollView 或 List。 |
| 底部按钮挡住内容 | 使用 safe_area_inset(edge="bottom")。 |
| 网格宽度不均 | 检查 appui.flexible() 列配置和 spacing。 |
| 列表不像原生 | 是否用 ScrollView + VStack 模拟了动态列表。 |
List、ForEach、Form、Section、搜索、刷新和滑动操作。
+List 与 Form 是展示结构化数据的首选:List 适合可选中、滑动操作的行;Form 适合设置页分组。ForEach 把 Python 序列映射为多行视图;Section 提供分组标题与脚注。修饰符 .list_style、.swipe_actions、.refreshable、.searchable 直接链在列表(或承载搜索的外层容器)上;若要显式包裹整行,也可使用 SwipeActions(content=..., leading=..., trailing=...)。当前框架不提供 List / ForEach 原生 on_delete 契约,删除动作请放在行级滑动按钮中。
示例会展示原生 List 的搜索、刷新、空状态、行操作和长列表承载方式。
+| 能力 | API |
|---|---|
| 列表 | List(content) |
| 数据驱动行 | ForEach(data, row_builder, key=...) |
| 表单 | Form(content)、Section("标题", content)、Section(content, header=...) |
| 样式 / 交互 | .list_style(style)、.swipe_actions、.refreshable(action)、.searchable(text, on_change) |
| 行外观 | .list_row_background(color)、.list_row_separator(visibility) |
List / Form 都是 纵向 容器;Form 默认呈现分组设置样式,但仍可用 Section 组织子控件。ForEach 不是布局,它把 Python 可迭代对象 展开 为多个子视图,因此必须保证 row_builder(item) 每次返回一棵合法的 View 子树。.searchable 是 修饰符:搜索框属于页面的一部分,过滤逻辑仍由你在 body() 里用 State 完成(见进阶示例)。State 对嵌套 list / dict 有深度观察;为减少多余刷新,批量修改请用 batch_update。list_style 常用取值automatic、inset、inset_grouped、grouped、plain、sidebar。设置页多用 inset_grouped,侧边栏列表可用 sidebar。
静态 Form + Section,以及一个简单的 List。
import appui
+
+state = appui.State(notify=True, note="")
+
+
+def set_note(value):
+ state.note = value
+
+
+def set_notify(value):
+ state.notify = value is True or value == "True" or value == "true"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form(
+ [
+ appui.Section(
+ "账户",
+ [
+ appui.TextField(
+ "备注",
+ text=state.note,
+ on_change=set_note,
+ ).text_field_style("rounded_border"),
+ ],
+ ),
+ appui.Section(
+ "通知",
+ [
+ appui.Toggle(
+ label="接收提醒",
+ is_on=state.notify,
+ on_change=set_notify,
+ ),
+ ],
+ footer="关闭后仍保留本地数据。",
+ ),
+ ]
+ )
+ .navigation_title("表单示例")
+ )
+
+
+appui.run(body, state=state)
+ForEach 动态行、.swipe_actions 删除、.searchable 过滤、.refreshable 下拉刷新、.list_style('inset_grouped'),以及 .list_row_background / .list_row_separator。
下面示例刻意把 过滤逻辑 放在 body() 顶部函数 filtered_items() 中,而不是藏在内联回调深处,便于单元测试与复用;refreshable 仅更新提示字符串,避免阻塞 UI 线程。
import appui
+
+state = appui.State(
+ items=["买牛奶", "写 appui 教程", "跑步三公里"],
+ query="",
+ refreshing_hint="",
+)
+
+
+def delete_item(name):
+ remaining = [x for x in state.items if x != name]
+ state.items = remaining
+
+
+def set_query(value):
+ state.query = value
+
+
+def refresh_list():
+ state.refreshing_hint = "已触发刷新回调(可在此拉取网络数据)"
+
+
+def item_key(item):
+ return item
+
+
+def row_view(item):
+ def delete_current():
+ delete_item(item)
+
+ return (
+ appui.Text(item)
+ .list_row_background("secondarySystemBackground")
+ .list_row_separator("hidden")
+ .swipe_actions(
+ edge="trailing",
+ content=[
+ appui.Button(
+ "删除",
+ role="destructive",
+ action=delete_current,
+ )
+ ],
+ )
+ )
+
+
+def filtered_items():
+ q = state.query.strip().lower()
+ if not q:
+ return list(state.items)
+ return [x for x in state.items if q in x.lower()]
+
+
+def body():
+ rows = filtered_items()
+ return appui.NavigationStack(
+ appui.VStack(
+ [
+ appui.Text("在搜索框输入以过滤").font("caption").foreground_color("secondaryLabel"),
+ appui.List(
+ [
+ appui.ForEach(
+ rows,
+ row_builder=row_view,
+ key=item_key,
+ )
+ ]
+ )
+ .searchable(
+ text=state.query,
+ on_change=set_query,
+ )
+ .refreshable(action=refresh_list)
+ .list_style("inset_grouped"),
+ appui.Text(state.refreshing_hint or "下拉列表以触发 refreshable")
+ .font("footnote")
+ .foreground_color("systemOrange"),
+ ],
+ spacing=8,
+ )
+ .padding()
+ .navigation_title("列表与搜索")
+ )
+
+
+appui.run(body, state=state)
+ForEach 必须提供稳定 key:字符串列表可用命名函数返回字符串本身;对象列表应使用唯一 id,否则删除 / 重排时行状态会错乱。.searchable 修饰的是「承载列表的视图」:通常与 List 链在一起;搜索文本要存入 State 并在 body() 里用同一字段过滤数据。swipe_actions 挂在「行视图」上:如示例中对 Text(item) 链式调用;挂在 List 整表上不会给每行单独滑动按钮。state.items 列表:state.items.remove(x) 若列表是普通 list 可能不会触发深度观察;推荐 state.items = new_list 或 batch_update(与 State 刷新规则一致)。Section 的参数顺序:推荐 Section("标题", [views], footer="...");需要关键字时再用 Section(content=[views], header="标题")。嵌套在 Form 中时务必传入 视图列表。refreshable 的回调尽量短:下拉刷新结束后系统才会收起转圈;若在回调里做长时间阻塞,界面会看起来「卡住」。可在回调里只更新标志位,把重活放到线程(注意线程安全与 State 更新方式)。list_row_background 颜色与深色模式:硬编码 #F2F2F7 在深色模式下可能对比不佳;可改用语义色如 secondarySystemBackground。on_drag 与滑删:系统滑动按钮与自定义水平拖拽共用手势通道;建议拆成不同交互区域或移除该行的滑动按钮。List 改为 List + NavigationLink,每行进入只读详情页。swipe_actions 的 edge='leading' 侧增加「完成」按钮,并用 .tint('green') 区分颜色。appui.LabeledContent 在 Form 最后一节展示当前过滤结果条数。List 树(不调用 run)import appui
+
+data = ["a", "b"]
+
+
+def text_row(value):
+ return appui.Text(value)
+
+
+def text_key(value):
+ return value
+
+
+tree = appui.List(
+ [
+ appui.ForEach(
+ data,
+ row_builder=text_row,
+ key=text_key,
+ )
+ ]
+)
+print(tree)
+完整页面可以组合 ForEach、swipe_actions、searchable 与 list_style。
LazyVStack 承载超长列表当行数可能上千时,可将 ForEach 放进 ScrollView + LazyVStack,以减轻一次性构建整表的开销。入口写法见 appui 概览。
import appui
+
+state = appui.State(rows=list(range(60)))
+
+
+def lazy_row(value):
+ return appui.Text(f"第 {value} 行").padding(edges="horizontal")
+
+
+def lazy_key(value):
+ return value
+
+
+def body():
+ return appui.NavigationStack(
+ appui.ScrollView(
+ appui.LazyVStack(
+ [
+ appui.ForEach(
+ state.rows,
+ row_builder=lazy_row,
+ key=lazy_key,
+ )
+ ],
+ spacing=6,
+ )
+ )
+ .navigation_title("懒加载列表示例")
+ )
+
+
+appui.run(body, state=state)
+| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 搜索框无反应 | on_change 未写回 State | 确保 setattr(state, 'query', v) |
| 滑动无删除按钮 | swipe_actions 挂在 List 上 | 移到行视图 |
| 误以为支持原生删除 | 直接寻找 on_delete / .onDelete | 改用行级 .swipe_actions(...) 或 SwipeActions(...) |
| 同一行横向拖拽失效 | 该行同时配置了 on_drag 与 swipe_actions | 保留其一,或把拖拽手势移到子区域 |
| 下拉不触发 | refreshable 未链到可滚动父级 | 与 List / ScrollView 链式连接 |
| 过滤结果不刷新 | filtered 未依赖 state | 把过滤函数放在 body() 内读取最新 state |
图片、相册、相机、地图、视频和 WebView 的页面模式。
+appui 中与图片、相册、相机、文件导入、视频、网页与地图相关的视图。下列示例均可在应用内 AppUI 预览环境运行。
示例会展示图片、网络图、相册、文件、相机、视频、网页和地图控件如何嵌入 AppUI 页面。
+Image(name=...);SF Symbols 用 Image(system_name=...)。AsyncImage,占位与失败视图通过子视图传入。FileImporter,默认复制到 App 可访问位置后返回路径列表。url 或 html。markers 为字典列表,键包括 latitude、longitude、title。Image 支持链式修饰:resizable()、aspect_ratio(ratio, content_mode)、symbol_rendering_mode(mode)、image_scale(scale)。
import appui
+
+state = appui.State(kind="SF Symbol")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Text(state.kind).font("headline"),
+ appui.Image(system_name="star.fill")
+ .resizable()
+ .aspect_ratio(content_mode="fit")
+ .symbol_rendering_mode("palette")
+ .image_scale("large")
+ .frame(width=56, height=56)
+ .foreground_color("systemYellow"),
+ appui.Label("设置", system_image="gear"),
+ ], spacing=16).padding()
+ .navigation_title("Image")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+content_mode 为 'fit' 或 'fill'。symbol_rendering_mode 常用:'hierarchical'、'palette'、'multicolor'(未识别时回退为单色)。
image_scale 为 'small'、'medium'(默认)、'large'。
参数:url、placeholder、error_view、content_mode、on_success、on_failure。占位与错误视图为子节点(前两个子视图依次为占位、错误)。
import appui
+
+state = appui.State(status="等待加载")
+
+
+def image_loaded():
+ state.status = "图片已加载"
+
+
+def image_failed():
+ state.status = "图片加载失败"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.AsyncImage(
+ url="https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png",
+ placeholder=appui.ProgressView(label="Loading"),
+ error_view=appui.Image(system_name="exclamationmark.triangle"),
+ content_mode="fit",
+ on_success=image_loaded,
+ on_failure=image_failed,
+ ).frame(height=120),
+ appui.LabeledContent("状态", value=state.status),
+ ], spacing=16).padding()
+ .navigation_title("AsyncImage")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+selection_limit:1 表示单选;0 表示不限制(以系统行为为准)。filter:'images'、'videos'、'all'。on_picked 收到 路径列表。
import appui
+
+state = appui.State(paths=[])
+
+
+def on_picked(paths):
+ state.paths = paths or []
+
+
+def body():
+ return appui.VStack([
+ appui.Text("已选: " + (" | ".join(state.paths) if state.paths else "无")).font("caption"),
+ appui.PhotoPicker(
+ selection_limit=1,
+ filter="images",
+ on_picked=on_picked,
+ label=appui.Label("选择照片", system_image="photo.on.rectangle"),
+ ),
+ ], spacing=12).padding()
+
+appui.run(body, state=state, presentation="sheet")
+allowed_types 可传类型名、扩展名或 MIME 类型,例如 'text'、'pdf'、'csv'、'image/png'。allows_multiple=True 允许多选。copy=True 是默认值,表示先复制进 App 可访问位置,再把路径列表传给 on_picked。
import appui
+
+state = appui.State(files=[])
+
+
+def on_files(paths):
+ state.files = paths or []
+
+
+def body():
+ rows = [
+ appui.Text(path).font("caption").line_limit(1)
+ for path in state.files
+ ]
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("导入", [
+ appui.FileImporter(
+ allowed_types=["text", "pdf", "csv"],
+ allows_multiple=True,
+ on_picked=on_files,
+ label=appui.Label("选择文件", system_image="doc.badge.plus"),
+ ),
+ ]),
+ appui.Section("文件", rows or [
+ appui.ContentUnavailableView("暂无文件", system_image="doc", description="从系统文件选择器导入")
+ ]),
+ ]).navigation_title("文件导入")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+文件选择由系统界面完成;用户取消时通常不会触发有效路径。需要读取文件内容时,在回调里保存路径,再在按钮动作、刷新函数或后台流程中读取,避免在 body() 里同步读大文件。
source:'camera' 或 'front'。media_type:'photo' 或 'video'。on_captured 收到单个路径字符串。
import appui
+
+state = appui.State(last="")
+
+
+def on_captured(path):
+ state.last = path or ""
+
+
+def body():
+ return appui.VStack([
+ appui.Text(state.last or "尚未拍摄").font("caption"),
+ appui.CameraPicker(
+ source="camera",
+ media_type="photo",
+ on_captured=on_captured,
+ label=appui.Label("拍照", system_image="camera.fill"),
+ ),
+ ], spacing=12).padding()
+
+appui.run(body, state=state, presentation="sheet")
+url 可为远程 HTTPS 或应用内可访问的本地文件名。autoplay、loop、show_controls 控制播放行为。
只展示视频时直接用 VideoPlayer(url=...)。如果页面需要播放/暂停/seek、保存进度、倍速、PiP 状态或切集,使用 PlayerController 并传给 VideoPlayer(player=player);AppUI 新页面不要再 import avplayer 控制同一块内嵌视频。
import appui
+
+state = appui.State(status="可播放")
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.VideoPlayer(
+ url="https://media.w3.org/2010/05/sintel/trailer.mp4",
+ autoplay=False,
+ loop=False,
+ show_controls=True,
+ ).frame(height=220),
+ appui.LabeledContent("状态", value=state.status),
+ ], spacing=16).padding()
+ .navigation_title("VideoPlayer")
+ )
+
+appui.run(body, state=state, presentation="sheet")
+import appui
+
+state = appui.State()
+
+def body():
+ return appui.WebView(
+ html="<html><body style='font-family:system-ui'><h1>appui</h1><p>内嵌 HTML</p></body></html>"
+ ).frame(height=360).padding()
+
+appui.run(body, state=state, presentation="sheet")
+远程页面示例:appui.WebView(url="https://www.apple.com").frame(height=360)。
默认中心为旧金山坐标。span 为经纬跨度。map_style 可为 'automatic'(默认,不传样式)、'standard'、'satellite'、'hybrid'。
import appui
+
+state = appui.State()
+
+def body():
+ return appui.MapView(
+ latitude=37.7749,
+ longitude=-122.4194,
+ span=0.08,
+ markers=[
+ {"latitude": 37.78, "longitude": -122.40, "title": "标记 A"},
+ ],
+ map_style="standard",
+ ).frame(height=280).padding()
+
+appui.run(body, state=state, presentation="sheet")
+ui 模块对照(迁移)ui 为 Pythonista 风格命令式 API;同一应用内可对照理解差异:
import ui
+
+v = ui.View()
+b = ui.Button(title="Go")
+
+
+def on_button(sender):
+ pass
+
+
+b.action = on_button
+v.add_subview(b)
+# v.present('sheet') # 需在 AppUI 预览环境中调用
+appui 用声明式 body() 返回 View,由 appui.run(body, state=..., presentation="sheet") 呈现。
| 视图 | 典型用途 |
|---|---|
Image | 资源图、SF Symbol、缩放与宽高比 |
AsyncImage | 网络图片与阶段 UI |
PhotoPicker / CameraPicker | 系统相册与相机,路径回调 |
FileImporter | 系统文件选择器,路径列表回调 |
VideoPlayer | 流媒体或本地视频 |
WebView | url 或 html 嵌入网页 |
MapView | 坐标、跨度、标注与样式 |
更完整的参数说明见同目录下的 appui-ref-media.md。
TabView、NavigationStack、NavigationLink 和 toolbar 的组合方式。
+导航的目标是让页面关系清楚:栈式跳转用 NavigationStack,多 tab 用 TabView,iPad 双栏用 NavigationSplitView,临时任务用 sheet / alert / popover。
示例会展示固定跳转、数据驱动导航、Tab、sheet 和 alert 的页面层级。
+| 需求 | API |
|---|---|
| 页面逐层进入、支持返回 | NavigationStack |
| 固定列表进入详情 | NavigationLink |
| 通过数据驱动跳转 | NavigationPath + destinations |
| 底部标签页 | TabView / Tab |
| iPad / Mac 侧栏 + 详情 | NavigationSplitView |
| 临时编辑页 | .sheet(...) |
| 强制流程 | .full_screen_cover(...) |
| 确认、错误、危险操作 | .alert(...) / .confirmation_dialog(...) |
| 小浮层 | .popover(...) |
最小写法是给根内容包一层 NavigationStack,再在子页面里设置标题。
import appui
+
+state = appui.State(last_opened="None")
+
+
+def mark_opened(title):
+ state.last_opened = title
+
+
+def settings_page():
+ def open_settings():
+ mark_opened("设置")
+
+ return appui.Form([
+ appui.Section("设置", [
+ appui.Text("这里是详情内容"),
+ appui.Button("记录打开", action=open_settings),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("Last opened", value=state.last_opened),
+ ]),
+ ]).navigation_title("设置")
+
+
+def about_page():
+ return appui.Text("实时快路径 appui").padding().navigation_title("关于")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.NavigationLink(title="设置", destination=settings_page()),
+ appui.NavigationLink(title="关于", destination=about_page()),
+ ]).navigation_title("首页")
+ )
+
+
+appui.run(body, state=state)
+固定页面跳转用 NavigationLink;动态路由用 NavigationPath。
NavigationPath 适合“从按钮打开指定页面”“根据 tag 和 data 打开详情”等数据驱动场景。
import appui
+
+path = appui.NavigationPath()
+state = appui.State(opened="None")
+
+
+def open_settings():
+ path.append({"tag": "settings", "data": None})
+
+
+def open_user():
+ path.append({"tag": "user", "data": 42})
+
+
+def go_back():
+ path.pop()
+
+
+def go_home():
+ path.pop_to_root()
+
+
+def settings_destination(data):
+ return appui.VStack([
+ appui.Text("设置").font("title2"),
+ appui.Button("返回", action=go_back),
+ ], spacing=12).padding().navigation_title("设置")
+
+
+def user_destination(user_id):
+ return appui.VStack([
+ appui.Text(f"用户 {user_id}").font("title2"),
+ appui.Button("返回首页", action=go_home),
+ ], spacing=12).padding().navigation_title("用户")
+
+
+def body():
+ return appui.NavigationStack(
+ content=appui.VStack([
+ appui.Text("首页").font("title2"),
+ appui.Button("打开设置", action=open_settings).button_style("bordered"),
+ appui.Button("打开用户 42", action=open_user).button_style("bordered_prominent"),
+ ], spacing=16).padding().navigation_title("示例"),
+ path=path,
+ destinations={
+ "settings": settings_destination,
+ "user": user_destination,
+ },
+ )
+
+
+appui.run(body, state=state)
+path.append({"tag": "...", "data": ...}) 的 tag 用来找目标构建函数,data 会传给目标函数。
Tab 适合并列的顶层区域,不要用来承载一次性流程。每个 tab 中如果还需要进入详情,再单独放自己的 NavigationStack。
import appui
+
+state = appui.State(selection=0)
+
+
+def set_selection(value):
+ state.selection = value
+
+
+def home_view():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Home", [
+ appui.Text("Home content")
+ ])
+ ]).navigation_title("首页")
+ )
+
+
+def settings_view():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Settings", [
+ appui.LabeledContent("Selected tab", value=str(state.selection))
+ ])
+ ]).navigation_title("设置")
+ )
+
+
+def body():
+ return appui.TabView(
+ selection=state.selection,
+ on_change=set_selection,
+ tabs=[
+ appui.Tab("首页", system_image="house", tag=0, content=home_view()),
+ appui.Tab("设置", system_image="gearshape", tag=1, content=settings_view()),
+ ],
+ )
+
+
+appui.run(body, state=state)
+弹层用状态控制显示和关闭。复杂 sheet 内容抽成命名函数。
+import appui
+
+state = appui.State(show_sheet=False, show_alert=False, name="")
+
+
+def set_name(value):
+ state.name = value
+
+
+def open_sheet():
+ state.show_sheet = True
+
+
+def close_sheet():
+ state.show_sheet = False
+
+
+def open_alert():
+ state.show_alert = True
+
+
+def close_alert():
+ state.show_alert = False
+
+
+def editor_sheet():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("编辑", [
+ appui.TextField("名称", text=state.name, on_change=set_name),
+ appui.Button("完成", action=close_sheet).button_style("bordered_prominent"),
+ ])
+ ]).navigation_title("编辑")
+ )
+
+
+def body():
+ root = (
+ appui.VStack([
+ appui.Button("编辑", action=open_sheet).button_style("bordered"),
+ appui.Button("删除", action=open_alert)
+ .foreground_color("systemRed"),
+ ], spacing=16)
+ .padding()
+ .sheet(is_presented=state.show_sheet, on_dismiss=close_sheet, content=editor_sheet)
+ .alert(
+ "确认删除?",
+ is_presented=state.show_alert,
+ on_dismiss=close_alert,
+ actions=[
+ appui.Button("取消", role="cancel", action=close_alert),
+ appui.Button("删除", role="destructive", action=close_alert),
+ ],
+ message="删除后无法恢复。",
+ )
+ )
+ return appui.NavigationStack(root.navigation_title("呈现"))
+
+
+appui.run(body, state=state)
+普通编辑用 sheet,破坏性操作先 alert 或 confirmation dialog。
+NavigationSplitView 适合大屏。窄屏上系统会自动退化为栈式体验。
appui.NavigationSplitView(
+ sidebar=appui.List([...]).navigation_title("项目"),
+ detail=appui.Text("选择一个项目").navigation_title("详情"),
+)
+如果目标设备主要是 iPhone,不要为了“看起来高级”强行使用 SplitView。
+| 问题 | 处理 |
|---|---|
| 页面没有标题 | 在内容 View 上加 .navigation_title(...)。 |
| 返回后状态丢失 | 把状态放在外层 State,不要在目标页面函数里重新创建。 |
destinations 回调报参数错 | 回调写成接收 data 的命名函数。 |
| sheet 点了没反应 | 确认 is_presented 读取的是状态字段,关闭时会把字段改回 False。 |
| Tab 和导航栈混乱 | Tab 做顶层分区,Stack 做分区内跳转。 |
| 工具栏图标按钮报错 | Button 的图标放在 content=Image(...) 或 content=Label(...)。 |
普通 State、Timer、ReactiveState 和实时快路径的选择。
+appui 的性能优化优先从界面结构、状态粒度和后台任务开始。大多数页面不需要特殊技巧,只要避免在 body() 里做重活,就能保持流畅。如果页面有高频属性更新,再看 实时快路径。
示例会展示列表筛选、状态粒度、Timer 和高频属性更新的性能写法。
+| 场景 | 推荐做法 |
|---|---|
| 列表数据 | 使用 List + ForEach,并提供稳定 key |
| 设置页 | 使用 Form + Section |
| 多字段状态变化 | 使用 state.batch_update(...) |
| 派生搜索结果 | 使用普通函数或 computed |
| 计时器和进度 | 模块级 Timer,回调中更新状态 |
| 高频数值 | 先用 State 做正确,再按需切到 ReactiveState |
| 大文件读取 | 放到按钮动作、任务或后台流程中 |
| 图片和网络 | 先异步加载,再把结果写入状态 |
body() 应该只描述界面。不要在里面同步读取大文件、下载网络内容、递归扫描目录、创建 Timer 或做复杂排序。
import appui
+
+state = appui.State(items=["main.py", "notes.md", "data.json"], selected="None")
+
+
+def item_key(name):
+ return name
+
+
+def select_item(name):
+ state.selected = name
+
+
+def row_view(name):
+ def select_current():
+ select_item(name)
+
+ return appui.Button(
+ action=select_current,
+ content=appui.Text(name).frame(max_width=appui.infinity, alignment="leading"),
+ ).button_style("plain")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Files", [
+ appui.ForEach(state.items, row_builder=row_view, key=item_key)
+ ]),
+ appui.Section("State", [
+ appui.LabeledContent("Selected", value=state.selected)
+ ]),
+ ]).navigation_title("Files")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+如果数据来自耗时操作,先在事件中准备好,再更新状态。
+把变化频繁的值放到明确字段里。一次动作改多个字段时用 batch_update,避免中间状态反复重建。
import appui
+
+state = appui.State(count=0, message="Ready")
+
+
+def plus():
+ state.batch_update(count=state.count + 1, message="Updated")
+
+
+def reset():
+ state.batch_update(count=0, message="Reset")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Counter", [
+ appui.LabeledContent("Count", value=str(state.count)),
+ appui.HStack([
+ appui.Button("增加", action=plus).button_style("bordered_prominent"),
+ appui.Button("重置", action=reset).button_style("bordered"),
+ ], spacing=12),
+ ], footer=state.message)
+ ]).navigation_title("Refresh Scope")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+大列表优先使用原生列表组件。过滤、排序、分组这类派生数据不要在每个 row 里重复计算。
+import appui
+
+rows = [{"id": index, "title": f"Item {index}"} for index in range(1, 101)]
+state = appui.State(query="", rows=rows, selected="None")
+
+
+def set_query(value):
+ state.query = value
+
+
+@appui.computed(state, depends_on=["query", "rows"])
+def filtered_rows():
+ keyword = state.query.strip().lower()
+ if not keyword:
+ return state.rows
+ return [row for row in state.rows if keyword in row["title"].lower()]
+
+
+def row_key(row):
+ return row["id"]
+
+
+def select_row(row):
+ state.selected = row["title"]
+
+
+def row_view(row):
+ def select_current():
+ select_row(row)
+
+ return appui.Button(
+ action=select_current,
+ content=appui.HStack([
+ appui.Text(row["title"]).frame(max_width=appui.infinity, alignment="leading"),
+ appui.Image(system_name="chevron.right").foreground_color("tertiaryLabel"),
+ ]),
+ ).button_style("plain")
+
+
+def body():
+ visible = filtered_rows()
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section(f"{len(visible)} results", [
+ appui.ForEach(visible, row_builder=row_view, key=row_key)
+ ]),
+ appui.Section("Selection", [
+ appui.LabeledContent("Selected", value=state.selected)
+ ]),
+ ])
+ .searchable(text=state.query, on_change=set_query)
+ .navigation_title("Large List")
+ )
+
+
+appui.run(body, state=state, presentation="sheet")
+不要把大量行直接塞进普通 VStack 再放进 ScrollView。列表需要稳定的行身份和系统级复用能力。
Timer 放模块级,启动和停止由回调控制,不要在 body() 里创建。
import appui
+
+state = appui.State(seconds=0, running=False)
+
+
+def tick():
+ if state.running:
+ state.seconds += 1
+
+
+timer = appui.Timer(interval=1.0, action=tick)
+
+
+def start():
+ state.running = True
+ timer.start()
+
+
+def stop():
+ state.running = False
+ timer.stop()
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Timer", [
+ appui.LabeledContent("Seconds", value=str(state.seconds)),
+ appui.HStack([
+ appui.Button("Start", action=start).button_style("bordered_prominent"),
+ appui.Button("Stop", action=stop).button_style("bordered"),
+ ], spacing=12),
+ ], footer="Running" if state.running else "Stopped")
+ ]).navigation_title("Timer")
+ )
+
+
+appui.run(body, state=state)
+当页面结构稳定、只是已有控件的值持续变化时,可以使用 实时快路径模式。它适合进度、滑块、仪表盘、计时器和状态文本。
+关键原则:
+State 版本正确。ReactiveState 或组件绑定。import appui
+
+state = appui.ReactiveState(
+ title="Meter",
+ level=(0.0, 1),
+ peak=(0.0, 2),
+)
+
+
+def update_meter(level, peak):
+ state.level = level
+ state.peak = peak
+VideoPlayer、MapView、WebView、相机预览这类视图不要放进普通列表行里滚动。把它们放在稳定区域,让下方的控制项使用 Form 或 List。
| 场景 | 推荐 |
|---|---|
| 视频播放 | 顶部固定 VideoPlayer,下方 Form 控制区 |
| 地图选点 | 顶部 MapView,下方坐标和操作 |
| WebView 帮助页 | WebView 独立稳定区域 |
| 图表仪表盘 | Chart 或 Canvas 放在固定高度卡片中 |
高频状态更新、二进制属性通道和实时 UI 的边界。
+实时快路径是 appui 的高频界面更新通道。它的目标不是让你手动操作更新机制,而是在状态变化很频繁时,让已经显示出来的原生控件尽量只更新必要属性。
你可以把它理解为:首屏仍然由 appui 正常构建,运行时如果只是文本、滑块、开关、进度、图片等属性变化,系统会优先走更轻的更新路径。
示例会展示普通 State 与 ReactiveState 在高频属性更新时的可见差异。
+这些场景继续使用普通 State 和 appui 组件即可。实时快路径会在适合的地方自动参与,不需要你把简单界面写复杂。
优先从清晰的状态模型开始:
+import appui
+
+state = appui.State(progress=0.4, title="下载中")
+
+def body():
+ return appui.VStack([
+ appui.Text(state.title),
+ appui.ProgressView(value=state.progress),
+ appui.Button("增加进度", action=bump),
+ ], spacing=16).padding()
+
+def bump():
+ state.progress = min(1.0, state.progress + 0.1)
+ if state.progress >= 1.0:
+ state.title = "已完成"
+
+appui.run(body, state=state, presentation="sheet")
+如果一个值会高频变化,再考虑 ReactiveState。它适合把频繁变化的字段和界面控件绑定起来,让系统选择更轻的刷新方式。
import appui
+
+state = appui.ReactiveState(value=0.5)
+
+
+def value_label():
+ return f"{state.value:.0%}"
+
+
+def body():
+ return appui.VStack([
+ appui.Text("音量"),
+ appui.Slider(**appui.bind(state, "value"), label="音量"),
+ appui.Text(value_label()),
+ ], spacing=16).padding()
+
+appui.run(body, state=state, presentation="sheet")
+appui 界面,再优化高频字段。ReactiveState 或组件已有绑定,不要自己维护一套刷新系统。State 重建,属性变化交给系统尽量增量刷新。实时快路径只解决“已有控件高频改值”的成本。真正流畅的界面还需要:
+List / ForEach,不要用大量 VStack 模拟。body() 中做复杂计算、排序或同步文件读取。更多整体建议见 性能实践。
+如果实时数据来自相机、传感器、麦克风、键盘高度、深浅色变化或照片选择,请看 iOS 原生能力。这些能力需要明确的启动和停止路径,不适合在普通 body() 重建循环中轮询。
State、ReactiveState、Timer 和批量更新的使用边界。
+appui 的界面由状态驱动:状态变了,body() 重新执行,View 树重新生成。状态代码要短、集中、可预测。
示例会展示表单字段、列表选择、搜索结果、滑块和计时器如何由状态驱动刷新。
+| 用法 | 选择 | 说明 |
|---|---|---|
| 表单字段、开关、当前 tab、普通计数 | State | 最常用,写入后触发重建。 |
| 高频数值或大数组 | ReactiveState | 可走实时快路径,减少整树重建压力。 |
| 不想触发重建的对象 | Ref | 保存句柄、缓存对象、一次性资源。 |
| 列表 / 字典增删改 | ObservableList / ObservableDict | State 会自动包装 list/dict,让局部变更也能通知界面。 |
| 派生值 | computed | 从状态计算,不手动同步副本。 |
| 状态变化后的副作用 | effect | 用于日志、保存、触发外部动作。 |
普通页面先用 State。只有高频更新或大数据刷新已经明显卡顿时,再考虑 ReactiveState。
State 适合绝大多数页面状态。控件读取当前字段,on_change 或按钮回调写回字段。
import appui
+
+state = appui.State(name="", enabled=True, count=0, progress=30.0)
+
+
+def set_name(value):
+ state.name = value
+
+
+def set_enabled(value):
+ state.enabled = value
+
+
+def set_progress(value):
+ state.progress = value
+
+
+def add_count():
+ state.count += 1
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("表单", [
+ appui.TextField("Name", text=state.name, on_change=set_name),
+ appui.Toggle("Enabled", is_on=state.enabled, on_change=set_enabled),
+ appui.Slider(value=state.progress, minimum=0, maximum=100, on_change=set_progress),
+ appui.Button("Add", action=add_count).button_style("bordered_prominent"),
+ ]),
+ appui.Section("当前值", [
+ appui.LabeledContent("name", value=state.name or "-"),
+ appui.LabeledContent("enabled", value=str(state.enabled)),
+ appui.LabeledContent("count", value=str(state.count)),
+ appui.LabeledContent("progress", value=f"{state.progress:.0f}%"),
+ ]),
+ ]).navigation_title("State")
+ )
+
+
+appui.run(body, state=state)
+多个字段一起更新时,用 batch_update:
state.batch_update(name="Demo", enabled=True, count=0)
+snapshot = state.to_dict()
+列表和字典放进 State 后会被包装成可观察容器。append、pop、update 等局部变更也能通知界面。
import appui
+
+state = appui.State(items=["Milk", "Coffee"], selected="None")
+
+
+def add_item():
+ state.items.append(f"Item {len(state.items) + 1}")
+
+
+def item_key(item):
+ return item
+
+
+def select_item(item):
+ state.selected = item
+
+
+def item_row(item):
+ def select_current():
+ select_item(item)
+
+ return appui.Button(
+ action=select_current,
+ content=appui.Text(item).frame(max_width=appui.infinity, alignment="leading"),
+ ).button_style("plain")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Items", [
+ appui.ForEach(state.items, row_builder=item_row, key=item_key)
+ ]),
+ appui.Section("State", [
+ appui.LabeledContent("Selected", value=state.selected)
+ ]),
+ ])
+ .navigation_title("ObservableList")
+ .toolbar([
+ appui.ToolbarItem(
+ placement="navigation_bar_trailing",
+ content=appui.Button("Add", action=add_item),
+ )
+ ])
+ )
+
+
+appui.run(body, state=state)
+如果只是偶尔改列表,也可以重新赋值:state.items = list(state.items) + ["New"]。
Ref 保存“不属于界面事实源”的对象,写入不会触发重建。
timer_ref = appui.Ref(None)
+cache_ref = appui.Ref({})
+典型用途是保存 timer、网络任务、播放器句柄、滚动代理等。不要把需要显示到界面的值放进 Ref。
computed 用来声明派生值,避免维护两份状态。
import appui
+
+state = appui.State(query="", items=["Layout", "Controls", "Navigation"])
+
+
+def set_query(value):
+ state.query = value
+
+
+@appui.computed(state, depends_on=["query", "items"])
+def visible_items():
+ keyword = state.query.strip().lower()
+ if not keyword:
+ return state.items
+ return [item for item in state.items if keyword in item.lower()]
+
+
+def item_key(item):
+ return item
+
+
+def item_row(item):
+ return appui.Label(item, system_image="doc.text")
+
+
+def log_query_change():
+ print("query changed", state.query)
+
+
+appui.effect(state, depends_on=["query"])(log_query_change)
+
+
+def body():
+ rows = visible_items()
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section(f"{len(rows)} results", [
+ appui.ForEach(rows, row_builder=item_row, key=item_key)
+ ])
+ ])
+ .searchable(text=state.query, on_change=set_query)
+ .navigation_title("computed")
+ )
+
+
+appui.run(body, state=state)
+副作用不要写进 body();body() 应该只负责返回 View。
数值控件需要双向绑定时,可以用 appui.bind(state, "field")。
appui.Slider(**appui.bind(state, "progress"), minimum=0, maximum=100)
+TextField、Toggle、Picker 的参数名不是 value,通常直接传当前值和 on_change。
ReactiveState 适合频繁更新的属性,例如 slider 值、拖动坐标、图表数据。它可以绑定实时属性通道,减少高频整树重建。
import appui
+
+state = appui.ReactiveState(progress=30.0)
+
+
+def body():
+ return appui.VStack([
+ appui.Text(f"{state.progress:.0f}%").font("largeTitle").bold(),
+ appui.Slider(**state.bind("progress"), minimum=0, maximum=100),
+ ], spacing=20).padding()
+
+
+appui.run(body, state=state)
+普通表单不需要上 ReactiveState;只有高频更新或大数据刷新明显卡顿时再用。
Timer 要在模块级创建,初始化时传入 action。不要在 body() 里创建 Timer。
import appui
+
+state = appui.State(seconds=0, running=False)
+
+
+def tick():
+ state.seconds += 1
+
+
+timer = appui.Timer(interval=1.0, repeats=True, action=tick)
+
+
+def start():
+ state.running = True
+ timer.start()
+
+
+def stop():
+ state.running = False
+ timer.stop()
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Timer", [
+ appui.LabeledContent("Seconds", value=f"{state.seconds}s"),
+ appui.HStack([
+ appui.Button("Start", action=start).button_style("bordered_prominent"),
+ appui.Button("Stop", action=stop).button_style("bordered"),
+ ], spacing=12),
+ ], footer="Running" if state.running else "Stopped")
+ ]).navigation_title("Timer")
+ )
+
+
+appui.run(body, state=state)
+auto_refresh 适合临时脚本和 demo。正式页面优先用明确的状态写入、绑定和生命周期。
State / ReactiveState。ObservableList / ObservableDict,也可以重新赋值。body() 里不要创建 timer、请求、文件写入。batch_update。ReactiveState。从命令式 UI 切到 State 驱动的声明式 View 树。
+appui 的核心规则很简单:body() 返回一棵 View 树,状态变化后重新计算这棵树。不要手动创建、保存、移动原生控件;只描述当前状态下界面应该是什么样。
运行示例后会看到计数、登录分支和导航路径随状态变化即时刷新,页面代码始终只描述当前 View 树。
+| 规则 | 含义 |
|---|---|
| View 是描述 | Text、Button、VStack 只是声明界面结构。 |
| State 是事实源 | 用户输入、选择、列表数据放进 State / ReactiveState。 |
| body 要可重复执行 | body() 里不要写网络请求、文件写入、定时器创建等副作用。 |
| 修饰符有顺序 | .padding().background() 和 .background().padding() 的视觉结果不同。 |
命令式 UI 常见写法是“找到控件,然后改它”。appui 的写法是“改状态,然后让界面重新声明”。
import appui
+
+state = appui.State(count=0)
+
+
+def increment():
+ state.count += 1
+
+
+def body():
+ return appui.VStack([
+ appui.Text(f"Count: {state.count}")
+ .font("largeTitle")
+ .bold(),
+ appui.Button("Add", action=increment)
+ .button_style("bordered_prominent"),
+ ], spacing=16).padding()
+
+
+appui.run(body, state=state)
+这段代码里没有“更新 label 文本”的命令。按钮只修改 state.count,下一次重建时 Text 自然显示新值。
View 可以嵌套,也可以根据状态返回不同分支。
+import appui
+
+state = appui.State(logged_in=False)
+
+
+def log_in():
+ state.logged_in = True
+
+
+def body():
+ if state.logged_in:
+ content = appui.Text("已登录").font("title2")
+ else:
+ content = appui.Button("登录", action=log_in)
+
+ return appui.VStack([
+ appui.Text("欢迎").font("largeTitle").bold(),
+ content,
+ ], spacing=20).padding()
+
+
+appui.run(body, state=state)
+条件分支应该返回完整的 View。不要在 body() 外保存某个 View 再反复修改它。
写界面前先列状态字段:
+| 场景 | 推荐状态 |
|---|---|
| 普通字段、表单、开关 | State |
| 高频数值,如 slider、拖动、图表数据 | ReactiveState |
| 不触发重建的对象句柄 | Ref |
| 列表 / 字典增删改 | ObservableList / ObservableDict |
状态写入通常放在按钮、输入框绑定、手势或定时器回调里。多个字段一起改时用 state.batch_update(...),避免中间状态触发多次重建。
修饰符返回新的 View 描述,因此可以连续调用:
+(
+ appui.Text("Hello")
+ .font("title")
+ .foreground_color("white")
+ .padding()
+ .background("systemBlue")
+ .corner_radius(12)
+)
+常用顺序是:内容样式 -> 尺寸/间距 -> 背景/边框 -> 交互/导航。遇到视觉不对,优先检查修饰符顺序。
+导航栈不要当成“打开页面”的命令集合,而是一个路径状态。
+import appui
+
+path = appui.NavigationPath()
+state = appui.State()
+
+
+def open_settings():
+ path.append({"tag": "settings"})
+
+
+def go_back():
+ path.pop()
+
+
+def settings_destination(data):
+ return appui.VStack([
+ appui.Text("设置").font("title2"),
+ appui.Button("返回", action=go_back),
+ ], spacing=12).padding()
+
+
+def body():
+ return appui.NavigationStack(
+ content=appui.VStack([
+ appui.Text("首页").navigation_title("示例"),
+ appui.Button("去设置页", action=open_settings),
+ ], spacing=16).padding(),
+ path=path,
+ destinations={
+ "settings": settings_destination,
+ },
+ )
+
+
+appui.run(body, state=state)
+destinations 的回调会接收 data 参数;即使暂时不用,也要写成命名函数,避免把页面构造逻辑藏在内联回调里。
| 误区 | 改法 |
|---|---|
在 body() 里创建定时器或发请求 | 放到 effect、按钮回调或生命周期回调。 |
| 把 View 保存到全局变量里再修改 | 保存状态,不保存 View。 |
| 列表原地改了但界面不刷新 | 使用 ObservableList,或重新赋值一个新列表。 |
| 参数名不确定 | 以 API 参考和代码补全为准。 |
一个 body() 写成几百行 | 拆成普通 Python 函数返回子 View。 |
用 Python 编写原生 MiniApp 页面、状态、导航和交互。
+appui 是 PythonIDE 用来编写原生 MiniApp 界面的模块。你用 Python 描述页面结构、状态和交互,PythonIDE 负责把它显示成 iOS 风格的表单、列表、导航、弹层、媒体和图表界面。
import appui
+
+state = appui.State(count=0)
+
+
+def add_one():
+ state.count += 1
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("计数器", [
+ appui.LabeledContent("当前值", value=str(state.count)),
+ appui.Button("加 1", action=add_one)
+ .button_style("bordered_prominent"),
+ ])
+ ]).navigation_title("AppUI")
+ )
+
+
+appui.run(body, state=state)
+预期效果:预览打开一个原生表单页面,点击按钮后当前值立即更新。
+| 目标 | 推荐看 |
|---|---|
| 第一次写 MiniApp | 快速上手 |
| 判断该用 AppUI、widget、ui 还是 scene | 运行时选择 |
| 选择页面结构 | AppUI UI 模式 |
| 按任务查 API | AppUI API 地图 |
| 排查空白、按钮、输入、列表和权限问题 | MiniApp 开发排错 |
| 查完整函数和组件 | 函数参考 |
appui.run(body, state=state)。body() 或 body(view_state) 返回一个 AppUI View。State 保存普通界面数据,ReactiveState 只用于高频变化。Binding / bind() / State.bind() 适合输入控件和状态双向连接。Form + Section,列表页用 List + ForEach,多页面用 NavigationStack 或 TabView。body() 里。| 需求 | 首选 |
|---|---|
| 表单、设置、列表、详情、工具页 | appui |
| 多页面 MiniApp、Tab、弹层、搜索、刷新 | appui |
| 图表、媒体、地图、WebView 和系统能力入口 | appui + 对应模块 |
| 主屏、锁屏、StandBy 小组件 | widget |
| Sprite、碰撞、逐帧绘制、小游戏 | scene |
| Pythonista 兼容的命令式界面脚本 | ui |
| 纯输出、批处理、日志转换 | 普通 Python / console |
| API | 保证 | 注意 |
|---|---|---|
appui.run(...) | 启动一个 MiniApp 预览或页面。 | 放在脚本末尾,一个脚本只保留一个入口。 |
appui.run(..., presentation=...) | 控制界面呈现方式。 | 可选 'sheet'(默认)、'fullscreen'、'fullscreen_with_close'。 |
State | 字段变化后触发页面重建。 | 多字段同时更新优先用 batch_update(...)。 |
ReactiveState | 适合高频数据更新。 | 普通表单和列表不要默认使用。 |
body() | 描述当前界面。 | 不要在里面发请求、申请权限、写文件或修改状态。 |
Button(action=...) | 点击后执行命名回调。 | 传函数本身,不要写 action=save()。 |
ForEach(..., key=...) | 用稳定 key 维护动态行身份。 | 不要把可变化的 index 当 key。 |
body() 返回 AppUI View,脚本末尾调用了 appui.run(...)。action 传入命名函数,而不是函数调用结果。on_change 接收新值并写回 State。ForEach 使用稳定 key。Form + Section,不要用一组自绘卡片模拟。body()。appui 与 ui 有 View、Button、TextField、Slider、Image、ScrollView 等同名类/函数,但语义不同。不要在同一文件中同时 from appui import * 和 from ui import *。
appui 与 scene 只有 run 这个入口名容易混淆。混用时用模块名限定调用:appui.run(body) / scene.run(MyScene())。
用四个可预览示例掌握 body、State、控件和导航。
+这篇教程用四个可预览示例串起 AppUI 的最小闭环:显示页面、修改状态、编辑表单、列表进详情。每段都可以直接运行。
+先让原生页面出现。body() 返回一个 View,脚本末尾调用 appui.run(body)。
import appui
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Text("Hello AppUI").font("title2").bold(),
+ appui.Text("用 Python 编写原生 MiniApp 界面")
+ .foreground_color("secondaryLabel"),
+ ], spacing=8)
+ .padding()
+ .navigation_title("Hello")
+ )
+
+
+appui.run(body)
+预期效果:预览显示一个带导航标题的原生页面,页面中有标题和说明文字。
+用 State 保存页面数据。按钮回调修改状态后,页面会用新状态重建。
import appui
+
+state = appui.State(count=0, message="Ready")
+
+
+def decrease():
+ state.batch_update(count=state.count - 1, message="Decreased")
+
+
+def increase():
+ state.batch_update(count=state.count + 1, message="Increased")
+
+
+def body(view_state):
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Text(f"当前计数:{view_state.count}").font("title3"),
+ appui.HStack([
+ appui.Button("-1", action=decrease).button_style("bordered"),
+ appui.Button("+1", action=increase)
+ .button_style("bordered_prominent"),
+ ], spacing=12),
+ appui.Text(view_state.message)
+ .font("caption")
+ .foreground_color("secondaryLabel"),
+ ], spacing=16)
+ .padding()
+ .navigation_title("状态")
+ )
+
+
+appui.run(body, state=state)
+预期效果:点击 +1 或 -1 后计数和状态文案同步变化。
设置页和编辑页优先使用 Form + Section。输入控件通过 on_change 写回 State。
import appui
+
+state = appui.State(name="Ada", enabled=False, volume=40.0, saved=False)
+
+
+def set_name(value):
+ state.batch_update(name=value, saved=False)
+
+
+def set_enabled(value):
+ state.batch_update(enabled=value, saved=False)
+
+
+def set_volume(value):
+ state.batch_update(volume=value, saved=False)
+
+
+def save():
+ state.saved = bool(state.name.strip())
+
+
+def body():
+ footer = "已保存" if state.saved else "修改后点击保存"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("资料", [
+ appui.TextField("姓名", text=state.name, on_change=set_name),
+ appui.Toggle("启用提醒", is_on=state.enabled, on_change=set_enabled),
+ appui.Slider(
+ value=state.volume,
+ minimum=0,
+ maximum=100,
+ on_change=set_volume,
+ ),
+ ], footer=footer),
+ appui.Section("操作", [
+ appui.Button("保存", action=save)
+ .button_style("bordered_prominent"),
+ ]),
+ ]).navigation_title("表单")
+ )
+
+
+appui.run(body, state=state)
+预期效果:修改文本、开关或滑块后页面保持响应;点击保存后底部文案变为“已保存”。
+多页面 MiniApp 通常用 NavigationStack 包根页面,列表进详情用 NavigationLink。动态行必须有稳定 key。
import appui
+
+items = [
+ {"id": "docs", "title": "文档", "subtitle": "Guide"},
+ {"id": "api", "title": "API", "subtitle": "Reference"},
+ {"id": "demo", "title": "示例", "subtitle": "Cookbook"},
+]
+
+state = appui.State(last_opened="None")
+
+
+def item_key(item):
+ return item["id"]
+
+
+def mark_opened(item):
+ state.last_opened = item["title"]
+
+
+def detail_view(item):
+ def open_current():
+ mark_opened(item)
+
+ return appui.Form([
+ appui.Section("详情", [
+ appui.LabeledContent("标题", value=item["title"]),
+ appui.LabeledContent("类型", value=item["subtitle"]),
+ appui.Button("标记打开", action=open_current),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("Last opened", value=state.last_opened),
+ ]),
+ ]).navigation_title(item["title"])
+
+
+def row_view(item):
+ label = appui.HStack([
+ appui.Text(item["title"])
+ .frame(max_width=appui.infinity, alignment="leading"),
+ appui.Text(item["subtitle"]).foreground_color("secondaryLabel"),
+ ])
+ return appui.NavigationLink(destination=detail_view(item), label=label)
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("入口", [
+ appui.ForEach(items, row_builder=row_view, key=item_key),
+ ])
+ ]).navigation_title("列表")
+ )
+
+
+appui.run(body, state=state)
+预期效果:列表显示三行入口,点击任一行进入详情页;详情页按钮会更新打开状态。
+| 检查项 | 合格标准 |
|---|---|
| 入口 | 脚本末尾只调用一次 appui.run(...) |
| 呈现 | 默认 presentation='sheet';全屏可用 'fullscreen' 或 'fullscreen_with_close' |
| 页面 | body() 或 body(state) 返回 AppUI View |
| 状态 | 回调修改 State,多字段更新用 batch_update(...) |
| 按钮 | action 传命名函数,不写 action=save() |
| 输入 | on_change 接收新值并写回 State |
| 列表 | ForEach 使用稳定 key |
body() 返回 View,且脚本末尾调用了 appui.run(...)。action 传函数本身,而不是函数调用结果。on_change 的参数名接收新值,并写回 State。ForEach 使用稳定业务 id,不使用会变化的 index。继续查 AppUI API 地图。需要判断该用哪个运行时时,先看 运行时选择。
+Chart、Canvas、DrawingContext 和 Path。
+本页覆盖 Chart、Canvas、DrawingContext 和 Path。Chart 用系统图表展示结构化数据;Canvas 和 DrawingContext 用命令列表画 2D 图形;Path 用矢量路径命令画自定义形状。
| 目标 | 首选 API | 说明 |
|---|---|---|
| 柱状、折线、面积、散点图 | Chart | 数据是字典列表,字段由 x、y、series 指定。 |
| 简单 2D 绘图 | Canvas + DrawingContext | 矩形、圆、线、文本、渐变、路径等命令。 |
| 自定义矢量形状 | Path | 三角形、曲线、弧线、可填充或描边的路径。 |
| 实时高频绘制 | Canvas + 稳定命令列表 | 避免每次 body() 重建大量命令。 |
Chart(data=None, x='x', y='y', type='bar', color=None, series=None)
| API | 签名 | 分类 |
|---|---|---|
Chart | Chart(data: Optional[Sequence[Dict[str, Any]]] = None, x: str = 'x', y: str = 'y', type: str = 'bar', color: Optional[ColorLike] = None, series: Optional[str] = None) | media |
import appui
+
+
+def body():
+ rows = [
+ {"day": "Mon", "value": 3},
+ {"day": "Tue", "value": 7},
+ {"day": "Wed", "value": 5},
+ {"day": "Thu", "value": 9},
+ ]
+
+ return appui.NavigationStack(
+ appui.Chart(
+ data=rows,
+ x="day",
+ y="value",
+ type="bar",
+ color="systemBlue",
+ )
+ .frame(height=240)
+ .padding()
+ .navigation_title("Chart")
+ )
+
+
+appui.run(body)
+import appui
+
+
+def body():
+ data = [
+ {"month": "Jan", "value": 10, "category": "A"},
+ {"month": "Jan", "value": 20, "category": "B"},
+ {"month": "Feb", "value": 14, "category": "A"},
+ {"month": "Feb", "value": 18, "category": "B"},
+ {"month": "Mar", "value": 22, "category": "A"},
+ {"month": "Mar", "value": 16, "category": "B"},
+ ]
+
+ return appui.NavigationStack(
+ appui.Chart(
+ data=data,
+ x="month",
+ y="value",
+ series="category",
+ type="line",
+ )
+ .frame(height=240)
+ .padding()
+ .navigation_title("Series")
+ )
+
+
+appui.run(body)
+Canvas(width=300, height=300, commands=None, context=None)
| API | 签名 | 分类 |
|---|---|---|
Canvas | Canvas(width: float = 300, height: float = 300, commands: Optional[Sequence[Dict[str, Any]]] = None, context: Optional[DrawingContext] = None) | drawing |
import appui
+
+
+def make_context():
+ ctx = appui.DrawingContext()
+ ctx.gradient_rect(0, 0, 240, 140, colors=["systemBlue", "systemTeal"])
+ ctx.rounded_rect(20, 20, 200, 100, corner_radius=16, color="systemBackground", fill=True)
+ ctx.fill_text("Canvas", 72, 76, color="label", font_size=22)
+ ctx.line(40, 98, 200, 98, color="separator", line_width=2)
+ return ctx
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Canvas(width=240, height=140, context=make_context())
+ .frame(width=240, height=140)
+ .padding()
+ .navigation_title("Canvas")
+ )
+
+
+appui.run(body)
+DrawingContext 的每个方法都会向 commands 追加一个公开命令字典,并返回自身,方便链式调用。
| API | 签名 | 所属类型 |
|---|---|---|
fill_rect | fill_rect(x: float, y: float, width: float, height: float, color: ColorLike = 'black') -> Self | DrawingContext |
stroke_rect | stroke_rect(x: float, y: float, width: float, height: float, color: ColorLike = 'black', line_width: float = 1, **kwargs: Any) -> Self | DrawingContext |
fill_circle | fill_circle(cx: float, cy: float, radius: float, color: ColorLike = 'black') -> Self | DrawingContext |
stroke_circle | stroke_circle(cx: float, cy: float, radius: float, color: ColorLike = 'black', line_width: float = 1, **kwargs: Any) -> Self | DrawingContext |
fill_ellipse | fill_ellipse(x: float, y: float, width: float, height: float, color: ColorLike = 'black') -> Self | DrawingContext |
stroke_ellipse | stroke_ellipse(x: float, y: float, width: float, height: float, color: ColorLike = 'black', line_width: float = 1, **kwargs: Any) -> Self | DrawingContext |
line | line(x1: float, y1: float, x2: float, y2: float, color: ColorLike = 'black', line_width: float = 1, **kwargs: Any) -> Self | DrawingContext |
fill_text | fill_text(text: str, x: float, y: float, color: ColorLike = 'black', font_size: float = 16, **kwargs: Any) -> Self | DrawingContext |
fill_path | fill_path(points: Sequence[Tuple[float, float]], color: ColorLike = 'black', close: bool = True) -> Self | DrawingContext |
stroke_path | stroke_path(points: Sequence[Tuple[float, float]], color: ColorLike = 'black', line_width: float = 1, close: bool = False, **kwargs: Any) -> Self | DrawingContext |
arc | arc(cx: float, cy: float, radius: float, start_angle: float = 0, end_angle: float = 360, color: ColorLike = 'black', line_width: float = 1, fill: bool = False, **kwargs: Any) -> Self | DrawingContext |
rounded_rect | rounded_rect(x: float, y: float, width: float, height: float, corner_radius: float = 8, color: ColorLike = 'black', line_width: float = 1, fill: bool = True, **kwargs: Any) -> Self | DrawingContext |
gradient_rect | gradient_rect(x: float, y: float, width: float, height: float, colors: Optional[Sequence[ColorLike]] = None, vertical: bool = True) -> Self | DrawingContext |
如果直接传 commands,使用下面的公开字段:
op | 主要字段 |
|---|---|
fill_rect / stroke_rect | x、y、w、h、c、lw |
fill_circle / stroke_circle | cx、cy、r、c、lw |
fill_ellipse / stroke_ellipse | x、y、w、h、c、lw |
line | x1、y1、x2、y2、c、lw |
fill_text | t、x、y、c、fs |
fill_path / stroke_path | pts、c、close、lw |
arc | cx、cy、r、sa、ea、c、lw、fill |
rounded_rect | x、y、w、h、cr、c、lw、fill |
gradient_rect | x、y、w、h、colors、vertical |
import appui
+
+ctx = appui.DrawingContext()
+ctx.fill_rect(0, 0, 10, 10, color="systemRed")
+ctx.stroke_rect(10, 0, 10, 10, color="systemBlue", line_width=2)
+ctx.fill_circle(50, 50, 20, color="systemGreen")
+ctx.line(0, 100, 100, 100, color="systemOrange", line_width=3)
+ctx.fill_text("Hi", 5, 105, color="label", font_size=14)
+assert len(ctx.commands) == 5
+Path(commands=None, fill=None, stroke=None, line_width=None)
| API | 签名 | 分类 |
|---|---|---|
Path | Path(commands: Optional[Sequence[Dict[str, Any]]] = None, fill: Optional[ColorLike] = None, stroke: Optional[ColorLike] = None, line_width: Optional[float] = None) | drawing |
Path 命令结构:
+| 命令键 | 结构 | 行为 |
|---|---|---|
move | [x, y] | 移动当前点。 |
line | [x, y] | 添加直线。 |
curve | {"to": [x, y], "control1": [x, y], "control2": [x, y]?} | 二次或三次贝塞尔曲线。 |
arc | {"cx": x, "cy": y, "r": r, "start": deg, "end": deg, "clockwise": bool} | 弧线。 |
close | 任意真值 | 闭合路径。 |
import appui
+
+
+def body():
+ commands = [
+ {"move": [40, 10]},
+ {"line": [80, 70]},
+ {"line": [0, 70]},
+ {"close": True},
+ ]
+
+ return appui.NavigationStack(
+ appui.Path(
+ commands=commands,
+ fill="systemOrange",
+ stroke="label",
+ line_width=2,
+ )
+ .frame(width=100, height=90)
+ .padding()
+ .navigation_title("Path")
+ )
+
+
+appui.run(body)
+| API | 不同点 |
|---|---|
Chart vs Canvas | Chart 负责数据可视化和坐标轴;Canvas 只画你给的图形命令。 |
Canvas vs Path | Canvas 可以混合矩形、圆、文字、渐变;Path 专注一个可填充/描边的矢量形状。 |
Path vs Rectangle / Circle | 常见形状用形状 API;不规则图形才用 Path。 |
Canvas 命令 vs DrawingContext | 直接命令适合从数据生成;DrawingContext 适合手写绘图逻辑。 |
| 错误 | 正确做法 |
|---|---|
每次 body() 都重新生成大量静态命令。 | 静态命令放到模块级常量或函数缓存,状态变化只更新必要数据。 |
Chart 的 x / y 字段名和数据字典不匹配。 | 统一字段名,缺失值先清洗再传入。 |
用 Chart 展示非数值 y 值。 | y 对应字段应是数值。 |
Canvas(width,height) 和外层 .frame(...) 尺寸冲突。 | 两者保持一致,或明确只让外层控制显示尺寸。 |
用 Canvas 手写原生控件。 | 表单、按钮、列表、进度优先用 AppUI 原生控件。 |
Button、TextField、Toggle、Picker、Slider 等控件签名。
+本页是 Text、输入控件、选择控件、按钮和系统控件的参考。这里的示例都使用命名函数连接 action / on_change / on_submit,这样预览打开后可以确认按钮、输入和选择是否真的改变状态。
| 目标 | 首选 API | 说明 |
|---|---|---|
| 普通文字 | Text | 静态标签、标题、说明文字。 |
| 一段文字混排 | AttributedText | 同一段内需要不同颜色、字重、斜体或链接。 |
| 执行动作 | Button | 保存、删除、刷新、打开页面等命令。 |
| 顶层关闭 | CloseButton | MiniApp 自定义关闭入口,尤其是 fullscreen_with_close。 |
| 单行输入 | TextField / SecureField | 普通文本或密码输入。 |
| 多行编辑 | TextEditor | 备注、正文、长文本。 |
| 搜索 | .searchable(...) 或 SearchField | 列表页优先用 .searchable;独立搜索框用 SearchField。 |
| 布尔开关 | Toggle | 开启/关闭某个选项。 |
| 数值调节 | Slider / Stepper | 连续值用 Slider,离散整数用 Stepper。 |
| 单选 | Picker / SegmentedControl / WheelPicker | 设置项用 Picker,少量模式切换用 SegmentedControl。 |
| 日期和颜色 | DatePicker / MultiDatePicker / ColorPicker | 使用系统原生选择器。 |
| 菜单和系统按钮 | Menu / PasteButton / RenameButton / EditButton | 复用系统语义和平台样式。 |
import appui
+
+state = appui.State(
+ name="Ada",
+ enabled=True,
+ level=3,
+ theme="System",
+ note="",
+)
+
+
+def set_name(value):
+ state.name = value
+
+
+def set_enabled(value):
+ state.enabled = value
+
+
+def set_level(value):
+ state.level = value
+
+
+def set_theme(value):
+ state.theme = value
+
+
+def set_note(value):
+ state.note = value
+
+
+def reset_controls():
+ state.batch_update(
+ name="Ada",
+ enabled=True,
+ level=3,
+ theme="System",
+ note="",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("输入", [
+ appui.TextField(
+ "Name",
+ text=state.name,
+ on_change=set_name,
+ submit_label="done",
+ ),
+ appui.TextEditor(text=state.note, on_change=set_note)
+ .frame(height=80),
+ appui.Toggle("Enabled", is_on=state.enabled, on_change=set_enabled),
+ ]),
+ appui.Section("选择", [
+ appui.Slider(
+ value=state.level,
+ minimum=0,
+ maximum=10,
+ step=1,
+ label=f"Level {state.level}",
+ on_change=set_level,
+ ),
+ appui.Picker(
+ "Theme",
+ selection=state.theme,
+ options=["System", "Light", "Dark"],
+ on_change=set_theme,
+ ).picker_style("segmented"),
+ ]),
+ appui.Section("动作", [
+ appui.Button("Reset", action=reset_controls)
+ .button_style("bordered"),
+ ]),
+ ]).navigation_title("Controls")
+ )
+
+
+appui.run(body, state=state)
+| API | 签名 | 分类 |
|---|---|---|
Text | Text(content: str = '') | text |
AttributedText | AttributedText(spans: Optional[Sequence[Dict[str, Any]]] = None) | text |
Button | Button(title: Optional[Union[str, View]] = None, action: Optional[Callable] = None, role: Optional[str] = None, content: Optional[ViewChild] = None, system_image: Optional[str] = None, image: Optional[str] = None, systemImage: Optional[str] = None) | control |
CloseButton | CloseButton(title: str = '', system_image: str = 'xmark', systemImage: Optional[str] = None) | control |
import appui
+
+
+def close_page():
+ appui.dismiss()
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Text("Plain Text").font("headline"),
+ appui.AttributedText(spans=[
+ {"text": "Rich ", "font_size": 18},
+ {"text": "Text", "font_size": 18, "weight": "bold", "color": "systemBlue"},
+ {"text": " link", "italic": True, "link": "https://www.python.org"},
+ ]),
+ appui.Button(
+ appui.Label("Close", system_image="xmark.circle"),
+ action=close_page,
+ ).button_style("bordered"),
+ ], alignment="leading", spacing=12)
+ .padding()
+ .navigation_title("Text")
+ )
+
+
+appui.run(body)
+| API | 签名 | 分类 |
|---|---|---|
TextField | TextField(placeholder: str = '', text: str = '', on_change: Optional[Callable] = None, on_submit: Optional[Callable] = None, keyboard_type: Optional[str] = None, autocapitalization: Optional[str] = None, autocorrection_disabled: bool = False, submit_label: Optional[str] = None, onChange: Optional[Callable] = None, onSubmit: Optional[Callable] = None, keyboardType: Optional[str] = None, autoCapitalization: Optional[str] = None, autocorrectionDisabled: Optional[bool] = None, submitLabel: Optional[str] = None, value: Optional[str] = None, **kwargs: Any) | control |
SecureField | SecureField(placeholder: str = '', text: str = '', on_change: Optional[Callable] = None, on_submit: Optional[Callable] = None, onChange: Optional[Callable] = None, onSubmit: Optional[Callable] = None) | control |
TextEditor | TextEditor(text: str = '', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | control |
TextFieldLink | TextFieldLink(title: str = '', prompt: str = '', on_submit: Optional[Callable] = None, onSubmit: Optional[Callable] = None) | control |
SearchField | SearchField(text: str = '', placeholder: str = 'Search', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None, on_submit: Optional[Callable] = None, onSubmit: Optional[Callable] = None) | control |
keyboard_type 常用值:"default"、"emailAddress"、"numberPad"、"decimalPad"、"url"、"phonePad"。submit_label 常用值:"done"、"go"、"send"、"search"、"next"、"continue"、"return"。
| API | 签名 | 分类 |
|---|---|---|
Toggle | Toggle(label: str = '', is_on: bool = False, on_change: Optional[Callable] = None, isOn: Optional[bool] = None, onChange: Optional[Callable] = None, value: Optional[bool] = None) | control |
Slider | Slider(value: float = 0.0, minimum: float = 0.0, maximum: float = 1.0, step: Optional[float] = None, label: str = '', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None, min_value: Optional[float] = None, max_value: Optional[float] = None, minValue: Optional[float] = None, maxValue: Optional[float] = None) | control |
Stepper | Stepper(label: str = '', value: int = 0, minimum: int = 0, maximum: int = 100, step: int = 1, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | control |
Picker | Picker(label: str = '', selection: Optional[str] = None, options: Optional[Sequence[str]] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | control |
SegmentedControl | SegmentedControl(options: Optional[Sequence[str]] = None, selection: Optional[str] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | control |
InlinePickerStyle | InlinePickerStyle(options: Optional[Sequence[str]] = None, selection: Optional[str] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | control |
WheelPicker | WheelPicker(options: Optional[Sequence[str]] = None, selection: Optional[str] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | control |
DatePicker | DatePicker(label: str = '', selection: Optional[str] = None, components: str = 'date', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | control |
MultiDatePicker | MultiDatePicker(title: str = '', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | control |
ColorPicker | ColorPicker(label: str = '', selection: Optional[str] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | control |
| API | 签名 | 分类 |
|---|---|---|
Menu | Menu(title: str = '', content: Optional[Sequence[View]] = None, children: Optional[Sequence[View]] = None, system_image: Optional[str] = None, image: Optional[str] = None, systemImage: Optional[str] = None) | control |
PasteButton | PasteButton(on_paste: Optional[Callable] = None, onPaste: Optional[Callable] = None) | control |
RenameButton | RenameButton(action: Optional[Callable] = None) | control |
EditButton | EditButton() | control |
SignInWithAppleButton | SignInWithAppleButton(type: str = 'signIn', on_complete: Optional[Callable] = None, onComplete: Optional[Callable] = None) | control |
Button 和 Menu 都支持直接传 system_image(SF Symbol 名)来显示图标,无需再包一层 content=appui.Label(...):
title 和 system_image → 图标 + 文字;只给 system_image → 纯图标。Menu 用 system_image 可以做工具栏右上角的「ellipsis.circle 溢出菜单」。content= 传任意视图作为完全自定义的标签;content 优先级高于 system_image。import appui
+
+
+def noop():
+ pass
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Button("收藏", action=noop, system_image="bookmark"),
+ appui.Button(action=noop, system_image="arrow.clockwise"),
+ ], alignment="leading", spacing=12)
+ .padding()
+ .navigation_title("Icons")
+ .toolbar([
+ appui.ToolbarItem(
+ placement="navigation_bar_trailing",
+ content=appui.Menu(system_image="ellipsis.circle", content=[
+ appui.Button("全部", action=noop, system_image="checkmark"),
+ appui.Button("收藏", action=noop),
+ ]),
+ ),
+ ])
+ )
+
+
+appui.run(body)
+| API | 不同点 |
|---|---|
TextField vs TextEditor | TextField 是单行输入,支持提交键;TextEditor 是多行编辑区域,通常需要明确 .frame(height=...)。 |
SearchField vs .searchable(...) | SearchField 是普通视图;.searchable(...) 会接入导航栏搜索体验,列表页更自然。 |
Picker vs SegmentedControl | Picker 可切换不同样式;SegmentedControl 是固定分段控件,适合少量模式。 |
Slider vs Stepper | Slider 适合连续数值;Stepper 适合精确整数或固定步进。 |
Button vs Menu | Button 立即执行单个动作;Menu 先展开命令列表。 |
Button vs CloseButton | CloseButton 带关闭语义,系统可以识别并避免重复注入关闭按钮。 |
| 错误 | 正确做法 |
|---|---|
| 在示例里写内联匿名回调,预览里很难读也不利于复用。 | 定义命名函数,例如 def save(): ...,再传 action=save。 |
TextField 只传 text=state.name,没有 on_change。 | 在 on_change(value) 中写回 state.name = value。 |
Slider 没有设置 minimum / maximum,默认范围和业务不一致。 | 显式写清范围和 step。 |
TextEditor 不设高度。 | 用 .frame(height=...) 给多行编辑区域稳定尺寸。 |
List、ForEach、Form、Section、Table、ProgressView 等数据展示视图。
+本页覆盖列表、表单、分组、表格、空状态、进度、链接、角标、时间刷新和富文本。数据展示页面要优先使用系统结构:列表用 List + Section + ForEach,设置页用 Form + Section,不要用普通 VStack 手写整套表单外观。
| 目标 | 首选 API | 说明 |
|---|---|---|
| 行列表 | List + ForEach | 可滚动、可分组、可加滑动操作。 |
| 设置页 | Form + Section | 原生设置样式,适合输入控件和开关。 |
| 标题分组 | Section | 用 header / footer 表达分组标题和说明。 |
| 折叠内容 | DisclosureGroup | 可展开详情、更多设置。 |
| 标签值行 | LabeledContent | 版本号、状态、统计值。 |
| 多列表格 | Table | iPad 或大屏数据表;iPhone 会按系统能力降级。 |
| 空状态 | ContentUnavailableView | 无数据、无搜索结果、加载失败。 |
| 进度 | ProgressView / Gauge | 加载状态、百分比、容量。 |
| 链接和分享 | Link / ShareLink | 打开系统浏览器或分享面板。 |
| 周期刷新文字 | TimelineView | 时钟、倒计时等低频时间展示。 |
| 富文本 | AttributedText | 同一段文本内混合颜色、字重、斜体和链接。 |
import appui
+
+state = appui.State(
+ items=[
+ {"id": "a", "title": "Alpha", "done": False},
+ {"id": "b", "title": "Beta", "done": True},
+ ],
+ selected="",
+)
+
+
+def item_key(item):
+ return item["id"]
+
+
+def select_item(item):
+ state.selected = item["title"]
+
+
+def row_view(item):
+ def choose():
+ select_item(item)
+
+ icon = "checkmark.circle.fill" if item["done"] else "circle"
+ return appui.Button(
+ action=choose,
+ content=appui.Label(item["title"], system_image=icon),
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Items", [
+ appui.ForEach(state.items, row_builder=row_view, key=item_key)
+ ]),
+ appui.Section("Selection", [
+ appui.LabeledContent("Selected", value=state.selected or "None"),
+ ]),
+ ]).navigation_title("Data")
+ )
+
+
+appui.run(body, state=state)
+| API | 签名 | 分类 |
|---|---|---|
List | List(content: Optional[Sequence[View]] = None) | collection |
ForEach | ForEach(data: Any, row_builder: Optional[Callable] = None, key: Optional[Callable] = None, rowBuilder: Optional[Callable] = None, content: Optional[Callable] = None) | collection |
Form | Form(content: Optional[Sequence[View]] = None) | collection |
Section | Section(content: Optional[ViewChild] = None, *, header: Optional[Union[str, View]] = None, footer: Optional[Union[str, View]] = None, children: Optional[ViewChild] = None, key: Optional[str] = None) | collection |
import appui
+
+state = appui.State(notify=True, email="ada@example.com")
+
+
+def set_notify(value):
+ state.notify = value
+
+
+def set_email(value):
+ state.email = value
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Account", [
+ appui.TextField(
+ "Email",
+ text=state.email,
+ on_change=set_email,
+ keyboard_type="emailAddress",
+ ),
+ appui.Toggle("Notifications", is_on=state.notify, on_change=set_notify),
+ ], footer="Used for account notifications."),
+ ]).navigation_title("Form")
+ )
+
+
+appui.run(body, state=state)
+| API | 签名 | 分类 |
|---|---|---|
GroupBox | GroupBox(label: Optional[str] = None, content: Optional[ViewChild] = None, children: Optional[Sequence[View]] = None) | collection |
DisclosureGroup | DisclosureGroup(label: str = '', is_expanded: Optional[bool] = None, content: Optional[ViewChild] = None, isExpanded: Optional[bool] = None, children: Optional[ViewChild] = None) | collection |
LabeledContent | LabeledContent(label: str = '', value: Optional[str] = None, content: Optional[View] = None) | collection |
ControlGroup | ControlGroup(label: str = '', content: Optional[Sequence[View]] = None, children: Optional[Sequence[View]] = None) | control |
import appui
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Summary", [
+ appui.LabeledContent("Version", value="1.0"),
+ appui.LabeledContent("Status", content=appui.Badge(text="New")),
+ ]),
+ appui.Section("Details", [
+ appui.DisclosureGroup(
+ "Advanced",
+ is_expanded=False,
+ content=[
+ appui.GroupBox(
+ "Cache",
+ content=[appui.Text("Local cache is enabled.")],
+ )
+ ],
+ )
+ ]),
+ ]).navigation_title("Groups")
+ )
+
+
+appui.run(body)
+Table(data=None, columns=None, on_select=None, onSelect=None)
| 参数 | 类型 | 说明 |
|---|---|---|
data | list[dict] | 表格行。 |
columns | list[dict] | 每列字典至少包含 title 和 key。 |
on_select | Callable / None | 选中行回调,接收行字典。 |
import appui
+
+state = appui.State(selected="")
+rows = [
+ {"id": 1, "name": "Ada", "role": "Admin"},
+ {"id": 2, "name": "Lin", "role": "Editor"},
+]
+
+
+def select_row(row):
+ state.selected = row["name"]
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Table(
+ data=rows,
+ columns=[
+ {"title": "ID", "key": "id"},
+ {"title": "Name", "key": "name"},
+ {"title": "Role", "key": "role"},
+ ],
+ on_select=select_row,
+ ),
+ appui.Text(state.selected or "No selection")
+ .font("footnote")
+ .foreground_color("secondaryLabel"),
+ ], spacing=12)
+ .padding()
+ .navigation_title("Table")
+ )
+
+
+appui.run(body, state=state)
+| API | 签名 | 分类 |
|---|---|---|
ContentUnavailableView | ContentUnavailableView(title: str = '', system_image: Optional[str] = None, description: Optional[str] = None, systemImage: Optional[str] = None) | presentation |
ProgressView | ProgressView(label: Optional[str] = None, value: Optional[float] = None, total: float = 1.0) | feedback |
Link | Link(title: str = '', url: str = '') | control |
Gauge | Gauge(value: float = 0.0, min_value: float = 0.0, max_value: float = 1.0, label: str = '', minValue: Optional[float] = None, maxValue: Optional[float] = None) | feedback |
ShareLink | ShareLink(item: str = '', subject: Optional[str] = None, message: Optional[str] = None) | control |
Badge | Badge(count: Optional[int] = None, text: Optional[str] = None) | presentation |
import appui
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Status", [
+ appui.ProgressView(label="Loading"),
+ appui.ProgressView(label="Progress", value=0.4, total=1.0)
+ .progress_view_style("linear"),
+ appui.Gauge(value=0.72, min_value=0.0, max_value=1.0, label="Storage")
+ .gauge_style("accessory_circular"),
+ ]),
+ appui.Section("Links", [
+ appui.Link(title="Apple", url="https://www.apple.com"),
+ appui.ShareLink(item="Shared from AppUI", subject="AppUI", message="Hello"),
+ ]),
+ appui.Section("Empty", [
+ appui.ContentUnavailableView(
+ title="No Results",
+ system_image="magnifyingglass",
+ description="Try another keyword.",
+ ),
+ ]),
+ ]).navigation_title("Status")
+ )
+
+
+appui.run(body)
+TimelineView(interval=1.0, content=None) 会按间隔刷新它的子视图,适合时钟、轻量倒计时等低频时间展示。content 是一个 View,不是视图列表。
import appui
+import time
+
+
+def clock_text():
+ return appui.Text(time.strftime("%H:%M:%S")).font("title")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.TimelineView(interval=1.0, content=clock_text())
+ .padding()
+ .navigation_title("Clock")
+ )
+
+
+appui.run(body)
+AttributedText(spans=None) 用 span 字典描述富文本。
| 键 | 类型 | 说明 |
|---|---|---|
text | str | 显示文字。 |
font_size | float | 字号。 |
weight | str | 字重,例如 "bold"、"semibold"、"regular"。 |
bold | bool | 是否加粗。 |
italic | bool | 是否斜体。 |
color | str | 颜色,例如 "systemBlue"、"#FF0000"。 |
link | str | 链接 URL。 |
underline | bool | 下划线。 |
strikethrough | bool | 删除线。 |
import appui
+
+
+def body():
+ return appui.NavigationStack(
+ appui.AttributedText(spans=[
+ {"text": "AppUI ", "font_size": 20, "weight": "bold"},
+ {"text": "rich text", "font_size": 20, "color": "systemBlue"},
+ {"text": " with link", "link": "https://www.python.org", "underline": True},
+ ])
+ .padding()
+ .navigation_title("Rich Text")
+ )
+
+
+appui.run(body)
+| API | 不同点 |
|---|---|
List vs Form | List 展示数据行;Form 展示设置和输入。 |
Section vs GroupBox | Section 是列表/表单分组;GroupBox 是内容盒。 |
ProgressView vs Gauge | ProgressView 强调加载或进度;Gauge 强调数值状态和容量。 |
Badge vs .badge(...) | Badge 是一个独立视图;.badge(...) 给支持角标的位置添加角标。 |
Link vs WebView | Link 打开外部浏览器;WebView 在 AppUI 内显示网页。 |
TimelineView vs Timer | TimelineView 刷新视图显示;Timer 运行状态更新逻辑。 |
| 错误 | 正确做法 |
|---|---|
ForEach 没有稳定键,列表刷新后行状态错位。 | 传 key 函数,或保证数据里有稳定 id / key。 |
在 Section 里再嵌套完整 List。 | 外层用一个 List,里面放多个 Section。 |
把设置页写成 VStack + Text + Toggle。 | 用 Form([Section(...)])。 |
TimelineView 的 content 传列表。 | 传一个 View,多视图时包进 VStack。 |
ProgressView(label=appui.Text(...))。 | label 传字符串;自定义说明放在相邻 Text。 |
environment_value、color_scheme、locale、layout_direction 和动态字体。
+environment_value(key, value) 用来设置少量通用环境值。它是一个通用入口;如果已经有更直接的专用修饰符,优先用专用修饰符。
environment_value(key, value)
+locale、layout_direction、dynamic_type_size 这类没有独立高频包装的环境项。| key | 值 | 作用 | 更直接的写法 |
|---|---|---|---|
color_scheme | light / dark | 强制明暗模式。 | preferred_color_scheme(...) |
layout_direction | left_to_right / right_to_left | 控制布局方向。 | 无 |
locale | 语言地区标识,如 zh_CN、en_US | 影响日期、数字、本地化格式。 | 无 |
line_spacing | 数字,如 4、6 | 设置行距。 | 无 |
multiline_text_alignment | leading / center / trailing | 多行文本对齐。 | multiline_text_alignment(...) |
allow_tight_spacing | True / False | 是否允许文字收紧间距。 | 无 |
truncation_mode | tail / head / middle | 文本截断位置。 | truncation_mode(...) |
dynamic_type_size | xSmall、small、medium、large、xLarge、xxLarge、xxxLarge、accessibility1 到 accessibility5 | 动态字体等级。 | 无 |
redaction | placeholder / none | 占位式骨架屏或取消 redaction。 | 无 |
autocorrection | True / False | 控制自动纠错;对输入控件更有意义。 | 对 TextField / SecureField 优先用构造参数 |
text_case | uppercase / lowercase / none | 统一大小写风格。 | 无 |
import appui
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Text("环境值示例"),
+ appui.Text("这段文字会使用更大的动态字体和更宽的行距。")
+ .line_limit(2),
+ ], spacing=12)
+ .padding()
+ .navigation_title("Environment")
+ .environment_value("locale", "zh_CN")
+ .environment_value("dynamic_type_size", "xLarge")
+ .environment_value("line_spacing", 6)
+ )
+
+
+appui.run(body)
+这些场景优先用专用 API,不要为了“统一写法”强行全部塞进 environment_value:
preferred_color_scheme("dark")multiline_text_alignment("leading")truncation_mode("middle")TextField(..., autocorrection_disabled=...)environment_value 更适合“给一整段视图树施加环境”,不是每个修饰符都拿它代替。dynamic_type_size 依赖较新的系统版本;过低系统可能不会生效。autocorrection 的实际体验最稳的方式仍然是直接在输入控件构造参数里声明。run、dismiss、animate、bind、grid item 等模块级函数。
+本页只查 appui 的模块级入口函数和网格辅助函数。页面结构、控件、导航和媒体视图分别看对应 API 参考。
| 目标 | API |
|---|---|
| 启动 AppUI 页面 | run |
| 关闭当前 AppUI 呈现 | dismiss |
| 包一组状态变化动画 | animate |
| 简单周期刷新 | auto_refresh |
| 降低首次启动延迟 | preload |
| 为值控件创建双向绑定 | bind |
| 声明派生数据 | computed |
| 状态变化后触发副作用 | effect |
| 定义网格列/行 | flexible、fixed、adaptive、grid_item |
| 使用自定义字体 | custom_font |
run 只在脚本末尾调用一次。bind 适合 Slider 这类 value + on_change 控件。网格列用 flexible、adaptive 或 fixed。
import appui
+
+state = appui.State(volume=0.4, selected="Volume")
+
+
+def select_card(name):
+ state.selected = name
+
+
+def card(name, value):
+ def select_current():
+ select_card(name)
+
+ return (
+ appui.Button(
+ action=select_current,
+ content=appui.VStack([
+ appui.Text(name).font("caption").foreground_color("secondaryLabel"),
+ appui.Text(value).font("title3").bold(),
+ ], alignment="leading", spacing=4),
+ )
+ .button_style("plain")
+ .frame(max_width=appui.infinity, min_height=84, alignment="leading")
+ .padding(12)
+ .background("secondarySystemBackground", corner_radius=8)
+ )
+
+
+@appui.computed(state, depends_on=["volume"])
+def volume_text():
+ return f"{state.volume:.0%}"
+
+
+def body():
+ grid = appui.LazyVGrid(
+ columns=[appui.flexible(minimum=120), appui.flexible(minimum=120)],
+ spacing=12,
+ content=[
+ card("Volume", volume_text()),
+ card("Selected", state.selected),
+ ],
+ )
+
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("bind", [
+ appui.Slider(**appui.bind(state, "volume"), minimum=0, maximum=1),
+ ]),
+ appui.Section("Grid helpers", [
+ grid,
+ ]),
+ ]).navigation_title("Functions")
+ )
+
+
+appui.run(body, state=state)
+| API | 签名 |
|---|---|
run | run(body_func: Optional[Union[Callable[[], View], Callable[[Union[State, ReactiveState, None]], View]]] = None, state: Optional[Union[State, ReactiveState]] = None, hot_reload: bool = False, presentation: str = 'sheet', body: Optional[Union[Callable[[], View], Callable[[Union[State, ReactiveState, None]], View]]] = None) -> None |
dismiss | dismiss() -> None |
presentation_present | presentation_present(field_name: str, *, state: Optional[Union[State, ReactiveState]] = None, value: bool = True) -> bool |
presentation_dismiss | presentation_dismiss(field_name: str, *, state: Optional[Union[State, ReactiveState]] = None) -> bool |
presentation_dismiss_all | presentation_dismiss_all(*, state: Optional[Union[State, ReactiveState]] = None) -> bool |
dismiss_all | dismiss_all(*, state: Optional[Union[State, ReactiveState]] = None) -> bool |
animate | animate(action: Callable[[], None], type: str = 'default') -> None |
auto_refresh | auto_refresh(interval: float = 1.0) -> None |
preload | preload() -> None |
bind | bind(state: Union[State, ReactiveState], field_name: str, *, parse: Optional[Callable[[Any], Any]] = None, format: Optional[Callable[[Any], Any]] = None, validate: Optional[Callable[[Any], bool]] = None) -> Dict[str, Any] |
computed | computed(state: Union[State, ReactiveState], depends_on: Sequence[str]) -> Callable |
effect | effect(state: Union[State, ReactiveState], depends_on: Sequence[str]) -> Callable |
flexible | flexible(minimum: float = 10, maximum: Optional[float] = None) -> Dict[str, Any] |
fixed | fixed(size: float) -> Dict[str, Any] |
adaptive | adaptive(minimum: float = 50, maximum: Optional[float] = None) -> Dict[str, Any] |
grid_item | grid_item(type: str = 'flexible', minimum: Optional[float] = None, maximum: Optional[float] = None, count: Optional[int] = None) -> Dict[str, Any] |
custom_font | custom_font(name: str, size: float = 17) -> str |
| API | 用法边界 |
|---|---|
bind vs on_change | Slider 这类 value 控件可以用 bind;TextField、Toggle、Picker 通常直接传当前值和 on_change。 |
computed vs 手动字段 | 派生数据用 computed,不要维护第二份 state.filtered_items。 |
effect vs body() 副作用 | 状态变化后的日志或同步用 effect;不要在 body() 里做副作用。 |
auto_refresh vs Timer | 简单原型可用 auto_refresh;正式页面用模块级 Timer 或明确回调。 |
flexible vs adaptive | 固定列数用多个 flexible;根据宽度自动改变列数用 adaptive。 |
| 错误 | 后果 | 修正 |
|---|---|---|
在按钮回调里再次调用 run | 运行时状态混乱 | run 只在脚本末尾调用一次。 |
在 body() 里调用 dismiss() | 页面构建时直接关闭 | 放到按钮或工具栏回调里。 |
把 bind 传给 TextField.text | 参数类型不对 | TextField(text=state.name, on_change=set_name)。 |
用 auto_refresh 做复杂业务轮询 | 刷新不可控 | 使用 Timer、后台任务或明确刷新按钮。 |
| 普通页面绕过公开 API 更新界面 | 可维护性差 | 先查公开 AppUI API。 |
GeometryReader、ViewThatFits、Group、Overlay 和 SafeAreaInset。
+GeometryReader / ViewThatFits / Group / Overlay / SafeAreaInset。
+签名
+GeometryReader(content=None, on_change=None, onChange=None,
+ children=None, on_geometry=None, onGeometry=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content / children | View \| list[View] \| None | 填充可用区域的内容。 |
on_change / onChange / on_geometry | 可调用 \| None | 尺寸变化时触发。默认传入单参数:"宽度,高度" 形式的字符串(如 "390.0,844.0")。若回调在签名上接受两个必选位置参数,则运行时会拆分为 (width: float, height: float) 调用。 |
示例
+import appui
+
+state = appui.State(w=0.0, h=0.0)
+
+
+def update_geometry(width, height):
+ state.batch_update(w=float(width), h=float(height))
+
+
+def body():
+ return appui.GeometryReader(
+ content=appui.VStack([
+ appui.Text(f"{state.w:.0f} × {state.h:.0f}").font("title3"),
+ ]),
+ on_change=update_geometry,
+ ).padding()
+
+appui.run(body, state=state, presentation="sheet")
+签名
+ViewThatFits(content=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content | list[View] \| None | 按顺序尝试子视图,采用第一个可在当前约束下布局成功的方案。 |
示例
+import appui
+
+def body():
+ return appui.ViewThatFits([
+ appui.HStack([appui.Text("宽屏一行标题")]),
+ appui.VStack([appui.Text("窄屏"), appui.Text("两行标题")]),
+ ]).padding()
+
+appui.run(body, presentation="sheet")
+签名
+Group(content=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content | list[View] \| None | 透明容器,不参与自身布局,用于组合或修饰器作用域。 |
示例
+import appui
+
+def body():
+ return appui.VStack([
+ appui.Group([
+ appui.Text("A"),
+ appui.Text("B"),
+ ]),
+ ], spacing=4).padding()
+
+appui.run(body, presentation="sheet")
+签名
+Overlay(content=None, overlay=None, alignment='center')
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content | View \| None | 承载视图。 |
overlay | View \| None | 叠放在上的视图。 |
alignment | str | 与 ZStack 相同的对齐关键字。 |
示例
+import appui
+
+def body():
+ return appui.Overlay(
+ content=appui.Image(system_name="bell"),
+ overlay=appui.Text("3").font("caption2").padding(4),
+ alignment="topTrailing",
+ ).padding()
+
+appui.run(body, presentation="sheet")
+签名
+SafeAreaInset(edge='bottom', content=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
edge | str | 嵌入安全区的一侧,如 bottom。 |
content | View \| None | 持久显示的条带内容。 |
示例
+import appui
+
+def body():
+ return appui.VStack([
+ appui.Text("主内容区域").frame(max_height=appui.infinity),
+ appui.SafeAreaInset(
+ edge="bottom",
+ content=appui.Text("底部工具条").padding(),
+ ),
+ ])
+
+appui.run(body, presentation="sheet")
+参阅:VStack、ScrollView
+LazyVGrid、LazyHGrid、Grid、GridRow 和轨道辅助函数。
+LazyVGrid / LazyHGrid / Grid / GridRow 与轨道辅助函数 flexible / fixed / adaptive / grid_item。
+签名
+LazyVGrid(columns=None, content=None, spacing=None, children=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
columns | list[dict] \| None | 列描述列表;缺省为 [{'type': 'flexible'}]。 |
content / children | list[View] \| None | 网格单元视图。 |
spacing | 数值 \| None | 单元间距。 |
示例
+import appui
+
+cols = [
+ appui.flexible(minimum=40),
+ appui.flexible(minimum=40),
+ appui.fixed(50),
+]
+
+def body():
+ return appui.LazyVGrid(
+ columns=cols,
+ spacing=8,
+ content=[appui.Text(str(i)).frame(max_width=appui.infinity) for i in range(12)],
+ ).padding()
+
+appui.run(body, presentation="sheet")
+参阅:flexible、fixed、adaptive、LazyHGrid
+签名
+LazyHGrid(rows=None, content=None, spacing=None, children=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
rows | list[dict] \| None | 行描述;缺省为 [{'type': 'flexible'}]。 |
content / children | list[View] \| None | 子视图。 |
spacing | 数值 \| None | 间距。 |
示例
+import appui
+
+rows = [appui.flexible(), appui.fixed(36)]
+
+def body():
+ return appui.LazyHGrid(
+ rows=rows,
+ spacing=6,
+ content=[appui.Text(f"({i})") for i in range(8)],
+ ).padding()
+
+appui.run(body, presentation="sheet")
+签名
+Grid(content=None, alignment='center', horizontal_spacing=None, vertical_spacing=None,
+ horizontalSpacing=None, verticalSpacing=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content | list[View] \| None | 通常由若干 GridRow 组成。 |
alignment | str | 单元格对齐。 |
horizontal_spacing / vertical_spacing | 数值 \| None | 行/列间距。 |
示例
+import appui
+
+def body():
+ return appui.Grid(
+ content=[
+ appui.GridRow([appui.Text("A1"), appui.Text("B1")]),
+ appui.GridRow([appui.Text("A2"), appui.Text("B2")]),
+ ],
+ horizontal_spacing=12,
+ vertical_spacing=8,
+ ).padding()
+
+appui.run(body, presentation="sheet")
+签名
+GridRow(content=None, alignment=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content | list[View] \| None | 一行中的单元视图。 |
alignment | str \| None | 行内对齐;None 表示默认。 |
示例
+见 Grid。
+ +签名
+flexible(minimum=10, maximum=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
minimum | 数值 | 轨道最小尺寸。 |
maximum | 数值 \| None | 最大尺寸;None 表示不限制。 |
示例
+import appui
+
+row = [appui.flexible(minimum=60), appui.fixed(44)]
+print(row[0]["type"], row[1]["type"])
+签名
+fixed(size)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
size | 数值 | 固定轨道尺寸。 |
示例
+import appui
+
+c = appui.fixed(120)
+assert c["type"] == "fixed"
+签名
+adaptive(minimum=50, maximum=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
minimum | 数值 | 每个自适应单元最小宽度。 |
maximum | 数值 \| None | 可选上限。 |
示例
+import appui
+
+cols = [appui.adaptive(minimum=80)]
+print(len(cols), cols[0]["type"])
+签名
+grid_item(type='flexible', minimum=None, maximum=None, count=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
type | str | 轨道类型:'flexible'、'fixed'、'adaptive'。 |
minimum | 数值 \| None | 轨道最小尺寸。 |
maximum | 数值 \| None | 轨道最大尺寸;None 表示不限制。 |
count | int \| None | 用于 adaptive 类型时的列数提示。 |
用途
+通用网格轨道描述函数,返回 dict;与 flexible、fixed、adaptive 功能等价,适合动态生成列/行描述。
import appui
+
+cols = [appui.grid_item('flexible', minimum=60), appui.grid_item('fixed', minimum=44)]
+print(cols)
+ScrollView、ScrollViewReader、滚动方向和定位锚点。
+ScrollView / ScrollViewReader。
+签名
+ScrollView(content=None, axes='vertical', shows_indicators=True, showsIndicators=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content | list[View] \| View \| None | 可滚动区域;支持 with ScrollView(): 收集子视图。 |
axes | str | vertical、horizontal 或 both。 |
shows_indicators | bool | 是否显示滚动指示条。 |
示例
+import appui
+
+def body():
+ with appui.ScrollView(axes="vertical") as sc:
+ for i in range(25):
+ appui.Text(f"段落 {i}").padding(horizontal=4)
+ return sc.padding()
+
+appui.run(body, presentation="sheet")
+参阅:ScrollViewReader、LazyVStack
+签名
+ScrollViewReader(content=None, axes='vertical', shows_indicators=True,
+ scroll_to=None, anchor='top', showsIndicators=None, scrollTo=None,
+ children=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content / children | list[View] \| None | 滚动内容。 |
axes | str | 同 ScrollView。 |
shows_indicators | bool | 是否显示指示器。 |
scroll_to / scrollTo | 任意 \| None | 初始或受控滚动目标,需与子视图 .id(...) 对应。 |
anchor | str | 滚动对齐锚点,如 top。 |
示例
+import appui
+
+def body():
+ return appui.ScrollViewReader(
+ content=[
+ appui.Text("顶部").id("top"),
+ appui.Spacer(min_length=400),
+ appui.Text("底部锚点").id("bottom"),
+ ],
+ axes="vertical",
+ scroll_to="bottom",
+ anchor="top",
+ ).padding()
+
+appui.run(body, presentation="sheet")
+参阅:ScrollView、Spacer
+VStack、HStack、ZStack、Lazy stacks、Spacer 和 Divider。
+VStack / HStack / ZStack / LazyVStack / LazyHStack / Spacer / Divider。
+签名
+VStack(content=None, alignment='center', spacing=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content | list[View] \| None | 子视图列表;亦可配合上下文管理器收集子级。 |
alignment | str | 横轴对齐(如 center、leading、trailing 等,由运行时映射)。 |
spacing | 数值 \| None | 子视图间距;None 为系统默认。 |
示例
+import appui
+
+def body():
+ with appui.VStack(spacing=12) as root:
+ appui.Text("标题").font("title2").bold()
+ appui.Text("说明文案").font("body").foreground_color("secondaryLabel")
+ return root.padding()
+
+appui.run(body, presentation="sheet")
+签名
+HStack(content=None, alignment='center', spacing=None)
+参数
+与 VStack 相同;对齐沿纵轴解释。
+示例
+import appui
+
+def body():
+ return appui.HStack([
+ appui.Image(system_name="star.fill").foreground_color("systemYellow"),
+ appui.Text("收藏").font("headline"),
+ appui.Spacer(),
+ appui.Text("详情").font("subheadline"),
+ ], spacing=8).padding()
+
+appui.run(body, presentation="sheet")
+签名
+ZStack(content=None, alignment='center')
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
content | list[View] \| None | 层叠子视图,后者绘制在上层。 |
alignment | str | center、leading、trailing、top、bottom、topLeading、topTrailing、bottomLeading、bottomTrailing。 |
示例
+import appui
+
+def body():
+ return appui.ZStack([
+ appui.RoundedRectangle(corner_radius=16).fill("systemBlue").frame(width=200, height=120),
+ appui.VStack([
+ appui.Text("前景").foreground_color("white").bold(),
+ appui.Text("叠放").font("caption").foreground_color("white"),
+ ]),
+ ], alignment="center").padding()
+
+appui.run(body, presentation="sheet")
+签名
+LazyVStack(content=None, alignment='center', spacing=None)
+参数
+同 VStack。惰性创建子项,适合长列表中的纵向堆叠。
+示例
+import appui
+
+def body():
+ return appui.ScrollView(
+ appui.LazyVStack(
+ content=[appui.Text(f"行 {i}") for i in range(40)],
+ spacing=6,
+ ),
+ axes="vertical",
+ )
+
+appui.run(body, presentation="sheet")
+签名
+LazyHStack(content=None, alignment='center', spacing=None)
+参数
+同 LazyVStack,轴向为水平。
+示例
+import appui
+
+def body():
+ return appui.ScrollView(
+ appui.LazyHStack(
+ content=[appui.Text(f"·{i}") for i in range(30)],
+ spacing=10,
+ ),
+ axes="horizontal",
+ shows_indicators=True,
+ )
+
+appui.run(body, presentation="sheet")
+参阅:HStack、ScrollView
+签名
+Spacer(min_length=None, minLength=None)
+参数
+| 参数 | 类型 | 说明 |
|---|---|---|
min_length / minLength | 数值 \| None | 在所在堆栈中占据弹性空白的最小长度。 |
示例
+import appui
+
+def body():
+ return appui.HStack([
+ appui.Text("左"),
+ appui.Spacer(min_length=24),
+ appui.Text("右"),
+ ]).padding()
+
+appui.run(body, presentation="sheet")
+签名
+Divider()
+参数
+无参数构造。
+示例
+import appui
+
+def body():
+ return appui.VStack([
+ appui.Text("上"),
+ appui.Divider(),
+ appui.Text("下"),
+ ], spacing=8).padding()
+
+appui.run(body, presentation="sheet")
+Stack、ScrollView、List/Form、Grid 和 GeometryReader。
+本页查 Stack、ScrollView、List/Form、Grid、GeometryReader 和数据展示容器的签名。怎么选择布局结构见 布局系统。
+| 分篇 | 适合查询 |
|---|---|
| 堆叠布局 API | VStack、HStack、ZStack、LazyVStack、LazyHStack、Spacer、Divider。 |
| 滚动 API | ScrollView、ScrollViewReader、滚动方向、初始定位和锚点。 |
| 网格 API | LazyVGrid、LazyHGrid、Grid、GridRow、flexible、fixed、adaptive、grid_item。 |
| 几何与特殊布局 API | GeometryReader、ViewThatFits、Group、Overlay、SafeAreaInset。 |
| 环境值 API | environment_value、preferred_color_scheme、locale、layout_direction、动态字体和文本环境。 |
| 目标 | API |
|---|---|
| 纵向/横向组合 | VStack、HStack |
| 层叠覆盖 | ZStack、Overlay |
| 自定义滚动内容 | ScrollView、LazyVStack |
| 程序化滚动 | ScrollViewReader |
| 卡片网格 | LazyVGrid、LazyHGrid、Grid |
| 原生动态列表 | List、ForEach |
| 设置和编辑页 | Form、Section |
| 空状态和进度 | ContentUnavailableView、ProgressView |
| 大屏表格 | Table |
同一页面可以同时使用 Form、List、LazyVGrid,但要让每个容器负责自己擅长的结构。
import appui
+
+cards = [
+ {"id": "a", "title": "Alpha"},
+ {"id": "b", "title": "Beta"},
+ {"id": "c", "title": "Gamma"},
+]
+
+
+def card_key(item):
+ return item["id"]
+
+
+def card_view(item):
+ return (
+ appui.Text(item["title"])
+ .frame(max_width=appui.infinity, min_height=72)
+ .background("secondarySystemBackground", corner_radius=8)
+ )
+
+
+def body():
+ grid = appui.LazyVGrid(
+ columns=[appui.adaptive(minimum=120)],
+ spacing=12,
+ content=[appui.ForEach(cards, row_builder=card_view, key=card_key)],
+ )
+
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Form", [
+ appui.LabeledContent("Container", value="Form"),
+ ]),
+ appui.Section("Grid", [
+ grid,
+ ]),
+ ]).navigation_title("Layout")
+ )
+
+
+appui.run(body)
+| API | 签名 | 分类 |
|---|---|---|
VStack | VStack(content: Optional[Sequence[View]] = None, alignment: str = 'center', spacing: Optional[float] = None) | layout |
HStack | HStack(content: Optional[Sequence[View]] = None, alignment: str = 'center', spacing: Optional[float] = None) | layout |
ZStack | ZStack(content: Optional[Sequence[View]] = None, alignment: str = 'center') | layout |
LazyVStack | LazyVStack(content: Optional[Sequence[View]] = None, alignment: str = 'center', spacing: Optional[float] = None) | layout |
LazyHStack | LazyHStack(content: Optional[Sequence[View]] = None, alignment: str = 'center', spacing: Optional[float] = None) | layout |
ScrollView | ScrollView(content: Optional[ViewChild] = None, axes: str = 'vertical', shows_indicators: bool = True, showsIndicators: Optional[bool] = None) | layout |
ScrollViewReader | ScrollViewReader(content: Optional[ViewChild] = None, axes: str = 'vertical', shows_indicators: bool = True, scroll_to: Optional[str] = None, anchor: str = 'top', showsIndicators: Optional[bool] = None, scrollTo: Optional[str] = None, children: Optional[ViewChild] = None) | layout |
LazyVGrid | LazyVGrid(columns: Optional[Sequence[dict]] = None, content: Optional[Sequence[View]] = None, spacing: Optional[float] = None, children: Optional[Sequence[View]] = None) | layout |
LazyHGrid | LazyHGrid(rows: Optional[Sequence[dict]] = None, content: Optional[Sequence[View]] = None, spacing: Optional[float] = None, children: Optional[Sequence[View]] = None) | layout |
Grid | Grid(content: Optional[Sequence[View]] = None, alignment: str = 'center', horizontal_spacing: Optional[float] = None, vertical_spacing: Optional[float] = None, horizontalSpacing: Optional[float] = None, verticalSpacing: Optional[float] = None) | layout |
GridRow | GridRow(content: Optional[Sequence[View]] = None, alignment: Optional[str] = None) | layout |
GeometryReader | GeometryReader(content: Optional[ViewChild] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None, children: Optional[ViewChild] = None, on_geometry: Optional[Callable] = None, onGeometry: Optional[Callable] = None) | layout |
ViewThatFits | ViewThatFits(content: Optional[Sequence[View]] = None) | layout |
Group | Group(content: Optional[Sequence[View]] = None) | layout |
Overlay | Overlay(content: Optional[View] = None, overlay: Optional[View] = None, alignment: str = 'center') | layout |
SafeAreaInset | SafeAreaInset(edge: str = 'bottom', content: Optional[View] = None) | layout |
Spacer | Spacer(min_length: Optional[float] = None, minLength: Optional[float] = None) | layout |
Divider | Divider() | layout |
| API | 签名 | 分类 |
|---|---|---|
List | List(content: Optional[Sequence[View]] = None) | collection |
ForEach | ForEach(data: Any, row_builder: Optional[Callable] = None, key: Optional[Callable] = None, rowBuilder: Optional[Callable] = None, content: Optional[Callable] = None) | collection |
Form | Form(content: Optional[Sequence[View]] = None) | collection |
Section | Section(content: Optional[ViewChild] = None, *, header: Optional[Union[str, View]] = None, footer: Optional[Union[str, View]] = None, children: Optional[ViewChild] = None, key: Optional[str] = None) | collection |
GroupBox | GroupBox(label: Optional[str] = None, content: Optional[ViewChild] = None, children: Optional[Sequence[View]] = None) | collection |
DisclosureGroup | DisclosureGroup(label: str = '', is_expanded: Optional[bool] = None, content: Optional[ViewChild] = None, isExpanded: Optional[bool] = None, children: Optional[ViewChild] = None) | collection |
LabeledContent | LabeledContent(label: str = '', value: Optional[str] = None, content: Optional[View] = None) | collection |
Table | Table(data: Optional[Sequence[Dict[str, Any]]] = None, columns: Optional[Sequence[Dict[str, str]]] = None, on_select: Optional[Callable] = None, onSelect: Optional[Callable] = None) | collection |
ControlGroup | ControlGroup(label: str = '', content: Optional[Sequence[View]] = None, children: Optional[Sequence[View]] = None) | control |
ContentUnavailableView | ContentUnavailableView(title: str = '', system_image: Optional[str] = None, description: Optional[str] = None, systemImage: Optional[str] = None) | presentation |
ProgressView | ProgressView(label: Optional[str] = None, value: Optional[float] = None, total: float = 1.0) | feedback |
Link | Link(title: str = '', url: str = '') | control |
Gauge | Gauge(value: float = 0.0, min_value: float = 0.0, max_value: float = 1.0, label: str = '', minValue: Optional[float] = None, maxValue: Optional[float] = None) | feedback |
ShareLink | ShareLink(item: str = '', subject: Optional[str] = None, message: Optional[str] = None) | control |
Badge | Badge(count: Optional[int] = None, text: Optional[str] = None) | presentation |
TimelineView | TimelineView(interval: float = 1.0, content: Optional[View] = None) | feedback |
| API | 用法边界 |
|---|---|
List vs ScrollView | 动态行列表用 List;完全自定义滚动视觉才用 ScrollView。 |
Form vs VStack | 设置和编辑页用 Form;普通局部排列用 VStack。 |
LazyVGrid vs Grid | 卡片网格用 LazyVGrid;需要严格行列对齐用 Grid。 |
Overlay vs .overlay(...) | 简单叠加优先用修饰符;需要显式节点时用 Overlay。 |
ContentUnavailableView vs 空 Text | 空状态用 ContentUnavailableView,不要让列表区域空白。 |
| 错误 | 后果 | 修正 |
|---|---|---|
VStack(children=[...]) | 构造参数不匹配 | VStack([...], spacing=...)。 |
Section(content=List(...)) | 嵌套滚动容器 | List([Section("Title", rows)])。 |
| 动态列表没有 key | 搜索、删除后行身份不稳 | ForEach(data, row_builder=..., key=...)。 |
| 用自绘卡片模拟设置页 | 信息密度低,键盘和辅助功能差 | Form + Section。 |
| 把重型媒体放进列表行 | 滚动后可能空白或状态丢失 | 媒体放稳定区域,控制项放 Form/List。 |
Image、AsyncImage、PhotoPicker、CameraPicker、VideoPlayer、WebView 和 MapView。
+本页覆盖图片、相册、相机、文件导入、地图、网页、视频和图标标题。媒体视图仍然是普通 View,可以继续使用 .frame(...)、.padding(...)、.background(...)、.clipped() 等修饰符。
| 目标 | 首选 API | 说明 |
|---|---|---|
| SF Symbol 或本地图片 | Image | 图标、资产目录图片、可缩放图片。 |
| 网络图片 | AsyncImage | 支持占位视图和失败视图。 |
| 图标 + 标题 | Label | 按钮、列表行、Tab、菜单项里的标准图标标题。 |
| 从相册选择 | PhotoPicker | 选择图片或视频,回调返回路径列表。 |
| 拍照或录像 | CameraPicker | 使用系统相机,回调返回路径字符串。 |
| 从文件导入 | FileImporter | 打开系统文件选择器,回调返回导入文件路径列表。 |
| 视频播放 | VideoPlayer | 内联或全屏视频播放,支持 AirPlay、PiP。 |
| 播放器控制 | PlayerController | 复用同一个原生播放器实例,控制播放、暂停、进度、倍速、音量和事件回调。 |
| 网页内容 | WebView | 加载 URL 或内联 HTML。 |
| 地图展示 | MapView | 显示 Apple Maps、中心点、缩放跨度和标记。 |
import appui
+
+state = appui.State(status="Ready", picked_count=0, imported_count=0)
+
+
+def image_loaded():
+ state.status = "Image loaded"
+
+
+def image_failed():
+ state.status = "Image failed"
+
+
+def receive_photos(paths):
+ state.picked_count = len(paths or [])
+ state.status = f"Picked {state.picked_count} item(s)"
+
+
+def receive_files(paths):
+ state.imported_count = len(paths or [])
+ state.status = f"Imported {state.imported_count} file(s)"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Image", [
+ appui.AsyncImage(
+ url="https://www.w3.org/Icons/w3c_home",
+ placeholder=appui.ProgressView(label="Loading"),
+ error_view=appui.Label("Failed", system_image="wifi.slash"),
+ content_mode="fit",
+ on_success=image_loaded,
+ on_failure=image_failed,
+ )
+ .frame(height=90)
+ .background("secondarySystemBackground", corner_radius=8),
+ ]),
+ appui.Section("Picker", [
+ appui.PhotoPicker(
+ selection_limit=2,
+ filter="images",
+ on_picked=receive_photos,
+ label=appui.Label("Choose Photos", system_image="photo.on.rectangle"),
+ ),
+ appui.Text(state.status).font("footnote").foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("Files", [
+ appui.FileImporter(
+ allowed_types=["text", "pdf", "csv"],
+ allows_multiple=True,
+ on_picked=receive_files,
+ label=appui.Label("Import Files", system_image="doc.badge.plus"),
+ ),
+ ]),
+ ]).navigation_title("Media")
+ )
+
+
+appui.run(body, state=state)
+| API | 签名 | 分类 |
|---|---|---|
Image | Image(name: Optional[str] = None, system_name: Optional[str] = None, systemName: Optional[str] = None) | media |
Label | Label(title: str = '', system_image: Optional[str] = None, image: Optional[str] = None, systemImage: Optional[str] = None) | text |
AsyncImage | AsyncImage(url: str = '', placeholder: Optional[View] = None, error_view: Optional[View] = None, content_mode: str = 'fit', on_success: Optional[Callable] = None, on_failure: Optional[Callable] = None) | media |
| 方法 | 说明 |
|---|---|
.resizable() | 允许图片按 frame 缩放。 |
.aspect_ratio(ratio=None, content_mode='fit', **kwargs) | 设置宽高比和填充模式;content_mode 为 "fit" 或 "fill"。 |
.symbol_rendering_mode(mode) | SF Symbol 渲染:"hierarchical"、"palette"、"multicolor"、"monochrome"。 |
.image_scale(scale) | SF Symbol 尺寸:"small"、"medium"、"large"。 |
import appui
+
+
+def body():
+ symbol = (
+ appui.Image(system_name="heart.fill")
+ .symbol_rendering_mode("multicolor")
+ .image_scale("large")
+ .foreground_color("systemPink")
+ )
+
+ photo = (
+ appui.Image(name="example")
+ .resizable()
+ .aspect_ratio(content_mode="fit")
+ .frame(height=90)
+ .background("secondarySystemBackground", corner_radius=8)
+ )
+
+ return appui.NavigationStack(
+ appui.VStack([symbol, photo], spacing=16)
+ .padding()
+ .navigation_title("Images")
+ )
+
+
+appui.run(body)
+| API | 签名 | 分类 |
|---|---|---|
PhotoPicker | PhotoPicker(selection_limit: int = 1, filter: str = 'images', on_picked: Optional[Callable] = None, label: Optional[View] = None, selectionLimit: Optional[int] = None, onPicked: Optional[Callable] = None, **kwargs: Any) | media |
CameraPicker | CameraPicker(source: str = 'camera', media_type: str = 'photo', on_captured: Optional[Callable] = None, label: Optional[View] = None, mediaType: Optional[str] = None, onCaptured: Optional[Callable] = None, **kwargs: Any) | media |
FileImporter | FileImporter(allowed_types: Optional[Union[str, Sequence[str]]] = None, allows_multiple: bool = False, copy: bool = True, on_picked: Optional[Callable] = None, label: Optional[View] = None, allowedTypes: Optional[Union[str, Sequence[str]]] = None, allowsMultiple: Optional[bool] = None, onPicked: Optional[Callable] = None, **kwargs: Any) | media |
回调契约:PhotoPicker.on_picked(paths) 与 FileImporter.on_picked(paths) 接收路径字符串列表;CameraPicker.on_captured(path) 接收单个路径字符串或空值。用户可能拒绝权限、取消选择或设备不可用,回调中要处理空路径和空列表。
import appui
+
+state = appui.State(last_path="")
+
+
+def receive_capture(path):
+ state.last_path = path or "No file"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Camera", [
+ appui.CameraPicker(
+ source="front",
+ media_type="photo",
+ on_captured=receive_capture,
+ label=appui.Label("Take Photo", system_image="camera"),
+ ),
+ appui.Text(state.last_path or "No capture yet")
+ .font("footnote")
+ .foreground_color("secondaryLabel"),
+ ])
+ ]).navigation_title("Camera")
+ )
+
+
+appui.run(body, state=state)
+import appui
+
+state = appui.State(files=[])
+
+
+def receive_files(paths):
+ state.files = paths or []
+
+
+def body():
+ rows = [
+ appui.Text(path).font("footnote").line_limit(1)
+ for path in state.files
+ ]
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Import", [
+ appui.FileImporter(
+ allowed_types=["text", "pdf", "csv"],
+ allows_multiple=True,
+ on_picked=receive_files,
+ label=appui.Label("Import Files", system_image="folder"),
+ ),
+ ]),
+ appui.Section("Files", rows or [
+ appui.ContentUnavailableView(
+ "No file",
+ system_image="doc",
+ description="Import a document first",
+ )
+ ]),
+ ]).navigation_title("Files")
+ )
+
+
+appui.run(body, state=state)
+| 场景 | 推荐写法 | 说明 |
|---|---|---|
| 只需要在页面里显示并播放一个视频 | VideoPlayer(url=...) | 最简单,适合普通预览、详情页视频。 |
| Mini App 需要播放/暂停/seek/倍速/进度保存/PiP 状态 | PlayerController + VideoPlayer(player=player) | AppUI 视频类应用的主入口。 |
| 不写 AppUI 页面,只写脚本播放音视频 | import avplayer | 脚本级媒体能力;AppUI 新页面不要优先用它控制内嵌播放器。 |
| API | 签名 | 分类 |
|---|---|---|
VideoPlayer | VideoPlayer(url: str = '', autoplay: bool = False, loop: bool = False, show_controls: bool = True, presentation: str = 'inline', allows_fullscreen: bool = True, allows_pip: bool = True, allows_airplay: bool = True, video_gravity: str = 'resizeAspect', enters_fullscreen_when_playback_begins: bool = False, exits_fullscreen_when_playback_ends: bool = True, showControls: Optional[bool] = None, allowsFullscreen: Optional[bool] = None, allowsPiP: Optional[bool] = None, allowsPictureInPicture: Optional[bool] = None, allowsAirPlay: Optional[bool] = None, videoGravity: Optional[str] = None, entersFullscreenWhenPlaybackBegins: Optional[bool] = None, exitsFullscreenWhenPlaybackEnds: Optional[bool] = None, allows_picture_in_picture: Optional[bool] = None, player: Optional[PlayerController] = None, player_id: Optional[str] = None, pause_on_disappear: Optional[bool] = None) | media |
PlayerController | PlayerController(id: str = 'main', url: str = '', autoplay: bool = False, loop: bool = False, rate: float = 1.0, volume: float = 1.0, allows_pip: bool = True, allows_airplay: bool = True, video_gravity: str = 'resizeAspect', pause_on_disappear: bool = True) | 公开类型 |
WebView | WebView(url: Optional[str] = None, html: Optional[str] = None) | media |
MapView | MapView(latitude: float = 37.7749, longitude: float = -122.4194, span: float = 0.05, markers: Optional[Sequence[Dict[str, Any]]] = None, map_style: str = 'automatic', mapStyle: Optional[str] = None) | media |
ShareLink | ShareLink(item: str = '', subject: Optional[str] = None, message: Optional[str] = None) | control |
VideoPlayer(url=...) 适合只展示并播放一个视频。视频类 Mini App 需要恢复播放进度、切集、外部按钮控制、倍速、音量或 PiP 状态时,先创建 PlayerController,再传给 VideoPlayer(player=player)。AppUI 页面不要再额外 import avplayer 去控制同一块内嵌视频;PlayerController 已经是 AppUI 的播放器控制入口。
PlayerController 默认 pause_on_disappear=True,页面退出或视图消失时会暂停对应播放器,避免视频声音继续播放。确实需要离开页面后继续播放时,显式传 pause_on_disappear=False。
import appui
+
+player = appui.PlayerController(
+ id="episode-player",
+ url="https://example.com/video.mp4",
+ autoplay=True,
+ allows_pip=True,
+ pause_on_disappear=True,
+)
+
+
+@player.on_progress(interval=5)
+def save_progress(seconds):
+ print("progress", seconds)
+
+
+def skip_forward():
+ player.seek(player.current_time + 30)
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.VideoPlayer(player=player).frame(height=220),
+ appui.HStack([
+ appui.Button("Play", action=player.play),
+ appui.Button("Pause", action=player.pause),
+ appui.Button("Skip", action=skip_forward),
+ ]),
+ ], spacing=12)
+ .padding()
+ .navigation_title("Player")
+ )
+
+
+appui.run(body)
+import appui
+
+
+def body():
+ markers = [
+ {"latitude": 35.68, "longitude": 139.76, "title": "Tokyo"},
+ {"latitude": 35.69, "longitude": 139.70, "title": "Shinjuku"},
+ ]
+
+ return appui.NavigationStack(
+ appui.ScrollView(
+ appui.VStack([
+ appui.MapView(
+ latitude=35.68,
+ longitude=139.76,
+ span=0.12,
+ markers=markers,
+ map_style="standard",
+ ).frame(height=220),
+ appui.WebView(html="<h1>AppUI</h1><p>Inline HTML content.</p>")
+ .frame(height=180),
+ ], spacing=16)
+ .padding()
+ ).navigation_title("Map & Web")
+ )
+
+
+appui.run(body)
+| API | 空值或失败时的表现 | 建议 |
|---|---|---|
AsyncImage | 无占位时可能显示空白;失败时使用 error_view。 | 总是提供 placeholder 和 error_view。 |
PhotoPicker | 用户取消时可能返回空列表。 | 在 on_picked 中处理 [] 或 None。 |
CameraPicker | 用户拒绝权限、取消或设备不可用时可能没有路径。 | 在 on_captured 中处理空路径并显示说明。 |
FileImporter | 用户取消时不会产生有效路径;部分外部文件类型可能无法读取。 | 默认保持 copy=True,在 on_picked 中处理 [] 或 None。 |
VideoPlayer | 空 url 没有有效播放源。 | 文档示例可展示空控件,真实应用必须传可播放地址。 |
WebView | 未传 url / html 时没有明确内容。 | 显式传 URL 或 HTML。 |
MapView | 无标记也能显示中心点。 | span 不要过大,标记字典至少包含 latitude、longitude。 |
ShareLink | item 为空时分享面板没有有意义内容。 | 先在状态或函数里生成可读文本、URL 或文件路径。 |
| API | 不同点 |
|---|---|
Image vs Label | Image 只有图像;Label 是图标和标题组合,按钮和列表行更常用。 |
Image vs AsyncImage | Image 用本地资源或 SF Symbol;AsyncImage 从网络 URL 加载。 |
PhotoPicker vs CameraPicker | PhotoPicker 从已有媒体库选择;CameraPicker 创建新媒体。 |
PhotoPicker vs FileImporter | PhotoPicker 只面向相册媒体;FileImporter 从 Files App 或文档提供方导入普通文件。 |
WebView vs Link | WebView 在 AppUI 内嵌网页;Link 打开系统浏览器。 |
VideoPlayer vs WebView | 视频用 VideoPlayer,不要用 WebView 包一层视频网页来播放。 |
| 错误 | 正确做法 |
|---|---|
AsyncImage 不设固定高度,加载后布局跳动。 | 用 .frame(height=...) 固定媒体区域。 |
网络图片使用 "fill" 但忘记 .clipped()。 | 填充裁切时加 .clipped()。 |
| 相册或相机回调假设一定有路径。 | 对空列表、空字符串和权限拒绝做处理。 |
需要普通文档却用 PhotoPicker。 | 用 FileImporter(allowed_types=[...], on_picked=...)。 |
用 Image(...).on_tap(...) 模拟按钮。 | 可点击媒体动作使用 Button(content=appui.Image(...)) 或 Button(content=appui.Label(...))。 |
在用户文档里混用命令式 ui 写法。 | AppUI 文档只展示声明式 body() + appui.run(...)。 |
外观、布局、导航、交互和可访问性修饰符。
+修饰符是所有 View 都能链式调用的行为。推荐顺序是:内容语义、文字样式、尺寸约束、交互、呈现、外观背景。顺序会影响布局和命中区域,例如先 .padding() 再 .background(...) 会让背景包住内边距。
| 目标 | 首选修饰符 |
|---|---|
| 调整尺寸和间距 | .frame(...)、.padding(...)、.fixed_size(...)、.layout_priority(...) |
| 文本和图标样式 | .font(...)、.foreground_color(...)、.line_limit(...)、.minimum_scale_factor(...) |
| 背景和视觉效果 | .background(...)、.corner_radius(...)、.shadow(...)、.opacity(...)、.blur(...) |
| 页面导航 | .navigation_title(...)、.toolbar(...)、.navigation_destination(...) |
| 控件样式 | .button_style(...)、.list_style(...)、.picker_style(...)、.text_field_style(...) |
| 交互 | .on_tap(...)、.on_long_press(...)、.on_drag(...)、.task(...) |
| 模态和列表行为 | .sheet(...)、.alert(...)、.confirmation_dialog(...)、.swipe_actions(...) |
| 焦点和键盘 | .focused(...)、.submit_label(...)、.on_submit(...)、.keyboard_dismiss(...) |
| 可访问性 | .accessibility_label(...)、.accessibility_hidden(...) |
import appui
+
+state = appui.State(tapped=0, show_alert=False)
+
+
+def tap_card():
+ state.tapped += 1
+ state.show_alert = True
+
+
+def close_alert():
+ state.show_alert = False
+
+
+def body():
+ card = (
+ appui.VStack([
+ appui.Text("Modifier Card")
+ .font("headline")
+ .foreground_color("label"),
+ appui.Text(f"Tapped {state.tapped} times")
+ .font("footnote")
+ .foreground_color("secondaryLabel")
+ .line_limit(1),
+ ], alignment="leading", spacing=6)
+ .frame(max_width=appui.infinity, alignment="leading")
+ .padding(16)
+ .background("secondarySystemBackground", corner_radius=8)
+ .content_shape("rect")
+ .on_tap(tap_card)
+ .accessibility_label("Modifier demo card")
+ .alert(
+ "Tapped",
+ message="The card received a tap.",
+ is_presented=state.show_alert,
+ on_dismiss=close_alert,
+ )
+ )
+
+ return appui.NavigationStack(
+ appui.VStack([card], spacing=16)
+ .padding(20)
+ .navigation_title("Modifiers")
+ )
+
+
+appui.run(body, state=state)
+| API | 签名 | 所属类型 |
|---|---|---|
.id | .id(key: str) -> Self | View |
.padding | .padding(value: Optional[float] = None, edges: Optional[str] = None, *, horizontal: Optional[float] = None, vertical: Optional[float] = None, top: Optional[float] = None, bottom: Optional[float] = None, leading: Optional[float] = None, trailing: Optional[float] = None, **kwargs: Any) -> Self | View |
.frame | .frame(width: Optional[float] = None, height: Optional[float] = None, min_width: Optional[float] = None, max_width: Optional[Union[float, str]] = None, min_height: Optional[float] = None, max_height: Optional[Union[float, str]] = None, alignment: Optional[str] = None, **kwargs: Any) -> Self | View |
.offset | .offset(x: float = 0, y: float = 0) -> Self | View |
.position | .position(x: float = 0, y: float = 0) -> Self | View |
.ignore_safe_area | .ignore_safe_area(edges: str = 'all', regions: str = 'all') -> Self | View |
.fixed_size | .fixed_size(horizontal: bool = True, vertical: bool = True) -> Self | View |
.layout_priority | .layout_priority(value: float) -> Self | View |
.alignment_guide | .alignment_guide(alignment: str = 'center', compute_value: Optional[float] = None) -> Self | View |
.container_relative_frame | .container_relative_frame(axis: str = 'vertical', count: int = 1, span: int = 1, spacing: float = 8) -> Self | View |
.safe_area_inset | .safe_area_inset(edge: str = 'bottom', content: Optional["View"] = None, spacing: Optional[float] = None) -> Self | View |
| API | 签名 | 所属类型 |
|---|---|---|
.font | .font(name: Optional[str] = None, size: Optional[float] = None, weight: Optional[str] = None, design: Optional[str] = None) -> Self | View |
.bold | .bold() -> Self | View |
.italic | .italic() -> Self | View |
.foreground_color | .foreground_color(color: ColorLike) -> Self | View |
.foreground_style | .foreground_style(style: Any) -> Self | View |
.background | .background(color: Optional[ColorLike] = None, corner_radius: float = 0, gradient: Optional[Sequence[ColorLike]] = None, gradient_type: str = 'linear', material: Optional[str] = None, cornerRadius: Optional[float] = None, gradientType: Optional[str] = None, opacity: Optional[float] = None, **kwargs: Any) -> Self | View |
.opacity | .opacity(value: float) -> Self | View |
.corner_radius | .corner_radius(value: float) -> Self | View |
.clip_shape | .clip_shape(shape: str) -> Self | View |
.clipped | .clipped() -> Self | View |
.shadow | .shadow(color: Optional[ColorLike] = None, radius: float = 5, x: float = 0, y: float = 2) -> Self | View |
.border | .border(color: ColorLike, width: float = 1) -> Self | View |
.overlay | .overlay(content: "View") -> Self | View |
.tint | .tint(color: ColorLike) -> Self | View |
.mask | .mask(content: "View") -> Self | View |
.drawing_group | .drawing_group() -> Self | View |
.blur | .blur(radius: float) -> Self | View |
.brightness | .brightness(amount: float) -> Self | View |
.contrast | .contrast(amount: float) -> Self | View |
.saturation | .saturation(amount: float) -> Self | View |
.grayscale | .grayscale(amount: float) -> Self | View |
| API | 签名 | 所属类型 |
|---|---|---|
.navigation_title | .navigation_title(title: Union[str, "View"]) -> Self | View |
.navigation_bar_title_display_mode | .navigation_bar_title_display_mode(mode: str) -> Self | View |
.navigation_bar_back_button_hidden | .navigation_bar_back_button_hidden(value: bool = True) -> Self | View |
.toolbar | .toolbar(items: Any) -> Self | View |
.toolbar_background | .toolbar_background(visibility: str = 'visible', bars: str = 'navigation_bar') -> Self | View |
.toolbar_color_scheme | .toolbar_color_scheme(scheme: str = 'dark', bars: str = 'navigation_bar') -> Self | View |
.navigation_destination | .navigation_destination(is_presented: bool = False, content: Optional["View"] = None, on_dismiss: Optional[Callable] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
| API | 签名 | 所属类型 |
|---|---|---|
.button_style | .button_style(style: str) -> Self | View |
.list_style | .list_style(style: str) -> Self | View |
.text_field_style | .text_field_style(style: str) -> Self | View |
.toggle_style | .toggle_style(style: str) -> Self | View |
.tab_view_style | .tab_view_style(style: str) -> Self | View |
.picker_style | .picker_style(style: str) -> Self | View |
.gauge_style | .gauge_style(style: str) -> Self | View |
.progress_view_style | .progress_view_style(style: str) -> Self | View |
.date_picker_style | .date_picker_style(style: str) -> Self | View |
| API | 签名 | 所属类型 |
|---|---|---|
.on_tap | .on_tap(action: Callable) -> Self | View |
.on_appear | .on_appear(action: Callable) -> Self | View |
.on_disappear | .on_disappear(action: Callable) -> Self | View |
.on_long_press | .on_long_press(action: Optional[Callable] = None, min_duration: float = 0.5, minDuration: Optional[float] = None, on_pressing: Optional[Callable] = None, onPressing: Optional[Callable] = None) -> Self | View |
.on_drag | .on_drag(on_changed: Optional[Callable] = None, on_ended: Optional[Callable] = None, onChanged: Optional[Callable] = None, onEnded: Optional[Callable] = None) -> Self | View |
.on_magnification | .on_magnification(on_changed: Optional[Callable] = None, on_ended: Optional[Callable] = None, onChanged: Optional[Callable] = None, onEnded: Optional[Callable] = None) -> Self | View |
.on_rotation | .on_rotation(on_changed: Optional[Callable] = None, on_ended: Optional[Callable] = None, onChanged: Optional[Callable] = None, onEnded: Optional[Callable] = None) -> Self | View |
.on_drop | .on_drop(action: Callable) -> Self | View |
.on_geometry | .on_geometry(action: Callable) -> Self | View |
.task | .task(action: Callable) -> Self | View |
.disabled | .disabled(value: bool = True) -> Self | View |
.hidden | .hidden() -> Self | View |
.simultaneous_gesture | .simultaneous_gesture(gesture: str = 'tap', callback: Optional[Callable] = None, on_changed: Optional[Callable] = None, min_duration: float = 0.5) -> Self | View |
.high_priority_gesture | .high_priority_gesture(gesture: str = 'tap', callback: Optional[Callable] = None, min_duration: float = 0.5) -> Self | View |
| API | 签名 | 所属类型 |
|---|---|---|
.alert | .alert(title: str, message: str = '', is_presented: bool = False, on_dismiss: Optional[Callable] = None, actions: Optional[Sequence["View"]] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
.sheet | .sheet(is_presented: bool = False, content: Optional[Union["View", Callable[[], "View"]]] = None, on_dismiss: Optional[Callable] = None, detents: Optional[str] = None, drag_indicator: Optional[str] = None, interactive_dismiss_disabled: bool = False, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None, dragIndicator: Optional[str] = None, interactiveDismissDisabled: Optional[bool] = None) -> Self | View |
.full_screen_cover | .full_screen_cover(is_presented: bool = False, content: Optional[Union["View", Callable[[], "View"]]] = None, on_dismiss: Optional[Callable] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
.confirmation_dialog | .confirmation_dialog(title: str, is_presented: bool = False, actions: Optional[Sequence["View"]] = None, message: str = '', on_dismiss: Optional[Callable] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
.context_menu | .context_menu(content: Optional[Sequence["View"]] = None) -> Self | View |
.searchable | .searchable(text: str = '', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None, placement: str = 'automatic', prompt: Optional[str] = None, on_submit: Optional[Callable] = None, onSubmit: Optional[Callable] = None) -> Self | View |
.swipe_actions | .swipe_actions(edge: str = 'trailing', content: Optional[Sequence["View"]] = None, actions: Optional[Sequence["View"]] = None) -> Self | View |
.refreshable | .refreshable(action: Optional[Callable] = None) -> Self | View |
.badge | .badge(count: Any) -> Self | View |
.popover | .popover(is_presented: bool = False, content: Optional[Union["View", Callable[[], "View"]]] = None, on_dismiss: Optional[Callable] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
.inspector | .inspector(is_presented: bool = False, content: Optional[Union["View", Callable[[], "View"]]] = None, on_dismiss: Optional[Callable] = None) -> Self | View |
| API | 签名 | 所属类型 |
|---|---|---|
.animation | .animation(type: str = 'default', value: Optional[Any] = None) -> Self | View |
.transition | .transition(type: str = 'opacity') -> Self | View |
.scale_effect | .scale_effect(scale: float) -> Self | View |
.rotation_effect | .rotation_effect(degrees: float) -> Self | View |
.rotation_3d_effect | .rotation_3d_effect(degrees: float, x: float = 0, y: float = 0, z: float = 0) -> Self | View |
.matched_geometry_effect | .matched_geometry_effect(ns_id: Optional[str] = None, namespace: Optional[str] = None, is_source: bool = True, nsId: Optional[str] = None, isSource: Optional[bool] = None) -> Self | View |
.content_transition | .content_transition(type: str = 'opacity') -> Self | View |
.phase_animator | .phase_animator(phases: Optional[Sequence[float]] = None, effect: str = 'scale_opacity', scale_range: float = 0.1, opacity_range: float = 0.2, duration: float = 0.6, animation: str = 'easeInOut') -> Self | View |
| API | 签名 | 所属类型 |
|---|---|---|
.focused | .focused(field_id: Union[bool, str, None] = None, equals: Optional[str] = None, fieldId: Union[bool, str, None] = None, key: Optional[str] = None) -> Self | View |
.submit_label | .submit_label(label: str) -> Self | View |
.on_submit | .on_submit(action: Callable) -> Self | View |
.keyboard_dismiss | .keyboard_dismiss(mode: str = 'interactive') -> Self | View |
.line_limit | .line_limit(limit: Optional[int]) -> Self | View |
.multiline_text_alignment | .multiline_text_alignment(alignment: str) -> Self | View |
.truncation_mode | .truncation_mode(mode: str) -> Self | View |
.minimum_scale_factor | .minimum_scale_factor(factor: float) -> Self | View |
.strikethrough | .strikethrough(active: bool = True, color: Optional[ColorLike] = None) -> Self | View |
.underline | .underline(active: bool = True, color: Optional[ColorLike] = None) -> Self | View |
| API | 签名 | 所属类型 |
|---|---|---|
.list_row_background | .list_row_background(color: ColorLike) -> Self | View |
.list_row_separator | .list_row_separator(visibility: str = 'hidden') -> Self | View |
.list_row_insets | .list_row_insets(top: float = 0, leading: float = 0, bottom: float = 0, trailing: float = 0) -> Self | View |
.scroll_content_background | .scroll_content_background(visibility: str = 'hidden') -> Self | View |
.scroll_position | .scroll_position(id: Optional[str] = None) -> Self | View |
.scroll_target_layout | .scroll_target_layout(enabled: bool = True) -> Self | View |
.scroll_target_behavior | .scroll_target_behavior(mode: str = 'view_aligned') -> Self | View |
.default_scroll_anchor | .default_scroll_anchor(anchor: str = 'top') -> Self | View |
.scroll_clip_disabled | .scroll_clip_disabled(disabled: bool = True) -> Self | View |
.content_margins | .content_margins(edges: str = 'all', length: Optional[float] = None, **kwargs: Any) -> Self | View |
.scroll_transition | .scroll_transition(axis: str = 'vertical', transition: str = 'identity') -> Self | View |
| API | 签名 | 所属类型 |
|---|---|---|
.accessibility_label | .accessibility_label(label: str) -> Self | View |
.accessibility_hidden | .accessibility_hidden(value: bool = True) -> Self | View |
.symbol_effect | .symbol_effect(effect: str = 'bounce', is_active: bool = True, value: Optional[Any] = None) -> Self | View |
.sensory_feedback | .sensory_feedback(style: str = 'impact', trigger: Optional[str] = None) -> Self | View |
.preferred_color_scheme | .preferred_color_scheme(scheme: str) -> Self | View |
.environment_value | .environment_value(key: str, value: Any) -> Self | View |
.z_index | .z_index(value: float) -> Self | View |
.content_shape | .content_shape(shape: str = 'rect') -> Self | View |
AppUI 保留 camelCase 兼容别名,例如 foregroundColor、navigationTitle、buttonStyle、onTap、fullScreenCover、confirmationDialog、keyboardDismiss、safeAreaInset、toolbarBackground。新文档和新示例统一使用 snake_case,因为它和 Python 代码风格一致,也更容易统一检索和维护。
| API | 不同点 |
|---|---|
.padding vs .frame | padding 改内容周围空白;frame 改视图可用尺寸。 |
.offset vs .position | offset 视觉偏移但保留原位置;position 在父容器内指定中心点。 |
.background vs .overlay | background 在后面绘制;overlay 在上面绘制。 |
.hidden vs 条件渲染 | .hidden() 保留空间;条件不返回该视图会释放空间。 |
.disabled vs 不传 action | .disabled(True) 保留控件样式和布局;不传 action 可能让交互语义不清。 |
.sheet vs .navigation_destination | sheet 是模态任务;navigation destination 是页面栈推进。 |
| 错误 | 正确做法 |
|---|---|
先 .background(...) 再 .padding(...),背景没有包住内边距。 | 常见卡片顺序是内容样式、.frame(...)、.padding(...)、.background(...)。 |
| 文字放在窄按钮里不设截断或缩放。 | 使用 .line_limit(1)、.minimum_scale_factor(...) 或调整布局。 |
给列表中每一行都加重阴影和 drawing_group()。 | 列表里保持轻量,复杂视觉尽量放到少量静态视图。 |
| 使用 CamelCase 和 snake_case 混写。 | 用户文档和示例统一写 snake_case。 |
| 手势命中区域太小。 | 给可点击区域加 .content_shape("rect"),并确保有足够 .padding(...)。 |
NavigationStack、NavigationLink、TabView、ToolbarItem。
+本页覆盖 NavigationStack、NavigationLink、NavigationSplitView、TabView、Tab、NavigationPath、ToolbarItem 和 ToolbarSpacer。页面级结构优先用这些原生导航容器,不要用手写按钮去模拟系统导航。
| 目标 | 首选 API | 说明 |
|---|---|---|
| 普通推入详情页 | NavigationStack + NavigationLink | 列表到详情、设置项到二级页。 |
| 代码控制 push/pop | NavigationStack(path=...) + NavigationPath | 登录流程、分步表单、点击完成后跳转。 |
| 多个主栏目 | TabView + Tab | 首页、搜索、我的等顶层导航。 |
| iPad 多列 | NavigationSplitView | 侧边栏 + 详情页,可加 supplementary 第三列。 |
| 顶栏/底栏命令 | .toolbar([...]) + ToolbarItem | 保存、关闭、编辑、分享等页面命令。 |
import appui
+
+items = [
+ {"id": "a", "title": "Alpha", "status": "Ready"},
+ {"id": "b", "title": "Beta", "status": "Draft"},
+]
+
+
+def item_key(item):
+ return item["id"]
+
+
+def detail_view(item):
+ return (
+ appui.Form([
+ appui.Section("详情", [
+ appui.LabeledContent("Title", value=item["title"]),
+ appui.LabeledContent("Status", value=item["status"]),
+ ])
+ ])
+ .navigation_title(item["title"])
+ .toolbar([
+ appui.ToolbarItem(
+ placement="navigation_bar_trailing",
+ content=appui.CloseButton(),
+ role="close",
+ )
+ ])
+ )
+
+
+def row_view(item):
+ return appui.NavigationLink(
+ destination=detail_view(item),
+ label=appui.Label(item["title"], system_image="doc.text"),
+ )
+
+
+def body():
+ root = appui.List([
+ appui.Section("Items", [
+ appui.ForEach(items, row_builder=row_view, key=item_key)
+ ])
+ ]).navigation_title("Navigation")
+
+ return appui.NavigationStack(root)
+
+
+appui.run(body)
+| API | 签名 | 分类 |
|---|---|---|
NavigationStack | NavigationStack(content: Optional[View] = None, path: Optional[NavigationPath] = None, destinations: Optional[Dict[str, Callable]] = None) | navigation |
NavigationView | NavigationView = NavigationStack | 兼容别名 |
NavigationLink | NavigationLink(title: Optional[str] = None, destination: Optional[View] = None, label: Optional[View] = None) | navigation |
NavigationSplitView | NavigationSplitView(sidebar: Optional[View] = None, detail: Optional[View] = None, supplementary: Optional[View] = None, column_visibility: str = 'all') | navigation |
TabView | TabView(tabs: Optional[Sequence["Tab"]] = None, selection: Optional[int] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None) | navigation |
Tab | Tab(title: str = '', system_image: Optional[str] = None, image: Optional[str] = None, content: Optional[View] = None, badge: Optional[int] = None, tag: Optional[int] = None, systemImage: Optional[str] = None, role: Optional[str] = None, key: Optional[str] = None) | navigation |
NavigationPath | NavigationPath() | 公开类型 |
ToolbarItem | ToolbarItem(placement: str = 'automatic', content: Optional[View] = None, role: Optional[str] = None) | presentation |
ToolbarSpacer | ToolbarSpacer(sizing: str = 'fixed', placement: str = 'automatic') | presentation |
| API | 签名 | 所属类型 |
|---|---|---|
append | append(view_or_value: Union["View", str, int, Dict[str, Any]]) -> None | NavigationPath |
pop | pop(count: int = 1) -> None | NavigationPath |
pop_to_root | pop_to_root() -> None | NavigationPath |
replace | replace(items: Sequence[Any]) -> None | NavigationPath |
import appui
+
+path = appui.NavigationPath()
+
+
+def open_profile():
+ path.append("profile")
+
+
+def go_root():
+ path.pop_to_root()
+
+
+def make_destination(route):
+ if route == "profile":
+ return appui.Form([
+ appui.Section("Profile", [
+ appui.Text("Programmatic destination"),
+ appui.Button("Back to root", action=go_root),
+ ])
+ ]).navigation_title("Profile")
+ return appui.Text("Unknown").navigation_title("Unknown")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Actions", [
+ appui.Button("Open profile", action=open_profile),
+ ])
+ ]).navigation_title("Root"),
+ path=path,
+ destinations={"profile": make_destination},
+ )
+
+
+appui.run(body)
+import appui
+
+state = appui.State(tab=0, enabled=True)
+
+
+def set_tab(value):
+ state.tab = value
+
+
+def set_enabled(value):
+ state.enabled = value
+
+
+def home_view():
+ return appui.NavigationStack(
+ appui.Text("Home").padding().navigation_title("Home")
+ )
+
+
+def settings_view():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Settings", [
+ appui.Toggle(
+ "Enabled",
+ is_on=state.enabled,
+ on_change=set_enabled,
+ ),
+ ])
+ ]).navigation_title("Settings")
+ )
+
+
+def body():
+ return appui.TabView(
+ tabs=[
+ appui.Tab("Home", system_image="house", content=home_view(), tag=0),
+ appui.Tab("Settings", system_image="gear", content=settings_view(), tag=1),
+ ],
+ selection=state.tab,
+ on_change=set_tab,
+ )
+
+
+appui.run(body, state=state)
+持续任务可以用 .tab_view_bottom_accessory(...) 显示底部常驻状态条,再用 .sheet(...) 打开完整面板。这样底部条仍由 iOS 26 TabView 原生区域承载,展开页则交给系统 sheet 处理圆角、拖拽条、下拉关闭和 detents。
import appui
+
+state = appui.State(show_panel=False)
+
+
+def open_panel():
+ state.show_panel = True
+
+
+def close_panel():
+ state.show_panel = False
+
+
+def compact_bar():
+ return appui.HStack([
+ appui.Image(system_name="arrow.down.circle.fill").foreground_color("systemBlue"),
+ appui.VStack([
+ appui.Text("下载中").font("subheadline").bold(),
+ appui.Text("42% · 3 个任务").font("caption").foreground_style("secondary"),
+ ], alignment="leading", spacing=2)
+ .frame(max_width=appui.infinity, alignment="leading"),
+ appui.Image(system_name="chevron.up").foreground_color("secondaryLabel"),
+ ], spacing=10).padding(horizontal=14, vertical=10)
+
+
+def expanded_panel():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("任务", [
+ appui.LabeledContent("当前进度", value="42%"),
+ appui.LabeledContent("剩余任务", value="3"),
+ appui.Button("完成", action=close_panel),
+ ])
+ ]).navigation_title("下载")
+ )
+
+
+def body():
+ tabs = appui.TabView(tabs=[
+ appui.Tab("首页", system_image="house", content=appui.Text("Home"), tag=0),
+ appui.Tab("设置", system_image="gear", content=appui.Text("Settings"), tag=1),
+ ])
+ return tabs.tab_view_bottom_accessory(
+ compact_bar().content_shape("rect").on_tap(open_panel)
+ ).sheet(
+ is_presented=state.show_panel,
+ on_dismiss=close_panel,
+ content=expanded_panel,
+ detents="medium_large",
+ drag_indicator="visible",
+ )
+
+
+appui.run(body, state=state)
+紧凑条会放在底部系统区域;点击后打开原生 sheet。不要再额外给同一个 TabView 挂底部 safe_area_bar,否则会和底部附件争同一块区域。需要更像系统媒体 App 的体验时,优先把完整播放器设计成适合 sheet 的内容,而不是自定义全屏 overlay。
| API | 签名 | 所属类型 |
|---|---|---|
.navigation_title | .navigation_title(title: Union[str, "View"]) -> Self | View |
.navigation_bar_title_display_mode | .navigation_bar_title_display_mode(mode: str) -> Self | View |
.navigation_bar_back_button_hidden | .navigation_bar_back_button_hidden(value: bool = True) -> Self | View |
.toolbar | .toolbar(items: Any) -> Self | View |
.toolbar_background | .toolbar_background(visibility: str = 'visible', bars: str = 'navigation_bar') -> Self | View |
.toolbar_color_scheme | .toolbar_color_scheme(scheme: str = 'dark', bars: str = 'navigation_bar') -> Self | View |
.navigation_destination | .navigation_destination(is_presented: bool = False, content: Optional["View"] = None, on_dismiss: Optional[Callable] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
.safe_area_bar | .safe_area_bar(edge: str = 'bottom', content: Optional["View"] = None, alignment: str = 'center', spacing: Optional[float] = None, safeAreaEdge: Optional[str] = None) -> Self | View |
.tab_view_bottom_accessory | .tab_view_bottom_accessory(content: Optional["View"] = None, enabled: bool = True, is_enabled: Optional[bool] = None, isEnabled: Optional[bool] = None) -> Self | View |
.tab_bar_minimize_behavior | .tab_bar_minimize_behavior(behavior: str = 'automatic') -> Self | View |
.tab_view_search_activation | .tab_view_search_activation(activation: str = 'search_tab_selection') -> Self | View |
| API | 不同点 |
|---|---|
NavigationLink vs NavigationPath | NavigationLink 适合用户点击某一行进入详情;NavigationPath 适合业务逻辑主动跳转。 |
TabView vs NavigationStack | TabView 是顶层栏目切换;每个 Tab 里面通常再放自己的 NavigationStack。 |
NavigationSplitView vs TabView | NavigationSplitView 是同一任务的多列信息架构;TabView 是多个任务域的顶层切换。 |
.toolbar vs 页面正文按钮 | 页面级命令放工具栏;内容相关动作放在表单、列表或卡片中。 |
.tab_view_bottom_accessory vs .sheet | 前者只显示底部常驻紧凑条;后者打开完整面板、表单或播放器页。 |
.tab_view_bottom_accessory vs .safe_area_bar | 前者是 TabView 的系统底部附件;后者是普通安全区插入内容,不要同时挂在同一个底部区域。 |
CloseButton vs 普通 Button | 关闭 MiniApp 时使用 CloseButton 或 ToolbarItem(role="close"),系统能识别关闭语义。 |
ToolbarSpacer vs 空白 Spacer | ToolbarSpacer 是工具栏项;正文布局留白继续用 Spacer。 |
| 错误 | 正确做法 |
|---|---|
只用 VStack 加按钮模拟页面切换。 | 使用 NavigationStack、NavigationLink 或 NavigationPath。 |
在 TabView 外面只包一个全局 NavigationStack。 | 每个 Tab 里面放自己的 NavigationStack,避免标题和返回栈互相污染。 |
| 用“播放”或“下载”单独占一个 Tab,但又需要全局状态条。 | 用 .tab_view_bottom_accessory(...) 放紧凑状态条,点击后用 .sheet(...) 打开完整面板。 |
| 隐藏返回按钮但没有替代路径。 | 用 toolbar 放明确的返回、取消或关闭按钮。 |
ForEach 行没有稳定 key。 | 给 ForEach 传稳定键函数,避免列表刷新后导航状态错位。 |
ToolbarItem 使用旧 placement 名称。 | 使用 AppUI 文档中的 navigation_bar_trailing。 |
alert、sheet、popover、confirmation dialog 和刷新动作。
+本页覆盖弹窗、模态、确认框、菜单、刷新和滑动操作。页面跳转用 导航 API,控件样式和布局修饰符用 修饰符 API。
+| 目标 | 首选 API | 说明 |
|---|---|---|
| 简短提示 | .alert(...) | 单条消息、确认结果、错误提示。 |
| 危险操作确认 | .confirmation_dialog(...) | 删除、退出、清空等需要用户确认的动作。 |
| 局部任务流 | .sheet(...) | 选择器、编辑表单、短流程。 |
| 全屏任务 | .full_screen_cover(...) | 登录、拍摄、沉浸式流程。 |
| iPad 气泡层 | .popover(...) | 从某个按钮展开的轻量内容。 |
| 长按菜单 | .context_menu(...) | 行内次要操作。 |
| 下拉刷新 | .refreshable(...) 或 Refreshable | 列表重新加载数据。 |
| 行滑动操作 | .swipe_actions(...) 或 SwipeActions | 删除、归档、置顶等列表行操作。 |
import appui
+
+state = appui.State(
+ show_sheet=False,
+ show_alert=False,
+ show_confirm=False,
+ count=0,
+)
+
+
+def open_sheet():
+ state.show_sheet = True
+
+
+def close_sheet():
+ state.show_sheet = False
+
+
+def open_alert():
+ state.show_alert = True
+
+
+def close_alert():
+ state.show_alert = False
+
+
+def open_confirm():
+ state.show_confirm = True
+
+
+def close_confirm():
+ state.show_confirm = False
+
+
+def add_count():
+ state.count += 1
+
+
+def reset_count():
+ state.count = 0
+ state.show_confirm = False
+
+
+def sheet_content():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Sheet", [
+ appui.Text(f"Count: {state.count}"),
+ appui.Button("+1", action=add_count),
+ appui.Button("Close", action=close_sheet),
+ ])
+ ]).navigation_title("Sheet")
+ )
+
+
+def body():
+ root = (
+ appui.Form([
+ appui.Section("Actions", [
+ appui.Button("Open sheet", action=open_sheet),
+ appui.Button("Show alert", action=open_alert),
+ appui.Button("Reset count", action=open_confirm)
+ .foreground_color("systemRed"),
+ ])
+ ])
+ .navigation_title("Presentation")
+ .sheet(
+ is_presented=state.show_sheet,
+ content=sheet_content,
+ on_dismiss=close_sheet,
+ detents="medium_large",
+ drag_indicator="visible",
+ )
+ .alert(
+ "Notice",
+ message="Alert is visible.",
+ is_presented=state.show_alert,
+ on_dismiss=close_alert,
+ )
+ .confirmation_dialog(
+ "Reset count?",
+ is_presented=state.show_confirm,
+ message="This cannot be undone.",
+ actions=[
+ appui.Button("Reset", action=reset_count, role="destructive"),
+ appui.Button("Cancel", action=close_confirm),
+ ],
+ on_dismiss=close_confirm,
+ )
+ )
+
+ return appui.NavigationStack(root)
+
+
+appui.run(body, state=state)
+这些组件可以作为视图使用;大多数页面更常用对应的链式修饰符。
+| API | 签名 | 分类 |
|---|---|---|
Alert | Alert(title: str = '', message: Optional[str] = None, is_presented: bool = False, actions: Optional[Sequence[View]] = None, isPresented: Optional[bool] = None) | presentation |
ConfirmationDialog | ConfirmationDialog(title: str = '', message: Optional[str] = None, is_presented: bool = False, actions: Optional[Sequence[View]] = None, isPresented: Optional[bool] = None) | presentation |
Popover | Popover(is_presented: bool = False, content: Optional[View] = None, trigger: Optional[View] = None, isPresented: Optional[bool] = None) | presentation |
Refreshable | Refreshable(on_refresh: Optional[Callable] = None, onRefresh: Optional[Callable] = None, content: Optional[ViewChild] = None) | collection |
SwipeActions | SwipeActions(content: Optional[View] = None, leading: Optional[Sequence[View]] = None, trailing: Optional[Sequence[View]] = None) | collection |
| API | 签名 | 所属类型 |
|---|---|---|
.alert | .alert(title: str, message: str = '', is_presented: bool = False, on_dismiss: Optional[Callable] = None, actions: Optional[Sequence["View"]] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
.sheet | .sheet(is_presented: bool = False, content: Optional[Union["View", Callable[[], "View"]]] = None, on_dismiss: Optional[Callable] = None, detents: Optional[str] = None, drag_indicator: Optional[str] = None, interactive_dismiss_disabled: bool = False, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None, dragIndicator: Optional[str] = None, interactiveDismissDisabled: Optional[bool] = None) -> Self | View |
.full_screen_cover | .full_screen_cover(is_presented: bool = False, content: Optional[Union["View", Callable[[], "View"]]] = None, on_dismiss: Optional[Callable] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
.confirmation_dialog | .confirmation_dialog(title: str, is_presented: bool = False, actions: Optional[Sequence["View"]] = None, message: str = '', on_dismiss: Optional[Callable] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
.popover | .popover(is_presented: bool = False, content: Optional[Union["View", Callable[[], "View"]]] = None, on_dismiss: Optional[Callable] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self | View |
.context_menu | .context_menu(content: Optional[Sequence["View"]] = None) -> Self | View |
.searchable | .searchable(text: str = '', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None, placement: str = 'automatic', prompt: Optional[str] = None, on_submit: Optional[Callable] = None, onSubmit: Optional[Callable] = None) -> Self | View |
.swipe_actions | .swipe_actions(edge: str = 'trailing', content: Optional[Sequence["View"]] = None, actions: Optional[Sequence["View"]] = None) -> Self | View |
.refreshable | .refreshable(action: Optional[Callable] = None) -> Self | View |
.badge | .badge(count: Any) -> Self | View |
.inspector | .inspector(is_presented: bool = False, content: Optional[Union["View", Callable[[], "View"]]] = None, on_dismiss: Optional[Callable] = None) -> Self | View |
import appui
+
+state = appui.State(items=["Inbox", "Archive", "Later"], last="")
+
+
+def item_key(item):
+ return item
+
+
+def row_view(item):
+ def mark_done():
+ state.last = f"Done: {item}"
+
+ def remove_item():
+ state.items = [current for current in state.items if current != item]
+ state.last = f"Deleted: {item}"
+
+ return (
+ appui.Text(item)
+ .swipe_actions(actions=[
+ appui.Button("Done", action=mark_done),
+ appui.Button("Delete", action=remove_item, role="destructive"),
+ ])
+ .context_menu([
+ appui.Button("Mark done", action=mark_done),
+ ])
+ )
+
+
+def refresh():
+ state.last = "Refreshed"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("Items", [
+ appui.ForEach(state.items, row_builder=row_view, key=item_key)
+ ]),
+ appui.Section("Status", [
+ appui.Text(state.last or "No action yet"),
+ ]),
+ ])
+ .navigation_title("Rows")
+ .refreshable(refresh)
+ )
+
+
+appui.run(body, state=state)
+| API | 不同点 |
|---|---|
.alert vs .confirmation_dialog | alert 用于提示;confirmation dialog 用于让用户在多个操作里确认。 |
.sheet vs .full_screen_cover | sheet 适合短任务;full screen 适合必须暂时离开主界面的完整流程。 |
.popover vs .sheet | popover 更适合 iPad 上从按钮展开的小面板;iPhone 上可能按系统规则表现为 sheet。 |
.context_menu vs .swipe_actions | context menu 是次要菜单;swipe actions 是列表行的高频快捷操作。 |
Refreshable vs .refreshable | 修饰符更常见;容器用于需要把刷新能力包成独立视图的场景。 |
is_presented / show_* 字段。if state.show_sheet: root = root.sheet(...) 这种条件挂载。content 传命名函数,例如 content=editor_sheet,不要 content=editor_sheet()。show_* 字段变化时,框架优先走原生 PresentationCoordinator,跳过 body() 与整树 JSON:
state.show_sheet = True # 自动 coordinator
+appui.presentation_present("show_sheet") # 显式 API
+appui.presentation_dismiss("show_sheet")
+appui.dismiss_all() # 关闭所有已注册弹层
+混合更新时,presentation 字段先走 coordinator,content 字段仍 rebuild:
+state.batch_update(show_sheet=True, token_draft="") # sheet 走 coordinator,draft 走 rebuild
+Diagnostics:presentation.coordinator = 快路径;presentation.coordinator_fallback = 回退 JSON。
| 错误 | 正确做法 |
|---|---|
sheet 关闭后没有同步 State。 | 在 on_dismiss 中把 state.show_sheet = False。 |
| sheet 一闪而过,没有原生进出动画。 | 始终挂载 .sheet(is_presented=...),不要条件包裹修饰符。 |
把复杂业务逻辑直接写在 body() 的按钮参数里。 | 定义命名函数,按钮只传函数名。 |
alert / dialog 的 is_presented 一直为 True。 | 在确认、取消和 dismiss 回调中恢复为 False。 |
| 列表行删除只改本地变量。 | 修改 State 中的数据源,触发重新渲染。 |
| 用 sheet 做顶层页面导航。 | 顶层页面结构用 NavigationStack 或 TabView。 |
Rectangle、RoundedRectangle、Circle、Capsule、Ellipse、Color 和 ToolbarItem。
+本页覆盖 Rectangle、RoundedRectangle、Circle、Capsule、Ellipse、Color 和 ToolbarItem。形状是普通 View,通常要配合 .frame(...) 才能得到稳定尺寸。
| 目标 | 首选 API | 说明 |
|---|---|---|
| 矩形背景或占位 | Rectangle | 基础块状形状。 |
| 卡片背景 | RoundedRectangle | 需要圆角但不想用 .background(...) 时。 |
| 圆形头像、圆点 | Circle | 宽高相同最稳定。 |
| 胶囊按钮背景 | Capsule | 两端半圆的条状背景。 |
| 椭圆 | Ellipse | 非等宽高的椭圆。 |
| 纯色块 | Color | 用作背景层或色块视图。 |
| 工具栏项 | ToolbarItem | 放到 .toolbar([...]) 里。 |
| API | 签名 | 所属类型 |
|---|---|---|
.fill | .fill(color: ColorLike) -> Self | _Shape |
.stroke | .stroke(color: ColorLike, line_width: float = 1, **kwargs: Any) -> Self | _Shape |
import appui
+
+
+def body():
+ card = appui.ZStack([
+ appui.RoundedRectangle(corner_radius=14)
+ .fill("secondarySystemBackground"),
+ appui.RoundedRectangle(corner_radius=14)
+ .stroke("separator", line_width=1),
+ appui.Text("Shape Card").font("headline"),
+ ]).frame(height=120)
+
+ return appui.NavigationStack(
+ appui.VStack([card], spacing=16)
+ .padding()
+ .navigation_title("Shapes")
+ )
+
+
+appui.run(body)
+| API | 签名 | 分类 |
|---|---|---|
Rectangle | Rectangle() | shape |
RoundedRectangle | RoundedRectangle(corner_radius: float = 10, cornerRadius: Optional[float] = None) | shape |
Circle | Circle() | shape |
Capsule | Capsule() | shape |
Ellipse | Ellipse() | shape |
Color | Color(value: Optional[ColorLike] = None, red: Optional[float] = None, green: Optional[float] = None, blue: Optional[float] = None, opacity: float = 1.0) | shape |
ToolbarItem | ToolbarItem(placement: str = 'automatic', content: Optional[View] = None, role: Optional[str] = None) | presentation |
fill、stroke、foreground_color、background 和 Color 都接受 AppUI 的颜色表达:
| 表达 | 示例 |
|---|---|
| 系统语义色 | "systemBlue"、"label"、"secondaryLabel"、"systemBackground"、"separator" |
| 十六进制 | "#3366CC" |
| RGB / RGBA 元组 | (0.2, 0.4, 0.8)、(0.2, 0.4, 0.8, 0.7) |
| 整数 | 0x3366CC |
import appui
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Color(value="systemIndigo").frame(height=28),
+ appui.Color(red=0.9, green=0.2, blue=0.2, opacity=0.5).frame(height=28),
+ appui.Circle().fill("#3366CC").frame(width=56, height=56),
+ appui.Capsule().stroke("systemGreen", line_width=3).frame(width=160, height=44),
+ appui.Ellipse().fill("systemOrange").frame(width=120, height=72),
+ ], spacing=12)
+ .padding()
+ .navigation_title("Color")
+ )
+
+
+appui.run(body)
+ToolbarItem 必须放在 .toolbar([...]) 中。placement 使用 AppUI 的 snake_case 名称:"automatic"、"navigation_bar_leading"、"navigation_bar_trailing"、"bottom_bar"、"principal"、"keyboard"。
import appui
+
+
+def close_page():
+ appui.dismiss()
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Text("Toolbar content")
+ .padding()
+ .navigation_title("Toolbar")
+ .toolbar([
+ appui.ToolbarItem(
+ placement="navigation_bar_trailing",
+ content=appui.Button(
+ action=close_page,
+ content=appui.Label("Done", system_image="checkmark"),
+ ),
+ role="close",
+ )
+ ])
+ )
+
+
+appui.run(body, presentation="sheet")
+| API | 不同点 |
|---|---|
Rectangle / Circle 等形状 | 原生常见形状,适合背景、边框、圆点、胶囊。 |
Path | 自定义矢量路径,适合不规则形状、曲线和弧。 |
Canvas | 命令式绘图表面,适合混合多种图元、文字和渐变。 |
.background(...) | 更适合普通卡片背景;不需要额外 ZStack。 |
import appui
+
+
+def body():
+ commands = [
+ {"move": [50, 0]},
+ {"line": [100, 90]},
+ {"line": [0, 90]},
+ {"close": True},
+ ]
+
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.RoundedRectangle(corner_radius=8)
+ .fill("systemBlue")
+ .frame(width=120, height=64),
+ appui.Path(commands=commands, fill="systemOrange", stroke="label", line_width=2)
+ .frame(width=120, height=100),
+ ], spacing=16)
+ .padding()
+ .navigation_title("Shape vs Path")
+ )
+
+
+appui.run(body)
+| 错误 | 正确做法 |
|---|---|
形状不设 .frame(...),显示尺寸不可控。 | 给形状明确宽高或高度。 |
| 圆形宽高不一致。 | Circle().frame(width: height:) 使用相同宽高;非圆用 Ellipse。 |
卡片背景都用 ZStack + RoundedRectangle。 | 普通卡片直接用 .background(..., corner_radius=...) 更简单。 |
ToolbarItem 使用旧 placement 名称。 | 使用 "navigation_bar_trailing" 等 snake_case 值。 |
用 Color(...) 包语义色再传给 .fill(...)。 | .fill("systemBlue") 直接传颜色表达即可。 |
State、ReactiveState、NavigationPath、Timer 和 Ref。
+本页查 State、PersistentState、ReactiveState、NavigationPath、Timer、Ref、Prop、ObservableList、ObservableDict 的签名和边界。状态写法的完整解释见 状态管理。
| 目标 | API |
|---|---|
| 普通表单、按钮、列表筛选 | State |
| 明确指定持久化状态 | PersistentState |
| 高频字段快路径 | ReactiveState |
| 程序化导航栈 | NavigationPath |
| 定时执行回调 | Timer |
| 保存不触发 UI 刷新的句柄 | Ref |
| 自定义实时属性 | Prop |
| 观察 list / dict 局部变更 | ObservableList / ObservableDict |
import appui
+
+state = appui.State(count=0, saved=False)
+save_count = appui.Ref(0)
+
+
+def increment():
+ state.batch_update(count=state.count + 1, saved=False)
+
+
+def save():
+ save_count.current += 1
+ state.saved = True
+
+
+def body():
+ status = "Saved" if state.saved else "Editing"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("State", [
+ appui.LabeledContent("Count", value=str(state.count)),
+ appui.LabeledContent("Status", value=status),
+ appui.LabeledContent("Save count", value=str(save_count.current)),
+ appui.Button("+1", action=increment),
+ appui.Button("Save", action=save).button_style("bordered_prominent"),
+ ])
+ ]).navigation_title("State")
+ )
+
+
+appui.run(body, state=state)
+import appui
+
+state = appui.State(seconds=0, running=False)
+
+
+def tick():
+ if state.running:
+ state.seconds += 1
+
+
+timer = appui.Timer(interval=1.0, repeats=True, action=tick)
+
+
+def start():
+ state.running = True
+ timer.start()
+
+
+def stop():
+ state.running = False
+ timer.stop()
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Timer", [
+ appui.LabeledContent("Seconds", value=str(state.seconds)),
+ appui.HStack([
+ appui.Button("Start", action=start).button_style("bordered_prominent"),
+ appui.Button("Stop", action=stop).button_style("bordered"),
+ ], spacing=12),
+ ])
+ ]).navigation_title("Timer")
+ )
+
+
+appui.run(body, state=state)
+| API | 签名 | 分类 |
|---|---|---|
State | State(persist: Optional[Union[bool, Sequence[str]]] = None, persist_key: Optional[str] = None, transient: Optional[Sequence[str]] = None, debounce: float = 0.3, **kwargs: Any) | 公开类型 |
PersistentState | PersistentState(persist_key: Optional[str] = None, transient: Optional[Sequence[str]] = None, debounce: float = 0.3, **kwargs: Any) | 公开类型 |
ReactiveState | ReactiveState(**kwargs: Any) | 公开类型 |
NavigationPath | NavigationPath() | 公开类型 |
Timer | Timer(interval: float = 1.0, repeats: bool = True, action: Optional[Callable[[], None]] = None) | 公开类型 |
Ref | Ref(initial: Any = None) | 公开类型 |
Prop | Prop(prop_id: int, val_type: str = 'auto', default: Any = None, attr: Optional[str] = None) | 公开类型 |
ObservableList | ObservableList(data: Iterable[Any], notify: Callable[[], None]) | 公开类型 |
ObservableDict | ObservableDict(data: Mapping[Any, Any], notify: Callable[[], None]) | 公开类型 |
| API | 方法 | 签名 | 所属类型 |
|---|---|---|---|
State | batch_update | batch_update(**kwargs: Any) -> None | State |
State | to_dict | to_dict() -> Dict[str, Any] | State |
State | get | get(key: str, default: Any = None) -> Any | State |
State | flush_persisted | flush_persisted() -> bool | State |
State | clear_persisted | clear_persisted() -> bool | State |
ReactiveState | batch_update | batch_update(**kwargs: Any) -> None | ReactiveState |
ReactiveState | to_dict | to_dict() -> Dict[str, Any] | ReactiveState |
ReactiveState | get | get(key: str, default: Any = None) -> Any | ReactiveState |
ReactiveState | bind | bind(field_name: str, *, parse: Optional[Callable[[Any], Any]] = None, format: Optional[Callable[[Any], Any]] = None, validate: Optional[Callable[[Any], bool]] = None) -> Dict[str, Any] | ReactiveState |
ReactiveState | bind_handles | bind_handles(**bindings: int) -> None | ReactiveState |
NavigationPath | append | append(view_or_value: Union["View", str, int, Dict[str, Any]]) -> None | NavigationPath |
NavigationPath | pop | pop(count: int = 1) -> None | NavigationPath |
NavigationPath | pop_to_root | pop_to_root() -> None | NavigationPath |
NavigationPath | replace | replace(items: Sequence[Any]) -> None | NavigationPath |
Timer | start | start() -> None | Timer |
Timer | stop | stop() -> None | Timer |
| API | 用法边界 |
|---|---|
State vs ReactiveState | 普通页面用 State;高频字段才用 ReactiveState。 |
State(persist=...) vs PersistentState | 简单保存可用 State(persist=True);需要固定持久化 key 或更清晰语义时用 PersistentState。 |
Ref vs State | 需要显示到 UI 的值用 State;句柄、缓存、连接对象用 Ref。 |
ObservableList vs 重新赋值 | 频繁局部增删改可依赖可观察容器;简单场景重新赋值更直观。 |
Timer vs auto_refresh | 正式页面用 Timer;临时 demo 可用 auto_refresh。 |
NavigationPath vs NavigationLink | 固定行详情用 NavigationLink;程序化跳转用 NavigationPath。 |
| 错误 | 后果 | 修正 |
|---|---|---|
把 UI 要显示的值放进 Ref | 改值后界面不刷新 | 放进 State。 |
在 body() 里创建 Timer | 每次重建都可能新建计时器 | 模块级创建。 |
先创建没有回调的 Timer,再事后赋 action | 容易漏掉回调 | 初始化时传 action=tick。 |
| 连续写多个字段 | 产生多次重建和中间状态 | 使用 batch_update。 |
| 列表按过滤后的下标修改 | 搜索后操作错行 | 数据项保留稳定 id。 |
设备端助手与内置设备工具(iOS 26+)。
+端侧助手(Foundation Models + 内置设备工具,iOS 26+):自然语言任务、可选工具调用。
+边界:设备端推理,内置工具由系统提供(见 list_tools())。不含 PythonIDE 云端 Agent;复杂多步自动化请用应用内 Agent 功能。
+| 项 | 说明 |
|---|---|
| 导入 | import assistant |
| 适合做什么 | 「帮我查天气」「总结这段文字」等带工具的自然语言任务 |
| 调用时机 | run() 放在按钮回调;可能较慢 |
| 推荐顺序 | is_available() → list_tools() → run(prompt, tools=...) |
| 返回 | {"text": str, "tool_calls": list} |
import assistant
+
+if not assistant.is_available():
+ print("Assistant 不可用")
+else:
+ print("内置工具:", assistant.list_tools())
+ result = assistant.run("用一句话介绍今天的日期")
+ print(result["text"])
+ print(result.get("tool_calls", []))
+指定工具子集:
+import assistant
+
+result = assistant.run(
+ "查询当前位置附近的天气",
+ tools=["location", "weather"],
+)
+print(result)
+import appui
+import assistant
+
+state = appui.State(
+ available="—",
+ tools="—",
+ prompt="今天适合出门跑步吗?",
+ reply="—",
+)
+
+
+def refresh_meta():
+ state.available = "是" if assistant.is_available() else "否"
+ names = assistant.list_tools() or []
+ state.tools = str(len(names)) + " 个内置工具"
+
+
+def run_task():
+ refresh_meta()
+ if state.available != "是":
+ state.reply = "Assistant 不可用(需 iOS 26+)"
+ return
+
+ try:
+ result = assistant.run(state.prompt)
+ calls = result.get("tool_calls", [])
+ suffix = f"(调用 {len(calls)} 个工具)" if calls else ""
+ state.reply = (result.get("text") or "—") + suffix
+ except assistant.AssistantError as exc:
+ state.reply = str(exc)
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("任务", [
+ appui.TextEditor(text=state.prompt).frame(min_height=80),
+ appui.Button("运行助手", action=run_task)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("可用", value=state.available),
+ appui.LabeledContent("工具", value=state.tools),
+ appui.Text(state.reply).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("端侧助手")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_available() | 助手是否可用 → bool |
list_tools() | 内置工具描述列表 |
run(prompt, tools=None) | 执行任务 → dict |
AssistantError | 操作失败异常 |
run(prompt, tools=None) — 发送自然语言任务。
| 字段 | 说明 |
|---|---|
text | 助手最终文本回复 |
tool_calls | 工具调用记录列表 |
result = assistant.run("总结这段话", tools=None)
+print(result["text"])
+tools 为工具名字符串列表时,限制可用内置工具范围。
| 错误写法 | 后果 | 修正 |
|---|---|---|
与 foundation_models 混用场景 | 能力重叠 | 纯文本用 foundation_models;要工具用 assistant |
在 body() 里 run | 每次刷新重复推理 | 放进按钮回调 |
| 低版本 iOS | is_available() 为 False | 降级为规则逻辑或提示用户 |
| 期望执行任意 Python 代码 | 超出能力 | 工具仅限系统内置集合 |
| 文档 | 用途 |
|---|---|
| foundation_models | 纯文本生成/摘要 |
| weather | 直接调用天气 API |
| location | 直接调用定位 API |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+麦克风录音、暂停继续和电平监测。
+麦克风录音:把音频保存为 .m4a、.caf 或 .wav 文件。
边界:输出文件保存在 App Documents 目录。需要麦克风系统授权;统一 permission 的+request("microphone")暂不支持弹窗,首次录音时系统可能提示,或在设置中开启。录音只能放在用户操作回调里,不要在body()中调用。
| 项 | 说明 |
|---|---|
| 导入 | import audio_recorder |
| 适合做什么 | 语音备忘、口述笔记、采集音频片段 |
| 调用时机 | start() / stop() 放在按钮回调 |
| 推荐顺序 | start() → 轮询 status() → stop() 拿文件信息 |
| 有状态 | 支持 pause() / resume() / cancel() 丢弃半成品 |
下面脚本开始录音、等待几秒后停止并打印文件信息:
+import time
+import audio_recorder
+
+try:
+ path = audio_recorder.start()
+ print("录音中:", path)
+ time.sleep(3)
+ info = audio_recorder.stop()
+ if info:
+ print("已保存:", info["path"])
+ print("时长:", info["duration"], "秒")
+ print("大小:", info["size"], "字节")
+except audio_recorder.AudioRecorderError as exc:
+ print("录音失败:", exc, f"code={exc.code}")
+开始/停止放在按钮回调;用 Timer 轮询电平与时长。
import appui
+import audio_recorder
+
+state = appui.State(
+ recording=False,
+ path="",
+ level=0.0,
+ seconds=0.0,
+ status="点击开始录音",
+)
+
+
+def tick():
+ if not state.recording:
+ return
+ st = audio_recorder.status()
+ state.level = float(st.get("average_power", 0.0))
+ state.seconds = float(st.get("duration", 0.0))
+
+
+poll_timer = appui.Timer(interval=0.1, repeats=True, action=tick)
+
+
+def toggle_recording():
+ if state.recording:
+ poll_timer.stop()
+ info = audio_recorder.stop() or {}
+ state.batch_update(
+ recording=False,
+ path=info.get("path", ""),
+ status=f"已保存 · {info.get('duration', 0):.1f}s",
+ )
+ return
+
+ try:
+ path = audio_recorder.start()
+ state.batch_update(
+ recording=True,
+ path=path or "",
+ status="录音中…",
+ )
+ poll_timer.start()
+ except audio_recorder.AudioRecorderError as exc:
+ state.status = f"无法开始: {exc}(请检查麦克风权限)"
+
+
+def cancel_recording():
+ if not state.recording:
+ return
+ poll_timer.stop()
+ audio_recorder.cancel()
+ state.batch_update(
+ recording=False,
+ path="",
+ seconds=0.0,
+ status="已取消并删除临时文件",
+ )
+
+
+def body():
+ label = "停止录音" if state.recording else "开始录音"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("录音", [
+ appui.LabeledContent("状态", value=state.status),
+ appui.LabeledContent(
+ "电平",
+ value=f"{state.seconds:.1f}s · {state.level:.0f} dB",
+ ),
+ appui.Text(state.path).font("caption").foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("操作", [
+ appui.Button(label, action=toggle_recording)
+ .button_style("bordered_prominent"),
+ appui.Button("取消录音", action=cancel_recording, role="destructive"),
+ ], footer="真机测试最可靠;需麦克风权限。"),
+ ]).navigation_title("录音")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
start(path, format, ...) | 开始录音 → 文件路径 |
stop() | 停止 → {path, duration, size, format} |
pause() / resume() | 暂停 / 继续 |
cancel() | 停止并删除半成品 |
status() | 状态与电平 |
is_recording() / duration() | 是否录音中 / 已录秒数 |
metering() | {average, peak} 电平(dBFS) |
start(path=None, format="m4a", quality="high", sample_rate=44100, channels=1)
path = audio_recorder.start()
+path = audio_recorder.start(format="wav", quality="high", channels=1)
+| 参数 | 说明 |
|---|---|
format | m4a(默认 AAC)/ caf / wav |
quality | low / medium / high / max |
sample_rate | 如 44100、48000 |
channels | 1 单声道 / 2 立体声 |
stop() — 返回文件信息字典;未录音时返回 None。
cancel() — 丢弃半成品,不返回结果。
status() 返回:
| 字段 | 说明 |
|---|---|
recording / paused | 是否录音 / 暂停 |
duration | 已录秒数 |
average_power / peak_power | 电平 dBFS(0 最大,负值更安静) |
path | 当前文件路径 |
st = audio_recorder.status()
+meter = audio_recorder.metering()
+轮询建议 ≥ 0.1s 间隔(如 appui.Timer)。
失败时抛出 AudioRecorderError,常见 code:
code | 含义 |
|---|---|
already_recording | 重复 start() |
start_failed | 无法启动(含权限或会话占用) |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 start() | 刷新时重复录音 | 放进按钮回调 |
把 permission.request() 当 bool | 判断错误且暂不支持 | 用 try/except 处理 start() |
高频轮询 status() | CPU 浪费 | Timer(interval=0.1) |
放弃录音不 cancel() | 留下垃圾文件 | 调用 cancel() |
| 文档 | 用途 |
|---|---|
| speech_recognition | 录音文件转文字 |
| sound | 播放本地音频 |
| permission | 权限状态查询 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+配置共享 AVAudioSession。
+配置共享 AVAudioSession:设置音频类别、激活会话、查询当前输入/输出路由。
边界:影响全 App 的音频行为,通常在播放/录音之前配置。与 sound、avplayer、audio_recorder 配合使用。不需要单独权限,但会改变其他模块的音频路由;配置放在按钮回调或启动流程,不要在 body() 里反复切换。
+| 项 | 说明 |
|---|---|
| 导入 | import audio_session |
| 适合做什么 | 后台播放、录音+外放、语音通话模式、查耳机/扬声器路由 |
| 调用时机 | 播放/录音前 set_category → set_active(True) |
| 推荐顺序 | 设类别 → 激活 → 使用音频模块 → 可选 set_active(False) |
| 全局影响 | 改类别会影响其他正在播放的音频 |
下面脚本切换到播放模式并查看当前路由:
+import audio_session
+
+try:
+ audio_session.set_category("playback")
+ audio_session.set_active(True)
+ route = audio_session.current_route()
+ print("输出:", route.get("outputs"))
+ print("输入:", route.get("inputs"))
+except audio_session.AudioSessionError as exc:
+ print("配置失败:", exc, f"code={exc.code}")
+录音场景常用 playAndRecord + defaultToSpeaker:
import audio_session
+
+audio_session.set_category(
+ "playAndRecord",
+ mode="voiceChat",
+ options=["defaultToSpeaker", "mixWithOthers"],
+)
+audio_session.set_active(True)
+类别切换和路由查询放在按钮回调。
+import appui
+import audio_session
+
+state = appui.State(
+ category="未设置",
+ outputs="—",
+ status="点击按钮配置音频会话",
+)
+
+
+def refresh_route():
+ try:
+ route = audio_session.current_route() or {}
+ outs = route.get("outputs") or []
+ if outs:
+ names = [o.get("name", "?") for o in outs]
+ state.outputs = " · ".join(names)
+ else:
+ state.outputs = "无输出设备"
+ except audio_session.AudioSessionError as exc:
+ state.outputs = f"查询失败: {exc}"
+
+
+def use_playback():
+ try:
+ audio_session.set_category("playback", options=["mixWithOthers"])
+ audio_session.set_active(True)
+ state.batch_update(category="playback", status="已切换为播放模式")
+ refresh_route()
+ except audio_session.AudioSessionError as exc:
+ state.status = f"失败: {exc}"
+
+
+def use_record():
+ try:
+ audio_session.set_category(
+ "playAndRecord",
+ options=["defaultToSpeaker"],
+ )
+ audio_session.set_active(True)
+ state.batch_update(category="playAndRecord", status="已切换为录音模式")
+ refresh_route()
+ except audio_session.AudioSessionError as exc:
+ state.status = f"失败: {exc}"
+
+
+def deactivate():
+ try:
+ audio_session.set_active(False)
+ state.status = "会话已停用"
+ except audio_session.AudioSessionError as exc:
+ state.status = f"停用失败: {exc}"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("当前状态", [
+ appui.LabeledContent("类别", value=state.category),
+ appui.LabeledContent("输出路由", value=state.outputs),
+ ]),
+ appui.Section("操作", [
+ appui.Button("播放模式", action=use_playback)
+ .button_style("bordered_prominent"),
+ appui.Button("录音模式", action=use_record),
+ appui.Button("刷新路由", action=refresh_route),
+ appui.Button("停用会话", action=deactivate, role="destructive"),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="切换类别会影响其他正在播放的音频。"),
+ ]).navigation_title("音频会话")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
set_category(category, mode, options) | 设置音频类别 |
set_active(active) | 激活 / 停用会话 |
current_route() | 当前输入/输出路由 |
AudioSessionError | 配置失败时抛出 |
set_category(category, mode=None, options=None)
| category | 说明 |
|---|---|
playback | 后台播放(默认回退值) |
record | 仅录音 |
playAndRecord / play_and_record | 播放 + 录音 |
ambient | 与其他 App 混音 |
soloAmbient / solo_ambient | 独占环境音 |
multiRoute | 多路由 |
| mode | 说明 |
|---|---|
voiceChat / voice_chat | 语音通话 |
videoChat / video_chat | 视频通话 |
spokenAudio / spoken_audio | 有声内容 |
measurement | 测量 |
default | 默认 |
| options | 说明 |
|---|---|
mixWithOthers | 与其他 App 混音 |
duckOthers | 压低其他 App 音量 |
defaultToSpeaker | 默认外放扬声器 |
bluetooth | 允许蓝牙 |
airplay | 允许 AirPlay |
set_active(active=True) — 激活或停用共享音频会话。
current_route() 返回:
{
+ "inputs": [{"name": "...", "type": "..."}],
+ "outputs": [{"name": "...", "type": "..."}],
+}
+| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里反复 set_category | 每次刷新都改全局音频 | 放进按钮或启动回调 |
录音前不设 playAndRecord | 录音无声或路由错误 | 录音前配置类别 |
| 与 audio_recorder 类别冲突 | 启动失败或无声 | 统一在录音前设好类别 |
忘记 set_active(True) | 配置不生效 | 类别后激活会话 |
| 文档 | 用途 |
|---|---|
| sound | 播放音效 |
| avplayer | 音视频播放 |
| audio_recorder | 麦克风录音 |
| speech | 语音合成 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+相机、照片、传感器、音频和系统状态的实时事件入口。
+相机、照片、传感器、音频和系统状态的实时事件入口。
+示例会展示系统实时事件如何启动、收到回调并在结束时停止监听。
+Aurora 原生实时信号入口,用于相机、照片、传感器、音频、键盘高度和深浅色变化。
+import aurora_system
+
+def handle_keyboard(height):
+ print("keyboard height", height)
+
+aurora_system.on_keyboard_height(handle_keyboard)
+aurora_system.system_state_setup()
+# Later, when the page closes:
+aurora_system.system_state_teardown()
+| 类型 | API | 签名 | 说明 |
|---|---|---|---|
| function | camera_setup | camera_setup(position: str=...) -> bool | 初始化相机会话,position 常用 "back" 或 "front"。 |
| function | camera_capture | camera_capture(callback: Callable[[str], None] \| None=...) -> bool | 触发拍照,结果路径交给回调或 on_camera_result。 |
| function | camera_teardown | camera_teardown() -> Any | 停止相机并释放资源。 |
| function | photos_pick | photos_pick(callback: Callable[[list[str]], None] \| None=..., limit: int=..., filter: str=...) -> bool | 打开系统相册选择器。 |
| function | on_photos_picked | on_photos_picked(callback: Callable[[list[str]], None]) -> Any | 注册相册选择结果回调。 |
| function | sensor_start | sensor_start(hz: float=...) -> Any | 按指定频率启动陀螺仪和加速度数据流。 |
| function | sensor_stop | sensor_stop() -> Any | 停止传感器数据流。 |
| function | audio_start | audio_start(handle_base: int=...) -> bool | 启动麦克风采样并推送音频频段数据。 |
| function | audio_stop | audio_stop() -> bool | 停止麦克风采样并释放资源。 |
| function | system_state_setup | system_state_setup() -> Any | 开始监听键盘高度和界面深浅色变化。 |
| function | system_state_teardown | system_state_teardown() -> Any | 停止监听系统状态。 |
| function | on_camera_result | on_camera_result(callback: Callable[[str], None]) -> Any | 注册相机结果回调。 |
| function | on_keyboard_height | on_keyboard_height(callback: Callable[[float], None]) -> Any | 注册键盘高度变化回调。 |
| function | on_style_change | on_style_change(callback: Callable[[str], None]) -> Any | 注册浅色/深色界面变化回调。 |
| 情况 | 应该怎么处理 |
|---|---|
| 没有收到事件 | 确认已调用对应 setup/start 函数,并在退出时调用 teardown/stop。 |
| 权限未授权 | 先用 permission 查询或请求权限,再启动实时事件。 |
| 事件过于频繁 | 只保存需要展示的字段,必要时节流更新 AppUI 状态。 |
| 页面关闭后仍回调 | 在关闭、停止或异常路径里释放监听。 |
AppUI 高频更新组件、批量刷新、回调和实时可视化工具。
+AppUI 高频更新组件、批量刷新、回调和实时可视化工具。
+示例会展示实时更新工具如何批量刷新状态、减少高频 UI 更新的重复工作。
+AppUI 高频更新工具,只更新已有稳定 id 的节点。
+from aurora_toolkit import AuroraLabel, AuroraProgress, frame_batch
+
+label = AuroraLabel("summary", text="Ready")
+progress = AuroraProgress("progress", value=0.0)
+label.bind()
+progress.bind()
+
+with frame_batch():
+ label.text = "Working"
+ progress.value = 0.5
+普通 AppUI 负责稳定结构,Aurora 负责高频数值。下面的 appui.Slider(value=0.0, ...) 是首屏结构占位;连续更新交给 AuroraSliderGroup 和 AuroraTextGroup。
import appui
+from aurora_toolkit import AuroraSliderGroup, AuroraTextGroup, frame_batch
+
+metrics = [
+ {"id": "cpu", "title": "CPU"},
+ {"id": "memory", "title": "Memory"},
+ {"id": "network", "title": "Network"},
+]
+sliders = AuroraSliderGroup("metric.slider", len(metrics))
+labels = AuroraTextGroup("metric.label", len(metrics))
+
+
+def metric_key(metric):
+ return metric["id"]
+
+
+def metric_row(metric):
+ index = metrics.index(metric)
+ return appui.VStack([
+ appui.Text(metric["title"]).id(f"metric.{index}.label"),
+ appui.Slider(value=0.0, minimum=0, maximum=1).id(f"metric.{index}"),
+ ], spacing=6)
+
+
+def push_frame(values):
+ with frame_batch():
+ for index, value in enumerate(values):
+ sliders[index] = value
+ labels[index] = f"{metrics[index]['title']} {value:.0%}"
+
+
+def body():
+ return appui.List([
+ appui.Section("Live", [
+ appui.ForEach(metrics, row_builder=metric_row, key=metric_key)
+ ])
+ ]).navigation_title("Realtime")
+| 类型 | API | 签名 | 说明 |
|---|---|---|---|
| function | frame_batch | frame_batch() -> Any | 把同一帧里的多次更新合并提交,适合进度、数值和实时状态。 |
| function | begin_frame | begin_frame() -> Any | 手动开始一组批量更新,适合已有循环里控制提交时机。 |
| function | end_frame | end_frame() -> None | 结束并提交 begin_frame 开始的更新。 |
| function | callback | callback(event_id: str) | 为 Aurora 控件注册事件回调。 |
| class | AuroraSlider | AuroraSlider(node_id: str, value: float=...) | 更新滑块数值。 |
| class | AuroraGauge | AuroraGauge(node_id: str, value: float=...) | 更新仪表盘或数字指标。 |
| class | AuroraProgress | AuroraProgress(node_id: str, value: float=...) | 更新进度条。 |
| class | AuroraStepper | AuroraStepper(node_id: str, value: int=..., minimum: int=..., maximum: int=..., step: int=...) | 更新步进器数值。 |
| class | AuroraLabel | AuroraLabel(node_id: str, text: str=..., text_color: Optional[str]=...) | 更新文字内容和文字颜色。 |
| class | AuroraTextField | AuroraTextField(node_id: str, text: str=..., placeholder: str=...) | 更新输入框文字和占位内容。 |
| class | AuroraToggle | AuroraToggle(node_id: str, is_on: bool=...) | 更新开关状态。 |
| class | AuroraView | AuroraView(node_id: str, opacity: float=..., offset_x: float=..., offset_y: float=..., scale: float=..., rotation: float=...) | 更新视图透明度、位移、缩放和旋转。 |
| class | AuroraPicker | AuroraPicker(node_id: str, selection: int=..., options: Optional[list[str]]=...) | 更新选择器当前项。 |
| class | AuroraDatePicker | AuroraDatePicker(node_id: str, timestamp: float=...) | 更新日期选择器时间。 |
| class | AuroraColorPicker | AuroraColorPicker(node_id: str, r: int=..., g: int=..., b: int=..., a: int=...) | 更新颜色选择器。 |
| class | AuroraSearchField | AuroraSearchField(node_id: str, query: str=...) | 更新搜索框内容。 |
| class | AuroraSecureField | AuroraSecureField(node_id: str, text: str=...) | 更新安全输入框内容。 |
| class | AuroraTextEditor | AuroraTextEditor(node_id: str, text: str=...) | 更新多行文本内容。 |
| class | AuroraButton | AuroraButton(node_id: str, title: str=..., callback_id: str=...) | 更新按钮标题并绑定回调。 |
| class | AuroraImage | AuroraImage(node_id: str, system_name: str=...) | 更新系统图标名称。 |
| class | AuroraLink | AuroraLink(node_id: str, url: str=...) | 更新链接地址。 |
| class | AuroraGaugeBar | AuroraGaugeBar(node_id: str, value: float=..., min_val: float=..., max_val: float=...) | 更新带范围的指标条。 |
| class | AuroraBadge | AuroraBadge(node_id: str, count: int=...) | 更新角标数字。 |
| class | AuroraSegmentedControl | AuroraSegmentedControl(node_id: str, selection: int=..., segments: Optional[list[str]]=...) | 更新分段控件选中项。 |
| class | AuroraSliderGroup | AuroraSliderGroup(prefix: str, count: int) | 批量更新一组滑块或数值控件。 |
| class | AuroraTextGroup | AuroraTextGroup(prefix: str, count: int) | 批量更新一组文本节点。 |
| class | AuroraImageGroup | AuroraImageGroup(prefix: str, count: int) | 批量更新一组系统图标。 |
| class | AuroraToggleGroup | AuroraToggleGroup(prefix: str, count: int) | 批量更新一组开关。 |
| class | AuroraIntGroup | AuroraIntGroup(prefix: str, count: int) | 批量更新一组整数选择值。 |
| class | AuroraColorGroup | AuroraColorGroup(prefix: str, count: int) | 批量更新一组颜色值。 |
| class | AuroraPointGroup | AuroraPointGroup(prefix: str, count: int) | 批量更新一组地图点位。 |
| class | AuroraAudioGroup | AuroraAudioGroup(band_count: int=...) | 读取实时音频频段数据,用于音频可视化。 |
| class | AuroraMap | AuroraMap(node_id: str, lat: float=..., lon: float=..., span: float=...) | 更新地图中心、缩放范围和大量标记点。 |
| class | AuroraNavigator | AuroraNavigator(node_id: str=...) | 控制轻量页面跳转。 |
| 情况 | 应该怎么处理 |
|---|---|
| 高频更新仍卡顿 | 减少每次更新的字段数量,批量提交相关变化。 |
| 状态不同步 | 确认 UI 展示只读取同一份状态,不混用临时副本。 |
| 页面结构变化频繁 | 结构变化交给普通 AppUI 重建,实时工具只处理已有控件的属性变化。 |
| 调试困难 | 先降到普通 State 写法,确认逻辑正确后再接入实时更新。 |
音视频加载、播放控制和原生播放器。
+脚本级音视频播放:加载远程/本地 URL、播放控制、跳转、调速与系统视频播放器。
+边界:底层 AVPlayer 能力,适合纯脚本或非 AppUI 场景。AppUI 页面内嵌视频请用 AppUI 媒体控件 的+VideoPlayer/PlayerController;短音效用 sound。切换媒体或离开页面时调用cleanup()。
| 项 | 说明 |
|---|---|
| 导入 | import avplayer |
| 适合做什么 | 播放 URL 流、脚本内播放控制、弹出系统视频播放器 |
| 调用时机 | 加载/播放在按钮回调;页面关闭时 cleanup() |
| 推荐顺序 | load(url) 成功 → play() → 控制 → cleanup() |
| 与 AppUI | 不要混用 avplayer 和 appui.VideoPlayer 控制同一媒体 |
下面脚本加载示例流、播放并清理:
+import avplayer
+
+url = (
+ "https://devstreaming-cdn.apple.com/videos/streaming/examples/"
+ "img_bipbop_adv_example_ts/master.m3u8"
+)
+
+if avplayer.load(url):
+ avplayer.set_volume(0.8)
+ avplayer.play()
+ print("时长:", avplayer.duration(), "秒")
+ print("当前:", avplayer.current_time(), "秒")
+ avplayer.pause()
+ avplayer.cleanup()
+else:
+ print("媒体加载失败")
+脚本级播放控制演示;AppUI 内嵌视频请改用 PlayerController。
import appui
+import avplayer
+
+DEMO_URL = (
+ "https://devstreaming-cdn.apple.com/videos/streaming/examples/"
+ "img_bipbop_adv_example_ts/master.m3u8"
+)
+
+state = appui.State(
+ status="未加载",
+ position="0:00",
+ duration="—",
+ loaded=False,
+)
+
+
+def _format_time(seconds):
+ seconds = max(0, int(seconds))
+ return f"{seconds // 60}:{seconds % 60:02d}"
+
+
+def load_media():
+ ok = avplayer.load(DEMO_URL)
+ state.loaded = ok
+ if ok:
+ dur = avplayer.duration()
+ state.batch_update(
+ status="已加载",
+ duration=_format_time(dur) if dur else "—",
+ position="0:00",
+ )
+ else:
+ state.batch_update(status="加载失败", loaded=False)
+
+
+def play_media():
+ if not state.loaded:
+ state.status = "请先加载媒体"
+ return
+ avplayer.play()
+ state.status = "播放中"
+
+
+def pause_media():
+ avplayer.pause()
+ state.status = "已暂停"
+
+
+def refresh_position():
+ if not state.loaded:
+ return
+ state.position = _format_time(avplayer.current_time())
+ if avplayer.is_playing():
+ state.status = "播放中"
+ dur = avplayer.duration()
+ if dur:
+ state.duration = _format_time(dur)
+
+
+def present_player():
+ if not state.loaded:
+ state.status = "请先加载媒体"
+ return
+ avplayer.present_video()
+ state.status = "已打开系统播放器"
+
+
+def cleanup_media():
+ avplayer.cleanup()
+ state.batch_update(
+ loaded=False,
+ status="已清理",
+ position="0:00",
+ duration="—",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("媒体", [
+ appui.LabeledContent("状态", value=state.status),
+ appui.LabeledContent("进度", value=f"{state.position} / {state.duration}"),
+ ]),
+ appui.Section("控制", [
+ appui.Button("加载示例流", action=load_media)
+ .button_style("bordered_prominent"),
+ appui.Button("播放", action=play_media),
+ appui.Button("暂停", action=pause_media),
+ appui.Button("刷新进度", action=refresh_position),
+ appui.Button("系统视频播放器", action=present_player),
+ appui.Button("清理资源", action=cleanup_media, role="destructive"),
+ ], footer="需要网络;HLS 示例流仅供演示。"),
+ ]).navigation_title("音视频播放")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
load(url) | 加载媒体 → bool |
play() / pause() / stop() | 播放控制 |
seek(seconds) | 跳转到指定秒数 |
duration() / current_time() | 总时长 / 当前位置(秒) |
is_playing() | 是否正在播放 |
set_volume(v) / set_rate(r) | 音量 / 倍速 |
present_video() | 弹出系统视频播放器 |
cleanup() | 释放资源 |
import avplayer
+
+if avplayer.load("https://example.com/audio.mp3"):
+ avplayer.set_volume(0.8)
+ avplayer.set_rate(1.0)
+ avplayer.play()
+ avplayer.seek(30)
+ avplayer.pause()
+ avplayer.cleanup()
+| API | 说明 |
|---|---|
load(url) | 成功返回 True;失败不要调用播放 |
duration() | 未准备好时可能为 0 |
present_video() | 仅适合视频;音频不要用 |
cleanup() | 切换媒体或脚本结束前调用 |
Player多路播放或脚本隔离时用 Player(id="main"):
player = avplayer.Player(id="main")
+player.load(url)
+player.play()
+print(player.duration(), player.current_time())
+player.cleanup()
+方法与模块级函数相同,另支持 on_progress、on_end、on_error 等回调(高级场景)。
| 场景 | 推荐 |
|---|---|
| AppUI 页面内嵌视频 | appui.PlayerController + appui.VideoPlayer |
| 纯脚本 / 兼容旧代码 | avplayer |
| 短音效 | sound |
import appui
+
+player = appui.PlayerController(id="main", url="https://example.com/video.m3u8")
+
+def body():
+ return appui.VideoPlayer(player=player)
+| 错误写法 | 后果 | 修正 |
|---|---|---|
未 load 就 play() | 无媒体可播 | 先检查 load(url) |
音频文件 present_video() | 界面不合适 | 仅视频使用 |
退出不 cleanup() | 资源占用 | 切换或关闭时清理 |
| AppUI 混用两套 API | 状态冲突 | 选一种方案 |
| 文档 | 用途 |
|---|---|
| AppUI 媒体控件 | 页面内 VideoPlayer |
| sound | 短音效与本地音频 |
| network | 下载媒体到本地 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+后台 URLSession 大文件下载。
+后台 URLSession 大文件下载:App 进入后台后仍可继续,适合较大文件。
+边界:使用系统后台+URLSession,通过task_id轮询status()获取结果。前台小文件下载请用 network 的download()。destination_path需为 App 可写路径;下载启动放在按钮回调,不要在body()里自动发起。
| 项 | 说明 |
|---|---|
| 导入 | import background_download |
| 适合做什么 | 大 JSON/媒体包后台拉取、断点续传式后台任务 |
| 调用时机 | 用户确认后 download();用 Timer 或按钮轮询 status() |
| 推荐顺序 | download() 拿 task_id → 轮询 status() → 读 path |
| 状态机 | running → completed / failed / cancelled |
下面脚本发起下载并轮询状态:
+import os
+import time
+import background_download
+
+dest = os.path.join(os.path.expanduser("~/Documents"), "httpbin.json")
+
+try:
+ task_id = background_download.download(
+ "https://httpbin.org/json",
+ dest,
+ )
+ print("任务:", task_id)
+
+ for _ in range(30):
+ st = background_download.status(task_id)
+ print(st.get("state"), st.get("progress"))
+ if st.get("state") in ("completed", "failed", "cancelled"):
+ break
+ time.sleep(1)
+
+ if st.get("state") == "completed":
+ print("已保存:", st.get("path"))
+ elif st.get("error"):
+ print("失败:", st.get("error"))
+except background_download.BackgroundDownloadError as exc:
+ print("下载失败:", exc, f"code={exc.code}")
+下载和轮询放在按钮回调;用 Timer 刷新进度状态。
import appui
+import os
+import background_download
+
+DEST = os.path.join(os.path.expanduser("~/Documents"), "demo-download.json")
+URL = "https://httpbin.org/json"
+
+state = appui.State(
+ task_id="",
+ dl_state="空闲",
+ progress="—",
+ status="点击开始下载",
+)
+
+
+def poll():
+ if not state.task_id:
+ return
+ try:
+ st = background_download.status(state.task_id) or {}
+ dl_state = st.get("state", "unknown")
+ prog = st.get("progress", 0.0)
+ state.batch_update(
+ dl_state=dl_state,
+ progress=f"{float(prog) * 100:.0f}%",
+ )
+ if dl_state == "completed":
+ poll_timer.stop()
+ state.status = f"完成: {st.get('path', DEST)}"
+ elif dl_state == "failed":
+ poll_timer.stop()
+ state.status = f"失败: {st.get('error', '未知错误')}"
+ elif dl_state == "cancelled":
+ poll_timer.stop()
+ state.status = "已取消"
+ except background_download.BackgroundDownloadError as exc:
+ poll_timer.stop()
+ state.status = f"查询失败: {exc}"
+
+
+poll_timer = appui.Timer(interval=0.5, repeats=True, action=poll)
+
+
+def start_download():
+ try:
+ task_id = background_download.download(URL, DEST)
+ state.batch_update(
+ task_id=task_id or "",
+ dl_state="running",
+ progress="0%",
+ status="下载中…",
+ )
+ poll_timer.start()
+ except background_download.BackgroundDownloadError as exc:
+ state.status = f"启动失败: {exc}"
+
+
+def cancel_download():
+ if not state.task_id:
+ return
+ try:
+ background_download.cancel(state.task_id)
+ poll_timer.stop()
+ state.batch_update(dl_state="cancelled", status="已请求取消")
+ except background_download.BackgroundDownloadError as exc:
+ state.status = f"取消失败: {exc}"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("任务", [
+ appui.LabeledContent("状态", value=state.dl_state),
+ appui.LabeledContent("进度", value=state.progress),
+ appui.Text(state.task_id or "—")
+ .font("caption")
+ .foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("操作", [
+ appui.Button("开始后台下载", action=start_download)
+ .button_style("bordered_prominent"),
+ appui.Button("取消", action=cancel_download, role="destructive"),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="需网络;大文件更适合后台下载。"),
+ ]).navigation_title("后台下载")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
download(url, destination_path, task_id) | 开始下载 → task_id |
status(task_id) | {state, progress, path, error} |
cancel(task_id) | 取消任务 |
BackgroundDownloadError | 操作失败时抛出 |
download(url, destination_path, task_id=None)
task_id = background_download.download(
+ "https://example.com/file.zip",
+ "/path/to/save.zip",
+)
+| 参数 | 说明 |
|---|---|
url | 下载地址 |
destination_path | 保存路径(App 可写) |
task_id | 可选自定义 ID;省略时自动生成 UUID |
status(task_id) 返回:
| 字段 | 说明 |
|---|---|
state | running / completed / failed / cancelled / unknown |
progress | 0.0–1.0(完成时为 1.0) |
path | 目标路径(完成后有效) |
error | 失败时的错误信息 |
建议轮询间隔 ≥ 0.5 秒。
+cancel(task_id) — 取消进行中的任务,状态变为 cancelled。
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 download() | 刷新时重复下载 | 放进按钮回调 |
destination_path 不可写 | 移动文件失败 | 用 Documents 等沙盒路径 |
不轮询 status() | 不知道何时完成 | Timer 或循环查询 |
| 小文件也用后台下载 | 过度复杂 | 前台用 network |
| 文档 | 用途 |
|---|---|
| network | 前台 HTTP / 小文件下载 |
| background | 后台任务保活 |
| storage | 记录 task_id 等元数据 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+后台任务、保活与 BGTask 调度。
+后台任务:申请延长执行时间、查询剩余时间、调度 BackgroundTasks。
+边界:iOS 后台时间有限(+begin_task约 30 秒);schedule_refresh/schedule_processing需 App 配置对应 BGTask 标识符。不含 background_download 大文件下载。
| 项 | 说明 |
|---|---|
| 导入 | import background |
| 调用时机 | 长任务前 begin_task(),结束 end_task() |
| 适合做什么 | 保存前进度.flush、短同步、注册后台刷新 |
| 推荐顺序 | begin_task() → 工作 → end_task() |
| 状态 | app_state():active / inactive / background |
申请后台时间并查询剩余秒数:
+import background
+import time
+
+if background.begin_task():
+ try:
+ print("应用状态:", background.app_state())
+ print("剩余秒数:", background.remaining_time())
+ time.sleep(2)
+ finally:
+ background.end_task()
+else:
+ print("无法申请后台任务")
+调度后台刷新(需工程内注册 identifier):
+import background
+
+ok = background.schedule_refresh("com.myapp.refresh")
+print("已调度" if ok else "调度失败")
+import appui
+import background
+
+state = appui.State(
+ app_state="—",
+ remaining="—",
+ task="未开始",
+)
+
+
+def refresh_status():
+ state.app_state = background.app_state()
+ state.remaining = f"{background.remaining_time():.1f}s"
+
+
+def start_task():
+ ok = background.begin_task()
+ refresh_status()
+ state.task = "进行中" if ok else "申请失败"
+
+
+def end_task():
+ background.end_task()
+ refresh_status()
+ state.task = "已结束"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("后台任务", [
+ appui.Button("申请延长执行", action=start_task)
+ .button_style("bordered_prominent"),
+ appui.Button("结束任务", action=end_task),
+ appui.Button("刷新状态", action=refresh_status),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("应用状态", value=state.app_state),
+ appui.LabeledContent("剩余时间", value=state.remaining),
+ appui.LabeledContent("任务", value=state.task),
+ ]),
+ ]).navigation_title("后台任务")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
begin_task() | 申请延长后台时间 → bool |
end_task() | 提前结束 |
remaining_time() | 剩余秒数 → float |
app_state() | active / inactive / background |
start_keep_alive() / stop_keep_alive() | 音频保活(慎用) |
schedule_refresh(id, earliest_date=0) | 调度 BGAppRefreshTask |
schedule_processing(id, earliest_date=0, requires_network=False, requires_charging=False) | 调度 BGProcessingTask |
background.begin_task()
+# ... 短任务 ...
+background.end_task()
+earliest_date 为 Unix 时间戳;0 表示约 15 分钟后最早触发。
start_keep_alive() — 启用后台音频会话以延长存活;会消耗电量,仅在有明确需求时使用。
| 错误写法 | 后果 | 修正 |
|---|---|---|
begin_task 后忘记 end_task | 浪费系统配额 | 用 try/finally |
| 在后台跑长计算 | 被系统终止 | 拆任务或用 schedule_processing |
| 未注册 BGTask 标识符 | schedule_* 失败 | 在 Xcode 工程配置 |
| 与 background_download 混淆 | 能力不同 | 大文件下载用专用模块 |
| 文档 | 用途 |
|---|---|
| background_download | 后台 URL 下载 |
| audio_session | 音频会话(保活相关) |
| notification | 本地提醒 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+Face ID / Touch ID 验证。
+Face ID / Touch ID 本机身份确认,支持设备密码回退。
+边界:只证明当前设备用户通过了系统认证,不能替代服务器登录。敏感数据仍用 keychain 保存,不要放进 storage。认证必须由用户动作触发,不要在 body() 里调用。
+| 项 | 说明 |
|---|---|
| 导入 | import biometric |
| 适合做什么 | 打开安全笔记、API Key 页、隐私设置前的二次确认 |
| 调用时机 | 放在按钮回调;用户取消后不要循环弹窗 |
| 推荐顺序 | is_available() → authenticate_with_passcode(reason) → 检查 result["success"] |
| 结果判断 | 看 result.get("success"),不要把整个 dict 当 bool |
下面脚本检查设备能力并尝试解锁:
+import biometric
+
+print("类型:", biometric.biometric_type()) # face_id / touch_id / none
+print("可用:", biometric.is_available())
+
+if biometric.is_available():
+ result = biometric.authenticate_with_passcode("解锁安全笔记")
+ if result.get("success"):
+ print("已解锁")
+ else:
+ print("失败:", result.get("error", "用户取消"))
+else:
+ print("当前设备不可用生物认证")
+认证由按钮触发;成功、取消、失败都写回界面,不打印敏感内容。
+import appui
+import biometric
+
+TYPE_LABELS = {
+ "face_id": "Face ID",
+ "touch_id": "Touch ID",
+ "none": "不可用",
+}
+
+state = appui.State(
+ status="未验证",
+ bio_type=TYPE_LABELS.get(biometric.biometric_type(), "未知"),
+ available="是" if biometric.is_available() else "否",
+ message="点击按钮开始验证",
+)
+
+
+def unlock_biometric_only():
+ if not biometric.is_available():
+ state.message = "生物认证不可用,请检查系统设置"
+ return
+ result = biometric.authenticate("验证身份以继续")
+ _apply_result(result, "仅生物认证")
+
+
+def unlock_with_passcode():
+ if not biometric.is_available():
+ state.message = "生物认证不可用,请检查系统设置"
+ return
+ result = biometric.authenticate_with_passcode("验证身份以继续")
+ _apply_result(result, "生物认证或设备密码")
+
+
+def _apply_result(result, mode):
+ if result.get("success"):
+ state.batch_update(
+ status="已解锁",
+ message=f"{mode} · 认证成功",
+ )
+ else:
+ state.batch_update(
+ status="未解锁",
+ message=result.get("error", "用户取消或认证失败"),
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("设备", [
+ appui.LabeledContent("类型", value=state.bio_type),
+ appui.LabeledContent("可用", value=state.available),
+ ]),
+ appui.Section("验证", [
+ appui.Button("Face ID / Touch ID 解锁", action=unlock_biometric_only)
+ .button_style("bordered_prominent"),
+ appui.Button("允许设备密码回退", action=unlock_with_passcode),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("结果", value=state.status),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("生物认证")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
biometric_type() | 返回 face_id / touch_id / none |
is_available() | 设备与系统设置是否可用 |
authenticate(reason) | 仅 Face ID / Touch ID |
authenticate_with_passcode(reason) | 生物认证 + 设备密码回退 |
biometric_type() — 当前支持的生物认证类型。
is_available() — 能否发起认证(硬件 + 系统设置均就绪)。
import biometric
+
+bio = biometric.biometric_type()
+if biometric.is_available():
+ ...
+authenticate(reason) — 只使用 Face ID / Touch ID,无密码回退。
authenticate_with_passcode(reason) — 推荐用于解锁流程;生物失败时可输入设备密码。
result = biometric.authenticate_with_passcode("打开安全笔记")
+if result.get("success"):
+ # 继续读取 keychain 等操作
+ ...
+else:
+ print(result.get("error"))
+返回字典常见字段:
+| 字段 | 说明 |
|---|---|
success | 认证是否通过 |
error | 失败或取消时的原因 |
注意:reason 会显示在系统弹窗中,用简短中文说明用途,如「验证身份以查看 API Key」。
+| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 authenticate() | 刷新时反复弹窗 | 放进按钮回调 |
if biometric.authenticate(...): | dict 恒为真值 | 检查 result.get("success") |
| 认证成功打印 token | 敏感信息进日志 | 只显示「已解锁」 |
| 用户取消后反复弹窗 | 体验差 | 保持锁定并提示原因 |
| 文档 | 用途 |
|---|---|
| keychain | 认证通过后读取 token |
| permission | 统一权限查询 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+BLE 外设广播模式。
+BLE 外设模式:让本机作为蓝牙外设广播,供其他设备(中心端)扫描连接。
+边界:与 bluetooth 中心端(扫描/连接)互补——本模块做外设广播。需要蓝牙硬件开启;模拟器通常不可用。广播和+update_value()放在按钮回调,不要在body()中自动启动。
| 项 | 说明 |
|---|---|
| 导入 | import ble_peripheral |
| 适合做什么 | 传感器数据广播、简易 BLE 信标、与自定义中心 App 配对 |
| 调用时机 | 用户点击后开始 start_advertising(),离开页面时 stop_advertising() |
| 推荐顺序 | start_advertising() → status() 确认 → update_value() 推送数据 |
| UUID | service_uuid 必填;characteristic_uuid 省略时默认与 service 相同 |
下面脚本启动广播、查看状态并停止:
+import ble_peripheral
+
+SERVICE = "12345678-1234-1234-1234-123456789ABC"
+
+try:
+ ble_peripheral.start_advertising(
+ "PyMiniApp",
+ SERVICE,
+ initial_value="hello",
+ )
+ print(ble_peripheral.status())
+ ble_peripheral.update_value("ping")
+ print(ble_peripheral.status())
+finally:
+ ble_peripheral.stop_advertising()
+开始/停止广播放在按钮回调;用 try/except 处理蓝牙未开启。
import appui
+import ble_peripheral
+
+SERVICE = "12345678-1234-1234-1234-123456789ABC"
+DEVICE_NAME = "PyMiniApp"
+
+state = appui.State(
+ advertising=False,
+ name="—",
+ service="—",
+ payload="hello",
+ status="点击开始广播",
+)
+
+
+def refresh_status():
+ try:
+ st = ble_peripheral.status() or {}
+ state.batch_update(
+ advertising=bool(st.get("advertising")),
+ name=st.get("name") or "—",
+ service=st.get("service_uuid") or "—",
+ )
+ except ble_peripheral.BlePeripheralError as exc:
+ state.status = f"状态查询失败: {exc}"
+
+
+def start_adv():
+ try:
+ ble_peripheral.start_advertising(
+ DEVICE_NAME,
+ SERVICE,
+ initial_value=state.payload,
+ )
+ refresh_status()
+ state.status = "正在广播"
+ except ble_peripheral.BlePeripheralError as exc:
+ state.status = f"无法开始: {exc}(请开启蓝牙)"
+
+
+def stop_adv():
+ try:
+ ble_peripheral.stop_advertising()
+ state.batch_update(
+ advertising=False,
+ name="—",
+ service="—",
+ status="已停止广播",
+ )
+ except ble_peripheral.BlePeripheralError as exc:
+ state.status = f"停止失败: {exc}"
+
+
+def push_value():
+ if not state.advertising:
+ state.status = "请先开始广播"
+ return
+ try:
+ ble_peripheral.update_value(state.payload)
+ state.status = f"已推送: {state.payload}"
+ except ble_peripheral.BlePeripheralError as exc:
+ state.status = f"推送失败: {exc}"
+
+
+def body():
+ label = "停止广播" if state.advertising else "开始广播"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("外设状态", [
+ appui.LabeledContent("广播中", value="是" if state.advertising else "否"),
+ appui.LabeledContent("名称", value=state.name),
+ appui.LabeledContent("服务 UUID", value=state.service),
+ ]),
+ appui.Section("数据", [
+ appui.TextField("特征值", text=state.payload),
+ appui.Button("推送特征值", action=push_value),
+ ]),
+ appui.Section("操作", [
+ appui.Button(label, action=stop_adv if state.advertising else start_adv)
+ .button_style("bordered_prominent"),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="真机 + 蓝牙开启;中心端用 bluetooth 模块扫描连接。"),
+ ]).navigation_title("BLE 外设")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
start_advertising(name, service_uuid, ...) | 开始 BLE 广播 |
stop_advertising() | 停止广播并清理服务 |
status() | {advertising, name, service_uuid} |
update_value(value) | 更新特征值并通知订阅方 |
BlePeripheralError | 操作失败时抛出 |
start_advertising(name, service_uuid, characteristic_uuid=None, initial_value=None)
ble_peripheral.start_advertising(
+ "SensorHub",
+ "12345678-1234-1234-1234-123456789ABC",
+ characteristic_uuid="ABCDEF00-0000-0000-0000-000000000001",
+ initial_value="42",
+)
+| 参数 | 说明 |
|---|---|
name | 广播本地名称 |
service_uuid | 服务 UUID |
characteristic_uuid | 特征 UUID;省略时用 service UUID |
initial_value | 初始特征值(UTF-8 文本) |
特征支持 read / write / notify。
+status() 返回:
| 字段 | 说明 |
|---|---|
advertising | 是否正在广播 |
name | 当前广播名称 |
service_uuid | 服务 UUID |
update_value(value) — 更新特征值(UTF-8 字符串),并向已订阅的中心端发送通知。须先 start_advertising()。
code | 含义 |
|---|---|
unavailable | 蓝牙未开启 |
invalid_input | 缺少 name / service_uuid |
invalid_state | 未广播时调用 update_value |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 start_advertising() | 刷新时重复广播 | 放进按钮回调 |
| 模拟器测试 | 蓝牙不可用 | 用真机 |
| 与 bluetooth 中心模式混淆 | 调错 API | 扫描连接用 bluetooth,广播用本模块 |
页面离开不 stop_advertising() | 持续耗电 | 停止按钮或生命周期回调里清理 |
| 文档 | 用途 |
|---|---|
| bluetooth | BLE 中心端扫描/连接/读写 |
| permission | 蓝牙权限状态查询 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+BLE 扫描、连接、服务发现和特征读写。
+BLE 中心模式:扫描外设、连接、发现服务、读写特征值。
+边界:面向低功耗蓝牙中心端(扫描/连接其他设备),不是普通 HTTP/WebSocket 网络。若要让本机被扫描,请用 ble_peripheral。需要蓝牙硬件与系统授权;扫描、连接放在按钮回调,不要在 body() 里自动执行。
+| 项 | 说明 |
|---|---|
| 导入 | import bluetooth |
| 适合做什么 | 传感器读取、Beacon 探测、自定义 BLE 外设通信 |
| 调用时机 | scan / connect / read / write 放在按钮回调 |
| 推荐顺序 | state() → scan() → connect() → discover_services() → 读写 → disconnect() |
| 数据格式 | write() 传 Base64 字符串;read() 返回 Base64 |
下面脚本检查蓝牙状态并扫描 5 秒:
+import bluetooth
+
+print("状态:", bluetooth.state())
+if bluetooth.state() == "powered_on":
+ devices = bluetooth.scan(duration=5)
+ for d in devices:
+ print(d.get("name"), d.get("uuid"), d.get("rssi"))
+else:
+ print("蓝牙不可用,请到设置中开启")
+连接并发现服务:
+import bluetooth
+
+if bluetooth.state() == "powered_on":
+ devices = bluetooth.scan(duration=5.0)
+ if devices:
+ uuid = devices[0]["uuid"]
+ result = bluetooth.connect(uuid)
+ if result == "ok":
+ try:
+ services = bluetooth.discover_services(uuid)
+ print(f"发现 {len(services)} 个服务")
+ finally:
+ bluetooth.disconnect(uuid)
+ else:
+ print("连接失败:", result)
+扫描、连接分步放在按钮回调;失败时保留可读状态。
+import appui
+import bluetooth
+
+state = appui.State(
+ ble_state="—",
+ device_count="0",
+ status="未扫描",
+ detail="点击扫描开始",
+)
+
+session = {"uuid": None}
+
+
+def refresh_ble_state():
+ state.ble_state = str(bluetooth.state())
+
+
+def scan_devices():
+ refresh_ble_state()
+ if state.ble_state != "powered_on":
+ state.batch_update(
+ status="蓝牙不可用",
+ detail=f"当前: {state.ble_state}",
+ device_count="0",
+ )
+ return
+
+ devices = bluetooth.scan(duration=5.0) or []
+ state.device_count = str(len(devices))
+ if not devices:
+ state.batch_update(
+ status="未发现设备",
+ detail="请靠近外设后重试",
+ )
+ return
+
+ target = devices[0]
+ session["uuid"] = target.get("uuid")
+ name = target.get("name") or "未命名设备"
+ state.batch_update(
+ status="已发现设备",
+ detail=f"{name} · RSSI {target.get('rssi', '—')}",
+ )
+
+
+def connect_first():
+ uuid = session.get("uuid")
+ if not uuid:
+ state.detail = "请先扫描"
+ return
+
+ result = bluetooth.connect(uuid)
+ if result != "ok":
+ state.batch_update(status="连接失败", detail=str(result))
+ return
+
+ services = bluetooth.discover_services(uuid) or []
+ state.batch_update(
+ status="已连接",
+ detail=f"{len(services)} 个服务",
+ )
+
+
+def disconnect_device():
+ uuid = session.get("uuid")
+ if uuid:
+ bluetooth.disconnect(uuid)
+ session["uuid"] = None
+ state.batch_update(status="已断开", detail="连接已释放")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("蓝牙", [
+ appui.LabeledContent("系统状态", value=state.ble_state),
+ appui.LabeledContent("扫描结果", value=state.device_count),
+ appui.Button("刷新状态", action=refresh_ble_state),
+ appui.Button("扫描设备", action=scan_devices)
+ .button_style("bordered_prominent"),
+ appui.Button("连接第一个", action=connect_first),
+ appui.Button("断开", action=disconnect_device, role="destructive"),
+ ]),
+ appui.Section("详情", [
+ appui.LabeledContent("状态", value=state.status),
+ appui.Text(state.detail).foreground_color("secondaryLabel"),
+ ], footer="真机测试;示例只连扫描到的第一台设备。"),
+ ]).navigation_title("蓝牙")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
state() | BLE 状态字符串 |
scan(duration) | 扫描外设列表 |
stop_scan() | 立即停止扫描 |
connect(uuid) | 连接 → ok 或错误信息 |
disconnect(uuid) | 断开连接 |
discover_services(uuid) | 服务与特征列表 |
read(...) | 读特征 → Base64 字符串 |
write(...) | 写特征(Base64 入参)→ bool |
state() 常见返回值:
| 值 | 含义 |
|---|---|
powered_on | 蓝牙已开启,可扫描 |
powered_off | 蓝牙关闭 |
unauthorized | 未授权 |
unsupported | 设备不支持 BLE |
scan(duration=5.0) — 返回 [{uuid, name, rssi}, ...]。
devices = bluetooth.scan(duration=3.0)
+bluetooth.stop_scan() # 提前结束
+connect(uuid) — 成功返回 "ok",失败返回错误描述字符串(不是异常)。
discover_services(uuid) — 返回服务字典列表(含特征 UUID),须先连接成功。
import base64
+
+data_b64 = bluetooth.read(peripheral_uuid, service_uuid, char_uuid)
+raw = base64.b64decode(data_b64) if data_b64 else b""
+
+payload = base64.b64encode(b"hello").decode("ascii")
+ok = bluetooth.write(peripheral_uuid, service_uuid, char_uuid, payload)
+write() 的第四个参数必须是 Base64 字符串,不要直接传 bytes。
state() 确认 powered_onscan() 找到目标 uuidconnect(uuid)discover_services(uuid) 获取 service_uuid / characteristic_uuidread() / write()disconnect(uuid)(建议 try/finally)| 错误写法 | 后果 | 修正 |
|---|---|---|
判断 poweredOn | 永远不匹配 | 使用 powered_on |
write() 直接传 bytes | 类型错误 | 先 base64.b64encode |
不检查 connect 返回值 | 未连接就读写 | 判断 == "ok" |
忘记 disconnect() | 连接残留 | finally 里断开 |
在 body() 里 scan() | 刷新时反复扫描耗电 | 放进按钮回调 |
| 与 ble_peripheral 混淆 | 调错 API | 广播用外设模块 |
| 文档 | 用途 |
|---|---|
| ble_peripheral | 本机作为 BLE 外设广播 |
| network | HTTP 网络请求 |
| websocket | WebSocket 连接 |
| permission | 蓝牙权限状态查询 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+内置扩展库清单、可用范围和替代建议。
+内置 C 扩展库清单、可用范围与替代建议。文档页,不是可导入模块。
+边界:不要写+import c_extensions。第三方含 C/Rust/native 扩展的包,仅已内置或当前环境能安装的可用;opencv、torch、scipy等桌面常见包通常无法直接 pip 到 iOS。优先用内置包、纯 Python 或 PythonIDE 原生模块。
| 项 | 说明 |
|---|---|
| 导入 | 无;使用真实包名如 import numpy |
| 适合做什么 | 判断能否 import、选替代路线、写降级逻辑 |
| 调用时机 | 脚本开头 try/except ImportError |
| 推荐顺序 | 查下表 → 尝试 import → 失败则换路线 |
| OCR/ML | 用 vision、coreml,非 pip 装 CV 包 |
安全使用已内置的 numpy:
try:
+ import numpy as np
+except ImportError:
+ np = None
+
+values = [1, 2, 3, 4, 5]
+if np is None:
+ mean = sum(values) / len(values)
+else:
+ mean = float(np.array(values).mean())
+print(mean)
+在界面展示「内置包可用 / 未内置包需替代」的检查结果(逻辑放按钮回调)。
+import appui
+
+state = appui.State(
+ numpy_ok="—",
+ cv2_ok="—",
+ hint="点击检查",
+)
+
+BUILTIN_TRY = [
+ ("numpy", "numpy_ok"),
+ ("cv2", "cv2_ok"),
+]
+
+
+def check_imports():
+ for module, attr in BUILTIN_TRY:
+ try:
+ __import__(module)
+ setattr(state, attr, "可导入")
+ except ImportError:
+ setattr(state, attr, "未内置")
+
+ if state.numpy_ok == "可导入":
+ state.hint = "数组统计可用 numpy"
+ elif state.cv2_ok == "未内置":
+ state.hint = "图像处理请用 vision / vision_helper / coreml"
+ else:
+ state.hint = "检查完成"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("检查", [
+ appui.Button("检查常见包", action=check_imports)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("numpy", value=state.numpy_ok),
+ appui.LabeledContent("cv2", value=state.cv2_ok),
+ appui.Text(state.hint).foreground_color("secondaryLabel"),
+ ], footer="c_extensions 是文档索引,不是 import 名。"),
+ ]).navigation_title("C 扩展")
+ )
+
+
+appui.run(body, state=state)
+| 包 | 典型用途 |
|---|---|
numpy | 数组、矩阵、统计 |
pandas | 表格数据处理 |
matplotlib | 绘图(亦可 AppUI Chart) |
cryptography / bcrypt / argon2-cffi | 加密与哈希 |
msgpack / msgspec / ujson / rapidjson | 高性能序列化 |
zstandard / xxhash | 压缩与哈希 |
PyYAML / ruamel.yaml | YAML |
regex / tornado / httptools | 工具库 |
完整列表见应用内文档生成源;新增内置包以运行时为准。
+cv2、opencv、scipy、scikit-learn、sklearn、tensorflow、torch、torchvision、lxml、psutil、h5py、numba、cython、gevent 等。
| 需求 | 优先选择 |
|---|---|
| 数组、统计 | numpy、pandas |
| 绘图 | matplotlib、appui Chart |
| OCR、条码、人脸 | vision、vision_helper |
| 图片分类模型 | coreml |
| 哈希、压缩 | xxhash、zstandard |
| iOS 设备能力 | 对应原生模块 |
try:
+ import numpy as np
+except ImportError:
+ np = None
+
+def mean_value(values):
+ if np is None:
+ return sum(values) / len(values)
+ return float(np.array(values).mean())
+| 错误写法 | 后果 | 修正 |
|---|---|---|
import c_extensions | 模块不存在 | 使用真实包名 |
| 桌面 pip 经验直接套 iOS | 安装失败 | 查内置表或换原生模块 |
无 ImportError 处理 | 脚本直接崩溃 | try/except + 纯 Python 降级 |
| 用未内置 CV 包做 OCR | 不可用 | vision |
| 文档 | 用途 |
|---|---|
| coreml | 端侧图像模型 |
| vision_helper | Vision 检测 API |
| 原生能力入口 | iOS 模块索引 |
| 路线 | 何时选 |
|---|---|
| 内置原生模块 | 优先查 iOS 原生能力 |
| pip / 纯 Python | 仅在沙盒允许且已有 wheel 时 |
| C 扩展编译 | 仅高级场景;先评估维护成本 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+日历事件、提醒事项和 EventKit 权限。
+日历与提醒事项:读取/写入 EventKit 事件,管理提醒待办。
+边界:事件和提醒是两个独立权限。写日历用+request_access(),写提醒用request_reminder_access()。时间参数用 Unix timestamp,不是日期字符串。
| 项 | 说明 |
|---|---|
| 导入 | import calendar_events |
| 适合做什么 | 创建会议/专注块、查日程、创建/完成提醒 |
| 调用时机 | 权限、读取、写入都放在按钮回调 |
| 推荐顺序 | 申请对应权限 → 创建/查询 → 保存返回的 id 供删除 |
| 时间格式 | start / end / due_timestamp 均为 Unix 秒级时间戳 |
下面脚本申请日历权限,创建 1 小时专注块:
+import time
+import calendar_events
+
+status = calendar_events.authorization_status()
+if status != "authorized":
+ status = calendar_events.request_access()
+
+if status == "authorized":
+ now = time.time()
+ event = calendar_events.create_event(
+ "专注时间",
+ now + 3600,
+ now + 7200,
+ notes="来自 PythonIDE",
+ )
+ print("已创建:", event)
+else:
+ print("日历未授权:", status)
+权限、创建和查询都放在按钮回调;未授权时展示状态,不继续写入。
+import time
+
+import appui
+import calendar_events
+
+state = appui.State(
+ calendar_auth="未查询",
+ status="等待操作",
+ result="—",
+)
+
+
+def refresh_auth():
+ state.calendar_auth = calendar_events.authorization_status()
+
+
+def create_focus_block():
+ status = calendar_events.request_access()
+ state.calendar_auth = status
+ if status != "authorized":
+ state.batch_update(status="日历未授权", result=str(status))
+ return
+
+ now = time.time()
+ event = calendar_events.create_event(
+ "专注时间",
+ now + 3600,
+ now + 7200,
+ notes="来自 PythonIDE",
+ )
+ state.batch_update(status="事件已创建", result=str(event))
+
+
+def create_reminder():
+ status = calendar_events.request_reminder_access()
+ if status != "authorized":
+ state.batch_update(status="提醒未授权", result=str(status))
+ return
+
+ reminder = calendar_events.create_reminder(
+ "整理笔记",
+ notes="来自 PythonIDE",
+ due_timestamp=time.time() + 3600,
+ )
+ state.batch_update(status="提醒已创建", result=str(reminder))
+
+
+def list_today():
+ status = calendar_events.request_access()
+ state.calendar_auth = status
+ if status != "authorized":
+ state.batch_update(status="日历未授权", result="—")
+ return
+
+ now = time.time()
+ events = calendar_events.get_events(now, now + 86400)
+ state.batch_update(
+ status="查询完成",
+ result=f"未来 24 小时共 {len(events)} 个事件",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("权限", [
+ appui.LabeledContent("日历授权", value=state.calendar_auth),
+ appui.Button("刷新授权状态", action=refresh_auth),
+ ]),
+ appui.Section("操作", [
+ appui.Button("创建专注块", action=create_focus_block)
+ .button_style("bordered_prominent"),
+ appui.Button("创建提醒", action=create_reminder),
+ appui.Button("查询今日事件", action=list_today),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("状态", value=state.status),
+ appui.Text(state.result).font("caption").foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("日历")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
authorization_status() / request_access() | 日历事件权限 |
request_reminder_access() | 提醒事项权限 |
get_calendars() | 可用日历列表 |
get_events(start, end) | 查询时间范围内事件 |
create_event(title, start, end, ...) | 创建日历事件 |
delete_event(event_id) | 删除事件 → bool |
get_reminders() | 提醒列表 |
create_reminder(title, ...) | 创建提醒 |
complete_reminder(id) / delete_reminder(id) | 完成/删除提醒 |
权限:
+status = calendar_events.authorization_status()
+if status != "authorized":
+ status = calendar_events.request_access()
+get_calendars() — 返回可写入的日历列表,可选 calendar_id 传给 create_event。
get_events(start, end, calendar_id=None) — 查询时间范围内事件,start/end 为 Unix timestamp。
create_event(title, start, end, *, notes=None, location=None, calendar_id=None, all_day=False) — 创建事件;end 必须晚于 start。
import time
+
+now = time.time()
+event = calendar_events.create_event(
+ "团队会议",
+ now + 3600,
+ now + 5400,
+ notes="周会",
+ location="会议室 A",
+)
+event_id = event.get("id") # 保存 id 供后续删除
+delete_event(event_id) — 返回 True/False。
提醒需要单独申请权限:
+if calendar_events.request_reminder_access() == "authorized":
+ reminder = calendar_events.create_reminder(
+ "提交报告",
+ notes="下班前",
+ due_timestamp=time.time() + 7200,
+ )
+| API | 说明 |
|---|---|
get_reminders() | 读取提醒列表 |
create_reminder(title, notes=None, due_timestamp=None) | 创建提醒 |
complete_reminder(reminder_id) | 标记完成 |
delete_reminder(reminder_id) | 删除提醒 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
| 写提醒前只申请日历权限 | 提醒仍无权限 | 用 request_reminder_access() |
传日期字符串给 create_event | 时间错误 | 传 Unix timestamp |
不保存事件 id | 后续无法删除 | 保存 create_event 返回的 id |
end <= start | 创建失败 | 确保结束时间晚于开始 |
| 文档 | 用途 |
|---|---|
| notification | 本地定时提醒(非日历) |
| permission | 统一权限查询 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+系统相机拍照与 AppUI CameraPicker。
+系统相机拍照:弹出相机界面,返回 PIL.Image 或 Base64 字符串。
边界:无独立+import camera模块,拍照 API 在 photos 的capture_image()/capture_image_base64()。录像请用 video_recorder。统一 permission 的request("camera")暂不支持弹窗;首次拍照时系统可能提示,或在设置中开启相机权限。
| 项 | 说明 |
|---|---|
| 导入 | import photos |
| 适合做什么 | 拍照存档、视觉识别前置、现场采集图片 |
| 调用时机 | capture_image() 放在按钮回调;不要写在 AppUI body() 中 |
| 推荐顺序 | 用户点击 → capture_image() → 判空 → 处理或 save_image() |
| AppUI 替代 | 声明式界面可用 appui.CameraPicker(回调返回文件路径) |
下面脚本打开相机拍一张,打印尺寸;用户取消时友好退出:
+import photos
+
+image = photos.capture_image()
+if image is None:
+ print("用户取消拍照")
+else:
+ width, height = photos.get_image_size(image)
+ print(f"已拍摄 {width}×{height}")
+ ok = photos.save_image(image, format="JPEG", quality=0.85)
+ print("保存到相册:", "成功" if ok else "失败")
+需要 Base64(如 vision_helper)时:
+import photos
+
+payload = photos.capture_image_base64(format="JPEG", quality=0.85)
+if payload:
+ print("Base64 长度:", len(payload))
+脚本式拍照放按钮回调;界面内也可嵌入 CameraPicker。
import appui
+import photos
+
+state = appui.State(
+ status="等待操作",
+ size="—",
+ path="",
+ message="点击按钮或下方拍照控件",
+)
+
+
+def capture_with_photos():
+ image = photos.capture_image()
+ if image is None:
+ state.batch_update(
+ status="已取消",
+ size="—",
+ path="",
+ message="用户取消拍照",
+ )
+ return
+
+ width, height = photos.get_image_size(image)
+ state.batch_update(
+ status="已拍照",
+ size=f"{width}×{height}",
+ path="内存图像(PIL/bytes)",
+ message="可用 save_image 写入相册",
+ )
+
+
+def on_camera_picked(path):
+ state.batch_update(
+ status="CameraPicker 完成",
+ size="—",
+ path=path or "",
+ message="已取消" if not path else "收到临时文件路径",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("脚本拍照", [
+ appui.Button("打开相机(photos)", action=capture_with_photos)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("声明式拍照", [
+ appui.CameraPicker(
+ source="camera",
+ media_type="photo",
+ on_captured=on_camera_picked,
+ label=appui.Label("拍照", system_image="camera.fill"),
+ ),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("状态", value=state.status),
+ appui.LabeledContent("尺寸", value=state.size),
+ appui.Text(state.path).font("caption").foreground_color("secondaryLabel"),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ], footer="真机测试最可靠;模拟器可能无相机。"),
+ ]).navigation_title("相机")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
capture_image(...) | 系统相机拍照 → PIL.Image / None |
capture_image_base64(...) | 拍照 → Base64 字符串 / None |
save_image(image, ...) | 保存到相册 → bool |
get_image_size(image) | 尺寸 → (width, height) |
appui.CameraPicker(...) | AppUI 相机控件,回调文件路径 |
capture_image(camera="back", show_preview=True, flash="auto", **kwargs)
image = photos.capture_image()
+if image is None:
+ print("取消或失败")
+camera、flash 等参数为 Pythonista 兼容入口,实际由系统相机界面控制。取消返回 None。
capture_image_base64(format="JPEG", quality=0.85) — 直接返回 Base64,适合传给 vision_helper。
| 参数 | 说明 |
|---|---|
source | camera(后置)/ front(前置) |
media_type | photo / video |
on_captured | 回调 (path: str),取消时可能为空 |
label | 自定义按钮内容 |
def on_selfie_captured(path):
+ print(path)
+
+
+appui.CameraPicker(
+ source="front",
+ media_type="photo",
+ on_captured=on_selfie_captured,
+ label=appui.Label("自拍", system_image="camera"),
+)
+CameraPicker 把图片写到临时目录并返回路径;photos.capture_image() 返回内存图像对象。
import permission
+
+entry = permission.status("camera") # 仅查询,不弹窗
+permission.request("camera") 当前不会弹出授权框;直接调用 capture_image(),由系统在需要时提示。
| 错误写法 | 后果 | 修正 |
|---|---|---|
if permission.request("camera"): | 判断错误且暂不支持 | 直接 capture_image() 并判 None |
在 body() 里 capture_image() | 刷新时反复弹相机 | 放进按钮回调 |
不处理 None | 用户取消后崩溃 | if image is None: return |
录像用 capture_image() | 无法录视频 | 用 video_recorder 或 CameraPicker(media_type="video") |
| 文档 | 用途 |
|---|---|
| photos | 选图、相册读写、保存 |
| video_recorder | 摄像头录像 |
| vision_helper | Base64 检测 |
| permission | 相机权限状态查询 |
| AppUI 媒体参考 | CameraPicker 完整签名 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+读取、写入和清空系统剪贴板。
+读写系统剪贴板:复制、粘贴、清空文本。
+边界:适合用户明确的复制/粘贴动作,不是持久化存储。token / 密码请用 keychain;长期保存文本用 storage。剪贴板是系统共享状态,其他 App 可能改写内容。+
| 项 | 说明 |
|---|---|
| 导入 | import clipboard |
| 适合做什么 | 复制链接/代码、粘贴用户剪贴内容、分享前准备 |
| 调用时机 | 放在按钮回调;不要在启动时静默 set 覆盖用户剪贴板 |
| 推荐顺序 | 用户点击复制 → set → 更新状态;粘贴时 get 并判空 |
| 兼容别名 | get_text() / set_text() 等同于 get() / set() |
下面脚本写入、读取并清空剪贴板:
+import clipboard
+
+clipboard.set("来自 PythonIDE 的文本")
+print("剪贴板:", clipboard.get())
+
+clipboard.clear()
+print("清空后:", repr(clipboard.get()))
+复制和粘贴都放在按钮回调里,操作后给出可见反馈。
+import appui
+import clipboard
+
+state = appui.State(
+ text="https://www.python.org",
+ status="等待操作",
+ preview="—",
+)
+
+
+def update_text(value):
+ state.text = value
+
+
+def copy_text():
+ if not state.text.strip():
+ state.status = "没有可复制的内容"
+ return
+ clipboard.set(state.text)
+ state.batch_update(
+ status="已复制到剪贴板",
+ preview=state.text[:60] + ("…" if len(state.text) > 60 else ""),
+ )
+
+
+def paste_text():
+ value = clipboard.get()
+ if value:
+ state.batch_update(
+ text=value,
+ status="已从剪贴板粘贴",
+ preview=value[:60] + ("…" if len(value) > 60 else ""),
+ )
+ else:
+ state.status = "剪贴板为空"
+
+
+def clear_clipboard():
+ clipboard.clear()
+ state.batch_update(status="已清空剪贴板", preview="—")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("文本", [
+ appui.TextField("内容", text=state.text, on_change=update_text),
+ appui.LabeledContent("预览", value=state.preview),
+ ]),
+ appui.Section("操作", [
+ appui.Button("复制", action=copy_text)
+ .button_style("bordered_prominent"),
+ appui.Button("粘贴", action=paste_text),
+ appui.Button("清空剪贴板", action=clear_clipboard, role="destructive"),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("剪贴板")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
get() | 读取剪贴板文字 → str(空时 "") |
set(text) | 写入字符串 |
clear() | 清空剪贴板 |
get_text() | 兼容别名,同 get() |
set_text(text) | 兼容别名,同 set() |
get() — 获取当前剪贴板文本,无内容时返回空字符串。
set(text) — 将字符串写入系统剪贴板,会覆盖当前内容。
import clipboard
+
+clipboard.set("要复制的链接")
+value = clipboard.get()
+clear() — 清空剪贴板(内部写入空字符串)。
clipboard.clear()
+| 别名 | 等价于 |
|---|---|
get_text() | get() |
set_text(text) | set(text) |
旧脚本可直接迁移,无需改调用名。
+| 错误写法 | 后果 | 修正 |
|---|---|---|
启动时自动 set | 静默覆盖用户剪贴板 | 只在用户点击后写入 |
| 用剪贴板存 token | 易被其他 App 读到 | 使用 keychain |
| 复制后无反馈 | 用户不知道是否成功 | 更新 State 状态文案 |
| 缓存剪贴板旧值 | 其他 App 改写后仍用旧数据 | 每次粘贴重新 get() |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+原生弹窗、HUD 和控制台样式。
+控制台与原生弹窗:阻塞式 alert / input_alert、HUD 提示、控制台颜色与链接(Pythonista 兼容 API)。
边界:弹窗 API 阻塞调用线程直到用户操作;在 AppUI 中应放在按钮回调。+set_color/clear作用于脚本控制台输出,不是 SwiftUI 界面样式。
| 项 | 说明 |
|---|---|
| 导入 | import console |
| 适合做什么 | 快速确认、输入一行文字、登录框、HUD 反馈 |
| 调用时机 | 弹窗放在按钮回调;勿在 body() 自动弹出 |
| 推荐顺序 | 需要输入 → input_alert;仅确认 → alert |
| 取消 | 用户点取消抛 KeyboardInterrupt |
import console
+
+choice = console.alert("确认", "确定删除这条记录吗?", button1="删除", button2="取消")
+if choice == 1:
+ print("已确认删除")
+
+name = console.input_alert("输入", "你的名字", input_text="")
+console.hud_alert("已保存", icon="checkmark", duration=1.5)
+控制台样式:
+import console
+
+console.set_color(0.2, 0.6, 1.0)
+print("蓝色文字")
+console.set_color() # 重置
+console.write_link("Python 官网", "https://www.python.org")
+弹窗与 HUD 放在按钮回调。
+import appui
+import console
+
+state = appui.State(
+ last_input="—",
+ last_choice="—",
+ status="点击按钮体验",
+)
+
+
+def show_alert():
+ try:
+ idx = console.alert(
+ "确认操作",
+ "这是一个原生 alert 示例",
+ button1="好的",
+ button2="算了",
+ )
+ state.last_choice = f"按钮 {idx}"
+ state.status = "alert 已关闭"
+ except KeyboardInterrupt:
+ state.status = "用户取消"
+
+
+def show_input():
+ try:
+ text = console.input_alert("输入", "说点什么", input_text="Hello")
+ state.last_input = text
+ state.status = "input 已完成"
+ console.hud_alert("收到", duration=1.2)
+ except KeyboardInterrupt:
+ state.status = "输入已取消"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("弹窗", [
+ appui.Button("显示 Alert", action=show_alert)
+ .button_style("bordered_prominent"),
+ appui.Button("显示 Input", action=show_input),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("Alert", value=state.last_choice),
+ appui.LabeledContent("Input", value=state.last_input),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("控制台弹窗")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
alert(title, message, *, button1='OK', ...) | 多按钮弹窗 → 1-based 索引 |
input_alert(title, message, input_text='', ...) | 带输入框 → str |
login_alert(...) | 用户名+密码 → (user, password) |
password_alert(...) | 密码框 → str |
hud_alert(message, icon='', duration=1.5) | 短暂 HUD 提示 |
set_color(r, g, b) / set_font(name, size) | 控制台样式 |
clear() | 清空控制台 |
write_link(title, url) | 输出可点击链接 |
idx = console.alert("标题", "正文", button1="确定", button2="取消")
+# 1 | 2 | 3;取消抛 KeyboardInterrupt
+text = console.input_alert("输入", ok_button_title="完成")
+user, pw = console.login_alert("登录")
+secret = console.password_alert("密码")
+原生能力入口不可用时回退到终端 input()。
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 alert | 每次刷新弹窗 | 放进按钮回调 |
忽略 KeyboardInterrupt | 取消时崩溃 | try/except KeyboardInterrupt |
用 console 做 AppUI 布局 | API 不匹配 | 用 appui 组件 |
| 主线程长阻塞 | 界面假死 | 弹窗本身会阻塞,避免嵌套多层 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+联系人读取、编辑、选择器和 vCard 导入导出。
+访问系统通讯录:权限、读取、搜索、编辑、分组、系统选择器与 vCard 导入导出。
+边界:联系人属于敏感数据。默认只读当前功能需要的字段和数量;列表页用+limit/offset;修改后需save()提交。token 等凭据不要用通讯录存,请用 keychain。
| 项 | 说明 |
|---|---|
| 导入 | import contacts |
| 适合做什么 | 选人、搜索联系人、展示卡片、创建/编辑联系人 |
| 调用时机 | 读取和选择器放在按钮回调;不要首屏批量读取 |
| 推荐顺序 | is_authorized → request_access → 读取/选择 → 修改后 save() |
| 编辑事务 | add_person 等改动需 save() 落盘;失败时 revert() |
下面脚本申请权限,读取前 10 个联系人并搜索名字:
+import contacts
+
+if not contacts.is_authorized():
+ contacts.request_access()
+
+if not contacts.is_authorized():
+ print("通讯录未授权")
+else:
+ people = contacts.get_all_people(limit=10)
+ for person in people:
+ name = getattr(person, "full_name", "") or "未命名"
+ print(person.identifier, name)
+
+ matches = contacts.find("张")
+ print("搜索到", len(matches), "个匹配")
+读取和系统选择器都放在按钮回调;列表不加载头像和 vCard。
+import appui
+import contacts
+
+state = appui.State(
+ status="等待操作",
+ selected="—",
+ people=[],
+)
+
+
+def person_key(person):
+ return person["id"]
+
+
+def person_row(person):
+ return appui.VStack([
+ appui.Text(person["name"]),
+ appui.Text(person["phone"]).font("caption").foreground_color("secondaryLabel"),
+ ], spacing=2)
+
+
+def load_people():
+ if not contacts.is_authorized():
+ contacts.request_access()
+ if not contacts.is_authorized():
+ state.batch_update(status="未授权", people=[], selected="—")
+ return
+
+ rows = []
+ for person in contacts.get_all_people(limit=20):
+ phones = getattr(person, "phone_numbers", []) or []
+ phone = phones[0] if phones else "—"
+ rows.append({
+ "id": person.identifier or str(person.id),
+ "name": (
+ getattr(person, "full_name", "")
+ or getattr(person, "organization", "")
+ or "未命名联系人"
+ ),
+ "phone": str(phone),
+ })
+ state.batch_update(
+ status=f"已读取 {len(rows)} 个联系人",
+ people=rows,
+ )
+
+
+def pick_one():
+ if not contacts.is_authorized():
+ contacts.request_access()
+ if not contacts.is_authorized():
+ state.status = "未授权,无法打开选择器"
+ return
+
+ picked = contacts.pick_contact()
+ if not picked:
+ state.status = "用户取消选择"
+ return
+ name = getattr(picked, "full_name", "") or "未命名联系人"
+ state.batch_update(
+ selected=name,
+ status="已通过系统选择器选中联系人",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("操作", [
+ appui.Button("读取联系人", action=load_people)
+ .button_style("bordered_prominent"),
+ appui.Button("系统选择器选人", action=pick_one),
+ appui.LabeledContent("状态", value=state.status),
+ appui.LabeledContent("已选", value=state.selected),
+ ]),
+ appui.Section("列表", [
+ appui.ForEach(state.people, row_builder=person_row, key=person_key),
+ ], footer="默认 limit=20,不含头像与 vCard。"),
+ ]).navigation_title("通讯录")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_authorized() / request_access() | 权限判断与申请 |
get_all_people(limit, offset) | 分页读取联系人 |
get_person(id, ...) | 读取单个联系人详情 |
find(name) | 按名字搜索 |
pick_contact() | 系统选择器选一人 |
add_person / save() / revert() | 编辑事务 |
export_vcards / import_vcards | vCard 导入导出 |
| API | 说明 |
|---|---|
authorization_status() | 查询授权状态 |
is_authorized() | 是否可访问通讯录 |
request_access() | 申请权限 |
manage_limited_access() | 调整「有限访问」联系人 |
capabilities() | 当前设备/系统能力 |
if not contacts.is_authorized():
+ contacts.request_access()
+get_all_people(limit=100, offset=0) — 分页读取,列表页务必设 limit。
get_person(person_or_id, include_image_data=False, include_vcard=False) — 读取详情;头像和 vCard 按需开启。
people = contacts.get_all_people(limit=20, offset=0)
+person = contacts.get_person(people[0], include_image_data=False)
+matches = contacts.find("Ada")
+by_phone = contacts.find_by_phone("138")
+by_email = contacts.find_by_email("ada@example.com")
+其他:get_me() 读取「我的名片」。
修改不是立即落盘,需 save() 提交;失败时 revert() 回滚。
person = contacts.new_person(seed={"given_name": "Ada", "family_name": "Lovelace"})
+contacts.add_person(person)
+try:
+ contacts.save()
+except Exception:
+ contacts.revert()
+ raise
+| API | 说明 |
|---|---|
new_person(seed=...) | 创建新联系人对象 |
add_person(person) | 加入待提交队列 |
remove_person(person) | 标记删除 |
edit_person(person, **kwargs) | 修改字段 |
save() | 提交到系统通讯录 |
revert() | 放弃待提交修改 |
分组与容器:get_all_groups、add_group、get_people_in_group、get_all_containers 等。
由用户主动选择,比无提示批量读取更稳妥:
+picked = contacts.pick_contact()
+if picked:
+ contacts.show_person(picked, allows_editing=False, allows_actions=True)
+
+multi = contacts.pick_contacts()
+prop = contacts.pick_property(kind="phone")
+pick_contact() 返回 None 表示用户取消。
data = contacts.export_vcards([person])
+contacts.import_vcards(data)
+history = contacts.get_change_history(token=None)
+| 错误写法 | 后果 | 修正 |
|---|---|---|
| 首屏读取全部联系人 | 隐私体验差、可能很慢 | 按钮触发 + limit |
修改后忘记 save() | 变更未落盘 | 成功路径调用 save() |
| 保存失败继续用待提交对象 | 状态不一致 | revert() 后重试 |
默认 include_image_data=True | 内存和隐私成本高 | 只在头像页按需加载 |
| 文档 | 用途 |
|---|---|
| permission | 统一权限查询(Bridge 对 contacts 支持有限) |
| keychain | 保存 token,不用通讯录 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+Core ML 模型加载、图片推理和模型信息读取。
+Core ML 模型推理:列出内置模型、加载、对图片做分类预测。
+边界:面向 App 内置的图像分类模型(如 MobileNetV2);不含自定义模型训练、模型转换。预测需要本地图片文件路径。+
| 项 | 说明 |
|---|---|
| 导入 | import coreml |
| 适合做什么 | 图片分类、快速识别物体类别 |
| 调用时机 | load_model / predict_image 放在按钮回调 |
| 推荐顺序 | list_models() → load_model(name) → predict_image(model, path) |
| 返回 | Top-5 预测列表,每项含 identifier 与 confidence |
列出可用模型并对一张照片分类:
+import coreml
+
+print("可用模型:", coreml.list_models())
+model = coreml.load_model("MobileNetV2")
+print(coreml.model_info(model))
+
+results = coreml.predict_image(model, "/path/to/photo.jpg")
+for item in results[:3]:
+ print(item.get("identifier"), item.get("confidence"))
+先用 photos 选图,再预测;结果展示在界面上。
+import appui
+import coreml
+import photos
+
+state = appui.State(
+ models="—",
+ status="尚未预测",
+ top_label="—",
+)
+
+
+def refresh_models():
+ names = coreml.list_models() or []
+ state.models = ", ".join(names[:3]) + ("…" if len(names) > 3 else "")
+
+
+def predict_selected():
+ refresh_models()
+ names = coreml.list_models() or []
+ if not names:
+ state.status = "无可用模型"
+ return
+
+ image = photos.pick_image()
+ if not image:
+ state.status = "未选择图片"
+ return
+
+ import os
+ path = os.path.join(os.path.expanduser("~/Documents"), "coreml-pick.jpg")
+ image.save(path, format="JPEG")
+
+ model = coreml.load_model(names[0])
+ results = coreml.predict_image(model, path) or []
+ if not results:
+ state.batch_update(status="预测失败", top_label="—")
+ return
+
+ best = results[0]
+ state.batch_update(
+ status=f"共 {len(results)} 条结果",
+ top_label=f"{best.get('identifier')} ({best.get('confidence', 0):.2f})",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("预测", [
+ appui.Button("选图并分类", action=predict_selected)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("模型", value=state.models),
+ appui.LabeledContent("结果", value=state.status),
+ appui.LabeledContent("Top-1", value=state.top_label),
+ ]),
+ ]).navigation_title("Core ML")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
list_models() | 可用模型名列表 |
load_model(name) | 加载模型,返回句柄 |
predict_image(model, image_path) | 图片分类 → Top-5 列表 |
model_info(model) | 模型元数据字典 |
model = coreml.load_model("MobileNetV2")
+info = coreml.model_info(model)
+results = coreml.predict_image(model, "/path/photo.jpg")
+# [{"identifier": "...", "confidence": 0.92}, ...]
+模型句柄为 _ModelHandle,可用 model.name 查看名称。加载失败抛 FileNotFoundError;原生能力入口不可用抛 RuntimeError。
| 错误写法 | 后果 | 修正 |
|---|---|---|
| 图片路径不存在 | 预测失败 | 先用 photos.pick_image() 获取有效路径 |
| 模型名拼写错误 | FileNotFoundError | 先 list_models() 确认名称 |
在 body() 里自动预测 | 每次刷新重复推理 | 放进按钮回调 |
| 期望 OCR 或条码 | 能力不匹配 | 用 vision 或 vision_helper |
| 文档 | 用途 |
|---|---|
| photos | 选图获取路径 |
| vision_helper | Vision 框架检测与分类 |
| c_extensions | 未内置深度学习包的替代路线 |
import appui
+import coreml
+
+
+def body():
+ return appui.Form([
+ appui.Section("Core ML", [
+ appui.Text("选择模型后在回调中调用 coreml.predict_image"),
+ ])
+ ])
+预期效果:打开 AppUI 表单页,后续在按钮回调中加载模型并展示预测结果。
+运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+MiniApp 作用域内 SQLite 数据库和 JSON collection。
+MiniApp 作用域内的原生 SQLite 持久化:可查询的历史、收藏、缓存与业务表。
+边界:数据库文件自动放在当前 MiniApp 数据目录,并由宿主原生 SQLite 桥管理连接、statement 生命周期和事务。小型开关/主题用 storage;token/密码用 keychain。只传文件名(如+"media"),不要传绝对路径或../;MiniApp 不要直接import sqlite3。
| 项 | 说明 |
|---|---|
| 导入 | import database |
| 适合做什么 | 播放历史、收藏、离线缓存、可排序列表、业务表 |
| 两条路径 | 大多数场景用 collection();需要索引/JOIN 用 open() + SQL |
| 调用时机 | 启动时读取、按钮回调写入;长任务结束可 close() |
| 安全写入 | SQL 用 ? 参数绑定,不要拼接用户输入 |
下面脚本用 Collection API 写入并列出收藏:
+import database
+
+favorites = database.collection("favorites")
+
+favorites.upsert("movie-1", {"title": "示例电影", "rating": 9.2})
+print(favorites.get("movie-1"))
+print(favorites.count())
+
+for item in favorites.list(order_by="updated_at desc", limit=10):
+ print(item["title"], item["rating"])
+用 Collection 管理列表示例数据;按钮触发增删查。
+import appui
+import database
+
+favorites = database.collection("demo_favorites")
+
+state = appui.State(
+ status="等待操作",
+ count="0",
+ items=[],
+)
+
+
+def item_key(row):
+ return row["id"]
+
+
+def item_row(row):
+ return appui.HStack([
+ appui.Text(row["title"]),
+ appui.Spacer(),
+ appui.Text(row["rating"]).foreground_color("secondaryLabel"),
+ ])
+
+
+def refresh_list():
+ rows = []
+ for entry in favorites.items(order_by="updated_at desc", limit=10):
+ value = entry.get("value") or {}
+ rows.append({
+ "id": entry["key"],
+ "title": value.get("title", entry["key"]),
+ "rating": f"⭐ {value.get('rating', '—')}",
+ })
+ state.batch_update(
+ status=f"共 {favorites.count()} 条记录",
+ count=str(favorites.count()),
+ items=rows,
+ )
+
+
+def add_sample():
+ key = f"item-{favorites.count() + 1}"
+ favorites.upsert(key, {"title": f"示例 {key}", "rating": 8.5})
+ refresh_list()
+
+
+def clear_all():
+ favorites.clear()
+ refresh_list()
+ state.status = "已清空 collection"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("操作", [
+ appui.Button("添加示例", action=add_sample)
+ .button_style("bordered_prominent"),
+ appui.Button("刷新列表", action=refresh_list),
+ appui.Button("清空", action=clear_all, role="destructive"),
+ appui.LabeledContent("状态", value=state.status),
+ ]),
+ appui.Section("列表", [
+ appui.ForEach(state.items, row_builder=item_row, key=item_key),
+ ], footer=f"当前 {state.count} 条 · collection: demo_favorites"),
+ ]).navigation_title("数据库")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
collection(name) | 默认库中的 JSON 文档集合(推荐) |
open(name) / connect(name) | 打开 MiniApp 作用域 SQLite |
database_path(name) | 调试用的数据库绝对路径 |
close_default_database() / close_all() | 关闭默认库或当前进程打开的库 |
Collection.upsert/get/delete | 增删改查 JSON 记录 |
Collection.items/list | 排序分页列表 |
Database.execute/query/transaction | 原生 SQL 操作 |
| 目标 | 首选 API |
|---|---|
| 收藏、历史、缓存、源列表 | database.collection(name) |
| 索引、JOIN、迁移、事务 | database.open(name) + SQL |
| 少量开关、主题 | storage |
| token、密码 | keychain |
大多数 MiniApp 的首选,底层 SQLite 存 JSON 文档:
+import database
+
+history = database.collection("history")
+history.upsert("ep-42", {"id": "ep-42", "title": "第 42 集", "position": 128.5})
+row = history.get("ep-42")
+items = history.list(order_by="updated_at desc", limit=20)
+history.delete("ep-42")
+history.clear()
+n = history.count()
+| API | 说明 |
|---|---|
upsert(key, value) | 插入或更新 JSON |
get(key, default=None) | 读取;不存在返回 default |
delete(key) | 删除一条 |
clear() | 清空当前 collection |
count() | 记录数 |
items(order_by, limit, offset) | 含 key/value/时间戳 |
list(order_by, limit, offset) | 只返回 value 列表 |
migrate_from_storage(key, key_field="id") | 从 storage JSON 迁移 |
order_by 支持:updated_at desc/asc、created_at desc/asc、key desc/asc。
需要明确表结构时使用:
+import database
+
+db = database.open("media")
+db.executescript("""
+CREATE TABLE IF NOT EXISTS episodes (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ position REAL NOT NULL DEFAULT 0
+);
+""")
+
+with db.transaction():
+ db.execute(
+ "INSERT OR REPLACE INTO episodes(id, title, position) VALUES (?, ?, ?)",
+ ["ep-42", "第 42 集", 128.5],
+ )
+
+row = db.query_one("SELECT * FROM episodes WHERE id = ?", ["ep-42"])
+db.close()
+| API | 说明 |
|---|---|
execute(sql, params) | 执行 SQL,返回影响行数 |
query(sql, params) | 返回 list[dict] |
query_one(sql, params, default) | 单行或默认值 |
scalar(sql, params, default) | 第一列标量 |
transaction() | 原子事务,异常回滚 |
user_version | schema 版本号(PRAGMA user_version) |
close() | 关闭当前连接 |
database.open("media") 自动变为 media.db,拒绝 ../ 和绝对路径。
history = database.collection("history")
+count = history.migrate_from_storage("video.history", key_field="id", remove=True)
+print("已迁移", count, "条")
+| 错误写法 | 后果 | 修正 |
|---|---|---|
大列表塞进 storage | 慢、难查询 | 用 collection() |
| token 写进 SQLite | 不安全 | 用 keychain |
open("../data.db") | 路径被拒绝 | 只传 "media" 等文件名 |
直接 import sqlite3 | 真机可能崩溃或绕过宿主隔离 | 用 import database |
| SQL 拼接用户输入 | 注入风险 | 用 ? 参数绑定 |
长时间不 close() | 连接泄漏 | 任务结束时关闭 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+设备、屏幕、电池和系统状态。
+读取当前 iPhone/iPad 的设备、系统、屏幕、电池、内存、温度、区域和语言信息。
+边界:用于界面适配、环境诊断和状态展示。identifier_for_vendor() 不是登录或支付凭据;改亮度要有明确用户动作。
+| 项 | 说明 |
|---|---|
| 导入 | import device |
| 适合做什么 | 设备信息页、布局适配、电量/低电量模式判断、诊断面板 |
| 调用时机 | 放在按钮回调或启动时读一次;不要写在 AppUI body() 里反复调用 |
| 推荐顺序 | 用户点击刷新 → 读取 model / screen_size 等 → 写回 State |
| 特殊值 | battery_level() 不可用时返回 -1.0,不要格式化成百分比 |
下面脚本打印设备型号、系统版本、屏幕和电量信息:
+import device
+
+print("设备:", device.name(), device.model())
+print("系统:", device.system_name(), device.system_version())
+width, height = device.screen_size()
+print(f"屏幕: {width:.0f}×{height:.0f} scale={device.screen_scale()}")
+
+level = device.battery_level()
+if level < 0:
+ print("电量: 不可用")
+else:
+ print(f"电量: {level:.0%} 状态: {device.battery_state()}")
+
+print("热状态:", device.thermal_state())
+print("低电量模式:", device.is_low_power_mode())
+print("区域:", device.locale(), device.timezone())
+设备信息由按钮触发读取,结果写进 State;不要在 body() 里直接调用 device.*。
import appui
+import device
+
+state = appui.State(
+ model="—",
+ system="—",
+ screen="—",
+ battery="—",
+ battery_state="—",
+ thermal="—",
+ low_power="—",
+ message="点击按钮刷新",
+)
+
+
+def format_battery(level):
+ if level < 0:
+ return "不可用"
+ return f"{level:.0%}"
+
+
+def format_memory(bytes_value):
+ if bytes_value <= 0:
+ return "—"
+ gb = bytes_value / (1024 ** 3)
+ return f"{gb:.1f} GB"
+
+
+def refresh_device_info():
+ width, height = device.screen_size()
+ state.batch_update(
+ model=device.model(),
+ system=f"{device.system_name()} {device.system_version()}",
+ screen=f"{width:.0f}×{height:.0f} @ {device.screen_scale():.0f}x",
+ battery=format_battery(device.battery_level()),
+ battery_state=device.battery_state(),
+ thermal=device.thermal_state(),
+ low_power="是" if device.is_low_power_mode() else "否",
+ message=f"内存 {format_memory(device.total_memory())} · 运行 {device.system_uptime():.0f}s",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("设备", [
+ appui.LabeledContent("型号", value=state.model),
+ appui.LabeledContent("系统", value=state.system),
+ appui.LabeledContent("屏幕", value=state.screen),
+ ]),
+ appui.Section("电源", [
+ appui.LabeledContent("电量", value=state.battery),
+ appui.LabeledContent("充电状态", value=state.battery_state),
+ appui.LabeledContent("低电量模式", value=state.low_power),
+ appui.LabeledContent("热状态", value=state.thermal),
+ ]),
+ appui.Section("操作", [
+ appui.Button("刷新设备信息", action=refresh_device_info)
+ .button_style("bordered_prominent"),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("设备信息")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
name() / model() | 设备名称 / 型号 |
system_name() / system_version() | 系统名称与版本 |
screen_size() / screen_scale() | 屏幕尺寸与 scale |
battery_level() / battery_state() | 电量与充电状态 |
thermal_state() / is_low_power_mode() | 热状态与低电量模式 |
total_memory() / processor_count() | 物理内存与 CPU 核心数 |
screen_brightness() / set_screen_brightness(v) | 读取 / 设置亮度 |
locale() / timezone() / preferred_languages() | 区域、时区与语言 |
| API | 返回 | 说明 |
|---|---|---|
name() | str | 用户设置的设备名称 |
model() | str | 设备型号标识 |
system_name() | str | 如 iOS |
system_version() | str | 系统版本号 |
identifier_for_vendor() | str | 开发者维度设备 ID;重装可能变化 |
system_uptime() | float | 开机以来秒数 |
orientation() | str | 当前屏幕方向 |
print(device.model(), device.system_version())
+print(device.identifier_for_vendor())
+| API | 返回 | 说明 |
|---|---|---|
screen_width() / screen_height() | float | 屏幕点数宽高 |
screen_size() | tuple | (width, height) |
screen_scale() | float | Retina scale |
screen_brightness() | float | 当前亮度 0.0–1.0 |
set_screen_brightness(value) | None | 设置亮度,需用户动作触发 |
width, height = device.screen_size()
+device.set_screen_brightness(0.6) # 仅在按钮回调里调用
+| API | 返回 | 说明 |
|---|---|---|
battery_level() | float | 0.0–1.0;不可用为 -1.0 |
battery_state() | str | unknown / unplugged / charging / full |
thermal_state() | str | nominal / fair / serious / critical |
is_low_power_mode() | bool | 是否低电量模式 |
total_memory() | int | 物理内存字节数 |
processor_count() | int | CPU 核心数 |
level = device.battery_level()
+if level >= 0:
+ print(f"{level:.0%}", device.battery_state())
+if device.is_low_power_mode():
+ print("低电量模式,可降低刷新频率")
+| API | 返回 | 说明 |
|---|---|---|
locale() | str | 当前区域标识 |
timezone() | str | 时区 |
preferred_languages() | list[str] | 用户首选语言列表 |
print(device.locale(), device.timezone())
+print(device.preferred_languages())
+| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里读 device.* | 每次刷新都重复调用 | 放进按钮回调或启动时读一次 |
battery_level() 为 -1.0 仍格式化成 % | 显示异常百分比 | 显示「不可用」 |
脚本自动 set_screen_brightness | 改变用户设备体验 | 仅在明确按钮动作里调用 |
把 identifier_for_vendor() 当登录凭据 | 不安全、可能变化 | 仅作设备区分,不作鉴权 |
| 文档 | 用途 |
|---|---|
| storage | 本地数据存储 |
| permission | 系统权限状态 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+原生对话框、表单和日期选择。
+阻塞式原生对话框:确认、输入、列表选择、简单表单和日期选择。
+边界:调用会阻塞脚本直到用户响应;取消时抛出+KeyboardInterrupt。适合短脚本临时交互;复杂页面、多步骤流程请用 AppUIForm/.sheet/.alert,不要在 AppUI 回调里连续弹多个阻塞对话框。
| 项 | 说明 |
|---|---|
| 导入 | import dialogs |
| 适合做什么 | 脚本开始前问一个值、危险操作确认、少量字段收集 |
| 调用时机 | 放在明确动作后;AppUI 里仅在按钮回调中偶尔使用 |
| 推荐顺序 | try 调用 → 处理返回值 → except KeyboardInterrupt 处理取消 |
| 取消行为 | 用户点取消会抛 KeyboardInterrupt,不是返回空值 |
下面脚本依次弹出输入框和列表选择:
+import dialogs
+
+try:
+ name = dialogs.input_alert("名称", placeholder="例如:Ada")
+ mode = dialogs.list_dialog("模式", ["快速", "精确"])
+ print(name, "→", mode)
+except KeyboardInterrupt:
+ print("用户取消")
+在按钮回调里触发对话框;用 try/except 处理取消,结果写回 State。
import appui
+import dialogs
+
+state = appui.State(
+ message="点击按钮体验对话框",
+ last_result="—",
+)
+
+
+def show_confirm():
+ try:
+ index = dialogs.alert(
+ "确认操作",
+ "这是一个演示对话框",
+ "继续",
+ "取消",
+ )
+ state.batch_update(
+ message="用户点了确认" if index == 1 else "用户选了其他按钮",
+ last_result=f"alert 返回 {index}",
+ )
+ except KeyboardInterrupt:
+ state.batch_update(message="用户取消", last_result="alert 已取消")
+
+
+def ask_name():
+ try:
+ name = dialogs.input_alert("你的名字", placeholder="请输入")
+ state.batch_update(
+ message=f"你好,{name}",
+ last_result=name,
+ )
+ except KeyboardInterrupt:
+ state.batch_update(message="输入已取消", last_result="—")
+
+
+def pick_color():
+ try:
+ color = dialogs.list_dialog("选择颜色", ["红色", "绿色", "蓝色"])
+ state.batch_update(
+ message=f"已选择 {color}",
+ last_result=color,
+ )
+ except KeyboardInterrupt:
+ state.batch_update(message="选择已取消", last_result="—")
+
+
+def show_hud():
+ dialogs.hud_alert("已保存!", duration=1.2)
+ state.message = "HUD 提示已显示"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("对话框", [
+ appui.Button("确认框", action=show_confirm)
+ .button_style("bordered_prominent"),
+ appui.Button("输入框", action=ask_name),
+ appui.Button("列表选择", action=pick_color),
+ appui.Button("HUD 提示", action=show_hud),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("状态", value=state.message),
+ appui.LabeledContent("返回值", value=state.last_result),
+ ]),
+ ]).navigation_title("对话框")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
alert(title, message, *buttons) | 确认框 → 按钮序号(1-based) |
input_alert(title, ...) | 文本输入 → str |
list_dialog(title, items, multiple=False) | 列表选择 |
form_dialog(title, fields) | 简单表单 → dict |
date_dialog(title, mode="date") | 日期/时间选择 → ISO 字符串 |
hud_alert(message, duration=1.0) | 短暂 HUD 提示 |
alert(title, message="", *button_titles, hide_cancel_button=False) — 原生确认框。
if dialogs.alert("删除?", "不可撤销", "删除", "取消") == 1:
+ do_delete()
+返回按下的按钮索引(1-based)。取消抛 KeyboardInterrupt。
input_alert(title, message="", placeholder="", secure=False, default_text="") — 文本输入;secure=True 为密码模式。
name = dialogs.input_alert("API Key", secure=True, placeholder="sk-...")
+list_dialog(title, items, multiple=False) — 单选返回 str,多选返回 list。
color = dialogs.list_dialog("颜色", ["红", "绿", "蓝"])
+tags = dialogs.list_dialog("标签", ["A", "B", "C"], multiple=True)
+form_dialog(title, fields) — 一次收集多个字段:
result = dialogs.form_dialog("新建笔记", [
+ {"type": "text", "key": "title", "title": "标题", "value": "每日笔记"},
+ {"type": "switch", "key": "pinned", "title": "置顶", "value": True},
+])
+print(result["title"], result["pinned"])
+字段 type 支持:text、password、number、email、url、switch。
date_dialog(title="", mode="date") — mode 可选 date / time / datetime,返回 ISO 8601 字符串。
when = dialogs.date_dialog("选择日期", mode="date")
+hud_alert(message, duration=1.0) — 自动消失的成功提示,不阻塞。
dialogs.hud_alert("已保存!", duration=1.5)
+| 需求 | API |
|---|---|
| 简单确认 | alert |
| 单个文本输入 | input_alert |
| 从几个选项中选 | list_dialog |
| 一次收集少量字段 | form_dialog |
| 日期或时间 | date_dialog |
| 短暂成功提示 | hud_alert |
| 错误写法 | 后果 | 修正 |
|---|---|---|
不捕获 KeyboardInterrupt | 取消时脚本中断 | try/except KeyboardInterrupt |
用 form_dialog 做复杂动态表单 | 难维护 | 改用 AppUI Form |
| AppUI 回调里连弹多个对话框 | 页面卡顿 | 合并流程或用 sheet |
| 假设取消返回空字符串 | 逻辑错误 | 取消会抛异常 |
| 文档 | 用途 |
|---|---|
| appui 列表与表单 | AppUI 内建表单与交互 |
| haptics | 操作成功后的触觉反馈 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+系统文件选择器与 AppUI FileImporter。
+系统文件选择器:从「文件」App 或文档提供方导入文件,回调返回可访问路径列表。
+边界:无独立+import file_pickerPython 模块;通过 AppUI 的FileImporter唤起UIDocumentPicker。默认copy=True会把文件复制进 App 沙盒再返回路径。相册图片请用 photos 的PhotoPicker,不是本组件。
| 项 | 说明 |
|---|---|
| 导入 | import appui |
| 适合做什么 | 导入 PDF/CSV/文本、选取用户文档、批量导入附件 |
| 调用时机 | 用户点击 FileImporter 按钮;在 on_picked 回调里读文件 |
| 推荐顺序 | 点选 → on_picked(paths) → 判空 → 后台或按钮里读取 |
| 取消行为 | 用户取消通常不触发回调,或收到空列表 |
下面脚本展示最小文件导入页:选中文本/PDF/CSV 后列出路径。
+import appui
+
+state = appui.State(files=[])
+
+
+def on_picked(paths):
+ state.files = paths or []
+
+
+def body():
+ return appui.Form([
+ appui.FileImporter(
+ allowed_types=["text", "pdf", "csv"],
+ allows_multiple=True,
+ on_picked=on_picked,
+ label=appui.Label("选择文件", system_image="doc.badge.plus"),
+ ),
+ appui.Text("已选: " + (" | ".join(state.files) if state.files else "无")),
+ ])
+
+
+appui.run(body, state=state)
+在回调里保存路径;读取文件内容放在后续按钮动作,避免在 body() 里同步读大文件。
import appui
+import os
+
+state = appui.State(
+ files=[],
+ preview="尚未选择文件",
+ status="点击导入按钮开始",
+)
+
+
+def on_files(paths):
+ paths = paths or []
+ state.files = paths
+ if not paths:
+ state.batch_update(
+ preview="—",
+ status="未选择任何文件",
+ )
+ return
+
+ name = os.path.basename(paths[0])
+ size = "—"
+ try:
+ size = f"{os.path.getsize(paths[0]):,} 字节"
+ except OSError:
+ pass
+
+ state.batch_update(
+ preview=f"{name} · {size}",
+ status=f"已导入 {len(paths)} 个文件",
+ )
+
+
+def body():
+ rows = [
+ appui.Text(path).font("caption").line_limit(2)
+ for path in state.files
+ ]
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("导入", [
+ appui.FileImporter(
+ allowed_types=["text", "pdf", "csv", "public.data"],
+ allows_multiple=True,
+ copy=True,
+ on_picked=on_files,
+ label=appui.Label("从文件 App 导入", system_image="folder"),
+ ),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("预览", [
+ appui.LabeledContent("首个文件", value=state.preview),
+ ]),
+ appui.Section(
+ "路径列表",
+ rows or [
+ appui.ContentUnavailableView(
+ "暂无文件",
+ system_image="doc",
+ description="从系统文件选择器导入",
+ )
+ ],
+ ),
+ ]).navigation_title("文件选择")
+ )
+
+
+appui.run(body, state=state)
+| 参数 | 作用 |
|---|---|
allowed_types | 限制可选类型(扩展名、MIME、UTType 名) |
allows_multiple | 是否多选 |
copy | 是否复制到沙盒(默认 True) |
on_picked | 回调 (paths: list[str]) |
label | 自定义按钮外观 |
FileImporter(allowed_types=None, allows_multiple=False, copy=True, on_picked=None, label=None)
def on_files_picked(paths):
+ print(paths)
+
+
+appui.FileImporter(
+ allowed_types=["pdf", "csv", "text", "image/png"],
+ allows_multiple=False,
+ copy=True,
+ on_picked=on_files_picked,
+ label=appui.Button("导入"),
+)
+| 值 | 含义 |
|---|---|
text / plain | 纯文本 |
pdf | |
csv | CSV |
image / jpeg / png | 图片 |
public.data / item | 较宽的文件类型 |
未识别类型时回退为系统通用 item。
def on_picked(paths):
+ if not paths:
+ return
+ with open(paths[0], "r", encoding="utf-8", errors="ignore") as f:
+ text = f.read(4000)
+大文件请在按钮回调或后台任务读取,不要阻塞 body()。
| 组件 | 来源 | 回调 |
|---|---|---|
PhotoPicker | 照片库 | 媒体文件路径列表 |
FileImporter | 文件 App / iCloud / 第三方提供方 | 任意允许类型路径列表 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 open(path) | 每次刷新重复读盘 | 放进 on_picked 或按钮回调 |
| 不处理空列表 | 取消后逻辑异常 | paths = paths or [] |
copy=False 读外部卷 | 路径可能很快失效 | 保持 copy=True(默认) |
选相册图片用 FileImporter | 体验差、类型受限 | 用 PhotoPicker |
| 文档 | 用途 |
|---|---|
| photos | 相册选图 / 拍照 |
| PDF 生成与预览 | |
| AppUI 媒体参考 | FileImporter 完整签名 |
| AppUI 媒体指南 | 更多媒体集成模式 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+系统字体选择器。
+系统字体选择器:弹出 UIFontPickerViewController,返回用户选中的字体信息。
边界:+pick()会阻塞脚本直到用户完成或取消(与 dialogs 类似)。只能放在按钮回调里,不要在body()或模块导入时调用。不需要额外权限。
| 项 | 说明 |
|---|---|
| 导入 | import font_picker |
| 适合做什么 | 主题设置、笔记 App 选字体、设计工具 |
| 调用时机 | 用户点击按钮后调用 pick() |
| 推荐顺序 | 按钮回调 → pick() → 判空 → 写入 State |
| 取消处理 | 用户取消返回 None |
下面脚本弹出字体选择器并打印结果:
+import font_picker
+
+try:
+ font = font_picker.pick()
+ if font is None:
+ print("用户取消")
+ else:
+ print(font["family"], font["name"], font.get("point_size"))
+except font_picker.FontPickerError as exc:
+ print("字体选择失败:", exc, f"code={exc.code}")
+pick() 放在按钮回调;结果写回 State 并用 Text 预览字体名。
import appui
+import font_picker
+
+state = appui.State(
+ family="—",
+ name="—",
+ size="—",
+ status="点击按钮选择字体",
+)
+
+
+def choose_font():
+ try:
+ font = font_picker.pick()
+ if font is None:
+ state.batch_update(
+ family="—",
+ name="—",
+ size="—",
+ status="用户取消选择",
+ )
+ return
+
+ state.batch_update(
+ family=font.get("family", "—"),
+ name=font.get("name", "—"),
+ size=str(font.get("point_size", "—")),
+ status="已选择字体",
+ )
+ except font_picker.FontPickerError as exc:
+ state.status = f"选择失败: {exc}"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("当前字体", [
+ appui.LabeledContent("族名", value=state.family),
+ appui.LabeledContent("字体名", value=state.name),
+ appui.LabeledContent("字号", value=state.size),
+ appui.Text(state.name)
+ .font("title2")
+ .foreground_color("label"),
+ ]),
+ appui.Section("操作", [
+ appui.Button("选择字体", action=choose_font)
+ .button_style("bordered_prominent"),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="pick() 会阻塞直到用户完成;请在按钮回调中调用。"),
+ ]).navigation_title("字体")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
pick() | 弹出系统字体选择器 → 字体字典 / None |
FontPickerError | Bridge 不可用或调用失败 |
pick() -> dict | None
font = font_picker.pick()
+成功时返回字典:
+| 字段 | 说明 |
|---|---|
family | 字体族名,如 PingFang SC |
name | PostScript 字体名 |
point_size | 选择器中的参考字号(默认约 17) |
用户取消时返回 None。
Bridge 不可用时抛出 FontPickerError:
try:
+ font = font_picker.pick()
+except font_picker.FontPickerError as exc:
+ print(exc.code)
+| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 pick() | 每次刷新都弹选择器 | 放进按钮回调 |
不判断 None | 用户取消后访问键崩溃 | if font is None: return |
在 onAppear 自动 pick() | 页面一打开就阻塞 | 等用户点击 |
把 point_size 当最终 UI 字号 | 可能与你的布局不一致 | 自行设定 appui.Text(...).font(...) |
| 文档 | 用途 |
|---|---|
| dialogs | 其他阻塞式系统面板 |
| AppUI 控件参考 | Text 字体修饰符 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+设备端大模型文本生成(Foundation Models,iOS 26+)。
+端侧文本生成(Apple Foundation Models,iOS 26+):对话、摘要、错误解释。
+边界:需 iOS 26+ 且设备支持 Apple Intelligence 端侧模型。与云端 Agent 不同;带工具调度的助手请用 assistant。+
| 项 | 说明 |
|---|---|
| 导入 | import foundation_models |
| 适合做什么 | 本地摘要、代码解释、短问答 |
| 调用时机 | respond / summarize 放在按钮回调 |
| 推荐顺序 | is_available() → 选择 API → 传入文本 |
| 隐私 | 推理在设备端完成,不经过本模块发网络请求 |
import foundation_models
+
+if not foundation_models.is_available():
+ print("Foundation Models 不可用")
+else:
+ answer = foundation_models.respond("用三句话介绍 Python")
+ print(answer)
+
+ summary = foundation_models.summarize("很长的文章正文……", max_sentences=3)
+ print(summary)
+解释 Python 报错:
+import foundation_models
+
+hint = foundation_models.explain_error(
+ "NameError: name 'x' is not defined",
+ code_snippet="print(x)",
+)
+print(hint)
+import appui
+import foundation_models
+
+state = appui.State(
+ available="—",
+ prompt="用一句话介绍 PythonIDE",
+ output="—",
+)
+
+
+def refresh_available():
+ state.available = "是" if foundation_models.is_available() else "否"
+
+
+def ask_model():
+ refresh_available()
+ if state.available != "是":
+ state.output = "Foundation Models 不可用(需 iOS 26+)"
+ return
+
+ try:
+ state.output = foundation_models.respond(state.prompt)
+ except foundation_models.FoundationModelsError as exc:
+ state.output = str(exc)
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("提问", [
+ appui.TextEditor(text=state.prompt).frame(min_height=80),
+ appui.Button("生成回答", action=ask_model)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("可用", value=state.available),
+ appui.Text(state.output).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("端侧模型")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_available() | 端侧模型是否可用 → bool |
respond(prompt, instructions=None) | 自由问答 → str |
summarize(text, max_sentences=3) | 文本摘要 → str |
explain_error(error, code_snippet='') | 解释报错 → str |
FoundationModelsError | 操作失败异常 |
text = foundation_models.respond(
+ "总结这段日志的关键问题",
+ instructions="回答要简短,用中文",
+)
+foundation_models.summarize(long_text, max_sentences=5)
+foundation_models.explain_error("SyntaxError: invalid syntax", code_snippet="if True\nprint()")
+| 错误写法 | 后果 | 修正 |
|---|---|---|
| 在低版本 iOS 调用 | is_available() 为 False | 检查系统版本与机型 |
在 body() 里自动生成 | 每次刷新重复推理 | 放进按钮回调 |
| 期望访问日历/照片等工具 | 本模块无工具 | 用 assistant |
| 超长上下文一次传入 | 可能截断或失败 | 分段摘要 |
| 文档 | 用途 |
|---|---|
| assistant | 带内置设备工具的助手 |
| translation | 文本翻译 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+触觉反馈和 Core Haptics 模式。
+iOS 触觉反馈:在用户明确操作后触发冲击、选择、通知类震动,或播放 Core Haptics 自定义模式。
+边界:触觉是辅助反馈,不能替代文字、颜色或状态变化。无触觉设备或用户关闭系统触觉时,界面仍要有可见反馈。不要在 AppUI body() 里触发,否则刷新时会重复震动。
+| 项 | 说明 |
|---|---|
| 导入 | import haptics |
| 适合做什么 | 按钮点击、选项切换、保存成功/失败、危险操作拦截 |
| 调用时机 | 放在按钮或明确事件回调;不要写在 body() 中 |
| 推荐顺序 | 用户操作 → haptics.* → 同时更新 State 文案 |
| 两套 API | impact/selection/notification 适合大多数场景;play 需先 is_supported() |
下面脚本依次触发选择、冲击和成功通知反馈:
+import haptics
+
+haptics.selection()
+haptics.impact("medium", intensity=0.8)
+haptics.notification("success")
+
+# 快捷方式
+haptics.success()
+haptics.light()
+触觉放在按钮回调里,同时更新界面状态。
+import appui
+import haptics
+
+state = appui.State(
+ message="点击按钮体验触觉反馈",
+ core_haptics="未检测",
+)
+
+
+def refresh_support():
+ state.core_haptics = "支持" if haptics.is_supported() else "不支持"
+
+
+def choose_item():
+ haptics.selection()
+ state.message = "选择反馈 · 轻微滴答"
+
+
+def save_item():
+ haptics.success()
+ state.message = "成功反馈 · 保存完成"
+
+
+def show_error():
+ haptics.error()
+ state.message = "错误反馈 · 请检查输入"
+
+
+def play_pattern():
+ if not haptics.is_supported():
+ state.message = "当前设备不支持 Core Haptics"
+ return
+ events = [
+ {"type": "transient", "time": 0.0, "intensity": 1.0, "sharpness": 0.8},
+ {"type": "continuous", "time": 0.08, "duration": 0.25, "intensity": 0.5, "sharpness": 0.3},
+ ]
+ ok = haptics.play(events)
+ state.message = "自定义模式已播放" if ok else "播放失败"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("设备", [
+ appui.LabeledContent("Core Haptics", value=state.core_haptics),
+ appui.Button("检测支持情况", action=refresh_support),
+ ]),
+ appui.Section("UIKit 反馈", [
+ appui.Button("选择", action=choose_item),
+ appui.Button("保存成功", action=save_item)
+ .button_style("bordered_prominent"),
+ appui.Button("模拟错误", action=show_error, role="destructive"),
+ ]),
+ appui.Section("自定义", [
+ appui.Button("播放短模式", action=play_pattern),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("触觉反馈")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
selection() | 选项切换的轻微滴答 |
impact(style, intensity=1.0) | 冲击反馈 |
notification(type) | 成功/警告/错误通知反馈 |
success() / warning() / error() | notification 快捷方式 |
light() / medium() / heavy() | impact 快捷方式 |
is_supported() | 是否支持 Core Haptics |
play(events) | 播放自定义事件列表 |
stop() | 停止 Core Haptics 引擎 |
适合绝大多数按钮和状态场景:
+import haptics
+
+haptics.selection()
+haptics.impact("medium", intensity=0.8)
+haptics.notification("success")
+| API | 参数 | 说明 |
|---|---|---|
impact(style, intensity=1.0) | light / medium / heavy / soft / rigid | 点击冲击感 |
notification(type) | success / warning / error | 结果类反馈 |
selection() | — | 选择变化 |
| 方法 | 等价于 |
|---|---|
success() | notification("success") |
warning() | notification("warning") |
error() | notification("error") |
light() | impact("light") |
medium() | impact("medium") |
heavy() | impact("heavy") |
is_supported() — 播放自定义模式前先检查。
play(events) — 事件列表,每项为 dict:
events = [
+ {"type": "transient", "time": 0.0, "intensity": 1.0, "sharpness": 0.8},
+ {"type": "continuous", "time": 0.08, "duration": 0.25, "intensity": 0.5, "sharpness": 0.3},
+]
+if haptics.is_supported():
+ haptics.play(events)
+| 字段 | 说明 |
|---|---|
type | transient 或 continuous |
time | 开始时间(秒) |
duration | 持续时间(continuous 用) |
intensity / sharpness | 强度与锐度 0..1 |
stop() — 退出页面或中断模式时停止引擎。
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里调 haptics.success() | 刷新时重复震动 | 放进按钮回调 |
| 只震动不更新 UI | 无触觉设备上无反馈 | 同时改 State 或显示 HUD |
不检查就 play(events) | 部分设备无效 | 先 is_supported() |
| 高频循环触发 | 体验差、耗电 | 仅在关键状态变化时触发一次 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+HealthKit 授权、查询和体重写入。
+HealthKit 健康数据:申请授权、读取步数/心率/睡眠等,写入体重样本。
+边界:健康数据非常敏感。只请求当前功能需要的类型(如+steps、heart_rate),不要一次申请全部。查询结果只做展示,不做医疗诊断;不要把明细写入 storage。
| 项 | 说明 |
|---|---|
| 导入 | import health |
| 适合做什么 | 步数/心率/睡眠仪表盘、体重记录、血压汇总 |
| 调用时机 | 授权和读取放在按钮回调;不要在 body() 里读 HealthKit |
| 推荐顺序 | is_available() → request_authorization(read=[...]) → 查询 |
| 类型名 | 用文档列出的 snake_case 类型名;时间范围用 Unix timestamp |
下面脚本申请权限并读取最近 24 小时步数与心率:
+import time
+import health
+
+if not health.is_available():
+ print("当前设备不支持 HealthKit")
+else:
+ ok = health.request_authorization(read=["steps", "heart_rate"])
+ if ok:
+ end = time.time()
+ start = end - 86400
+ print("步数:", health.query_steps(start=start, end=end))
+ print("心率:", health.query_heart_rate(start=start, end=end))
+ else:
+ print("用户未授权读取健康数据")
+授权和读取放在按钮回调;空结果展示明确状态。
+import appui
+import health
+
+state = appui.State(
+ status="未读取",
+ steps="—",
+ heart_rate="—",
+ sleep="—",
+)
+
+
+def refresh_dashboard():
+ if not health.is_available():
+ state.batch_update(
+ status="设备不支持 HealthKit",
+ steps="—",
+ heart_rate="—",
+ sleep="—",
+ )
+ return
+
+ read_types = ["steps", "heart_rate", "sleep"]
+ if not health.request_authorization(read=read_types):
+ state.batch_update(
+ status="未授权读取健康数据",
+ steps="—",
+ heart_rate="—",
+ sleep="—",
+ )
+ return
+
+ steps = int(health.query_steps() or 0)
+ heart_records = health.query_heart_rate() or []
+ sleep_records = health.query_sleep() or []
+
+ state.batch_update(
+ status="最近 24 小时步数 · 7 天睡眠",
+ steps=f"{steps:,}",
+ heart_rate=f"{len(heart_records)} 条记录",
+ sleep=f"{len(sleep_records)} 条记录",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("今日概览", [
+ appui.LabeledContent("步数", value=state.steps),
+ appui.LabeledContent("心率", value=state.heart_rate),
+ appui.LabeledContent("睡眠", value=state.sleep),
+ ]),
+ appui.Section("操作", [
+ appui.Button("刷新健康数据", action=refresh_dashboard)
+ .button_style("bordered_prominent"),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="仅展示数据,不做诊断;真机 + 健康 App 有数据时效果最好。"),
+ ]).navigation_title("健康")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_available() | 设备是否支持 HealthKit |
request_authorization(read, write) | 申请读写权限 → bool |
authorization_status(type_id) | 查询某类型授权状态 |
query_steps(start, end) | 步数(默认最近 24h) |
query_heart_rate(start, end) | 心率记录列表 |
query_sleep(start, end) | 睡眠数据(默认最近 7 天) |
query_weight(limit) / save_weight(kg) | 读取 / 写入体重 |
query_blood_pressure(start, end) | 血压记录(默认 30 天) |
summarize_blood_pressure(records) | 血压统计汇总 |
import health
+
+if health.is_available():
+ ok = health.request_authorization(
+ read=["steps", "heart_rate"],
+ write=["weight"],
+ )
+ status = health.authorization_status("steps")
+常用授权类型:steps、heart_rate、weight、sleep、workout、blood_pressure_systolic、blood_pressure_diastolic、distance、calories。
血压授权推荐使用组件类型:
+health.request_authorization(
+ read=["blood_pressure_systolic", "blood_pressure_diastolic"]
+)
+blood_pressure 只作为兼容简写,会在运行时展开为收缩压和舒张压组件;不要把任意 HealthKit 原始标识符传给 request_authorization。
注意:只申请当前页面需要的类型,用户更容易接受。+
query_steps(start=None, end=None) — 返回步数整数,默认最近 24 小时。
query_heart_rate(start, end) — 返回 [{value, start, end}, ...],单位 bpm。
query_sleep(start, end) — 睡眠分析记录,默认最近 7 天。
query_weight(limit=10) — 最近体重 [{value, date}, ...],单位 kg。
query_workouts(limit=20) — 最近锻炼记录。
query_quantity(type_id, start, end, unit) — 通用数量查询。
import time
+
+end = time.time()
+start = end - 86400
+steps = health.query_steps(start=start, end=end)
+save_weight(kg, timestamp=None) — 保存体重样本到 HealthKit。
health.save_weight(70.5)
+需先在 request_authorization 的 write 中包含 weight。
records = health.query_blood_pressure()
+summary = health.summarize_blood_pressure(records)
+# count, latest, systolic/diastolic avg/min/max — 不做诊断
+| 错误写法 | 后果 | 修正 |
|---|---|---|
read=["step_count"] 或 read=["HKQuantityTypeIdentifier..."] | 类型名错误,授权会返回 False | 使用文档列出的 snake_case 类型 |
read=["blood_pressure"] 依赖组合别名 | 不同系统对血压组合授权表现不一致 | 使用 blood_pressure_systolic + blood_pressure_diastolic |
| 一次申请所有权限 | 用户易拒绝 | 只申请当前需要的类型 |
健康明细写入 storage | 敏感数据扩散 | 只展示汇总 |
| 对异常值做诊断结论 | 误导用户 | 仅展示原始/统计数据 |
| 文档 | 用途 |
|---|---|
| permission | 统一权限查询(HealthKit 用本模块授权) |
| storage | 非敏感偏好存储 |
| 原生能力入口 | MiniApp 场景配方 |
import appui
+import health
+
+
+def body():
+ return appui.Form([
+ appui.Section("健康", [
+ appui.Text("在授权后读取步数等指标并绑定到 State"),
+ ])
+ ])
+预期效果:打开健康数据展示页;未授权时提示用户去系统设置授权。
+运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+本地 HTTP 文件服务器。
+本地 HTTP 文件服务器:在 127.0.0.1 上提供 Documents(或指定目录)的静态文件访问。
边界:仅监听本机回环地址,供同设备上的浏览器或其他客户端读取文件。不是公网服务器;远程访问请用 ssh 或 network。+start()/stop()放在按钮回调,离开页面前应stop()。
| 项 | 说明 |
|---|---|
| 导入 | import http_server |
| 适合做什么 | 临时共享工作区文件、本地调试静态资源、给其他 App 读文件 |
| 调用时机 | 用户点击启动;页面关闭或切换时停止 |
| 推荐顺序 | start() 拿 URL → status() 确认 → 访问文件 → stop() |
| 根目录 | 默认 App Documents;可用 root_dir 指定 |
下面脚本在 18080 端口启动服务、查看状态并停止:
+import http_server
+
+try:
+ url = http_server.start(port=18080)
+ print("服务地址:", url)
+ print(http_server.status())
+finally:
+ http_server.stop()
+访问 http://127.0.0.1:18080/你的文件名 可读取 root_dir 下的文件;根路径 / 返回 JSON 提示。
启动/停止放在按钮回调;状态展示当前 URL 与根目录。
+import appui
+import http_server
+
+state = appui.State(
+ running=False,
+ url="—",
+ port="—",
+ root="—",
+ status="点击启动本地服务",
+)
+
+
+def refresh_status():
+ try:
+ st = http_server.status() or {}
+ state.batch_update(
+ running=bool(st.get("running")),
+ url=st.get("url") or "—",
+ port=str(st.get("port", "—")),
+ root=st.get("root_dir") or "—",
+ )
+ except http_server.HttpServerError as exc:
+ state.status = f"查询失败: {exc}"
+
+
+def start_server():
+ try:
+ url = http_server.start(port=18080)
+ refresh_status()
+ state.status = f"已启动: {url}"
+ except http_server.HttpServerError as exc:
+ state.status = f"启动失败: {exc}"
+
+
+def stop_server():
+ try:
+ http_server.stop()
+ state.batch_update(
+ running=False,
+ url="—",
+ port="—",
+ status="已停止",
+ )
+ refresh_status()
+ except http_server.HttpServerError as exc:
+ state.status = f"停止失败: {exc}"
+
+
+def body():
+ label = "停止服务" if state.running else "启动服务"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("服务状态", [
+ appui.LabeledContent("运行中", value="是" if state.running else "否"),
+ appui.LabeledContent("地址", value=state.url),
+ appui.LabeledContent("端口", value=state.port),
+ appui.LabeledContent("根目录", value=state.root),
+ ]),
+ appui.Section("操作", [
+ appui.Button(label, action=stop_server if state.running else start_server)
+ .button_style("bordered_prominent"),
+ appui.Button("刷新状态", action=refresh_status),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="仅本机 127.0.0.1;把文件放进根目录后通过 URL 访问。"),
+ ]).navigation_title("HTTP 服务")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
start(port, root_dir) | 启动服务 → 访问 URL |
stop() | 停止服务 |
status() | {running, port, url, root_dir} |
HttpServerError | 操作失败时抛出 |
start(port=8080, root_dir=None)
url = http_server.start()
+url = http_server.start(port=18080, root_dir="/path/to/folder")
+| 参数 | 说明 |
|---|---|
port | 监听端口(默认 8080) |
root_dir | 文件根目录;省略时用 Documents |
返回形如 http://127.0.0.1:8080/ 的 URL。若端口已被占用或无效会抛出 HttpServerError。
stop() — 关闭监听器。
status() 返回:
| 字段 | 说明 |
|---|---|
running | 是否正在服务 |
port | 当前端口 |
url | 访问地址(未运行时为空) |
root_dir | 文件根目录 |
GET / → JSON 提示 {"ok":true,"message":"PythonIDE http_server"}GET /filename → 读取 root_dir/filename 的二进制内容| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 start() | 刷新时重复启动 | 放进按钮回调 |
页面离开不 stop() | 端口占用、耗电 | 停止按钮或退出时清理 |
| 端口被占用 | 启动失败 | 换端口或先 stop() |
| 期望公网访问 | 仅本机回环 | 用 SSH 隧道或其他方案 |
| 文档 | 用途 |
|---|---|
| network | HTTP 客户端下载 |
| background_download | 后台大文件下载 |
| ssh | 远程文件传输 |
| storage | 记录服务端口等配置 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+相册、联系人、权限、相机、控制台和安全存储。
+iOS 原生 Python 模块总入口:按任务选模块、组合调用、权限与真机要求速查。
+边界:这是 能力索引页,不是+import名。具体 API 见各模块文档;系统面板、硬件扫描、权限申请必须由按钮或菜单触发,不要在 AppUIbody()或模块导入时自动执行。
| 项 | 说明 |
|---|---|
| 导入 | 按任务 import 对应模块(如 import photos) |
| 适合做什么 | 相册、定位、通知、蓝牙、健康、网络等系统能力 |
| 调用时机 | 按钮/刷新/选择器触发;body() 只展示 State |
| 敏感数据 | token/密钥 → keychain;普通设置 → storage |
| 文档导航 | schema navigation.iosNative 投影;见下方 文档导航 表 |
| 下一步 | 按场景配方选模块 → 打开模块页 → 复制 AppUI 示例 |
相册、剪贴板、HUD 与钥匙串组合(函数式,适合脚本调试):
+import clipboard
+import console
+import keychain
+import photos
+
+
+def pick_photo_and_copy_size():
+ image = photos.pick_image(raw_data=True)
+ if not image:
+ return "已取消选择"
+ clipboard.set(f"picked {len(image)} bytes")
+ console.hud_alert("已复制图片大小", "success")
+ return f"已复制 {len(image)} bytes"
+
+
+def save_demo_token():
+ ok = keychain.set_password("demo", "token", "demo-token")
+ return "已保存" if ok else "保存失败"
+设备信息、本地快照与通知提醒组合;原生调用均在按钮回调里。
+import appui
+import device
+import haptics
+import notification
+import storage
+
+SNAPSHOT_KEY = "native.snapshot"
+REMINDER_ID = "native.snapshot.reminder"
+
+state = appui.State(
+ message="就绪",
+ rows=[
+ {"id": "model", "title": "型号", "value": "点击刷新"},
+ {"id": "battery", "title": "电量", "value": "—"},
+ ],
+)
+
+
+def row_key(row):
+ return row["id"]
+
+
+def row_view(row):
+ return appui.LabeledContent(row["title"], value=row["value"])
+
+
+def refresh_device_info():
+ state.rows = [
+ {"id": "model", "title": "型号", "value": device.model()},
+ {"id": "battery", "title": "电量", "value": f"{device.battery_level():.0%}"},
+ ]
+ state.message = "设备信息已刷新"
+
+
+def save_snapshot():
+ storage.set_json(SNAPSHOT_KEY, state.rows)
+ if haptics.is_supported():
+ haptics.notification("success")
+ state.message = "快照已保存"
+
+
+def remind_later():
+ perm = notification.request_permission()
+ if not perm.get("granted"):
+ state.message = "未获得通知权限"
+ return
+
+ result = notification.schedule(
+ REMINDER_ID,
+ "原生快照",
+ "查看已保存的设备快照",
+ delay=60,
+ )
+ state.message = f"已调度提醒: {result}"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("设备", [
+ appui.ForEach(state.rows, row_builder=row_view, key=row_key),
+ ]),
+ appui.Section("操作", [
+ appui.Button("刷新设备", action=refresh_device_info),
+ appui.Button("保存快照", action=save_snapshot)
+ .button_style("bordered_prominent"),
+ appui.Button("1 分钟后提醒", action=remind_later),
+ ], footer=state.message),
+ ]).navigation_title("原生能力")
+ )
+
+
+appui.run(body, state=state)
+| 目标 | 首选模块 | 注意 |
|---|---|---|
| 查询或申请权限 | permission | 只在用户点击后申请 |
| 选择、拍照、保存图片 | photos | 取消返回 None,要先判断 |
| 保存 token、密码 | keychain | 不要写到 storage 或日志 |
| 保存普通设置 | storage | 主题、开关、筛选条件 |
| 弹窗、HUD、输入 | console / dialogs | 短反馈用 HUD |
| 定位、运动、健康、蓝牙、NFC | 对应模块 | 先展示用途再请求 |
| 网络、WebSocket | network / websocket | 勿在 body() 发请求 |
| 快捷指令、Live Activity | shortcuts / live_activity | 需明确用户动作 |
所有原生能力入口由同一套能力清单统一定义(App 内与 Agent 共用)。先判断入口类型,再打开对应文档。
+| 入口类型 | 第一行写什么 | 文档入口 |
|---|---|---|
| Python 模块 | import <module> | API 参考 › Python 模块 |
| AppUI 原生组件 | import appui + 组件名 | API 参考 › AppUI 原生组件 |
| 专题页 | 见专题页「首选入口」 | API 参考 › 专题页 |
| 参考 | 见参考页说明 | c_extensions 等 |
完整 Python import 模块表(含类型与呈现方式):
| 模块 | 类型 | 呈现 | 分类 | 权限 | 能力 |
|---|---|---|---|---|---|
alarm | Python 模块 | Python API | 系统服务 | 无 | alarm_schedule, alarm_cancel |
assistant | Python 模块 | Python API | 系统服务 | 无 | assistant_tools, device_context_answers |
audio_recorder | Python 模块 | Python API | 媒体与视觉 | microphone | microphone_capture, recording_control, level_metering |
audio_session | Python 模块 | Python API | 媒体与视觉 | 无 | audio_session_category, audio_route_query |
avplayer | Python 模块 | Python API | 媒体与视觉 | 无 | audio_video_load, playback_control, native_player |
background | Python 模块 | Python API | 网络与连接 | 无 | background_task, remaining_time, app_state |
background_download | Python 模块 | Python API | 网络与连接 | 无 | background_download, download_progress |
biometric | Python 模块 | 系统表单 | 设备与传感器 | biometric | face_id, touch_id, passcode_fallback |
ble_peripheral | Python 模块 | Python API | 网络与连接 | 无 | ble_peripheral_advertising, ble_characteristic_update |
bluetooth | Python 模块 | Python API | 网络与连接 | bluetooth | ble_scan, ble_connect, read, write |
c_extensions | 参考 | 参考清单 | 网络与连接 | 无 | 扩展清单, 兼容性说明 |
calendar_events | Python 模块 | 系统选择器 | 系统服务 | calendar, reminders | calendar_read, calendar_write, reminders |
clipboard | Python 模块 | Python API | 系统服务 | 无 | read_clipboard, write_clipboard, clear_clipboard |
console | Python 模块 | 系统表单 | 系统服务 | 无 | hud, alerts, input_dialogs |
contacts | Python 模块 | 系统选择器 | 系统服务 | contacts | contacts_read, contacts_picker, contacts_edit |
coreml | Python 模块 | Python API | 媒体与视觉 | 无 | model_listing, model_loading, image_prediction |
database | Python 模块 | Python API | 系统服务 | 无 | sqlite, native_sqlite_bridge, collections, transactions |
device | Python 模块 | Python API | 设备与传感器 | 无 | device_state, screen, battery, thermal_state |
dialogs | Python 模块 | 系统表单 | 系统服务 | 无 | native_dialogs, forms, pickers |
font_picker | Python 模块 | 系统表单 | 系统服务 | 无 | font_picker_ui |
foundation_models | Python 模块 | Python API | 系统服务 | 无 | on_device_generation, summarization, error_explanation |
haptics | Python 模块 | Python API | 设备与传感器 | 无 | impact_feedback, notification_feedback, core_haptics |
health | Python 模块 | Python API | 设备与传感器 | health | steps, heart_rate, sleep, body_metrics |
http_server | Python 模块 | Python API | 网络与连接 | 无 | local_http_server, file_serving |
keyboard | Python 模块 | Python API | 自动化与扩展 | 无 | toolbar_buttons, insert_text, set_buttons |
keychain | Python 模块 | Python API | 系统服务 | 无 | secure_password_storage, service_listing |
live_activity | Python 模块 | Python API | 系统服务 | 无 | dynamic_island, lock_screen_activity |
location | Python 模块 | Python API | 设备与传感器 | location | gps, heading, geocoding |
mail | Python 模块 | 撰写界面 | 系统服务 | 无 | mail_compose, attachment_send |
media_composer | Python 模块 | Python API | 媒体与视觉 | 无 | video_merge, audio_mux, media_export |
message | Python 模块 | 撰写界面 | 系统服务 | 无 | sms_compose, imessage_compose |
motion | Python 模块 | Python API | 设备与传感器 | motion | accelerometer, gyroscope, attitude, barometer |
music | Python 模块 | Python API | 媒体与视觉 | 无 | music_playback, catalog_search |
music_player | Python 模块 | Python API | 媒体与视觉 | 无 | music_queue, playback_control, play_mode, now_playing_metadata, remote_commands, playback_restore, progress_events, queue_preload, preload_events |
network | Python 模块 | Python API | 网络与连接 | 无 | http, download, connectivity |
nfc | Python 模块 | 系统选择器 | 系统服务 | nfc | ndef_scan, ndef_write |
notification | Python 模块 | Python API | 系统服务 | notifications | local_notifications, badges, scheduled_alerts |
now_playing | Python 模块 | Python API | 媒体与视觉 | 无 | now_playing_metadata, playback_progress |
objc_util | Python 模块 | Python API | 自动化与扩展 | 无 | Objective-C 运行时, 系统框架访问 |
pdf | Python 模块 | Python API | 媒体与视觉 | 无 | pdf_creation, text_extraction, page_rendering, quicklook_preview |
permission | Python 模块 | Python API | 系统服务 | 无 | permission_status, permission_request |
photos | Python 模块 | Python API | 媒体与视觉 | photos, camera | photo_picker, camera_capture, photo_save, video_save, asset_read |
qrcode | Python 模块 | Python API | 媒体与视觉 | 无 | qr_generation, png_export |
shazam | Python 模块 | Python API | 媒体与视觉 | microphone | music_identification, microphone_recognition, file_recognition |
shortcuts | Python 模块 | Python API | 自动化与扩展 | 无 | run_shortcut, open_url, open_settings |
sound | Python 模块 | Python API | 媒体与视觉 | 无 | sound_effects, audio_player |
speech | Python 模块 | Python API | 媒体与视觉 | speech | text_to_speech, voice_listing |
speech_recognition | Python 模块 | Python API | 媒体与视觉 | speech, microphone | live_transcription, file_transcription, locale_listing |
ssh | Python 模块 | Python API | 网络与连接 | 无 | ssh_exec, sftp_upload, sftp_download |
storage | Python 模块 | Python API | 系统服务 | 无 | user_defaults, json_values |
storekit | Python 模块 | Python API | 系统服务 | 无 | iap_purchase, subscription_status, product_catalog |
translation | Python 模块 | Python API | 系统服务 | 无 | on_device_translation, language_listing |
video_recorder | Python 模块 | Python API | 媒体与视觉 | camera | video_recording |
vision | Python 模块 | Python API | 媒体与视觉 | 无 | ocr |
vision_helper | Python 模块 | Python API | 媒体与视觉 | 无 | face_detection, barcode_detection, rectangle_detection, classification |
weather | Python 模块 | Python API | 网络与连接 | location | current_conditions, daily_forecast, hourly_forecast |
websocket | Python 模块 | Python API | 网络与连接 | 无 | websocket_connect, send, receive, close |
无独立 import 的系统 UI,通过 AppUI 组件触发:
| 组件入口 | AppUI 组件 | 文档 | 关联模块 |
|---|---|---|---|
camera_picker | CameraPicker | camera-module | photos |
file_importer | FileImporter | file-picker-module | — |
map_view | MapView | location-module | location, permission |
photo_picker | PhotoPicker | photos-module | photos |
player_controller | PlayerController | appui-ref-media | avplayer |
share_link | ShareLink | share-module | — |
video_player | VideoPlayer | appui-ref-media | avplayer |
web_view | WebView | appui-ref-media | — |
同一能力的双入口说明(脚本模块 vs AppUI 组件):
+| 专题 | 首选入口 | 文档 | 说明 |
|---|---|---|---|
camera | photos | camera-module | Camera capture via photos.capture_image or AppUI CameraPicker. |
file_picker | appui | file-picker-module | File selection has no import module; use AppUI FileImporter. |
share | appui | share-module | System share sheet has no import module; use AppUI ShareLink. |
侧边栏分组由 schema navigation.iosNative 投影,与 App 内文档导航一致:
| 目标 | 推荐组合 | 说明 |
|---|---|---|
| 选图并保存设置 | appui + photos + storage | 按钮触发 picker,结果写入 State |
| 下载并存相册 | network + photos | 下载到本地再 save_video |
| 地图展示位置 | permission + location + MapView | 先查权限再定位 |
| 健康面板 | permission + health + Chart | 拒绝时显示空状态 |
| 提醒任务 | notification + storage | 稳定 identifier 调度/取消 |
| 播放历史列表 | database + List | 大列表勿塞 storage |
| 安全配置页 | biometric + keychain + Form | 密钥进 keychain |
| 传感器面板 | motion + haptics | 高频数据避免每帧重建 UI |
| 远端列表 | network + List + .refreshable | 请求放刷新回调 |
以下模块在 自动化与扩展 集合,或供进阶场景使用:
+| 模块 | 用途 |
|---|---|
| shortcuts | 运行快捷指令、打开 URL/设置 |
| keyboard | 脚本编辑器键盘工具栏 |
| objc_util | Objective-C Runtime 底层访问 |
| c_extensions | 内置 C 扩展清单与替代路线 |
| 全部模块总览 | 按导入名浏览完整模块表 |
| 能力 | 真机要求 | 权限 | 用户动作 | 推荐入口 |
|---|---|---|---|---|
| 相册、拍照 | 推荐真机 | photos / camera | 是 | photos |
| 定位、运动 | 真机 | location / motion | 是 | location、motion |
| HealthKit | 真机 | health | 是 | health |
| BLE | 真机 | bluetooth | 是 | bluetooth |
| NFC | 真机且支持 NFC | nfc | 是 | nfc |
| 本地通知 | 真机/模拟器部分可测 | notifications | 是 | notification |
| Vision / Core ML | 视系统能力 | 无 | 通常是 | vision、coreml |
| WebSocket | 需网络 | 无 | 否 | websocket |
| Keychain / Storage | App 环境 | 无 | 剪贴板推荐用户动作 | keychain、storage |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里调系统 API | 刷新时反复弹窗/扫描 | 放进按钮回调 |
| 权限被拒后循环申请 | 体验差、可能被拒 | 显示状态与设置入口 |
用户取消未处理 None | 崩溃或脏 UI | 保留原状态并提示 |
token 写入日志或 storage | 隐私风险 | 用 keychain |
| 真机能力在模拟器硬测 | 误判不可用 | 查平台矩阵,真机验证 |
| 文档 | 用途 |
|---|---|
| permission | 统一权限(侧边栏「基础与权限」) |
| photos | 相册与相机(「媒体 › 采集」) |
| notification | 本地通知(「界面与系统」) |
| live_activity | 锁屏实时活动(「界面与系统」) |
| shortcuts | 快捷指令(自动化与扩展) |
| 全部模块总览 | 完整模块字母/分类表(补充索引) |
import 对应模块并检查权限/可用性。State,给用户可见反馈。运行示例后,界面应出现文档描述的目标结果;若与预期不符,请按「失败路径」排查。
+.js 文件可调用的接口与示例。
+.js 文件可以调用一组由应用提供的 JS 接口,用来输出日志、弹窗、读写文件、访问剪贴板、发送网络请求、触发通知和读取设备信息。
WebView 示例会显示一个按钮,点击后写入剪贴板并把读取结果显示在页面里。
+| 需求 | 首选 | 原因 |
|---|---|---|
| HTML 里响应按钮、更新 DOM、展示轻量交互 | JS API | 逻辑离页面最近,回调能直接更新元素。 |
| 原生表单、列表、导航、图表和媒体页面 | appui | 原生控件、状态和可访问性更完整。 |
| 复杂网络请求、鉴权、上传下载、文件批处理 | Python 模块 | 错误处理和数据处理更清晰。 |
| 照片、定位、通知、健康、蓝牙等系统能力 | 原生模块 | 权限、取消和不可用状态有明确约定。 |
| 只嵌入一个静态说明页或预览页 | WebView(html=...) | JS API 可以只用于复制、打开链接和轻提示。 |
| 分组 | API | 用途 |
|---|---|---|
| console | console.log, console.warn, console.error | 日志输出 |
| 弹窗 | alert, confirm, prompt | 短流程阻塞输入 |
| 剪贴板 | getClipboard, setClipboard | 读取和写入文本 |
| 存储 | localStorage | 小型持久键值状态 |
| 网络 | fetch | HTTP GET 辅助函数 |
| 系统 | openURL, openSettings, getDeviceInfo | 系统接口 |
如果你要把 HTML 嵌入 AppUI,先用 WebView(html=...) 放入页面,再在 HTML 中调用注入的 JS API。
import appui
+
+html = """
+<button id="copy">复制文本</button>
+<p id="status">等待操作</p>
+<script>
+document.getElementById("copy").addEventListener("click", function() {
+ setClipboard("来自 WebView")
+ getClipboard(function(text) {
+ document.getElementById("status").textContent = text || "剪贴板为空"
+ })
+})
+</script>
+"""
+
+
+def body():
+ return appui.NavigationStack(
+ appui.WebView(html=html).navigation_title("JS API")
+ )
+
+
+appui.run(body)
+localStorage,复杂数据和敏感数据交给 Python 模块。console.log("Hello")
+console.warn("警告")
+console.error("错误信息")
+alert("提示")
+
+confirm("确定吗?", function(ok) {
+ console.log(ok)
+})
+
+prompt("请输入", "默认", function(text) {
+ console.log(text)
+})
+getClipboard(function(text) {
+ console.log(text)
+})
+
+setClipboard("复制的内容")
+localStorage.setItem("key", "value")
+var value = localStorage.getItem("key")
+localStorage.removeItem("key")
+readFile("a.txt", function(err, content) {
+ console.log(err || content)
+})
+
+writeFile("b.txt", "内容", function(err) {
+ console.log(err || "ok")
+})
+
+listDir(".", function(err, jsonText) {
+ console.log(err || JSON.parse(jsonText))
+})
+fetch("https://httpbin.org/get")
+ .then(function(body) {
+ console.log(body)
+ })
+ .catch(function(err) {
+ console.error(err)
+ })
+requestNotificationPermission(function(granted) {
+ if (granted) {
+ scheduleNotification("标题", "内容", 3)
+ }
+})
+console.log(getBatteryLevel(), isCharging())
+console.log(getDeviceInfo())
+openURL("https://apple.com")
+openSettings()
+vibrate("light")
+
+var now = getCurrentTime()
+console.log(formatDate(now, "yyyy-MM-dd HH:mm"))
+
+console.log(hash("hello", "sha256"))
+
+qrcode("https://www.python.org", function(err, base64) {
+ console.log(err || base64)
+})
+var timer = setTimeout(function() {
+ console.log("1 秒后")
+}, 1000)
+
+var interval = setInterval(function() {
+ console.log("每 2 秒")
+}, 2000)
+
+clearTimeout(timer)
+clearInterval(interval)
+showLoading("处理中...")
+
+takeScreenshot(function(err, base64) {
+ hideLoading()
+ console.log(err ? err : "base64 长度:" + base64.length)
+})
+| 情况 | 应该怎么处理 |
|---|---|
| API 未定义 | 确认代码运行在应用提供的 WebView 环境里,而不是普通外部浏览器。 |
| 回调没有触发 | 把结果写到页面元素或 console,先确认按钮事件已绑定。 |
| 文件或通知失败 | 检查用户动作、权限和文件路径;失败时显示简短错误。 |
| 请求复杂接口 | 复杂 HTTP、鉴权和上传流程优先交给 Python network 模块。 |
| 检查项 | 合格标准 |
|---|---|
| 环境 | 页面在 AppUI WebView 中运行,不依赖外部浏览器注入 API。 |
| 触发 | 剪贴板、通知、打开 URL 和文件写入由用户点击触发。 |
| 回调 | 成功、取消和失败都更新页面元素,不只写 console。 |
| 数据 | secret 不放进 localStorage,复杂数据交给 Python 保存。 |
| 边界 | 复杂 HTTP、权限和原生能力不堆在网页脚本里。 |
fetch 是轻量 GET 辅助函数;复杂请求优先用 Python network 模块。编辑器自定义键盘工具栏与插入动作。
+脚本编辑器键盘工具栏:自定义快捷键按钮、插入文本与代码片段。
+边界:作用于 PythonIDE 脚本编辑器键盘上方工具栏,不是 AppUI 小应用内键盘。配置代码应在编辑器中运行的脚本里执行;AppUI 示例仅演示配置写法。+
| 项 | 说明 |
|---|---|
| 导入 | import keyboard |
| 适合做什么 | Tab 缩进、常用关键字、代码片段、光标跳转 |
| 调用时机 | 编辑器打开前或脚本开头注册按钮 |
| 推荐顺序 | clear()(可选)→ add_button / add_group → 回调里 insert_text |
| 回调 | action 为 Python 函数,点击时在当前编辑器执行 |
在编辑器脚本中注册工具栏按钮:
+import keyboard
+
+
+def insert_tab():
+ keyboard.insert_text(" ")
+
+
+def insert_print():
+ keyboard.insert_text("print()")
+
+
+def insert_def_snippet():
+ keyboard.insert_snippet("def ${1:name}(${2:args}):\n ${0:pass}")
+
+
+keyboard.clear()
+keyboard.add_button("Tab", action=insert_tab)
+keyboard.add_button("print", action=insert_print)
+keyboard.add_button("def", action=insert_def_snippet)
+分组按钮:
+import keyboard
+
+
+def insert_import():
+ keyboard.insert_text("import ")
+
+
+def insert_if():
+ keyboard.insert_text("if :\n ", offset=-6)
+
+
+keyboard.add_group("常用", buttons=[
+ keyboard.Button("import", action=insert_import),
+ keyboard.Button("if", action=insert_if),
+])
+说明:以下界面展示推荐配置;实际生效需在编辑器中运行同等 keyboard 代码。
import appui
+
+state = appui.State(
+ config_status="未应用",
+ snippet_preview="def name(args):\n pass",
+)
+
+
+def show_config_hint():
+ state.config_status = (
+ "请在编辑器脚本中执行 keyboard.add_button;"
+ "AppUI 内不会修改编辑器工具栏。"
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("说明", [
+ appui.Text(
+ "keyboard 模块自定义脚本编辑器键盘工具栏,"
+ "不作用于本 AppUI 界面内的输入框。"
+ ).foreground_color("secondaryLabel"),
+ appui.Button("查看配置说明", action=show_config_hint)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("推荐配置示例", [
+ appui.LabeledContent("Tab", value='insert_text(" ")'),
+ appui.LabeledContent("print", value='insert_text("print()")'),
+ appui.Text(state.snippet_preview)
+ .font("caption")
+ .foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("状态", [
+ appui.Text(state.config_status).foreground_color("secondaryLabel"),
+ ], footer="在编辑器新建脚本,粘贴「快速开始」代码后运行一次即可。"),
+ ]).navigation_title("键盘工具栏")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
add_button(label, action=None, icon=None) | 添加单个按钮 |
add_group(name, buttons) | 添加按钮组 |
set_buttons(buttons) | 替换全部按钮 |
remove_button(label) | 移除按钮 |
clear() | 清空自定义按钮 |
insert_text(text, offset=0) | 插入文本,可选光标偏移 |
insert_snippet(template) | 插入片段($1、$0 占位) |
move_cursor(offset) | 移动光标 |
Button(label, action=None, icon=None) | 按钮描述类 |
keyboard.insert_text(" ") # Tab
+keyboard.insert_text("if :\n ", offset=-6) # 光标回到条件处
+keyboard.insert_snippet("def ${1:name}():\n ${0:pass}")
+keyboard.move_cursor(-3)
+icon 可选 SF Symbol 名称。
同一 label 对应稳定 btn_{label} ID;remove_button / clear 会注销回调。
| 错误写法 | 后果 | 修正 |
|---|---|---|
| 在 AppUI 里期望改工具栏 | 不生效 | 在编辑器脚本中注册 |
重复 add_button 同名 | 覆盖或重复 | 先 clear() 或 remove_button |
action 未调用 insert_* | 按钮无效果 | 回调里写插入逻辑 |
| 与系统键盘混淆 | 概念错误 | 本模块仅编辑器 accessory 工具栏 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+iOS 钥匙串安全存储。
+iOS 钥匙串:安全保存 token、密码、API Key 等敏感小字符串。
+边界:只存敏感凭据,不存主题、筛选条件或大型 JSON(用 storage)。不要把 get_password() 的明文打印到日志或界面;需要展示时用「已配置 / 未配置」或掩码。
+| 项 | 说明 |
|---|---|
| 导入 | import keychain |
| 适合做什么 | 登录 token、API Key、私密密码、多账号凭据 |
| 调用时机 | 保存/删除放在按钮回调;读取可在启动时,但不要在 body() 里反复读 |
| 推荐顺序 | 固定 service + account → set_password → 需要时 get_password → 退出时 delete_password |
| 定位键 | service 是命名空间(如 myapp.api),account 是用户名或环境名 |
下面脚本写入、读取、删除一条演示凭据:
+import keychain
+
+SERVICE = "demo-api"
+ACCOUNT = "default"
+
+ok = keychain.set_password(SERVICE, ACCOUNT, "secret-token")
+print("保存:", ok)
+
+token = keychain.get_password(SERVICE, ACCOUNT)
+print("已配置:", bool(token)) # 不要 print(token) 明文
+
+ok = keychain.delete_password(SERVICE, ACCOUNT)
+print("删除:", ok)
+
+for service, account in keychain.get_services():
+ print(service, account)
+用 SecureField 收集输入,保存后清空输入框;界面只显示状态,不展示明文 secret。
import appui
+import keychain
+
+SERVICE = "demo-api"
+ACCOUNT = "default"
+
+state = appui.State(
+ token="",
+ status="未检查",
+ message="输入 token 后点保存",
+)
+
+
+def update_token(value):
+ state.token = value
+
+
+def save_token():
+ value = state.token.strip()
+ if not value:
+ state.message = "请输入 token"
+ return
+ ok = keychain.set_password(SERVICE, ACCOUNT, value)
+ state.batch_update(
+ token="",
+ status="已配置" if ok else "保存失败",
+ message="已保存到钥匙串" if ok else "保存失败,请重试",
+ )
+
+
+def check_token():
+ token = keychain.get_password(SERVICE, ACCOUNT)
+ state.batch_update(
+ status="已配置" if token else "未配置",
+ message="凭据存在" if token else "尚未保存凭据",
+ )
+
+
+def delete_token():
+ ok = keychain.delete_password(SERVICE, ACCOUNT)
+ state.batch_update(
+ status="已删除" if ok else "删除失败",
+ message="凭据已清除" if ok else "没有可删除的凭据",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("API Token", [
+ appui.SecureField("粘贴 token", text=state.token, on_change=update_token),
+ appui.Button("保存到钥匙串", action=save_token)
+ .button_style("bordered_prominent"),
+ appui.Button("检查是否已配置", action=check_token),
+ appui.Button("删除凭据", action=delete_token, role="destructive"),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("配置", value=state.status),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("钥匙串")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
set_password(service, account, password) | 保存或更新 → bool |
get_password(service, account="") | 读取 → str 或 None |
delete_password(service, account="") | 删除 → bool |
get_services() | 列出本 App 已存的 (service, account) |
条目由 service + account 两个字符串定位:
| 参数 | 说明 |
|---|---|
service | 稳定命名空间,如 openai、github、myapp.production |
account | 用户名、邮箱或环境名,如 default、prod |
password | 要保存的 secret 字符串 |
import keychain
+
+keychain.set_password("myapp.api", "prod", "sk-xxx")
+token = keychain.get_password("myapp.api", "prod")
+if token:
+ # 用于请求头,不要打印到界面
+ headers = {"Authorization": f"Bearer {token}"}
+set_password(...) — 成功返回 True,失败返回 False。
get_password(...) — 存在时返回字符串,不存在返回 None。判断用 if token:,不要假设空字符串。
delete_password(...) — 用户退出账号或切换环境时调用。
get_services() — 返回 list[tuple],每项是 (service, account):
for service, account in keychain.get_services():
+ print(service, account)
+用于设置页的「已保存账号」列表;仍不要直接展示密码明文。
+| 数据类型 | 推荐模块 |
|---|---|
| 主题、筛选、小型 JSON | storage |
| token、密码、API Key | keychain |
| 大文件、图片 | 文件系统或媒体模块 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
把 token 存进 storage | 敏感数据不安全 | 使用 keychain.set_password |
print(get_password(...)) | secret 进入日志 | 只打印 bool(token) 或掩码 |
每次随机 service 名 | 旧凭据读不到 | 使用固定命名空间 |
在 body() 里读并显示明文 | 刷新时泄露 secret | 只显示「已配置 / 未配置」 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+锁屏和 Dynamic Island 的 Live Activity 控制。
+Live Activity:在锁屏和 Dynamic Island 展示进行中的任务(上传、导出、计时等)。
+边界:不是普通通知,也不是长期后台任务;适合有明确开始、更新、结束的短流程。+progress范围0.0–1.0;任务结束(成功/失败/取消)都要end(),避免锁屏残留。需要 iOS 16.2+ 且系统允许 Live Activity;控制放在按钮或真实任务回调,不要在body()里自动start()。
| 项 | 说明 |
|---|---|
| 导入 | import live_activity |
| 适合做什么 | 导出进度、同步状态、训练计时、配送跟踪 |
| 调用时机 | 用户启动任务时 start → 进度变化时 update → 完成时 end |
| 推荐顺序 | is_supported() → start() → update() → end() |
| 敏感信息 | 锁屏可见,不要展示 token、密码或隐私明细 |
下面脚本模拟一次导出流程:
+import live_activity
+
+if not live_activity.is_supported():
+ print("当前设备不支持 Live Activity")
+else:
+ live_activity.start(
+ title="导出",
+ message="准备中…",
+ progress=0.0,
+ compact_text="0%",
+ )
+ live_activity.update(message="进行中", progress=0.5, compact_text="50%")
+ live_activity.end(message="完成", dismiss_delay=5)
+用按钮模拟开始、更新、结束,并同步页面状态。
+import appui
+import live_activity
+
+state = appui.State(
+ message="未开始",
+ progress=0.0,
+ supported=live_activity.is_supported(),
+)
+
+
+def start_activity():
+ if not state.supported:
+ state.message = "当前设备不支持 Live Activity"
+ return
+ live_activity.start(
+ title="导出",
+ message="开始导出",
+ progress=0.0,
+ icon="arrow.down.circle.fill",
+ compact_text="0%",
+ )
+ state.batch_update(message="已开始", progress=0.0)
+
+
+def update_activity():
+ if not state.supported:
+ return
+ live_activity.update(
+ message="进行中",
+ progress=0.5,
+ compact_text="50%",
+ )
+ state.batch_update(message="进行中", progress=0.5)
+
+
+def end_activity():
+ if not state.supported:
+ return
+ live_activity.end(message="完成", dismiss_delay=5)
+ state.batch_update(message="已结束", progress=1.0)
+
+
+def body():
+ support_text = "支持" if state.supported else "不支持"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("环境", [
+ appui.LabeledContent("Live Activity", value=support_text),
+ ]),
+ appui.Section("模拟任务", [
+ appui.ProgressView("进度", value=state.progress),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("操作", [
+ appui.Button("开始", action=start_activity)
+ .button_style("bordered_prominent"),
+ appui.Button("更新到 50%", action=update_activity),
+ appui.Button("结束", action=end_activity, role="destructive"),
+ ], footer="真机 + 系统设置中开启 Live Activity 效果最好。"),
+ ]).navigation_title("实时活动")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_supported() | 设备与系统是否支持 |
start(title, message, progress, icon, compact_text) | 开始活动 |
update(...) | 更新字段(只传要改的) |
end(message, dismiss_delay) | 结束活动 |
is_supported() -> bool — 检查 ActivityKit 是否可用且用户未在系统设置中关闭。
start(title="", message="", progress=None, icon=None, compact_text=None)
live_activity.start(
+ title="下载中",
+ message="正在下载…",
+ progress=0.0,
+ icon="arrow.down.circle.fill",
+ compact_text="0%",
+)
+| 参数 | 说明 |
|---|---|
progress | 0.0–1.0,超出会被裁剪 |
icon | SF Symbol 名称 |
compact_text | Dynamic Island 紧凑模式短文案 |
update(title=None, message=None, progress=None, icon=None, compact_text=None) — 只更新传入的字段,其余保持不变。
end(message=None, dismiss_delay=None) — 结束当前活动。
live_activity.end(message="失败", dismiss_delay=3)
+dismiss_delay 为结束后在锁屏保留的秒数;省略则尽快消失。
| 错误写法 | 后果 | 修正 |
|---|---|---|
普通按钮反馈也 start() | 打扰用户锁屏 | 用 AppUI 状态或 notification |
progress > 1.0 | 显示异常 | 限制在 0.0–1.0 |
只 start 不 end | 锁屏状态残留 | 成功/失败/取消都 end() |
| 展示敏感 token | 锁屏泄露 | 只显示进度与中性文案 |
在 body() 里 start() | 刷新时重复创建 | 放进任务/按钮回调 |
| 文档 | 用途 |
|---|---|
| notification | 任务完成后的本地通知 |
| background | 短时后台任务 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+定位、指南针和地理编码。
+定位与地理编码:申请权限、获取坐标、指南针方向,以及地址正反解析。
+边界:本模块提供 CoreLocation 与 CLGeocoder,不包含地图 UI。要在 AppUI 里画地图标记,请看 定位地图 Cookbook。+
| 项 | 说明 |
|---|---|
| 导入 | import location |
| 适合做什么 | 当前坐标、指南针、坐标转地址、地址转坐标 |
| 调用时机 | 定位放在按钮回调;reverse_geocode / geocode 走后台线程 |
| 推荐顺序 | 查/申请权限 → start_updates → get_location → stop_updates |
| 用完即停 | 不需要持续定位时调用 stop_updates();指南针同理用 stop_heading() |
下面脚本申请权限,读取一次当前坐标,并做一次逆地理编码:
+import asyncio
+import time
+import location
+
+if not location.is_authorized():
+ location.request_access()
+ time.sleep(0.5)
+
+if not location.is_authorized():
+ print("位置权限未授权:", location.authorization_status())
+else:
+ location.start_updates()
+ try:
+ time.sleep(1)
+ loc = location.get_location()
+ if loc:
+ lat, lon = loc["latitude"], loc["longitude"]
+ print(f"坐标: {lat:.5f}, {lon:.5f}")
+ print(f"精度: ±{loc['accuracy']:.0f} m")
+
+ place = asyncio.run(
+ asyncio.to_thread(location.reverse_geocode, lat, lon)
+ )
+ if place.get("success"):
+ print("地址:", place.get("name") or place.get("city"))
+ else:
+ print("地址解析失败:", place.get("error"))
+ else:
+ print("暂未获得坐标,请稍后重试")
+ finally:
+ location.stop_updates()
+把定位、指南针、地址解析都放进按钮回调;界面只展示当前状态。地理编码在后台线程执行,避免主线程死锁。
+import threading
+import time
+
+import appui
+import location
+
+DEFAULT_LAT = 31.2304
+DEFAULT_LON = 121.4737
+
+state = appui.State(
+ auth="未查询",
+ status="点击按钮开始",
+ latitude=DEFAULT_LAT,
+ longitude=DEFAULT_LON,
+ accuracy="—",
+ heading="—",
+ place="—",
+)
+
+
+def _format_place(result):
+ if not result.get("success"):
+ return result.get("error", "解析失败")
+ parts = [
+ result.get("name"),
+ result.get("street"),
+ result.get("city"),
+ result.get("state"),
+ ]
+ text = " · ".join(part for part in parts if part)
+ return text or str(result)
+
+
+def refresh_location():
+ auth = location.authorization_status()
+ state.auth = auth
+
+ if auth in ("denied", "restricted"):
+ state.status = "定位权限不可用,请到系统设置允许定位"
+ return
+
+ if not location.is_authorized():
+ state.status = "正在请求定位权限..."
+ location.request_access()
+ state.auth = location.authorization_status()
+ if not location.is_authorized():
+ state.status = "请在系统弹窗中允许定位后,再次点击「刷新定位」"
+ return
+
+ state.status = "正在定位..."
+ location.start_updates()
+ try:
+ time.sleep(0.8)
+ loc = location.get_location()
+ if loc and loc.get("latitude") is not None:
+ state.latitude = float(loc["latitude"])
+ state.longitude = float(loc["longitude"])
+ state.accuracy = f"±{float(loc.get('accuracy', 0.0)):.0f} m"
+ state.status = "定位成功"
+ else:
+ state.status = "暂未获得坐标,请稍后再次点击「刷新定位」"
+ finally:
+ location.stop_updates()
+
+
+def read_heading():
+ location.start_heading()
+ try:
+ time.sleep(0.5)
+ heading = location.get_heading()
+ if heading:
+ magnetic = float(heading.get("magnetic", 0.0))
+ state.heading = f"{magnetic:.0f}°"
+ state.status = "指南针已更新"
+ else:
+ state.heading = "—"
+ state.status = "暂未获得指南针数据"
+ finally:
+ location.stop_heading()
+
+
+def reverse_lookup():
+ if state.status != "定位成功":
+ state.status = "请先点击「刷新定位」"
+ return
+
+ state.status = "正在解析地址..."
+ lat, lon = state.latitude, state.longitude
+
+ def work():
+ result = location.reverse_geocode(lat, lon)
+ state.place = _format_place(result)
+ state.status = "地址解析完成" if result.get("success") else "地址解析失败"
+
+ threading.Thread(target=work, daemon=True).start()
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("操作", [
+ appui.Button("刷新定位", action=refresh_location)
+ .button_style("bordered_prominent"),
+ appui.Button("读取指南针", action=read_heading),
+ appui.Button("坐标转地址", action=reverse_lookup),
+ ]),
+ appui.Section("定位", [
+ appui.LabeledContent("授权", value=state.auth),
+ appui.LabeledContent("纬度", value=f"{state.latitude:.5f}"),
+ appui.LabeledContent("经度", value=f"{state.longitude:.5f}"),
+ appui.LabeledContent("精度", value=state.accuracy),
+ appui.LabeledContent("指南针", value=state.heading),
+ ]),
+ appui.Section("地址", [
+ appui.Text(state.place).foreground_color("secondaryLabel"),
+ appui.Text(state.status).font("caption").foreground_color("tertiaryLabel"),
+ ]),
+ ]).navigation_title("定位")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
authorization_status() | 查询授权 → 如 authorized_when_in_use |
is_authorized() | 是否已授权 → bool |
request_access() | 申请「使用期间」定位权限 |
start_updates() | 开始接收位置更新 |
stop_updates() | 停止位置更新 |
get_location() | 读取最新坐标 → dict 或 None |
start_heading() | 开始接收指南针更新 |
stop_heading() | 停止指南针更新 |
get_heading() | 读取指南针 → dict 或 None |
reverse_geocode(lat, lon) | 坐标转地址 → dict |
geocode(address) | 地址转坐标 → dict |
| API | 说明 |
|---|---|
authorization_status() | 返回状态字符串,不弹窗 |
is_authorized() | authorized_when_in_use 或 authorized_always 时为 True |
request_access() | 弹出系统授权,申请「使用期间」定位 |
authorization_status() 常见值:
not_determined — 尚未选择authorized_when_in_use — 使用期间允许authorized_always — 始终允许denied / restricted — 不可用if not location.is_authorized():
+ location.request_access()
+print(location.authorization_status())
+也可用 permission 统一查询:permission.status("location")。
start_updates() — 开始接收 GPS 更新。第一次读取前通常需要等待一小段时间。
get_location() — 返回最新坐标字典,暂无数据时返回 None。
import time
+import location
+
+location.start_updates()
+try:
+ time.sleep(1)
+ loc = location.get_location()
+ if loc:
+ print(loc["latitude"], loc["longitude"])
+finally:
+ location.stop_updates()
+| 字段 | 说明 |
|---|---|
latitude / longitude | 纬度 / 经度 |
altitude | 海拔(米) |
accuracy | 水平精度(米) |
timestamp | Unix 时间戳 |
stop_updates() — 停止定位,省电。
| API | 说明 |
|---|---|
start_heading() | 开始接收指南针更新 |
get_heading() | 读取磁北、真北与精度 |
stop_heading() | 停止指南针更新 |
import time
+import location
+
+location.start_heading()
+try:
+ time.sleep(0.5)
+ heading = location.get_heading()
+ if heading:
+ print(heading["magnetic"], heading["true_heading"])
+finally:
+ location.stop_heading()
+get_heading() 字段:magnetic、true_heading、accuracy。
注意:指南针与 GPS 是两条独立更新流,分别启动和停止。+
reverse_geocode(latitude, longitude) — 坐标转地址。
geocode(address) — 地址转坐标。
import asyncio
+import location
+
+
+async def lookup():
+ place = await asyncio.to_thread(location.reverse_geocode, 31.2304, 121.4737)
+ coords = await asyncio.to_thread(location.geocode, "上海市黄浦区")
+ print(place)
+ print(coords)
+
+
+asyncio.run(lookup())
+成功时 reverse_geocode 通常带 success: True,并可能有 name、street、city、state、country、postal_code 等字段。geocode 成功时带 latitude、longitude。
注意:不要在 App 主线程同步调用地理编码;否则会返回+code: -100错误。MiniApp / AppUI 回调里用asyncio.to_thread(...)或后台线程。
在 AppUI 中用 appui.MapView 展示中心点与标记;先通过本模块拿到坐标,再在地图组件里绑定。完整示例见 定位地图 Cookbook。
| 错误写法 | 后果 | 修正 |
|---|---|---|
主线程直接 geocode / reverse_geocode | 返回 code: -100,可能死锁 | 用 asyncio.to_thread 或后台线程 |
未 start_updates 就读 get_location | 返回 None | 先启动更新并等待 0.5–1 秒 |
权限被拒后反复 request_access | 用户烦躁、仍拿不到坐标 | 提示去系统设置,不要循环弹窗 |
用完不 stop_updates | 持续耗电 | 读取完成后调用 stop_updates() |
| 文档 | 用途 |
|---|---|
| permission | 统一查询 location 权限 |
| weather | 按坐标或当前位置查天气 |
| 定位地图 Cookbook | AppUI MapView 标记当前位置 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+系统邮件编写器发送邮件。
+系统邮件撰写:弹出 MessageUI 邮件编辑器,填写收件人、主题、正文与附件。
+边界:仅唤起系统邮件界面,用户确认后才发送;不能静默发信。需设备已配置邮件账户(+can_send()为True)。
| 项 | 说明 |
|---|---|
| 导入 | import mail |
| 适合做什么 | 分享报告、发送导出文件、反馈邮件 |
| 调用时机 | compose() 放在按钮回调;会弹出全屏编辑器 |
| 推荐顺序 | can_send() → compose(to, subject, body, ...) |
| 返回值 | sent / saved / cancelled / failed |
检查能否发信并撰写一封邮件:
+import mail
+
+if not mail.can_send():
+ print("未配置邮件账户")
+else:
+ result = mail.compose(
+ to=["user@example.com"],
+ subject="Hello",
+ body="来自 PythonIDE 的测试邮件",
+ )
+ print(result)
+带附件:
+import mail
+
+attachment = {
+ "path": "/path/report.pdf",
+ "mime_type": "application/pdf",
+ "name": "report.pdf",
+}
+result = mail.compose(
+ to="team@example.com",
+ subject="周报",
+ body="请查收附件",
+ attachments=[attachment],
+)
+撰写邮件放在按钮回调;界面展示最近一次结果。
+import appui
+import mail
+
+state = appui.State(
+ can_send="—",
+ to="user@example.com",
+ subject="PythonIDE 演示",
+ body="这是一封测试邮件。",
+ result="尚未发送",
+)
+
+
+def refresh_can_send():
+ state.can_send = "是" if mail.can_send() else "否"
+
+
+def send_mail():
+ refresh_can_send()
+ if state.can_send != "是":
+ state.result = "未配置邮件账户"
+ return
+
+ result = mail.compose(
+ to=[state.to],
+ subject=state.subject,
+ body=state.body,
+ )
+ state.result = result
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("邮件", [
+ appui.TextField("收件人", text=state.to),
+ appui.TextField("主题", text=state.subject),
+ appui.TextEditor(text=state.body).frame(min_height=100),
+ appui.Button("撰写并发送", action=send_mail)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("可发送", value=state.can_send),
+ appui.LabeledContent("结果", value=state.result),
+ ]),
+ ]).navigation_title("邮件")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
can_send() | 是否已配置邮件账户 → bool |
compose(to, subject='', body='', cc=None, bcc=None, attachments=None) | 弹出编辑器 → 结果字符串 |
MailError | 原生能力入口失败异常 |
compose(...) — 呈现系统 MFMailComposeViewController。
| 参数 | 说明 |
|---|---|
to / cc / bcc | 字符串或列表 |
subject / body | 主题与正文 |
attachments | [{"path", "mime_type", "name"}, ...] |
result = mail.compose(to=["a@b.com"], subject="Hi", body="...")
+# sent | saved | cancelled | failed
+| 错误写法 | 后果 | 修正 |
|---|---|---|
未检查 can_send() | 无法弹出编辑器 | 先判断并提示用户配置账户 |
在 body() 里调用 compose | 每次刷新弹邮件窗 | 放进按钮回调 |
| 期望后台自动发信 | 系统不允许 | 用户必须在编辑器中确认发送 |
| 附件路径不存在 | failed | 确认文件在沙盒可访问路径 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+视频合并、配音与导出。
+视频合成与导出:按顺序拼接视频、为视频替换/添加音轨、转码导出 MP4。
+边界:基于 AVFoundation,操作可能耗时较长(阻塞脚本数秒到两分钟)。输入/输出路径须为 App 可访问的本地文件;合成放在按钮回调,不要在 body() 中自动执行。素材可先由 video_recorder 或 photos 获得。
+| 项 | 说明 |
|---|---|
| 导入 | import media_composer |
| 适合做什么 | 多段录像拼接、替换背景音、统一导出 MP4 |
| 调用时机 | 用户确认后调用;显示进度/状态中 |
| 推荐顺序 | 确认源文件存在 → merge_videos / merge_audio / export → 校验输出路径 |
| 输出格式 | 当前 Bridge 导出为 .mp4 |
下面脚本检查素材是否存在,再合并导出:
+import os
+import media_composer
+
+clip = os.path.join(os.path.expanduser("~/Documents"), "clip1.mov")
+out = os.path.join(os.path.expanduser("~/Documents"), "merged.mp4")
+
+if not os.path.exists(clip):
+ print("请先把视频放到:", clip)
+else:
+ try:
+ path = media_composer.merge_videos([clip], out)
+ print("已导出:", path)
+ except media_composer.MediaComposerError as exc:
+ print("合成失败:", exc, f"code={exc.code}")
+为视频替换音轨:
+import media_composer
+
+media_composer.merge_audio("video.mov", "audio.m4a", "output.mp4")
+合成放在按钮回调;先检查文件是否存在再执行。
+import appui
+import os
+import media_composer
+
+DOCS = os.path.expanduser("~/Documents")
+CLIP = os.path.join(DOCS, "clip1.mov")
+OUTPUT = os.path.join(DOCS, "merged.mp4")
+
+state = appui.State(
+ input_path=CLIP,
+ output_path=OUTPUT,
+ result="—",
+ status="准备好素材后点击合成",
+)
+
+
+def run_merge():
+ if not os.path.exists(state.input_path):
+ state.batch_update(
+ result="—",
+ status=f"找不到源文件: {state.input_path}",
+ )
+ return
+
+ try:
+ path = media_composer.merge_videos([state.input_path], state.output_path)
+ state.batch_update(
+ result=path or state.output_path,
+ status="合成完成",
+ )
+ except media_composer.MediaComposerError as exc:
+ state.batch_update(
+ result="—",
+ status=f"失败: {exc}",
+ )
+
+
+def body():
+ exists = "存在" if os.path.exists(state.input_path) else "缺失"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("素材", [
+ appui.LabeledContent("源视频", value=state.input_path),
+ appui.LabeledContent("状态", value=exists),
+ appui.LabeledContent("输出", value=state.output_path),
+ ]),
+ appui.Section("操作", [
+ appui.Button("合并导出 MP4", action=run_merge)
+ .button_style("bordered_prominent"),
+ appui.LabeledContent("结果", value=state.result),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="请先把 clip1.mov 放到 Documents;合成可能耗时。"),
+ ]).navigation_title("视频合成")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
merge_videos(paths, output_path) | 按顺序拼接视频 |
merge_audio(video_path, audio_path, output_path) | 视频 + 音轨合成 |
export(input_path, output_path, format) | 转码/导出 |
MediaComposerError | 操作失败时抛出 |
merge_videos(paths, output_path) -> str
path = media_composer.merge_videos(
+ ["part1.mov", "part2.mov"],
+ "final.mp4",
+)
+按列表顺序拼接各文件的视频轨道;无视频轨的文件会被跳过。返回输出路径。
+merge_audio(video_path, audio_path, output_path) -> str
path = media_composer.merge_audio(
+ "silent.mov",
+ "voice.m4a",
+ "with_audio.mp4",
+)
+取视频的画面轨与音频文件的音轨,时长取两者较短者。
+export(input_path, output_path, format="mp4") -> str
path = media_composer.export("input.mov", "output.mp4")
+format 为兼容参数;当前 Bridge 统一导出 MP4。
code | 含义 |
|---|---|
invalid_input | 路径缺失或列表为空 |
compose_failed | 无法创建合成轨道 |
export_failed | 导出会话失败或超时 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里合成 | 每次刷新都重跑 | 放进按钮回调 |
| 源文件不存在 | export_failed / 空输出 | 先 os.path.exists() |
空 paths 列表 | invalid_input | 至少提供一个视频 |
| 合成中反复点按钮 | 重复阻塞 | 加状态锁或禁用按钮 |
| 文档 | 用途 |
|---|---|
| video_recorder | 采集视频素材 |
| avplayer | 预览合成结果 |
| photos | 保存视频到相册 |
| audio_recorder | 采集配音素材 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+系统信息编写器发送短信或 iMessage。
+系统短信 / iMessage 撰写:弹出 Messages 编辑器发送文本或附件。
+边界:仅唤起系统信息界面,用户确认后才发送;不能静默群发短信。模拟器上可能不可用。+
| 项 | 说明 |
|---|---|
| 导入 | import message |
| 适合做什么 | 快速发短信、分享链接、附带文件 |
| 调用时机 | compose() 放在按钮回调 |
| 推荐顺序 | can_send() → compose(recipients, body, ...) |
| 返回值 | sent / cancelled / failed |
import message
+
+if not message.can_send():
+ print("此设备无法发送短信")
+else:
+ result = message.compose(
+ ["+8613800138000"],
+ "Hello from PythonIDE",
+ )
+ print(result)
+import appui
+import message
+
+state = appui.State(
+ can_send="—",
+ phone="+8613800138000",
+ body="来自 PythonIDE 的测试短信",
+ result="尚未发送",
+)
+
+
+def refresh_can_send():
+ state.can_send = "是" if message.can_send() else "否"
+
+
+def send_sms():
+ refresh_can_send()
+ if state.can_send != "是":
+ state.result = "无法发送短信"
+ return
+
+ result = message.compose([state.phone], state.body)
+ state.result = result
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("短信", [
+ appui.TextField("号码", text=state.phone),
+ appui.TextEditor(text=state.body).frame(min_height=80),
+ appui.Button("撰写短信", action=send_sms)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("可发送", value=state.can_send),
+ appui.LabeledContent("结果", value=state.result),
+ ]),
+ ]).navigation_title("短信")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
can_send() | 设备能否发短信 → bool |
compose(recipients, body='', attachments=None) | 弹出编辑器 → 结果字符串 |
MessageError | 原生能力入口失败异常 |
compose(recipients, body='', attachments=None)
| 参数 | 说明 |
|---|---|
recipients | 手机号或 Apple ID,字符串或列表 |
body | 正文 |
attachments | 文件路径列表 |
result = message.compose(["+8613800138000"], "你好")
+# sent | cancelled | failed
+| 错误写法 | 后果 | 修正 |
|---|---|---|
未检查 can_send() | 编辑器打不开 | 先判断可用性 |
在 body() 里 compose | 每次刷新弹窗 | 放进按钮回调 |
| 期望批量静默发送 | 系统不允许 | 需用户逐条确认 |
| 模拟器测试 | 常返回不可用 | 在真机验证 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+按任务选择入口、状态、布局、控件、导航、媒体和图表 API。
+这页按任务组织 AppUI API。还没确定页面结构时,先看 AppUI UI 模式。
+import appui
+
+state = appui.State(name="MiniApp", enabled=True, saved=False)
+
+
+def set_name(value):
+ state.batch_update(name=value, saved=False)
+
+
+def set_enabled(value):
+ state.batch_update(enabled=value, saved=False)
+
+
+def save():
+ state.saved = bool(state.name.strip())
+
+
+def body():
+ footer = "Saved" if state.saved else "Edit values and tap Save"
+ form = appui.Form([
+ appui.Section("Controls", [
+ appui.TextField("Name", text=state.name, on_change=set_name),
+ appui.Toggle("Enabled", is_on=state.enabled, on_change=set_enabled),
+ ]),
+ appui.Section("Output", [
+ appui.LabeledContent("Name", value=state.name or "Missing"),
+ appui.LabeledContent("Enabled", value=str(state.enabled)),
+ ], footer=footer),
+ ]).navigation_title("API 地图")
+
+ return appui.NavigationStack(
+ form.toolbar([
+ appui.ToolbarItem(
+ placement="navigation_bar_trailing",
+ content=appui.Button("Save", action=save)
+ .button_style("bordered_prominent"),
+ )
+ ])
+ )
+
+
+appui.run(body, state=state)
+预期效果:页面显示输入、开关、输出和导航栏保存按钮;修改后点击 Save 会更新保存状态。
+| 任务 | 首选 API | 继续阅读 |
|---|---|---|
| 启动 MiniApp | appui.run | 函数参考 |
| 控制呈现方式 | appui.run(..., presentation=...) | 函数参考 |
| 程序化弹层 | presentation_present / presentation_dismiss | 呈现 API |
| 保存普通界面状态 | State | 状态管理 |
| 多字段一起更新 | state.batch_update(...) | 状态管理 |
| 高频局部刷新 | ReactiveState | 性能与实时界面 |
| 派生数据 | computed | 状态管理 |
| 状态变化后的副作用 | effect | 状态管理 |
| 双向绑定 | bind | 函数参考 |
| 输入设置项 | Form + Section | 布局系统 |
| 动态列表 | List + ForEach | 布局系统 |
| 列表进详情 | NavigationStack + NavigationLink | 导航与页面结构 |
| 程序化跳转 | NavigationPath + destinations | 导航与页面结构 |
| 多主分区 | TabView + Tab | 导航与页面结构 |
| 底部状态条展开面板 | .tab_view_bottom_accessory + .sheet | 导航参考 |
| 导航栏按钮 | ToolbarItem | 导航参考 |
| 临时弹层 | .sheet / .popover | 呈现 API |
| 确认和错误提示 | .alert / .confirmation_dialog | 呈现 API |
| 搜索和刷新 | .searchable / .refreshable | 交互与回调 |
| 空状态 | ContentUnavailableView | 数据展示 |
| 加载状态 | ProgressView | 数据展示 |
| 输入控件 | TextField、SecureField、TextEditor | 控件 API |
| 选择控件 | Toggle、Picker、Slider、Stepper | 控件 API |
| 媒体和文件 | PhotoPicker、CameraPicker、FileImporter | 媒体 API |
| 地图、网页、视频 | MapView、WebView、VideoPlayer | 媒体 API |
| 图表和绘制 | Chart、Canvas、DrawingContext、Path | 图表 API |
| API | 保证 | 注意 |
|---|---|---|
appui.run(...) | 启动当前 MiniApp 页面。 | 一个脚本只保留一个最终入口。 |
State | 字段变化后页面重建。 | 多字段同时更新用 batch_update(...)。 |
Button(action=...) | 点击后执行命名回调。 | 不要写 action=save()。 |
TextField(on_change=...) | 输入变化时把新值交给回调。 | 回调要写回 State。 |
ForEach(..., key=...) | 维护动态行身份。 | key 必须来自稳定业务 id。 |
NavigationLink | 打开目标详情页。 | 目标页用函数返回 View,避免把复杂逻辑塞进行内。 |
.tab_view_bottom_accessory(...) + .sheet(...) | 底部常驻 compact 视图点击后打开原生 sheet。 | 适合播放、下载、录音、导航等持续任务;紧凑条负责常驻状态,完整面板交给系统 sheet 处理下拉关闭、圆角和拖拽手感。 |
.sheet(...) / .alert(...) | 展示临时页面或提示。 | is_presented 要由状态控制。 |
PhotoPicker / FileImporter | 触发系统选择流程。 | 处理用户取消、权限拒绝和空结果。 |
NavigationStack、TabView、List、Form、ScrollView。State、ReactiveState、bind、computed、Timer。appui-ref-*。Button(action=...) 传入命名函数。on_change 写回 State。ForEach 使用稳定 key。body() 返回 View,appui.run(...) 在脚本末尾调用。| 文档 | 用途 |
|---|---|
| 快速上手 | 跑通第一个 MiniApp。 |
| 运行时选择 | 判断是否应该使用 AppUI。 |
| AppUI UI 模式 | 选择页面结构。 |
| MiniApp 开发排错 | 排查入口、状态、布局和系统能力。 |
按页面类型选择 Form、List、NavigationStack、TabView 和媒体承载。
+这篇文档帮你先选对页面结构,再进入具体 API。已经知道 API 名时,直接查 AppUI API 地图。
+Form + Section,动态数据优先 List + ForEach。NavigationStack + NavigationLink,主分区优先 TabView + Tab。import appui
+
+items = [
+ {"id": "inbox", "title": "收件箱", "count": 12},
+ {"id": "today", "title": "今日任务", "count": 5},
+ {"id": "done", "title": "已完成", "count": 32},
+]
+
+
+def item_key(item):
+ return item["id"]
+
+
+def detail_view(item):
+ return appui.Form([
+ appui.Section(item["title"], [
+ appui.LabeledContent("Count", value=str(item["count"])),
+ ])
+ ]).navigation_title(item["title"])
+
+
+def row_view(item):
+ return appui.NavigationLink(
+ destination=detail_view(item),
+ label=appui.HStack([
+ appui.Text(item["title"])
+ .frame(max_width=appui.infinity, alignment="leading"),
+ appui.Text(str(item["count"])).foreground_color("secondaryLabel"),
+ ]),
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("分区", [
+ appui.ForEach(items, row_builder=row_view, key=item_key),
+ ]),
+ ]).navigation_title("UI 模式")
+ )
+
+
+appui.run(body)
+预期效果:列表显示三个分区,点击任一行进入对应详情页。
+| 页面类型 | 推荐结构 | 适合 |
|---|---|---|
| 单页工具 | NavigationStack + VStack | 计算器、转换器、小查询页 |
| 设置页 | NavigationStack + Form + Section | 开关、输入、选择器、账号配置 |
| 列表页 | NavigationStack + List + ForEach | 任务、笔记、记录、搜索结果 |
| 列表详情 | NavigationStack + NavigationLink | 课程、商品、文档、条目详情 |
| 多主分区 | TabView + Tab | 首页、记录、设置、统计 |
| 底部常驻状态条 | TabView + .tab_view_bottom_accessory + .sheet | 音乐、播客、下载、录音、导航 |
| 仪表盘 | ScrollView + LazyVGrid + Chart | KPI、日报、趋势图、监控 |
| 媒体页 | VideoPlayer / MapView / WebView | 视频、地图、网页和富内容 |
| 自定义绘制 | Canvas + DrawingContext + Path | 轻量绘图、签名板、图形面板 |
| 能力 | 常见场景 | 继续阅读 |
|---|---|---|
NavigationStack | 页面 push、详情页、设置页 | 导航与页面结构 |
NavigationLink | 列表进详情、设置项进入子页 | 导航与页面结构 |
NavigationPath | 程序化跳转、返回、带参数详情 | 导航与页面结构 |
TabView / Tab | 首页、记录、设置等主分区 | 导航与页面结构 |
.tab_view_bottom_accessory + .sheet | 底部状态条点击后打开原生面板 | 导航参考 |
Form / Section | 设置、资料编辑、筛选条件 | 布局系统 |
TextField / SecureField | 单行输入、密码、API Key | 控件 API |
Toggle / Picker / Slider | 开关、模式、连续数值 | 控件 API |
List / ForEach | 动态行、分组数据、原生列表 | 布局系统 |
ContentUnavailableView | 空状态、无结果、无权限提示 | 数据展示 |
.searchable / .refreshable | 搜索过滤、下拉刷新 | 交互与回调 |
PhotoPicker / FileImporter | 选图、导入 PDF/CSV/文本 | 媒体与系统集成 |
CameraPicker / MapView / WebView | 拍照、地图、网页 | 媒体与系统集成 |
Chart / Canvas / Path | 数据看板、自定义绘制 | 图表与绘图 |
VideoPlayer / AsyncImage | 视频播放、远程封面 | 媒体与系统集成 |
VStack 承载整页;内容会变多时用 List、Form 或 ScrollView。List 行里放复杂媒体承载;视频、地图、WebView 应该放在稳定页面区域。.tab_view_bottom_accessory 显示紧凑条,再用 .sheet 展开详情。systemBackground、label、secondaryLabel。body();放到按钮、刷新或明确加载动作。描述一个 AppUI MiniApp 时,最好一次说清:
+appui MiniApp。Form + Section。ForEach 提供稳定业务 key。scene、VideoPlayer、Canvas 或专门媒体区域。按任务查 API 见 AppUI API 地图。需要判断运行时时,先看 运行时选择。
+| 文档 | 用途 |
|---|---|
| 快速上手 | 四个可预览示例串起最小闭环。 |
| AppUI API 地图 | 按任务查 30 项高频 API。 |
| 原生能力入口 | AppUI 页面里调用 iOS 原生模块。 |
| 开发排错 | 空白页、按钮无效、输入不更新等问题。 |
判断该用 appui、widget、ui、scene 还是 console。
+MiniApp 先选运行时,再写代码。运行时选错会让布局难维护、交互不可用、系统能力接不上。
+大多数“像 App 一样能点、能输入、能保存状态”的需求,默认选 appui。只有当需求明确是小组件、游戏绘制、Pythonista 兼容 UI 或纯输出脚本时,才换运行时。
| 需求 | 首选运行时 | 不建议 |
|---|---|---|
| 原生表单、列表、导航、设置页、工具页 | appui | 用 HTML 模拟 iOS 设置页 |
| 主屏、锁屏、StandBy 小组件 | widget | 用 AppUI 写桌面 Widget |
| 命令输出、批处理、日志 | console | 为纯输出脚本硬做 UI |
| 绘制、小游戏、精灵、连续触摸 | scene | 用 AppUI 手写游戏循环 |
| Pythonista 兼容的命令式界面脚本 | ui | 和 appui 混用通配导入 |
| 已有 Web 页面或必须使用 DOM/CSS/JS | HTML / WebView | 为普通工具页引入 Web 结构 |
| 相机、定位、通知、存储等系统能力 | appui + 对应模块 | 在 Web 页面里绕过原生能力 |
import appui
+
+state = appui.State(status="Ready")
+
+
+def run_action():
+ state.status = "Action completed"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("MiniApp", [
+ appui.LabeledContent("Runtime", value="appui"),
+ appui.LabeledContent("Status", value=state.status),
+ appui.Button("Run", action=run_action)
+ .button_style("bordered_prominent"),
+ ])
+ ]).navigation_title("运行时")
+ )
+
+
+appui.run(body, state=state)
+预期效果:预览显示一个原生表单页面,点击 Run 后状态变为 Action completed。
用于主屏、锁屏和 StandBy 小组件。它没有普通页面输入和长列表交互,适合信息展示、参数配置、时间线刷新和受控桌面按钮。
+用于一次性脚本、批处理、日志输出和转换任务。用户只需要看输出时,不要硬做 AppUI。
+用于持续绘制、游戏、精灵、物理和触摸循环。它不是设置页、列表页或表单页的替代品。
+用于 Pythonista 兼容的命令式界面脚本。新 MiniApp 默认不要从 ui 开始;需要原生 App 风格页面时用 appui。
用于已有 Web 页面、必须复用 DOM/CSS/JS 的内容,或需要展示外部网页。普通 iOS 风格工具页不建议用 Web 结构模拟。
+appui,因为它覆盖表单、列表、导航、设置和大多数工具页。widget。scene。console。appui,使用 NavigationStack、Form、List。widget 的边界;需要输入时做 AppUI 页面。scene。| 文档 | 用途 |
|---|---|
| 快速上手 | 跑通第一个 AppUI MiniApp。 |
| AppUI UI 模式 | 选择页面结构和常见模式。 |
| AppUI API 地图 | 按任务查 API。 |
| MiniApp 开发排错 | 排查入口、状态、布局和系统能力。 |
排查入口、状态、按钮、输入、列表、布局和系统能力问题。
+排错先看运行时是否选对,再看入口是否完整,最后查状态、交互、布局和系统能力。不要只确认“预览能打开”,还要确认按钮、输入、搜索、刷新和弹层都能产生可见变化。
+import appui
+
+state = appui.State(saved=False)
+
+
+def save():
+ state.saved = True
+
+
+def body():
+ label = "Saved" if state.saved else "Not saved"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("Action", [
+ appui.LabeledContent("Status", value=label),
+ appui.Button("Save", action=save)
+ .button_style("bordered_prominent"),
+ ])
+ ]).navigation_title("排错")
+ )
+
+
+appui.run(body, state=state)
+预期效果:页面显示保存状态,点击 Save 后状态从 Not saved 变成 Saved。
| 现象 | 先查 | 继续看 |
|---|---|---|
| 预览打不开 | import appui、body()、appui.run(...) | 快速上手 |
| 点按钮没反应 | action 是否传函数本身 | AppUI API 地图 |
| 输入不更新 | on_change 是否写回 State | 状态管理 |
| 列表刷新错乱 | ForEach 是否有稳定 key | 布局系统 |
| 页面不像原生 App | 是否使用 List、Form、NavigationStack | AppUI UI 模式 |
| 高频刷新卡顿 | Timer 是否在 body() 外创建 | 性能与实时界面 |
| 权限或系统能力失败 | 是否处理拒绝、取消和不可用 | 原生能力入口 |
def save():
+ state.saved = True
+
+
+appui.Button("Save", action=save)
+不要这样写:
+appui.Button("Save", action=save())
+save() 会在构建页面时立刻执行,按钮拿不到正确回调。
def row_key(row):
+ return row["id"]
+
+
+def row_view(row):
+ return appui.Text(row["title"])
+
+
+appui.ForEach(rows, row_builder=row_view, key=row_key)
+不要把筛选后的 index 当 key。删除、重排、搜索后 index 会变化,行状态可能错位。
+appui.run(...)。key,删除、搜索、重排后不能错行。body() 里,放到按钮、刷新或明确的加载动作中。State。on_change 接收新值,保存按钮读取的是同一个 State。ForEach(..., key=...)。| 文档 | 用途 |
|---|---|
| 运行时选择 | 判断 AppUI、widget、ui、scene 和 console。 |
| 快速上手 | 用四个可预览示例掌握入口、状态、表单和导航。 |
| AppUI UI 模式 | 常见页面结构和反模式。 |
| AppUI API 地图 | 按任务查 API。 |
| 原生能力入口 | 系统能力、权限和失败路径。 |
MiniApp 常用原生能力入口,不是完整模块总表。
+主文档已合并:入口模型、Python 模块表、AppUI 原生组件入口表与侧边栏导航均由 schema 生成,请优先阅读 **iOS 原生能力**。本文仅保留历史深链,不再维护独立能力表。+
MiniApp 场景下的原生能力配方:按任务选模块、组合调用、状态写回界面。
+边界:这是 MiniApp 配方索引,不是可+import的模块。完整 API 以各模块文档为准;系统调用放在按钮回调,不要写在body()里。
| 项 | 说明 |
|---|---|
| 导入 | 按场景 import 对应模块(如 photos、storage) |
| 适合做什么 | 选图、定位、通知、持久化、网络列表等 MiniApp 常见流 |
| 调用时机 | 按钮、刷新、选择器触发;body() 只读 State |
| 数据分工 | 设置 storage、记录 database、密钥 keychain |
| 反馈 | 成功/取消/失败都写回 State 或 HUD |
保存主题并触发触觉反馈:
+import haptics
+import storage
+
+storage.set("demo.theme", "Dark")
+if haptics.is_supported():
+ haptics.notification("success")
+print("已保存:", storage.get("demo.theme"))
+设备信息、快照、通知组合;所有原生调用在按钮回调里。
+import appui
+import device
+import haptics
+import notification
+import storage
+
+SNAPSHOT_KEY = "native.snapshot"
+REMINDER_ID = "native.snapshot.reminder"
+
+state = appui.State(
+ message="就绪",
+ rows=[
+ {"id": "model", "title": "型号", "value": "点击刷新"},
+ {"id": "battery", "title": "电量", "value": "—"},
+ ],
+)
+
+
+def row_key(row):
+ return row["id"]
+
+
+def row_view(row):
+ return appui.LabeledContent(row["title"], value=row["value"])
+
+
+def refresh_device_info():
+ state.rows = [
+ {"id": "model", "title": "型号", "value": device.model()},
+ {"id": "battery", "title": "电量", "value": f"{device.battery_level():.0%}"},
+ ]
+ state.message = "设备信息已刷新"
+
+
+def save_snapshot():
+ storage.set_json(SNAPSHOT_KEY, state.rows)
+ if haptics.is_supported():
+ haptics.notification("success")
+ state.message = "快照已保存"
+
+
+def remind_later():
+ perm = notification.request_permission()
+ if not perm.get("granted"):
+ state.message = "未获得通知权限"
+ return
+
+ result = notification.schedule(
+ REMINDER_ID,
+ "原生快照",
+ "查看已保存的设备快照",
+ delay=60,
+ )
+ state.message = f"已调度提醒: {result}"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.List([
+ appui.Section("设备", [
+ appui.ForEach(state.rows, row_builder=row_view, key=row_key),
+ ]),
+ appui.Section("操作", [
+ appui.Button("刷新设备", action=refresh_device_info),
+ appui.Button("保存快照", action=save_snapshot)
+ .button_style("bordered_prominent"),
+ appui.Button("1 分钟后提醒", action=remind_later),
+ ], footer=state.message),
+ ]).navigation_title("原生能力")
+ )
+
+
+appui.run(body, state=state)
+| 能力 | 主文档 | 典型用途 |
|---|---|---|
| 定位与地图 | location | 坐标、地理编码、指南针 |
| 相册与相机 | photos | 选图、拍照、保存 |
| 通知与实时活动 | notification、live_activity | 提醒、角标、锁屏状态 |
| 持久化 | storage、database、keychain | 设置、记录、密钥 |
| 设备与传感器 | device、motion | 屏幕、电池、传感器 |
| 触觉与声音 | haptics、sound | 反馈、提示音 |
| 网络 | network、websocket | HTTP、实时连接 |
| 编辑器工具栏 | keyboard | 插入代码片段 |
| 目标 | 推荐组合 | 说明 |
|---|---|---|
| 选图并保存设置 | appui + photos + storage | 按钮触发 picker,路径写入 State |
| 下载并存相册 | network + photos | 下载到本地再 save_video |
| 地图展示位置 | permission + location + MapView | 先查权限再定位 |
| 健康面板 | permission + health + Chart | 拒绝时显示空状态 |
| 提醒任务 | notification + storage | 稳定 identifier 调度/取消 |
| 播放历史 | database + List | 大列表勿塞 storage |
| 安全配置 | biometric + keychain + Form | 密钥进 keychain |
| 传感器面板 | motion + haptics | 高频数据避免每帧重建 UI |
| 远端列表 | network + List + .refreshable | 请求放刷新回调 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里请求权限/发通知 | 刷新时反复弹窗 | 放进按钮回调 |
token 写入 storage 或日志 | 泄露风险 | 用 keychain |
| 权限拒绝后空白页 | 用户不知原因 | 显示说明与重试按钮 |
| 用户取消选择未处理 | 崩溃或脏状态 | 判断 None 并保留原状态 |
| 网络失败清空旧数据 | 体验差 | 保留缓存并显示错误 |
| 文档 | 用途 |
|---|---|
| iOS 原生能力 | 入口模型、模块表与权限矩阵 |
| 全部模块总览 | 按分类浏览模块 |
| 运行时选择 | appui / scene / widget 选型 |
| permission | 统一权限查询与申请 |
| photos | 相册与相机 |
| notification | 本地通知 |
body() 构建期| 情况 | 处理 |
|---|---|
| 权限拒绝 | 提示去设置开启,并保留当前页面 |
| 设备不支持 | 用 is_available() 或模块返回值判断 |
| 用户取消 | 不要当作成功继续写数据 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,请按「失败路径」排查。
+按分类查看全部 30+ 内置 Python 模块。
+主入口:iOS 原生能力(入口模型、Python 模块表、AppUI 原生组件入口、专题页与文档导航)。本文是补充索引,按导入名浏览全部模块。+
按分类浏览 PythonIDE 内置 Python 模块,快速找到对应能力文档。
+边界:这是 模块索引页,不是 import 名。选定模块后打开具体文档;页面型 MiniApp 优先 appui,小组件用 widget。
+保存设置、读设备、查通知权限:
+import device
+import notification
+import storage
+
+storage.set("theme", "system")
+print("device", device.model(), device.system_version())
+print("theme", storage.get("theme", "system"))
+
+if notification.authorization_status() != "authorized":
+ notification.request_permission()
+用索引思路组合三个常用模块:设置、设备、通知。
+import appui
+import device
+import notification
+import storage
+
+state = appui.State(
+ theme=storage.get("theme", "system"),
+ model="—",
+ notify_auth="—",
+ message="点击按钮开始",
+)
+
+
+def refresh_all():
+ state.model = device.model()
+ state.notify_auth = str(notification.authorization_status())
+ state.message = "已刷新"
+
+
+def save_theme():
+ storage.set("theme", state.theme)
+ state.message = f"已保存主题: {state.theme}"
+
+
+def request_notify():
+ result = notification.request_permission()
+ state.notify_auth = str(notification.authorization_status())
+ state.message = "已授权" if result.get("granted") else "未授权"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("设置", [
+ appui.Picker(
+ "主题",
+ selection=state.theme,
+ options=["system", "light", "dark"],
+ ).picker_style("segmented"),
+ appui.Button("保存主题", action=save_theme)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("设备与通知", [
+ appui.Button("刷新状态", action=refresh_all),
+ appui.Button("申请通知权限", action=request_notify),
+ appui.LabeledContent("型号", value=state.model),
+ appui.LabeledContent("通知授权", value=state.notify_auth),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("模块总览")
+ )
+
+
+appui.run(body, state=state)
+| 目标 | 首选 |
|---|---|
| 工具页、设置页、列表页、媒体控制 | appui |
| 主屏/锁屏/StandBy 小组件 | widget |
| Pythonista 风格命令式 UI | ui |
| 2D 游戏、物理、逐帧绘制 | scene |
| 系统能力(照片、定位等) | 对应原生模块 + AppUI 按钮 |
| 纯脚本、日志输出 | Python + console |
| 模块 | 导入名 | 用途 |
|---|---|---|
| appui | import appui | 声明式原生界面与 MiniApp |
| aurora_system | import aurora_system | 相机、传感器等实时事件 |
| aurora_toolkit | import aurora_toolkit | 高频更新与批量刷新工具 |
| ui | import ui | Pythonista 风格 UI |
| scene | import scene | 2D 场景、scene.run、节点、Action、物理与 Classic 绘图 |
| turtle | import turtle | 原生 turtle 绘图 |
| 模块 | 导入名 | 用途 |
|---|---|---|
| device | import device | 设备、屏幕、电池 |
| location | import location | 定位、指南针、地理编码 |
| motion | import motion | 加速度计、陀螺仪、气压计 |
| haptics | import haptics | 触觉反馈 |
| biometric | import biometric | Face ID / Touch ID |
| health | import health | HealthKit 数据 |
| 模块 | 导入名 | 用途 |
|---|---|---|
| permission | import permission | 统一权限查询与申请 |
| storage | import storage | UserDefaults 键值 |
| database | import database | SQLite 与 JSON collection |
| keychain | import keychain | 钥匙串 |
| clipboard | import clipboard | 剪贴板 |
| console | import console | 弹窗、HUD |
| dialogs | import dialogs | 表单与选择器对话框 |
| notification | import notification | 本地通知 |
| calendar_events | import calendar_events | 日历与提醒 |
| contacts | import contacts | 联系人 |
| live_activity | import live_activity | Live Activity |
| nfc | import nfc | NFC 读写 |
| alarm | import alarm | 系统闹钟 |
import mail | 邮件撰写 | |
| message | import message | 短信 / iMessage |
| translation | import translation | 设备端翻译 |
| foundation_models | import foundation_models | 端侧文本生成 |
| assistant | import assistant | 端侧助手与工具 |
| storekit | import storekit | 内购与订阅 |
| font_picker | import font_picker | 字体选择器 |
| 模块 | 导入名 | 用途 |
|---|---|---|
| photos | import photos | 相册、拍照、保存 |
| camera | 见 photos | 拍照(photos.capture_image) |
| file_picker | 见 appui | 文件选择(FileImporter) |
| share | 见 appui | 分享(ShareLink) |
| avplayer | import avplayer | 音视频播放 |
| music_player | import music_player | 队列式音乐播放器 |
| sound | import sound | 音效与本地播放器 |
| now_playing | import now_playing | 锁屏正在播放元数据 |
| audio_recorder | import audio_recorder | 录音 |
| video_recorder | import video_recorder | 录像 |
| audio_session | import audio_session | 音频会话 |
| speech | import speech | 文字转语音 |
| speech_recognition | import speech_recognition | 语音识别 |
| shazam | import shazam | 听歌识曲 |
| music | import music | Apple Music 控制 |
| media_composer | import media_composer | 音视频合成 |
import pdf | PDF 创建与预览 | |
| qrcode | import qrcode | 二维码生成 |
| vision | import vision | OCR |
| vision_helper | import vision_helper | 人脸、条码、分类 |
| coreml | import coreml | Core ML 推理 |
| 模块 | 导入名 | 用途 |
|---|---|---|
| network | import network | HTTP、下载 |
| websocket | import websocket | WebSocket 客户端 |
| bluetooth | import bluetooth | BLE 中心模式 |
| ble_peripheral | import ble_peripheral | BLE 外设模式 |
| background | import background | 后台任务 |
| background_download | import background_download | 后台下载 |
| http_server | import http_server | 本地 HTTP 服务 |
| ssh | import ssh | SSH / SFTP |
| weather | import weather | 天气 |
| c_extensions | 不是 import 名 | 内置 C 扩展清单 |
| 模块 | 导入名 | 用途 |
|---|---|---|
| widget | import widget | WidgetKit 小组件 |
| shortcuts | import shortcuts | 快捷指令 |
| objc_util | import objc_util | ObjC Runtime |
| keyboard | import keyboard | 编辑器键盘工具栏 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
| 不选运行时直接用原生模块堆 UI | 难维护 | 页面用 appui |
import c_extensions | 模块不存在 | 用真实包名 |
| 索引页当 API 文档 | 签名错误 | 点进具体模块页 |
| 权限未说明用途就申请 | 被拒率高 | 先展示说明再按钮申请 |
敏感数据用 storage | 不安全 | 用 keychain |
| 文档 | 用途 |
|---|---|
| iOS 原生能力 | 入口模型、模块表与权限矩阵 |
| 原生能力入口 | MiniApp 配方 |
| permission | 权限 |
| appui | 声明式 UI |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,请按「失败路径」排查。
+设备运动、陀螺仪、磁力计和气压计。
+设备运动传感器:加速度、重力、陀螺仪、磁力计、姿态与气压高度。
+边界:传感器耗电;+start_*后必须stop_*。读取放在按钮回调或Timer轮询,不要在body()里启动采集。模拟器数据有限,真机测试更可靠;读取可能返回None,需判空。
| 项 | 说明 |
|---|---|
| 导入 | import motion |
| 适合做什么 | 运动仪表盘、倾斜检测、简单姿态反馈、气压变化 |
| 调用时机 | start_updates() 后轮询 get_*();停止时 stop_updates() |
| 推荐顺序 | is_available() → start_* → 采样 → stop_* |
| 刷新频率 | UI 展示 0.2–1 秒一次即可,不必 60Hz 重建界面 |
下面脚本启动综合运动更新并读取数据:
+import time
+import motion
+
+if not motion.is_available():
+ print("当前设备不支持运动数据")
+else:
+ motion.start_updates(interval=1 / 30)
+ try:
+ time.sleep(0.2)
+ print("加速度:", motion.get_acceleration())
+ print("重力:", motion.get_gravity())
+ print("旋转:", motion.get_rotation_rate())
+ print("姿态:", motion.get_attitude())
+ finally:
+ motion.stop_updates()
+单独使用气压计:
+import motion
+
+motion.start_altimeter()
+try:
+ print(motion.get_altitude()) # {pressure, relative_altitude}
+finally:
+ motion.stop_altimeter()
+开始/停止放在按钮回调;用 Timer 轮询采样,停止时关掉定时器。
import appui
+import motion
+
+state = appui.State(
+ accel="—",
+ attitude="—",
+ status="点击开始采集",
+)
+
+
+def sample_motion():
+ acc = motion.get_acceleration()
+ att = motion.get_attitude()
+ if acc:
+ state.accel = f"x={acc['x']:.2f} y={acc['y']:.2f} z={acc['z']:.2f}"
+ if att:
+ state.attitude = f"roll={att['roll']:.2f} pitch={att['pitch']:.2f}"
+
+
+poll_timer = appui.Timer(interval=0.5, repeats=True, action=sample_motion)
+
+
+def start_sampling():
+ if not motion.is_available():
+ state.status = "设备不支持运动数据"
+ return
+ motion.start_updates(interval=1 / 30)
+ poll_timer.start()
+ state.status = "采集中…"
+
+
+def stop_sampling():
+ poll_timer.stop()
+ motion.stop_updates()
+ state.status = "已停止"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("采样", [
+ appui.Button("开始", action=start_sampling)
+ .button_style("bordered_prominent"),
+ appui.Button("停止", action=stop_sampling),
+ ]),
+ appui.Section("数据", [
+ appui.LabeledContent("加速度", value=state.accel),
+ appui.LabeledContent("姿态", value=state.attitude),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="真机测试;停止时务必调用 stop_updates。"),
+ ]).navigation_title("运动传感器")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_available() | 是否支持运动数据 |
start_updates / stop_updates | 综合 device motion |
start_accelerometer / stop_accelerometer | 仅加速度计 |
start_gyroscope / stop_gyroscope | 仅陀螺仪 |
start_magnetometer / stop_magnetometer | 仅磁力计 |
start_altimeter / stop_altimeter | 气压高度计 |
get_acceleration() 等 | 读取最新采样 |
start_updates(interval=0) — 综合采集(加速度 + 陀螺仪 + 姿态等)。interval 为秒,1/30 ≈ 30Hz;0 使用默认约 60Hz。
motion.start_updates(interval=1 / 10)
+# … 读取 …
+motion.stop_updates()
+各传感器可单独启停:start_accelerometer、start_gyroscope、start_magnetometer、start_altimeter。
| 函数 | 返回 |
|---|---|
get_acceleration() | {x, y, z} 或 None |
get_gravity() | {x, y, z} 或 None |
get_rotation_rate() | {x, y, z} 或 None |
get_magnetic_field() | {x, y, z} 或 None |
get_attitude() | {roll, pitch, yaw} 或 None |
get_altitude() | {pressure, relative_altitude} 或 None |
未启动对应 start_* 或硬件无数据时返回 None。
| 错误写法 | 后果 | 修正 |
|---|---|---|
只 start 不 stop | 持续耗电 | try/finally 或按钮停止 |
在 body() 里 start_updates | 刷新时重复启动 | 放进按钮回调 |
不判断 None | 解包崩溃 | 先 if data: |
| 高频重建大界面 | 卡顿 | Timer 0.5s+ 或 Aurora 快路径 |
| 模拟器当真机 | 数据全零或不可用 | 真机验证 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+Apple Music 播放控制与曲库搜索。
+Apple Music 播放控制与曲库搜索:播放/暂停、切歌、当前曲目、目录检索(MusicKit)。
+边界:控制系统 Apple Music 播放与目录搜索,不播放本地文件。需 MusicKit 授权;无订阅时搜索仍可用,播放需要有效 Apple Music 订阅。必须先+play_song()/play_songs()设置队列,再play()继续播放。
| 项 | 说明 |
|---|---|
| 导入 | import music |
| 适合做什么 | 播放控制面板、曲库搜索、显示正在播放 |
| 调用时机 | 播放/搜索放在按钮回调 |
| 推荐顺序 | request_authorization() → search() → play_song(id) |
| 授权 | authorization_status() 查询;request_authorization() 弹窗 |
申请授权、搜索并播放第一首:
+import music
+
+print("授权状态:", music.authorization_status())
+if music.request_authorization():
+ results = music.search("Beatles", limit=5)
+ if results:
+ music.play_song(results[0]["id"])
+ print("正在播放:", music.current_track())
+暂停与继续:
+import music
+
+music.pause()
+music.play() # 仅当队列里已有曲目时可用
+先搜索保存曲目 ID,再播放;play() 只用于暂停后继续。
import appui
+import music
+
+state = appui.State(
+ auth="—",
+ track="—",
+ search_hit="—",
+ queued_song_id="",
+)
+
+
+def refresh_auth():
+ state.auth = music.authorization_status()
+
+
+def request_auth():
+ ok = music.request_authorization()
+ refresh_auth()
+ state.search_hit = "已授权" if ok else "未授权"
+
+
+def search_demo():
+ refresh_auth()
+ if state.auth != "authorized":
+ state.search_hit = "请先授权"
+ return
+
+ songs = music.search("钢琴", limit=3) or []
+ if not songs:
+ state.search_hit = "无结果"
+ state.queued_song_id = ""
+ return
+
+ first = songs[0]
+ state.queued_song_id = str(first.get("id") or "")
+ state.search_hit = f"{first.get('title')} — {first.get('artist')}"
+
+
+def play_selected():
+ refresh_auth()
+ if state.auth != "authorized":
+ state.search_hit = "请先授权"
+ return
+ if not state.queued_song_id:
+ state.search_hit = "请先搜索曲目"
+ return
+
+ try:
+ music.play_song(state.queued_song_id)
+ track = music.current_track()
+ if track:
+ state.track = f"{track.get('title')} — {track.get('artist')}"
+ else:
+ state.track = "已开始播放"
+ state.search_hit = "播放中"
+ except music.MusicError as err:
+ state.search_hit = str(err)
+ if err.code == "subscription_required":
+ state.track = "需要 Apple Music 订阅"
+
+
+def resume_playback():
+ try:
+ music.play()
+ track = music.current_track()
+ if track:
+ state.track = f"{track.get('title')} — {track.get('artist')}"
+ state.search_hit = "继续播放"
+ except music.MusicError as err:
+ state.search_hit = str(err)
+
+
+def pause_track():
+ music.pause()
+ state.track = "已暂停"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("授权", [
+ appui.Button("申请 MusicKit 授权", action=request_auth)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("控制", [
+ appui.Button("搜索「钢琴」", action=search_demo),
+ appui.Button("播放搜索结果", action=play_selected)
+ .button_style("bordered_prominent"),
+ appui.Button("继续播放", action=resume_playback),
+ appui.Button("暂停", action=pause_track),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("授权", value=state.auth),
+ appui.LabeledContent("正在播放", value=state.track),
+ appui.LabeledContent("搜索", value=state.search_hit),
+ ], footer="播放前需先搜索;无订阅时可能返回 subscription_required。"),
+ ]).navigation_title("Apple Music")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
authorization_status() | not_determined / denied / restricted / authorized |
request_authorization() | 申请授权 → bool |
search(term, limit=25) | 曲库搜索 → [{id, title, artist, album}, ...] |
play_song(song_id) | 用目录 ID 建队列并播放(推荐) |
play_songs(song_ids) | 多首入队并播放 |
play_search_result(song) | 播放 search() 返回的单条 dict |
play() | 继续播放当前队列(队列空 → empty_queue) |
pause() | 暂停 |
skip_to_next() / skip_to_previous() | 切歌 |
current_track() | {'id','title','artist','album','duration'} 或 None |
status = music.authorization_status()
+if music.request_authorization():
+ ...
+songs = music.search("Beatles", limit=10)
+music.play_song(songs[0]["id"])
+# 或
+music.play_search_result(songs[0])
+
+music.pause()
+music.play() # resume
+track = music.current_track()
+失败抛 MusicError,常见 code:
| code | 含义 |
|---|---|
denied | 未授权 |
empty_queue | 队列空时调用了 play() |
not_found | 目录 ID 无效 |
subscription_required | 可能需要 Apple Music 订阅 |
playback_failed | 其他播放错误 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
未授权就 search / play_song | MusicError: denied | 先 request_authorization() |
直接 play() 不搜歌 | empty_queue | 先 play_song(id) |
| 期望播放本地 MP3 | 能力不匹配 | 用 avplayer |
| 无订阅强行播放 | subscription_required | 开通 Apple Music 或只展示搜索结果 |
| 文档 | 用途 |
|---|---|
| avplayer | 本地/网络音视频播放 |
| audio_session | 音频会话配置 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+队列式原生音乐播放器、锁屏控制和播放恢复。
+队列式原生音乐播放器:维护当前歌曲、播放队列、播放模式、进度、错误状态、锁屏 Now Playing、控制中心按钮和上次播放恢复。
+边界:+music_player播放你自己的 URL / 本地音频队列;music 是 Apple Music / MusicKit;avplayer 是低层单媒体播放器;now_playing 只发布元数据。播放、切歌、恢复和系统控制属于副作用,放在按钮回调、页面初始化或用户确认流程里,不要在 AppUIbody()中反复调用。
| 项 | 说明 |
|---|---|
| 导入 | import music_player |
| 适合做什么 | 音乐 MiniApp、歌单播放器、搜索结果播放、后台音乐播放 |
| 不适合做什么 | Apple Music 资料库控制、短音效、AppUI 内嵌视频 |
| 状态归属 | 宿主全局播放器服务 |
| 系统集成 | 可选 Now Playing、控制中心 / 锁屏按钮、后台音频 |
| 恢复 | restore() 恢复上次队列、当前歌曲、进度和播放模式 |
import music_player
+
+songs = [
+ {
+ "title": "Demo Track",
+ "artist": "PythonIDE",
+ "url": "https://example.com/demo.mp3",
+ "artwork_url": "https://example.com/cover.jpg",
+ }
+]
+
+music_player.set_queue(songs, start_index=0)
+music_player.play()
+print(music_player.state())
+如果队列里的歌曲已经带有真实音频 URL,可以让宿主提前准备后面的几首,降低下一曲等待:
+music_player.set_queue(songs, start_index=0, autoplay=True, preload_count=3)
+music_player.prefetch_next(count=3)
++preload_count/prefetch_next()只预加载已经有url的音频。搜索平台 ID、聚合源 ID 或需要签名换链的歌曲,应先在你的 MiniApp 后台解析成真实 URL,再交给music_player。
如果不想显示锁屏卡片或接管控制中心按钮,在播放前关闭:
+music_player.configure(
+ now_playing=False,
+ remote_commands=False,
+ background=False,
+)
+UI 完全由你自己写,播放器只提供能力:
+import json
+import appui
+import music_player
+
+songs = [
+ {
+ "title": "Night Demo",
+ "artist": "PythonIDE",
+ "url": "https://example.com/night.mp3",
+ "artwork_url": "https://example.com/night.jpg",
+ },
+ {
+ "title": "Morning Demo",
+ "artist": "PythonIDE",
+ "url": "https://example.com/morning.mp3",
+ },
+]
+
+state = appui.State(
+ title="未播放",
+ artist="",
+ status="ready",
+ elapsed="0:00",
+ mode="sequence",
+)
+
+
+def handle_player_event(payload):
+ data = json.loads(payload or "{}")
+ current = data.get("current") or {}
+ state.title = current.get("title") or current.get("name") or "未播放"
+ state.artist = current.get("artist") or ""
+ state.status = data.get("state", "ready")
+ seconds = int(data.get("elapsed") or 0)
+ state.elapsed = f"{seconds // 60}:{seconds % 60:02d}"
+
+
+music_player.on_event(
+ handle_player_event,
+ events=["ready", "play", "pause", "current", "ended", "progress", "remote_command"],
+)
+
+
+def load_and_play():
+ music_player.configure(now_playing=True, remote_commands=True, background=True)
+ music_player.set_queue(songs, start_index=0)
+ music_player.play()
+
+
+def toggle_mode():
+ next_mode = "repeat_all" if state.mode == "sequence" else "shuffle" if state.mode == "repeat_all" else "sequence"
+ state.mode = next_mode
+ music_player.set_play_mode(next_mode)
+
+
+def body():
+ return appui.NavigationStack(
+ appui.VStack([
+ appui.Text(state.title).font("title2").bold(),
+ appui.Text(state.artist or "—").foreground_color("secondaryLabel"),
+ appui.Text(f"{state.status} · {state.elapsed}").font("caption"),
+ appui.HStack([
+ appui.Button("上一首", action=music_player.previous),
+ appui.Button("播放", action=load_and_play).button_style("bordered_prominent"),
+ appui.Button("暂停", action=music_player.pause),
+ appui.Button("下一首", action=music_player.next),
+ ], spacing=10),
+ appui.Button(f"模式: {state.mode}", action=toggle_mode),
+ ], spacing=14)
+ .padding()
+ .navigation_title("音乐播放器")
+ )
+
+
+appui.run(body, state=state)
+每首歌至少需要 url:
| 字段 | 说明 |
|---|---|
url | 音频 URL、本地路径或 file:// |
title / name | 歌曲标题 |
artist / singer | 艺术家 |
album | 专辑名 |
duration | 可选时长;真实时长准备好后以播放器为准 |
artwork_path | 本地封面路径 |
artwork_url / cover / pic | 远程封面 URL,宿主会缓存后用于 Now Playing |
id | 业务侧歌曲 ID,可用于你的列表选中状态 |
configure(now_playing=None, remote_commands=None, background=None, persist=None, auto_advance=None, progress_interval=None, can_next=None, can_previous=None)
| 参数 | 说明 |
|---|---|
now_playing | 是否同步锁屏 / 控制中心元数据 |
remote_commands | 是否响应系统播放、暂停、上一首、下一首、seek |
background | 是否配置后台播放音频会话 |
persist | 是否保存队列、进度和播放模式 |
auto_advance | 播放结束后是否按模式自动切歌 |
progress_interval | 进度事件间隔,最小 0.25 秒 |
can_next / can_previous | 覆盖系统按钮可用状态 |
| API | 说明 |
|---|---|
set_queue(songs, start_index=0, play_mode=None, autoplay=False, preload_count=0) | 替换队列并加载当前歌曲;preload_count 会预备后面 N 首已解析 URL |
queue() | 返回 {queue, current_index, count} |
current() | 返回当前歌曲 |
add(song, play_next=False, autoplay=False) | 追加或插到下一首 |
remove(index) | 删除歌曲 |
move(from_index, to_index) | 移动歌曲 |
clear() | 停止并清空队列 |
prepare(song, index=None) | 预备一首已有真实 url 的歌曲,降低之后切到它时的加载时间 |
prefetch_next(count=3) | 按当前队列和播放模式预备后面几首 |
preload_state() | 返回预加载池状态:count、preload_count、items |
| API | 说明 |
|---|---|
play() | 播放当前歌曲 |
pause() | 暂停 |
toggle() | 播放 / 暂停切换 |
stop() | 停止并回到 0 秒 |
next() | 下一首 |
previous() | 上一首;当前进度超过 3 秒时先回到开头 |
seek(seconds) | 跳转 |
set_play_mode(mode) | sequence / repeat_all / repeat_one / shuffle |
set_volume(volume) | 0.0–1.0 |
set_rate(rate) | 0.25–3.0 |
| API | 说明 |
|---|---|
state() | 返回完整状态:state、playing、current、queue、elapsed、duration、error 等 |
restore(autoplay=False) | 恢复上次队列和进度,默认不自动播放 |
on_event(callback, events=None) 订阅事件。callback 可以是 Python 函数或 callback id;函数收到 JSON 字符串。
常用事件:
+| 事件 | 说明 |
|---|---|
ready | 当前歌曲可播放 |
play / pause / stop | 播放状态变化 |
current / queue | 当前歌曲或队列变化 |
ended | 当前歌曲结束 |
progress | 进度更新 |
preload | 发起预加载 |
prepared | 某首预加载歌曲已准备好 |
item_failed | 某首预加载歌曲准备失败 |
seek | 跳转 |
error | 播放错误 |
restore | 恢复完成 |
remote_command | 锁屏 / 控制中心按钮触发 |
import json
+import music_player
+
+def on_player_event(payload):
+ event = json.loads(payload)
+ print(event["event"], event.get("state"), event.get("elapsed"))
+
+music_player.on_event(on_player_event, events=["play", "pause", "remote_command"])
+| 需求 | 用哪个 |
|---|---|
| 自己的音乐队列、锁屏按钮、后台播放 | music_player |
| 播放单个 URL 音频 / 视频 | avplayer |
| AppUI 内嵌视频控件 | appui.PlayerController + appui.VideoPlayer |
| 只发布锁屏标题 / 封面 / 进度 | now_playing |
| Apple Music 资料库 / MusicKit | music |
| 短音效 | sound |
| 大文件后台下载 | background_download |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 set_queue() 或 play() | 刷新时反复重载和播放 | 放到按钮回调或一次性初始化 |
用 music 播放自己的 MP3 URL | 模块不匹配 | 用 music_player 或 avplayer |
| 每个页面自己维护队列 | 页面关闭后状态容易散 | 用 music_player.state() / restore() |
不处理 error 事件 | 网络或格式失败时 UI 卡住 | 订阅 error 并显示失败状态 |
| 不想锁屏显示但没配置 | 控制中心出现播放器卡片 | 播放前 configure(now_playing=False, remote_commands=False) |
运行后,歌曲队列由宿主全局播放器维护;关闭页面再恢复时可通过 restore() 找回上次队列和进度。启用系统集成时,锁屏和控制中心会显示歌曲信息,并把系统按钮回调给 miniapp。
原生 HTTP 请求、连接状态和下载。
+原生 HTTP 客户端:发起 GET/POST 等请求、判断网络状态、下载文件到本地路径。
+边界:适合 REST API 和小文件下载。大文件、后台传输或复杂上传请看系统能力规划;响应体在内存中,超大响应注意内存占用。+
| 项 | 说明 |
|---|---|
| 导入 | import network |
| 适合做什么 | 拉取 JSON API、提交表单、检查联网、下载文件 |
| 调用时机 | 放在按钮回调或加载任务;不要写在 AppUI body() 中 |
| 推荐顺序 | is_connected() 提示 → get/post → 检查 response.ok → 解析 json() |
| 蜂窝/低数据 | is_expensive() / is_constrained() 为真时避免自动下大文件 |
下面脚本检查网络,请求 GitHub API 并打印仓库名:
+import network
+
+if not network.is_connected():
+ print("当前无网络")
+else:
+ response = network.get(
+ "https://api.github.com/repos/python/cpython",
+ timeout=15,
+ )
+ if response.ok:
+ data = response.json()
+ print(data["full_name"], "⭐", data["stargazers_count"])
+ else:
+ print("请求失败:", response.status, response.text[:200])
+请求放在按钮回调里,加载、成功、失败都写回界面。
+import appui
+import network
+
+state = appui.State(
+ status="未请求",
+ network="—",
+ detail="点击按钮开始",
+)
+
+
+def refresh_network_info():
+ if not network.is_connected():
+ state.network = "离线"
+ return
+ conn = network.connection_type()
+ flags = []
+ if network.is_expensive():
+ flags.append("计费网络")
+ if network.is_constrained():
+ flags.append("低数据模式")
+ suffix = f"({' · '.join(flags)})" if flags else ""
+ state.network = f"{conn}{suffix}"
+
+
+def fetch_repo():
+ refresh_network_info()
+ if not network.is_connected():
+ state.batch_update(status="离线", detail="当前没有可用网络")
+ return
+
+ state.status = "请求中..."
+ response = network.get(
+ "https://api.github.com/repos/python/cpython",
+ timeout=15,
+ )
+ if not response.ok:
+ state.batch_update(status="请求失败", detail=str(response.status))
+ return
+
+ try:
+ data = response.json()
+ except Exception as error:
+ state.batch_update(status="解析失败", detail=str(error))
+ return
+
+ stars = data.get("stargazers_count", "—")
+ state.batch_update(
+ status="请求成功",
+ detail=f"{data.get('full_name', '')} · ⭐ {stars}",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("网络", [
+ appui.LabeledContent("连接", value=state.network),
+ appui.LabeledContent("状态", value=state.status),
+ appui.Text(state.detail).foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("操作", [
+ appui.Button("获取 CPython 仓库信息", action=fetch_repo)
+ .button_style("bordered_prominent"),
+ ], footer="需要网络;真机测试最可靠。"),
+ ]).navigation_title("网络请求")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_connected() | 是否有可用网络 → bool |
connection_type() | wifi / cellular / ethernet / none / other |
is_expensive() | 是否计费网络(蜂窝/热点) |
is_constrained() | 是否低数据模式 |
get/post/put/delete/patch(...) | HTTP 快捷方法 → Response |
request(method, url, ...) | 通用 HTTP 请求 |
download(url, dest_path) | 下载文件 → bool |
import network
+
+if network.is_connected():
+ print(network.connection_type())
+ if network.is_expensive():
+ print("计费网络,避免大流量")
+| API | 说明 |
|---|---|
is_connected() | 当前能否联网 |
connection_type() | 连接类型字符串 |
is_expensive() | 蜂窝或热点等 |
is_constrained() | 系统低数据模式 |
request(method, url, headers=None, body=None, json=None, timeout=30) — 通用请求。
快捷方法:get、post、put、delete、patch,参数相同。
response = network.get("https://httpbin.org/get", timeout=15)
+response = network.post(
+ "https://httpbin.org/post",
+ json={"title": "Hello"},
+ headers={"Accept": "application/json"},
+ timeout=20,
+)
+Response 对象:
| 属性/方法 | 说明 |
|---|---|
status | HTTP 状态码 |
ok | 200–299 时为 True |
text | 响应正文字符串 |
json() | 解析 JSON;非 JSON 会抛异常 |
headers | 响应头字典 |
if response.ok:
+ data = response.json()
+else:
+ print(response.status, response.text[:200])
+download(url, dest_path, timeout=120) — 下载到本地路径,成功返回 True。
ok = network.download(
+ "https://httpbin.org/json",
+ "report.json",
+ timeout=30,
+)
+下载失败返回 False;路径需脚本可写。配合 photos 的 save_video 时,先 download 再传本地路径。
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里发请求 | 每次刷新都联网 | 放进按钮回调 |
不检查 response.ok | 把错误当成功解析 | 先判断 ok 再 json() |
对非 JSON 直接 json() | 解析异常 | try/except 或改读 text |
| 蜂窝网络自动下大文件 | 流量浪费 | 检查 is_expensive() 并提示用户 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+NFC 标签读取和 NDEF 写入。
+NFC 标签读写:扫描 NDEF 记录、写入文本或 URI。需要支持 NFC 的 iPhone 硬件。
+边界:仅读写 NDEF 格式标签;不含银行卡支付、门禁卡模拟。扫描/写入会弹出系统 NFC 会话,必须放在按钮回调里,不要在 body() 中自动触发。
+| 项 | 说明 |
|---|---|
| 导入 | import nfc |
| 适合做什么 | 读取标签内容、写入链接/文本、物联网配对 |
| 调用时机 | scan() / write() 放在按钮回调 |
| 推荐顺序 | is_available() → scan() 或 write(records) |
| 记录格式 | type 为 text 或 uri,配合 payload 字符串 |
下面脚本检查 NFC 是否可用,并尝试扫描标签(需将手机靠近标签):
+import nfc
+
+if not nfc.is_available():
+ print("此设备不支持 NFC 读取")
+else:
+ tag = nfc.scan(message="请将 iPhone 靠近 NFC 标签", timeout=30)
+ if tag:
+ for rec in tag.get("records", []):
+ print(rec.get("type"), rec.get("payload"))
+ else:
+ print("扫描失败或已取消")
+写入一条文本记录:
+import nfc
+
+records = [{"type": "text", "payload": "Hello from PythonIDE"}]
+ok = nfc.write(records, message="靠近标签以写入")
+print("写入成功" if ok else "写入失败")
+扫描与写入分步放在按钮回调;界面只展示最近一次结果。
+import appui
+import nfc
+
+state = appui.State(
+ available="—",
+ last_result="尚未操作",
+ detail="点击按钮开始",
+)
+
+
+def refresh_available():
+ state.available = "是" if nfc.is_available() else "否"
+
+
+def scan_tag():
+ refresh_available()
+ if state.available != "是":
+ state.detail = "设备不支持 NFC"
+ return
+
+ tag = nfc.scan(message="请将 iPhone 靠近标签", timeout=30)
+ if not tag:
+ state.batch_update(last_result="扫描失败", detail="超时或用户取消")
+ return
+
+ records = tag.get("records", [])
+ lines = [f"{r.get('type')}: {r.get('payload')}" for r in records]
+ state.batch_update(
+ last_result=f"读到 {len(records)} 条记录",
+ detail="\n".join(lines) if lines else "空标签",
+ )
+
+
+def write_demo():
+ refresh_available()
+ if state.available != "是":
+ state.detail = "设备不支持 NFC"
+ return
+
+ records = [{"type": "uri", "payload": "https://pythonide.xin"}]
+ ok = nfc.write(records, message="靠近标签以写入链接")
+ state.batch_update(
+ last_result="写入成功" if ok else "写入失败",
+ detail="https://pythonide.xin",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("NFC", [
+ appui.Button("扫描标签", action=scan_tag)
+ .button_style("bordered_prominent"),
+ appui.Button("写入示例链接", action=write_demo),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("可用", value=state.available),
+ appui.LabeledContent("结果", value=state.last_result),
+ appui.Text(state.detail).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("NFC")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_available() | 设备是否支持 NFC 读取 → bool |
scan(message, timeout=30) | 弹出扫描会话 → {"records": [...]} 或 None |
write(records, message) | 写入 NDEF 记录 → bool |
is_available() — 检查硬件与系统是否支持 NFC 读取。
if nfc.is_available():
+ ...
+scan(message="Hold your iPhone near an NFC tag", timeout=30.0) — 启动系统 NFC 扫描会话,阻塞直到完成、超时或取消。
返回 {"records": [{"type": "text"|"uri", "payload": "..."}, ...]},失败返回 None。
tag = nfc.scan(message="请将 iPhone 靠近标签", timeout=30)
+write(records, message="Hold your iPhone near an NFC tag to write") — 将 NDEF 记录写入标签。
| 字段 | 说明 |
|---|---|
type | "text" 或 "uri" |
payload | 文本或 URL 字符串 |
nfc.write([
+ {"type": "text", "payload": "会议室 A"},
+ {"type": "uri", "payload": "https://example.com"},
+])
+| 错误写法 | 后果 | 修正 |
|---|---|---|
未检查 is_available() | 在不支持设备上崩溃 | 先判断可用性 |
在 body() 里调用 scan() | 每次刷新都弹 NFC 会话 | 放进按钮回调 |
records 缺少 type/payload | 写入失败 | 按 NDEF 格式构造列表 |
| 用 NFC 做支付/门禁模拟 | 超出模块能力 | 使用对应系统 App 或专用 SDK |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+本地通知权限、定时通知和角标管理。
+本地通知:申请权限、定时提醒、管理待发送通知、设置角标。不包含远程推送。
+边界:只做本机本地通知。远程推送不在本模块;锁屏实时状态请看 live_activity。+
| 项 | 说明 |
|---|---|
| 导入 | import notification |
| 适合做什么 | 稍后提醒、番茄钟、每日复盘、角标计数 |
| 调用时机 | 放在按钮回调里;不要写在 AppUI body() 中 |
| 推荐顺序 | 查/申请权限 → schedule 或 schedule_at_date → 需要时用 remove_* / set_badge |
| 标识符 | identifier 保持稳定,方便取消或覆盖同一条通知 |
下面脚本申请权限,并在 60 秒后发送一条通知:
+import notification
+
+result = notification.request_permission()
+if not result.get("granted"):
+ print("未授权:", result)
+else:
+ print(notification.schedule(
+ "demo.reminder",
+ "休息一下",
+ "站起来活动 1 分钟",
+ delay=60,
+ ))
+把申请权限、调度、取消都放进按钮回调;界面只展示当前状态。
+import appui
+import notification
+
+REMINDER_ID = "demo.reminder"
+
+state = appui.State(
+ auth="未查询",
+ pending="—",
+ message="点击按钮开始",
+)
+
+
+def refresh_status():
+ state.auth = str(notification.authorization_status())
+ state.pending = str(notification.pending_count())
+
+
+def schedule_one_minute():
+ perm = notification.request_permission()
+ if not perm.get("granted"):
+ state.message = "未获得通知权限"
+ return
+
+ result = notification.schedule(
+ REMINDER_ID,
+ "稍后提醒",
+ "1 分钟后见",
+ delay=60,
+ )
+ refresh_status()
+ state.message = f"已调度: {result}"
+
+
+def cancel_reminder():
+ notification.remove_pending(REMINDER_ID)
+ refresh_status()
+ state.message = "已取消待发送提醒"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("提醒", [
+ appui.Button("1 分钟后提醒", action=schedule_one_minute)
+ .button_style("bordered_prominent"),
+ appui.Button("取消这条提醒", action=cancel_reminder, role="destructive"),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("授权", value=state.auth),
+ appui.LabeledContent("待发送", value=state.pending),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("本地通知")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
request_permission() | 申请权限 → {"granted": bool} |
authorization_status() | 查询授权 → 如 authorized / denied |
schedule(...) | 延迟 delay 秒后发送 |
schedule_at_date(...) | 指定年月日时分发送 |
remove_pending(id) | 取消一条待发送 |
remove_all_pending() | 取消全部待发送 |
pending_count() | 待发送数量 |
pending_identifiers() | 待发送 ID 列表 |
set_badge(n) | 设置角标,0 清除 |
request_permission() — 弹出系统授权,等待用户选择。
result = notification.request_permission()
+if result.get("granted"):
+ ...
+authorization_status() — 只查询,不弹窗。
status = notification.authorization_status()
+# authorized | denied | not_determined | provisional
+也可用 permission 查询:permission.status("notifications")。
schedule(identifier, title, body, delay=5.0, sound=True, badge=0) — 相对延迟发送。
notification.schedule(
+ "stretch.break",
+ "活动一下",
+ "站起来伸展 1 分钟",
+ delay=60,
+ sound=True,
+ badge=1,
+)
+| 参数 | 说明 |
|---|---|
identifier | 稳定 ID |
title / body | 标题与正文 |
delay | 延迟秒数 |
badge | 0 表示不修改角标 |
schedule_at_date(identifier, title, body, year, month, day, *, hour=0, minute=0, repeats=False) — 绝对时间发送。
notification.schedule_at_date(
+ "daily.review",
+ "每日复盘",
+ "整理今天的笔记",
+ 2026, 6, 10,
+ hour=21,
+ minute=30,
+)
+注意:参数是 year, month, day, hour, minute,不是 Unix 时间戳。
+| API | 说明 |
|---|---|
remove_pending(identifier) | 取消指定 ID |
remove_all_pending() | 取消全部 |
pending_count() | 返回 int |
pending_identifiers() | 返回 list |
set_badge(number) | 设置角标;0 清除 |
notification.remove_pending("stretch.break")
+print(notification.pending_count())
+print(notification.pending_identifiers())
+notification.set_badge(3)
+| 错误写法 | 后果 | 修正 |
|---|---|---|
未授权就 schedule | 通知可能不出现 | 先 request_permission(),检查 granted |
schedule_at_date 传时间戳 | 参数不匹配 | 传年月日时分 |
每次随机 identifier | 无法取消旧通知 | 使用固定 ID |
在 body() 里调度 | 刷新时反复触发 | 放进按钮回调 |
| 文档 | 用途 |
|---|---|
| permission | 统一查询 notifications 权限 |
| live_activity | 锁屏与灵动岛实时活动 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+锁屏与控制中心的正在播放信息。
+锁屏与控制中心「正在播放」元数据:标题、艺术家、封面、进度。配合 sound 或 avplayer 播放自有音频时使用。
+边界:只发布元数据到系统 Now Playing 卡片,不替代音频播放本身。未实际播放时卡片可能不出现;远程流媒体优先用 avplayer 自带控制。
+| 项 | 说明 |
|---|---|
| 导入 | import now_playing |
| 适合做什么 | 播客/音乐 App 锁屏信息、进度条同步 |
| 调用时机 | 开始播放后 set_info;进度变化时 update_elapsed |
| 推荐顺序 | 开始播放 → set_info → 定时/回调 update_elapsed → 结束 clear |
| 封面 | artwork_path 为本地图片路径,无效则只显示文字 |
发布元数据并更新进度:
+import now_playing
+
+try:
+ now_playing.set_info(
+ title="第一集",
+ artist="播客名称",
+ album="第一季",
+ duration=3600,
+ elapsed=120,
+ artwork_path="/path/cover.jpg",
+ )
+ now_playing.update_elapsed(180)
+finally:
+ now_playing.clear()
+元数据发布与进度更新放在按钮回调;配合本地播放时卡片更稳定。
+import appui
+import now_playing
+
+state = appui.State(
+ title="PythonIDE 演示",
+ artist="播客",
+ elapsed="120",
+ status="未发布",
+)
+
+session = {"elapsed": 120.0, "duration": 3600.0}
+
+
+def publish_card():
+ session["elapsed"] = float(state.elapsed) if state.elapsed else 0.0
+ now_playing.set_info(
+ title=state.title,
+ artist=state.artist,
+ duration=session["duration"],
+ elapsed=session["elapsed"],
+ )
+ state.status = "已发布到锁屏卡片"
+
+
+def advance_progress():
+ session["elapsed"] = min(session["elapsed"] + 30, session["duration"])
+ state.elapsed = str(int(session["elapsed"]))
+ now_playing.update_elapsed(session["elapsed"])
+ state.status = f"进度 {int(session['elapsed'])}s"
+
+
+def clear_card():
+ now_playing.clear()
+ state.status = "已清除"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("元数据", [
+ appui.TextField("标题", text=state.title),
+ appui.TextField("艺术家", text=state.artist),
+ appui.TextField("进度(秒)", text=state.elapsed),
+ ]),
+ appui.Section("控制", [
+ appui.Button("发布到锁屏", action=publish_card)
+ .button_style("bordered_prominent"),
+ appui.Button("进度 +30 秒", action=advance_progress),
+ appui.Button("清除", action=clear_card, role="destructive"),
+ ]),
+ appui.Section("状态", [
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="需配合 sound/avplayer 实际播放,锁屏卡片才稳定出现。"),
+ ]).navigation_title("正在播放")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
set_info(title, artist='', album='', duration=0, elapsed=0, artwork_path=None, playback_rate=1.0) | 发布完整元数据 |
update_elapsed(seconds, playback_rate=1.0) | 仅更新已播放秒数 |
clear() | 清除锁屏卡片信息 |
NowPlayingError | 原生能力入口失败异常 |
now_playing.set_info(
+ title="曲目名",
+ artist="歌手",
+ album="专辑",
+ duration=240.0,
+ elapsed=12.0,
+ artwork_path="/path/cover.jpg",
+ playback_rate=1.0,
+)
+| 参数 | 说明 |
|---|---|
duration / elapsed | 总时长与当前进度(秒) |
artwork_path | 本地封面图路径,可选 |
playback_rate | 播放速率,暂停时可设 0 |
now_playing.update_elapsed(95.5)
+now_playing.clear()
+| 错误写法 | 后果 | 修正 |
|---|---|---|
只 set_info 不播放音频 | 锁屏卡片可能不出现 | 配合 sound.Player 或 avplayer |
| 封面路径无效 | 无封面图 | 检查沙盒内文件路径 |
忘记 clear() | 停止后仍显示旧信息 | 播放结束或页面关闭时清除 |
在 body() 里自动发布 | 每次刷新重复设置 | 放进按钮或播放回调 |
| 文档 | 用途 |
|---|---|
| sound | 本地音频播放 |
| avplayer | 音视频流播放 |
| audio_session | 音频会话类别 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+Objective-C Runtime 调用与系统框架访问。
+Objective-C Runtime 访问层:调用系统类、selector、Foundation/UIKit 对象与 block。
+边界:高级能力入口;拍照、网络、定位等优先用已封装模块。UIKit 调用须+@on_main_thread;block 回调用retain_global保活。
| 项 | 说明 |
|---|---|
| 导入 | from objc_util import ObjCClass, ns, on_main_thread, ... |
| 适合做什么 | 访问未封装系统 API、转换 ObjC 对象、创建 block |
| 调用时机 | UI 相关放主线程;长期回调对象要保活 |
| 推荐顺序 | 确认无公开模块 → ObjCClass → 调用 selector |
| Selector | Python 方法名中 _ 对应 ObjC 的 : |
读取设备信息:
+from objc_util import ObjCClass
+
+UIDevice = ObjCClass("UIDevice")
+device = UIDevice.currentDevice()
+print(device.name())
+print(device.systemVersion())
+Python 对象转 Objective-C:
+from objc_util import ObjCClass, ns, nsurl
+
+payload = ns({"name": "Ada", "level": 3})
+url = nsurl("https://www.python.org")
+print(url.absoluteString())
+通过 Runtime 读取设备名(只读、放按钮回调)。
+import appui
+from objc_util import ObjCClass, on_main_thread
+
+state = appui.State(
+ device_name="—",
+ os_version="—",
+ status="点击读取",
+)
+
+
+@on_main_thread
+def read_device():
+ UIDevice = ObjCClass("UIDevice")
+ device = UIDevice.currentDevice()
+ state.device_name = str(device.name())
+ state.os_version = str(device.systemVersion())
+ state.status = "已读取"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("设备", [
+ appui.Button("读取设备信息", action=read_device)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("名称", value=state.device_name),
+ appui.LabeledContent("系统", value=state.os_version),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("ObjC Runtime")
+ )
+
+
+appui.run(body, state=state)
+| 分组 | API |
|---|---|
| Runtime | ObjCClass、ObjCInstance、sel、load_framework |
| 转换 | ns(py_obj)、nsurl(url_or_path)、nsdata_to_bytes、uiimage_to_png |
| 线程 | on_main_thread(func) |
| 生命周期 | retain_global(obj)、release_global(obj) |
| Block | ObjCBlock(func, restype=None, argtypes=None) |
| 结构体 | CGPoint、CGRect、CGSize、UIEdgeInsets、NSRange 等 |
NSDictionary = ObjCClass("NSDictionary")
+obj = NSDictionary.dictionaryWithObject_forKey_("value", "key")
+# 对应 dictionaryWithObject:forKey:
+from objc_util import ObjCClass, on_main_thread
+
+UIColor = ObjCClass("UIColor")
+
+@on_main_thread
+def blue():
+ return UIColor.systemBlueColor()
+| 函数 | 用途 |
|---|---|
ns({"a": 1}) | dict/list/str → Foundation 对象 |
nsurl("https://...") | 创建 NSURL |
nsdata_to_bytes(data) | NSData → bytes |
| 错误写法 | 后果 | 修正 |
|---|---|---|
| 未封装能力直接用 Runtime | 脆弱、难维护 | 优先 photos、network 等 |
| UIKit 非主线程调用 | 崩溃或无效 | @on_main_thread |
block 未 retain_global | 回调失效 | 保活到流程结束 |
| 猜测 selector 签名 | 运行时错误 | 对照 Apple 文档逐项验证 |
| 调用私有 API | 审核风险 | 仅用公开 API |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+创建、读取、渲染和预览 PDF 文档。
+PDF 创建、读取、渲染与预览:文本/图片/HTML 转 PDF、提取文字、单页截图、QuickLook 预览。
+边界:操作本地文件路径;preview() 弹出系统 QuickLook 查看器。不含 PDF 表单填写、数字签名等高级编辑。
+| 项 | 说明 |
|---|---|
| 导入 | import pdf |
| 适合做什么 | 导出报告、相册转 PDF、提取文字、预览文档 |
| 调用时机 | 创建/读取放按钮回调;preview() 会弹全屏查看器 |
| 推荐顺序 | 创建 → info() 确认页数 → preview() 或 extract_text() |
| 路径 | 使用 App 可写目录(如 ~/Documents) |
从纯文本创建 PDF 并读取元数据:
+import os
+import pdf
+
+out = os.path.join(os.path.expanduser("~/Documents"), "note.pdf")
+pdf.create_from_text(out, "第一行\n第二行", title="笔记")
+print(pdf.info(out))
+print(pdf.extract_text(out))
+从 HTML 渲染:
+import os
+import pdf
+
+path = os.path.join(os.path.expanduser("~/Documents"), "report.pdf")
+pdf.from_html("<h1>周报</h1><p>本周进展良好。</p>", path)
+pdf.preview(path)
+创建与预览放在按钮回调;界面展示页数与提取摘要。
+import appui
+import os
+import pdf
+
+DOC = os.path.join(os.path.expanduser("~/Documents"), "pdf-demo.pdf")
+
+state = appui.State(
+ pages="—",
+ preview="未创建",
+ snippet="点击按钮生成示例 PDF",
+)
+
+
+def create_demo():
+ pdf.create_from_text(
+ DOC,
+ "PythonIDE PDF 示例\n\n这是第二段文字。",
+ title="PDF 演示",
+ )
+ meta = pdf.info(DOC) or {}
+ text = pdf.extract_text(DOC) or ""
+ state.batch_update(
+ pages=str(meta.get("page_count", "—")),
+ preview="已生成",
+ snippet=text[:120] + ("…" if len(text) > 120 else ""),
+ )
+
+
+def open_preview():
+ if not os.path.exists(DOC):
+ state.snippet = "请先生成 PDF"
+ return
+ pdf.preview(DOC)
+ state.preview = "已打开 QuickLook"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("操作", [
+ appui.Button("生成示例 PDF", action=create_demo)
+ .button_style("bordered_prominent"),
+ appui.Button("QuickLook 预览", action=open_preview),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("页数", value=state.pages),
+ appui.LabeledContent("文件", value=state.preview),
+ appui.Text(state.snippet).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("PDF")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
create_from_text(path, text, title=None) | 纯文本 → PDF |
create_from_images(path, image_paths) | 多图 → 多页 PDF |
from_html(html, path) | HTML 字符串 → PDF |
extract_text(path) | 提取全文 |
info(path) | 元数据 page_count / title / author / encrypted |
page_image(path, index=0, scale=2.0) | 单页渲染为 PNG bytes |
preview(path) | 弹出 QuickLook 预览 |
pdf.create_from_text("/path/note.pdf", "Hello\nWorld", title="Note")
+pdf.create_from_images("/path/album.pdf", ["/path/a.jpg", "/path/b.jpg"])
+pdf.from_html("<h1>Report</h1>", "/path/report.pdf")
+info(path) — 返回元数据字典。
extract_text(path) — 返回完整文本字符串。
page_image(path, index=0, scale=2.0) — 将指定页渲染为 PNG 原始字节;失败抛 PDFError。
preview(path) — 呈现原生 QuickLook 查看器,返回 True 表示成功弹出。
PDFError(message, code=None) — 原生能力入口不可用或操作失败时抛出。
| 错误写法 | 后果 | 修正 |
|---|---|---|
| 路径不可写 | 创建失败 | 使用 ~/Documents 等沙盒目录 |
在 body() 里调用 preview() | 每次刷新弹查看器 | 放进按钮回调 |
把 page_image 结果当路径用 | 类型错误 | 返回值是 bytes,需自行保存 |
| 期望编辑已有 PDF | 超出能力 | 仅支持创建、读取、渲染 |
| 文档 | 用途 |
|---|---|
| photos | 选图后 create_from_images |
| share | 生成后通过分享面板发送 |
| file_picker | 选择已有 PDF 路径 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+统一查询权限状态,并请求位置或通知权限。
+统一权限入口:查询授权状态,并在支持的模块上触发系统授权弹窗。
+边界:+permission只管「查状态 / 弹授权」,不替代业务模块。拍照、选图、读通讯录等具体操作仍调用photos、contacts等模块。当前运行时request()仅支持location和notifications;status()可查location、notifications、health、motion、speech。MODULES列出规划中的统一键名,其余键请先用各业务模块自带 API。
| 项 | 说明 |
|---|---|
| 导入 | import permission |
| 适合做什么 | 设置页展示权限、功能前置判断、批量刷新状态 |
| 调用时机 | request() 放在按钮回调;不要写在 AppUI body() 中 |
| 推荐顺序 | status(module) 判断 → 需要时 request(module) → 再 status 确认 → 调业务模块 |
| 判断授权 | 看返回字典的 authorized 字段,不要只比对 status 字符串 |
下面脚本查询定位与通知权限,并在未授权时发起申请:
+import permission
+
+for key in ("location", "notifications"):
+ entry = permission.status(key)
+ print(key, "→", entry)
+
+ if not entry.get("authorized"):
+ result = permission.request(key)
+ print("申请结果:", result)
+ entry = permission.status(key)
+ print("最新状态:", entry)
+request("location") 只触发系统弹窗,不等待用户点选;request("notifications") 会等待用户响应并返回 granted。
权限操作全部放进按钮回调;被拒绝后展示状态,不循环弹窗。
+import appui
+import permission
+
+state = appui.State(
+ location_status="未查询",
+ notification_status="未查询",
+ message="点击按钮开始",
+)
+
+
+def format_entry(entry):
+ if not entry:
+ return "未查询"
+ if entry.get("error"):
+ return str(entry.get("error"))
+ mark = "已授权" if entry.get("authorized") else "未授权"
+ return f"{mark}({entry.get('status', 'unknown')})"
+
+
+def find_entry(statuses, module_name):
+ for item in statuses.get("modules", []):
+ if item.get("module") == module_name:
+ return item
+ return {}
+
+
+def refresh_all():
+ result = permission.status_all()
+ if not result.get("success", True):
+ state.message = str(result)
+ return
+ state.location_status = format_entry(find_entry(result, "location"))
+ state.notification_status = format_entry(find_entry(result, "notifications"))
+ state.message = "已刷新全部权限"
+
+
+def request_location():
+ result = permission.request("location")
+ if result.get("error"):
+ state.message = result["error"]
+ return
+ state.location_status = format_entry(permission.status("location"))
+ state.message = "已发起定位授权,请在系统弹窗中选择"
+
+
+def request_notifications():
+ result = permission.request("notifications")
+ if result.get("error"):
+ state.message = result["error"]
+ return
+ if "granted" in result:
+ mark = "已授权" if result.get("granted") else "未授权"
+ state.notification_status = f"{mark}({result.get('status', '—')})"
+ else:
+ state.notification_status = format_entry(permission.status("notifications"))
+ state.message = "通知权限已更新"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("定位", [
+ appui.Button("申请定位权限", action=request_location)
+ .button_style("bordered_prominent"),
+ appui.LabeledContent("状态", value=state.location_status),
+ ]),
+ appui.Section("通知", [
+ appui.Button("申请通知权限", action=request_notifications)
+ .button_style("bordered_prominent"),
+ appui.LabeledContent("状态", value=state.notification_status),
+ ]),
+ appui.Section("批量", [
+ appui.Button("刷新全部状态", action=refresh_all),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("权限中心")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
status(module) | 查询单个模块 → dict |
request(module) | 申请权限 → dict(仅 location / notifications) |
status_all() | 批量查询当前 Bridge 支持的模块 |
MODULES | 规划中的统一权限键名列表 |
status(module) — 查询单个模块,不弹窗。
entry = permission.status("location")
+if entry.get("authorized"):
+ ...
+常见返回字段:
+| 字段 | 说明 |
|---|---|
success | 是否查询成功 |
module | 模块键名 |
status | 状态字符串(各模块含义不同) |
authorized | 是否视为已授权 → 优先看这个 |
error | 失败时的错误信息 |
定位常见 status:authorized_when_in_use、authorized_always、denied、not_determined。通知常见:authorized、denied、provisional。
status_all() — 返回 Bridge 当前支持的模块列表:
result = permission.status_all()
+for item in result.get("modules", []):
+ print(item["module"], item["status"], item["authorized"])
+返回结构:{"success": True, "modules": [{"module": "...", "status": "...", "authorized": bool}, ...]}。按 module 字段查找,不是以模块名为 key 的扁平字典。
request(module) — 触发系统授权。目前仅支持:
| 模块键 | 行为 |
|---|---|
location | 弹出「使用期间」定位授权;立即返回 {"action": "requested"},需再查 status |
notifications | 弹出通知授权;等待用户选择,返回含 granted 的结果 |
# 定位:申请后需再查询
+permission.request("location")
+entry = permission.status("location")
+
+# 通知:可直接看 granted
+result = permission.request("notifications")
+if result.get("granted"):
+ ...
+对其他键(如 camera、photos、contacts)调用 request() 会返回 permission_request_not_supported。请改用业务模块,例如 notification.request_permission()、location.request_access()、photos.pick_image() 首次调用时系统会自动弹窗。
MODULES 与运行时支持MODULES 是统一键名规划,完整列表:
import permission
+print(permission.MODULES)
+当前 Bridge 已接通的范围:
+| 键名 | status | request | 备注 |
|---|---|---|---|
location | ✅ | ✅ | |
notifications | ✅ | ✅ | 不是 notification |
health | ✅ | ❌ | 用 health 按数据类型申请 |
motion / speech | ✅ | ❌ | 返回 available 占位状态 |
camera / photos / contacts 等 | ❌ | ❌ | 用对应业务模块 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
permission.request("notification") | 键名错误 | 使用 notifications |
把 request() 返回值当 bool | 判断逻辑错误 | 看 authorized 或 granted 字段 |
对 camera 调 permission.request | 返回不支持 | 直接调 photos / camera 模块 |
在 body() 里请求权限 | 刷新时反复弹窗 | 放进按钮回调 |
用户拒绝后反复 request | 体验差、仍拿不到权限 | 展示状态并引导去系统设置 |
| 文档 | 用途 |
|---|---|
| location | 定位权限与坐标读取 |
| notification | 通知权限与本地提醒 |
| photos | 相册访问与选图 |
| health | HealthKit 按类型授权 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+相册选择、拍照、保存和资源查询。
+访问系统照片库:选图、拍照、保存图片/视频、读取资源与相册,以及图片 Base64 转换。
+边界:走系统照片/相机界面,用户可随时取消(返回+None或False)。visionOCR 要 bytes,vision_helper检测要 Base64;大文件不要塞进storage。
| 项 | 说明 |
|---|---|
| 导入 | import photos |
| 适合做什么 | 选图上传、拍照存档、相册浏览、视觉识别前置 |
| 调用时机 | pick_image / capture_image 放在按钮回调;不要写在 body() 中 |
| 推荐顺序 | 用户点击 → pick_image / capture_image → 判空 → 处理或 save_image |
| 取消处理 | 用户取消返回 None;保存失败返回 False |
| 入口 | 第一行 | 适合场景 |
|---|---|---|
| Python 模块 | import photos | pick_image()、capture_image() 等函数式调用 |
| AppUI 组件 | import appui + PhotoPicker | 表单内嵌选图、on_picked 回调绑定 |
两种入口共享相册权限与取消语义;相机场景见 camera-module,任意文件见 file-picker-module。
+下面脚本从相册选一张图,打印尺寸;用户取消时友好退出:
+import photos
+
+image = photos.pick_image()
+if image is None:
+ print("用户取消选图")
+else:
+ width, height = photos.get_image_size(image)
+ print(f"已选择 {width}×{height}")
+
+ saved = photos.save_image(image, format="JPEG", quality=0.85)
+ print("保存到相册:", "成功" if saved else "失败")
+没有 Pillow 时,用 pick_image(raw_data=True) 获取 bytes。
选图和拍照都在按钮回调里执行;取消时保持页面可操作。表单内嵌选图也可用 appui.PhotoPicker(见 AppUI 媒体参考)。
import appui
+import photos
+
+state = appui.State(
+ status="等待操作",
+ size="—",
+ saved="—",
+ message="点击按钮开始",
+)
+
+
+def pick_photo():
+ image = photos.pick_image()
+ if image is None:
+ state.batch_update(
+ status="已取消",
+ size="—",
+ saved="—",
+ message="用户取消选图",
+ )
+ return
+
+ width, height = photos.get_image_size(image)
+ state.batch_update(
+ status="已选图",
+ size=f"{width}×{height}",
+ saved="—",
+ message="选图成功,可继续处理或保存",
+ )
+
+
+def capture_and_save():
+ image = photos.capture_image()
+ if image is None:
+ state.batch_update(
+ status="已取消",
+ size="—",
+ saved="—",
+ message="用户取消拍照",
+ )
+ return
+
+ width, height = photos.get_image_size(image)
+ ok = photos.save_image(image, format="JPEG", quality=0.85)
+ state.batch_update(
+ status="已拍照",
+ size=f"{width}×{height}",
+ saved="已保存到相册" if ok else "保存失败",
+ message="拍照流程完成",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("操作", [
+ appui.Button("从相册选图", action=pick_photo)
+ .button_style("bordered_prominent"),
+ appui.Button("拍照并保存", action=capture_and_save),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("状态", value=state.status),
+ appui.LabeledContent("尺寸", value=state.size),
+ appui.LabeledContent("保存", value=state.saved),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("照片")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
pick_image(...) | 系统选图 → PIL.Image / bytes / None |
capture_image(...) | 系统拍照 → PIL.Image / None |
save_image(image, ...) | 保存图片到相册 → bool |
save_video(path) | 保存本地视频文件到相册 → bool |
get_assets(...) | 列举照片资源 → list[Asset] |
pick_asset(...) | 选择单个 Asset |
pick_image_base64(...) | 选图并返回 Base64 字符串 |
get_image_size(image) | 图片尺寸 → (width, height) |
pick_image(raw_data=False) — 打开系统照片选择器。
image = photos.pick_image()
+if image is None:
+ print("用户取消")
+
+raw = photos.pick_image(raw_data=True) # 无 Pillow 时用 bytes
+默认返回 PIL.Image;raw_data=True 返回 bytes。show_albums、multi 等兼容参数会被接受但当前由系统界面决定行为。
capture_image() — 打开系统相机拍照,取消返回 None。camera、flash 等参数为兼容入口,实际由系统拍照界面控制。
image = photos.capture_image()
+if image:
+ photos.save_image(image)
+pick_asset() — 选择照片库中的 Asset 对象,便于读取元数据:
asset = photos.pick_asset()
+if asset:
+ print(asset.pixel_width, asset.pixel_height)
+ data = asset.get_image(original=True, raw_data=True)
+save_image(image, format="JPEG", quality=0.85) — 保存 PIL.Image 或 bytes 到照片库。
ok = photos.save_image(image, format="JPEG", quality=0.85)
+save_video(path) — 把本地视频文件路径写入相册(不读入内存):
import network
+import photos
+
+path = "demo.mp4"
+if network.download("https://example.com/demo.mp4", path):
+ ok = photos.save_video(path)
+album_name 等参数会被接受但当前忽略,文件保存到系统默认位置。
get_assets(media_type="photo", limit=20) — 列举资源,务必设置合理 limit。
assets = photos.get_assets(media_type="photo", limit=20)
+for asset in assets[:3]:
+ print(asset.local_identifier, asset.pixel_width, asset.pixel_height)
+media_type 支持 photo、video、all。limit=0 表示不限制,列表页不建议使用。
Asset 常用属性:local_identifier、media_type、pixel_width、pixel_height、creation_date、duration、is_favorite。
asset = photos.pick_asset()
+if asset:
+ image = asset.get_image(original=True)
+ raw = asset.get_image(original=True, raw_data=True)
+其他读取 API:get_recent_images(count=10)、get_favorites()、get_count(media_type="photo")。
| API | 说明 |
|---|---|
get_albums() | 用户相册 |
get_smart_albums() | 系统智能相册 |
get_moments() | 时刻分组 |
create_album(title) | 创建相册 |
get_screenshots_album() | 截图相册 |
get_recently_added_album() | 最近添加 |
get_selfies_album() | 自拍相册 |
给 vision_helper 等需要 Base64 的流程使用:
+import photos
+import vision_helper
+
+payload = photos.pick_image_base64(format="JPEG", quality=0.85)
+if payload:
+ result = vision_helper.detect_barcodes(payload)
+ print(result)
+| API | 说明 |
|---|---|
pick_image_base64(...) | 选图 → Base64 |
capture_image_base64(...) | 拍照 → Base64 |
image_to_base64(image, ...) | 已有图片 → Base64 |
base64_to_image(b64_string) | Base64 → PIL.Image |
注意:vision OCR 接收 bytes;vision_helper 检测接收 Base64 字符串。
+| 错误写法 | 后果 | 修正 |
|---|---|---|
不判断 pick_image() 返回值 | 用户取消后崩溃 | 先 if image is None: return |
get_assets(limit=0) 拉全库 | 内存和耗时暴涨 | 设置合理 limit |
把 Base64 存进 storage | 体积大、读写慢 | 存文件路径或按需重选 |
把文件路径传给 vision_helper | 类型错误 | 用 pick_image_base64 或 image_to_base64 |
在 body() 里打开选图器 | 刷新时反复弹窗 | 放进按钮回调 |
| 文档 | 用途 |
|---|---|
| vision | 图片 bytes OCR |
| vision_helper | Base64 人脸/条码/分类检测 |
| permission | 照片权限状态查询 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+生成二维码图片(读取用 vision_helper)。
+二维码生成:把文字或链接编码为 PNG 图片(CoreImage CIQRCodeGenerator)。
+边界:只负责生成,不从图片读取/扫描二维码。识别请用 vision_helper 的 detect_barcodes。无需权限,不联网。
+| 项 | 说明 |
|---|---|
| 导入 | import qrcode |
| 适合做什么 | 分享链接、设备配对码、活动签到码 |
| 调用时机 | generate / save 放在按钮回调 |
| 推荐顺序 | 准备文本 → save() 或 generate() → 分享或相册展示 |
| 纠错等级 | L/M(默认)/Q/H,越高越抗污损、容量越小 |
生成 PNG 字节并保存到文件:
+import os
+import qrcode
+
+out = os.path.join(os.path.expanduser("~/Documents"), "qr-preview.png")
+png = qrcode.generate("https://pythonide.xin", size=512, correction="M")
+print("字节数:", len(png))
+path = qrcode.save("https://pythonide.xin", out, size=600)
+print("已保存:", path)
+生成放进按钮回调;appui.Image 不直接显示文件路径,因此展示保存路径文本。
import appui
+import os
+import qrcode
+
+state = appui.State(
+ text="https://pythonide.xin",
+ path="",
+ size="512",
+)
+
+
+def make_qr():
+ out = os.path.join(os.path.expanduser("~/Documents"), "qr-preview.png")
+ size = int(state.size) if state.size.isdigit() else 512
+ state.path = qrcode.save(state.text, out, size=size, correction="M")
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("内容", [
+ appui.TextField("要编码的内容", text=state.text),
+ appui.TextField("边长(像素)", text=state.size),
+ ]),
+ appui.Section("生成", [
+ appui.Button("生成二维码", action=make_qr)
+ .button_style("bordered_prominent"),
+ appui.Text(
+ "已保存到:" + state.path if state.path else "尚未生成"
+ ).foreground_color("secondaryLabel"),
+ ], footer="生成后可用分享或相册查看 PNG。"),
+ ]).navigation_title("二维码")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
generate(text, size=512, correction='M') | 返回 PNG bytes |
generate_base64(text, size=512, correction='M') | 返回 Base64 PNG 字符串 |
save(text, path, size=512, correction='M') | 写入文件并返回路径 |
QRCodeError | 生成失败异常 |
| 参数 | 说明 |
|---|---|
text | 要编码的字符串 |
size | 输出边长(像素),通常 64–4096 |
correction | L(7%) / M(15%) / Q(25%) / H(30%) |
png = qrcode.generate("Hello", size=256, correction="H")
+b64 = qrcode.generate_base64("Hello")
+path = qrcode.save("Hello", "/path/qr.png")
+| 错误写法 | 后果 | 修正 |
|---|---|---|
用 qrcode 扫描图片 | 模块不支持读取 | 用 vision_helper.detect_barcodes |
| 文本过长 | 编码失败 | 缩短内容或降低纠错等级 |
用 appui.Image 显示文件路径 | 显示不出来 | 保存后用 share 或相册 |
| 保存到不可写路径 | QRCodeError | 改用 ~/Documents |
| 文档 | 用途 |
|---|---|
| vision_helper | 从图片识别二维码/条码 |
| share | 分享生成的 PNG |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+按生命周期、节点、Action、Physics、Classic 绘图与常量查 scene 公开名称。
+按生命周期、节点、Action、Physics、Classic 绘图与常量快速查找 scene 公开名称。
精确签名以 scene API 参考 与 运行时/scene类型存根 为准。
+import scene
+
+
+class DemoScene(scene.Scene):
+ def setup(self):
+ self.background_color = "black"
+
+ def draw(self):
+ scene.fill(1, 1, 1)
+ scene.text("scene", x=self.size.w / 2, y=self.size.h / 2, font_size=24)
+
+
+scene.run(DemoScene())
+| 你要做什么 | 先看 |
|---|---|
| 启动与关闭场景 | run、Scene.setup、Scene.stop(关闭回调) |
| 每帧逻辑 | update、dt、t、frame_count |
| 精灵 / 文字 / 形状 | SpriteNode、LabelNode、ShapeNode、Texture |
| 动画 | Action.move_to、Action.sequence、Action.repeat_forever |
| 触摸 | touch_began、Touch.location |
| 碰撞 | PhysicsBody、contact_began、Contact |
| Classic 画布 | background、fill、rect、ellipse、line、text |
| 屏幕与安全区 | get_screen_size、get_safe_area_insets |
| 模态场景 | present_modal_scene、dismiss_modal_scene |
| 名称 | 说明 |
|---|---|
run | 运行 Scene 子类(阻塞至关闭) |
get_screen_size | 屏幕尺寸 → Size |
get_screen_scale | 屏幕缩放因子 |
get_safe_area_insets | 安全区 → EdgeInsets |
gravity | 设备重力向量 → Vector3 |
play_effect | 播放系统音效 |
get_image_path | 解析内置图片路径 |
get_controllers | 已连接游戏手柄列表 |
| 名称 | 说明 |
|---|---|
background | 清屏背景色 |
fill / no_fill | 填充色 |
stroke / no_stroke / stroke_weight | 描边 |
tint / no_tint | 着色 |
rect / ellipse / line | 基础图形 |
image / text | 图片与文字 |
translate / rotate / scale | 变换 |
push_matrix / pop_matrix | 矩阵栈 |
blend_mode / use_shader | 混合与着色器 |
load_image / load_image_file / load_pil_image / unload_image | 图片资源 |
render_text | 预渲染文字纹理 |
image_quad ⚠️ | 未实现 |
triangle_strip ⚠️ | 未实现 |
| 名称 | 说明 |
|---|---|
Point / Size / Rect / Vector2 / Vector3 | 几何类型 |
EdgeInsets | 安全区内边距 |
Touch | location、prev_location、touch_id |
Contact | 物理碰撞回调:node_a、node_b、contact_point |
| 名称 | 说明 |
|---|---|
Scene | 场景基类 |
SceneView | 嵌入式场景视图(高级) |
Node | 节点基类 |
SpriteNode | 精灵 |
LabelNode | 文字 |
ShapeNode | 矢量路径 |
EffectNode | 特效节点 |
EmitterNode | 粒子发射器 |
Texture | 纹理 |
Shader | 自定义着色器 |
Action | 动作工厂 |
PhysicsWorld / PhysicsBody | 物理 |
PinJoint / SpringJoint / RopeJoint | 关节约束 |
Scene 生命周期方法setup、update、draw、did_evaluate_actions、touch_began、touch_moved、touch_ended、did_change_size、controller_changed、contact_began、present_modal_scene、dismiss_modal_scene、pause、resume、stop
Scene 常用属性size、bounds、background_color、children、physics_world、safe_area_insets、dt、t、frame_count、touches、effects_enabled、crop_rect、view、presented_scene、presenting_scene
Action 静态方法move_to、move_by、rotate_to、rotate_by、scale_to、scale_by、scale_x_to、scale_y_to、fade_to、fade_by、sequence、group、repeat、repeat_forever、wait、remove、call、set_uniform
| 分组 | 名称 |
|---|---|
| 方向 | DEFAULT_ORIENTATION、PORTRAIT、LANDSCAPE |
| 混合 | BLEND_NORMAL、BLEND_ADD、BLEND_MULTIPLY |
| 纹理过滤 | FILTERING_LINEAR、FILTERING_NEAREST |
| 缓动 | TIMING_LINEAR、TIMING_EASE_IN、TIMING_EASE_OUT、TIMING_EASE_IN_OUT、TIMING_SINODIAL、TIMING_BOUNCE_*、TIMING_ELASTIC_*、TIMING_EASE_BACK_* |
| 文档 | 用途 |
|---|---|
| scene 概览 | 教程、可运行示例、常见错误 |
| scene API 参考 | 构造函数与参数详解 |
| 情况 | 处理 |
|---|---|
| 权限被拒绝 | 在设置中开启权限后,从用户触发的回调重试 |
| 设备或能力不可用 | 先检查返回值或 is_available(),再给用户可读提示 |
| 用户取消 | 保留当前界面状态,不要当作成功继续流程 |
| 参数或 API 名错误 | 对照模块 API 参考与 schema,修正后再运行 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,请按「失败路径」排查。
+集中列出 scene 生命周期、节点、Action、Physics 与 Classic 绘图签名。
+scene 提供 Pythonista 兼容的 2D 场景 API(Swift SpriteKit 原生能力入口)。适合游戏与动画;表单/列表请用 appui。
scene.run(
+ scene_instance: Scene,
+ *,
+ orientation: int = 0,
+ frame_interval: int = 1,
+ anti_alias: bool = False,
+ show_fps: bool = False,
+ multi_touch: bool = True,
+) -> None
+| 参数 | 说明 |
|---|---|
orientation | DEFAULT_ORIENTATION(0)、PORTRAIT(1)、LANDSCAPE(2) |
frame_interval | 1≈60fps,2≈30fps |
show_fps | 左上角显示帧率 |
anti_alias | 抗锯齿 |
multi_touch | 是否多点触控 |
run() 阻塞直到场景视图关闭。关闭时若子类实现了 stop() 会被调用。
| 方法 | 时机 |
|---|---|
setup() | 场景首次显示前;self.size 已设置 |
update() | 每帧;可用 self.dt、self.t、self.frame_count |
draw() | Classic 模式每帧绘制 |
did_evaluate_actions() | 每帧 Action 求值后 |
touch_began(touch) / touch_moved / touch_ended | 触摸事件 |
did_change_size() | 尺寸或方向变化 |
controller_changed(key, value) | 游戏手柄状态变化 |
contact_began(contact) | 物理碰撞(需配置 contact_test_bitmask) |
present_modal_scene(other) | 叠加模态子场景 |
dismiss_modal_scene() | 关闭当前呈现的模态场景 |
pause() / resume() | App 前后台 |
stop() | 场景被用户关闭 |
| 属性 | 类型 | 说明 |
|---|---|---|
size | Size | 宽 w、高 h |
bounds | Rect | 场景边界 |
background_color | RGBA 元组或颜色名 | 背景色 |
children | list[Node] | 子节点 |
physics_world | PhysicsWorld | 物理世界 |
safe_area_insets | EdgeInsets | 安全区 |
dt / t | float | 帧间隔 / 运行时间(秒) |
frame_count | int | 帧计数 |
touches | dict | 当前活动触摸 |
Node(position=(0, 0), *, z_position=0, scale=1, alpha=1, parent=None)
+常用属性:position、rotation、x_scale、y_scale、alpha、z_position、parent、children、physics_body、blend_mode、paused、speed
常用方法:add_child、remove_from_parent、run_action、remove_action、remove_all_actions
SpriteNode(
+ texture=None,
+ position=(0, 0),
+ *,
+ size=None,
+ color="white",
+ z_position=0,
+ alpha=1,
+ parent=None,
+)
+texture:图片名、Texture 或 None(纯色块)。color= 关键字,例如 SpriteNode(color="red", size=(40,40))。LabelNode(
+ text="",
+ font=("Helvetica", 20),
+ position=(0, 0),
+ *,
+ parent=None,
+)
+属性 text、font(元组或名称)可读写。
ShapeNode(
+ path=None,
+ fill_color="white",
+ stroke_color="clear",
+ position=(0, 0),
+ *,
+ parent=None,
+)
+Texture(name_or_image)
+属性:size、filtering_mode(FILTERING_LINEAR / FILTERING_NEAREST)
工厂静态方法(均需 node.run_action(action) 才会执行):
| 方法 | 签名要点 |
|---|---|
move_to | (x, y, duration=0.5, timing_mode=TIMING_LINEAR) |
move_by | (dx, dy, duration=0.5, timing_mode=...) |
scale_to | (scale, duration=0.5, ...) — 统一缩放 |
scale_by | 相对缩放 |
rotate_to / rotate_by | 弧度 |
fade_to / fade_by | 透明度 |
sequence(*actions) | 顺序执行 |
group(*actions) | 并行执行 |
repeat(action, count) | count=0 配合 repeat_forever |
wait(duration) | 等待 |
remove() | 从父节点移除 |
call(func_id, ...) | 回调(通过注册 ID) |
PhysicsBody.rectangle(width=0, height=0, w=0, h=0)
+PhysicsBody.circle(radius=0, r=0)
+附着:node.physics_body = body(自动绑定到 SpriteKit)
| 属性 | 说明 |
|---|---|
dynamic | 是否受力和碰撞推动 |
affected_by_gravity | 是否受重力 |
allows_rotation | 是否可旋转 |
restitution | 弹性 |
friction | 摩擦 |
category_bitmask / collision_bitmask / contact_test_bitmask | 碰撞过滤 |
通过 scene.physics_world 访问;属性 gravity(Vector2)。
PinJoint(node_a, node_b, anchor)
+SpringJoint(node_a, node_b, anchor_a, anchor_b, *, damping=0.5, frequency=1.0)
+RopeJoint(node_a, node_b, anchor_a, anchor_b)
+SpringJoint 的 damping、frequency 必须关键字传递。
contact_began(contact) 收到 Contact:node_a、node_b、contact_point、collision_impulse。
仅在 Scene.draw() 内调用。
| 函数 | 签名 |
|---|---|
background(r, g, b) | 清屏 |
fill(r, g, b, a=1) | 填充色 |
stroke(r, g, b, a=1) | 描边色 |
stroke_weight(w) | 线宽 |
rect(x, y, w, h, corner_radius=0) | 矩形 |
ellipse(x, y, w, h) | 椭圆外接框 |
line(x1, y1, x2, y2) | 线段 |
text(txt, font_name='Helvetica', font_size=16, x=0, y=0, alignment=5) | 文字 |
image(name, x, y, w, h, ...) | 已加载图片 |
translate / rotate / scale | 变换 |
push_matrix / pop_matrix | 矩阵栈 |
| 函数 | 行为 |
|---|---|
image_quad(...) | NotImplementedError |
triangle_strip(...) | NotImplementedError |
| 函数 | 返回 | 说明 |
|---|---|---|
get_screen_size() | Size | 屏幕点尺寸 |
get_screen_scale() | float | Retina 缩放 |
get_safe_area_insets() | EdgeInsets | 安全区 |
gravity() | Vector3 | 设备加速度方向 |
play_effect(name, volume=1, pitch=1) | — | 系统音效 |
get_image_path(name) | str | 资源路径 |
get_controllers() | list | 手柄列表 |
'white'、'#RRGGBB'、(r,g,b)、(r,g,b,a) 或灰度浮点。y 向上增大(与 UIKit/SpriteKit 场景坐标一致)。draw()。scene 硬做表单。| 文档 | 用途 |
|---|---|
| scene 概览 | 可运行示例与选型 |
| scene API 索引 | 名称速查 |
| 情况 | 处理 |
|---|---|
| 权限被拒绝 | 在设置中开启权限后,从用户触发的回调重试 |
| 设备或能力不可用 | 先检查返回值或 is_available(),再给用户可读提示 |
| 用户取消 | 保留当前界面状态,不要当作成功继续流程 |
| 参数或 API 名错误 | 对照模块 API 参考与 schema,修正后再运行 |
以下名称与 scene 模块公开导出一致,供检索与 Agent 覆盖校验:
Scene, Node, SpriteNode, LabelNode, ShapeNode, EffectNode, EmitterNode, Action, Texture, Shader, SceneView, Touch, Vector2, Vector3, Size, Rect, Point, EdgeInsets, PhysicsBody, PhysicsWorld, Contact, PinJoint, SpringJoint, RopeJoint, run, get_screen_size, get_screen_scale, gravity, play_effect, get_image_path, get_controllers, get_safe_area_insets, background, fill, no_fill, stroke, no_stroke, stroke_weight, tint, no_tint, rect, ellipse, line, image, text, translate, rotate, scale, push_matrix, pop_matrix, blend_mode, use_shader, load_image, load_image_file, load_pil_image, render_text, unload_image, BLEND_NORMAL, BLEND_ADD, BLEND_MULTIPLY, DEFAULT_ORIENTATION, PORTRAIT, LANDSCAPE, TIMING_LINEAR, TIMING_EASE_IN, TIMING_EASE_IN_2, TIMING_EASE_OUT, TIMING_EASE_OUT_2, TIMING_EASE_IN_OUT, TIMING_SINODIAL, TIMING_EASE_BACK_IN, TIMING_EASE_BACK_OUT, TIMING_EASE_BACK_IN_OUT, TIMING_ELASTIC_IN, TIMING_ELASTIC_OUT, TIMING_ELASTIC_IN_OUT, TIMING_BOUNCE_IN, TIMING_BOUNCE_OUT, TIMING_BOUNCE_IN_OUT, FILTERING_LINEAR, FILTERING_NEAREST
运行示例后,界面应出现文档描述的目标结果;若与预期不符,请按「失败路径」排查。
+2D 场景、精灵节点、触摸、Action 动画、物理与 Classic 绘图。
+Pythonista 风格的 2D 场景运行时:精灵节点、触摸、逐帧 update()、Classic 绘图、动作动画与 SpriteKit 物理。
边界:scene 适合小游戏、画布动画和物理交互。普通表单、设置页、列表导航请用 appui;教材式海龟绘图用 turtle;主屏小组件用 widget。坐标原点在左下角(与 Pythonista 一致)。
+| 项 | 说明 |
|---|---|
| 导入 | import scene |
| 适合做什么 | 触摸游戏、精灵动画、粒子、碰撞、Classic 画布、手柄输入 |
| 入口 | 定义 Scene 子类,脚本末尾只调用一次 scene.run(MyScene()) |
| 生命周期 | setup() 建节点;update() 每帧逻辑;draw() Classic 绘图;触摸走 touch_* |
| 关闭方式 | scene.run() 会阻塞直到场景关闭(界面上的关闭按钮或系统下滑 dismiss) |
| MiniApp | Scene 类型 MiniApp 入口同样是 scene.run(...),不是 appui.run() |
| 文档示例 | 代码块标记 previewScene,点「预览」直接打开全屏场景,不弹控制台 sheet |
点击运行后会打开全屏 2D 场景;触摸屏幕可看到坐标更新。
+import scene
+
+
+class TapDemo(scene.Scene):
+ def setup(self):
+ self.background_color = (0.08, 0.09, 0.12)
+ self.label = scene.LabelNode(
+ "Tap anywhere",
+ font=("Helvetica", 22),
+ position=(self.size.w / 2, self.size.h / 2),
+ parent=self,
+ )
+
+ def touch_began(self, touch):
+ self.label.text = f"{touch.location.x:.0f}, {touch.location.y:.0f}"
+
+
+scene.run(TapDemo(), show_fps=True)
+SpriteNode 的第一个位置参数是纹理名;纯色块请用关键字 color=。动画用 node.run_action(...) 启动。
import scene
+
+
+class SpriteDemo(scene.Scene):
+ def setup(self):
+ self.background_color = "black"
+ self.box = scene.SpriteNode(
+ color="cyan",
+ size=(80, 80),
+ position=(self.size.w / 2, self.size.h / 2),
+ parent=self,
+ )
+ pulse = scene.Action.sequence([
+ scene.Action.scale_to(1.2, 0.5),
+ scene.Action.scale_to(0.8, 0.5),
+ ])
+ self.box.run_action(scene.Action.repeat_forever(pulse))
+
+
+scene.run(SpriteDemo(), show_fps=True)
+draw())fill、rect、line、text 等只能在 draw() 里调用。
import scene
+
+
+class DrawDemo(scene.Scene):
+ def draw(self):
+ scene.background(0.05, 0.06, 0.08)
+ scene.fill(0.2, 0.6, 1.0)
+ scene.rect(40, 80, 120, 80, corner_radius=12)
+ scene.stroke(1.0, 1.0, 1.0, 0.8)
+ scene.stroke_weight(3)
+ scene.line(40, 40, 180, 120)
+ scene.fill(1, 1, 1)
+ scene.text("Draw", x=100, y=130, font_size=20)
+
+
+scene.run(DrawDemo())
+给节点设置 physics_body;静态地面设 dynamic = False。需要碰撞回调时重写 contact_began(contact)。
import scene
+
+
+class PhysicsDemo(scene.Scene):
+ def setup(self):
+ self.background_color = "#101820"
+ w, h = self.size.w, self.size.h
+
+ floor = scene.SpriteNode(
+ color="#444",
+ size=(w, 120),
+ position=(w / 2, 60),
+ parent=self,
+ )
+ floor_body = scene.PhysicsBody.rectangle(w=w, h=120)
+ floor_body.dynamic = False
+ floor.physics_body = floor_body
+
+ self.ball = scene.SpriteNode(
+ color="#ff8c42",
+ size=(44, 44),
+ position=(w / 2, h - 100),
+ parent=self,
+ )
+ ball_body = scene.PhysicsBody.circle(r=22)
+ ball_body.restitution = 0.65
+ self.ball.physics_body = ball_body
+
+ def touch_began(self, touch):
+ self.ball.position = (touch.location.x, touch.location.y)
+
+
+scene.run(PhysicsDemo(), show_fps=True)
+主场景 present_modal_scene,子场景 dismiss_modal_scene 关闭。
import scene
+
+
+class SubScene(scene.Scene):
+ def setup(self):
+ self.background_color = (0.1, 0.1, 0.15, 0.92)
+ self.label = scene.LabelNode(
+ "子场景 — 点一下关闭",
+ font=("Helvetica", 20),
+ position=(self.size.w / 2, self.size.h / 2),
+ parent=self,
+ )
+
+ def touch_began(self, touch):
+ self.dismiss_modal_scene()
+
+
+class MainScene(scene.Scene):
+ def setup(self):
+ self.label = scene.LabelNode(
+ "主场景 — 点一下打开子场景",
+ font=("Helvetica", 20),
+ position=(self.size.w / 2, self.size.h / 2),
+ parent=self,
+ )
+
+ def touch_began(self, touch):
+ self.present_modal_scene(SubScene())
+
+
+scene.run(MainScene())
+scene.run(SceneSubclass(), ...) 阻塞当前脚本,直到用户关闭场景视图。setup() 创建节点、纹理、物理体;此时 self.size 已由运行时注入。update();节点位移/缩放优先 Action,避免每帧新建节点。scene.rect 等)只放在 draw();保留式 UI 用 SpriteNode / LabelNode。touch_began / touch_moved / touch_ended,用 touch.location.x/y。setup() 缓存,不要在每帧重复加载。| 需求 | 首选 | 原因 |
|---|---|---|
| 小游戏、Sprite、粒子、碰撞、触摸循环 | scene | 帧循环、节点树、Action、Physics |
| 设置页、列表、图表、导航 | appui | 原生控件与状态管理 |
| Pythonista 命令式 UIKit 控件 | ui | 迁移旧 ui 脚本 |
| 教材海龟、几何作图 | turtle | API 更简单,无节点/物理 |
| 主屏/锁屏展示 | widget | WidgetKit 时间线模型 |
| API | 作用 |
|---|---|
run(scene, *, orientation=0, frame_interval=1, ...) | 全屏运行场景(阻塞至关闭) |
Scene | 场景基类:setup / update / draw / touch_* |
SpriteNode / LabelNode / ShapeNode | 精灵、文字、矢量形状 |
Action.move_to / sequence / repeat_forever | 节点动画 |
PhysicsBody / PhysicsWorld | 碰撞与重力 |
background / fill / rect / text | Classic 绘图(仅 draw()) |
get_screen_size() / get_safe_area_insets() | 屏幕与安全区 |
PORTRAIT / LANDSCAPE | run() 的方向常量 |
scene.run()scene.run(
+ MyScene(),
+ orientation=scene.PORTRAIT, # DEFAULT_ORIENTATION=0, PORTRAIT=1, LANDSCAPE=2
+ frame_interval=1, # 1≈60fps,2≈30fps
+ anti_alias=True,
+ show_fps=True,
+ multi_touch=True,
+)
+IDE / MiniApp 的 Scene 模式 会在内部使用主线程回调(_mode='main');文档里直接运行上述示例即可。
# 纹理精灵(texture 为资源名或 Texture)
+scene.SpriteNode("Player", position=(100, 200), parent=self)
+
+# 纯色块 — 必须用 color= 关键字,不要把颜色当第一个位置参数
+scene.SpriteNode(color="cyan", size=(60, 60), position=(x, y), parent=self)
+
+# 文字
+scene.LabelNode("Hello", font=("Helvetica", 24), position=(x, y), parent=self)
+
+# 形状
+scene.ShapeNode(fill_color="yellow", stroke_color="white", parent=self)
+Action.move_to(x, y, duration, timing_mode=scene.TIMING_LINEAR) 常用 timing:TIMING_EASE_IN_OUT、TIMING_BOUNCE_OUT 等(见 API 参考)。
| API | 说明 |
|---|---|
scene.text(txt, x=0, y=0, font_name='Helvetica', font_size=16) | 在 draw() 中绘制文字 |
scene.rect(x, y, w, h, corner_radius=0) | 矩形;先 fill / stroke 再画 |
scene.image(name, x, y, w, h) | 绘制已加载图片 |
image_quad、triangle_strip 在兼容层中尚未实现,调用会抛 NotImplementedError。
| 属性 | 说明 |
|---|---|
self.size / self.size.w / self.size.h | 场景尺寸(Size,支持 / 2 等向量运算) |
self.dt / self.t | 帧间隔 / 累计时间(秒) |
self.frame_count | 帧计数 |
self.physics_world | 物理世界,可改 gravity |
self.safe_area_insets | 刘海/底栏安全区 |
完整签名见 scene API 索引 与 scene API 参考。
+| 错误写法 | 后果 | 修正 |
|---|---|---|
SpriteNode("cyan", ...) | "cyan" 被当成纹理名 | 使用 SpriteNode(color="cyan", ...) |
在 setup() 里调用 scene.rect() | 不显示或行为异常 | 绘图放进 draw() |
只创建 Action.move_to(...) 不 run_action | 节点不动 | node.run_action(action) |
脚本里没有 scene.run(...) | 场景不出现 | 末尾调用一次 scene.run(MyScene()) |
在 update() 里做网络/大文件 I/O | 掉帧、卡顿 | 放后台线程,主线程只改状态 |
用 appui.Button 做场景 HUD | API 不存在 | 用 LabelNode + 触摸,或改用 appui |
表单/设置页整页用 scene | 难维护 | 改用 appui 的 Form / List |
| 情况 | 处理 |
|---|---|
| 场景不显示 | 确认调用了 scene.run(SceneSubclass()),且 setup() 未抛错 |
| 触摸无反应 | 实现 touch_began 等,坐标用 touch.location |
节点在 (0,0) | 检查 position= 是否在 setup() 里用 self.size.w/h 居中 |
| 物理不碰撞 | 设置 physics_body,静态体 dynamic=False |
| 关闭后文档里再运行提示忙 | 等上一次 scene.run 完全结束,或先停止当前 Python 运行 |
| 文档 | 用途 |
|---|---|
| scene API 索引 | 按类族快速查名称 |
| scene API 参考 | 生命周期、节点、Action、Physics 签名 |
| turtle | 海龟教学绘图 |
| appui | 原生表单与导航 UI |
| ui | Pythonista 风格 ui 控件 |
| motion | 加速度计/陀螺仪(可与 scene 配合) |
| 目标 | 写法 |
|---|---|
| 精灵 / 物理游戏 | Scene 子类 + 节点树 |
| 即时画布 | draw() + Classic API |
| 教程海龟 | turtle |
setup() 建节点;update() 做帧逻辑;draw() 只负责即时绘制。Action + run_action,不要阻塞循环。scene.run(...)。color= 关键字参数运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+系统分享面板与 AppUI ShareLink。
+系统分享面板:把文本、链接或文件路径交给 iOS 分享表单(AirDrop、备忘录、邮件等)。
+边界:无独立+import sharePython 模块;通过 AppUI 的ShareLink唤起系统ShareLink/ 分享表单。item需是有意义的字符串(URL、文件路径或分享正文)。复杂导出流水线可结合 file_picker 先拿到路径再分享。
| 项 | 说明 |
|---|---|
| 导入 | import appui |
| 适合做什么 | 分享笔记、导出 CSV/文本、转发链接 |
| 调用时机 | 用户点击 ShareLink;内容先在 State 或函数里准备好 |
| 推荐顺序 | 生成内容 → 写入 State → ShareLink(item=...) |
| 空内容 | item 为空时分享面板无有效载荷 |
下面脚本构建一段可分享文本,并用 ShareLink 展示分享按钮:
import appui
+
+state = appui.State(note="来自 PythonIDE 的分享示例")
+
+
+def body():
+ return appui.VStack([
+ appui.TextEditor(text=state.note),
+ appui.ShareLink(
+ item=state.note,
+ subject="我的笔记",
+ message="请查看附件内容",
+ ),
+ ], spacing=12).padding()
+
+
+appui.run(body, state=state)
+先编辑内容,再点分享;item 随 State 更新。
import appui
+
+state = appui.State(
+ title="周报摘要",
+ body="本周完成:文档重写、健康模块示例、网络请求演示。",
+ status="编辑后点分享",
+)
+
+
+def body():
+ share_text = f"{state.title}\n\n{state.body}"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("内容", [
+ appui.TextField("标题", text=state.title),
+ appui.TextEditor(text=state.body)
+ .frame(min_height=120),
+ ]),
+ appui.Section("分享", [
+ appui.ShareLink(
+ item=share_text,
+ subject=state.title,
+ message="分享自 PythonIDE",
+ ),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="也可把 item 设为 https:// 链接或沙盒内文件路径。"),
+ ]).navigation_title("分享")
+ )
+
+
+appui.run(body, state=state)
+| 参数 | 作用 |
|---|---|
item | 分享主体:文本、URL 或文件路径 |
subject | 可选主题(部分目标 App 使用) |
message | 可选附言 |
ShareLink(item='', subject=None, message=None)
appui.ShareLink(
+ item="https://www.python.org",
+ subject="Python",
+ message="官方站点",
+)
+分享本地文件时,item 传绝对路径字符串(通常来自 FileImporter 或你自己写入沙盒的文件):
appui.ShareLink(item="/var/mobile/Containers/Data/.../export.csv")
+| 组件 | 行为 |
|---|---|
Link | 直接在 Safari 打开 URL |
ShareLink | 弹出系统分享面板,可选多种目标 App |
item 在每次 body() 求值时读取当前 State;编辑后再次点击分享即可获得最新文本。
| 错误写法 | 后果 | 修正 |
|---|---|---|
item="" | 分享面板空内容 | 生成后再展示 ShareLink |
| 分享未复制进沙盒的外部路径 | 目标 App 读不到文件 | 先 FileImporter(copy=True) 或自行复制 |
| 在回调里手动弹分享 | 无对应 Python API | 用 ShareLink 组件 |
把敏感 token 写进 item | 泄露到第三方 App | 只分享用户确认过的内容 |
| 文档 | 用途 |
|---|---|
| file_picker | 导入待分享文件 |
| clipboard | 复制到剪贴板(不弹分享面板) |
| 结构化邮件发送 | |
| AppUI 数据参考 | ShareLink / Link 签名 |
| WebView 分享示例 | 完整导出页面样板 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+听歌识曲:麦克风或音频文件匹配 Shazam 曲库。
+听歌识曲(ShazamKit):通过麦克风识别正在播放的歌曲,或从本地音频片段匹配曲库。
+边界:需要 iOS 17+,并在 Apple Developer 为+app.pythonide的 App Services 中开启 ShazamKit(与 Capabilities 是两项配置)。实时识曲需要麦克风权限;recognize_file()只需可读音频文件。与 speech_recognition(语音转文字)和 music(Apple Music 播放控制)用途不同。
| 项 | 说明 |
|---|---|
| 导入 | import shazam |
| 适合做什么 | 「这是什么歌」、哼唱/外放识曲、短音频片段匹配 |
| 调用时机 | recognize() / recognize_file() 放在按钮回调 |
| 推荐顺序 | is_available() → 申请麦克风(实时模式)→ recognize() |
| 音频建议 | 实时模式默认听 8 秒;文件模式建议 3–12 秒清晰片段 |
| 联网 | 需要网络访问 Shazam 曲库 |
下面脚本检查能力并尝试识曲(需在安静环境、有音乐播放时测试):
+import shazam
+
+print("可用:", shazam.is_available())
+
+try:
+ song = shazam.recognize(duration=8)
+ print(song["title"], "-", song["artist"])
+ if song.get("apple_music_url"):
+ print("Apple Music:", song["apple_music_url"])
+except shazam.ShazamError as exc:
+ print("识曲失败:", exc, f"(code={exc.code})")
+从本地短音频识曲(不需麦克风):
+import shazam
+
+try:
+ song = shazam.recognize_file("/path/to/clip.m4a")
+ print(song["title"], song.get("album", ""))
+except shazam.ShazamError as exc:
+ print(exc.code, exc)
+识曲请求放在按钮回调;进行中可显示状态,成功后展示歌名与封面链接字段。
+import appui
+import shazam
+
+state = appui.State(
+ title="—",
+ artist="—",
+ status="点击按钮开始识曲",
+ artwork_url="",
+)
+
+
+def _show_song(song):
+ state.title = song.get("title") or "—"
+ state.artist = song.get("artist") or "—"
+ state.artwork_url = song.get("artwork_url") or ""
+ state.status = "识别成功"
+
+
+def listen_now():
+ if not shazam.is_available():
+ state.status = "当前系统不支持 ShazamKit(需 iOS 17+)"
+ return
+
+ state.status = "聆听中…请播放音乐"
+
+ try:
+ song = shazam.recognize(duration=8)
+ _show_song(song)
+ except shazam.ShazamError as exc:
+ if exc.code == "denied":
+ state.status = "需要麦克风权限,请在系统设置中允许"
+ elif exc.code == "no_match":
+ state.status = "未识别到匹配歌曲,请靠近音源重试"
+ elif exc.code == "shazamkit_auth":
+ state.status = "ShazamKit 未在开发者后台开启,请配置后重装"
+ else:
+ state.status = f"识曲失败: {exc} (code={exc.code})"
+
+
+def cancel_listen():
+ shazam.cancel()
+ state.status = "已取消"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("结果", [
+ appui.LabeledContent("歌曲", value=state.title),
+ appui.LabeledContent("歌手", value=state.artist),
+ appui.Text(state.status).font("caption").foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("操作", [
+ appui.Button("开始识曲(8 秒)", action=listen_now)
+ .button_style("bordered_prominent"),
+ appui.Button("取消", action=cancel_listen, role="destructive"),
+ ], footer="需要 ShazamKit App Service、网络与麦克风;真机测试最可靠。"),
+ ]).navigation_title("听歌识曲")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_available() | 是否支持 ShazamKit 实时识曲 |
status() | 当前识别状态 {recognizing, state} |
recognize(duration=8) | 麦克风识曲 → dict |
recognize_file(path) | 本地音频文件识曲 → dict |
cancel() | 取消进行中的 recognize() |
ShazamError | 识曲失败时抛出 |
成功时返回字典,常见键:
+| 键 | 说明 |
|---|---|
matched | 是否匹配成功(True) |
title | 歌曲名 |
artist | 艺术家 |
album | 专辑(可能为空) |
shazam_id | Shazam 媒体 ID |
genres | 风格列表 |
apple_music_url | Apple Music 链接(有则返回) |
artwork_url | 封面图 URL(有则返回) |
isrc | ISRC(有则返回) |
web_url / video_url | 其他关联链接(有则返回) |
import shazam
+
+song = shazam.recognize(duration=10)
+print(song["title"], song["artist"])
+| 参数 | 说明 |
|---|---|
duration | 聆听秒数,自动限制在 3–20 秒 |
首次调用时系统可能弹出麦克风授权;拒绝后会抛 ShazamError(code="denied")。
song = shazam.recognize_file("recording.m4a")
+建议使用 3–12 秒、单声道/立体声均可的常见格式(m4a、wav、mp3 等)。片段太短会报 invalid_input。
失败时抛出 ShazamError,可通过 exc.code 区分:
code | 含义 |
|---|---|
unavailable | 系统版本低于 iOS 17,或 ShazamKit 不可用 |
shazamkit_auth | 开发者后台未开启 ShazamKit App Service |
denied | 麦克风未授权(recognize) |
no_match | 未在曲库中找到匹配 |
timeout | 聆听超时仍未匹配 |
cancelled | 调用了 cancel() |
already_recognizing | 上一次 recognize() 尚未结束 |
invalid_input | 文件路径无效、格式不支持或音频过短 |
network_error | 网络或 Shazam 服务异常 |
try:
+ shazam.recognize()
+except shazam.ShazamError as exc:
+ print(exc.code, exc)
+| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里调 recognize() | 每次刷新都开麦/联网 | 放进按钮回调,结果写进 State |
| 未开 ShazamKit App Service | shazamkit_auth | 在 Developer → App Services 勾选 ShazamKit 并重装 |
| 环境太吵或歌太短 | no_match / timeout | 靠近音源、延长 duration 或换更清晰的片段 |
| 用 1 秒文件去匹配 | invalid_input | 提供约 3–12 秒音频 |
不捕获 ShazamError | 异常直接打断流程 | 用 try/except 并给出兜底 UI |
| 文档 | 用途 |
|---|---|
| music | Apple Music 播放与搜索 |
| speech_recognition | 语音转文字(非识曲) |
| audio_recorder | 录制音频后再 recognize_file |
| permission | 查询其他权限状态 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+系统快捷指令集成、自动化和 URL 跳转。
+系统快捷指令集成、自动化和 URL 跳转。
+示例会展示系统快捷指令入口和 Python shortcuts 模块的最小调用。
| 目标 | 推荐方式 |
|---|---|
| 从系统快捷指令里运行 Python 脚本 | 使用“运行 Python 脚本”动作。 |
| 从 App 内按钮触发已有快捷指令 | 在 AppUI 回调里调用 shortcuts.run_shortcut(...)。 |
| 打开网页、深链或系统设置 | 调用 shortcuts.open_url(...) 或 shortcuts.open_settings()。 |
| 需要表单、确认和结果展示 | 用 AppUI 承载页面,把快捷指令能力放进按钮。 |
| 需要长期后台自动化 | 使用系统快捷指令自动化,不把循环写进前台页面。 |
| 动作 | 用途 |
|---|---|
| 运行 Python 代码 | 执行短代码片段 |
| 运行 Python 脚本 | 执行工作区 .py 文件并传参 |
| 在应用中运行 | 打开 App 运行需要输入或 UI 的脚本 |
| 获取脚本输出 | 读取不等待运行后的输出 |
| 创建 Python 脚本 | 创建 .py 文件后再运行 |
print("hello")
+import shortcuts
+
+shortcuts.run_shortcut("Daily Log")
+shortcuts.open_url("https://www.apple.com")
+shortcuts.open_settings()
+import appui
+import shortcuts
+
+state = appui.State(status="等待操作")
+
+
+def run_daily_log():
+ ok = shortcuts.run_shortcut("Daily Log")
+ state.status = "已触发" if ok else "未能触发"
+
+
+def open_settings():
+ shortcuts.open_settings()
+ state.status = "已打开设置"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("快捷指令", [
+ appui.Button("运行 Daily Log", action=run_daily_log),
+ appui.Button("打开设置", action=open_settings),
+ ]),
+ appui.Section("状态", [
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("快捷指令")
+ )
+
+
+appui.run(body, state=state)
+预期效果:页面提供两个明确按钮;触发失败不会中断页面,状态区域会显示结果。
+| 情况 | 应该怎么处理 |
|---|---|
| 系统入口不可用 | 显示不可用状态,不重复触发打开动作。 |
| 用户取消或没有配置 | 保持原页面状态,并提示需要先配置对应系统能力。 |
| 回调没有返回预期结果 | 记录简短状态,避免把长日志或敏感内容展示给用户。 |
| 需要复杂界面 | 用 appui 承载页面,把自动化能力放在按钮回调里调用。 |
run_shortcut(...) 需要用户设备上已经存在同名快捷指令。| 检查项 | 合格标准 |
|---|---|
| 快捷指令名称 | 设备上已经存在同名快捷指令。 |
| 触发时机 | 运行快捷指令和打开 URL 都由按钮、菜单或系统动作触发。 |
| 结果反馈 | 成功、取消、未配置和不可用状态都有简短提示。 |
| 页面边界 | 多步骤交互放在 AppUI,不把 UI 流程塞进系统动作。 |
| 文档 | 用途 |
|---|---|
| shortcuts | 运行快捷指令、打开 URL、进入设置。 |
运行快捷指令、打开 URL、进入设置。
+系统快捷指令与外链:运行快捷指令、打开 URL、进入本 App 设置页。
+边界:+run_shortcut()/open_url()会离开当前 App 或跳转系统界面,必须由用户点击触发,不要在body()或页面加载时自动调用。run_shortcut(name)依赖用户在「快捷指令」App 中已存在同名指令。
| 项 | 说明 |
|---|---|
| 导入 | import shortcuts |
| 适合做什么 | 衔接系统自动化、打开网页/Deep Link、跳转设置 |
| 调用时机 | 按钮回调里调用 |
| 推荐顺序 | 确认用户意图 → run_shortcut / open_url / open_settings → 更新状态 |
| 返回值 | 三个 API 均返回 bool 表示是否成功发起 |
下面脚本打开网页并进入 App 设置:
+import shortcuts
+
+ok = shortcuts.open_url("https://www.apple.com")
+print("打开 URL:", ok)
+
+ok = shortcuts.open_settings()
+print("打开设置:", ok)
+运行已创建的快捷指令(名称须完全一致):
+import shortcuts
+
+ok = shortcuts.run_shortcut("每日记录")
+print("已启动:", ok)
+外跳操作全部放在按钮回调;结果写回 State。
import appui
+import shortcuts
+
+state = appui.State(status="等待操作")
+
+
+def open_website():
+ ok = shortcuts.open_url("https://www.apple.com")
+ state.status = "已打开网页" if ok else "打开 URL 失败"
+
+
+def open_settings():
+ ok = shortcuts.open_settings()
+ state.status = "已打开设置" if ok else "打开设置失败"
+
+
+def run_daily_log():
+ ok = shortcuts.run_shortcut("每日记录")
+ state.status = "已启动快捷指令" if ok else "启动失败(检查名称是否存在)"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("外链", [
+ appui.Button("打开 Apple 官网", action=open_website)
+ .button_style("bordered_prominent"),
+ appui.Button("打开本 App 设置", action=open_settings),
+ ]),
+ appui.Section("快捷指令", [
+ appui.Button("运行「每日记录」", action=run_daily_log),
+ appui.Text(
+ "需先在系统「快捷指令」App 中创建同名指令"
+ ).font("caption").foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("状态", [
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="所有外跳必须由用户点击触发。"),
+ ]).navigation_title("快捷指令")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
run_shortcut(name) | 按名称运行快捷指令 → bool |
open_url(url) | 打开网页 / Deep Link / App Scheme → bool |
open_settings() | 打开本 App 系统设置页 → bool |
run_shortcut(name) -> bool
ok = shortcuts.run_shortcut("My Workflow")
+会跳转到「快捷指令」App 执行;名称不匹配时可能打开但不执行目标流程。
+open_url(url) -> bool
shortcuts.open_url("https://example.com")
+shortcuts.open_url("mailto:support@example.com")
+shortcuts.open_url("pythonide://some-path") # 示例 scheme
+支持 https://、mailto:、tel: 及第三方 App 的 URL Scheme。
open_settings() -> bool — 打开当前 App 在 iOS 设置中的页面。
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 open_url() | 页面一加载就跳走 | 放进按钮回调 |
| 猜测快捷指令名称 | 无法执行 | 使用用户已有的精确名称 |
| 用 Shortcuts 做核心业务 | 难维护、难调试 | 优先 PythonIDE 原生模块 |
| 不告知用户将离开 App | 体验突兀 | 按钮文案写清楚 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+音效播放、长音频播放器和静音开关行为。
+音效与本地音频播放:短音效、长音频 Player、音量与静音开关控制。
+边界:短反馈用+play_effect;本地长音频用Player。远程视频/流媒体请看 avplayer;文字朗读用 speech。MIDIPlayer为兼容占位,不提供完整 MIDI 播放。
| 项 | 说明 |
|---|---|
| 导入 | import sound |
| 适合做什么 | 点击音效、成功/错误提示音、本地音乐/播客播放 |
| 调用时机 | 放在按钮回调;页面关闭时停止循环音效 |
| 推荐顺序 | 短音 play_effect → 保存 handle → 需要时 stop_effect |
| 循环音效 | looping=True 时必须保存 handle,否则无法停止 |
下面脚本播放短音效并调节全局音量:
+import sound
+
+handle = sound.play_effect("arcade:Coin_1", volume=0.8)
+print("handle:", handle)
+
+sound.set_volume(0.8)
+print("全局音量:", sound.get_volume())
+
+if handle:
+ sound.stop_effect(handle)
+音效和音量控制放在按钮回调里,状态同步写回界面。
+import appui
+import sound
+
+state = appui.State(
+ status="未播放",
+ volume=sound.get_volume(),
+ handle=None,
+)
+
+
+def play_click():
+ handle = sound.play_effect("arcade:Coin_1", volume=state.volume)
+ state.handle = handle
+ state.status = "音效已触发" if handle else "音效不可用(检查名称或文件)"
+
+
+def play_loop():
+ if state.handle:
+ sound.stop_effect(state.handle)
+ handle = sound.play_effect("arcade:Coin_1", volume=state.volume, looping=True)
+ state.handle = handle
+ state.status = "循环播放中" if handle else "循环音效不可用"
+
+
+def stop_current():
+ if state.handle:
+ sound.stop_effect(state.handle)
+ state.handle = None
+ state.status = "已停止当前音效"
+
+
+def stop_all():
+ sound.stop_all_effects()
+ state.batch_update(handle=None, status="已停止所有音效")
+
+
+def update_volume(value):
+ state.volume = value
+ sound.set_volume(value)
+ state.status = f"全局音量 {value:.1f}"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("音效", [
+ appui.Button("播放点击音", action=play_click)
+ .button_style("bordered_prominent"),
+ appui.Button("循环播放", action=play_loop),
+ appui.Button("停止当前", action=stop_current),
+ appui.Button("停止全部", action=stop_all, role="destructive"),
+ ]),
+ appui.Section("音量", [
+ appui.Slider(
+ value=state.volume,
+ minimum=0,
+ maximum=1,
+ step=0.1,
+ label="全局音量",
+ on_change=update_volume,
+ ),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("音效")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
play_effect(name, ...) | 播放短音效 → handle |
stop_effect(handle) | 停止指定音效 |
stop_all_effects() | 停止全部音效 |
set_volume(v) / get_volume() | 全局主音量 0.0–1.0 |
set_honors_silent_switch(flag) | 是否遵守 iOS 静音开关 |
Player(path) | 本地长音频播放器 |
play_effect(name, volume=1.0, pitch=1.0, pan=0.0, looping=False)
handle = sound.play_effect("arcade:Coin_1", volume=0.8)
+handle = sound.play_effect("ui:click1", looping=True)
+sound.stop_effect(handle)
+sound.stop_all_effects()
+| 参数 | 说明 |
|---|---|
name | Pythonista 风格名(如 arcade:Coin_1)或文件路径 |
volume | 0.0–1.0 |
pitch | 播放速率 0.5–2.0 |
pan | 立体声 -1.0(左)到 1.0(右) |
looping | 循环播放,需保存 handle 才能停止 |
失败时返回 None。
Player(file_path) — 本地音频文件播放器:
player = sound.Player("audio.m4a")
+player.volume = 0.8
+player.number_of_loops = 0 # 0 不循环,-1 无限循环
+player.play()
+
+print(player.playing, player.duration, player.current_time)
+player.pause()
+player.stop()
+| 属性/方法 | 说明 |
|---|---|
play() / pause() / stop() | 播放控制 |
playing | 是否正在播放 |
duration / current_time | 总时长与当前位置(秒) |
volume / rate | 音量与速率 |
number_of_loops | 循环次数 |
sound.set_volume(0.7)
+sound.set_honors_silent_switch(True) # 遵守静音开关
+set_volume 影响所有音效和 Player。
| 错误写法 | 后果 | 修正 |
|---|---|---|
| 循环音效不保存 handle | 无法停止 | 保存 play_effect 返回值 |
用 sound 朗读文字 | 能力不匹配 | 使用 speech |
| 离开页面不停止循环音 | 后台继续响 | 调用 stop_effect 或 stop_all_effects |
| 改全局音量无 UI 反馈 | 用户不知当前音量 | 界面显示音量值 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+文字转语音和语音列表。
+文字转语音(TTS):朗读文本、暂停/恢复、查询可用语音。
+边界:适合朗读结果、无障碍提示、学习卡片。短音效用 sound;语音识别用 speech_recognition。AppUI 回调里保持 wait=False,否则界面会被阻塞。
+| 项 | 说明 |
|---|---|
| 导入 | import speech |
| 适合做什么 | 朗读文本、多语言播报、无障碍反馈 |
| 调用时机 | 放在按钮回调;长文本必须提供停止入口 |
| 推荐顺序 | 先 stop() 清队列 → say(..., wait=False) → 需要时 pause/resume |
| 语言标签 | BCP-47,如 zh-CN、en-US |
下面脚本用中文朗读,并列出前几个可用语音:
+import speech
+
+speech.say("你好,PythonIDE", language="zh-CN", wait=True)
+
+voices = speech.available_voices()
+print("可用语音数:", len(voices))
+if voices:
+ print("示例:", voices[:3])
+朗读控制放在按钮回调;AppUI 里使用 wait=False,并提供停止/暂停入口。
import appui
+import speech
+
+state = appui.State(
+ text="你好,欢迎使用 PythonIDE",
+ language="zh-CN",
+ status="未朗读",
+ voice_count="—",
+)
+
+
+def update_text(value):
+ state.text = value
+
+
+def update_language(value):
+ state.language = value
+
+
+def refresh_voices():
+ voices = speech.available_voices()
+ state.voice_count = str(len(voices))
+
+
+def speak():
+ text = state.text.strip()
+ if not text:
+ state.status = "请输入要朗读的文本"
+ return
+ speech.stop()
+ speech.say(text, language=state.language, rate=0.5, wait=False)
+ state.status = "朗读中"
+
+
+def pause_speech():
+ speech.pause()
+ state.status = "已暂停"
+
+
+def resume_speech():
+ speech.resume()
+ state.status = "朗读中"
+
+
+def stop_speech():
+ speech.stop()
+ state.status = "已停止"
+
+
+def check_speaking():
+ state.status = "朗读中" if speech.is_speaking() else "空闲"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("文本", [
+ appui.TextField("内容", text=state.text, on_change=update_text),
+ appui.Picker(
+ "语言",
+ selection=state.language,
+ options=["zh-CN", "en-US", "ja-JP"],
+ on_change=update_language,
+ ),
+ ]),
+ appui.Section("控制", [
+ appui.Button("朗读", action=speak)
+ .button_style("bordered_prominent"),
+ appui.Button("暂停", action=pause_speech),
+ appui.Button("继续", action=resume_speech),
+ appui.Button("停止", action=stop_speech, role="destructive"),
+ appui.Button("检查状态", action=check_speaking),
+ appui.Button("刷新语音列表", action=refresh_voices),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("状态", value=state.status),
+ appui.LabeledContent("可用语音", value=state.voice_count),
+ ]),
+ ]).navigation_title("语音朗读")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
say(text, language, rate, pitch, volume, wait) | 朗读文本 |
stop() | 立即停止 |
pause() / resume() | 暂停 / 恢复 |
is_speaking() | 是否正在朗读 → bool |
available_voices() | 可用语音标识列表 |
say(text, language=None, rate=0.5, pitch=1.0, volume=1.0, wait=False)
import speech
+
+speech.say("Hello", language="en-US", wait=False)
+speech.say("你好", language="zh-CN", rate=0.5, volume=1.0)
+| 参数 | 说明 |
|---|---|
text | 要朗读的文本 |
language | BCP-47 标签,如 zh-CN;None 用系统默认 |
rate | 语速 0.0–1.0 |
pitch | 音调 0.5–2.0 |
volume | 音量 0.0–1.0 |
wait | True 阻塞到读完;AppUI 里用 False |
开始新朗读前建议先 stop(),避免多段语音排队。
speech.pause()
+speech.resume()
+speech.stop()
+if speech.is_speaking():
+ print("正在朗读")
+离开页面或切换内容时调用 stop()。
available_voices() — 返回设备可用语音标识列表,适合设置页;不要在 body() 里每次刷新都调用。
voices = speech.available_voices()
+print(len(voices), voices[:5])
+| 错误写法 | 后果 | 修正 |
|---|---|---|
AppUI 里 wait=True | 界面阻塞 | 使用 wait=False |
用 speech 播放音效 | 能力不匹配 | 使用 sound |
新朗读前不 stop() | 多段语音重叠排队 | 先 stop() 再 say() |
| 长文本无停止按钮 | 用户无法打断 | 提供「停止」按钮 |
| 文档 | 用途 |
|---|---|
| sound | 短音效与本地音频 |
| speech_recognition | 语音转文字 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+语音转文字:实时听写和音频文件转写。
+语音转文字(SFSpeechRecognizer):实时麦克风听写,或转写音频文件。
+边界:与 speech(文字→语音)方向相反。实时模式需要语音识别 + 麦克风系统授权;+start()未授权时会报denied。统一 permission 的request()暂不支持speech/microphone弹窗,需在系统设置中开启权限。
| 项 | 说明 |
|---|---|
| 导入 | import speech_recognition as sr |
| 适合做什么 | 语音输入、听写备忘、语音搜索、音频转文字 |
| 调用时机 | start() / recognize_file() 放在按钮回调 |
| 实时模式 | start → 轮询 text() → stop() 拿最终文本 |
| 文件模式 | recognize_file(path) 一次性返回完整稿 |
下面脚本检查能力并开始中文实时听写(需在系统设置中已授权):
+import speech_recognition as sr
+
+print("支持:", sr.is_available())
+print("语言示例:", sr.supported_locales()[:5])
+
+try:
+ sr.start(locale="zh-CN")
+ # 用户说话期间可轮询 sr.text()
+ print("中间结果:", sr.text())
+ final = sr.stop()
+ print("最终结果:", final)
+except sr.SpeechRecognitionError as exc:
+ print("失败:", exc, f"code={exc.code}")
+开始/停止放在按钮回调;用 Timer 轮询中间结果。
import appui
+import speech_recognition as sr
+
+state = appui.State(
+ listening=False,
+ transcript="点击开始听写",
+ status="空闲",
+)
+
+
+def tick():
+ if state.listening:
+ state.transcript = sr.text() or "(正在聆听…)"
+
+
+poll_timer = appui.Timer(interval=0.3, repeats=True, action=tick)
+
+
+def toggle_listening():
+ if state.listening:
+ poll_timer.stop()
+ state.transcript = sr.stop() or "(无识别内容)"
+ state.batch_update(listening=False, status="已停止")
+ return
+
+ try:
+ sr.start(locale="zh-CN", partial=True)
+ state.batch_update(
+ listening=True,
+ status="聆听中",
+ transcript="(正在聆听…)",
+ )
+ poll_timer.start()
+ except sr.SpeechRecognitionError as exc:
+ state.transcript = (
+ f"无法开始: {exc}\n"
+ "请在系统设置中开启「语音识别」和「麦克风」"
+ )
+ state.status = str(exc.code or "error")
+
+
+def cancel_listening():
+ if state.listening:
+ poll_timer.stop()
+ sr.cancel()
+ state.batch_update(
+ listening=False,
+ status="已取消",
+ transcript="听写已取消",
+ )
+
+
+def check_locales():
+ locales = sr.supported_locales()
+ state.status = f"支持 {len(locales)} 种语言"
+
+
+def body():
+ label = "停止听写" if state.listening else "开始听写"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("听写", [
+ appui.Text(state.transcript).foreground_color("secondaryLabel"),
+ appui.LabeledContent("状态", value=state.status),
+ ]),
+ appui.Section("操作", [
+ appui.Button(label, action=toggle_listening)
+ .button_style("bordered_prominent"),
+ appui.Button("取消", action=cancel_listening, role="destructive"),
+ appui.Button("查看支持语言", action=check_locales),
+ ], footer="真机测试最可靠;需语音识别与麦克风权限。"),
+ ]).navigation_title("语音听写")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_available() | 设备/语言是否支持 |
supported_locales() | 支持的 BCP-47 语言列表 |
start(locale, on_device, partial) | 开始实时听写 |
text() / status() | 当前识别文本 / 完整状态 |
stop() | 停止并返回最终文本 |
cancel() | 立即停止并丢弃结果 |
recognize_file(path, locale) | 转写音频文件 |
import speech_recognition as sr
+
+sr.start(locale="zh-CN", on_device=False, partial=True)
+while sr.is_listening():
+ print(sr.text())
+final = sr.stop()
+| 参数 | 说明 |
|---|---|
locale | BCP-47,如 zh-CN、en-US |
on_device | 优先离线识别(设备支持时) |
partial | 是否返回中间结果 |
status() 返回:listening、transcript、is_final、error。
已在听写时再次 start() 会抛 SpeechRecognitionError(code="already_listening")。
只需语音识别权限(不需麦克风):
+text = sr.recognize_file("/path/to/audio.m4a", locale="zh-CN")
+失败时抛出 SpeechRecognitionError,可通过 exc.code 区分:
code | 含义 |
|---|---|
denied | 语音识别未授权 |
already_listening | 重复 start() |
unavailable | 语言或设备不支持 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 start() | 刷新时重复启动 | 放进按钮回调 |
未授权就 start() | denied 异常 | 系统设置开启权限 |
把 permission.request() 当 bool | 判断错误且暂不支持弹窗 | 用 try/except 处理 start() |
高频轮询 text() | 浪费 CPU | 用 Timer(interval=0.3) 节流 |
| 文档 | 用途 |
|---|---|
| speech | 文字转语音(相反方向) |
| audio_recorder | 录音到文件再转写 |
| permission | 权限状态查询 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+SSH 远程命令与 SFTP 文件传输。
+SSH 远程命令与 SFTP 文件传输:连接 Linux/树莓派等设备,执行命令、上传下载文件。
+边界:需要可达的网络与有效凭据(密码或私钥)。不要把密码写进公开脚本;生产环境用 keychain 存凭据。连接和执行放在按钮回调;+session_id用完后务必disconnect()。
| 项 | 说明 |
|---|---|
| 导入 | import ssh |
| 适合做什么 | 远程运维、部署脚本、拉取日志、SFTP 传文件 |
| 调用时机 | 用户点击连接/执行;长命令注意 timeout |
| 推荐顺序 | connect() → execute() / upload() / download() → disconnect() |
| 会话 | connect() 返回 session_id,后续 API 都要带上 |
下面脚本连接主机、执行命令并断开:
+import ssh
+
+HOST = "192.168.1.10" # 换成你可访问的主机
+USER = "pi"
+PASSWORD = "YOUR_PASSWORD" # 勿提交到版本库
+
+sid = None
+try:
+ sid = ssh.connect(HOST, USER, password=PASSWORD)
+ result = ssh.execute(sid, "echo hello")
+ print(result["stdout"].strip())
+ print("exit_code:", result["exit_code"])
+except ssh.SSHError as exc:
+ print("SSH 失败:", exc, f"code={exc.code}")
+finally:
+ if sid:
+ ssh.disconnect(sid)
+使用私钥认证:
+import ssh
+
+sid = ssh.connect(
+ "example.com",
+ "deploy",
+ private_key=open("id_rsa").read(),
+ passphrase="optional",
+)
+连接与命令执行放在按钮回调;凭据存在 State(勿记录到日志)。
import appui
+import ssh
+
+state = appui.State(
+ host="192.168.1.10",
+ user="pi",
+ password="",
+ session_id="",
+ output="—",
+ status="填写主机信息后连接",
+)
+
+
+def do_connect():
+ if not state.host or not state.user:
+ state.status = "请填写主机和用户名"
+ return
+ if state.session_id:
+ state.status = "已连接,请先断开"
+ return
+ try:
+ sid = ssh.connect(
+ state.host,
+ state.user,
+ password=state.password or None,
+ )
+ state.batch_update(
+ session_id=sid or "",
+ status="已连接",
+ output="—",
+ )
+ except ssh.SSHError as exc:
+ state.status = f"连接失败: {exc}"
+
+
+def run_command():
+ if not state.session_id:
+ state.status = "请先连接"
+ return
+ try:
+ result = ssh.execute(state.session_id, "uname -a", timeout=15)
+ text = (result.get("stdout") or "").strip() or "(无输出)"
+ code = result.get("exit_code", -1)
+ state.batch_update(
+ output=text[:500],
+ status=f"exit_code={code}",
+ )
+ except ssh.SSHError as exc:
+ state.status = f"执行失败: {exc}"
+
+
+def do_disconnect():
+ if not state.session_id:
+ return
+ try:
+ ssh.disconnect(state.session_id)
+ state.batch_update(
+ session_id="",
+ output="—",
+ status="已断开",
+ )
+ except ssh.SSHError as exc:
+ state.status = f"断开失败: {exc}"
+
+
+def body():
+ connected = bool(state.session_id)
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("连接", [
+ appui.TextField("主机", text=state.host),
+ appui.TextField("用户名", text=state.user),
+ appui.SecureField("密码", text=state.password),
+ appui.LabeledContent(
+ "会话",
+ value=state.session_id[:8] + "…" if connected else "未连接",
+ ),
+ ]),
+ appui.Section("命令", [
+ appui.Button(
+ "连接" if not connected else "已连接",
+ action=do_connect,
+ ).button_style("bordered_prominent")
+ .disabled(connected),
+ appui.Button("执行 uname -a", action=run_command),
+ appui.Button("断开", action=do_disconnect, role="destructive"),
+ ]),
+ appui.Section("输出", [
+ appui.Text(state.output).font("caption"),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="需同一局域网或可达主机;密码勿写入公开代码。"),
+ ]).navigation_title("SSH")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
connect(host, username, ...) | 建立连接 → session_id |
execute(session_id, command, timeout) | 远程执行 → {stdout, exit_code} |
upload(session_id, local, remote) | SFTP 上传 |
download(session_id, remote, local) | SFTP 下载 |
disconnect(session_id) | 关闭会话 |
SSHError | 操作失败时抛出 |
connect(host, username, password=None, port=22, private_key=None, passphrase=None)
sid = ssh.connect("10.0.0.5", "admin", password="secret")
+sid = ssh.connect("server", "deploy", port=2222, private_key=key_text)
+密码与私钥至少提供一种。
+execute(session_id, command, timeout=30)
result = ssh.execute(sid, "ls -la /tmp")
+print(result["stdout"])
+print(result["exit_code"])
+stdout 为合并后的标准输出字符串;exit_code 为远程命令退出码。
ssh.upload(sid, "/local/file.txt", "/remote/dir/file.txt")
+ssh.download(sid, "/remote/log.txt", "/local/log.txt")
+upload 会上传到 remote_path 的父目录(Bridge 行为)。
disconnect(session_id) — 关闭并移除会话;后续再用同一 ID 会报 invalid_session。
code | 含义 |
|---|---|
invalid_input | 缺少 host/用户名/凭据 |
invalid_session | session_id 无效或已断开 |
unknown_command | Bridge 命令错误 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
| 密码硬编码并提交 Git | 凭据泄露 | 用 keychain 或运行时输入 |
不 disconnect() | 连接泄漏 | finally 里断开 |
在 body() 里 connect() | 刷新时重复连接 | 放进按钮回调 |
| 主机不可达 | 连接超时 | 检查网络/VPN/防火墙 |
| 文档 | 用途 |
|---|---|
| keychain | 安全存储 SSH 凭据 |
| network | HTTP 连通性检查 |
| http_server | 本地文件服务 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+UserDefaults 持久键值存储。
+轻量级键值存储(基于 UserDefaults):保存设置项、开关、计数和小型 JSON 配置。
+边界:适合小数据偏好,不是文件数据库,也不是敏感数据存储。token / 密码用 keychain;大列表、离线缓存用 database。MiniApp 内键名会自动按应用隔离,不同 MiniApp 不会互相覆盖。+
写入后可在「设置 → 应用数据」中直接查看和修改,不需要把经常变化的值写死在代码里。关联小组件会读取同一份数据;界面只显示脚本使用的简单键名,不显示内部隔离前缀。
+| 项 | 说明 |
|---|---|
| 导入 | import storage |
| 适合做什么 | 主题、排序、筛选条件、开关、小型 JSON 配置 |
| 调用时机 | 在 on_change、按钮回调或启动时读写;不要在 body() 里反复写入 |
| 推荐顺序 | 启动时 get(带默认值)→ 用户操作后 set → 需要时 remove / all_keys |
| 键名规范 | 用 settings.theme 这类命名空间,避免不同脚本冲突 |
下面脚本写入并读取字符串、布尔、整数和 JSON:
+import storage
+
+storage.set("settings.theme", "dark")
+storage.set_bool("settings.notifications", True)
+storage.set_int("app.launch_count", storage.get_int("app.launch_count", 0) + 1)
+storage.set_json("profile", {"name": "Ada", "level": 3})
+
+print("主题:", storage.get("settings.theme", "system"))
+print("通知:", storage.get_bool("settings.notifications", False))
+print("启动次数:", storage.get_int("app.launch_count", 0))
+print("资料:", storage.get_json("profile", {}))
+设置项与 State 同步:用户改动时写入 storage,启动时从 storage 恢复。
import appui
+import storage
+
+THEME_OPTIONS = ["跟随系统", "浅色", "深色"]
+
+state = appui.State(
+ theme=storage.get("settings.theme", "跟随系统"),
+ notifications=storage.get_bool("settings.notifications", True),
+ message="设置会自动保存",
+)
+
+
+def set_theme(value):
+ state.theme = value
+ storage.set("settings.theme", value)
+ state.message = f"主题已保存: {value}"
+
+
+def set_notifications(value):
+ state.notifications = value
+ storage.set_bool("settings.notifications", value)
+ state.message = "通知开关已保存" if value else "通知已关闭"
+
+
+def reset_settings():
+ for key in storage.all_keys(prefix="settings."):
+ storage.remove(key)
+ state.batch_update(
+ theme="跟随系统",
+ notifications=True,
+ message="已重置 settings.* 下的所有键",
+ )
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("偏好", [
+ appui.Picker(
+ "主题",
+ selection=state.theme,
+ options=THEME_OPTIONS,
+ on_change=set_theme,
+ ).picker_style("segmented"),
+ appui.Toggle(
+ "允许通知",
+ is_on=state.notifications,
+ on_change=set_notifications,
+ ),
+ ]),
+ appui.Section("维护", [
+ appui.Button("重置设置", action=reset_settings, role="destructive"),
+ appui.Text(state.message).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("设置")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
get(key, default) / set(key, value) | 字符串读写 |
get_bool / set_bool | 布尔读写 |
get_int / set_int | 整数读写 |
get_float / set_float | 浮点数读写 |
get_json / set_json | JSON 对象读写 |
has_key(key) | 判断键是否存在 |
remove(key) | 删除一个键 |
all_keys(prefix="") | 列出键,可按前缀筛选 |
import storage
+
+storage.set("settings.theme", "dark")
+theme = storage.get("settings.theme", "system")
+
+storage.set_bool("settings.done", True)
+done = storage.get_bool("settings.done", False)
+
+storage.set_int("counter", 3)
+storage.set_float("volume", 0.8)
+| API | 返回 | 说明 |
|---|---|---|
get(key, default=None) | str 或 default | 键不存在时返回 default |
set(key, value) | None | 写入字符串或可转字符串的值 |
get_int / set_int | int / None | 计数、索引、步进值 |
get_float / set_float | float / None | 比例、阈值、音量 |
get_bool / set_bool | bool / None | 开关状态 |
注意:读取时总是传 default,让首次运行和清空后仍能正常工作。
+get_json(key, default=None) / set_json(key, value) — 存取 JSON 可序列化对象。
storage.set_json("filters", {"status": "open", "sort": "date"})
+filters = storage.get_json("filters", {})
+保持 JSON 小而稳定;大列表、图片、日志请改用 database 或文件。
+has_key(key) — 判断键是否存在(兼容旧版未隔离的键名)。
remove(key) — 删除键;会同时尝试删除当前命名空间和旧格式键。
all_keys(prefix="") — 列出键,传前缀可清理一组设置:
for key in storage.all_keys(prefix="settings."):
+ storage.remove(key)
+MiniApp 内 all_keys 返回的是去掉应用前缀后的逻辑键名,便于脚本按 settings.* 管理。
| 错误写法 | 后果 | 修正 |
|---|---|---|
把 token 放进 storage | 敏感数据不安全 | 使用 keychain |
多个脚本共用 theme 短键 | 键名互相覆盖 | 使用 settings.theme 命名空间 |
读取不传 default | 首次运行显示 None | storage.get("key", default) |
| 大列表 / 图片塞进 JSON | 慢、占空间 | 改用 database 或文件 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+应用内购买与订阅(StoreKit 2)。
+应用内购买与订阅(StoreKit 2):加载商品、发起购买、恢复购买、查询订阅状态。
+边界:需要 App Store Connect 配置商品 ID,并使用带 In-App Purchase 能力的签名构建;沙盒账号在真机测试。+purchase()会弹出系统购买面板;用户取消返回result: "cancelled",不抛异常。购买、恢复、管理订阅放在按钮回调,不要在body()中调用。
| 项 | 说明 |
|---|---|
| 导入 | import storekit |
| 适合做什么 | 专业版解锁、订阅会员、恢复已购权益 |
| 调用时机 | 用户点击购买/恢复;先 load_products 展示价格 |
| 推荐顺序 | load_products → 展示商品 → purchase → subscription_status |
| 用户取消 | purchase 返回 {"result": "cancelled"},需单独处理 |
下面脚本加载商品元数据并打印价格:
+import storekit
+
+PRODUCT_ID = "com.yourapp.pro" # 换成你在 App Store Connect 创建的商品 ID
+
+try:
+ products = storekit.load_products([PRODUCT_ID])
+ for p in products:
+ print(p["display_name"], p["price"], p["type"])
+except storekit.StoreKitError as exc:
+ print("商品加载失败:", exc, f"code={exc.code}")
+发起购买并处理结果:
+import storekit
+
+result = storekit.purchase(PRODUCT_ID)
+if result.get("result") == "purchased":
+ print("购买成功:", result.get("transaction_id"))
+elif result.get("result") == "cancelled":
+ print("用户取消")
+elif result.get("result") == "pending":
+ print("待处理(如家长批准)")
+else:
+ print("购买未完成:", result)
+加载、购买、恢复都放在按钮回调;取消时保持页面可操作。
+import appui
+import storekit
+
+PRODUCT_ID = "com.yourapp.pro" # 换成你的商品 ID
+
+state = appui.State(
+ product_name="—",
+ product_price="—",
+ product_type="—",
+ subs="—",
+ status="点击加载商品",
+)
+
+
+def load_product():
+ try:
+ products = storekit.load_products([PRODUCT_ID])
+ if not products:
+ state.batch_update(
+ product_name="—",
+ product_price="—",
+ product_type="—",
+ status="未找到商品(检查商品 ID 与签名配置)",
+ )
+ return
+ p = products[0]
+ state.batch_update(
+ product_name=p.get("display_name", "—"),
+ product_price=p.get("price", "—"),
+ product_type=p.get("type", "—"),
+ status="商品已加载",
+ )
+ except storekit.StoreKitError as exc:
+ state.status = f"加载失败: {exc}"
+
+
+def buy_product():
+ try:
+ result = storekit.purchase(PRODUCT_ID) or {}
+ outcome = result.get("result", "unknown")
+ if outcome == "purchased":
+ state.status = f"购买成功 · tx={result.get('transaction_id', '—')}"
+ refresh_subs()
+ elif outcome == "cancelled":
+ state.status = "用户取消购买"
+ elif outcome == "pending":
+ state.status = "购买待处理"
+ else:
+ state.status = f"购买未完成: {outcome}"
+ except storekit.StoreKitError as exc:
+ state.status = f"购买失败: {exc}"
+
+
+def restore_purchases():
+ try:
+ restored = storekit.restore() or []
+ state.status = f"已恢复 {len(restored)} 项" if restored else "无可恢复购买"
+ refresh_subs()
+ except storekit.StoreKitError as exc:
+ state.status = f"恢复失败: {exc}"
+
+
+def refresh_subs():
+ try:
+ subs = storekit.subscription_status() or []
+ if not subs:
+ state.subs = "无活跃订阅"
+ return
+ lines = []
+ for s in subs:
+ pid = s.get("product_id", "?")
+ active = "有效" if s.get("is_active") else "失效"
+ lines.append(f"{pid} · {active}")
+ state.subs = " · ".join(lines)
+ except storekit.StoreKitError as exc:
+ state.subs = f"查询失败: {exc}"
+
+
+def manage_subs():
+ try:
+ storekit.show_manage_subscriptions()
+ state.status = "已打开系统订阅管理"
+ except storekit.StoreKitError as exc:
+ state.status = f"无法打开: {exc}"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("商品", [
+ appui.LabeledContent("名称", value=state.product_name),
+ appui.LabeledContent("价格", value=state.product_price),
+ appui.LabeledContent("类型", value=state.product_type),
+ ]),
+ appui.Section("订阅状态", [
+ appui.Text(state.subs).font("caption"),
+ ]),
+ appui.Section("操作", [
+ appui.Button("加载商品", action=load_product),
+ appui.Button("购买", action=buy_product)
+ .button_style("bordered_prominent"),
+ appui.Button("恢复购买", action=restore_purchases),
+ appui.Button("刷新订阅", action=refresh_subs),
+ appui.Button("管理订阅", action=manage_subs),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ], footer="需真机 + 沙盒账号 + 有效商品 ID。"),
+ ]).navigation_title("内购")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
load_products(identifiers) | 加载商品元数据列表 |
purchase(product_id) | 发起购买 → {result, transaction_id} |
restore() | 恢复购买 → 商品 ID 列表 |
subscription_status() | 当前订阅权益列表 |
show_manage_subscriptions() | 打开系统订阅管理页 |
StoreKitError | Bridge/网络/校验失败时抛出 |
load_products(identifiers) -> list[dict]
products = storekit.load_products(["com.app.monthly", "com.app.yearly"])
+每个商品字典:
+| 字段 | 说明 |
|---|---|
id | 商品 ID |
display_name | 显示名称 |
description | 描述 |
price | 本地化价格字符串(如 ¥12.00) |
type | subscription / non_consumable / consumable |
列表为空表示 ID 无效或未在 App Store Connect 配置。
+purchase(product_id) -> dict
result = storekit.purchase("com.yourapp.pro")
+result | 含义 |
|---|---|
purchased | 购买成功;transaction_id 有值 |
cancelled | 用户取消(非异常) |
pending | 待处理(如 Ask to Buy) |
failed | 其他失败 |
成功交易会在 Bridge 内 finish();无需 Python 侧再调完成接口。
restore() -> list[str] — 遍历当前有效权益,返回已恢复的商品 ID 列表。
subscription_status() -> list[dict]
for sub in storekit.subscription_status():
+ print(sub["product_id"], sub["is_active"], sub.get("expiration_date"))
+| 字段 | 说明 |
|---|---|
product_id | 订阅商品 ID |
is_active | 是否仍有效 |
expiration_date | 过期时间 Unix 时间戳,或 null |
show_manage_subscriptions() — 打开系统「管理订阅」界面(iOS 15+)。
code | 含义 |
|---|---|
invalid_input | 缺少商品 ID |
not_found | 商品不存在 |
verification_failed | 交易校验失败 |
timeout | 异步操作超时 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 purchase() | 刷新时重复弹购买 | 放进按钮回调 |
| 把用户取消当异常 | 误判为崩溃 | 检查 result == "cancelled" |
| 商品 ID 写错 | load_products 返回空 | 对照 App Store Connect |
| 模拟器未登录沙盒 | 加载/购买失败 | 真机 + 沙盒测试账号 |
| 未配置 IAP 能力 | Bridge 不可用 | 签名构建开启 In-App Purchase |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+设备端文本翻译。
+设备端文本翻译(Apple Translation 框架):离线翻译、查询支持语言。
+边界:需 iOS 支持 Translation 框架;首次翻译可能下载语言包。仅文本翻译,不含实时语音同传。+
| 项 | 说明 |
|---|---|
| 导入 | import translation |
| 适合做什么 | 笔记翻译、多语言 UI 文案、离线翻译 |
| 调用时机 | translate() 放在按钮回调 |
| 推荐顺序 | is_available() → supported_languages() → translate() |
| 语言码 | BCP-47,如 en、zh-Hans、ja |
import translation
+
+if not translation.is_available():
+ print("Translation 不可用")
+else:
+ print(translation.supported_languages())
+ text = translation.translate("你好,世界", target="en")
+ print(text)
+指定源语言:
+import translation
+
+text = translation.translate("Bonjour", target="zh-Hans", source="fr")
+print(text)
+import appui
+import translation
+
+state = appui.State(
+ available="—",
+ source="你好,世界",
+ target_lang="en",
+ result="—",
+)
+
+
+def refresh_available():
+ state.available = "是" if translation.is_available() else "否"
+
+
+def do_translate():
+ refresh_available()
+ if state.available != "是":
+ state.result = "Translation 不可用"
+ return
+
+ try:
+ state.result = translation.translate(state.source, target=state.target_lang)
+ except translation.TranslationError as exc:
+ state.result = str(exc)
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("翻译", [
+ appui.TextEditor(text=state.source).frame(min_height=80),
+ appui.TextField("目标语言", text=state.target_lang),
+ appui.Button("翻译", action=do_translate)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("可用", value=state.available),
+ appui.Text(state.result).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("翻译")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_available() | Translation 是否可用 → bool |
supported_languages() | 支持的语言码列表 |
translate(text, target='en', source=None) | 翻译文本 → str |
TranslationError | 翻译失败异常 |
translate(text, target='en', source=None) — source 省略时自动检测源语言。
translation.translate("你好", target="en")
+translation.translate("Hello", target="zh-Hans", source="en")
+| 错误写法 | 后果 | 修正 |
|---|---|---|
未检查 is_available() | 抛 TranslationError | 先判断可用性与系统版本 |
| 无效语言码 | 翻译失败 | 用 supported_languages() 核对 |
在 body() 里自动翻译 | 每次刷新重复请求 | 放进按钮回调 |
| 超长文本一次翻译 | 可能超时或截断 | 分段翻译 |
| 文档 | 用途 |
|---|---|
| foundation_models | 端侧文本生成(iOS 26+) |
| speech | 语音合成 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+Tk-free 原生 turtle 绘图,兼容教材脚本、填充、多画笔和事件。
+Tk-free native turtle graphics for teaching scripts and classic turtle programs.
运行示例后会出现原生 turtle 画布,绘制填充五角星并等待点击关闭。
+turtle 是 App 内置模块,用户直接写 import turtle 或 from turtle import *。使用原生绘图能力,不依赖 _tkinter,适合教材代码、几何绘图、填充图形、多画笔、点击/拖拽/键盘/定时器交互。
import turtle| 情况 | 应该怎么处理 |
|---|---|
| 画布不刷新 | 静态复杂图使用 tracer(0) 后手动调用 update()。 |
| 教材代码依赖 Tk 对象 | getcanvas() 只提供常见兼容方法;需要完整桌面 Tk 能力时应改写。 |
| 动画过快或卡顿 | 调整 speed()、delay()、ontimer(),避免超大循环阻塞主线程。 |
| 需要游戏物理或精灵 | 使用 scene,不要在 turtle 中手写完整游戏循环。 |
turtle 包。getcanvas() 只提供常见兼容占位方法。tracer(0) 后 update();动画用 speed()、ontimer() 或普通 turtle 循环。import turtle
+
+screen = turtle.Screen()
+screen.title("Turtle Demo")
+screen.bgcolor("black")
+screen.tracer(0)
+
+pen = turtle.Turtle()
+pen.shape("turtle")
+pen.speed(0)
+pen.pensize(2)
+pen.color("cyan", "magenta")
+
+pen.begin_fill()
+for _ in range(5):
+ pen.forward(140)
+ pen.right(144)
+pen.end_fill()
+
+pen.penup()
+pen.goto(0, -160)
+pen.color("white")
+pen.write("Native turtle", align="center", font=("Arial", 18, "bold"))
+
+screen.update()
+screen.exitonclick()
+| 类型 | API | 说明 |
|---|---|---|
| class | Screen() | 获取全局 turtle 画布。 |
| class | Turtle() | 创建画笔,支持多 turtle。 |
| motion | forward(), backward(), goto(), setx(), sety(), home() | 移动与定位。 |
| angle | left(), right(), setheading(), heading(), degrees(), radians(), mode() | 角度、方向和 logo 模式。 |
| drawing | pensize(), pencolor(), fillcolor(), color(), dot(), circle() | 线条、颜色、圆弧和点。 |
| fill | begin_fill(), end_fill(), filling() | 填充路径。 |
| cursor | shape(), shapesize(), hideturtle(), showturtle(), stamp() | 光标、印章和自定义形状。 |
| screen | title(), bgcolor(), setup(), screensize(), setworldcoordinates() | 画布配置。 |
| render | tracer(), update(), speed(), delay() | 刷新和动画速度。 |
| events | onclick(), ondrag(), onrelease(), onkey(), ontimer(), listen() | 触摸、拖拽、键盘和定时器。 |
| dialogs | textinput(), numinput() | 系统输入弹窗。 |
| lifecycle | done(), mainloop(), exitonclick(), bye(), save() | 等待、关闭和 PNG 保存。 |
Screen, Turtle, RawTurtle, TurtleScreen, Pen, Vec2D, Shape, TurtleGraphicsError, forward, fd, backward, back, bk, right, rt, left, lt, goto, setpos, setposition, setx, sety, setheading, seth, home, circle, dot, stamp, clearstamp, clearstamps, undo, speed, position, pos, towards, xcor, ycor, heading, distance, degrees, radians, pendown, pd, down, penup, pu, up, isdown, pensize, width, pencolor, fillcolor, color, begin_fill, end_fill, filling, reset, clear, write, hideturtle, ht, showturtle, st, isvisible, shape, shapesize, turtlesize, resizemode, tilt, tiltangle, settiltangle, bgcolor, bgpic, title, setup, screensize, setworldcoordinates, window_width, window_height, tracer, update, delay, listen, onkey, onkeypress, onkeyrelease, onclick, ondrag, onrelease, onscreenclick, ontimer, textinput, numinput, done, mainloop, exitonclick, bye, getcanvas, colormode, register_shape, addshape, mode, save.
register_shape() / addshape() 支持内置形状、polygon、compound,以及图片文件路径占位。postscript() 在 iOS 上会保存 PNG 路径,不生成 PostScript 文本。
按类族、工具函数和常量族快速查 ui 公开名称。
+按类族、工具函数和常量族快速查 ui 公开名称。
+示例会展示 ui.View、ui.Button 和 present() 的最小组合;索引用来快速定位类、函数、常量和常见方法族。
import ui
+
+
+def tapped(sender):
+ sender.title = "Tapped"
+
+
+view = ui.View(frame=(0, 0, 320, 220))
+view.name = "ui demo"
+
+button = ui.Button(frame=(40, 88, 240, 44), title="Tap")
+button.action = tapped
+
+view.add_subview(button)
+view.present("sheet")
+| 你要做什么 | 先看 |
|---|---|
| 打开一个简单命令式页面 | View、Button、present |
| 做标签、输入、开关、滑块 | Label、TextField、TextView、Switch、Slider |
| 做表格或滚动内容 | TableView、ListDataSource、ScrollView |
| 做手势交互 | TapGestureRecognizer、PanGestureRecognizer、LongPressGestureRecognizer |
| 做离屏绘图或自绘视图 | ImageContext、CanvasView、Path、draw_string |
| 处理颜色、字体和尺寸 | Color、Font、Rect、parse_color、measure_string |
| 分组 | 名称 |
|---|---|
| 函数 | parse_color, parse_font, begin_image_context, end_image_context, get_image_from_current_context, set_color, fill_rect, stroke_rect, draw_string, get_screen_size, get_window_size, get_keyboard_frame, get_ui_style, measure_string, delay, cancel_delays, in_background, animate, convert_point, convert_rect, set_blend_mode, set_shadow, load_view, load_view_str, close_all, dump_view |
| 类 | Color, Point, Size, Rect, Transform, Touch, GestureSender, GestureRecognizer, TapGestureRecognizer, PanGestureRecognizer, PinchGestureRecognizer, SwipeGestureRecognizer, LongPressGestureRecognizer, Font, View, ButtonItem, Button, Label, TextField, TextView, ImageView, WebView, ActivityIndicator, TableView, ListDataSource, TableViewCell, Switch, Slider, SegmentedControl, DatePicker, ProgressView, Stepper, NavigationView, ScrollView, Path, GState, ImageContext, Image, CanvasView, autoreleasepool |
| 方法 | Color.rgb, Color.hex, Color.named, Point.as_tuple, Rect.min_x, Rect.min_y, Rect.max_x, Rect.max_y, Rect.origin, Rect.size, Rect.center, Rect.as_tuple, Rect.contains_point, Rect.contains_rect, Rect.inset, Rect.intersection, Rect.intersects, Rect.translate, Rect.union, Transform.rotation, Transform.scale, Transform.translation, Transform.concat, Transform.invert, GestureRecognizer.action, GestureRecognizer.action, TapGestureRecognizer.number_of_taps_required, TapGestureRecognizer.number_of_taps_required, TapGestureRecognizer.number_of_touches_required, TapGestureRecognizer.number_of_touches_required, SwipeGestureRecognizer.direction, SwipeGestureRecognizer.direction, LongPressGestureRecognizer.minimum_press_duration, LongPressGestureRecognizer.minimum_press_duration, Font.system_font_of_size, Font.bold_system_font_of_size, Font.italic_system_font_of_size, View.frame, View.frame, View.x, View.x, View.y, View.y, View.width, View.width, View.height, View.height, View.background_color, View.background_color, View.alpha, View.alpha, View.hidden, View.hidden, View.corner_radius, View.corner_radius, View.border_width, View.border_width, View.title, View.title, View.content_mode, View.content_mode, View.touch_enabled, View.touch_enabled, View.multitouch_enabled, View.multitouch_enabled, View.transform, View.transform, View.bg_color, View.bg_color, View.tint_color, View.tint_color, View.left_button_items, View.left_button_items, View.right_button_items, View.right_button_items, View.border_color, View.border_color, View.flex, View.flex, View.name, ... (365 total) |
| 常量 | ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT, ALIGN_JUSTIFIED, ALIGN_NATURAL, LB_WORD_WRAP, LB_CHAR_WRAP, LB_CLIP, LB_TRUNCATE_HEAD, LB_TRUNCATE_TAIL, LB_TRUNCATE_MIDDLE, KEYBOARD_DEFAULT, KEYBOARD_ASCII, KEYBOARD_NUMBERS, KEYBOARD_URL, KEYBOARD_NUMBER_PAD, KEYBOARD_PHONE_PAD, KEYBOARD_NAME_PHONE_PAD, KEYBOARD_EMAIL, KEYBOARD_DECIMAL_PAD, KEYBOARD_TWITTER, KEYBOARD_WEB_SEARCH, BLEND_NORMAL, BLEND_MULTIPLY, BLEND_SCREEN, BLEND_OVERLAY, BLEND_DARKEN, BLEND_LIGHTEN, BLEND_COLOR_DODGE, BLEND_COLOR_BURN, BLEND_SOFT_LIGHT, BLEND_HARD_LIGHT, BLEND_DIFFERENCE, BLEND_EXCLUSION, BLEND_HUE, BLEND_SATURATION, BLEND_COLOR, BLEND_LUMINOSITY, BLEND_CLEAR, BLEND_COPY, BLEND_SOURCE_IN, BLEND_SOURCE_OUT, BLEND_SOURCE_ATOP, BLEND_DESTINATION_OVER, BLEND_DESTINATION_IN, BLEND_DESTINATION_OUT, BLEND_DESTINATION_ATOP, BLEND_XOR, BLEND_PLUS_DARKER, BLEND_PLUS_LIGHTER, LINE_CAP_BUTT, LINE_CAP_ROUND, LINE_CAP_SQUARE, LINE_JOIN_MITER, LINE_JOIN_ROUND, LINE_JOIN_BEVEL, RENDERING_MODE_AUTOMATIC, RENDERING_MODE_ORIGINAL, RENDERING_MODE_TEMPLATE, CONTENT_SCALE_TO_FILL, CONTENT_SCALE_ASPECT_FIT, CONTENT_SCALE_ASPECT_FILL, CONTENT_REDRAW, CONTENT_CENTER, CONTENT_TOP, CONTENT_BOTTOM, CONTENT_LEFT, CONTENT_RIGHT, CONTENT_TOP_LEFT, CONTENT_TOP_RIGHT, CONTENT_BOTTOM_LEFT, CONTENT_BOTTOM_RIGHT, DATE_PICKER_MODE_TIME, DATE_PICKER_MODE_DATE, DATE_PICKER_MODE_DATE_AND_TIME, DATE_PICKER_MODE_COUNTDOWN, ACTIVITY_INDICATOR_STYLE_GRAY, ACTIVITY_INDICATOR_STYLE_WHITE, ACTIVITY_INDICATOR_STYLE_WHITE_LARGE |
| 情况 | 应该怎么处理 |
|---|---|
| 页面空白 | 确认已创建根 ui.View,设置了非零 frame,并调用 present(...)。 |
| 点击无反应 | 回调要赋函数对象,例如 button.action = tapped,不要写成 tapped()。 |
| 布局错位 | 检查 frame、flex 和父视图尺寸;复杂响应式页面优先改用 appui。 |
| 输入或列表状态难维护 | 把新页面迁到 appui.State、Form、List 和 NavigationStack。 |
集中列出 ui 构造签名、回调入口、工具函数和常量提示。
+ui 是 Pythonista 风格的命令式原生 UI。它适合迁移 Pythonista 风格脚本、手动 frame 布局和需要直接控制 UIKit 风格视图的场景。新 MiniApp 的表单、列表、导航和设置页优先用 appui。
示例会展示命令式控件、sender 回调和自定义绘图;API 表用于确认常用控件、布局属性和工具函数。
+import ui
+
+
+def tapped(sender):
+ sender.title = "Tapped"
+
+
+view = ui.View(frame=(0, 0, 320, 220))
+view.name = "ui demo"
+
+button = ui.Button(frame=(40, 88, 240, 44), title="Tap")
+button.action = tapped
+
+view.add_subview(button)
+view.present("sheet")
+| API | 用途 |
|---|---|
View(frame=...) | 所有 ui 组件的基类 |
view.frame | (x, y, width, height) |
view.flex | Pythonista 风格自动伸缩规则 |
view.add_subview(child) | 添加子视图 |
view.remove_from_superview() | 从父视图移除 |
view.present(style="sheet") | 展示视图 |
view.close() | 关闭视图 |
view.layout() | 子类中重写,处理尺寸变化 |
view.draw() | 子类中重写,自定义绘制 |
| API | 用途 |
|---|---|
Button(frame=..., title=...) | 按钮,回调用 button.action = func |
Label(frame=..., text=...) | 文本标签 |
TextField(frame=..., placeholder=...) | 单行输入 |
TextView(frame=..., text=...) | 多行输入 |
ImageView(frame=...) | 图片显示 |
WebView(frame=...) | 网页显示 |
TableView(frame=...) | 表格列表 |
Switch(frame=...) | 开关 |
Slider(frame=...) | 滑块 |
SegmentedControl(frame=...) | 分段控件 |
DatePicker(frame=...) | 日期选择 |
ProgressView(frame=...) | 进度条 |
NavigationView(view) | 命令式导航容器 |
ScrollView(frame=...) | 滚动容器 |
ui 的回调通常接收 sender。按钮点击后修改 sender 或其它已保存的视图引用。
import ui
+
+
+def slider_changed(sender):
+ label.text = f"{sender.value:.0%}"
+
+
+view = ui.View(frame=(0, 0, 320, 180))
+label = ui.Label(frame=(40, 40, 240, 32), text="50%")
+slider = ui.Slider(frame=(40, 88, 240, 32))
+slider.value = 0.5
+slider.action = slider_changed
+
+view.add_subview(label)
+view.add_subview(slider)
+view.present("sheet")
+自定义绘图通过重写 draw(),或使用离屏图片上下文。
import ui
+
+
+class Badge(ui.View):
+ def draw(self):
+ ui.set_color("#3478F6")
+ ui.fill_rect(0, 0, self.width, self.height)
+ ui.draw_string(
+ "42",
+ rect=(0, 10, self.width, 40),
+ font=("<system-bold>", 28),
+ color="white",
+ alignment=ui.ALIGN_CENTER,
+ )
+
+
+badge = Badge(frame=(0, 0, 96, 64))
+badge.present("sheet")
+| API | 用途 |
|---|---|
parse_color(value) | 解析颜色 |
parse_font(value) | 解析字体 |
measure_string(text, ...) | 测量文本尺寸 |
delay(seconds, func) | 延迟执行 |
cancel_delays() | 取消 delay |
in_background(func) | 后台线程装饰器 |
animate(animation, duration=...) | 执行动画 |
get_screen_size() | 屏幕尺寸 |
get_keyboard_frame() | 键盘 frame |
load_view(...) | 加载 .pyui |
dump_view(view) | 调试视图树 |
| 情况 | 应该怎么处理 |
|---|---|
| 页面空白 | 确认已创建根 ui.View,设置了非零 frame,并调用 present(...)。 |
| 点击无反应 | 回调要赋函数对象,例如 button.action = tapped,不要写成 tapped()。 |
| 布局错位 | 检查 frame、flex 和父视图尺寸;复杂响应式页面优先改用 appui。 |
| 输入或列表状态难维护 | 把新页面迁到 appui.State、Form、List 和 NavigationStack。 |
ui 页面必须显式设置 frame 或 flex。ui 组件和 appui 组件放进同一棵界面树。appui。button.action = tapped,不要写成 button.action = tapped()。Pythonista 风格原生 UI 组件与交互。
+Pythonista 风格原生 UI 组件与交互。
+运行示例后会出现一个手动 frame 布局的原生 sheet,点击按钮会直接修改按钮标题。
+Pythonista 风格命令式 UI,使用 frame/flex 手动布局;新 MiniApp 默认优先用 appui。
+| 需求 | 首选 | 原因 |
|---|---|---|
迁移 Pythonista 风格的 ui 脚本 | ui | 代码通常已经按 View、frame、action(sender) 组织。 |
| 新建设置页、表单、列表或多页面工具 | appui | 状态、导航、输入和动态列表更容易维护。 |
| 需要 Sprite、触摸循环、物理或逐帧绘制 | scene | 场景生命周期和渲染循环更适合动画与小游戏。 |
| 只需要桌面小组件 | widget | 小组件有独立的 WidgetKit 约束和刷新模型。 |
| 只想生成一张图片或画布 | ui.ImageContext / CanvasView | 不需要完整页面时,离屏绘图更轻。 |
import ui
+
+def tapped(sender):
+ sender.title = "Tapped"
+
+view = ui.View(frame=(0, 0, 320, 220))
+view.name = "ui demo"
+button = ui.Button(frame=(40, 80, 240, 44), title="Tap")
+button.action = tapped
+view.add_subview(button)
+view.present("sheet")
+ui.View(frame=...)。add_subview(...) 挂到父视图。frame 设定初始位置,用 flex 处理简单的横竖屏变化。sender,在回调里修改标题、文本、颜色或子视图。present(...),不要在导入模块时弹出多个页面。appui。| 类型 | API | 签名 | 说明 |
|---|---|---|---|
| class | Color | Color(r: float, g: float, b: float, a: float=1.0) -> None | 颜色类,兼容Pythonista的ui.Color |
| class | Point | Point(x: Union[float, Sequence[float]]=0.0, y: float=0.0) -> None | 二维点,兼容 Pythonista 的 ui.Point |
| class | Size | Size(w: Union[float, Sequence[float]]=0.0, h: float=0.0) -> None | 尺寸,兼容 Pythonista 的 ui.Size |
| class | Rect | Rect(x: Union[float, Sequence[float]]=0.0, y: float=0.0, w: float=0.0, h: float=0.0) -> None | 矩形,兼容 Pythonista 的 ui.Rect |
| class | Transform | Transform(a: float=1.0, b: float=0.0, c: float=0.0, d: float=1.0, tx: float=0.0, ty: float=0.0) -> None | 2D 仿射变换,兼容 Pythonista 的 ui.Transform |
| class | Touch | Touch(location: Sequence[float]=..., prev_location: Optional[Sequence[float]]=..., phase: str=..., touch_id: int=..., timestamp: Union[int, float]=...) -> None | 触摸事件,兼容 Pythonista 的 ui.Touch(简化实现) |
| class | GestureSender | GestureSender(view: Optional[View]=..., state: int=..., location: Sequence[float]=..., translation: Sequence[float]=..., scale: float=..., direction: int=...) -> None | 手势识别器回调的 sender 对象,兼容 Pythonista(state, location, translation, scale, direction) |
| class | GestureRecognizer | GestureRecognizer(gesture_id: str) -> None | 手势识别器基类,兼容 Pythonista(action(sender)) |
| class | TapGestureRecognizer | TapGestureRecognizer() -> None | 点击手势(Pythonista 兼容) |
| class | PanGestureRecognizer | PanGestureRecognizer() -> None | 拖动手势(Pythonista 兼容) |
| class | PinchGestureRecognizer | PinchGestureRecognizer() -> None | 捏合手势(Pythonista 兼容) |
| class | SwipeGestureRecognizer | SwipeGestureRecognizer() -> None | 滑动手势(Pythonista 兼容),direction: LEFT=1, RIGHT=2, UP=3, DOWN=4 |
| class | LongPressGestureRecognizer | LongPressGestureRecognizer() -> None | 长按手势(Pythonista 兼容) |
| function | parse_color | parse_color(color: Any) -> Tuple[float, float, float, float] | 解析颜色参数为RGBA元组,支持语义色(systemBackground 等)自适应深色/浅色模式 |
| class | Font | Font(name: str=..., size: float=...) -> None | 字体类,兼容Pythonista的ui.Font |
| function | parse_font | parse_font(font: Any) -> Tuple[str, float] | 解析字体参数为(name, size)元组 |
| class | View | View(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 视图基类,所有UI组件的父类 |
| class | ButtonItem | ButtonItem(title: str=..., image: Any=..., action: Optional[Callable[..., Any]]=..., **kwargs: Any) -> None | 导航栏按钮项,用于 left_button_items / right_button_items |
| class | Button | Button(frame: Optional[_Frame]=..., title: str=..., **kwargs: Any) -> None | 按钮组件 |
| class | Label | Label(frame: Optional[_Frame]=..., text: str=..., **kwargs: Any) -> None | 文本标签组件 |
| class | TextField | TextField(frame: Optional[_Frame]=..., text: str=..., placeholder: str=..., **kwargs: Any) -> None | 单行文本输入组件 |
| class | TextView | TextView(frame: Optional[_Frame]=..., text: str=..., **kwargs: Any) -> None | 多行文本输入组件 |
| class | ImageView | ImageView(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 图片显示组件 |
| class | WebView | WebView(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 网页视图 |
| class | ActivityIndicator | ActivityIndicator(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 加载指示器 |
| class | TableView | TableView(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 表格视图 |
| class | ListDataSource | ListDataSource() -> None | TableView 数据源协议 子类实现 tableview_number_of_sections, tableview_number_of_rows, tableview_cell_for_row 等 |
| class | TableViewCell | TableViewCell(style: str=..., **kwargs: Any) -> None | 表格行单元,兼容 Pythonista |
| class | Switch | Switch(frame: Optional[_Frame]=..., value: bool=..., **kwargs: Any) -> None | 开关组件 |
| class | Slider | Slider(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 滑块组件 |
| class | SegmentedControl | SegmentedControl(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 分段控制器 |
| class | DatePicker | DatePicker(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 日期时间选择器 |
| class | ProgressView | ProgressView(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 进度条组件,兼容 Pythonista 的 ui.ProgressView |
| class | Stepper | Stepper(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 步进器组件,兼容 Pythonista 的 ui.Stepper |
| class | NavigationView | NavigationView(view: Optional[View]=..., **kwargs: Any) -> None | 导航视图,支持 push_view / pop_view 兼容 Pythonista 的 ui.NavigationView |
| class | ScrollView | ScrollView(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 滚动视图,兼容 Pythonista 的 ui.ScrollView 可容纳超出可见区域的子视图并支持滚动 |
| class | Path | Path() -> None | 路径类,兼容 Pythonista 的 ui.Path 在 ImageContext 内使用 |
| class | GState | GState() | with ui.GState(): 保存和恢复绘图状态 |
| class | ImageContext | ImageContext(width: float, height: float) -> None | 离屏绘图上下文,兼容 Pythonista 的 ui.ImageContext with ui.ImageContext(100, 100) as ctx: ui.set_color('red') ui.fill_rect(0, 0, 50, 50) img = ctx.get_image() # 可在 with 块内调用,会立即结束上下文并返回图像 |
| class | Image | Image(data: bytes=...) -> None | 图像类,兼容 Pythonista 的 ui.Image |
| function | begin_image_context | begin_image_context(width: float, height: float) -> None | Start an image drawing context (Pythonista-compatible standalone function). |
| function | end_image_context | end_image_context() -> None | End the current image drawing context. |
| function | get_image_from_current_context | get_image_from_current_context() -> Image | Capture the current drawing context as an Image. Call before end_image_context() to get the drawn image, or after end_image_context() to retrieve the last captured result. |
| function | set_color | set_color(color: _ColorLike) -> None | 设置当前绘图颜色 |
| function | fill_rect | fill_rect(x: float, y: float, w: float, h: float) -> None | 填充矩形 |
| function | stroke_rect | stroke_rect(x: float, y: float, w: float, h: float) -> None | 描边矩形 |
| function | draw_string | draw_string(text: str, rect: Any=..., font: _FontLike=..., color: _ColorLike=..., alignment: int=..., line_break_mode: int=...) -> None | 绘制字符串 rect: (x, y, w, h) 元组 |
| class | CanvasView | CanvasView(frame: Optional[_Frame]=..., **kwargs: Any) -> None | 画布视图,通过 render(draw_func) 自绘内容 兼容 Pythonista 的 ui 画布用法 |
| function | get_screen_size | get_screen_size() -> Tuple[float, float] | 获取屏幕尺寸 |
| function | get_window_size | get_window_size() -> Size | 获取窗口尺寸(同 get_screen_size) |
| function | get_keyboard_frame | get_keyboard_frame() -> Tuple[float, float, float, float] | 获取键盘在屏幕上的 frame (x, y, width, height);未显示时为 (0, 0, 0, 0)(Pythonista 兼容) |
| function | get_ui_style | get_ui_style() -> str | 获取当前 UI 风格:'light' 或 'dark'(Pythonista 兼容) |
| function | measure_string | measure_string(text: str, max_width: float=..., font: _FontLike=..., alignment: int=..., line_break_mode: int=...) -> Tuple[float, float] | 测量字符串尺寸,返回 (width, height) |
| function | delay | delay(seconds: float, func: Callable[..., Any]) -> None | 延迟 seconds 秒后执行 func(Pythonista 兼容)。可用 cancel_delays() 取消未执行的 delay。 |
| function | cancel_delays | cancel_delays() -> None | 取消所有通过 delay() 调度且尚未执行的回调(Pythonista 兼容) |
| function | in_background | in_background(fn: Callable[..., Any]) -> Callable[..., None] | 装饰器:在后台线程执行函数 |
| function | animate | animate(animation: Callable[[], Any], duration: float=..., delay_sec: float=..., completion: Optional[Callable[[], Any]]=...) -> None | 执行动画。按指定延迟执行动画回调,并在 duration 秒后执行 completion。注意:回调异步执行,适合轻量属性更新。 |
| function | convert_point | convert_point(point: Any=..., from_view: Optional[View]=..., to_view: Optional[View]=...) -> Point | 坐标转换。from_view/to_view 为 None 表示窗口坐标系 |
| function | convert_rect | convert_rect(rect: Any=..., from_view: Optional[View]=..., to_view: Optional[View]=...) -> Rect | 矩形坐标转换 |
| function | set_blend_mode | set_blend_mode(mode: int) -> None | 设置绘图混合模式 (BLEND_* 常量) |
| function | set_shadow | set_shadow(color: Optional[_ColorLike], offset_x: float, offset_y: float, blur_radius: float) -> None | 设置阴影。color 为 None 时清除阴影 |
| class | autoreleasepool | autoreleasepool() | autoreleasepool 上下文(Python 中为 no-op) |
| function | load_view | load_view(name: Optional[str]=..., bindings: Optional[Dict[str, Any]]=..., stackframe: Any=..., verbose: bool=...) -> View | 加载 .pyui 文件。 name: 文件名(不含扩展名会自动加 .pyui) bindings: 名称到可调用对象的映射,用于绑定 action 等 |
| function | load_view_str | load_view_str(json_str: str, bindings: Optional[Dict[str, Any]]=..., stackframe: Any=..., verbose: bool=...) -> View | 从 JSON 字符串加载视图 |
| function | close_all | close_all(animated: bool=...) -> None | 关闭所有已展示的 UI 界面(Pythonista 兼容) |
| function | dump_view | dump_view(view: View, indent: int=...) -> None | 调试输出视图树(Pythonista 兼容) |
| 情况 | 应该怎么处理 |
|---|---|
| 页面空白 | 确认已创建根 ui.View,设置了非零 frame,并调用 present(...)。 |
| 点击无反应 | 回调要赋函数对象,例如 button.action = tapped,不要写成 tapped()。 |
| 布局错位 | 检查 frame、flex 和父视图尺寸;复杂响应式页面优先改用 appui。 |
| 输入或列表状态难维护 | 把新页面迁到 appui.State、Form、List 和 NavigationStack。 |
| 检查项 | 合格标准 |
|---|---|
| 根视图 | 有非零 frame,并设置清晰的 name 或标题。 |
| 子视图 | 所有控件都已加入父视图,位置不依赖魔法数字之外的隐式状态。 |
| 回调 | action 传函数对象,不写成 action=handler()。 |
| 布局 | 横竖屏或不同窗口尺寸下,关键控件不会移出可见区域。 |
| 边界 | 新功能不混用 ui 和 appui 组件树。 |
摄像头录像保存为文件。
+摄像头录像:把视频保存为 .mov 文件到 App Documents 目录。
边界:需要相机权限;统一 permission 的+request("camera")暂不支持弹窗,在start()上用try/except VideoRecorderError处理失败。拍照见 camera / photos。录像只能放在用户操作回调里,不要在body()中调用。
| 项 | 说明 |
|---|---|
| 导入 | import video_recorder |
| 适合做什么 | 短视频采集、现场记录、后续 media_composer 剪辑 |
| 调用时机 | start() / stop() 放在按钮回调 |
| 推荐顺序 | start() → 轮询 status() → stop() 拿文件信息 |
| 有状态 | 支持 cancel() 丢弃半成品;不可重复 start() |
下面脚本开始录像、等待几秒后停止并打印文件信息:
+import time
+import video_recorder
+
+try:
+ path = video_recorder.start()
+ print("录像中:", path)
+ time.sleep(3)
+ info = video_recorder.stop()
+ if info:
+ print("已保存:", info["path"])
+ print("时长:", info["duration"], "秒")
+ print("大小:", info["size"], "字节")
+except video_recorder.VideoRecorderError as exc:
+ print("录像失败:", exc, f"code={exc.code}")
+开始/停止放在按钮回调;用 Timer 轮询录制时长。
import appui
+import video_recorder
+
+state = appui.State(
+ recording=False,
+ path="",
+ seconds=0.0,
+ status="点击开始录像",
+)
+
+
+def tick():
+ if not state.recording:
+ return
+ st = video_recorder.status()
+ state.seconds = float(st.get("duration", 0.0))
+
+
+poll_timer = appui.Timer(interval=0.2, repeats=True, action=tick)
+
+
+def toggle_recording():
+ if state.recording:
+ poll_timer.stop()
+ info = video_recorder.stop() or {}
+ state.batch_update(
+ recording=False,
+ path=info.get("path", ""),
+ status=f"已保存 · {info.get('duration', 0):.1f}s",
+ )
+ return
+
+ try:
+ path = video_recorder.start(camera="back")
+ state.batch_update(
+ recording=True,
+ path=path or "",
+ status="录像中…",
+ )
+ poll_timer.start()
+ except video_recorder.VideoRecorderError as exc:
+ state.status = f"无法开始: {exc}(请检查相机权限)"
+
+
+def cancel_recording():
+ if not state.recording:
+ return
+ poll_timer.stop()
+ video_recorder.cancel()
+ state.batch_update(
+ recording=False,
+ path="",
+ seconds=0.0,
+ status="已取消并删除临时文件",
+ )
+
+
+def body():
+ label = "停止录像" if state.recording else "开始录像"
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("录像", [
+ appui.LabeledContent("状态", value=state.status),
+ appui.LabeledContent("时长", value=f"{state.seconds:.1f} 秒"),
+ appui.Text(state.path).font("caption").foreground_color("secondaryLabel"),
+ ]),
+ appui.Section("操作", [
+ appui.Button(label, action=toggle_recording)
+ .button_style("bordered_prominent"),
+ appui.Button("取消录像", action=cancel_recording, role="destructive"),
+ ], footer="真机测试最可靠;需相机权限。"),
+ ]).navigation_title("录像")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
start(path, camera, quality) | 开始录像 → 文件路径 |
stop() | 停止 → {path, duration, size} |
cancel() | 停止并删除半成品 |
status() | {recording, duration, path} |
VideoRecorderError | 操作失败时抛出 |
start(path=None, camera="back", quality="high")
path = video_recorder.start()
+path = video_recorder.start(camera="front")
+| 参数 | 说明 |
|---|---|
path | 输出路径;省略时自动生成到 Documents |
camera | back(后置)/ front(前置) |
quality | 兼容参数,当前 Bridge 可能忽略 |
stop() — 返回 {path, duration, size};未录像时返回空字典。
cancel() — 丢弃半成品文件。
status() 返回:
| 字段 | 说明 |
|---|---|
recording | 是否正在录像 |
duration | 已录秒数 |
path | 当前输出路径 |
code | 含义 |
|---|---|
already_recording | 重复 start() |
unavailable | 相机不可用或无法添加输出 |
unknown_command | Bridge 命令错误 |
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 start() | 刷新时重复录像 | 放进按钮回调 |
if permission.request("camera"): | 判断错误且暂不支持 | 用 try/except 处理 start() |
放弃录像不 cancel() | 留下垃圾文件 | 调用 cancel() |
| 与 audio_recorder 同时录 | 硬件会话冲突 | 分开使用,先停一个 |
| 文档 | 用途 |
|---|---|
| camera | 拍照 |
| media_composer | 视频合并/导出 |
| avplayer | 播放录像文件 |
| permission | 相机权限状态查询 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+人脸、条码、矩形与图像分类。
+本文档已迁移至 vision-helper-module。侧边栏与 schema 均以 vision-helper-module.md 为准。
+import appui
+import vision_helper
+
+
+def body():
+ return appui.Form([
+ appui.Section("Vision Helper", [
+ appui.Text("在按钮回调中传入 base64 图片并调用 detect_* API"),
+ ])
+ ])
+预期效果:页面展示检测入口;成功后在界面上显示检测摘要。
+# 最小调用骨架(在命名回调中执行)
+# result = vision_helper.detect_barcodes(image_b64)
+| 情况 | 处理 |
|---|---|
| 权限被拒绝 | 在设置中开启权限后,从用户触发的回调重试 |
| 设备或能力不可用 | 先检查返回值或 is_available(),再给用户可读提示 |
| 用户取消 | 保留当前界面状态,不要当作成功继续流程 |
| 参数或 API 名错误 | 对照模块 API 参考与 schema,修正后再运行 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,请按「失败路径」排查。
+使用 Vision 从图片字节中识别文字。
+图片 OCR(文字识别):从图片字节提取可见文字。新代码请 import vision(与 vision_fix 等价)。
边界:专注文字识别(OCR)。人脸、条码、矩形检测请用 vision_helper。需要能导入 Vision 框架或 objc_util;纯 ctypes 回退不支持 OCR。
+| 项 | 说明 |
|---|---|
| 导入 | import vision |
| 适合做什么 | 名片识别、截图取字、票据文字提取 |
| 调用时机 | OCR 放在按钮回调;传入图片 bytes |
| 推荐顺序 | 读图/选图 → recognize_text_from_image_data(data) |
| 依赖 | is_vision_available() 为 True 时再调用 |
从本地图片文件读取并识别文字:
+import vision
+
+if not vision.is_vision_available():
+ print("Vision 框架不可用")
+else:
+ with open("/path/to/photo.jpg", "rb") as f:
+ data = f.read()
+ text = vision.recognize_text_from_image_data(data)
+ print(text or "未识别到文字")
+检查框架并获取底层类(高级用法):
+import vision
+
+engine = vision.setup_vision_framework()
+if engine:
+ print("导入方式:", engine.get("method"))
+选图后 OCR,结果展示在界面上。
+import appui
+import photos
+import vision
+
+state = appui.State(
+ available="—",
+ status="尚未识别",
+ text="",
+)
+
+
+def refresh_available():
+ state.available = "是" if vision.is_vision_available() else "否"
+
+
+def ocr_from_photos():
+ refresh_available()
+ if state.available != "是":
+ state.status = "Vision 不可用"
+ return
+
+ data = photos.pick_image(raw_data=True)
+ if not data:
+ state.status = "未选择图片"
+ return
+
+ result = vision.recognize_text_from_image_data(data)
+ if not result:
+ state.batch_update(status="未识别到文字", text="")
+ return
+
+ preview = result[:200] + ("…" if len(result) > 200 else "")
+ state.batch_update(status=f"共 {len(result)} 字符", text=preview)
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("OCR", [
+ appui.Button("选图并识别", action=ocr_from_photos)
+ .button_style("bordered_prominent"),
+ ]),
+ appui.Section("结果", [
+ appui.LabeledContent("Vision", value=state.available),
+ appui.LabeledContent("状态", value=state.status),
+ appui.Text(state.text or "—").foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("文字识别")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
is_vision_available() | Vision 是否可用 → bool |
setup_vision_framework() | 返回底层类字典或 None |
recognize_text_from_image_data(image_data) | OCR → str 或 None |
is_vision_available() — 检测能否通过直接导入或 objc_util 使用 Vision。
setup_vision_framework() — 返回包含 VNRecognizeTextRequest、VNImageRequestHandler 等类的字典;method 字段为 direct_import 或 objc_util。
recognize_text_from_image_data(image_data) — 传入图片原始 bytes,返回多行合并的识别文本;失败返回 None。
with open(path, "rb") as f:
+ text = vision.recognize_text_from_image_data(f.read())
+识别级别为 accurate(setRecognitionLevel_(1)),适合文档与截图。
| 错误写法 | 后果 | 修正 |
|---|---|---|
传文件路径而非 bytes | 类型错误 | 先 open(path, "rb").read() |
未检查 is_vision_available() | 返回 None 难排查 | 先判断可用性 |
在 body() 里自动 OCR | 每次刷新重复识别 | 放进按钮回调 |
| 需要扫二维码 | 能力不匹配 | 用 vision_helper.detect_barcodes |
| 文档 | 用途 |
|---|---|
| vision_helper | 人脸、条码、分类、矩形检测 |
| photos | 选图与 Base64 |
| objc_util | 底层 Vision 类访问 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+当前天气与每日/每小时预报(WeatherKit)。
+当前天气与预报(基于 Apple WeatherKit)。支持按坐标查询、按设备位置查询,以及多日/多小时预报。
+边界:需要 App 开启 WeatherKit 能力(Apple Developer 配置 + 带 entitlement 的签名构建)。current_location() 还需要定位权限;不包含地图或定位 UI,定位请看 location。
+| 项 | 说明 |
|---|---|
| 导入 | import weather |
| 适合做什么 | 天气卡片、出行建议、温度/湿度/风速展示、短期预报 |
| 调用时机 | 放在按钮回调或加载任务;不要写在 AppUI body() 中 |
| 推荐顺序 | 需要时申请定位 → current / current_location → 可选 daily / hourly |
| 单位约定 | 温度 °C;风速 m/s;湿度 0..1;symbol 为 SF Symbol 名 |
下面脚本按坐标查询上海当前天气,并打印未来 3 天预报:
+import weather
+
+LAT, LON = 31.2304, 121.4737
+
+try:
+ now = weather.current(LAT, LON)
+ print(f"{now['temperature']:.1f}°C", now["condition"], now["symbol"])
+ print(f"体感 {now['feels_like']:.1f}°C · 湿度 {now['humidity']:.0%}")
+
+ days = weather.daily(LAT, LON, days=3)
+ for day in days:
+ print(day["date"][:10], day["low"], "~", day["high"], "°C", day["condition"])
+except weather.WeatherError as exc:
+ print("查询失败:", exc, f"(code={exc.code})")
+天气请求放进按钮回调;结果写进 State,用 symbol 显示 SF Symbol 图标。
import appui
+import permission
+import weather
+
+DEMO_LAT = 31.2304
+DEMO_LON = 121.4737
+
+state = appui.State(
+ temp="—",
+ summary="点击按钮开始",
+ symbol="cloud",
+ forecast="—",
+ message="",
+)
+
+
+def _apply_current(now, label):
+ state.temp = f"{now.get('temperature', 0):.1f}°C"
+ state.summary = now.get("condition", "—")
+ state.symbol = now.get("symbol", "cloud")
+ state.message = f"{label}查询成功"
+
+
+def _format_forecast(days):
+ lines = []
+ for day in days[:3]:
+ date = str(day.get("date", ""))[:10]
+ low = day.get("low", "—")
+ high = day.get("high", "—")
+ lines.append(f"{date} {low}~{high}°C {day.get('condition', '')}")
+ return "\n".join(lines) if lines else "暂无预报"
+
+
+def load_shanghai():
+ try:
+ now = weather.current(DEMO_LAT, DEMO_LON)
+ _apply_current(now, "上海")
+ state.forecast = _format_forecast(
+ weather.daily(DEMO_LAT, DEMO_LON, days=3)
+ )
+ except weather.WeatherError as exc:
+ state.message = f"查询失败: {exc} (code={exc.code})"
+
+
+def load_here():
+ entry = permission.status("location")
+ if not entry.get("authorized"):
+ permission.request("location")
+ entry = permission.status("location")
+ if not entry.get("authorized"):
+ state.message = "需要定位权限,请先在系统弹窗中允许"
+ return
+ try:
+ now = weather.current_location()
+ _apply_current(now, "当前位置")
+ except weather.WeatherError as exc:
+ state.message = f"查询失败: {exc} (code={exc.code})"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("当前", [
+ appui.Image(system_name=state.symbol).font("largeTitle"),
+ appui.LabeledContent("温度", value=state.temp),
+ appui.LabeledContent("状况", value=state.summary),
+ ]),
+ appui.Section("操作", [
+ appui.Button("查询上海天气", action=load_shanghai)
+ .button_style("bordered_prominent"),
+ appui.Button("查询当前位置天气", action=load_here),
+ ]),
+ appui.Section("3 日预报", [
+ appui.Text(state.forecast).font("caption").foreground_color("secondaryLabel"),
+ appui.Text(state.message).font("caption2").foreground_color("tertiaryLabel"),
+ ], footer="需要 WeatherKit 能力与网络;真机测试最可靠。"),
+ ]).navigation_title("天气")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
current(lat, lon) | 按坐标查当前天气 → dict |
current_location() | 按设备位置查当前天气 → dict(需定位权限) |
daily(lat, lon, days=7) | 每日预报 → list[dict] |
hourly(lat, lon, hours=24) | 每小时预报 → list[dict] |
WeatherError | 查询失败时抛出的异常 |
current(latitude, longitude) — 按经纬度查询。
now = weather.current(37.33, -122.03)
+print(now["temperature"], now["symbol"])
+current_location() — 使用设备当前位置,需先获得定位授权。
import permission
+
+if not permission.status("location").get("authorized"):
+ permission.request("location")
+now = weather.current_location()
+当前天气常见字段:
+| 字段 | 说明 |
|---|---|
temperature / feels_like | 气温 / 体感温度(°C) |
condition | 天气状况文字 |
symbol | SF Symbol 名,可配合 appui.Image(system_name=...) |
humidity | 湿度(0..1) |
wind_speed | 风速(m/s) |
uv_index | 紫外线指数 |
is_daylight | 是否白天 |
daily(latitude, longitude, days=7) — 返回每日预报列表。
days = weather.daily(31.23, 121.47, days=5)
+for day in days:
+ print(day["date"], day["low"], day["high"], day["symbol"])
+每日字段:date、condition、symbol、high、low、precipitation_chance。
hourly(latitude, longitude, hours=24) — 返回每小时预报列表。
hours = weather.hourly(31.23, 121.47, hours=12)
+for hour in hours:
+ print(hour["date"], hour["temperature"], hour["condition"])
+每小时字段:date、condition、symbol、temperature、precipitation_chance。
查询失败时抛出 WeatherError,可通过 exc.code 区分原因:
code | 含义 |
|---|---|
unavailable | 系统版本过低、WeatherKit 未启用或暂无数据 |
weatherkit_auth | WeatherKit JWT/entitlement 未正确配置 |
denied | 定位权限未授权(current_location) |
network_error | 网络或 WeatherKit 服务异常 |
try:
+ weather.current(0, 0)
+except weather.WeatherError as exc:
+ print(exc.code, exc)
+| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里调 weather.current() | 每次刷新都联网 | 放进按钮回调,结果写进 State |
未授权就 current_location() | 抛出 WeatherError(code="denied") | 先 permission.request("location") |
| 未配置 WeatherKit | weatherkit_auth 或 unavailable | 在 Developer 为 App ID 开启 WeatherKit 并重装 |
不捕获 WeatherError | 异常直接打断流程 | 用 try/except 并给出兜底 UI |
| 文档 | 用途 |
|---|---|
| location | 定位权限与坐标 |
| permission | 统一查询 location 权限 |
| 原生能力入口 | MiniApp 场景配方 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+原生 WebSocket 连接、收发和回调。
+原生 WebSocket 客户端(URLSessionWebSocketTask):连接、收发消息、事件回调。
+边界:客户端连接+ws:///wss://服务器;不含 WebSocket 服务端。阻塞式receive()会等待消息;AppUI 场景推荐on_message回调。
| 项 | 说明 |
|---|---|
| 导入 | import websocket |
| 适合做什么 | 实时聊天、行情推送、协作白板 |
| 调用时机 | connect 放按钮回调;长连接用 on_message |
| 推荐顺序 | connect(url) → send → receive 或设回调 → close() |
| 状态 | ws.state:open / connecting / closing / closed |
阻塞式收发(适合脚本):
+import websocket
+
+ws = websocket.connect("wss://echo.websocket.org")
+ws.send("Hello")
+msg = ws.receive(timeout=10)
+print(msg) # {"type": "text", "data": "Hello"}
+ws.close()
+回调式(适合 AppUI):
+import websocket
+
+
+def on_message(data):
+ print("收到:", data)
+
+
+def on_close(code):
+ print("关闭:", code)
+
+
+ws = websocket.connect("wss://echo.websocket.org")
+ws.on_message = on_message
+ws.on_close = on_close
+ws.send("Hello")
+连接、发送、断开放在按钮回调;最近一条消息展示在界面。
+import appui
+import websocket
+
+ECHO_URL = "wss://echo.websocket.org"
+
+state = appui.State(
+ conn_state="未连接",
+ last_msg="—",
+ status="点击连接",
+)
+
+session = {"ws": None}
+
+
+def on_message(data):
+ state.last_msg = str(data)[:120]
+ state.status = "收到消息"
+
+
+def on_close(code):
+ state.batch_update(conn_state="已关闭", status=f"关闭码 {code}")
+ session["ws"] = None
+
+
+def connect_echo():
+ if session.get("ws") and session["ws"].is_open:
+ state.status = "已连接"
+ return
+
+ try:
+ ws = websocket.connect(ECHO_URL)
+ ws.on_message = on_message
+ ws.on_close = on_close
+ session["ws"] = ws
+ state.batch_update(conn_state=ws.state, status="已连接 echo 服务")
+ except Exception as exc:
+ state.batch_update(conn_state="失败", status=str(exc))
+
+
+def send_ping():
+ ws = session.get("ws")
+ if not ws or not ws.is_open:
+ state.status = "请先连接"
+ return
+
+ ws.send("ping from PythonIDE")
+ state.status = "已发送 ping"
+
+
+def disconnect():
+ ws = session.get("ws")
+ if ws:
+ ws.close()
+ session["ws"] = None
+ state.conn_state = "未连接"
+
+
+def body():
+ return appui.NavigationStack(
+ appui.Form([
+ appui.Section("连接", [
+ appui.Button("连接 Echo 服务", action=connect_echo)
+ .button_style("bordered_prominent"),
+ appui.Button("发送 ping", action=send_ping),
+ appui.Button("断开", action=disconnect, role="destructive"),
+ ]),
+ appui.Section("状态", [
+ appui.LabeledContent("连接", value=state.conn_state),
+ appui.LabeledContent("最近消息", value=state.last_msg),
+ appui.Text(state.status).foreground_color("secondaryLabel"),
+ ]),
+ ]).navigation_title("WebSocket")
+ )
+
+
+appui.run(body, state=state)
+| API | 作用 |
|---|---|
connect(url, protocols=None) | 建立连接,返回 WebSocket |
ws.send(data) | 发送文本或 bytes |
ws.receive(timeout=30) | 阻塞等待消息 → dict 或 None |
ws.close(code=1000) | 关闭连接 |
ws.state / ws.is_open | 连接状态 |
ws.on_message / on_open / on_close / on_error | 事件回调 |
ws = websocket.connect("wss://example.com/socket", protocols=["chat"])
+连接失败抛 ConnectionError。
send(data) — 字符串发文本;bytes 发二进制(内部 Base64 编码)。
receive(timeout=30) — 返回 {"type": "text"|"binary", "data": ...} 或超时 None。
在 AppUI 中设置 on_message 等属性时,底层通过 _appui_native._register_callback 注册;需保持 ws 对象存活。
| 错误写法 | 后果 | 修正 |
|---|---|---|
在 body() 里 connect | 每次刷新新建连接 | 放进按钮回调,复用 session |
主线程长时间 receive | 界面卡住 | 用 on_message 或后台线程 |
| 用 WebSocket 访问 REST | 协议不匹配 | 用 network |
忘记 close() | 泄漏连接 | 用完关闭或依赖析构 |
| 文档 | 用途 |
|---|---|
| network | HTTP 请求 |
| http_server | 本地 HTTP 服务(非 WS 服务端) |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+按任务/场景索引 Widget API;含 symbol 链式 color 等易错点。
+发布流程见 从脚本到桌面;完整签名见 widget API 参考。
+按任务和场景组织 widget 公开 API。需要确认参数名、返回值或修饰符链时,再查 API 参考。
边界:本页是索引,不展开教程。入门见 widget 概览;故障见 排错。+
可运行基线脚本见 排错 · 最小健康基线(全系列唯一基线,避免各篇重复)。本页只做 API 路由,不另附 runnable 示例。
+| 你要做什么 | 先看 | 专题 |
|---|---|---|
| 从零写第一个小组件 | Widget()、text()、render() | 概览 |
| 发布到主屏 | Studio 发布流程 | 从脚本到桌面 |
| 预览里调配色/标题 | widget.param.* | 参数面板 |
| 桌面按钮/开关 | widget.state、button()、toggle() | 状态和交互 |
| small/medium/large 适配 | widget.context、family_value()、when() | 布局与尺寸 |
| 数字动画 / 定时刷新 | timeline()、widget.entry、flip() | 时间线和动画 |
| 深色/透明/图片/Symbol | container_background、symbol()、image() | 资源与外观 |
| 预览空白 / 桌面不对 | 基线排查、validate() | 排错 |
| 任务 | 首选 API | 注意 |
|---|---|---|
| 创建根容器 | Widget(background=, padding=, style=) | 脚本里只有一个 Widget() |
| 提交渲染 | w.render() | 必须在脚本末尾 |
| 布局诊断 | w.validate(family=) | Studio「Widget 诊断」对应此报告 |
| 流式横/纵排 | row()、column()、layer() | 必须用 with w.row(): 上下文 |
| 等列网格 | grid(columns=) | 子节点按顺序填格 |
| 表格线 / Bingo | table(rows, columns) + table.cell() | 勿用 rect 拼线 |
| 点坐标绘制 | canvas()、.place() | 读 content_width / content_height |
| 标题与数值 | text()、value()、title()、caption() | 可变文案加 line_limit + min_scale |
| SF Symbol | symbol(name) → .color() .font_size() | symbol 无 color=/size= 参数 |
| 图片 / SVG | image()、svg() | 支持 light/dark 双资源 |
| 进度与图表 | progress()、ring_chart()、line_chart()、bar_chart() | small 注意高度预算 |
| 可调配置 | widget.param.text/color/number/slider/bool/choice/file | 不是桌面 state |
| 桌面状态 | widget.state.int/bool/list/... | .increment()、.toggle()、.toggle_item() |
| 按钮/开关/链接 | button()、toggle()、link() | action 必须来自 state 工厂;link(..., icon=) 不可链 .line_limit() |
| 时间线 | w.timeline(entries=, update=) | entry 字段用 widget.entry.* |
| 数据动画 | .content_transition("numericText")、.id()、flip() | 非 60fps 循环 |
| 系统倒计时 | countdown()、timer_text() | 原生走时 |
| 外观/透明 | container_background()、transparent_background()、background_image() | 染色用 .accentable() |
| 缓存数据 | cache_json()、history()、save_image() | 见 API 参考 |
| 名称 | 说明 |
|---|---|
widget.SMALL | 主屏小(约 158×158 内容区) |
widget.MEDIUM | 主屏中(约 338×158) |
widget.LARGE | 主屏大(约 338×354) |
widget.CIRCULAR | 锁屏圆形 accessory |
widget.RECTANGULAR | 锁屏矩形 accessory |
widget.INLINE | 锁屏行内 accessory |
| 名称 | 说明 |
|---|---|
widget.context | 当前 family、width/height、content_*、is_family() |
widget.param | 预览面板参数声明 |
widget.state | 桌面持久状态 + AppIntent 工厂 |
widget.entry | 当前 timeline entry 字段 |
widget.family_value() | 按 family 返回值 |
widget.action | 受控动作助手(高级) |
widget.color | 颜色助手 |
widget.storage | 轻量存储 |
Widget 实例)| 方法 | 说明 |
|---|---|
Widget(...) | 创建根容器 |
w.render() | 输出 IR,结束构建 |
w.validate(family=) | 布局/动画诊断报告 |
w.timeline(...) | 声明时间线条目与刷新策略 |
w.background() | 运行时改根背景 |
w.container_background() | 系统容器背景 |
w.transparent_background() | 请求透明 |
w.content_margins() | 内容边距开关 |
w.background_image() | 背景图 + scrim |
| 方法 | 说明 |
|---|---|
row() / column() | 横/纵栈(with 块) |
layer() | 叠放 |
grid() | 等列网格 |
table() | 单路径表格线 |
canvas() | 点位坐标画布 |
when() / unless() | 按 family 显示/隐藏块 |
spacer() / divider() | 间距与分割线 |
| 方法 | 说明 |
|---|---|
text() / rich_text() | 文本 |
value() | 数字/状态展示 |
symbol() | SF Symbol(语义图标) |
icon() | 低级图标(一般优先 symbol) |
image() / svg() | 位图 / 矢量 |
badge() / label() / status() | 组合标签 |
progress() / ring_chart() / line_chart() / bar_chart() | 图表 |
flip() | 翻页数字块 |
countdown() / timer_text() / dynamic_date() | 时间相关 |
| 方法 | 说明 |
|---|---|
button(action=) | AppIntent 按钮 |
toggle(state=) | 开关(color=,无 tint=) |
link(title, url) | 打开链接 |
| 修饰符 | 说明 |
|---|---|
.line_limit(n) / .min_scale(f) | 防裁切 |
.color() / .font_size() / .font_weight() | 外观(symbol 用链式上色) |
.id() | 稳定视图身份 |
.content_transition() / .animation() / .transition() | 数据更新动画 |
.accentable() | 系统染色参与与否 |
.privacy_sensitive() / .redacted() | 锁屏隐私 |
.monospaced_digit() | 等宽数字 |
| 函数 | 说明 |
|---|---|
widget.save_image() | 注册图片供 image/background_image 引用 |
widget.cache_json() | 带 TTL 的 JSON 缓存 |
widget.history() | 按 bucket 保留历史序列 |
widget.validate_layout() | 对 IR 文档做诊断 |
row / column;精确定位再用 table / canvas。widget.param;用户点桌面 → widget.state。action 只用 state 工厂,不传 Python 函数或字符串。family_value / is_family / when,勿整体缩小 large 布局。.line_limit(1).min_scale(0.65+)。w.symbol("name") 后链 .color(accent).font_size(18);多色用 rendering="palette", palette=[...]。# ❌ symbol 不接受 color=/size=
+w.symbol("star.fill", color="#F59E0B", size=18)
+
+# ✅
+w.symbol("star.fill").color("#F59E0B").font_size(18)
+w.symbol("paintpalette.fill", rendering="palette", palette=["#EF4444", "#3B82F6"])
+| 文档 | 覆盖 API |
|---|---|
| widget 概览 | 总览、快速开始、心智模型 |
| 从脚本到桌面 | render、发布、桌面验证 |
| 布局与尺寸 | row/grid/table/canvas、context |
| 参数面板 | widget.param |
| 状态和交互 | widget.state、button、toggle、link |
| 时间线和动画 | timeline、entry、flip、动画修饰符 |
| 资源与外观 | 背景、图片、SVG、accentable |
| 排错 | validate、常见 TypeError |
| API 参考 | 全部公开签名 |
每篇主打不同场景,避免复制粘贴同一套计数器示例:
+| 文档 | 主打示例 |
|---|---|
| 概览 | 饮水环形图 ±、每日一言、折线图 |
| 从脚本到桌面 | 习惯开关发布验证 |
| 参数面板 | 主题天气 choice 换肤 |
| 状态和交互 | 连续签到、习惯清单、闹钟开关、链接 |
| 布局与尺寸 | 健康仪表盘、快递追踪、播客 when、周历 grid、Bingo table、canvas、锁屏 |
| 时间线和动画 | 零钱罐、分时电价、flip 翻页、阶段文案、会议倒计时 |
| 资源与外观 | 渐变、accentable、layer、背景图、头像、SVG、步数隐私 |
| 排错 | 最小健康基线(唯一 runnable 基线) |
| 现象 | 处理 |
|---|---|
| 不知道用哪个 API | 本文「先看哪一类」→ 专题文档 |
| 签名记不清 | API 参考 |
TypeError: unexpected keyword | 排错 对照表(tint、symbol color 等) |
| 示例能预览、桌面异常 | 发布流程 + 缓存重装 |
相关 API:widget-api-reference.md
+公开 Widget() API 速查。
+这页是公开 Widget() API 的签名索引。先用 API 地图 按任务选择能力,再回到这里核对函数名、参数名和返回句柄。
| 目标 | 推荐 API |
|---|---|
| 普通信息卡 | text、value、progress、row、column |
| 精确表格 | table、canvas、path、shape、.place() |
| 可点击状态 | widget.state、button、toggle |
| 可调参数 | widget.param.color/slider/text/bool/choice/file |
| 动态数据 | widget.entry、timeline、content_transition、flip |
| 外观适配 | container_background、content_margins、transparent_background、accentable |
| API | 保证 | 注意 |
|---|---|---|
Widget() | 创建一个小组件根容器,控制背景、边距和整体样式。 | 一个脚本只保留一个最终渲染的小组件。 |
w.render() | 输出当前小组件并结束构建流程。 | 放在脚本末尾;没有调用时预览和发布都不会得到内容。 |
widget.param.* | 在预览面板提供可配置输入,构建时读取当前值。 | 参数不是桌面点击状态;改名后需要重新运行并发布。 |
widget.state.* | 保存桌面交互状态,并为按钮或开关生成安全动作。 | 不要把普通 Python 回调传给桌面按钮。 |
w.button() / w.toggle() | 创建系统允许的点击或开关交互。 | 交互区域要清晰,避免同一区域同时承担链接和按钮。 |
w.timeline() / widget.entry | 声明一组未来显示结果,用于系统刷新和数据更新动画。 | 刷新时机由系统调度,不适合连续动画。 |
widget.family_value() | 为不同 family 选择不同值。 | small、medium、large 内容密度差异大,不要只缩小同一套布局。 |
.line_limit() / .min_scale() | 限制可变文本的行数,并允许文字在小尺寸内缩放。 | 用户可改文本、按钮标题和主值默认都应该加。 |
w.container_background() | 声明系统小组件背景。 | 透明、染色和背景移除是否生效取决于系统宿主环境。 |
| 场景 | 常用 API | 用途 |
|---|---|---|
| 创建和运行 | Widget()、w.render、w.validate、w.timeline | 创建、检查、发布小组件,并声明更新时间线。 |
| 尺寸和数据 | widget.context、widget.entry、widget.param、widget.state、widget.family_value | 读取当前尺寸、时间线数据、参数和桌面交互状态。 |
| 文字和内容 | w.text、w.rich_text、w.value、w.symbol、w.svg、w.image、w.badge | 标题、数值、图标、图片和组合文字。 |
| 图表和进度 | w.progress、w.ring_chart、w.line_chart、w.bar_chart | 进度条、圆环、折线和柱状图。 |
| 布局 | w.row、w.column、w.layer、w.grid、w.table、w.canvas、w.spacer、w.divider | 从自动布局到精确表格、画布定位。 |
| 形状和线条 | w.shape、w.rect、w.circle、w.path;.stroke、.clip_shape、.mask、.reverse_mask | 线宽、虚线、裁切、遮罩和自定义图形。 |
| 交互 | w.button、w.toggle、w.link;widget.action | AppIntent 按钮、开关、链接和状态动作。 |
| 外观 | w.container_background、w.content_margins、w.transparent_background、w.background_image;.accentable、.privacy_sensitive、.redacted | 深色、透明、染色、隐私和占位。 |
| 动画 | w.flip;.content_transition、.animation、.transition、.id | WidgetKit 数据更新动画和稳定身份。 |
| 资源和缓存 | widget.save_image、widget.cache_json、widget.history | 保存图片、缓存网络数据和保留历史值。 |
widget.CIRCULAR:Family: circularwidget.INLINE:Family: inlinewidget.LARGE:Family: largewidget.MEDIUM:Family: mediumwidget.RECTANGULAR:Family: rectangularwidget.SMALL:Family: smallwidget.action:动作助手,用于创建受控刷新、状态和打开链接动作。widget.color:颜色助手,用于固定色、浅深色和系统角色色。widget.context:当前 family、尺寸和内容区域信息。widget.entry:当前 timeline entry 的字段读取入口。widget.family:当前小组件 family 名称。widget.param:预览面板参数声明入口。widget.state:桌面交互状态声明入口。widget.storage:小组件脚本可用的轻量存储入口。widget.cache_jsonwidget.cache_json(
+ url: str,
+ *,
+ ttl: Optional[float] = 3600,
+ default: Any = None,
+ params: Optional[Dict[str, Any]] = None,
+ headers: Optional[Dict[str, str]] = None,
+ key: Optional[str] = None,
+ timeout: float = 8,
+) -> Any
+widget.family_valuewidget.family_value(default: Any = None, **values: Any) -> Any
+widget.historywidget.history(
+ key: str,
+ value: Any = None,
+ limit: int = 7,
+ *,
+ bucket: Optional[str] = "day",
+ default: Any = None,
+) -> Any
+widget.save_imagewidget.save_image(source: Union[str, bytes], name: str, *, variant: Optional[str] = None) -> str
+w.backgroundw.background(value: WidgetBackground) -> "Widget"
+w.background_imagew.background_image(
+ asset: Optional[ImageLike] = None,
+ content_mode: str = "fill",
+ dim: Optional[Union[bool, float]] = None,
+ scrim: Optional[Union[bool, str]] = None,
+ scrim_opacity: Optional[float] = None,
+ focal: str = "center",
+ overlay_color: ColorLike = "#000000",
+ *,
+ light: Optional[str] = None,
+ dark: Optional[str] = None,
+) -> "Widget"
+w.badgew.badge(
+ text: Union[str, int, float],
+ icon: Optional[str] = None,
+ tone: str = "accent",
+ style: str = "plain",
+) -> WidgetNode
+w.bar_chartw.bar_chart(
+ values: List[Union[int, float]],
+ color: Optional[ColorLike] = None,
+ height: Optional[float] = None,
+ min_value: Optional[float] = None,
+ max_value: Optional[float] = None,
+ spacing: Optional[float] = None,
+ corner_radius: Optional[float] = None,
+ track_color: Optional[ColorLike] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ segment_colors: Optional[List[ColorLike]] = None,
+ baseline: Optional[float] = None,
+ threshold: Optional[float] = None,
+ labels: Optional[Union[List[Any], Dict[str, Any]]] = None,
+ label_color: Optional[ColorLike] = None,
+) -> WidgetNode
+w.bodyw.body(content: Union[str, int, float]) -> WidgetNode
+w.buttonw.button(
+ title: Optional[str] = None,
+ action: Optional[Union[str, WidgetAction]] = None,
+ url: Optional[str] = None,
+ color: Optional[ColorLike] = None,
+ background: Optional[WidgetBackground] = None,
+ size: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ *,
+ style: Optional[str] = None,
+ layout: Optional[str] = None,
+ press: Optional[Dict[str, Any]] = None,
+ normal: Optional[Dict[str, Any]] = None,
+) -> WidgetNode
+w.canvasw.canvas(
+ height: Optional[float] = None,
+ coordinate_space: str = "relative",
+ align: str = "center",
+ background: Optional[WidgetBackground] = None,
+ padding: Optional[Union[int, float]] = None,
+ corner_radius: Optional[float] = None,
+ border_color: Optional[ColorLike] = None,
+ border_width: Optional[float] = None,
+ opacity: Optional[float] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ fill: bool = False,
+) -> Canvas
+w.captionw.caption(content: Union[str, int, float]) -> WidgetNode
+w.changew.change(
+ primary: Union[str, int, float],
+ secondary: Optional[Union[str, int, float]] = None,
+ direction: Optional[str] = None,
+) -> WidgetNode
+w.circlew.circle(
+ color: Optional[ColorLike] = None,
+ size: Optional[float] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ border_color: Optional[ColorLike] = None,
+ border_width: Optional[float] = None,
+) -> WidgetNode
+w.columnw.column(
+ spacing: Optional[Union[int, float]] = None,
+ align: Optional[str] = None,
+) -> WidgetContainer
+w.container_backgroundw.container_background(
+ value: Optional[WidgetBackground] = None,
+ *,
+ removable: Optional[bool] = None,
+) -> "Widget"
+w.content_marginsw.content_margins(
+ enabled: bool = True,
+ padding: Optional[Union[int, float, Sequence[float], Dict[str, float]]] = None,
+) -> "Widget"
+w.contextw.context -> WidgetContext
+w.countdownw.countdown(
+ title: Optional[Union[str, int, float]] = "Countdown",
+ target: Optional[Union[datetime, str]] = None,
+ subtitle: Optional[Union[str, int, float]] = None,
+ icon: Optional[str] = None,
+ tone: Optional[str] = None,
+ accent: Optional[ColorLike] = None,
+) -> WidgetBlock
+w.datew.date(target: Optional[Union[datetime, str]] = None, style: str = "date") -> WidgetNode
+w.dividerw.divider(color: Optional[ColorLike] = None, opacity: Optional[float] = None, ) -> WidgetNode
+w.dynamic_datew.dynamic_date(target: Optional[Union[datetime, str]] = None, style: str = "date") -> WidgetNode
+w.flipw.flip(
+ value: Any,
+ previous: Optional[Any] = None,
+ *,
+ direction: str = "up",
+ width: Optional[float] = None,
+ height: Optional[float] = None,
+ size: Optional[float] = None,
+ weight: str = "bold",
+ color: Optional[ColorLike] = None,
+ background: Optional[WidgetBackground] = None,
+ corner_radius: Optional[float] = None,
+ duration: float = 0.55,
+ delta: Optional[float] = None,
+ perspective: float = 0.62,
+ shadow_opacity: float = 0.18,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ design: Optional[str] = "monospaced",
+) -> WidgetNode
+w.gridw.grid(
+ columns: int = 2,
+ spacing: Optional[Union[int, float]] = None,
+ row_spacing: Optional[Union[int, float]] = None,
+ column_spacing: Optional[Union[int, float]] = None,
+ align: Optional[str] = None,
+ padding: Optional[Union[int, float]] = None,
+ background: Optional[WidgetBackground] = None,
+ opacity: Optional[float] = None,
+ corner_radius: Optional[float] = None,
+ border_color: Optional[ColorLike] = None,
+ border_width: Optional[float] = None,
+ url: Optional[str] = None,
+ shadow_color: Optional[ColorLike] = None,
+ shadow_radius: Optional[float] = None,
+ shadow_x: float = 0,
+ shadow_y: float = 2,
+ rows: Optional[int] = None,
+ equal: bool = False,
+ fill: bool = False,
+) -> WidgetContainer
+w.imagew.image(
+ name: Optional[ImageLike] = None,
+ width: Optional[float] = None,
+ height: Optional[float] = None,
+ corner_radius: Optional[float] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ content_mode: Optional[str] = None,
+ *,
+ light: Optional[str] = None,
+ dark: Optional[str] = None,
+ rendering_mode: Optional[str] = None,
+) -> WidgetNode
+w.layerw.layer(
+ align: str = "center",
+ padding: Optional[Union[int, float]] = None,
+ background: Optional[WidgetBackground] = None,
+ corner_radius: Optional[float] = None,
+) -> WidgetContainer
+w.line_chartw.line_chart(
+ values: List[Union[int, float]],
+ color: Optional[ColorLike] = None,
+ height: Optional[float] = None,
+ min_value: Optional[float] = None,
+ max_value: Optional[float] = None,
+ fill: bool = True,
+ show_points: bool = True,
+ line_width: Optional[float] = None,
+ track_color: Optional[ColorLike] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ segment_colors: Optional[List[ColorLike]] = None,
+ baseline: Optional[float] = None,
+ threshold: Optional[float] = None,
+ labels: Optional[Union[List[Any], Dict[str, Any]]] = None,
+ label_color: Optional[ColorLike] = None,
+) -> WidgetNode
+w.linkw.link(
+ title: str,
+ url: str,
+ icon: Optional[str] = None,
+ color: Optional[ColorLike] = None,
+) -> Union[WidgetNode, WidgetContainer]
+w.listw.list(
+ items: List[Any],
+ title: Optional[Union[str, int, float]] = None,
+ limit: Optional[int] = None,
+ empty_text: Optional[Union[str, int, float]] = None,
+ dividers: bool = False,
+) -> WidgetContainer
+w.pathw.path(
+ points: List[PathPoint],
+ stroke: Optional[ColorLike] = None,
+ fill: Optional[ColorLike] = None,
+ line_width: Optional[Union[float, str]] = None,
+ closed: bool = False,
+ height: Optional[float] = None,
+ coordinate_space: str = "relative",
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ line_cap: Optional[str] = None,
+ line_join: Optional[str] = None,
+ dash: Optional[Sequence[float]] = None,
+ miter_limit: Optional[float] = None,
+) -> WidgetNode
+w.progressw.progress(
+ value: Union[int, float],
+ total: float = 1.0,
+ color: Optional[ColorLike] = None,
+ height: Optional[float] = None,
+ track_color: Optional[ColorLike] = None,
+ *,
+ title: Optional[str] = None,
+ subtitle: Optional[str] = None,
+ unit: Optional[str] = None,
+ icon: Optional[str] = None,
+ tone: Optional[str] = None,
+ accent: Optional[str] = None,
+ style: Optional[str] = None,
+) -> WidgetNode
+w.rectw.rect(
+ color: Optional[ColorLike] = None,
+ width: Optional[float] = None,
+ height: Optional[float] = None,
+ corner_radius: Optional[float] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ border_color: Optional[ColorLike] = None,
+ border_width: Optional[float] = None,
+) -> WidgetNode
+w.regionw.region(
+ slot: str = "center",
+ spacing: Optional[Union[int, float]] = None,
+ align: Optional[str] = None,
+) -> WidgetContainer
+w.relative_timew.relative_time(target: Optional[Union[datetime, str]] = None) -> WidgetNode
+w.renderw.render(url: Optional[str] = None) -> None
+w.rich_textw.rich_text(
+ parts: List[RichTextPart],
+ size: Optional[float] = None,
+ weight: Optional[str] = None,
+ color: Optional[ColorLike] = None,
+ align: Optional[str] = None,
+ max_lines: Optional[int] = None,
+ design: Optional[str] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ minimum_scale_factor: Optional[float] = None,
+ font_width: Optional[str] = None,
+) -> WidgetNode
+w.ring_chartw.ring_chart(
+ value: Union[int, float],
+ total: float = 1.0,
+ label: Optional[str] = None,
+ color: Optional[ColorLike] = None,
+ track_color: Optional[ColorLike] = None,
+ size: Optional[float] = None,
+ line_width: Optional[float] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+) -> WidgetNode
+w.roww.row(
+ spacing: Optional[Union[int, float]] = None,
+ align: Optional[str] = None,
+) -> WidgetContainer
+w.sectionw.section(
+ title: Optional[Union[str, int, float]] = None,
+ spacing: Optional[Union[int, float]] = None,
+ subtitle: Optional[Union[str, int, float]] = None,
+ style: Optional[str] = None,
+) -> WidgetContainer
+w.shapew.shape(
+ kind: str = "rectangle",
+ color: Optional[ColorLike] = None,
+ width: Optional[float] = None,
+ height: Optional[float] = None,
+ size: Optional[float] = None,
+ corner_radius: Optional[float] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ border_color: Optional[ColorLike] = None,
+ border_width: Optional[float] = None,
+ shadow_color: Optional[ColorLike] = None,
+ shadow_radius: Optional[float] = None,
+ shadow_x: float = 0,
+ shadow_y: float = 2,
+ stroke_color: Optional[ColorLike] = None,
+ stroke_width: Optional[Union[float, str]] = None,
+ dash: Optional[Sequence[float]] = None,
+ line_cap: Optional[str] = None,
+ line_join: Optional[str] = None,
+ miter_limit: Optional[float] = None,
+ top_leading_radius: Optional[float] = None,
+ top_trailing_radius: Optional[float] = None,
+ bottom_leading_radius: Optional[float] = None,
+ bottom_trailing_radius: Optional[float] = None,
+) -> WidgetNode
+w.spacerw.spacer(length: Optional[Union[int, float]] = None) -> WidgetNode
+w.surfacew.surface(
+ role: str = "panel",
+ spacing: Optional[Union[int, float]] = None,
+ align: Optional[str] = None,
+ padding: Optional[Union[int, float]] = None,
+ background: Optional[WidgetBackground] = None,
+ corner_radius: Optional[float] = None,
+ border_color: Optional[ColorLike] = None,
+ border_width: Optional[float] = None,
+ shadow_color: Optional[ColorLike] = None,
+ shadow_radius: Optional[float] = None,
+) -> WidgetContainer
+w.svgw.svg(
+ name: Optional[ImageLike] = None,
+ width: Optional[float] = None,
+ height: Optional[float] = None,
+ color: Optional[ColorLike] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ content_mode: str = "fit",
+ *,
+ light: Optional[str] = None,
+ dark: Optional[str] = None,
+) -> WidgetNode
+w.symbolw.symbol(
+ name: str,
+ rendering: Optional[str] = None,
+ palette: Optional[Sequence[ColorLike]] = None,
+ variant: Optional[str] = None,
+ scale: Optional[str] = None,
+) -> WidgetNode
+w.tablew.table(
+ rows: int,
+ columns: int,
+ *,
+ line_color: Optional[ColorLike] = None,
+ line_width: Union[float, str] = "hairline",
+ line_cap: str = "butt",
+ line_join: str = "miter",
+ dash: Optional[Sequence[float]] = None,
+ miter_limit: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ width: Optional[float] = None,
+ height: Optional[float] = None,
+ background: Optional[WidgetBackground] = None,
+ opacity: Optional[float] = None,
+ corner_radius: Optional[float] = None,
+ border: bool = True,
+ fill: bool = True,
+ align: str = "center",
+) -> Table
+w.textw.text(
+ content: Union[str, int, float],
+ size: Optional[float] = None,
+ weight: Optional[str] = None,
+ color: Optional[ColorLike] = None,
+ align: Optional[str] = None,
+ max_lines: Optional[int] = None,
+ design: Optional[str] = None,
+ opacity: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ frame: Optional[Dict[str, Any]] = None,
+ minimum_scale_factor: Optional[float] = None,
+ font_width: Optional[str] = None,
+) -> WidgetNode
+w.timew.time(target: Optional[Union[datetime, str]] = None) -> WidgetNode
+w.timelinew.timeline(
+ entries: Optional[List[Dict[str, Any]]] = None,
+ *,
+ update: str = "after",
+ after: Any = None,
+ interval: Optional[float] = None,
+) -> "Widget"
+w.timer_textw.timer_text(target: Optional[Union[datetime, str]] = None) -> WidgetNode
+w.titlew.title(content: Union[str, int, float]) -> WidgetNode
+w.togglew.toggle(
+ title: Optional[str] = None,
+ is_on: Union[bool, str] = False,
+ action: Optional[Union[str, WidgetAction]] = None,
+ url: Optional[str] = None,
+ color: Optional[ColorLike] = None,
+ background: Optional[WidgetBackground] = None,
+ size: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+ *,
+ value: Optional[Union[bool, str]] = None,
+ state: Optional[State[bool]] = None,
+ style: Optional[str] = None,
+ layout: Optional[str] = None,
+ press: Optional[Dict[str, Any]] = None,
+ normal: Optional[Dict[str, Any]] = None,
+) -> WidgetNode
+w.transparent_backgroundw.transparent_background(enabled: bool = True) -> "Widget"
+w.unlessw.unless(*families: str, layout: str = "layer") -> WidgetContainer
+w.validatew.validate(family: Optional[str] = None) -> Dict[str, Any]
+w.valuew.value(
+ value: Union[str, int, float, State[int], State[float], State[str], State[str]],
+ unit: Optional[str] = None,
+ subtitle: Optional[str] = None,
+ format: Optional[str] = None,
+) -> WidgetNode
+w.whenw.when(*families: str, layout: str = "layer") -> WidgetContainer
+.accentable.accentable(enabled: bool = True) -> Self
+.accessibility.accessibility(
+ label: Optional[str] = None,
+ value: Optional[str] = None,
+ hint: Optional[str] = None,
+ hidden: Optional[bool] = None,
+) -> Self
+.align.align(value: str) -> Self
+.animation.animation(
+ value: str = "default",
+ *,
+ duration: Optional[float] = None,
+ value_by: Optional[Any] = None,
+) -> Self
+.background.background(value: WidgetBackground) -> Self
+.bar_spacing.bar_spacing(value: float) -> Self
+.baseline.baseline(value: float = 0, color: Optional[ColorLike] = None) -> Self
+.button_style.button_style(value: str = "plain") -> Self
+.capsule.capsule(tone: Optional[str] = None, padding: Optional[Union[int, float]] = None, ) -> Self
+.clip.clip(kind: str = "roundedRectangle", corner_radius: Optional[float] = None) -> Self
+.clip_shape.clip_shape(kind: str = "roundedRectangle", corner_radius: Optional[float] = None) -> Self
+.color.color(value: ColorLike) -> Self
+.compressed.compressed(enabled: bool = True) -> Self
+.content_transition.content_transition(value: str = "opacity") -> Self
+.control_layout.control_layout(value: str = "overlay") -> Self
+.control_style.control_style(value: str = "plain") -> Self
+.corner_radius.corner_radius(value: float) -> Self
+.fill.fill(enabled: bool = True) -> Self
+.fixed_size.fixed_size(horizontal: bool = True, vertical: bool = True) -> Self
+.font.font(
+ value: Optional[Union[str, int, float, Dict[str, Any]]] = None,
+ *,
+ size: Optional[Union[float, Dict[str, Any]]] = None,
+ weight: Optional[str] = None,
+ **size_values: Any,
+) -> Self
+.font_size.font_size(size: float) -> Self
+.font_style.font_style(style: str) -> Self
+.font_weight.font_weight(weight: str) -> Self
+.font_width.font_width(width: str) -> Self
+.frame.frame(
+ x: float = 0,
+ y: float = 0,
+ width: Optional[float] = None,
+ height: Optional[float] = None,
+ *,
+ inset: Union[int, float] = 0,
+) -> _CanvasFrame
+.grid.grid(
+ rows: int,
+ columns: int,
+ *,
+ padding: Union[int, float] = 0,
+ gap: Union[int, float] = 0,
+ row_gap: Optional[float] = None,
+ column_gap: Optional[float] = None,
+ line_width: Optional[float] = None,
+ border: bool = False,
+ x: float = 0,
+ y: float = 0,
+ width: Optional[float] = None,
+ height: Optional[float] = None,
+) -> CanvasGrid
+.guide_lines.guide_lines(count: Union[bool, int] = True, color: Optional[ColorLike] = None) -> Self
+.height.height(value: Optional[Union[float, Dict[str, Any]]] = None, **values: Any) -> Self
+.hide.hide(*families: str) -> Self
+.id.id(value: Any) -> Self
+.importance.importance(value: str = "primary") -> Self
+.intent.intent(action: Union[str, WidgetAction]) -> Self
+.labels.labels(
+ start: Optional[Union[str, int, float]] = None,
+ end: Optional[Union[str, int, float]] = None,
+ color: Optional[ColorLike] = None,
+) -> Self
+.layout_priority.layout_priority(value: float = 1) -> Self
+.line_limit.line_limit(value: int) -> Self
+.line_width.line_width(value: Union[float, str]) -> Self
+.link.link(url: str) -> Self
+.mask.mask(kind: str = "roundedRectangle", corner_radius: Optional[float] = None) -> Self
+.mask_view.mask_view(align: str = "center") -> WidgetContainer
+.min_scale.min_scale(value: float) -> Self
+.monospaced.monospaced(enabled: bool = True) -> Self
+.monospaced_digit.monospaced_digit(enabled: bool = True) -> Self
+.normal.normal(**style: Any) -> Self
+.offset.offset(x: float = 0, y: float = 0) -> Self
+.opacity.opacity(value: float) -> Self
+.overflow.overflow(
+ action: Optional[str] = None,
+ *,
+ importance: Optional[str] = None,
+ preserve: Optional[bool] = None,
+) -> Self
+.overlay.overlay(color: ColorLike = "#000000", opacity: float = 0.18) -> Self
+.overlay_view.overlay_view(align: str = "center") -> WidgetContainer
+.padding.padding(
+ value: Optional[Union[int, float]] = None,
+ *,
+ horizontal: Optional[float] = None,
+ vertical: Optional[float] = None,
+ top: Optional[float] = None,
+ leading: Optional[float] = None,
+ bottom: Optional[float] = None,
+ trailing: Optional[float] = None,
+) -> Self
+.palette.palette(colors: Sequence[ColorLike]) -> Self
+.pixel_perfect_center.pixel_perfect_center(enabled: bool = True) -> Self
+.place.place(x: Optional[float] = None, y: Optional[float] = None, unit: str = "relative") -> Self
+.plain.plain(enabled: bool = True) -> Self
+.points.points(enabled: bool = True) -> Self
+.position.position(x: Optional[float] = None, y: Optional[float] = None, unit: str = "points") -> Self
+.preserve.preserve(enabled: bool = True) -> Self
+.pressed.pressed(**style: Any) -> Self
+.privacy_sensitive.privacy_sensitive(enabled: bool = True) -> Self
+.redacted.redacted(reason: str = "placeholder") -> Self
+.rendering.rendering(mode: str, colors: Optional[Sequence[ColorLike]] = None) -> Self
+.reverse_mask.reverse_mask(kind: str = "roundedRectangle", corner_radius: Optional[float] = None) -> Self
+.rotate.rotate(degrees: float) -> Self
+.rotation.rotation(degrees: float) -> Self
+.scale.scale(value: Union[float, str]) -> Self
+.segment_colors.segment_colors(colors: List[ColorLike]) -> Self
+.shadow.shadow(color: ColorLike = "#000000", radius: float = 4, x: float = 0, y: float = 2, ) -> Self
+.slot.slot(value: str) -> Self
+.soft_background.soft_background(
+ tone: Optional[str] = None,
+ corner_radius: Optional[float] = None,
+ padding: Optional[Union[int, float]] = None,
+) -> Self
+.stroke.stroke(
+ color: Optional[ColorLike] = None,
+ *,
+ width: Optional[Union[float, str]] = None,
+ dash: Optional[Sequence[float]] = None,
+ cap: Optional[str] = None,
+ join: Optional[str] = None,
+ miter_limit: Optional[float] = None,
+) -> Self
+.threshold.threshold(value: float, color: Optional[ColorLike] = None) -> Self
+.toggle_style.toggle_style(value: str = "checkbox") -> Self
+.tone.tone(value: str) -> Self
+.track_color.track_color(value: ColorLike) -> Self
+.transition.transition(value: str = "opacity", edge: Optional[str] = None) -> Self
+.variant.variant(value: str) -> Self
+.width.width(value: Optional[Union[float, Dict[str, Any]]] = None, **values: Any) -> Self
+文字节点返回的是可继续修改的句柄:
+w.text("ADHD Bingo", size=28, weight="bold", color="#242326") \
+ .line_limit(1) \
+ .min_scale(0.7)
+数值用 value(),需要数字变化动画时加 content_transition("numericText") 和稳定身份:
w.value(count, format="{} 次") \
+ .id("count") \
+ .monospaced_digit() \
+ .content_transition("numericText") \
+ .line_limit(1) \
+ .min_scale(0.7)
+推荐顺序是:内容 -> 字体和颜色 -> 尺寸 -> 位置 -> 动画或交互。
+w.text("喝水") \
+ .font_size(16) \
+ .color("#242326") \
+ .line_limit(1) \
+ .min_scale(0.7) \
+ .frame(width=72, height=44) \
+ .place(120, 80, unit="points")
+信息卡、进度卡、列表优先用 row()、column()、grid()。需要做表格、键盘、翻页钟、海报式排版时,用 table()、canvas()、.frame() 和 .place() 读取 w.context 后按点位布局。
渐变/双色背景、accentable 染色、图片/SVG/Symbol、透明与隐私占位。
+WidgetKit 负责浅色/深色、透明、系统染色(Tinted)等外观策略。脚本只声明意图——颜色、背景、图标、图片路径——不要用手写遮罩去模拟系统行为。
+边界:本篇讲背景、图标、图片、SVG 与 .accentable() 等外观 API。可调默认配色用 widget.param;布局防裁切见 布局与尺寸。
+| 项 | 说明 |
|---|---|
| 背景 | 纯色、(浅色, 深色)、渐变 dict、background_image |
| 系统表面 | container_background、transparent_background、content_margins |
| 图标 | w.symbol(SF Symbol)、w.svg(矢量文件) |
| 位图 | w.image、widget.param.file、widget.save_image |
| 染色 | .accentable(True/False) 控制是否参与系统 Tinted |
| 隐私 | .privacy_sensitive()、.redacted() 锁屏占位 |
渐变根背景 + 浅色文字,无需外部图片即可预览。
+预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+w = widget.Widget(
+ background={
+ "gradient": ["#4F46E5", "#7C3AED", "#DB2777"],
+ "direction": "vertical",
+ },
+ padding=16,
+)
+with w.row(spacing=10):
+ w.symbol("sparkles", scale="medium").color("#FFFFFF")
+ w.text("今日灵感", size=16, weight="semibold", color="#FFFFFF").line_limit(1)
+w.text("写一段只给自己的话", size=13, color="#E0E7FF").line_limit(2).min_scale(0.72)
+w.render()
+要点:
+Widget(background=...) 支持单色、("#F8FAFC", "#0F172A") 双色、或 {"gradient": [...], "direction": "vertical"}。"secondary"(随系统外观变化)。"vertical" / "horizontal"。脚本声明外观意图(颜色 / 背景 / 资源路径 / accentable)
+ ↓
+ PythonIDE 生成 Widget IR + 资源清单
+ ↓
+ WidgetKit 按系统外观(浅色 / 深色 / Tinted / 透明)渲染
+| API | 作用 |
|---|---|
Widget(background=...) | 卡片内容区底色(纯色 / 双色 / 渐变) |
w.container_background(..., removable=) | 系统 Widget 容器背景(可请求移除) |
w.background_image(...) | 铺满容器的背景图 + 遮罩 |
w.transparent_background(True) | 请求透明(宿主是否允许由系统决定) |
w.content_margins(False) | 边到边布局,去掉系统内容边距 |
普通卡片用 Widget(background=...);需要照片墙时用 background_image;需要融入主屏壁纸时组合 transparent_background + container_background(..., removable=True)。
| 来源 | 写法 |
|---|---|
| 脚本同目录 | "cover.jpg" |
assets/ 子目录 | "assets/icon.svg" |
| 用户文件参数 | widget.param.file("封面", "assets/cover.jpg", extensions=[...]) |
| 运行时注册 | widget.save_image(url_or_bytes, "avatar") → w.image("avatar") |
| 远程 URL | w.image("https://example.com/pic.png")(需网络,注意缓存) |
深色模式应为图片提供 dark 变体,不要只靠压暗浅色图。
+accentable)Tinted / 强调色模式下,只有 .accentable(True) 的节点会跟随系统染色;标题等需保持自定义色时显式 .accentable(False)。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+show = widget.param.text("标题", "专注电台")
+subtitle = widget.param.text("副标题", "深度工作 · 25 分钟")
+
+w = widget.Widget(background=("#F8FAFC", "#0F172A"), padding=14)
+w.container_background(("#F8FAFC", "#0F172A"), removable=True)
+w.content_margins(False)
+(
+ w.text(show, size=16, weight="semibold")
+ .line_limit(1)
+ .min_scale(0.72)
+ .accentable(False)
+)
+(
+ w.text(subtitle, size=12, color="secondary")
+ .line_limit(1)
+ .min_scale(0.72)
+ .accentable(False)
+)
+(
+ w.symbol(
+ "waveform.circle.fill",
+ rendering="palette",
+ palette=["#F59E0B", "#2563EB"],
+ variant="fill",
+ scale="large",
+ )
+ .accentable(True)
+)
+w.render()
+w.container_background(..., removable=True):允许系统在支持场景移除容器底。w.symbol(..., rendering="palette", palette=[...]):SF Symbol 多色渲染。.accentable(True);文案保持设计色 → .accentable(False)。layer 背景)用 layer 叠一块圆角面板,不必整张 Widget 都上色。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+accent = widget.param.color("面板强调", "#10B981")
+task = widget.param.text("待办", "整理发票拍照")
+
+w = widget.Widget(background=("#F1F5F9", "#020617"), padding=14)
+w.text("桌面便签", size=14, color="secondary").line_limit(1)
+with w.layer(background=("#FFFFFF", "#1E293B"), corner_radius=12, padding=12):
+ with w.row(spacing=8):
+ w.symbol("checkmark.circle").color(accent).font_size(18)
+ w.text(task, size=15, weight="semibold").line_limit(1).min_scale(0.72)
+ w.text("下班前完成", size=12, color="secondary").line_limit(1)
+w.render()
+background_image + 文件参数)浅色/深色各选一张图;scrim="bottom" 在底部加渐变遮罩,保证白字可读。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+light_bg = widget.param.file("浅色背景", "", extensions=["jpg", "jpeg", "png"])
+dark_bg = widget.param.file("深色背景", "", extensions=["jpg", "jpeg", "png"])
+title = widget.param.text("标题", "周末登山")
+
+w = widget.Widget(padding=14)
+w.background_image(
+ (light_bg, dark_bg),
+ content_mode="fill",
+ focal="center",
+ scrim="bottom",
+ scrim_opacity=0.48,
+)
+w.text(title, size=18, weight="bold", color="#FFFFFF").line_limit(1).min_scale(0.72)
+w.text("海拔 1280m", size=12, color="#E2E8F0").line_limit(1)
+w.render()
+assets/。focal="topTrailing" 等控制 fill 裁剪焦点;content_mode="fit" 可整张展示不裁剪。w.image)角标/封面用 w.image;支持圆角与浅色/深色双资源。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+avatar = widget.param.file("头像", "", extensions=["png", "jpg", "jpeg"])
+frame = widget.family_value(44, small=36, large=52)
+
+w = widget.Widget(background=("#FFFFFF", "#111827"), padding=14)
+with w.row(spacing=12):
+ w.image(avatar, width=frame, height=frame, corner_radius=frame / 2, content_mode="fill")
+ with w.column(spacing=4):
+ w.text("Py 开发者", size=15, weight="semibold").line_limit(1).min_scale(0.72).line_limit(1)
+ w.text("本周 commit +12", size=12, color="secondary").line_limit(1)
+w.render()
+若有两张图,可写 w.image(light="avatar-light.png", dark="avatar-dark.png", ...) 或元组 w.image(("light.png", "dark.png"), ...)。
w.svg)适合单色可着色的矢量角标;文件放 assets/ 或通过 param.file 选择。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+icon = widget.param.file("图标", "", extensions=["svg"])
+accent = widget.param.color("图标色", "#22C55E")
+size = widget.family_value(28, small=24, large=32)
+
+w = widget.Widget(background=("#FAFAFA", "#1C1C1E"), padding=14)
+with w.row(spacing=10):
+ w.svg(icon, width=size, height=size, color=accent, content_mode="fit")
+ with w.column(spacing=2):
+ w.text("自定义 SVG", size=15, weight="semibold").line_limit(1).min_scale(0.72).line_limit(1)
+ w.text("path + viewBox 图标", size=12, color="secondary").line_limit(1)
+w.render()
+预览侧栏选本地 .svg 文件;简单场景优先 w.symbol(...),免维护文件。
步数等敏感数字在锁屏可模糊;与 时间线和动画 的 numericText 可叠加。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+steps = widget.state.int("steps", 8420)
+
+w = widget.Widget(background=("#F0FDF4", "#14532D"), padding=14)
+(
+ w.text("今日步数", size=14, color="secondary")
+ .line_limit(1)
+ .privacy_sensitive()
+)
+(
+ w.value(steps, unit="步")
+ .monospaced_digit()
+ .line_limit(1)
+ .min_scale(0.72)
+ .privacy_sensitive()
+ .redacted("placeholder")
+)
+w.text("锁屏显示占位,解锁后见完整数字", size=11, color="secondary").line_limit(1)
+w.render()
+.privacy_sensitive():标记敏感内容。.redacted("placeholder"):锁屏显示系统占位样式。请求透明需同时声明容器可移除与透明开关;桌面是否真透明取决于宿主与系统。
+w = widget.Widget(padding=12)
+w.transparent_background(True)
+w.container_background("clear", removable=True)
+w.content_margins(False)
+w.text("透明卡片", size=15, weight="semibold").accentable(True)
+w.render()
+不要在透明卡片上再叠不透明全屏 Widget(background=...),否则透明意图会被盖住。
# 纯色 / 双色 / 渐变
+w = widget.Widget(background="#FFFFFF")
+w = widget.Widget(background=("#F8FAFC", "#0F172A"))
+w = widget.Widget(background={"gradient": ["#3B82F6", "#8B5CF6"], "direction": "horizontal"})
+
+w.background(("#FAFAFA", "#18181B")) # 运行时改根背景
+w.container_background(bg, removable=True) # 系统容器底
+w.transparent_background(True) # 请求透明
+w.content_margins(False) # 边到边
+
+w.background_image(
+ ("day.jpg", "night.jpg"),
+ content_mode="fill",
+ scrim="bottom",
+ scrim_opacity=0.45,
+ focal="center",
+)
+w.symbol("star.fill", scale="large").color("#F59E0B")
+w.symbol("paintpalette.fill", rendering="palette", palette=["#EF4444", "#3B82F6"])
+
+w.svg("assets/logo.svg", width=24, height=24, color="#111827")
+w.image("cover.jpg", width=60, height=60, corner_radius=8)
+w.image(light="avatar-light.png", dark="avatar-dark.png", rendering_mode="accented")
+w.text("标题").accentable(False)
+w.symbol("bell.fill").accentable(True)
+w.text("余额").privacy_sensitive().redacted("placeholder")
+# 脚本中下载或生成后注册,供 w.image / background_image 按名称引用
+path = widget.save_image("https://example.com/pic.png", "cover")
+w.image("cover", width=80, height=80)
+完整签名见 API 参考:container_background、background_image、transparent_background、symbol、svg、image、.accentable、.privacy_sensitive、.redacted。
| 错误写法 | 后果 | 修正 |
|---|---|---|
| 染色模式下标题变色 | 未关闭 accentable | 设计色文案 .accentable(False) |
| 图标不跟系统色 | 写了 .accentable(False) | 应用染色的图标 .accentable(True) |
| 深色模式图片发灰 | 只有浅色图 | background_image((light, dark)) 或 image(light=, dark=) |
| 透明不生效 | 只设了 Widget 底色 | transparent_background + container_background(removable=True) |
| SVG 空白 | 路径错或预览无文件 | 放 assets/ 或用 param.file 重选 |
| 背景图字看不清 | 无遮罩 | scrim="bottom" + scrim_opacity |
| 用手写 rect 模拟系统底 | 与 WidgetKit 策略冲突 | 用 container_background / transparent_background |
w.symbol(..., color=, size=) | TypeError | w.symbol("name").color(c).font_size(n) |
| 现象 | 处理 |
|---|---|
| 预览浅色正常、深色异常 | 检查 (light, dark) 是否成对提供 |
| Tinted 模式颜色全乱 | 梳理每个节点的 accentable 默认值与显式设置 |
| 图片不显示 | 路径、extensions、是否已 save_image 注册 |
| SVG 构建报错 | 确认是图标级 SVG(viewBox + path);复杂 SVG 可能需简化 |
| 锁屏数字太清晰 | 加 .privacy_sensitive() + .redacted("placeholder") |
| 透明在桌面无效 | 系统/宿主限制;回退半透明双色背景 |
| 预览与桌面不一致 | 重新发布;查 排错 |
| 文档 | 用途 |
|---|---|
| widget 概览 | 总览 |
| 参数面板 | param.color / param.file |
| 布局与尺寸 | 图标尺寸、family_value |
| 时间线和动画 | numericText 与隐私数字叠加 |
| 排错 | 预览/桌面外观不一致 |
| API 参考 | 完整签名 |
相关 API:widget-api-reference.md
+widget.state、按钮 ±、习惯清单、开关与链接;AppIntent 桌面交互。
+桌面小组件不执行任意 Python 回调。按钮、开关、列表勾选只能触发受控 AppIntent:改写 widget.state、刷新时间线,或打开链接。
边界:本篇讲桌面点击与 widget.state。预览面板里的配色、标题默认值用 widget.param,不要混用。
+| 项 | 说明 |
|---|---|
| 何时用 | 计数器、完成开关、习惯勾选、打开网页/深链 |
| 核心 API | widget.state.* → .increment() / .toggle() / .toggle_item() |
| 节点 | w.button(..., action=)、w.toggle(..., state=)、w.link(...) |
| 限制 | action= 必须来自 state 工厂;不能传 Python 函数 |
| 发布后 | 改 state key 需重新发布;桌面可能需删组件重装 |
单按钮累加 + 数字过渡:比 概览 · 饮水打卡 的 ± 环图更简单,专讲 action 与 numericText。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+streak = widget.state.int("streak", 0)
+accent = widget.param.color("主色", "#2563EB")
+
+w = widget.Widget(background=("#FFFFFF", "#0F172A"), padding=14)
+w.text("连续签到", size=16, weight="semibold").line_limit(1)
+(
+ w.value(streak, unit="天")
+ .id("streak")
+ .content_transition("numericText")
+ .monospaced_digit()
+ .line_limit(1)
+ .min_scale(0.72)
+)
+(
+ w.button("签到", action=streak.increment(), background=accent, color="#FFFFFF")
+ .line_limit(1)
+ .min_scale(0.72)
+ .pressed(scale=0.94)
+)
+w.render()
+要点:
+streak.increment() 返回 AppIntent,赋给 action=;不能写字符串或 Python 函数。.content_transition("numericText") + .id(...):数字变化时系统过渡动画。.pressed(scale=0.94) 等价于 button(..., press={"scale": 0.94})。widget.param.color 只调配色,不改桌面计数值。state.list + toggle_item)多项勾选用 widget.state.list;每项 done.toggle_item(i) 切换是否在列表里。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+habits = ["晨跑", "喝水 8 杯", "阅读 30 分", "整理桌面"]
+done = widget.state.list("habits_done", [])
+accent = widget.param.color("完成色", "#22C55E")
+
+w = widget.Widget(background=("#FAFAFA", "#1C1C1E"), padding=14)
+w.text("今日习惯", size=16, weight="semibold").line_limit(1)
+for i, label in enumerate(habits):
+ checked = done.contains(i)
+ mark = "✓ " if checked else "○ "
+ (
+ w.button(
+ mark + label,
+ action=done.toggle_item(i),
+ style="plain",
+ color=accent if checked else "secondary",
+ size=13,
+ )
+ .line_limit(1)
+ .min_scale(0.72)
+ )
+w.render()
+done.contains(i):预览/桌面判断第 i 项是否已勾选;toggle_item(i) 的 i 类型须与 contains 一致。w.toggle(..., state=);长清单用 state.list 更省节点。table() 示例。state.bool + w.toggle)布尔状态用 state= 绑定,不要手写静态 True/False 配假 action。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+alarm_on = widget.state.bool("alarm_on", False)
+accent = widget.param.color("开启色", "#F59E0B")
+
+w = widget.Widget(background=("#FFFBEB", "#292524"), padding=14)
+w.text("早起闹钟", size=16, weight="semibold").line_limit(1)
+w.toggle("明早 7:00", state=alarm_on, color=accent, style="switch")
+# 脚本每次重建时读取 state 当前值(非 AppUI 式实时绑定)
+status = "已开启,记得早睡" if alarm_on else "已关闭"
+w.text(status, size=13, color=accent if alarm_on else "secondary").line_limit(1).min_scale(0.72)
+w.render()
+w.toggle(..., color=accent):着色用 color=,没有 tint= 参数。style="switch" / "checkbox" 控制外观;与 state= 搭配最省事。w.link)链接打开 URL;与按钮/开关不要叠在同一块可点区域。
+预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+label = widget.param.text("链接标题", "Apple 官网")
+accent = widget.param.color("链接色", "#2563EB")
+
+w = widget.Widget(background=("#FFFFFF", "#111827"), padding=14)
+w.text("快捷入口", size=16, weight="semibold").line_limit(1)
+w.link(label, "https://www.apple.com", icon="safari.fill", color=accent)
+w.text("点击在 Safari 打开", size=12, color="secondary").line_limit(1).min_scale(0.72)
+w.render()
+icon= 在标题旁显示 SF Symbol。icon= 时返回行容器,不能链 .line_limit();无 icon 时返回 text 句柄才可链修饰符。button 分清层级;同一容器不要既当链接又塞按钮。widget.state.int/bool/list/... ← 桌面持久化
+ ↓
+.increment() / .toggle() / .toggle_item(i) ← AppIntent 工厂
+ ↓
+w.button(..., action=...) 或 w.toggle(..., state=...)
+ ↓
+用户点击 → 扩展执行 Intent → 状态改写 → WidgetKit 刷新时间线
+Widget() 构建前,与 widget.param 同级。count.increment()、done.toggle()、items.toggle_item(2)。state=:w.toggle("标题", state=done),让运行时自动绑 done.toggle()。w.link(title, url) 直接打开;深链同样写 URL 字符串。"score" 改成 "points" 后,旧桌面实例可能仍读写 "score"。widget.param 怎么分widget.param | widget.state | |
|---|---|---|
| 谁改 | 预览面板 / Studio | 桌面点击 |
| 典型 | 主题色、默认标题 | 计数、开关、清单勾选 |
| 传给 UI | color=、background= | value / state= + action |
| API | 适合 | 常用动作 |
|---|---|---|
widget.state.int(key, default) | 次数、步数、分数 | .increment(by=1)、.decrement(by=1)、.set(n) |
widget.state.float(key, default) | 进度、比例 | 同上 |
widget.state.bool(key, default) | 完成、开启 | .toggle();配合 w.toggle(..., state=) |
widget.state.str(key, default) | 当前模式、选中项 | .set("模式A") |
widget.state.list(key, default) | 多选清单、Bingo | .toggle_item(item)、.contains(item)、.set([]) |
key 是持久化 ID;改名等同新状态,需重新发布。
count = widget.state.int("count", 0)
+done = widget.state.bool("done", False)
+items = widget.state.list("picked", [])
+
+w.button("加 1", action=count.increment())
+w.button("减 2", action=count.decrement(by=2))
+w.button("归零", action=count.set(0), style="plain")
+w.toggle("完成", state=done, color="#22C55E")
+w.button("选 A", action=items.toggle_item("A"), style="plain")
+w.link("文档", "https://example.com", icon="book.fill")
+w.button("+", action=count.increment()).pressed(scale=0.94)
+w.value(count).content_transition("numericText").monospaced_digit()
+| 错误写法 | 后果 | 修正 |
|---|---|---|
action="increment" 或 Python 函数 | 点击无反应 | 用 count.increment() 等工厂 |
w.toggle(..., tint=accent) | TypeError | 用 color=accent |
静态 True + 手写 toggle action | 开关不保存 | w.toggle(..., state=done) |
用 widget.param.bool 当桌面开关 | 只在预览里变 | 交互用 widget.state.bool |
链接容器里再塞 button | 点击区域冲突 | 链接、按钮分块布局 |
| 改 state key 不重发 | 计数错乱或归零 | 重新发布;删桌面组件重装 |
| 现象 | 处理 |
|---|---|
| 按钮点击没反应 | 确认 action 来自 widget.state 工厂方法 |
| Toggle 显示但不记忆 | 改用 state=,勿拼静态布尔 |
| 清单勾选无效 | toggle_item 的参数类型与 contains 一致(都用 int 或都用 str) |
| 链接打不开 | 检查 URL 含 https://;勿与按钮叠层 |
| 点击后桌面仍旧 | AppIntent 已执行但 WidgetKit 缓存 → 重新运行发布;删组件重装 |
| 预览能点、桌面不能 | 确认已走 从脚本到桌面 发布流程 |
| 文档 | 用途 |
|---|---|
| widget 概览 | 总览与入门示例 |
| 参数面板 | widget.param 与 state 区分 |
| 从脚本到桌面 | 发布后在桌面验证交互 |
| 布局与尺寸 | table() 九宫格、row() 横排 |
| 时间线和动画 | content_transition、数字过渡 |
| 排错 | 预览/桌面不一致 |
| API 参考 | widget.state、button、toggle、link 签名 |
相关 API:widget-api-reference.md
+row/column/grid/table/canvas、family 预算、when/unless 与锁屏 accessory。
+小组件布局以 WidgetKit family 为边界:先定目标尺寸与信息密度,再选容器;不要把 large 的内容硬塞进 small。
+边界:本篇讲 row/column/grid/table/canvas、+widget.context与防裁切。桌面点击与widget.state见 状态和交互;参数面板见 参数面板。
| 项 | 说明 |
|---|---|
| 核心原则 | 按 family 设计,不是把同一套 UI 整体缩小 |
| 容器 | row() / column() / grid() / table() / canvas() / layer() |
| 尺寸 API | widget.context、widget.family_value()、w.when() / w.unless() |
| 主屏 | small / medium / large(158²、338×158、338×354 内容区) |
| 锁屏 | circular / rectangular / inline — 极简,节点数严格受限 |
| 收尾 | 可变文案加 .line_limit(1).min_scale(0.65+) |
row() 横排三列指标,column() 在每列里纵向叠标题与数值——适合仪表盘,比单列更省高度。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+steps = widget.param.number("步数", 8420, min=0, max=50000)
+calories = widget.param.number("千卡", 420, min=0, max=5000)
+sleep_h = widget.param.number("睡眠(h)", 7, min=0, max=12)
+accent = widget.param.color("主色", "#10B981")
+
+w = widget.Widget(background=("#ECFDF5", "#064E3B"), padding=14)
+w.text("今日健康", size=16, weight="semibold").line_limit(1)
+with w.row(spacing=12):
+ with w.column(spacing=2):
+ w.text("步数", size=11, color="secondary").line_limit(1)
+ w.value(steps).monospaced_digit().line_limit(1).min_scale(0.72)
+ with w.column(spacing=2):
+ w.text("千卡", size=11, color="secondary").line_limit(1)
+ w.value(calories).monospaced_digit().line_limit(1).min_scale(0.72)
+ with w.column(spacing=2):
+ w.text("睡眠", size=11, color="secondary").line_limit(1)
+ (
+ w.value(sleep_h, unit="h")
+ .monospaced_digit()
+ .line_limit(1)
+ .min_scale(0.72)
+ )
+w.rect(color=accent, height=3, corner_radius=2)
+w.render()
+要点:
+with w.row(): / with w.column(): 上下文管理器,不要把子节点当位置参数传入。spacing= 控制子项间距;align= 可选 leading / center / trailing。选定 family(small / medium / large / 锁屏 accessory)
+ ↓
+读 widget.context → content_width × content_height(扣掉 padding 后的可画区域)
+ ↓
+决定信息层级:标题 → 主值 → 次要说明 → 图表/表格
+ ↓
+选容器:语义流式用 row/column/grid;精确格子用 table;点位绘制用 canvas
+ ↓
+防裁切:line_limit + min_scale;small 隐藏次要行
+以下为默认 padding 下的内容区宽高,脚本里用 widget.context 或 w.context 读取(属性,不可调用;不要写 widget.context()):
| Family | 约 content 宽 × 高 | 设计建议 |
|---|---|---|
small | 158 × 158 | 标题 + 1 主值 + 1 视觉元素 |
medium | 338 × 158 | 默认主力版;可横排 2–3 列 |
large | 338 × 354 | 可加第二段:图表、表格、说明行 |
circular | 64 × 64 | SF Symbol + 极短数字/缩写 |
rectangular | 160 × 56 | 一行或图标+一行字 |
inline | 220 × 22 | 单行文字,无图表/表格 |
常量:widget.SMALL、widget.MEDIUM、widget.LARGE、widget.CIRCULAR、widget.RECTANGULAR、widget.INLINE。
| API | 底层 | 适合 |
|---|---|---|
row(spacing=, align=) | 横向栈 | 多指标横排、按钮组 |
column(spacing=, align=) | 纵向栈 | 表单式上下结构 |
layer(align=) | 叠放 | 背景块上叠文字/图标 |
grid(columns=, spacing=, equal=) | 等列网格 | 周历、四宫格摘要 |
table(rows, columns) | 单路径表格线 | Bingo、日历格、精确分割线 |
canvas(fill=, coordinate_space=) | 点位坐标 | 表盘、键盘、自定义图形 |
日常卡片优先 row / column;需要不重叠的细表格线用 table(),不要用多个 rect 拼边框(线宽会叠粗、交点发圆)。
is_family 分支)同一脚本为 small 只保留单号+状态,medium 加图标行,large 再补预计送达。
+预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+tracking = widget.param.text("单号", "SF1234567890")
+status = widget.param.text("状态", "运输中")
+eta = widget.param.text("预计", "明天 18:00")
+accent = widget.param.color("主色", "#F59E0B")
+ctx = widget.context
+pad = widget.family_value(14, small=10, large=16)
+
+w = widget.Widget(background=("#FFFBEB", "#292524"), padding=pad)
+
+if ctx.is_family("small"):
+ w.text("快递", size=14, weight="semibold", color=accent).line_limit(1)
+ w.text(tracking, size=12).line_limit(1).min_scale(0.6)
+ w.text(status, size=11, color="secondary").line_limit(1).min_scale(0.72)
+else:
+ w.text("快递追踪", size=16, weight="semibold").line_limit(1)
+ w.text(tracking, size=12, color="secondary").line_limit(1).min_scale(0.65)
+ with w.row(spacing=8):
+ w.symbol("shippingbox.fill").color(accent).font_size(16)
+ w.text(status, size=14, weight="semibold").line_limit(1).min_scale(0.72)
+ if ctx.is_family("large"):
+ w.text(f"预计送达 {eta}", size=12, color="secondary").line_limit(1)
+ else:
+ w.text(f"预计 {eta}", size=12, color="secondary").line_limit(1).min_scale(0.72)
+
+w.render()
+ctx.is_family("small") 等价于 ctx.is_family(widget.SMALL)。.min_scale(0.6) 或缩短显示字段。family_value 与 w.when("large")只改字号/边距用 family_value;整段 UI 仅 large 出现时用 w.when。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+show = widget.param.text("节目", "心灵马杀鸡")
+episode = widget.param.text("集数", "第 128 期")
+progress = widget.param.slider("进度", 0.42, min=0, max=1, step=0.01)
+accent = widget.param.color("主色", "#8B5CF6")
+
+title_size = widget.family_value(16, small=14, large=18)
+bar_h = widget.family_value(6, small=5, large=8)
+pad = widget.family_value(14, small=10, large=16)
+
+w = widget.Widget(background=("#FAF5FF", "#1E1B4B"), padding=pad)
+w.text(show, size=title_size, weight="semibold").line_limit(1).min_scale(0.72)
+w.text(episode, size=12, color="secondary").line_limit(1).min_scale(0.72)
+w.progress(float(progress), total=1, color=accent, height=bar_h)
+
+with w.when("large", layout="column"):
+ w.text("继续收听 →", size=12, color=accent).line_limit(1)
+
+w.render()
+w.when("large", layout="column"):仅 large 渲染块内子节点;layout 还可为 "row" / "layer"。w.unless("small"):除 small 外都显示,适合「medium/large 才显示的脚注」。grid 周历打卡grid(columns=7) 按子节点顺序从左到右、从上到下填格;equal=True 等宽列。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+days = ["一", "二", "三", "四", "五", "六", "日"]
+plan = widget.param.choice("打卡计划", ["一三五", "天天练", "周末"], default="一三五")
+accent = widget.param.color("打卡色", "#EF4444")
+
+active_sets = {
+ "一三五": {0, 2, 4},
+ "天天练": set(range(7)),
+ "周末": {5, 6},
+}
+active = active_sets[str(plan)]
+
+w = widget.Widget(background=("#FFFFFF", "#1C1C1E"), padding=12)
+w.text("本周训练", size=15, weight="semibold").line_limit(1)
+with w.grid(columns=7, spacing=4, equal=True):
+ for i, day in enumerate(days):
+ mark = "●" if i in active else "○"
+ (
+ w.text(day + mark, size=10, color=accent if i in active else "secondary")
+ .line_limit(1)
+ .min_scale(0.55)
+ .align("center")
+ )
+w.render()
+table 九宫格(Bingo)习惯打卡的纵向列表见 状态和交互;需要可见格线时用 table() + table.cell(row, col)。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+items = ["喝水", "散步", "整理", "阅读", "计划", "备份", "写作", "放松", "复盘"]
+done = widget.state.list("bingo_done", [])
+accent = widget.param.color("完成色", "#4F9A48")
+
+w = widget.Widget(background=("#FBFAF5", "#141414"), padding=10)
+w.text("今日九宫格", size=17, weight="bold").line_limit(1).min_scale(0.72).align("center")
+
+with w.table(rows=3, columns=3, line_color=accent, line_width="hairline", fill=True) as table:
+ for i, label in enumerate(items):
+ checked = done.contains(i)
+ with table.cell(i // 3, i % 3):
+ (
+ w.button(
+ ("✓ " if checked else "") + label,
+ action=done.toggle_item(i),
+ style="plain",
+ color=accent if checked else "#242326",
+ size=12,
+ press={"scale": 0.94},
+ normal={"scale": 1},
+ )
+ .line_limit(1)
+ .min_scale(0.55)
+ .align("center")
+ )
+
+w.render()
+表格线建议:
+line_width="hairline":最细像素线。line_cap="butt"、line_join="miter":端点不圆、交点直角。.min_scale(0.55),避免 small 格子裁切。canvas 读取可画区域需要按点坐标摆放(表盘刻度、自定义键盘)时,先读 content_width / content_height,再 .place()。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+w = widget.Widget(background="#FFFFFF", padding=12)
+ctx = widget.context
+cw = ctx.content_width
+ch = ctx.content_height
+
+with w.canvas(fill=True, coordinate_space="points") as c:
+ c.rect(color="#EEF2F7", width=cw, height=ch).place(cw / 2, ch / 2, unit="points")
+ (
+ c.text(f"{int(cw)} × {int(ch)} pt", size=14, weight="semibold")
+ .line_limit(1)
+ .min_scale(0.72)
+ .place(cw / 2, ch / 2, unit="points")
+ )
+ (
+ c.text(ctx.family, size=11, color="secondary")
+ .line_limit(1)
+ .place(cw / 2, ch / 2 + 16, unit="points")
+ )
+
+w.render()
+ctx.width / ctx.height 含 padding 前预算;摆放内容用 content_*。coordinate_space="points":.place(x, y, unit="points") 以画布左上为原点。锁屏圆形/矩形/行内节点上限低(见上表);按 family 分别写布局,不要复用 medium 整块 UI。
+预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+steps = widget.state.int("steps", 6234)
+accent = widget.param.color("主色", "#34D399")
+ctx = widget.context
+
+w = widget.Widget(background=("#F0FDF4", "#14532D"), padding=widget.family_value(8, circular=0, rectangular=4, inline=0))
+
+if ctx.is_family("circular"):
+ w.symbol("figure.walk").color(accent).font_size(14)
+ w.text(str(int(steps) // 1000) + "k", size=11, weight="bold").line_limit(1).min_scale(0.6)
+elif ctx.is_family("rectangular"):
+ with w.row(spacing=6):
+ w.symbol("figure.walk").color(accent).font_size(14)
+ (
+ w.value(steps, unit="步")
+ .monospaced_digit()
+ .line_limit(1)
+ .min_scale(0.65)
+ )
+elif ctx.is_family("inline"):
+ w.text(f"今日 {int(steps)} 步", size=12).line_limit(1).min_scale(0.72)
+else:
+ w.text("请切换到锁屏 accessory 预览", size=13, color="secondary").line_limit(2).min_scale(0.7)
+
+w.render()
+预览里选 circular / rectangular / inline 查看效果;发布后在锁屏编辑界面单独添加(见 从脚本到桌面)。
+| 手段 | 用法 |
|---|---|
| 单行 + 缩放 | .line_limit(1).min_scale(0.65)(small 可降到 0.55) |
| 等宽数字 | .monospaced_digit() 避免数字跳动挤版 |
| 按 family 藏行 | if not ctx.is_family("small"): w.text(...) |
| 按 family 改字号 | widget.family_value(16, small=14, large=18) |
| 按 family 改边距 | padding=widget.family_value(14, small=10, large=18) |
| large 填空白 | w.when("large") 增加图表、说明、第二表格 |
| 格内文字 | 表格/Bingo .min_scale(0.55) + .align("center") |
widget.contextctx = widget.context # 或 w.context(绑定 Widget padding)
+ctx.family # "small" | "medium" | "large" | ...
+ctx.width / ctx.height # family 预算(点)
+ctx.content_width # 扣 padding 后可画宽
+ctx.content_height # 扣 padding 后可画高
+ctx.is_family("small", "medium")
+ctx.value(default, small=..., large=...) # 同 family_value,读当前 family
+widget.family_valuesize = widget.family_value(16, small=14, medium=16, large=20)
+pad = widget.family_value(14, small=10, rectangular=6, circular=4, inline=0)
+with w.row(spacing=10, align="center"):
+ w.symbol("star.fill").color("#F59E0B")
+ w.text("标题")
+
+with w.column(spacing=4, align="leading"):
+ w.text("副标题", size=12, color="secondary")
+
+with w.layer(align="center", background="#EEF2F7", corner_radius=8):
+ w.text("叠放")
+
+with w.grid(columns=2, spacing=8, equal=True):
+ w.text("A")
+ w.text("B")
+
+with w.when("large", layout="column"):
+ w.line_chart([1, 3, 2, 5])
+完整签名见 API 参考:row、column、grid、table、canvas、when、unless、family_value、.place()、.align()。
| 错误写法 | 后果 | 修正 |
|---|---|---|
w.row(w.text(...), spacing=8) | 子节点不进容器 | with w.row(): 块内添加 |
| large 布局原样给 small | 文字裁切、按钮重叠 | is_family / family_value 分支 |
多个 rect 拼表格线 | 线粗、交点圆 | 用 w.table(...) |
| 锁屏用 medium 整块 UI | 构建警告或裁切 | circular/rectangular/inline 单独写 |
canvas 用 width 当边界 | 内容溢出 padding | 用 content_width / content_height |
忘记 line_limit | 多行撑破高度 | 可变文案 .line_limit(1).min_scale(...) |
grid 子节点过多 | 挤到下一行难控 | 控制列数与单元内容极简 |
widget.context() / w.context() | TypeError: not callable | 用 ctx = widget.context 或 w.context |
press={"scale": 0.94} 写在 w.button(...) 参数里;.pressed(scale=0.94) 是链式修饰符,二者等价。交互示例见 状态和交互。
| 现象 | 处理 |
|---|---|
| small 文字被裁切 | 减行数;min_scale 降到 0.55–0.65;隐藏次要 text |
| medium 正常、large 空 | w.when("large") 加图表/说明;勿只放大字号 |
| 表格线发粗/交点圆 | line_width="hairline"、line_cap="butt"、line_join="miter" |
| canvas 元素跑出边界 | 改用 content_* 算坐标;fill=True 铺满可画区 |
| 锁屏 accessory 空白 | 预览切换到 circular/rectangular/inline;检查是否走了 else 占位分支 |
| row 里列挤成一团 | 增大 spacing;减少列数;small 改单列 column |
| 预览与桌面布局不一致 | 两端的 family 要一致;查 排错 |
| 文档 | 用途 |
|---|---|
| widget 概览 | 总览与入门 |
| 参数面板 | widget.param 与布局配色 |
| 状态和交互 | 表格格内 toggle_item、按钮 |
| 从脚本到桌面 | 锁屏 accessory 添加步骤 |
| 时间线和动画 | 数字过渡、flip 动画 |
| 资源与外观 | 背景、渐变、图片 |
| 排错 | 裁切与预览不一致 |
| API 参考 | 布局节点完整签名 |
相关 API:widget-api-reference.md
+Python 编写 WidgetKit 小组件:参数、状态、布局与发布到桌面。
+用 Python 描述主屏、锁屏与 StandBy 小组件;由 PythonIDE + WidgetKit 负责渲染、刷新与桌面交互。
+边界:+widget是 WidgetKit 时间线模型,不是普通 App 页面。不要用 appui 做小组件,不要用 scene 做 60fps 游戏画布。脚本只描述布局、参数、状态与时间线,结尾必须w.render()。
| 项 | 说明 |
|---|---|
| 导入 | import widget |
| 适合做什么 | 计数器、进度、图表卡片、锁屏 accessory、可点按钮/开关 |
| 入口 | w = widget.Widget(...) → 组合节点 → w.render()(只创建一个 Widget) |
| 可调参数 | widget.param.text/color/number/slider/bool/choice/file — 预览面板可编辑 |
| 桌面交互 | widget.state.int/bool/... → count.increment()、done.toggle() 等 action |
| 尺寸适配 | widget.context、widget.family_value(...) 按 small/medium/large 分支 |
| 发布 | Widget Studio 运行后发布;文档 previewWidget 仅预览 → 从脚本到桌面 |
运行下面脚本会打开小组件预览(非 AppUI 全屏)。用环形图展示饮水进度,+ / − 在桌面改写杯数。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+goal = widget.param.number("每日杯数", 8, min=1, max=12)
+glasses = widget.state.int("glasses", 0)
+accent = widget.param.color("主色", "#38BDF8")
+
+w = widget.Widget(background=("#F0F9FF", "#0C4A6E"), padding=14)
+w.text("饮水打卡", size=16, weight="semibold").line_limit(1).min_scale(0.72)
+w.ring_chart(
+ min(int(glasses), int(goal)),
+ total=int(goal),
+ label=f"{int(glasses)}/{int(goal)} 杯",
+ color=accent,
+)
+with w.row(spacing=10):
+ (
+ w.button("−", action=glasses.decrement(), style="plain")
+ .line_limit(1)
+ .min_scale(0.72)
+ )
+ (
+ w.button("+", action=glasses.increment(), background=accent, color="#FFFFFF")
+ .line_limit(1)
+ .min_scale(0.72)
+ )
+w.render()
+要点:
+widget.param.number(...):预览里可调每日目标杯数。widget.state.int(...) + ring_chart(...):桌面点击后环与标签同步刷新。with w.row(...):横排多个按钮,比纵向堆叠更省高度。.line_limit(1).min_scale(...):防止 small/medium 文字被裁切。纯展示卡片:无 widget.state,演示 param.text、symbol 与 family_value;small 隐藏出处行。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+quote = widget.param.text("名言", "专注一件事,做到极致。")
+author = widget.param.text("出处", "— 佚名")
+ink = widget.param.color("文字色", "#E2E8F0")
+title_size = widget.family_value(17, small=15, large=20)
+body_size = widget.family_value(15, small=13, large=17)
+
+w = widget.Widget(background=("#0F172A", "#020617"), padding=16)
+with w.row(spacing=8):
+ w.symbol("quote.bubble.fill").color("#60A5FA").font_size(18)
+ w.text("每日一言", size=14, weight="semibold", color="secondary").line_limit(1)
+w.text(quote, size=body_size, color=ink).line_limit(3).min_scale(0.7)
+if not widget.context.is_family("small"):
+ w.text(author, size=12, color="secondary").line_limit(1).min_scale(0.72)
+w.render()
+静态数据折线 + 可调曲线色;适合仪表盘类小组件,不涉及桌面点击。
+预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+title = widget.param.text("标题", "本周专注")
+accent = widget.param.color("曲线色", "#6366F1")
+minutes = [25, 40, 15, 50, 30, 45, 20]
+chart_h = widget.family_value(48, small=36, large=56)
+
+w = widget.Widget(background=("#F5F3FF", "#1E1B4B"), padding=14)
+with w.row(spacing=8):
+ w.symbol("brain.head.profile").color(accent).font_size(18)
+ w.text(title, size=16, weight="semibold").line_limit(1).min_scale(0.72)
+w.line_chart(minutes, color=accent, height=chart_h, fill=True)
+if not widget.context.is_family("small"):
+ w.text("分钟 / 天(示例数据)", size=11, color="secondary").line_limit(1)
+w.render()
+w = widget.Widget(background=..., padding=..., style="clean") — 只管整体背景与边距。text、value、symbol、image、progress、line_chart 等。row() / column() / grid();精确定位才用 canvas() / table()。widget.param.* 在构建 UI 之前声明,供预览面板与 Studio 持久化。widget.state.* 生成桌面可执行的 increment / toggle / set action。widget.context.family、content_width、content_height 做尺寸分支。w.render()。| 需求 | 首选 | 原因 |
|---|---|---|
| 主屏/锁屏/StandBy 卡片 | widget | WidgetKit 时间线与系统刷新预算 |
| 设置页、表单、导航 App | appui | 完整交互与滚动列表 |
| 触摸游戏、逐帧动画 | scene | 实时帧循环,不是 Widget 模型 |
| 教材海龟绘图 | turtle | 单次画布,非桌面组件 |
| 适合 | 不适合 |
|---|---|
| 静态/准静态展示、数字过渡动画 | 文本输入框、长列表滚动 |
| 按钮、开关、链接(AppIntent) | WebView、视频、复杂手势 |
| 时间线刷新、深色/透明/渐变背景 | 60fps 实时动画、任意 Python 回调在扩展内执行 |
| API | 作用 |
|---|---|
Widget(...) | 根容器;唯一入口 |
w.render() | 提交 IR,生成预览/桌面时间线 |
widget.param.text/color/number/slider/bool/choice/file | 用户可调参数 |
widget.state.int/float/bool/str/list | 持久状态 + action 工厂 |
count.increment() / done.toggle() | 按钮/开关的 action= |
widget.context | 当前 family 与内容区尺寸 |
widget.family_value(d, small=..., large=...) | 按尺寸返回值 |
w.timeline(...) | 多条时间线条目(见专题文档) |
widget.SMALL / MEDIUM / LARGE | 尺寸常量 |
widget.param 声明| 方法 | 用途 |
|---|---|
text(name, default) | 标题、文案 |
color(name, default) | 十六进制或语义色 |
number(name, default, min=, max=) | 整数/小数参数 |
slider(name, default, min=, max=, step=) | 滑块 |
bool(name, default) | 开关类参数(预览面板,不是桌面 state) |
choice(name, options, default=) | 下拉选项 |
file(name, default, extensions=) | 图片等资源路径 |
第一个参数 name 是参数 ID,同时作为预览面板默认标题。
widget.state 与交互count = widget.state.int("count", 0)
+done = widget.state.bool("done", False)
+
+w.button("加 1", action=count.increment())
+w.button("减 1", action=count.decrement(by=2))
+w.toggle("完成", state=done) # 用 state=,不要手写 toggle action
+状态 key 变更或删除后,需重新发布小组件,否则桌面可能仍读写旧 key。
+ctx = widget.context
+
+if ctx.is_family("small"):
+ w.text("简版", size=14)
+elif ctx.is_family("large"):
+ w.text("完整版", size=18)
+
+size = widget.family_value(14, small=12, medium=14, large=18)
+| 错误写法 | 后果 | 修正 |
|---|---|---|
忘记 w.render() | 预览空白 | 脚本末尾调用一次 |
创建多个 Widget() | 行为未定义 | 只保留一个根 Widget |
SpriteNode / appui.Button 混入脚本 | 构建失败 | 只用 widget.Widget 节点 API |
按钮 action 写死字符串 | 点击无反应 | 用 widget.state 的 .increment() 等 |
widget.param 写在 render() 之后 | 参数面板缺失 | 先声明参数再构建 UI |
| small 塞满 large 文案 | 文字裁切 | family_value / is_family 分支 + line_limit |
| 改 state key 不重新发布 | 桌面计数错乱 | 重新运行、发布,必要时删桌面组件重装 |
传 Python 函数给 action= | 需拉起 App 执行 | 交互优先 widget.state;见 交互 |
w.symbol(..., color=, size=) | TypeError | w.symbol("name").color(c).font_size(n) |
w.toggle(..., tint=) | TypeError | 用 color= |
w.link(..., icon=).line_limit() | AttributeError | 带 icon 的 link 不可链修饰符 |
| 情况 | 处理 |
|---|---|
| 预览空白 | 检查 w.render()、是否只创建一个 Widget、脚本是否混入 appui/scene |
| 参数不出现 | 确认 widget.param.* 在 UI 构建前,且 name 非空 |
| 点击没反应 | action 必须来自 widget.state;开关用 w.toggle(..., state=done) |
| 桌面仍旧内容 | 重新运行并发布;删除主屏小组件后重新添加 |
| 文本被裁切 | .line_limit(1).min_scale(0.65);small 隐藏次要行 |
| 预览与桌面不一致 | 查 排错;确认已发布最新构建 |
文档里的 previewWidget 不能直接上主屏。完整步骤见 从脚本到桌面。
widget.param → 切换 family 检查裁切widget.state| 文档 | 用途 |
|---|---|
| 从脚本到桌面 | 发布流程与检查清单 |
| 布局与尺寸 | row/column/grid、family 预算 |
| 参数面板 | widget.param 详解 |
| 状态和交互 | state、按钮、开关、链接 |
| 时间线和动画 | timeline、content_transition |
| 资源与外观 | 颜色、渐变、图片、SVG |
| 排错 | 预览/桌面不一致 |
| API 索引 | 按任务选 API |
| API 参考 | 全部节点签名 |
运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。
+widget.param 声明预览可调配置,与 widget.state 桌面交互区分。
+用 widget.param 声明用户可在预览面板调节的配置项;返回值直接用于 Widget()、text()、progress()、button() 等。
边界:widget.param 是小组件配置(预览/发布时的参数),不是桌面点击改写的数据。计数、开关持久化用 widget.state,见 状态和交互。
+| 项 | 说明 |
|---|---|
| 何时用 | 颜色、标题文案、字号、阈值、是否紧凑、主题选项、背景图文件 |
| 何时不用 | 用户点按钮 +1、桌面开关记忆 → 用 widget.state |
| 声明时机 | 构建 UI 之前;在 w.render() 之前 |
| 改名/改类型后 | 重新运行并发布;桌面小组件可能需重新添加 |
预览侧栏会出现多类参数;choice 切换主题时,背景、图标与强调色一并变化(无 widget.state)。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+theme = widget.param.choice("主题", ["海洋", "日落", "森林"], default="海洋")
+themes = {
+ "海洋": (("#E0F2FE", "#082F49"), "#0EA5E9", "drop.fill"),
+ "日落": (("#FFF7ED", "#431407"), "#F97316", "sun.max.fill"),
+ "森林": (("#ECFDF5", "#052E16"), "#10B981", "leaf.fill"),
+}
+bg, accent, icon = themes[str(theme)]
+title = widget.param.text("标题", "今日天气")
+city = widget.param.text("城市", "上海")
+temp = widget.param.number("温度", 24, min=-20, max=45)
+
+w = widget.Widget(background=bg, padding=14)
+with w.row(spacing=10):
+ w.symbol(icon).color(accent).font_size(24)
+ w.text(title, size=16, weight="semibold", color=accent).line_limit(1).min_scale(0.72)
+(
+ w.value(temp, unit="°")
+ .content_transition("numericText")
+ .monospaced_digit()
+ .line_limit(1)
+ .min_scale(0.72)
+)
+w.text(city, size=12, color="secondary").line_limit(1).min_scale(0.72)
+w.render()
+widget.param | widget.state | |
|---|---|---|
| 谁改 | 用户在 预览面板 / Studio 调 | 用户在 桌面小组件 点按 |
| 持久化 | 写入 Widget 项目配置 | 写入小组件状态存储 |
| 典型用途 | 主题色、默认标题、目标值 | 计数器、完成开关、列表勾选 |
| 传给 UI | 直接作 color=、size=、background= | 作 value / state= + action |
# ✅ 配置:强调色可调
+accent = widget.param.color("强调色", "#2563EB")
+
+# ✅ 交互:桌面点击累加
+count = widget.state.int("count", 0)
+w.button("加 1", action=count.increment(), background=accent)
+| API | 返回值 | 预览控件 |
|---|---|---|
param.text(name, default) | str(可绑定) | 文本框 |
param.color(name, default) | 颜色字符串 | 取色器 |
param.slider(name, default, min=, max=, step=) | float/int | 滑块 |
param.number(name, default, min=, max=) | 数字 | 数字步进 |
param.bool(name, default) | bool | 开关 |
param.choice(name, options, default=) | str(选项之一) | 下拉/分段 |
param.file(name, default, extensions=) | 文件路径 str | 文件选择 |
name 是参数 ID,同时作为预览面板默认标题;建议用用户能看懂的中文,如 "背景色"、"标题字号"。
texttitle = widget.param.text("标题", "每日记录")
+subtitle = widget.param.text("副标题", "", placeholder="可选")
+hint = widget.param.text(
+ "备注",
+ "",
+ title="页脚说明",
+ description="显示在卡片底部,可为空",
+ group="高级",
+ hidden=False,
+)
+用于标题、备注、显示用文案默认值。绑定后的字符串传给 w.text(title, ...) 时,用户改参数会反映到预览。
可选关键字(均在 name、default 之后):
| 参数 | 作用 |
|---|---|
placeholder | 预览侧栏输入框占位提示 |
title | 覆盖侧栏显示标题(默认用 name) |
description | 侧栏帮助说明 |
group | 参数分组标题 |
order | 组内排序(数字越小越靠前) |
hidden | True 时侧栏隐藏(脚本仍可读取) |
role / live | 高级用途;一般示例可省略 |
colorpaper = widget.param.color("背景色", "#FBFAF5")
+ink = widget.param.color("文字色", "#242326")
+accent = widget.param.color("强调色", "#2563EB")
+#RRGGBB 或语义色名字符串。Widget(background=paper)、w.text(..., color=ink)、w.progress(..., color=accent)、w.button(..., background=accent)。toggle 用 color=,没有 tint= 参数。slider / numbertitle_size = widget.param.slider("标题字号", 18, min=12, max=28, step=1)
+target = widget.param.number("目标次数", 8, min=1, max=20)
+slider:连续调节(字号、透明度、圆角等)。number:离散数值(目标、阈值、上限)。size=、height= 等时建议 int(...),尤其在 family_value 分支里。boolcompact = widget.param.bool("紧凑模式", False)
+show_footer = widget.param.bool("显示页脚", True)
+返回普通 bool,用于 Python 分支,例如:
padding = 10 if compact else 16
+if show_footer:
+ w.text("页脚", size=11, color="secondary")
+不要与 widget.state.bool(桌面交互)混淆。
choicemode = widget.param.choice("风格", ["健康", "专注", "代码"], default="专注")
+options 至少一项。default 必须是 options 中的某一项(或省略则用第一项)。w.text("风格:" + mode)。filelogo = widget.param.file("角标图", "", extensions=["png", "jpg", "svg"])
+bg = widget.param.file("背景图", "assets/bg.png", extensions=["png", "jpg"])
+assets/。w.image(logo, ...) 或背景资源 API(见 资源与外观)。参数常和 widget.context、widget.family_value 一起用,避免 small 字号过大:
size = widget.param.slider("标题字号", 20, min=14, max=28, step=1)
+display_size = widget.family_value(int(size), small=14, medium=int(size), large=min(28, int(size) + 2))
+w.text(title, size=display_size).line_limit(1).min_scale(0.65)
+详见 布局与尺寸。
+| 阶段 | 行为 |
|---|---|
| 文档 / Studio 预览 | 侧栏改参数 → 重新构建当前 family 预览 |
| 发布到桌面 | 使用发布时刻的项目参数快照 |
| 改参数名 | 旧桌面实例可能仍绑定旧 key → 重新发布,必要时删组件重装 |
| 改默认值 | 新发布生效;已添加的桌面组件不自动改配置 |
Studio 中 color / slider / number / bool 等常带 live 预览:拖动滑块即可看效果,无需改代码。
| 错误写法 | 后果 | 修正 |
|---|---|---|
用 widget.state 当配色 | 桌面乱改主题色 | 配色用 widget.param.color |
param 写在 render() 之后 | 面板无参数 | 全部提前到 UI 构建前 |
choice 的 default 不在 options | 构建报错 | default 必须是 options 之一 |
slider 传入字符串默认值 | 类型异常 | 用数字 18,不是 "18" |
| 参数名随代码重构乱改 | 桌面配置丢失 | 对用户可见名保持稳定,或接受重装 |
toggle(..., tint=) | TypeError | 用 color= |
| 现象 | 处理 |
|---|---|
| 预览侧栏没有参数 | 确认 widget.param.* 且在 w.render() 前 |
| 改参数预览不变 | 看控制台构建日志;color/slider 类应自动 live |
| 桌面仍是旧配色 | 重新运行发布;不是 param 没生效而是未重发 |
| 文件参数路径无效 | 文件放进脚本同目录或 assets/;检查 extensions |
| 参数与状态搞混 | 对照上文表格;交互见 状态和交互 |
| 文档 | 用途 |
|---|---|
| widget 概览 | 总览与示例 |
| 从脚本到桌面 | 发布流程 |
| 状态和交互 | widget.state、按钮、开关 |
| 资源与外观 | 图片、渐变、文件资源 |
| 布局与尺寸 | family_value、防裁切 |
| API 参考 | widget.param 完整签名 |
相关 API:widget-api-reference.md
+Widget Studio 预览、发布、系统添加主屏小组件与桌面验证。
+API 地图见 widget-api-index.md;完整签名见 widget-api-reference.md。
+从第一行 Python 脚本到主屏/锁屏真正显示:写脚本 → 预览 → 调参 → 发布 → 系统添加小组件。
+边界:本篇讲发布流程。API 与节点写法见 widget 概览;文档里的 previewWidget 只能预览,不能直接发布到桌面。
+| 项 | 说明 |
|---|---|
| 读完你会 | 完成一次端到端发布,并在桌面验证按钮交互 |
| 前提 | 脚本含 Widget() + w.render(),且能正常预览 |
| 推荐路径 | Widget Studio 独立项目(最省事) |
| 耗时 | 首次约 5–10 分钟(含系统添加小组件) |
本篇示例专注发布验证:一个开关 + 状态文案,便于在桌面确认 AppIntent 是否生效。完整 API 写法(环形图、± 按钮等)见 widget 概览 · 饮水打卡。
+预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+habit = widget.param.text("习惯名", "每日阅读")
+done = widget.state.bool("done", False)
+accent = widget.param.color("主色", "#7C3AED")
+
+w = widget.Widget(background=("#FAF5FF", "#1E1B4B"), padding=14)
+w.text(habit, size=16, weight="semibold").line_limit(1).min_scale(0.72)
+w.toggle("今日完成", state=done, color=accent, style="switch")
+status = "已完成,发布验证通过" if done else "桌面点开关以验证"
+w.text(status, size=12, color=accent if done else "secondary").line_limit(1).min_scale(0.72)
+w.render()
+预期效果
+widget.state 与发布链路正常| 入口 | 能预览 | 能发布到桌面 | 适用 |
|---|---|---|---|
文档 previewWidget | ✅ | ❌ | 学 API、看效果 |
| Widget Studio | ✅ | ✅ | 日常开发发布(推荐) |
| MiniApp Widget 工程 | ✅ | ✅ | 工程内多文件 widget 目录 |
文档示例要上线:把代码复制到 Studio 新建脚本,或放进自己的 Widget 工程后再发布。
+w = widget.Widget(...)w.render()widget.state 的 count.increment() 等 actionwidget.param(习惯名、主色等)预览成功时,Studio 会生成草稿快照;满足条件时会自动发布到 App 的 Widget 扩展缓存(状态栏常见提示:「已生成并发布 N 个尺寸」或「Widget 已更新,可在桌面选择」)。
+发布只代表 App 内已有可渲染快照;还要在系统里添加一次小组件:
+锁屏 accessory(圆形/矩形/行内)需在锁屏编辑界面单独添加,布局要更精简(见 布局与尺寸)。
+若 widget 脚本在 MiniApp 的 widgets/ 目录下:
多 widget _manifest、槽位与工程结构见 MiniApp 内说明;本篇不展开工程细节。
+| 检查项 | 合格标准 |
|---|---|
| 入口 | 仅一个 Widget(),最后 w.render() |
| 预览 | Studio/文档预览非空白,无构建错误日志 |
| 文本 | 可变文案有 .line_limit(1).min_scale(0.65+) |
| 尺寸 | small 只保留核心信息;medium 为默认主力 |
| 参数 | widget.param.* 在 UI 构建之前声明 |
| 状态 | 按钮 action=count.increment();开关 w.toggle(..., state=done) |
| 外观 | 浅色/深色模式下对比度可读 |
| 发布 | Studio 出现「已发布」类成功提示 |
| 桌面 | 系统已添加小组件,且选中正确项目/槽位 |
| 错误 | 后果 | 修正 |
|---|---|---|
| 只在文档里预览就认为已上桌面 | 主屏没有组件 | 必须走 Studio 发布 + 系统添加 |
| 预览成功但未在系统里添加小组件 | 桌面看不到 | 长按主屏幕 → + → 选 App |
| 选了错误的 Studio 项目槽位 | 显示别的 widget | 添加时核对项目名称 |
改了 widget.state 的 key | 点击计数错乱 | 重新发布;删桌面组件重装 |
| 改了布局/参数默认值 | 桌面仍旧版 | 重新运行发布;必要时删组件重装 |
| small 塞满 large 内容 | 裁切、难读 | widget.context.is_family("small") 分支 |
action 写死字符串 | 点击无效 | 用 widget.state 生成 action |
| 现象 | 处理 |
|---|---|
| 预览空白 | 查 w.render();勿混用 appui/scene → 排错 |
| 发布失败 / 无成功提示 | 看预览控制台日志;修脚本后重新运行 |
| 参数面板为空 | widget.param 须在 render() 前声明 → 参数面板 |
| 桌面不刷新 | 重新运行发布;删主屏小组件 → 重新添加 |
| 预览正常、桌面异常 | 确认已发布且选对口槽位;查 排错 |
| 点击无反应 | action 必须来自 widget.state → 状态和交互 |
| 变更类型 | 建议 |
|---|---|
| 改颜色默认值、文案 | 重新运行发布;参数可在预览里覆盖 |
改 widget.state key | 必须重新发布;建议删桌面组件重装 |
| 改布局结构 / 新增 family | 重新运行发布,逐尺寸检查 |
| 仅改注释 | 可不发布(桌面无变化) |
| 文档 | 用途 |
|---|---|
| widget 概览 | API、示例、心智模型 |
| 参数面板 | widget.param 详解 |
| 状态和交互 | 按钮、开关、链接 |
| 布局与尺寸 | small/medium/large、锁屏 accessory |
| 排错 | 预览/桌面不一致 |
| API 索引 | 按任务选 API |
相关 API:widget-api-reference.md
+timeline/entry、state 数字动画、flip 翻页与 countdown 倒计时。
+Widget 动画由 WidgetKit 在数据更新时触发:timeline entry 切换、AppIntent 改写 widget.state,或系统按计划刷新。不是 App 里的 60fps 循环动画。
边界:本篇讲+w.timeline、widget.entry、数字过渡与flip。布局防裁切见 布局与尺寸;按钮触发 state 见 状态和交互。
| 项 | 说明 |
|---|---|
| 两类数据源 | widget.state(桌面点击)与 widget.entry(时间线条目字段) |
| 数字动画 | .content_transition("numericText") + .monospaced_digit() + .id(...) |
| 时间线 | w.timeline(entries=[...], update=, after=) 声明未来快照 |
| 翻页块 | w.flip(current, previous=...) 配合 entry 前后值 |
| 系统计时 | w.countdown / w.timer_text — WidgetKit 原生倒计时 |
| 限制 | 无持续循环;刷新时机由系统调度 |
桌面点「消费」后余额数字滚动过渡——这是 state + numericText,不依赖 timeline。
+预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+balance = widget.state.int("balance", 1280)
+accent = widget.param.color("主色", "#10B981")
+num_size = widget.family_value(42, small=34, large=52)
+
+w = widget.Widget(background=("#ECFDF5", "#064E3B"), padding=14)
+w.text("零钱罐", size=14, color="secondary").line_limit(1)
+(
+ w.value(balance.text("¥{}"))
+ .id("balance")
+ .font_size(num_size)
+ .monospaced_digit()
+ .content_transition("numericText")
+ .line_limit(1)
+ .min_scale(0.65)
+)
+(
+ w.button("消费 ¥10", action=balance.decrement(by=10), background=accent, color="#FFFFFF")
+ .line_limit(1)
+ .min_scale(0.72)
+)
+w.render()
+要点:
+balance.text("¥{}"):把 state 格式化成带货币符号的展示串,同时保留 state 绑定。.content_transition("numericText"):数值变化时系统数字滚动动画。.id("balance"):给变化节点稳定身份,动画更可靠。 ┌─────────────────────────────────────┐
+ │ WidgetKit 刷新时机 │
+ └─────────────────────────────────────┘
+ 系统调度 / timeline 到期 / AppIntent 改 state
+ ↓
+ 当前 entry 字段 或 state 新值 写入快照
+ ↓
+ 节点带 content_transition / flip / animation / transition
+ ↓
+ 系统播放一次过渡动画
+widget.state vs widget.entrywidget.state | widget.entry | |
|---|---|---|
| 谁写入 | 桌面 AppIntent(按钮、开关) | w.timeline entries 每条快照 |
| 典型用途 | 计数器、余额、开关 | 按计划变化的展示数、翻页前后值 |
| 绑定写法 | widget.state.int("key", 0) | widget.entry.int("price", 0.58) |
| 格式化 | count.text("{} 次") | price.text("{:.2f}") |
| 动画触发 | 点击后 state 变 | 预览/桌面切到下一 entry |
同一张卡片可以同时有 state(交互)和 entry(定时),但动画字段要各绑各的 key,不要混用。
+| API | 适合 |
|---|---|
.content_transition("numericText") | value() / 数字 state / entry 变化 |
.content_transition("opacity") | 文案淡入淡出 |
.animation("smooth", value_by="price") | entry 字段变化时额外平滑(常配合 numericText) |
.transition("push", edge="bottom") | 节点插入/替换方向 |
w.flip(current, previous=...) | 翻页钟、日历块、前后对比数字 |
w.countdown(..., target=) | 距某时刻剩余时间(系统驱动) |
timeline + entry + 数字动画)声明 3 条未来快照;price / prev_price 为 entry 字段。预览里时间线会按条目轮播(Studio 约每秒切一帧)。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+from datetime import datetime, timedelta, timezone
+
+now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
+price = widget.entry.float("price", 0.62)
+prev_price = widget.entry.float("prev_price", 0.68)
+accent = widget.param.color("强调色", "#F59E0B")
+
+w = widget.Widget(background=("#FFFBEB", "#292524"), padding=14)
+w.timeline(
+ entries=[
+ {"date": now.isoformat(), "price": 0.68, "prev_price": 0.72},
+ {"date": (now + timedelta(hours=1)).isoformat(), "price": 0.62, "prev_price": 0.68},
+ {"date": (now + timedelta(hours=2)).isoformat(), "price": 0.55, "prev_price": 0.62},
+ ],
+ update="end",
+)
+w.text("分时电价", size=14, color="secondary").line_limit(1)
+(
+ w.value(price.text("{:.2f}"), unit="元/度")
+ .content_transition("numericText")
+ .animation("smooth", duration=0.35, value_by="price")
+ .monospaced_digit()
+ .line_limit(1)
+ .min_scale(0.72)
+)
+if not widget.context.is_family("small"):
+ (
+ w.text(prev_price.text("上一档 {:.2f}"), size=11, color="secondary")
+ .line_limit(1)
+ .min_scale(0.72)
+ )
+w.render()
+dict;必须含 date(ISO 8601 字符串,建议 UTC)。price、prev_price)与 widget.entry.*("price", ...) 的 name 对应。update="end":在最后一条 entry 之后请求刷新(映射 WidgetKit .atEnd)。flip)flip 需要当前值与 previous 两个 entry 字段;适合时钟、日历翻页。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+from datetime import datetime, timedelta, timezone
+
+now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
+minute = widget.entry.int("minute", 41)
+prev_minute = widget.entry.int("prev_minute", 40)
+accent = widget.param.color("数字色", "#38BDF8")
+
+flip_w = widget.family_value(110, small=88, large=130)
+flip_h = widget.family_value(72, small=58, large=88)
+flip_sz = widget.family_value(44, small=32, large=52)
+
+w = widget.Widget(background=("#0B1220", "#020617"), padding=16)
+w.timeline(
+ entries=[
+ {"date": now.isoformat(), "minute": 41, "prev_minute": 40},
+ {"date": (now + timedelta(minutes=1)).isoformat(), "minute": 42, "prev_minute": 41},
+ {"date": (now + timedelta(minutes=2)).isoformat(), "minute": 43, "prev_minute": 42},
+ ],
+ update="end",
+)
+w.text("当前分钟", size=13, color="#94A3B8").line_limit(1).min_scale(0.72).line_limit(1)
+(
+ w.flip(
+ minute.text("{:02d}"),
+ previous=prev_minute.text("{:02d}"),
+ width=flip_w,
+ height=flip_h,
+ size=flip_sz,
+ color="#F8FAFC",
+ background="#1E293B",
+ corner_radius=10,
+ direction="up",
+ )
+ .id("flip-minute")
+)
+w.render()
+minute.text("{:02d}"):entry 整数格式化为两位(与 Python 占位符一致)。direction="up" / "down" 控制翻页方向;尺寸用 family_value 防 small 溢出。entry + transition)非数字也可用 entry 驱动;文案切换加 content_transition / transition。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+from datetime import datetime, timedelta, timezone
+
+now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
+phase = widget.entry.str("phase", "候场")
+accent = widget.param.color("主色", "#8B5CF6")
+
+w = widget.Widget(background=("#FAF5FF", "#1E1B4B"), padding=14)
+w.timeline(
+ entries=[
+ {"date": now.isoformat(), "phase": "候场"},
+ {"date": (now + timedelta(minutes=3)).isoformat(), "phase": "演讲进行中"},
+ {"date": (now + timedelta(minutes=20)).isoformat(), "phase": "问答环节"},
+ ],
+ update="end",
+)
+w.text("活动状态", size=14, color="secondary").line_limit(1)
+(
+ w.text(phase, size=18, weight="semibold", color=accent)
+ .id("phase-label")
+ .content_transition("opacity")
+ .transition("push", edge="bottom")
+ .line_limit(1)
+ .min_scale(0.72)
+)
+w.render()
+距目标时刻的剩余时间由 WidgetKit 自动走时;适合会议、车次、截止时间。
+预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+from datetime import datetime, timedelta, timezone
+
+eta = datetime.now(timezone.utc) + timedelta(hours=1, minutes=25)
+accent = widget.param.color("主色", "#6366F1")
+
+w = widget.Widget(background=("#EEF2FF", "#1E1B4B"), padding=14)
+w.text("会议倒计时", size=14, weight="semibold").line_limit(1).min_scale(0.72)
+(
+ w.countdown(
+ "距离会议",
+ target=eta.isoformat(),
+ subtitle="三楼 302 · 可提前 5 分钟入场",
+ accent=accent,
+ )
+)
+w.render()
+target 接受 ISO 8601 字符串或 datetime。timer_text;与 w.timeline 互补:一个管「走到某时刻」,一个管「预先排好的多条快照」。w.timeline 刷新策略update | 行为 |
|---|---|
"after"(默认) | 在 after 指定时刻之后请求下次刷新;或用 interval(秒) |
"end" / "atEnd" | 在最后一条 entry 之后刷新 |
"never" | 不再自动刷新 |
"rapid" | 向系统请求更频繁刷新;不保证实时频率,仍受 WidgetKit 预算合并 |
from datetime import datetime, timedelta, timezone
+
+now = datetime.now(timezone.utc)
+w.timeline(
+ entries=[{"date": now.isoformat(), "value": 1}],
+ update="after",
+ after=(now + timedelta(minutes=30)).isoformat(),
+)
+
+# 或用间隔秒数(桌面映射为 after = now + interval)
+w.timeline(entries=[...], update="after", interval=900)
+注意:
+update="rapid" 适合「希望尽量跟紧」的场景(如短周期电价、临近截止的倒计时),但仍是请求而非定时器;系统可能合并或延后刷新,构建诊断可能给出 warning。.animation(..., duration=, value_by=) 的 duration 控制过渡时长;value_by 填 entry 字段名(如 "price"),与 content_transition("numericText") 常一起用。score = widget.state.int("score", 0)
+(
+ w.value(score, unit="分")
+ .id("score")
+ .monospaced_digit()
+ .content_transition("numericText")
+)
+w.button("+", action=score.increment())
+amount = widget.entry.int("amount", 100)
+w.timeline(entries=[
+ {"date": "2026-06-10T08:00:00Z", "amount": 100},
+ {"date": "2026-06-10T09:00:00Z", "amount": 128},
+], update="end")
+(
+ w.value(amount.text("{}"))
+ .content_transition("numericText")
+ .animation("smooth", value_by="amount")
+)
+变化频繁的节点建议 .id("唯一串");entry 绑定节点也可自动生成 entry:price 类身份,显式 id 更利于排查。
| 错误写法 | 后果 | 修正 |
|---|---|---|
只有 value(count) 无 content_transition | 数字跳变无动画 | 加 .content_transition("numericText") |
用 widget.state 绑 timeline 字段 | entry 不随时间线变 | timeline 字段用 widget.entry.* |
entry 无 date | 顺序错乱或只用间隔推算 | 每条写 ISO date |
flip 只有当前值无 previous | 翻页效果弱 | 同时声明 previous=prev_*.text(...) |
| 期望 60fps 循环 | Widget 不支持 | 改用 timeline 分段或 countdown |
| 改 timeline 不重新发布 | 桌面仍旧快照 | 重新运行发布 |
| 现象 | 处理 |
|---|---|
| 点击按钮数字不动 | 确认 action 来自 widget.state;已加 content_transition |
| 预览 timeline 不轮播 | 检查 entries 非空、date 合法;重新运行预览 |
| 数字无动画、直接跳 | 加 .monospaced_digit()、.id(...)、content_transition |
flip 不翻页 | 确认 previous 绑 entry;timeline 至少 2 条 |
| 桌面时间不更新 | 发布最新构建;countdown 的 target 是否在未来 |
| 预览正常、桌面无动画 | 系统可能合并刷新;删小组件重装;查 排错 |
| 动画卡顿或缺失 | 减少同屏动画节点;small 降低 font_size / flip 尺寸 |
| 文档 | 用途 |
|---|---|
| widget 概览 | 总览 |
| 状态和交互 | widget.state、AppIntent |
| 布局与尺寸 | family_value、防裁切 |
| 从脚本到桌面 | 发布后在桌面验证 |
| 排错 | 预览/桌面不一致 |
| API 参考 | timeline、entry、flip、修饰符签名 |
相关 API:widget-api-reference.md
+基线排查、预览≠桌面、裁切/点击/动画、w.validate 诊断。
+预览正常但桌面异常、点击无反应、文字被裁切、动画不动——按现象 → 原因 → 修复逐项排查。先跑最小基线,再二分加回功能。
+边界:本篇是故障手册,不教 API 写法。入门见 widget 概览;发布流程见 从脚本到桌面。+
按下方「五步排查流程」与「现象对照表」处理空白、裁切、点击无效与桌面缓存问题。
+| 项 | 说明 |
|---|---|
| 第一步 | 用最小健康基线确认环境与 w.render() |
| 定位法 | 现象对照表 → 专题文档 → w.validate() 诊断 |
| 高发问题 | 空白、裁切、参数缺失、点击无效、预览≠桌面、缓存 |
| 工具 | Studio 构建日志、w.validate(family=)、逐 family 预览 |
| 原则 | 先减功能再定位,不要同时在脚本里改五处 |
全系列唯一可运行基线示例(API 地图 等页请链到此处,勿各写一份)。下面只有根容器 + 一行字 + render()。若它也预览空白,问题在环境或运行方式,不在业务逻辑。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
+import widget
+
+w = widget.Widget(background=("#FFFFFF", "#111827"), padding=14)
+w.text("Widget 基线 OK", size=16, weight="semibold").line_limit(1).min_scale(0.72).line_limit(1)
+w.render()
+基线通过后,每次只加回一类功能(参数 → 状态按钮 → 图片 → timeline),看哪一步开始坏。
+1. 基线能预览吗? ─否→ 检查 w.render()、是否混用 appui/scene、构建报错
+ ↓ 是
+2. 文档预览还是 Studio? ─仅文档→ 要上桌面必须 Studio 发布
+ ↓
+3. 三个 family 都看过吗? ─裁切→ 布局分支 / line_limit / min_scale
+ ↓
+4. 交互/动画在预览能复现吗? ─否→ state action / content_transition
+ ↓
+5. 预览正常、桌面仍旧? ─→ 重新发布 → 删主屏小组件 → 重新添加
+| 现象 | 优先检查 | 深入阅读 |
|---|---|---|
| 预览空白 | w.render()、只 import widget | 下文 §空白 |
| 文档能预览、桌面没有 | 是否 Studio 发布 + 系统添加 | 从脚本到桌面 |
| 文字裁切 / 挤在一起 | is_family、family_value、line_limit | 布局与尺寸 |
| 参数侧栏为空 | widget.param 在 render() 前 | 参数面板 |
| 按钮 / 开关无效 | widget.state 的 .increment() 等 | 状态和交互 |
| 数字无动画 | content_transition("numericText")、.id() | 时间线和动画 |
| 颜色 / 图片不对 | 双色资源、accentable | 资源与外观 |
| Studio 报「Widget 诊断」 | w.validate()、逐 family 修 | 下文 §布局诊断 |
| 原因 | 识别 | 修复 |
|---|---|---|
忘记 w.render() | 预览全白、无 IR 输出 | 脚本末尾调用一次 |
多个 Widget() | 行为未定义 | 只保留一个根 w = widget.Widget(...) |
混入 appui / scene | 构建异常或空白 | Widget 脚本只用 widget API |
| 语法 / 运行时错误 | Studio 控制台有 Traceback | 修报错行;先用基线脚本验证 |
svg() / image() 无路径 | TypeError: requires name | 提供路径、param.file 或 save_image |
toggle(..., tint=) | TypeError: unexpected keyword | 改用 color= |
symbol(..., color=, size=) | TypeError: unexpected keyword | w.symbol("name").color(c).font_size(n);symbol 仅支持 rendering/palette/variant/scale |
link(..., icon=).line_limit() | AttributeError: no line_limit | 带 icon 的 link 不能链修饰符;改无 icon 或去掉链式调用 |
widget.context() / w.context() | TypeError: not callable | 用 ctx = widget.context 或 w.context(属性) |
# ❌ 构建期就会失败
+w.toggle("完成", state=done, tint="#22C55E")
+
+# ✅
+w.toggle("完成", state=done, color="#22C55E")
+| 原因 | 识别 | 修复 |
|---|---|---|
只在文档里 previewWidget | 主屏从未添加组件 | 代码复制到 Widget Studio 发布 |
| 未在系统里添加小组件 | App 已发布但主屏无入口 | 长按主屏 → + → 选 Python IDE |
| 选错 Studio 项目 / 槽位 | 显示别的 widget 内容 | 添加时核对项目名称 |
| WidgetKit 缓存旧快照 | 改代码后桌面仍旧 | Studio 重新运行发布 |
| 仍不更新 | 缓存顽固 | 删除主屏小组件 → 重新添加 |
改了 widget.state 的 key | 点击计数错乱或归零 | 重新发布;建议删组件重装 |
| 文档预览 ≠ Studio 预览 | 参数/资源路径不同 | 以 Studio 项目目录为准 |
发布检查清单见 从脚本到桌面。
+| 原因 | 识别 | 修复 |
|---|---|---|
| large 布局塞进 small | 仅 small 裁切 | ctx.is_family("small") 减行或藏次要内容 |
| 字号 / 边距不分 family | medium 正常、small 爆版 | widget.family_value(16, small=14, large=18) |
| 可变文案无保护 | 长标题被 «…» 截断 | .line_limit(1).min_scale(0.65)(格内可降到 0.55) |
row 塞太多列 | small 数字叠在一起 | 改 column 或减少列数 |
| 表格线发粗 | 格子边框像厚框 | 用 w.table(..., line_width="hairline"),勿 rect 拼线 |
| 锁屏 accessory 套 medium UI | circular 几乎看不见 | 为 circular/rectangular/inline 单独写布局 |
防裁切清单见 布局与尺寸。
+| 原因 | 识别 | 修复 |
|---|---|---|
param 写在 render() 后 | 侧栏无参数 | 全部移到 UI 构建之前 |
choice 的 default 非法 | 构建报错 | default 必须是 options 之一 |
slider 默认值为字符串 | 类型异常 | 用数字 18,不是 "18" |
| 混淆 param 与 state | 预览能改、桌面不变(或反之) | 配色用 param,点击用 state |
| 改参数名后桌面仍旧 | 旧实例绑旧 key | 重新发布,必要时删组件重装 |
| 原因 | 识别 | 修复 |
|---|---|---|
action 写死字符串 | 点击完全无反应 | action=count.increment() |
| 传入 Python 函数 | 需拉起 App,Widget 内不执行 | 用 widget.state 工厂方法 |
| Toggle 不记忆 | 显示开关但状态不回写 | w.toggle(..., state=done) |
toggle_item 类型不一致 | 勾选状态错乱 | contains(i) 与 toggle_item(i) 同类型 |
| 链接与按钮同区域 | 点哪都不对 | 链接、按钮分块;见 交互 |
| 预览能点、桌面不能 | 未发布或 state key 已变 | 重新发布 + 桌面验证 |
count = widget.state.int("count", 0)
+
+# ❌
+w.button("+", action="increment")
+
+# ✅
+w.button("+", action=count.increment())
+| 原因 | 识别 | 修复 |
|---|---|---|
无 content_transition | 数字直接跳变 | .content_transition("numericText") |
| 无稳定身份 | 动画时有时无 | .id("唯一名") 或绑 state/entry key |
用 entry 绑交互数字 | 点击不变 | 交互用 widget.state,计划变化用 widget.entry |
timeline 无 date | entry 顺序乱 | 每条 entry 写 ISO date |
| 期望 60fps 循环 | Widget 能力不支持 | 改用 timeline 分段或 countdown |
| 桌面动画少于预览 | 系统合并刷新 | 正常;重新发布后仍无则查 entry/state |
详见 时间线和动画。
+| 原因 | 识别 | 修复 |
|---|---|---|
| 深色模式发灰 | 只有浅色图 | background_image((light, dark)) 或 image(light=, dark=) |
| Tinted 模式颜色乱 | 节点默认参与染色 | 设计色 .accentable(False),要跟系统 .accentable(True) |
| 透明背景无效 | 仍有不透明底 | transparent_background + container_background(removable=True) |
| 背景图字看不清 | 照片太亮 | scrim="bottom" + scrim_opacity |
| SVG / 图片不显示 | 预览路径空 | 文件放进 assets/ 或侧栏 param.file 重选 |
详见 资源与外观。
+w.validate)构建后可用 w.validate() 检查当前 family 的布局风险。Studio 预览若出现 「Widget 诊断」,需按提示修阻断项后再发布。
import widget
+
+w = widget.Widget(background="#FFFFFF", padding=14)
+w.text("标题", size=16).line_limit(1)
+# ... 构建 UI ...
+
+report = w.validate(family=widget.SMALL) # 在 w.render() 之前调用
+print(report["status"]) # clean | warning | error
+for issue in report.get("issues", []):
+ print(issue["code"], issue["message"])
+w.render()
+validate() 读取当前已构建的 UI 树;须在 w.render() 之前调用。Studio 构建时也会按 family 自动跑诊断。
常见诊断码(非完整列表):
+| 代码 | 含义 | 建议 |
|---|---|---|
text_size_too_large | 字号相对 family 过大 | 减小 size 或 family_value 分支 |
offset_clip_risk | 偏移过大可能裁切 | 减小 offset;用布局容器代替硬偏移 |
chart_height_risk | 图表过高挤占版面 | 降低 height;small 隐藏图表 |
frame_width_overflow | 固定宽超出内容区 | 减小 frame 或改用流式布局 |
frame_height_overflow | 固定高超出内容区 | 同上 |
对 small、medium、large 各跑一次验证(脚本在 Studio 会按 family 多次构建)。Widget 建议 为可优化提示;阻断项必须修掉。
复杂脚本卡住时,按顺序恢复功能:
+| 步骤 | 加回什么 | 验证什么 |
|---|---|---|
| 1 | 仅 Widget + text + render | 基线预览 |
| 2 | widget.param 一两个 | 侧栏参数出现 |
| 3 | widget.state + 一个按钮 | 点击有反应 |
| 4 | row / column / 图表 | 各 family 无裁切 |
| 5 | 图片 / SVG / background_image | 资源路径正确 |
| 6 | w.timeline + entry / flip | 预览 entry 轮播 |
| 7 | Studio 发布 → 桌面添加 | 端到端一致 |
某步开始失败时,问题就在刚加回的那一类 API。
+收集这些信息便于反馈或自查:
+w.validate() 对应该 family 的 issues| 文档 | 用途 |
|---|---|
| widget 概览 | API 与心智模型 |
| 从脚本到桌面 | 发布与桌面验证 |
| 布局与尺寸 | 裁切、family 预算 |
| 参数面板 | widget.param |
| 状态和交互 | 按钮、开关、链接 |
| 时间线和动画 | numericText、timeline |
| 资源与外观 | 双色图、accentable |
| API 参考 | 签名核对 |
相关 API:widget-api-reference.md
+Inline HTML content.
\")\n .frame(height=180),\n ], spacing=16)\n .padding()\n ).navigation_title(\"Map & Web\")\n )\n\n\nappui.run(body) 加载状态和权限 API 空值或失败时的表现 建议 `AsyncImage` 无占位时可能显示空白;失败时使用 `error_view`。 总是提供 `placeholder` 和 `error_view`。 `PhotoPicker` 用户取消时可能返回空列表。 在 `on_picked` 中处理 `[]` 或 `None`。 `CameraPicker` 用户拒绝权限、取消或设备不可用时可能没有路径。 在 `on_captured` 中处理空路径并显示说明。 `FileImporter` 用户取消时不会产生有效路径;部分外部文件类型可能无法读取。 默认保持 `copy=True`,在 `on_picked` 中处理 `[]` 或 `None`。 `VideoPlayer` 空 `url` 没有有效播放源。 文档示例可展示空控件,真实应用必须传可播放地址。 `WebView` 未传 `url` / `html` 时没有明确内容。 显式传 URL 或 HTML。 `MapView` 无标记也能显示中心点。 `span` 不要过大,标记字典至少包含 `latitude`、`longitude`。 `ShareLink` `item` 为空时分享面板没有有意义内容。 先在状态或函数里生成可读文本、URL 或文件路径。 与相邻 API 的区别 API 不同点 `Image` vs `Label` `Image` 只有图像;`Label` 是图标和标题组合,按钮和列表行更常用。 `Image` vs `AsyncImage` `Image` 用本地资源或 SF Symbol;`AsyncImage` 从网络 URL 加载。 `PhotoPicker` vs `CameraPicker` `PhotoPicker` 从已有媒体库选择;`CameraPicker` 创建新媒体。 `PhotoPicker` vs `FileImporter` `PhotoPicker` 只面向相册媒体;`FileImporter` 从 Files App 或文档提供方导入普通文件。 `WebView` vs `Link` `WebView` 在 AppUI 内嵌网页;`Link` 打开系统浏览器。 `VideoPlayer` vs `WebView` 视频用 `VideoPlayer`,不要用 WebView 包一层视频网页来播放。 常见错误 错误 正确做法 `AsyncImage` 不设固定高度,加载后布局跳动。 用 `.frame(height=...)` 固定媒体区域。 网络图片使用 `\"fill\"` 但忘记 `.clipped()`。 填充裁切时加 `.clipped()`。 相册或相机回调假设一定有路径。 对空列表、空字符串和权限拒绝做处理。 需要普通文档却用 `PhotoPicker`。 用 `FileImporter(allowed_types=[...], on_picked=...)`。 用 `Image(...).on_tap(...)` 模拟按钮。 可点击媒体动作使用 `Button(content=appui.Image(...))` 或 `Button(content=appui.Label(...))`。 在用户文档里混用命令式 `u" + }, + { + "id": "appui-ref-navigation", + "title": "导航 API", + "subtitle": "NavigationStack、NavigationLink、TabView、ToolbarItem。", + "section": "appui / API 参考", + "url": "pages/appui-ref-navigation/", + "text": "导航 API NavigationStack、NavigationLink、TabView、ToolbarItem。 appui API 参考 appui api navigation NavigationStack NavigationLink TabView ToolbarItem 导航 导航 API 本页覆盖 NavigationStack、NavigationLink、NavigationSplitView、TabView、Tab、NavigationPath、ToolbarItem 和 ToolbarSpacer。页面级结构优先用这些原生导航容器,不要用手写按钮去模拟系统导航。 什么时候用 目标 首选 API 说明 普通推入详情页 `NavigationStack` + `NavigationLink` 列表到详情、设置项到二级页。 代码控制 push/pop `NavigationStack(path=...)` + `NavigationPath` 登录流程、分步表单、点击完成后跳转。 多个主栏目 `TabView` + `Tab` 首页、搜索、我的等顶层导航。 iPad 多列 `NavigationSplitView` 侧边栏 + 详情页,可加 supplementary 第三列。 顶栏/底栏命令 `.toolbar([...])` + `ToolbarItem` 保存、关闭、编辑、分享等页面命令。 最小正确示例 import appui\n\nitems = [\n {\"id\": \"a\", \"title\": \"Alpha\", \"status\": \"Ready\"},\n {\"id\": \"b\", \"title\": \"Beta\", \"status\": \"Draft\"},\n]\n\n\ndef item_key(item):\n return item[\"id\"]\n\n\ndef detail_view(item):\n return (\n appui.Form([\n appui.Section(\"详情\", [\n appui.LabeledContent(\"Title\", value=item[\"title\"]),\n appui.LabeledContent(\"Status\", value=item[\"status\"]),\n ])\n ])\n .navigation_title(item[\"title\"])\n .toolbar([\n appui.ToolbarItem(\n placement=\"navigation_bar_trailing\",\n content=appui.CloseButton(),\n role=\"close\",\n )\n ])\n )\n\n\ndef row_view(item):\n return appui.NavigationLink(\n destination=detail_view(item),\n label=appui.Label(item[\"title\"], system_image=\"doc.text\"),\n )\n\n\ndef body():\n root = appui.List([\n appui.Section(\"Items\", [\n appui.ForEach(items, row_builder=row_view, key=item_key)\n ])\n ]).navigation_title(\"Navigation\")\n\n return appui.NavigationStack(root)\n\n\nappui.run(body) 导航容器签名 API 签名 分类 `NavigationStack` `NavigationStack(content: Optional[View] = None, path: Optional[NavigationPath] = None, destinations: Optional[Dict[str, Callable]] = None)` `navigation` `NavigationView` `NavigationView = NavigationStack` `兼容别名` `NavigationLink` `NavigationLink(title: Optional[str] = None, destination: Optional[View] = None, label: Optional[View] = None)` `navigation` `NavigationSplitView` `NavigationSplitView(sidebar: Optional[View] = None, detail: Optional[View] = None, supplementary: Optional[View] = None, column_visibility: str = 'all')` `navigation` `TabView` `TabView(tabs: Optional[Sequence[\"Tab\"]] = None, selection: Optional[int] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `navigation` `Tab` `Tab(title: str = '', system_image: Optional[str] = None, image: Optional[str] = None, content: Optional[View] = None, badge: Optional[int] = None, tag: Optional[int] = None, systemImage: Optional[str] = None, role: Optional[str] = None, key: Optional[str] = None)` `navigation` `NavigationPath` `NavigationPath()` `公开类型` `ToolbarItem` `ToolbarItem(placement: str = 'automatic', content: Optional[View] = None, role: Optional[str] = None)` `presentation` `ToolbarSpacer` `ToolbarSpacer(sizing: str = 'fixed', placement: str = 'automatic')` `presentation` NavigationPath 方法 API 签名 所属类型 `append` `append(view_or_value: Union[\"View\", str, int, Dict[str, Any]]) -> None` `NavigationPath` `pop` `pop(count: int = 1) -> None` `NavigationPath` `pop_to_root` `pop_to_root() -> None` `NavigationPath` `replace` `replace(items: Sequence[Any]) -> None` `NavigationPath` import appui\n\npath = appui.NavigationPath()\n\n\ndef open_profile():\n path.append(\"profile\")\n\n\ndef go_root():\n path.pop_to_root()\n\n\ndef make_destination(route):\n if route == \"profile\":\n return appui.Form([\n appui.Section(\"Profile\", [\n appui.Text(\"Programmatic destination\"),\n appui.Button(\"Back to root\", action=go_root),\n ])\n ]).navigation_title(\"Profile\")\n return appui.Text(\"Unknown\").navigation_title(\"Unknown\")\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Actions\", [\n appui.Button(\"Open profile\", action=open_profile),\n ])\n ]).navigation_title(\"Root\"),\n path=path,\n destinations={\"profile\": make_destination},\n )\n\n\nappui.run(body) TabView 示例 import appui\n\nstate = appui.State(tab=0, enabled=True)\n\n\ndef set_tab(value):\n state.tab = value\n\n\ndef set_enabled(value):\n state.enabled = value\n\n\ndef home_view():\n return appui.NavigationStack(\n appui.Text(\"Home\").padding().navigation_title(\"Home\")\n )\n\n\ndef settings_view():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Settings\", [\n appui.Toggle(\n \"Enabled\",\n is_on=state.enabled,\n on_change=set_enabled,\n ),\n ])\n ]).navigation_title(\"Settings\")\n )\n\n\ndef body():\n return appui.TabView(\n tabs=[\n appui.Tab(\"Home\", system_image=\"house\", content=home_view(), tag=0),\n appui.Tab(\"Settings\", system_image=\"gear\", content=settings_view(), tag=1),\n ],\n selection=state.tab,\n on_change=set_tab,\n )\n\n\nappui.run(body, state=state) 底部附件与原生 Sheet 持续任务可以用 .tab_view_bottom_accessory(...) 显示底部常驻状态条,再用 .sheet(...) 打开完整面板。这样底部条仍由 iOS 26 TabView 原生区域承载,展开页则交给系统 sheet 处理圆角、拖拽条、下拉关闭和 detents。 import appui\n\nstate = appui.State(show_panel=False)\n\n\ndef open_panel():\n state.show_panel = True\n\n\ndef close_panel():\n state.show_panel = False\n\n\ndef compact_bar():\n return appui.HStack([\n appui.Image(system_name=\"arrow.down.circle.fill\").foreground_color(\"systemBlue\"),\n appui.VStack([\n appui.Text(\"下载中\").font(\"subheadline\").bold(),\n appui.Text(\"42% · 3 个任务\").font(\"caption\").foreground_style(\"secondary\"),\n ], alignment=\"leading\", spacing=2)\n .frame(max_width=appui.infinity, alignment=\"leading\"),\n appui.Image(system_name=\"chevron.up\").foreground_color(\"secondaryLabel\"),\n ], spacing=10).padding(horizontal=14, vertical=10)\n\n\ndef expanded_panel():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"任务\", [\n appui.LabeledContent(\"当前进度\", value=\"42%\"),\n appui.LabeledContent(\"剩余任务\", value=\"3\"),\n appui.Button(\"完成\", action=close_panel),\n ])\n ]).navigation_title(\"下载\")\n )\n\n\ndef body():\n tabs = appui.TabView(tabs=[\n appui.Tab(\"首页\", system_image=\"house\", content=appui.Text(\"Home\"), tag=0),\n appui.Tab(\"设置\", system_image=\"gear\", content=appui.Text(\"Settings\"), tag=1),\n ])\n return tabs.tab_view_bottom_accessory(\n compact_bar().content_shape(\"rect\").on_tap(open_panel)\n ).sheet(\n is_presented=state.show_panel,\n on_dismiss=close_panel,\n content=expanded_panel,\n detents=\"medium_large\",\n drag_indicator=\"visible\",\n )\n\n\nappui.run(body, state=state) 紧凑条会放在底部系统区域;点击后打开原生 sheet。不要再额外给同一个 TabView 挂底部 safe_area_bar,否则会和底部附件争同一块区域。需要更像系统媒体 App 的体验时,优先把完整播放器设计成适合 sheet 的内容,而不是自定义全屏 overlay。 常用修饰符 API 签名 所属类型 `.navigation_title` `.navigation_title(title: Union[str, \"View\"]) -> Self` `View` `.navigation_bar_title_display_mode` `.navigation_bar_title_display_mode(mode: str) -> Self` `View` `.navigation_bar_back_button_hidden` `.navigation_bar_back_button_hidden(value: bool = True) -> Self` `View` `.toolbar` `.toolbar(items: Any) -> Self` `View` `.toolbar_background` `.toolbar_background(visibility: str = 'visible', bars: str = 'navigation_bar') -> Self` `View` `.toolbar_color_scheme` `.toolbar_color_scheme(scheme: str = 'dark', bars: str = 'navigation_bar') -> Self` `View` `.navigation_destination` `.navigation_destination(is_presented: bool = False, content: Optional[\"View\"] = None, on_dismiss: Optional[Callable] = None, isPresented: Optional[bool] = None, onDismiss: Optional[Callable] = None) -> Self` `View` `.safe_area_bar` `.safe_area_bar(edge: str = 'bottom', content: Optional[\"View\"] = None, alignment: str = 'center', spacing: Optional[float] = None, safeAreaEdge: Optional[str] = None) -> Self` `View` `.tab_view_bottom_accessory` `.tab_view_bottom_accessory(content: Optional[\"View\"] = None, enabled: bool = True, is_enabled: Optional[bool] = None, isEnabled: Optional[bool] = None) -> Self` `View` `.tab_bar_minimize_behavior` `.tab_bar_minimize_behavior(behavior: str = 'automatic') -> Self` `View` `.tab_view_search_activation` `.tab_view_search_activation(activation: str = 'search_tab_selection') -> Self` `View` 与相邻 API 的区别 API 不同点 `NavigationLink` vs `NavigationPath` `NavigationLink` 适合用户点击某一行进入详情;`NavigationPath` 适合业务逻辑主动跳转。 `TabView` vs `NavigationStack` `TabView` 是顶层栏目切换;每个 Tab 里面通常再放自己的 `NavigationStack`。 `NavigationSplitView` vs `TabView` `NavigationSplitView` 是同一任务的多列信息架构;`TabView` 是多个任务域的顶层切换。 `.toolbar` vs 页面正文按钮 页面级命令放工具栏;内容相关动作放在表单、列表或卡片中。 `.tab_view_bottom_accessory` vs `.sheet` 前者只显示底部常驻紧凑条;后者打开完整面板、表单或播放器页。 `.tab_view_bottom_accessory` vs `.safe_area_bar` 前者是 TabView 的系统底部附件;后者是普通安全区插入内容,不要同时挂在同一个底部区域。 `CloseButton` vs 普通 `Button` 关闭 MiniApp 时使用 `CloseButton` 或 `ToolbarItem(role=\"close\")`,系统能识别关闭语义。 `ToolbarSpacer` vs 空白 `Spacer` `ToolbarSpacer` 是工具栏项;正文布局留白继续用 `Spacer`。 常见错误 错误 正确做法 只用 `VStack` 加按钮模拟页面切换。 使用 `NavigationStack`、`NavigationLink` 或 `NavigationPath`。 在 `TabView` 外面只包一个全局 `NavigationStack`。 每个 Tab 里面放自己的 `NavigationStack`,避免标题和返回栈互相污染。 用“播放”或“下载”单独占一个 Tab,但又需要全局状态条。 用 `.tab_view_bottom_accessory(...)` 放紧凑状态条,点击后用 `.sheet(...)` 打开完整面板。 隐藏返回按钮但没有替代路径。 用 toolbar 放明确的返回、取消或关闭按钮。 `ForEach` 行没有稳定 `key`。 给 `ForEach` 传稳定键函数,避免列表刷新后导航状态错位。 `ToolbarItem` 使用旧 placement 名称。 使用 AppUI 文档中的 `navigation_bar_trailing`。 相关文档 文档 用途 [导航与页面结构](appui-guide-navigation) TabView、NavigationStack、NavigationPath 和 sheet 的完整模式。 [呈现 API](appui-ref-presentation) sheet、alert、popover、confirmation dialog。 [形状 API](appui-ref-shapes) `ToolbarItem` 也在形状/工具栏页中保留了签名。" + }, + { + "id": "appui-ref-layout", + "title": "布局 API", + "subtitle": "Stack、ScrollView、List/Form、Grid 和 GeometryReader。", + "section": "appui / API 参考", + "url": "pages/appui-ref-layout/", + "text": "布局 API Stack、ScrollView、List/Form、Grid 和 GeometryReader。 appui API 参考 appui api layout VStack HStack ScrollView LazyVGrid GeometryReader 布局 布局 API 本页查 Stack、ScrollView、List/Form、Grid、GeometryReader 和数据展示容器的签名。怎么选择布局结构见 布局系统。 分篇速查 分篇 适合查询 [堆叠布局 API](appui-ref-layout-stacks) `VStack`、`HStack`、`ZStack`、`LazyVStack`、`LazyHStack`、`Spacer`、`Divider`。 [滚动 API](appui-ref-layout-scroll) `ScrollView`、`ScrollViewReader`、滚动方向、初始定位和锚点。 [网格 API](appui-ref-layout-grids) `LazyVGrid`、`LazyHGrid`、`Grid`、`GridRow`、`flexible`、`fixed`、`adaptive`、`grid_item`。 [几何与特殊布局 API](appui-ref-layout-geometry) `GeometryReader`、`ViewThatFits`、`Group`、`Overlay`、`SafeAreaInset`。 [环境值 API](appui-ref-environment) `environment_value`、`preferred_color_scheme`、`locale`、`layout_direction`、动态字体和文本环境。 什么时候用 目标 API 纵向/横向组合 `VStack`、`HStack` 层叠覆盖 `ZStack`、`Overlay` 自定义滚动内容 `ScrollView`、`LazyVStack` 程序化滚动 `ScrollViewReader` 卡片网格 `LazyVGrid`、`LazyHGrid`、`Grid` 原生动态列表 `List`、`ForEach` 设置和编辑页 `Form`、`Section` 空状态和进度 `ContentUnavailableView`、`ProgressView` 大屏表格 `Table` 容器组合示例 同一页面可以同时使用 Form、List、LazyVGrid,但要让每个容器负责自己擅长的结构。 import appui\n\ncards = [\n {\"id\": \"a\", \"title\": \"Alpha\"},\n {\"id\": \"b\", \"title\": \"Beta\"},\n {\"id\": \"c\", \"title\": \"Gamma\"},\n]\n\n\ndef card_key(item):\n return item[\"id\"]\n\n\ndef card_view(item):\n return (\n appui.Text(item[\"title\"])\n .frame(max_width=appui.infinity, min_height=72)\n .background(\"secondarySystemBackground\", corner_radius=8)\n )\n\n\ndef body():\n grid = appui.LazyVGrid(\n columns=[appui.adaptive(minimum=120)],\n spacing=12,\n content=[appui.ForEach(cards, row_builder=card_view, key=card_key)],\n )\n\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Form\", [\n appui.LabeledContent(\"Container\", value=\"Form\"),\n ]),\n appui.Section(\"Grid\", [\n grid,\n ]),\n ]).navigation_title(\"Layout\")\n )\n\n\nappui.run(body) 布局签名 API 签名 分类 `VStack` `VStack(content: Optional[Sequence[View]] = None, alignment: str = 'center', spacing: Optional[float] = None)` `layout` `HStack` `HStack(content: Optional[Sequence[View]] = None, alignment: str = 'center', spacing: Optional[float] = None)` `layout` `ZStack` `ZStack(content: Optional[Sequence[View]] = None, alignment: str = 'center')` `layout` `LazyVStack` `LazyVStack(content: Optional[Sequence[View]] = None, alignment: str = 'center', spacing: Optional[float] = None)` `layout` `LazyHStack` `LazyHStack(content: Optional[Sequence[View]] = None, alignment: str = 'center', spacing: Optional[float] = None)` `layout` `ScrollView` `ScrollView(content: Optional[ViewChild] = None, axes: str = 'vertical', shows_indicators: bool = True, showsIndicators: Optional[bool] = None)` `layout` `ScrollViewReader` `ScrollViewReader(content: Optional[ViewChild] = None, axes: str = 'vertical', shows_indicators: bool = True, scroll_to: Optional[str] = None, anchor: str = 'top', showsIndicators: Optional[bool] = None, scrollTo: Optional[str] = None, children: Optional[ViewChild] = None)` `layout` `LazyVGrid` `LazyVGrid(columns: Optional[Sequence[dict]] = None, content: Optional[Sequence[View]] = None, spacing: Optional[float] = None, children: Optional[Sequence[View]] = None)` `layout` `LazyHGrid` `LazyHGrid(rows: Optional[Sequence[dict]] = None, content: Optional[Sequence[View]] = None, spacing: Optional[float] = None, children: Optional[Sequence[View]] = None)` `layout` `Grid` `Grid(content: Optional[Sequence[View]] = None, alignment: str = 'center', horizontal_spacing: Optional[float] = None, vertical_spacing: Optional[float] = None, horizontalSpacing: Optional[float] = None, verticalSpacing: Optional[float] = None)` `layout` `GridRow` `GridRow(content: Optional[Sequence[View]] = None, alignment: Optional[str] = None)` `layout` `GeometryReader` `GeometryReader(content: Optional[ViewChild] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None, children: Optional[ViewChild] = None, on_geometry: Optional[Callable] = None, onGeometry: Optional[Callable] = None)` `layout` `ViewThatFits` `ViewThatFits(content: Optional[Sequence[View]] = None)` `layout` `Group` `Group(content: Optional[Sequence[View]] = None)` `layout` `Overlay` `Overlay(content: Optional[View] = None, overlay: Optional[View] = None, alignment: str = 'center')` `layout` `SafeAreaInset` `SafeAreaInset(edge: str = 'bottom', content: Optional[View] = None)` `layout` `Spacer` `Spacer(min_length: Optional[float] = None, minLength: Optional[float] = None)` `layout` `Divider` `Divider()` `layout` 数据容器 API 签名 分类 `List` `List(content: Optional[Sequence[View]] = None)` `collection` `ForEach` `ForEach(data: Any, row_builder: Optional[Callable] = None, key: Optional[Callable] = None, rowBuilder: Optional[Callable] = None, content: Optional[Callable] = None)` `collection` `Form` `Form(content: Optional[Sequence[View]] = None)` `collection` `Section` `Section(content: Optional[ViewChild] = None, *, header: Optional[Union[str, View]] = None, footer: Optional[Union[str, View]] = None, children: Optional[ViewChild] = None, key: Optional[str] = None)` `collection` `GroupBox` `GroupBox(label: Optional[str] = None, content: Optional[ViewChild] = None, children: Optional[Sequence[View]] = None)` `collection` `DisclosureGroup` `DisclosureGroup(label: str = '', is_expanded: Optional[bool] = None, content: Optional[ViewChild] = None, isExpanded: Optional[bool] = None, children: Optional[ViewChild] = None)` `collection` `LabeledContent` `LabeledContent(label: str = '', value: Optional[str] = None, content: Optional[View] = None)` `collection` `Table` `Table(data: Optional[Sequence[Dict[str, Any]]] = None, columns: Optional[Sequence[Dict[str, str]]] = None, on_select: Optional[Callable] = None, onSelect: Optional[Callable] = None)` `collection` `ControlGroup` `ControlGroup(label: str = '', content: Optional[Sequence[View]] = None, children: Optional[Sequence[View]] = None)` `control` `ContentUnavailableView` `ContentUnavailableView(title: str = '', system_image: Optional[str] = None, description: Optional[str] = None, systemImage: Optional[str] = None)` `presentation` `ProgressView` `ProgressView(label: Optional[str] = None, value: Optional[float] = None, total: float = 1.0)` `feedback` `Link` `Link(title: str = '', url: str = '')` `control` `Gauge` `Gauge(value: float = 0.0, min_value: float = 0.0, max_value: float = 1.0, label: str = '', minValue: Optional[float] = None, maxValue: Optional[float] = None)` `feedback` `ShareLink` `ShareLink(item: str = '', subject: Optional[str] = None, message: Optional[str] = None)` `control` `Badge` `Badge(count: Optional[int] = None, text: Optional[str] = None)` `presentation` `TimelineView` `TimelineView(interval: float = 1.0, content: Optional[View] = None)` `feedback` 相邻 API 区别 API 用法边界 `List` vs `ScrollView` 动态行列表用 `List`;完全自定义滚动视觉才用 `ScrollView`。 `Form` vs `VStack` 设置和编辑页用 `Form`;普通局部排列用 `VStack`。 `LazyVGrid` vs `Grid` 卡片网格用 `LazyVGrid`;需要严格行列对齐用 `Grid`。 `Overlay` vs `.overlay(...)` 简单叠加优先用修饰符;需要显式节点时用 `Overlay`。 `ContentUnavailableView` vs 空 `Text` 空状态用 `ContentUnavailableView`,不要让列表区域空白。 常见错误 错误 后果 修正 `VStack(children=[...])` 构造参数不匹配 `VStack([...], spacing=...)`。 `Section(content=List(...))` 嵌套滚动容器 `List([Section(\"Title\", rows)])`。 动态列表没有 key 搜索、删除后行身份不稳 `ForEach(data, row_builder=..., key=...)`。 用自绘卡片模拟设置页 信息密度低,键盘和辅助功能差 `Form + Section`。 把重型媒体放进列表行 滚动后可能空白或状态丢失 媒体放稳定区域,控制项放 `Form/List`。 相关示例 文档 用途 [布局系统](appui-guide-layout) Stack、ScrollView、Grid、List/Form 的选择。 [示例:待办列表](appui-cookbook-todo) 完整 List、ForEach、搜索和稳定 id。 [示例:仪表盘](appui-cookbook-dashboard) LazyVGrid 和卡片布局。" + }, + { + "id": "appui-ref-shapes", + "title": "形状 API", + "subtitle": "Rectangle、RoundedRectangle、Circle、Capsule、Ellipse、Color 和 ToolbarItem。", + "section": "appui / API 参考", + "url": "pages/appui-ref-shapes/", + "text": "形状 API Rectangle、RoundedRectangle、Circle、Capsule、Ellipse、Color 和 ToolbarItem。 appui API 参考 appui api shapes Rectangle RoundedRectangle Circle Capsule Ellipse Color ToolbarItem 形状 形状 API 本页覆盖 Rectangle、RoundedRectangle、Circle、Capsule、Ellipse、Color 和 ToolbarItem。形状是普通 View,通常要配合 .frame(...) 才能得到稳定尺寸。 什么时候用 目标 首选 API 说明 矩形背景或占位 `Rectangle` 基础块状形状。 卡片背景 `RoundedRectangle` 需要圆角但不想用 `.background(...)` 时。 圆形头像、圆点 `Circle` 宽高相同最稳定。 胶囊按钮背景 `Capsule` 两端半圆的条状背景。 椭圆 `Ellipse` 非等宽高的椭圆。 纯色块 `Color` 用作背景层或色块视图。 工具栏项 `ToolbarItem` 放到 `.toolbar([...])` 里。 形状公共方法 API 签名 所属类型 `.fill` `.fill(color: ColorLike) -> Self` `_Shape` `.stroke` `.stroke(color: ColorLike, line_width: float = 1, **kwargs: Any) -> Self` `_Shape` import appui\n\n\ndef body():\n card = appui.ZStack([\n appui.RoundedRectangle(corner_radius=14)\n .fill(\"secondarySystemBackground\"),\n appui.RoundedRectangle(corner_radius=14)\n .stroke(\"separator\", line_width=1),\n appui.Text(\"Shape Card\").font(\"headline\"),\n ]).frame(height=120)\n\n return appui.NavigationStack(\n appui.VStack([card], spacing=16)\n .padding()\n .navigation_title(\"Shapes\")\n )\n\n\nappui.run(body) 形状签名 API 签名 分类 `Rectangle` `Rectangle()` `shape` `RoundedRectangle` `RoundedRectangle(corner_radius: float = 10, cornerRadius: Optional[float] = None)` `shape` `Circle` `Circle()` `shape` `Capsule` `Capsule()` `shape` `Ellipse` `Ellipse()` `shape` `Color` `Color(value: Optional[ColorLike] = None, red: Optional[float] = None, green: Optional[float] = None, blue: Optional[float] = None, opacity: float = 1.0)` `shape` `ToolbarItem` `ToolbarItem(placement: str = 'automatic', content: Optional[View] = None, role: Optional[str] = None)` `presentation` 颜色 fill、stroke、foreground_color、background 和 Color 都接受 AppUI 的颜色表达: 表达 示例 系统语义色 `\"systemBlue\"`、`\"label\"`、`\"secondaryLabel\"`、`\"systemBackground\"`、`\"separator\"` 十六进制 `\"#3366CC\"` RGB / RGBA 元组 `(0.2, 0.4, 0.8)`、`(0.2, 0.4, 0.8, 0.7)` 整数 `0x3366CC` import appui\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.Color(value=\"systemIndigo\").frame(height=28),\n appui.Color(red=0.9, green=0.2, blue=0.2, opacity=0.5).frame(height=28),\n appui.Circle().fill(\"#3366CC\").frame(width=56, height=56),\n appui.Capsule().stroke(\"systemGreen\", line_width=3).frame(width=160, height=44),\n appui.Ellipse().fill(\"systemOrange\").frame(width=120, height=72),\n ], spacing=12)\n .padding()\n .navigation_title(\"Color\")\n )\n\n\nappui.run(body) ToolbarItem ToolbarItem 必须放在 .toolbar([...]) 中。placement 使用 AppUI 的 snake_case 名称:\"automatic\"、\"navigation_bar_leading\"、\"navigation_bar_trailing\"、\"bottom_bar\"、\"principal\"、\"keyboard\"。 import appui\n\n\ndef close_page():\n appui.dismiss()\n\n\ndef body():\n return appui.NavigationStack(\n appui.Text(\"Toolbar content\")\n .padding()\n .navigation_title(\"Toolbar\")\n .toolbar([\n appui.ToolbarItem(\n placement=\"navigation_bar_trailing\",\n content=appui.Button(\n action=close_page,\n content=appui.Label(\"Done\", system_image=\"checkmark\"),\n ),\n role=\"close\",\n )\n ])\n )\n\n\nappui.run(body, presentation=\"sheet\") 与 Path / Canvas 的边界 API 不同点 `Rectangle` / `Circle` 等形状 原生常见形状,适合背景、边框、圆点、胶囊。 `Path` 自定义矢量路径,适合不规则形状、曲线和弧。 `Canvas` 命令式绘图表面,适合混合多种图元、文字和渐变。 `.background(...)` 更适合普通卡片背景;不需要额外 ZStack。 import appui\n\n\ndef body():\n commands = [\n {\"move\": [50, 0]},\n {\"line\": [100, 90]},\n {\"line\": [0, 90]},\n {\"close\": True},\n ]\n\n return appui.NavigationStack(\n appui.VStack([\n appui.RoundedRectangle(corner_radius=8)\n .fill(\"systemBlue\")\n .frame(width=120, height=64),\n appui.Path(commands=commands, fill=\"systemOrange\", stroke=\"label\", line_width=2)\n .frame(width=120, height=100),\n ], spacing=16)\n .padding()\n .navigation_title(\"Shape vs Path\")\n )\n\n\nappui.run(body) 常见错误 错误 正确做法 形状不设 `.frame(...)`,显示尺寸不可控。 给形状明确宽高或高度。 圆形宽高不一致。 `Circle().frame(width: height:)` 使用相同宽高;非圆用 `Ellipse`。 卡片背景都用 `ZStack` + `RoundedRectangle`。 普通卡片直接用 `.background(..., corner_radius=...)` 更简单。 `ToolbarItem` 使用旧 placement 名称。 使用 `\"navigation_bar_trailing\"` 等 snake_case 值。 用 `Color(...)` 包语义色再传给 `.fill(...)`。 `.fill(\"systemBlue\")` 直接传颜色表达即可。 相关文档 文档 用途 [图表与画布 API](appui-ref-charts) Canvas、DrawingContext、Path。 [修饰符 API](appui-ref-modifiers) background、overlay、clip_shape、shadow、toolbar。 [深色模式指南](appui-guide-darkmode) 语义颜色和深浅色适配。" + }, + { + "id": "appui-ref-controls", + "title": "控件 API", + "subtitle": "Button、TextField、Toggle、Picker、Slider 等控件签名。", + "section": "appui / API 参考", + "url": "pages/appui-ref-controls/", + "text": "控件 API Button、TextField、Toggle、Picker、Slider 等控件签名。 appui API 参考 appui api controls Button TextField Toggle Picker Slider 控件 控件 API 本页是 Text、输入控件、选择控件、按钮和系统控件的参考。这里的示例都使用命名函数连接 action / on_change / on_submit,这样预览打开后可以确认按钮、输入和选择是否真的改变状态。 什么时候用 目标 首选 API 说明 普通文字 `Text` 静态标签、标题、说明文字。 一段文字混排 `AttributedText` 同一段内需要不同颜色、字重、斜体或链接。 执行动作 `Button` 保存、删除、刷新、打开页面等命令。 顶层关闭 `CloseButton` MiniApp 自定义关闭入口,尤其是 `fullscreen_with_close`。 单行输入 `TextField` / `SecureField` 普通文本或密码输入。 多行编辑 `TextEditor` 备注、正文、长文本。 搜索 `.searchable(...)` 或 `SearchField` 列表页优先用 `.searchable`;独立搜索框用 `SearchField`。 布尔开关 `Toggle` 开启/关闭某个选项。 数值调节 `Slider` / `Stepper` 连续值用 `Slider`,离散整数用 `Stepper`。 单选 `Picker` / `SegmentedControl` / `WheelPicker` 设置项用 `Picker`,少量模式切换用 `SegmentedControl`。 日期和颜色 `DatePicker` / `MultiDatePicker` / `ColorPicker` 使用系统原生选择器。 菜单和系统按钮 `Menu` / `PasteButton` / `RenameButton` / `EditButton` 复用系统语义和平台样式。 最小正确示例 import appui\n\nstate = appui.State(\n name=\"Ada\",\n enabled=True,\n level=3,\n theme=\"System\",\n note=\"\",\n)\n\n\ndef set_name(value):\n state.name = value\n\n\ndef set_enabled(value):\n state.enabled = value\n\n\ndef set_level(value):\n state.level = value\n\n\ndef set_theme(value):\n state.theme = value\n\n\ndef set_note(value):\n state.note = value\n\n\ndef reset_controls():\n state.batch_update(\n name=\"Ada\",\n enabled=True,\n level=3,\n theme=\"System\",\n note=\"\",\n )\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"输入\", [\n appui.TextField(\n \"Name\",\n text=state.name,\n on_change=set_name,\n submit_label=\"done\",\n ),\n appui.TextEditor(text=state.note, on_change=set_note)\n .frame(height=80),\n appui.Toggle(\"Enabled\", is_on=state.enabled, on_change=set_enabled),\n ]),\n appui.Section(\"选择\", [\n appui.Slider(\n value=state.level,\n minimum=0,\n maximum=10,\n step=1,\n label=f\"Level {state.level}\",\n on_change=set_level,\n ),\n appui.Picker(\n \"Theme\",\n selection=state.theme,\n options=[\"System\", \"Light\", \"Dark\"],\n on_change=set_theme,\n ).picker_style(\"segmented\"),\n ]),\n appui.Section(\"动作\", [\n appui.Button(\"Reset\", action=reset_controls)\n .button_style(\"bordered\"),\n ]),\n ]).navigation_title(\"Controls\")\n )\n\n\nappui.run(body, state=state) 文本和按钮 API 签名 分类 `Text` `Text(content: str = '')` `text` `AttributedText` `AttributedText(spans: Optional[Sequence[Dict[str, Any]]] = None)` `text` `Button` `Button(title: Optional[Union[str, View]] = None, action: Optional[Callable] = None, role: Optional[str] = None, content: Optional[ViewChild] = None, system_image: Optional[str] = None, image: Optional[str] = None, systemImage: Optional[str] = None)` `control` `CloseButton` `CloseButton(title: str = '', system_image: str = 'xmark', systemImage: Optional[str] = None)` `control` import appui\n\n\ndef close_page():\n appui.dismiss()\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.Text(\"Plain Text\").font(\"headline\"),\n appui.AttributedText(spans=[\n {\"text\": \"Rich \", \"font_size\": 18},\n {\"text\": \"Text\", \"font_size\": 18, \"weight\": \"bold\", \"color\": \"systemBlue\"},\n {\"text\": \" link\", \"italic\": True, \"link\": \"https://www.python.org\"},\n ]),\n appui.Button(\n appui.Label(\"Close\", system_image=\"xmark.circle\"),\n action=close_page,\n ).button_style(\"bordered\"),\n ], alignment=\"leading\", spacing=12)\n .padding()\n .navigation_title(\"Text\")\n )\n\n\nappui.run(body) 输入控件 API 签名 分类 `TextField` `TextField(placeholder: str = '', text: str = '', on_change: Optional[Callable] = None, on_submit: Optional[Callable] = None, keyboard_type: Optional[str] = None, autocapitalization: Optional[str] = None, autocorrection_disabled: bool = False, submit_label: Optional[str] = None, onChange: Optional[Callable] = None, onSubmit: Optional[Callable] = None, keyboardType: Optional[str] = None, autoCapitalization: Optional[str] = None, autocorrectionDisabled: Optional[bool] = None, submitLabel: Optional[str] = None, value: Optional[str] = None, **kwargs: Any)` `control` `SecureField` `SecureField(placeholder: str = '', text: str = '', on_change: Optional[Callable] = None, on_submit: Optional[Callable] = None, onChange: Optional[Callable] = None, onSubmit: Optional[Callable] = None)` `control` `TextEditor` `TextEditor(text: str = '', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `control` `TextFieldLink` `TextFieldLink(title: str = '', prompt: str = '', on_submit: Optional[Callable] = None, onSubmit: Optional[Callable] = None)` `control` `SearchField` `SearchField(text: str = '', placeholder: str = 'Search', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None, on_submit: Optional[Callable] = None, onSubmit: Optional[Callable] = None)` `control` keyboard_type 常用值:\"default\"、\"emailAddress\"、\"numberPad\"、\"decimalPad\"、\"url\"、\"phonePad\"。submit_label 常用值:\"done\"、\"go\"、\"send\"、\"search\"、\"next\"、\"continue\"、\"return\"。 选择和调节控件 API 签名 分类 `Toggle` `Toggle(label: str = '', is_on: bool = False, on_change: Optional[Callable] = None, isOn: Optional[bool] = None, onChange: Optional[Callable] = None, value: Optional[bool] = None)` `control` `Slider` `Slider(value: float = 0.0, minimum: float = 0.0, maximum: float = 1.0, step: Optional[float] = None, label: str = '', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None, min_value: Optional[float] = None, max_value: Optional[float] = None, minValue: Optional[float] = None, maxValue: Optional[float] = None)` `control` `Stepper` `Stepper(label: str = '', value: int = 0, minimum: int = 0, maximum: int = 100, step: int = 1, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `control` `Picker` `Picker(label: str = '', selection: Optional[str] = None, options: Optional[Sequence[str]] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `control` `SegmentedControl` `SegmentedControl(options: Optional[Sequence[str]] = None, selection: Optional[str] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `control` `InlinePickerStyle` `InlinePickerStyle(options: Optional[Sequence[str]] = None, selection: Optional[str] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `control` `WheelPicker` `WheelPicker(options: Optional[Sequence[str]] = None, selection: Optional[str] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `control` `DatePicker` `DatePicker(label: str = '', selection: Optional[str] = None, components: str = 'date', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `control` `MultiDatePicker` `MultiDatePicker(title: str = '', on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `control` `ColorPicker` `ColorPicker(label: str = '', selection: Optional[str] = None, on_change: Optional[Callable] = None, onChange: Optional[Callable] = None)` `control` 系统控件 API 签名 分类 `Menu` `Menu(title: str = '', content: Optional[Sequence[View]] = None, children: Optional[Sequence[View]] = None, system_image: Optional[str] = None, image: Optional[str] = None, systemImage: Optional[str] = None)` `control` `PasteButton` `PasteButton(on_paste: Optional[Callable] = None, onPaste: Optional[Callable] = None)` `control` `RenameButton` `RenameButton(action: Optional[Callable] = None)` `control` `EditButton` `EditButton()` `control` `SignInWithAppleButton` `SignInWithAppleButton(type: str = 'signIn', on_complete: Optional[Callable] = None, onComplete: Optional[Callable] = None)` `control` 按钮与菜单的图标 Button 和 Menu 都支持直接传 system_image(SF Symbol 名)来显示图标,无需再包一层 content=appui.Label(...): 同时给 title 和 system_image → 图标 + 文字;只给 system_image → 纯图标。 Menu 用 system_image 可以做工具栏右上角的「ellipsis.circle 溢出菜单」。 仍可用 content= 传任意视图作为完全自定义的标签;content 优先级高于 system_image。 import appui\n\n\ndef noop():\n pass\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.Button(\"收藏\", action=noop, system_image=\"bookmark\"),\n appui.Button(action=noop, system_image=\"arrow.clockwise\"),\n ], alignment=\"leading\", spacing=12)\n .padding()\n .navigation_title(\"Icons\")\n .toolbar([\n appui.ToolbarItem(\n placement=\"navigation_bar_trailing\",\n content=appui.Menu(system_image=\"ellipsis.circle\", content=[\n appui.Button(\"全部\", action=noop, system_image=\"checkmark\"),\n appui.Button(\"收藏\", action=noop),\n ]),\n ),\n ])\n )\n\n\nappui.run(body) 与相邻 API 的区别 API 不同点 `TextField` vs `TextEditor` `TextField` 是单行输入,支持提交键;`TextEditor` 是多行编辑区域,通常需要明确 `.frame(height=...)`。 `SearchField` vs `.searchable(...)` `SearchField` 是普通视图;`.searchable(...)` 会接入导航栏搜索体验,列表页更自然。 `Picker` vs `SegmentedControl` `Picker` 可切换不同样式;`SegmentedControl` 是固定分段控件,适合少量模式。 `Slider` vs `Stepper` `Slider` 适合连续数值;`Stepper` 适合精确整数或固定步进。 `Button` vs `Menu` `Button` 立即执行单个动作;`Menu` 先展开命令列表。 `Button` vs `CloseButton` `CloseButton` 带关闭语义,系统可以识别并避免重复注入关闭按钮。 常见错误 错误 正确做法 在示例里写内联匿名回调,预览里很难读也不利于复用。 定义命名函数,例如 `def save(): ...`,再传 `action=save`。 `TextField` 只传 `text=state.name`,没有 `on_change`。 在 `on_change(value)` 中写回 `state.name = value`。 `Slider` 没有设置 `minimum` / `maximum`,默认范围和业务不一致。 显式写清范围和 `step`。 `TextEditor` 不设高度。 用 `.frame(height=...)` 给多行编辑区域稳定尺寸。 相关文档 文档 用途 [状态 API](appui-ref-state) `State.batch_update`、`ReactiveState`、`bind`、`computed`。 [交互指南](appui-guide-interaction) 手势、焦点、键盘、上下文菜单。 [示例:设置表单](appui-cookbook-settings) 完整 Form、Section、输入控件和 toolbar 示例。" + }, + { + "id": "appui-ref-data", + "title": "数据展示 API", + "subtitle": "List、ForEach、Form、Section、Table、ProgressView 等数据展示视图。", + "section": "appui / API 参考", + "url": "pages/appui-ref-data/", + "text": "数据展示 API List、ForEach、Form、Section、Table、ProgressView 等数据展示视图。 appui API 参考 appui api data List ForEach Form Section Table ProgressView 数据展示 数据展示 API 本页覆盖列表、表单、分组、表格、空状态、进度、链接、角标、时间刷新和富文本。数据展示页面要优先使用系统结构:列表用 List + Section + ForEach,设置页用 Form + Section,不要用普通 VStack 手写整套表单外观。 什么时候用 目标 首选 API 说明 行列表 `List` + `ForEach` 可滚动、可分组、可加滑动操作。 设置页 `Form` + `Section` 原生设置样式,适合输入控件和开关。 标题分组 `Section` 用 header / footer 表达分组标题和说明。 折叠内容 `DisclosureGroup` 可展开详情、更多设置。 标签值行 `LabeledContent` 版本号、状态、统计值。 多列表格 `Table` iPad 或大屏数据表;iPhone 会按系统能力降级。 空状态 `ContentUnavailableView` 无数据、无搜索结果、加载失败。 进度 `ProgressView` / `Gauge` 加载状态、百分比、容量。 链接和分享 `Link` / `ShareLink` 打开系统浏览器或分享面板。 周期刷新文字 `TimelineView` 时钟、倒计时等低频时间展示。 富文本 `AttributedText` 同一段文本内混合颜色、字重、斜体和链接。 最小正确示例 import appui\n\nstate = appui.State(\n items=[\n {\"id\": \"a\", \"title\": \"Alpha\", \"done\": False},\n {\"id\": \"b\", \"title\": \"Beta\", \"done\": True},\n ],\n selected=\"\",\n)\n\n\ndef item_key(item):\n return item[\"id\"]\n\n\ndef select_item(item):\n state.selected = item[\"title\"]\n\n\ndef row_view(item):\n def choose():\n select_item(item)\n\n icon = \"checkmark.circle.fill\" if item[\"done\"] else \"circle\"\n return appui.Button(\n action=choose,\n content=appui.Label(item[\"title\"], system_image=icon),\n )\n\n\ndef body():\n return appui.NavigationStack(\n appui.List([\n appui.Section(\"Items\", [\n appui.ForEach(state.items, row_builder=row_view, key=item_key)\n ]),\n appui.Section(\"Selection\", [\n appui.LabeledContent(\"Selected\", value=state.selected or \"None\"),\n ]),\n ]).navigation_title(\"Data\")\n )\n\n\nappui.run(body, state=state) 列表和表单 API 签名 分类 `List` `List(content: Optional[Sequence[View]] = None)` `collection` `ForEach` `ForEach(data: Any, row_builder: Optional[Callable] = None, key: Optional[Callable] = None, rowBuilder: Optional[Callable] = None, content: Optional[Callable] = None)` `collection` `Form` `Form(content: Optional[Sequence[View]] = None)` `collection` `Section` `Section(content: Optional[ViewChild] = None, *, header: Optional[Union[str, View]] = None, footer: Optional[Union[str, View]] = None, children: Optional[ViewChild] = None, key: Optional[str] = None)` `collection` import appui\n\nstate = appui.State(notify=True, email=\"ada@example.com\")\n\n\ndef set_notify(value):\n state.notify = value\n\n\ndef set_email(value):\n state.email = value\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Account\", [\n appui.TextField(\n \"Email\",\n text=state.email,\n on_change=set_email,\n keyboard_type=\"emailAddress\",\n ),\n appui.Toggle(\"Notifications\", is_on=state.notify, on_change=set_notify),\n ], footer=\"Used for account notifications.\"),\n ]).navigation_title(\"Form\")\n )\n\n\nappui.run(body, state=state) 分组和详情 API 签名 分类 `GroupBox` `GroupBox(label: Optional[str] = None, content: Optional[ViewChild] = None, children: Optional[Sequence[View]] = None)` `collection` `DisclosureGroup` `DisclosureGroup(label: str = '', is_expanded: Optional[bool] = None, content: Optional[ViewChild] = None, isExpanded: Optional[bool] = None, children: Optional[ViewChild] = None)` `collection` `LabeledContent` `LabeledContent(label: str = '', value: Optional[str] = None, content: Optional[View] = None)` `collection` `ControlGroup` `ControlGroup(label: str = '', content: Optional[Sequence[View]] = None, children: Optional[Sequence[View]] = None)` `control` import appui\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Summary\", [\n appui.LabeledContent(\"Version\", value=\"1.0\"),\n appui.LabeledContent(\"Status\", content=appui.Badge(text=\"New\")),\n ]),\n appui.Section(\"Details\", [\n appui.DisclosureGroup(\n \"Advanced\",\n is_expanded=False,\n content=[\n appui.GroupBox(\n \"Cache\",\n content=[appui.Text(\"Local cache is enabled.\")],\n )\n ],\n )\n ]),\n ]).navigation_title(\"Groups\")\n )\n\n\nappui.run(body) 表格 Table(data=None, columns=None, on_select=None, onSelect=None) 参数 类型 说明 `data` `list[dict]` 表格行。 `columns` `list[dict]` 每列字典至少包含 `title` 和 `key`。 `on_select` `Callable` / `None` 选中行回调,接收行字典。 import appui\n\nstate = appui.State(selected=\"\")\nrows = [\n {\"id\": 1, \"name\": \"Ada\", \"role\": \"Admin\"},\n {\"id\": 2, \"name\": \"Lin\", \"role\": \"Editor\"},\n]\n\n\ndef select_row(row):\n state.selected = row[\"name\"]\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.Table(\n data=rows,\n columns=[\n {\"title\": \"ID\", \"key\": \"id\"},\n {\"title\": \"Name\", \"key\": \"name\"},\n {\"title\": \"Role\", \"key\": \"role\"},\n ],\n on_select=select_row,\n ),\n appui.Text(state.selected or \"No selection\")\n .font(\"footnote\")\n .foreground_color(\"secondaryLabel\"),\n ], spacing=12)\n .padding()\n .navigation_title(\"Table\")\n )\n\n\nappui.run(body, state=state) 空状态、进度、链接和分享 API 签名 分类 `ContentUnavailableView` `ContentUnavailableView(title: str = '', system_image: Optional[str] = None, description: Optional[str] = None, systemImage: Optional[str] = None)` `presentation` `ProgressView` `ProgressView(label: Optional[str] = None, value: Optional[float] = None, total: float = 1.0)` `feedback` `Link` `Link(title: str = '', url: str = '')` `control` `Gauge` `Gauge(value: float = 0.0, min_value: float = 0.0, max_value: float = 1.0, label: str = '', minValue: Optional[float] = None, maxValue: Optional[float] = None)` `feedback` `ShareLink` `ShareLink(item: str = '', subject: Optional[str] = None, message: Optional[str] = None)` `control` `Badge` `Badge(count: Optional[int] = None, text: Optional[str] = None)` `presentation` import appui\n\n\ndef body():\n return appui.NavigationStack(\n appui.List([\n appui.Section(\"Status\", [\n appui.ProgressView(label=\"Loading\"),\n appui.ProgressView(label=\"Progress\", value=0.4, total=1.0)\n .progress_view_style(\"linear\"),\n appui.Gauge(value=0.72, min_value=0.0, max_value=1.0, label=\"Storage\")\n .gauge_style(\"accessory_circular\"),\n ]),\n appui.Section(\"Links\", [\n appui.Link(title=\"Apple\", url=\"https://www.apple.com\"),\n appui.ShareLink(item=\"Shared from AppUI\", subject=\"AppUI\", message=\"Hello\"),\n ]),\n appui.Section(\"Empty\", [\n appui.ContentUnavailableView(\n title=\"No Results\",\n system_image=\"magnifyingglass\",\n description=\"Try another keyword.\",\n ),\n ]),\n ]).navigation_title(\"Status\")\n )\n\n\nappui.run(body) TimelineView TimelineView(interval=1.0, content=None) 会按间隔刷新它的子视图,适合时钟、轻量倒计时等低频时间展示。content 是一个 View,不是视图列表。 import appui\nimport time\n\n\ndef clock_text():\n return appui.Text(time.strftime(\"%H:%M:%S\")).font(\"title\")\n\n\ndef body():\n return appui.NavigationStack(\n appui.TimelineView(interval=1.0, content=clock_text())\n .padding()\n .navigation_title(\"Clock\")\n )\n\n\nappui.run(body) AttributedText AttributedText(spans=None) 用 span 字典描述富文本。 键 类型 说明 `text` `str` 显示文字。 `font_size` `float` 字号。 `weight` `str` 字重,例如 `\"bold\"`、`\"semibold\"`、`\"regular\"`。 `bold` `bool` 是否加粗。 `italic` `bool` 是否斜体。 `color` `str` 颜色,例如 `\"systemBlue\"`、`\"#FF0000\"`。 `link` `str` 链接 URL。 `underline` `bool` 下划线。 `strikethrough` `bool` 删除线。 import appui\n\n\ndef body():\n return appui.NavigationStack(\n appui.AttributedText(spans=[\n {\"text\": \"AppUI \", \"font_size\": 20, \"weight\": \"bold\"},\n {\"text\": \"rich text\", \"font_size\": 20, \"color\": \"systemBlue\"},\n {\"text\": \" with link\", \"link\": \"https://www.python.org\", \"underline\": True},\n ])\n .padding()\n .navigation_title(\"Rich Text\")\n )\n\n\nappui.run(body) 与相邻 API 的区别 API 不同点 `List` vs `Form` `List` 展示数据行;`Form` 展示设置和输入。 `Section` vs `GroupBox` `Section` 是列表/表单分组;`GroupBox` 是内容盒。 `ProgressView` vs `Gauge` `ProgressView` 强调加载或进度;`Gauge` 强调数值状态和容量。 `Badge` vs `.badge(...)` `Badge` 是一个独立视图;`.badge(...)` 给支持角标的位置添加角标。 `Link` vs `WebView` `Link` 打开外部浏览器;`WebView` 在 AppUI 内显示网页。 `TimelineView` vs `Timer` `TimelineView` 刷新视图显示;`Timer` 运行状态更新逻辑。 常见错误 错误 正确做法 `ForEach` 没有稳定键,列表刷新后行状态错位。 传 `key` 函数,或保证数据里有稳定 `id` / `key`。 在 `Section` 里再嵌套完整 `List`。 外层用一个 `List`,里面放多个 `Section`。 把设置页写成 `VStack` + `Text` + `Toggle`。 用 `Form([Section(...)])`。 `TimelineView` 的 `content` 传列表。 传一个 `View`,多视图时包进 `VStack`。 `ProgressView(label=appui.Text(...))`。 `label` 传字符串;自定义说明放在相邻 `Text`。 相关文档 文档 用途 [布局 API](appui-ref-layout) VStack、ScrollView、LazyVGrid、Form、List 的布局边界。 [呈现 API](appui-ref-presentation) refreshable、swipe actions、context menu。 [状态 API](appui-ref-state) 让列表和表单响应数据变化。" + }, + { + "id": "appui-ref-layout-scroll", + "title": "滚动 API", + "subtitle": "ScrollView、ScrollViewReader、滚动方向和定位锚点。", + "section": "appui / API 参考", + "url": "pages/appui-ref-layout-scroll/", + "text": "滚动 API ScrollView、ScrollViewReader、滚动方向和定位锚点。 appui API 参考 appui api layout ScrollView ScrollViewReader scroll_to anchor shows_indicators 滚动 滚动 API ScrollView / ScrollViewReader。 ScrollView 签名 ScrollView(content=None, axes='vertical', shows_indicators=True, showsIndicators=None) 参数 参数 类型 说明 `content` `list[View] \\| View \\| None` 可滚动区域;支持 `with ScrollView():` 收集子视图。 `axes` `str` `vertical`、`horizontal` 或 `both`。 `shows_indicators` `bool` 是否显示滚动指示条。 示例 import appui\n\ndef body():\n with appui.ScrollView(axes=\"vertical\") as sc:\n for i in range(25):\n appui.Text(f\"段落 {i}\").padding(horizontal=4)\n return sc.padding()\n\nappui.run(body, presentation=\"sheet\") 参阅 :ScrollViewReader、LazyVStack ScrollViewReader 签名 ScrollViewReader(content=None, axes='vertical', shows_indicators=True,\n scroll_to=None, anchor='top', showsIndicators=None, scrollTo=None,\n children=None) 参数 参数 类型 说明 `content` / `children` `list[View] \\| None` 滚动内容。 `axes` `str` 同 [ScrollView](#scrollview)。 `shows_indicators` `bool` 是否显示指示器。 `scroll_to` / `scrollTo` `任意 \\| None` 初始或受控滚动目标,需与子视图 `.id(...)` 对应。 `anchor` `str` 滚动对齐锚点,如 `top`。 示例 import appui\n\ndef body():\n return appui.ScrollViewReader(\n content=[\n appui.Text(\"顶部\").id(\"top\"),\n appui.Spacer(min_length=400),\n appui.Text(\"底部锚点\").id(\"bottom\"),\n ],\n axes=\"vertical\",\n scroll_to=\"bottom\",\n anchor=\"top\",\n ).padding()\n\nappui.run(body, presentation=\"sheet\") 参阅 :ScrollView、Spacer" + }, + { + "id": "appui-ref-state", + "title": "状态 API", + "subtitle": "State、ReactiveState、NavigationPath、Timer 和 Ref。", + "section": "appui / API 参考", + "url": "pages/appui-ref-state/", + "text": "状态 API State、ReactiveState、NavigationPath、Timer 和 Ref。 appui API 参考 appui api state State ReactiveState NavigationPath Timer Ref 状态 API 本页查 State、PersistentState、ReactiveState、NavigationPath、Timer、Ref、Prop、ObservableList、ObservableDict 的签名和边界。状态写法的完整解释见 状态管理。 什么时候用 目标 API 普通表单、按钮、列表筛选 `State` 明确指定持久化状态 `PersistentState` 高频字段快路径 `ReactiveState` 程序化导航栈 `NavigationPath` 定时执行回调 `Timer` 保存不触发 UI 刷新的句柄 `Ref` 自定义实时属性 `Prop` 观察 list / dict 局部变更 `ObservableList` / `ObservableDict` State 示例 import appui\n\nstate = appui.State(count=0, saved=False)\nsave_count = appui.Ref(0)\n\n\ndef increment():\n state.batch_update(count=state.count + 1, saved=False)\n\n\ndef save():\n save_count.current += 1\n state.saved = True\n\n\ndef body():\n status = \"Saved\" if state.saved else \"Editing\"\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"State\", [\n appui.LabeledContent(\"Count\", value=str(state.count)),\n appui.LabeledContent(\"Status\", value=status),\n appui.LabeledContent(\"Save count\", value=str(save_count.current)),\n appui.Button(\"+1\", action=increment),\n appui.Button(\"Save\", action=save).button_style(\"bordered_prominent\"),\n ])\n ]).navigation_title(\"State\")\n )\n\n\nappui.run(body, state=state) Timer 示例 import appui\n\nstate = appui.State(seconds=0, running=False)\n\n\ndef tick():\n if state.running:\n state.seconds += 1\n\n\ntimer = appui.Timer(interval=1.0, repeats=True, action=tick)\n\n\ndef start():\n state.running = True\n timer.start()\n\n\ndef stop():\n state.running = False\n timer.stop()\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Timer\", [\n appui.LabeledContent(\"Seconds\", value=str(state.seconds)),\n appui.HStack([\n appui.Button(\"Start\", action=start).button_style(\"bordered_prominent\"),\n appui.Button(\"Stop\", action=stop).button_style(\"bordered\"),\n ], spacing=12),\n ])\n ]).navigation_title(\"Timer\")\n )\n\n\nappui.run(body, state=state) 状态签名 API 签名 分类 `State` `State(persist: Optional[Union[bool, Sequence[str]]] = None, persist_key: Optional[str] = None, transient: Optional[Sequence[str]] = None, debounce: float = 0.3, **kwargs: Any)` `公开类型` `PersistentState` `PersistentState(persist_key: Optional[str] = None, transient: Optional[Sequence[str]] = None, debounce: float = 0.3, **kwargs: Any)` `公开类型` `ReactiveState` `ReactiveState(**kwargs: Any)` `公开类型` `NavigationPath` `NavigationPath()` `公开类型` `Timer` `Timer(interval: float = 1.0, repeats: bool = True, action: Optional[Callable[[], None]] = None)` `公开类型` `Ref` `Ref(initial: Any = None)` `公开类型` `Prop` `Prop(prop_id: int, val_type: str = 'auto', default: Any = None, attr: Optional[str] = None)` `公开类型` `ObservableList` `ObservableList(data: Iterable[Any], notify: Callable[[], None])` `公开类型` `ObservableDict` `ObservableDict(data: Mapping[Any, Any], notify: Callable[[], None])` `公开类型` 主要方法 API 方法 签名 所属类型 `State` `batch_update` `batch_update(**kwargs: Any) -> None` `State` `State` `to_dict` `to_dict() -> Dict[str, Any]` `State` `State` `get` `get(key: str, default: Any = None) -> Any` `State` `State` `flush_persisted` `flush_persisted() -> bool` `State` `State` `clear_persisted` `clear_persisted() -> bool` `State` `ReactiveState` `batch_update` `batch_update(**kwargs: Any) -> None` `ReactiveState` `ReactiveState` `to_dict` `to_dict() -> Dict[str, Any]` `ReactiveState` `ReactiveState` `get` `get(key: str, default: Any = None) -> Any` `ReactiveState` `ReactiveState` `bind` `bind(field_name: str, *, parse: Optional[Callable[[Any], Any]] = None, format: Optional[Callable[[Any], Any]] = None, validate: Optional[Callable[[Any], bool]] = None) -> Dict[str, Any]` `ReactiveState` `ReactiveState` `bind_handles` `bind_handles(**bindings: int) -> None` `ReactiveState` `NavigationPath` `append` `append(view_or_value: Union[\"View\", str, int, Dict[str, Any]]) -> None` `NavigationPath` `NavigationPath` `pop` `pop(count: int = 1) -> None` `NavigationPath` `NavigationPath` `pop_to_root` `pop_to_root() -> None` `NavigationPath` `NavigationPath` `replace` `replace(items: Sequence[Any]) -> None` `NavigationPath` `Timer` `start` `start() -> None` `Timer` `Timer` `stop` `stop() -> None` `Timer` 相邻 API 区别 API 用法边界 `State` vs `ReactiveState` 普通页面用 `State`;高频字段才用 `ReactiveState`。 `State(persist=...)` vs `PersistentState` 简单保存可用 `State(persist=True)`;需要固定持久化 key 或更清晰语义时用 `PersistentState`。 `Ref` vs `State` 需要显示到 UI 的值用 `State`;句柄、缓存、连接对象用 `Ref`。 `ObservableList` vs 重新赋值 频繁局部增删改可依赖可观察容器;简单场景重新赋值更直观。 `Timer` vs `auto_refresh` 正式页面用 `Timer`;临时 demo 可用 `auto_refresh`。 `NavigationPath` vs `NavigationLink` 固定行详情用 `NavigationLink`;程序化跳转用 `NavigationPath`。 常见错误 错误 后果 修正 把 UI 要显示的值放进 `Ref` 改值后界面不刷新 放进 `State`。 在 `body()` 里创建 `Timer` 每次重建都可能新建计时器 模块级创建。 先创建没有回调的 `Timer`,再事后赋 action 容易漏掉回调 初始化时传 `action=tick`。 连续写多个字段 产生多次重建和中间状态 使用 `batch_update`。 列表按过滤后的下标修改 搜索后操作错行 数据项保留稳定 id。 相关文档 文档 用途 [状态管理](appui-guide-state) 状态模式、computed/effect、Timer。 [导航与页面结构](appui-guide-navigation) `NavigationPath` 和 `NavigationLink`。 [性能与实时界面](appui-guide-performance) 什么时候使用 `ReactiveState`。" + }, + { + "id": "appui-ref-environment", + "title": "环境值 API", + "subtitle": "environment_value、color_scheme、locale、layout_direction 和动态字体。", + "section": "appui / API 参考", + "url": "pages/appui-ref-environment/", + "text": "环境值 API environment_value、color_scheme、locale、layout_direction 和动态字体。 appui API 参考 appui api layout environment_value preferred_color_scheme color_scheme locale layout_direction dynamic_type_size 环境值 环境值 API environment_value(key, value) 用来设置少量通用环境值。它是一个通用入口;如果已经有更直接的专用修饰符,优先用专用修饰符。 签名 environment_value(key, value) 什么时候用 你想批量给一段视图树施加语言、方向、动态字体或文本环境。 你要覆盖 locale、layout_direction、dynamic_type_size 这类没有独立高频包装的环境项。 你已经知道目标 key,不想拆成多个专用修饰符。 当前支持的 key key 值 作用 更直接的写法 `color_scheme` `light` / `dark` 强制明暗模式。 `preferred_color_scheme(...)` `layout_direction` `left_to_right` / `right_to_left` 控制布局方向。 无 `locale` 语言地区标识,如 `zh_CN`、`en_US` 影响日期、数字、本地化格式。 无 `line_spacing` 数字,如 `4`、`6` 设置行距。 无 `multiline_text_alignment` `leading` / `center` / `trailing` 多行文本对齐。 `multiline_text_alignment(...)` `allow_tight_spacing` `True` / `False` 是否允许文字收紧间距。 无 `truncation_mode` `tail` / `head` / `middle` 文本截断位置。 `truncation_mode(...)` `dynamic_type_size` `xSmall`、`small`、`medium`、`large`、`xLarge`、`xxLarge`、`xxxLarge`、`accessibility1` 到 `accessibility5` 动态字体等级。 无 `redaction` `placeholder` / `none` 占位式骨架屏或取消 redaction。 无 `autocorrection` `True` / `False` 控制自动纠错;对输入控件更有意义。 对 `TextField` / `SecureField` 优先用构造参数 `text_case` `uppercase` / `lowercase` / `none` 统一大小写风格。 无 推荐写法 import appui\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.Text(\"环境值示例\"),\n appui.Text(\"这段文字会使用更大的动态字体和更宽的行距。\")\n .line_limit(2),\n ], spacing=12)\n .padding()\n .navigation_title(\"Environment\")\n .environment_value(\"locale\", \"zh_CN\")\n .environment_value(\"dynamic_type_size\", \"xLarge\")\n .environment_value(\"line_spacing\", 6)\n )\n\n\nappui.run(body) 专用修饰符优先 这些场景优先用专用 API,不要为了“统一写法”强行全部塞进 environment_value: 明暗模式:preferred_color_scheme(\"dark\") 多行文本对齐:multiline_text_alignment(\"leading\") 截断:truncation_mode(\"middle\") 输入框自动纠错:TextField(..., autocorrection_disabled=...) 注意事项 environment_value 更适合“给一整段视图树施加环境”,不是每个修饰符都拿它代替。 dynamic_type_size 依赖较新的系统版本;过低系统可能不会生效。 autocorrection 的实际体验最稳的方式仍然是直接在输入控件构造参数里声明。 不在当前支持表里的 key 不保证生效。" + }, + { + "id": "appui-ref-layout-grids", + "title": "网格 API", + "subtitle": "LazyVGrid、LazyHGrid、Grid、GridRow 和轨道辅助函数。", + "section": "appui / API 参考", + "url": "pages/appui-ref-layout-grids/", + "text": "网格 API LazyVGrid、LazyHGrid、Grid、GridRow 和轨道辅助函数。 appui API 参考 appui api layout LazyVGrid LazyHGrid Grid GridRow flexible fixed adaptive grid_item 网格 网格 API LazyVGrid / LazyHGrid / Grid / GridRow 与轨道辅助函数 flexible / fixed / adaptive / grid_item。 LazyVGrid 签名 LazyVGrid(columns=None, content=None, spacing=None, children=None) 参数 参数 类型 说明 `columns` `list[dict] \\| None` 列描述列表;缺省为 `[{'type': 'flexible'}]`。 `content` / `children` `list[View] \\| None` 网格单元视图。 `spacing` `数值 \\| None` 单元间距。 示例 import appui\n\ncols = [\n appui.flexible(minimum=40),\n appui.flexible(minimum=40),\n appui.fixed(50),\n]\n\ndef body():\n return appui.LazyVGrid(\n columns=cols,\n spacing=8,\n content=[appui.Text(str(i)).frame(max_width=appui.infinity) for i in range(12)],\n ).padding()\n\nappui.run(body, presentation=\"sheet\") 参阅 :flexible、fixed、adaptive、LazyHGrid LazyHGrid 签名 LazyHGrid(rows=None, content=None, spacing=None, children=None) 参数 参数 类型 说明 `rows` `list[dict] \\| None` 行描述;缺省为 `[{'type': 'flexible'}]`。 `content` / `children` `list[View] \\| None` 子视图。 `spacing` `数值 \\| None` 间距。 示例 import appui\n\nrows = [appui.flexible(), appui.fixed(36)]\n\ndef body():\n return appui.LazyHGrid(\n rows=rows,\n spacing=6,\n content=[appui.Text(f\"({i})\") for i in range(8)],\n ).padding()\n\nappui.run(body, presentation=\"sheet\") 参阅 :LazyVGrid、Grid Grid 签名 Grid(content=None, alignment='center', horizontal_spacing=None, vertical_spacing=None,\n horizontalSpacing=None, verticalSpacing=None) 参数 参数 类型 说明 `content` `list[View] \\| None` 通常由若干 [GridRow](#gridrow) 组成。 `alignment` `str` 单元格对齐。 `horizontal_spacing` / `vertical_spacing` `数值 \\| None` 行/列间距。 示例 import appui\n\ndef body():\n return appui.Grid(\n content=[\n appui.GridRow([appui.Text(\"A1\"), appui.Text(\"B1\")]),\n appui.GridRow([appui.Text(\"A2\"), appui.Text(\"B2\")]),\n ],\n horizontal_spacing=12,\n vertical_spacing=8,\n ).padding()\n\nappui.run(body, presentation=\"sheet\") 参阅 :GridRow、LazyVGrid GridRow 签名 GridRow(content=None, alignment=None) 参数 参数 类型 说明 `content` `list[View] \\| None` 一行中的单元视图。 `alignment` `str \\| None` 行内对齐;`None` 表示默认。 示例 见 Grid。 参阅 :Grid、HStack flexible 签名 flexible(minimum=10, maximum=None) 参数 参数 类型 说明 `minimum` `数值` 轨道最小尺寸。 `maximum` `数值 \\| None` 最大尺寸;`None` 表示不限制。 示例 import appui\n\nrow = [appui.flexible(minimum=60), appui.fixed(44)]\nprint(row[0][\"type\"], row[1][\"type\"]) 参阅 :LazyVGrid、fixed fixed 签名 fixed(size) 参数 参数 类型 说明 `size` `数值` 固定轨道尺寸。 示例 import appui\n\nc = appui.fixed(120)\nassert c[\"type\"] == \"fixed\" 参阅 :LazyVGrid、adaptive adaptive 签名 adaptive(minimum=50, maximum=None) 参数 参数 类型 说明 `minimum` `数值` 每个自适应单元最小宽度。 `maximum` `数值 \\| None` 可选上限。 示例 import appui\n\ncols = [appui.adaptive(minimum=80)]\nprint(len(cols), cols[0][\"type\"]) 参阅 :LazyVGrid、flexible grid_item 签名 grid_item(type='flexible', minimum=None, maximum=None, count=None) 参数 参数 类型 说明 `type` `str` 轨道类型:`'flexible'`、`'fixed'`、`'adaptive'`。 `minimum` `数值 \\| None` 轨道最小尺寸。 `maximum` `数值 \\| None` 轨道最大尺寸;`None` 表示不限制。 `count` `int \\| None` 用于 `adaptive` 类型时的列数提示。 用途 通用网格轨道描述函数,返回 dict;与 flexible、fixed、adaptive 功能等价,适合动态生成列/行描述。 import appui\n\ncols = [appui.grid_item('flexible', minimum=60), appui.grid_item('fixed', minimum=44)]\nprint(cols)" + }, + { + "id": "miniapp-appui-api-index", + "title": "AppUI API 地图", + "subtitle": "按任务选择入口、状态、布局、控件、导航、媒体和图表 API。", + "section": "appui / 开始", + "url": "pages/miniapp-appui-api-index/", + "text": "AppUI API 地图 按任务选择入口、状态、布局、控件、导航、媒体和图表 API。 appui 开始 appui API Reference appui api api 速查 NavigationPath NavigationSplitView ScrollViewReader TextField SecureField TextEditor SearchField Button Toggle Slider Picker DatePicker PhotoPicker CameraPicker MapView VideoPlayer WebView AsyncImage Chart Canvas Path Popover Alert ConfirmationDialog Refreshable SafeAreaInset SwipeActions Table Grid ShareLink AppUI API 地图 这页按任务组织 AppUI API。还没确定页面结构时,先看 AppUI UI 模式。 最小正确组合 import appui\n\nstate = appui.State(name=\"MiniApp\", enabled=True, saved=False)\n\n\ndef set_name(value):\n state.batch_update(name=value, saved=False)\n\n\ndef set_enabled(value):\n state.batch_update(enabled=value, saved=False)\n\n\ndef save():\n state.saved = bool(state.name.strip())\n\n\ndef body():\n footer = \"Saved\" if state.saved else \"Edit values and tap Save\"\n form = appui.Form([\n appui.Section(\"Controls\", [\n appui.TextField(\"Name\", text=state.name, on_change=set_name),\n appui.Toggle(\"Enabled\", is_on=state.enabled, on_change=set_enabled),\n ]),\n appui.Section(\"Output\", [\n appui.LabeledContent(\"Name\", value=state.name or \"Missing\"),\n appui.LabeledContent(\"Enabled\", value=str(state.enabled)),\n ], footer=footer),\n ]).navigation_title(\"API 地图\")\n\n return appui.NavigationStack(\n form.toolbar([\n appui.ToolbarItem(\n placement=\"navigation_bar_trailing\",\n content=appui.Button(\"Save\", action=save)\n .button_style(\"bordered_prominent\"),\n )\n ])\n )\n\n\nappui.run(body, state=state) 预期效果:页面显示输入、开关、输出和导航栏保存按钮;修改后点击 Save 会更新保存状态。 按任务查 API 任务 首选 API 继续阅读 启动 MiniApp `appui.run` [函数参考](appui-ref-functions.md) 控制呈现方式 `appui.run(..., presentation=...)` [函数参考](appui-ref-functions.md) 程序化弹层 `presentation_present` / `presentation_dismiss` [呈现 API](appui-ref-presentation.md) 保存普通界面状态 `State` [状态管理](appui-guide-state.md) 多字段一起更新 `state.batch_update(...)` [状态管理](appui-guide-state.md) 高频局部刷新 `ReactiveState` [性能与实时界面](appui-guide-performance.md) 派生数据 `computed` [状态管理](appui-guide-state.md) 状态变化后的副作用 `effect` [状态管理](appui-guide-state.md) 双向绑定 `bind` [函数参考](appui-ref-functions.md) 输入设置项 `Form + Section` [布局系统](appui-guide-layout.md) 动态列表 `List + ForEach` [布局系统](appui-guide-layout.md) 列表进详情 `NavigationStack + NavigationLink` [导航与页面结构](appui-guide-navigation.md) 程序化跳转 `NavigationPath + destinations` [导航与页面结构](appui-guide-navigation.md) 多主分区 `TabView + Tab` [导航与页面结构](appui-guide-navigation.md) 底部状态条展开面板 `.tab_view_bottom_accessory` + `.sheet` [导航参考](appui-ref-navigation.md#底部附件与原生-sheet) 导航栏按钮 `ToolbarItem` [导航参考](appui-ref-navigation.md) 临时弹层 `.sheet` / `.popover` [呈现 API](appui-ref-presentation.md) 确认和错误提示 `.alert` / `.confirmation_dialog` [呈现 API](appui-ref-presentation.md) 搜索和刷新 `.searchable` / `.refreshable` [交互与回调](appui-guide-interaction.md) 空状态 `ContentUnavailableView` [数据展示](appui-ref-data.md) 加载状态 `ProgressView` [数据展示](appui-ref-data.md) 输入控件 `TextField`、`SecureField`、`TextEditor` [控件 API](appui-ref-controls.md) 选择控件 `Toggle`、`Picker`、`Slider`、`Stepper` [控件 API](appui-ref-controls.md) 媒体和文件 `PhotoPicker`、`CameraPicker`、`FileImporter` [媒体 API](appui-ref-media.md) 地图、网页、视频 `MapView`、`WebView`、`VideoPlayer` [媒体 API](appui-ref-media.md) 图表和绘制 `Chart`、`Canvas`、`DrawingContext`、`Path` [图表 API](appui-ref-charts.md) 行为契约 API 保证 注意 `appui.run(...)` 启动当前 MiniApp 页面。 一个脚本只保留一个最终入口。 `State` 字段变化后页面重建。 多字段同时更新用 `batch_update(...)`。 `Button(action=...)` 点击后执行命名回调。 不要写 `action=save()`。 `TextField(on_change=...)` 输入变化时把新值交给回调。 回调要写回 `State`。 `ForEach(..., key=...)` 维护动态行身份。 key 必须来自稳定业务 id。 `NavigationLink` 打开目标详情页。 目标页用函数返回 View,避免把复杂逻辑塞进行内。 `.tab_view_bottom_accessory(...)` + `.sheet(...)` 底部常驻 compact 视图点击后打开原生 sheet。 适合播放、下载、录音、导航等持续任务;紧凑条负责常驻状态,完整面板交给系统 sheet 处理下拉关闭、圆角和拖拽手感。 `.sheet(...)` / `.alert(...)` 展示临时页面或提示。 `is_presented` 要由状态控制。 `PhotoPicker` / `FileImporter` 触发系统选择流程。 处理用户取消、权限拒绝和空结果。 选择顺序 先选容器:NavigationStack、TabView、List、Form、ScrollView。 再选数据流:State、ReactiveState、bind、computed、Timer。 再选控件:输入、选择、按钮、工具栏、弹层。 再选状态反馈:空状态、加载、错误、保存成功、权限拒绝。 最后选视觉:字体、颜色、边距、背景、图表或媒体。 失败路径 API 名不确定:先按任务查本页,再进入对应 appui ref 。 按钮没反应:确认 Button(action=...) 传入命名函数。 输入不更新:确认 on_change 写回 State。 列表错乱:确认 ForEach 使用稳定 key。 页面空白:确认 body() 返回 View,appui.run(...) 在脚本末尾调用。 相关文档 文档 用途 [快速上手](appui-quickstart.md) 跑通第一个 MiniApp。 [运行时选择](miniapp-choose-runtime.md) 判断是否应该使用 AppUI。 [AppUI UI 模式](miniapp-appui-ui-patterns.md) 选择页面结构。 [MiniApp 开发排错](miniapp-dev-troubleshooting.md) 排查入口、状态、布局和系统能力。" + }, + { + "id": "miniapp-appui-ui-patterns", + "title": "AppUI UI 模式", + "subtitle": "按页面类型选择 Form、List、NavigationStack、TabView 和媒体承载。", + "section": "appui / 开始", + "url": "pages/miniapp-appui-ui-patterns/", + "text": "AppUI UI 模式 按页面类型选择 Form、List、NavigationStack、TabView 和媒体承载。 appui 开始 appui UI Layout appui ui 布局模式 表单 列表 导航 dashboard NavigationPath NavigationSplitView ScrollViewReader TextField SecureField TextEditor SearchField Button Toggle Slider Picker DatePicker PhotoPicker CameraPicker MapView VideoPlayer WebView AsyncImage Chart Canvas Path Popover Alert ConfirmationDialog Refreshable SafeAreaInset SwipeActions Table Grid ShareLink AppUI UI 模式 这篇文档帮你先选对页面结构,再进入具体 API。已经知道 API 名时,直接查 AppUI API 地图。 先记住 4 条规则 先定页面结构,再定控件。 设置和编辑优先 Form + Section,动态数据优先 List + ForEach。 多页面优先 NavigationStack + NavigationLink,主分区优先 TabView + Tab。 默认使用原生结构;只有视频、地图、WebView、相机、Chart、Canvas、游戏化首屏这类场景才使用自定义承载。 最小模式示例 import appui\n\nitems = [\n {\"id\": \"inbox\", \"title\": \"收件箱\", \"count\": 12},\n {\"id\": \"today\", \"title\": \"今日任务\", \"count\": 5},\n {\"id\": \"done\", \"title\": \"已完成\", \"count\": 32},\n]\n\n\ndef item_key(item):\n return item[\"id\"]\n\n\ndef detail_view(item):\n return appui.Form([\n appui.Section(item[\"title\"], [\n appui.LabeledContent(\"Count\", value=str(item[\"count\"])),\n ])\n ]).navigation_title(item[\"title\"])\n\n\ndef row_view(item):\n return appui.NavigationLink(\n destination=detail_view(item),\n label=appui.HStack([\n appui.Text(item[\"title\"])\n .frame(max_width=appui.infinity, alignment=\"leading\"),\n appui.Text(str(item[\"count\"])).foreground_color(\"secondaryLabel\"),\n ]),\n )\n\n\ndef body():\n return appui.NavigationStack(\n appui.List([\n appui.Section(\"分区\", [\n appui.ForEach(items, row_builder=row_view, key=item_key),\n ]),\n ]).navigation_title(\"UI 模式\")\n )\n\n\nappui.run(body) 预期效果:列表显示三个分区,点击任一行进入对应详情页。 按页面选择结构 页面类型 推荐结构 适合 单页工具 `NavigationStack + VStack` 计算器、转换器、小查询页 设置页 `NavigationStack + Form + Section` 开关、输入、选择器、账号配置 列表页 `NavigationStack + List + ForEach` 任务、笔记、记录、搜索结果 列表详情 `NavigationStack + NavigationLink` 课程、商品、文档、条目详情 多主分区 `TabView + Tab` 首页、记录、设置、统计 底部常驻状态条 `TabView + .tab_view_bottom_accessory + .sheet` 音乐、播客、下载、录音、导航 仪表盘 `ScrollView + LazyVGrid + Chart` KPI、日报、趋势图、监控 媒体页 `VideoPlayer` / `MapView` / `WebView` 视频、地图、网页和富内容 自定义绘制 `Canvas + DrawingContext + Path` 轻量绘图、签名板、图形面板 高频能力入口 能力 常见场景 继续阅读 `NavigationStack` 页面 push、详情页、设置页 [导航与页面结构](appui-guide-navigation.md) `NavigationLink` 列表进详情、设置项进入子页 [导航与页面结构](appui-guide-navigation.md) `NavigationPath` 程序化跳转、返回、带参数详情 [导航与页面结构](appui-guide-navigation.md) `TabView` / `Tab` 首页、记录、设置等主分区 [导航与页面结构](appui-guide-navigation.md) `.tab_view_bottom_accessory` + `.sheet` 底部状态条点击后打开原生面板 [导航参考](appui-ref-navigation.md#底部附件与原生-sheet) `Form` / `Section` 设置、资料编辑、筛选条件 [布局系统](appui-guide-layout.md) `TextField` / `SecureField` 单行输入、密码、API Key [控件 API](appui-ref-controls.md) `Toggle` / `Picker` / `Slider` 开关、模式、连续数值 [控件 API](appui-ref-controls.md) `List` / `ForEach` 动态行、分组数据、原生列表 [布局系统](appui-guide-layout.md) `ContentUnavailableView` 空状态、无结果、无权限提示 [数据展示](appui-ref-data.md) `.searchable` / `.refreshable` 搜索过滤、下拉刷新 [交互与回调](appui-guide-interaction.md) `PhotoPicker` / `FileImporter` 选图、导入 PDF/CSV/文本 [媒体与系统集成](appui-guide-media.md) `CameraPicker` / `MapView` / `WebView` 拍照、地图、网页 [媒体与系统集成](appui-guide-media.md) `Chart` / `Canvas` / `Path` 数据看板、自定义绘制 [图表与绘图](appui-guide-charts.md) `VideoPlayer` / `AsyncImage` 视频播放、远程封面 [媒体与系统集成](appui-guide-media.md) 反模式速查 不要用一个巨大 VStack 承载整页;内容会变多时用 List、Form 或 ScrollView。 不要在 List 行里放复杂媒体承载;视频、地图、WebView 应该放在稳定页面区域。 不要把所有编辑逻辑塞进列表行;详情页或表单页负责局部编辑。 不要把全局播放、下载、录音状态硬塞成一个独立 Tab;需要常驻底部状态时用 .tab_view_bottom_accessory 显示紧凑条,再用 .sheet 展开详情。 不要用颜色硬模拟主题;优先使用 systemBackground、label、secondaryLabel。 不要把网络、权限、文件写入 body();放到按钮、刷新或明确加载动作。 需求描述模板 描述一个 AppUI MiniApp 时,最好一次说清: 这是一个 appui MiniApp。 有几个页面,每页负责什么。 每页是列表、表单、详情、仪表盘还是媒体页。 需要哪些状态、空态、错误态和加载态。 需要哪些系统能力、权限、保存或导出路径。 失败路径 页面越来越乱:先回到“按页面选择结构”,不要先堆控件。 设置页不像原生:改成 Form + Section。 列表搜索后错行:给 ForEach 提供稳定业务 key。 动画或媒体卡顿:确认是否应该用 scene、VideoPlayer、Canvas 或专门媒体区域。 API 参考 按任务查 API 见 AppUI API 地图。需要判断运行时时,先看 运行时选择。 相关文档 文档 用途 [快速上手](appui-quickstart.md) 四个可预览示例串起最小闭环。 [AppUI API 地图](miniapp-appui-api-index.md) 按任务查 30 项高频 API。 [原生能力入口](miniapp-native-capabilities.md) AppUI 页面里调用 iOS 原生模块。 [开发排错](miniapp-dev-troubleshooting.md) 空白页、按钮无效、输入不更新等问题。" + }, + { + "id": "miniapp-dev-troubleshooting", + "title": "MiniApp 开发排错", + "subtitle": "排查入口、状态、按钮、输入、列表、布局和系统能力问题。", + "section": "appui / 开始", + "url": "pages/miniapp-dev-troubleshooting/", + "text": "MiniApp 开发排错 排查入口、状态、按钮、输入、列表、布局和系统能力问题。 appui 开始 Debug Troubleshooting miniapp 开发排错 运行时失配 参数错误 布局异常 API 参数错误 交互无响应 MiniApp 开发排错 排错先看运行时是否选对,再看入口是否完整,最后查状态、交互、布局和系统能力。不要只确认“预览能打开”,还要确认按钮、输入、搜索、刷新和弹层都能产生可见变化。 最小健康样例 import appui\n\nstate = appui.State(saved=False)\n\n\ndef save():\n state.saved = True\n\n\ndef body():\n label = \"Saved\" if state.saved else \"Not saved\"\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Action\", [\n appui.LabeledContent(\"Status\", value=label),\n appui.Button(\"Save\", action=save)\n .button_style(\"bordered_prominent\"),\n ])\n ]).navigation_title(\"排错\")\n )\n\n\nappui.run(body, state=state) 预期效果:页面显示保存状态,点击 Save 后状态从 Not saved 变成 Saved。 快速定位 现象 先查 继续看 预览打不开 `import appui`、`body()`、`appui.run(...)` [快速上手](appui-quickstart.md) 点按钮没反应 `action` 是否传函数本身 [AppUI API 地图](miniapp-appui-api-index.md) 输入不更新 `on_change` 是否写回 `State` [状态管理](appui-guide-state.md) 列表刷新错乱 `ForEach` 是否有稳定 `key` [布局系统](appui-guide-layout.md) 页面不像原生 App 是否使用 `List`、`Form`、`NavigationStack` [AppUI UI 模式](miniapp-appui-ui-patterns.md) 高频刷新卡顿 `Timer` 是否在 `body()` 外创建 [性能与实时界面](appui-guide-performance.md) 权限或系统能力失败 是否处理拒绝、取消和不可用 [原生能力入口](miniapp-native-capabilities.md) 正确的按钮写法 def save():\n state.saved = True\n\n\nappui.Button(\"Save\", action=save) 不要这样写: appui.Button(\"Save\", action=save()) save() 会在构建页面时立刻执行,按钮拿不到正确回调。 正确的列表写法 def row_key(row):\n return row[\"id\"]\n\n\ndef row_view(row):\n return appui.Text(row[\"title\"])\n\n\nappui.ForEach(rows, row_builder=row_view, key=row_key) 不要把筛选后的 index 当 key。删除、重排、搜索后 index 会变化,行状态可能错位。 运行前检查 完整示例应该能独立复制运行,入口只保留一次 appui.run(...)。 按钮、输入、搜索、刷新和弹层都应该有可见状态变化。 动态列表必须有稳定 key,删除、搜索、重排后不能错行。 涉及相册、相机、定位、通知、健康数据等能力时,要处理权限拒绝、用户取消和设备不可用。 网络、文件、权限请求不要直接写在 body() 里,放到按钮、刷新或明确的加载动作中。 失败时保留旧数据或显示空状态,不要让页面静默空白。 失败路径 空白:先跑本页“最小健康样例”。如果它能显示,问题通常在你的状态、资源、权限或页面结构中。 交互无效:确认回调是命名函数,并且回调里修改了 State。 输入无法保存:确认 on_change 接收新值,保存按钮读取的是同一个 State。 列表错行:确认每个数据项有稳定 id,并传给 ForEach(..., key=...)。 页面结构失控:回到 AppUI UI 模式,先确定页面类型。 相关文档 文档 用途 [运行时选择](miniapp-choose-runtime.md) 判断 AppUI、widget、ui、scene 和 console。 [快速上手](appui-quickstart.md) 用四个可预览示例掌握入口、状态、表单和导航。 [AppUI UI 模式](miniapp-appui-ui-patterns.md) 常见页面结构和反模式。 [AppUI API 地图](miniapp-appui-api-index.md) 按任务查 API。 [原生能力入口](miniapp-native-capabilities.md) 系统能力、权限和失败路径。" + }, + { + "id": "appui-module", + "title": "appui 概览", + "subtitle": "用 Python 编写原生 MiniApp 页面、状态、导航和交互。", + "section": "appui / 开始", + "url": "pages/appui-module/", + "text": "appui 概览 用 Python 编写原生 MiniApp 页面、状态、导航和交互。 appui 开始 appui native-ui miniapp appui 声明式 原生界面 MiniApp 预览 State NavigationStack Form appui appui 是 PythonIDE 用来编写原生 MiniApp 界面的模块。你用 Python 描述页面结构、状态和交互,PythonIDE 负责把它显示成 iOS 风格的表单、列表、导航、弹层、媒体和图表界面。 最小脚本 import appui\n\nstate = appui.State(count=0)\n\n\ndef add_one():\n state.count += 1\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"计数器\", [\n appui.LabeledContent(\"当前值\", value=str(state.count)),\n appui.Button(\"加 1\", action=add_one)\n .button_style(\"bordered_prominent\"),\n ])\n ]).navigation_title(\"AppUI\")\n )\n\n\nappui.run(body, state=state) 预期效果:预览打开一个原生表单页面,点击按钮后当前值立即更新。 先选写法 目标 推荐看 第一次写 MiniApp [快速上手](appui-quickstart.md) 判断该用 AppUI、widget、ui 还是 scene [运行时选择](miniapp-choose-runtime.md) 选择页面结构 [AppUI UI 模式](miniapp-appui-ui-patterns.md) 按任务查 API [AppUI API 地图](miniapp-appui-api-index.md) 排查空白、按钮、输入、列表和权限问题 [MiniApp 开发排错](miniapp-dev-troubleshooting.md) 查完整函数和组件 [函数参考](appui-ref-functions.md) 写 AppUI 的心智模型 入口:脚本末尾调用一次 appui.run(body, state=state)。 页面:body() 或 body(view_state) 返回一个 AppUI View。 状态:State 保存普通界面数据,ReactiveState 只用于高频变化。 绑定:Binding / bind() / State.bind() 适合输入控件和状态双向连接。 结构:设置页用 Form + Section,列表页用 List + ForEach,多页面用 NavigationStack 或 TabView。 交互:按钮、输入、搜索、刷新和弹层使用命名回调修改状态。 副作用:联网、文件、权限、通知和耗时任务不要写在 body() 里。 适用场景 需求 首选 表单、设置、列表、详情、工具页 `appui` 多页面 MiniApp、Tab、弹层、搜索、刷新 `appui` 图表、媒体、地图、WebView 和系统能力入口 `appui` + 对应模块 主屏、锁屏、StandBy 小组件 `widget` Sprite、碰撞、逐帧绘制、小游戏 `scene` Pythonista 兼容的命令式界面脚本 `ui` 纯输出、批处理、日志转换 普通 Python / `console` 行为契约 API 保证 注意 `appui.run(...)` 启动一个 MiniApp 预览或页面。 放在脚本末尾,一个脚本只保留一个入口。 `appui.run(..., presentation=...)` 控制界面呈现方式。 可选 `'sheet'`(默认)、`'fullscreen'`、`'fullscreen_with_close'`。 `State` 字段变化后触发页面重建。 多字段同时更新优先用 `batch_update(...)`。 `ReactiveState` 适合高频数据更新。 普通表单和列表不要默认使用。 `body()` 描述当前界面。 不要在里面发请求、申请权限、写文件或修改状态。 `Button(action=...)` 点击后执行命名回调。 传函数本身,不要写 `action=save()`。 `ForEach(..., key=...)` 用稳定 key 维护动态行身份。 不要把可变化的 index 当 key。 API 参考 分类 文档 函数与入口 [函数参考](appui-ref-functions.md) 状态与绑定 [状态 API](appui-ref-state.md) 布局容器 [布局 API](appui-ref-layout.md) 控件 [控件参考](appui-ref-controls.md) 导航 [导航参考](appui-ref-navigation.md) 呈现和数据展示 [呈现参考](appui-ref-presentation.md)、[数据展示](appui-ref-data.md) 修饰符 [修饰符参考](appui-ref-modifiers.md) 图表、媒体、地图、形状 [图表](appui-ref-charts.md)、[媒体](appui-ref-media.md)、[形状](appui-ref-shapes.md) 失败路径 预览空白:确认 body() 返回 AppUI View,脚本末尾调用了 appui.run(...)。 按钮没反应:确认 action 传入命名函数,而不是函数调用结果。 输入不更新:确认 on_change 接收新值并写回 State。 列表错行:确认 ForEach 使用稳定 key。 页面不像原生设置:设置页优先用 Form + Section,不要用一组自绘卡片模拟。 权限或网络反复弹出:确认副作用没有写进 body()。 模块兼容性 appui 与 ui 有 View、Button、TextField、Slider、Image、ScrollView 等同名类/函数,但语义不同。不要在同一文件中同时 from appui import 和 from ui import 。 appui 与 scene 只有 run 这个入口名容易混淆。混用时用模块名限定调用:appui.run(body) / scene.run(MyScene())。" + }, + { + "id": "appui-quickstart", + "title": "快速上手", + "subtitle": "用四个可预览示例掌握 body、State、控件和导航。", + "section": "appui / 开始", + "url": "pages/appui-quickstart/", + "text": "快速上手 用四个可预览示例掌握 body、State、控件和导航。 appui 开始 appui quickstart preview 快速上手 hello state button tabview previewAppUI 快速上手 这篇教程用四个可预览示例串起 AppUI 的最小闭环:显示页面、修改状态、编辑表单、列表进详情。每段都可以直接运行。 1. Hello World 先让原生页面出现。body() 返回一个 View,脚本末尾调用 appui.run(body)。 import appui\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.Text(\"Hello AppUI\").font(\"title2\").bold(),\n appui.Text(\"用 Python 编写原生 MiniApp 界面\")\n .foreground_color(\"secondaryLabel\"),\n ], spacing=8)\n .padding()\n .navigation_title(\"Hello\")\n )\n\n\nappui.run(body) 预期效果:预览显示一个带导航标题的原生页面,页面中有标题和说明文字。 2. State 和按钮 用 State 保存页面数据。按钮回调修改状态后,页面会用新状态重建。 import appui\n\nstate = appui.State(count=0, message=\"Ready\")\n\n\ndef decrease():\n state.batch_update(count=state.count - 1, message=\"Decreased\")\n\n\ndef increase():\n state.batch_update(count=state.count + 1, message=\"Increased\")\n\n\ndef body(view_state):\n return appui.NavigationStack(\n appui.VStack([\n appui.Text(f\"当前计数:{view_state.count}\").font(\"title3\"),\n appui.HStack([\n appui.Button(\"-1\", action=decrease).button_style(\"bordered\"),\n appui.Button(\"+1\", action=increase)\n .button_style(\"bordered_prominent\"),\n ], spacing=12),\n appui.Text(view_state.message)\n .font(\"caption\")\n .foreground_color(\"secondaryLabel\"),\n ], spacing=16)\n .padding()\n .navigation_title(\"状态\")\n )\n\n\nappui.run(body, state=state) 预期效果:点击 +1 或 1 后计数和状态文案同步变化。 3. 表单控件 设置页和编辑页优先使用 Form + Section。输入控件通过 on_change 写回 State。 import appui\n\nstate = appui.State(name=\"Ada\", enabled=False, volume=40.0, saved=False)\n\n\ndef set_name(value):\n state.batch_update(name=value, saved=False)\n\n\ndef set_enabled(value):\n state.batch_update(enabled=value, saved=False)\n\n\ndef set_volume(value):\n state.batch_update(volume=value, saved=False)\n\n\ndef save():\n state.saved = bool(state.name.strip())\n\n\ndef body():\n footer = \"已保存\" if state.saved else \"修改后点击保存\"\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"资料\", [\n appui.TextField(\"姓名\", text=state.name, on_change=set_name),\n appui.Toggle(\"启用提醒\", is_on=state.enabled, on_change=set_enabled),\n appui.Slider(\n value=state.volume,\n minimum=0,\n maximum=100,\n on_change=set_volume,\n ),\n ], footer=footer),\n appui.Section(\"操作\", [\n appui.Button(\"保存\", action=save)\n .button_style(\"bordered_prominent\"),\n ]),\n ]).navigation_title(\"表单\")\n )\n\n\nappui.run(body, state=state) 预期效果:修改文本、开关或滑块后页面保持响应;点击保存后底部文案变为“已保存”。 4. 导航和列表 多页面 MiniApp 通常用 NavigationStack 包根页面,列表进详情用 NavigationLink。动态行必须有稳定 key。 import appui\n\nitems = [\n {\"id\": \"docs\", \"title\": \"文档\", \"subtitle\": \"Guide\"},\n {\"id\": \"api\", \"title\": \"API\", \"subtitle\": \"Reference\"},\n {\"id\": \"demo\", \"title\": \"示例\", \"subtitle\": \"Cookbook\"},\n]\n\nstate = appui.State(last_opened=\"None\")\n\n\ndef item_key(item):\n return item[\"id\"]\n\n\ndef mark_opened(item):\n state.last_opened = item[\"title\"]\n\n\ndef detail_view(item):\n def open_current():\n mark_opened(item)\n\n return appui.Form([\n appui.Section(\"详情\", [\n appui.LabeledContent(\"标题\", value=item[\"title\"]),\n appui.LabeledContent(\"类型\", value=item[\"subtitle\"]),\n appui.Button(\"标记打开\", action=open_current),\n ]),\n appui.Section(\"状态\", [\n appui.LabeledContent(\"Last opened\", value=state.last_opened),\n ]),\n ]).navigation_title(item[\"title\"])\n\n\ndef row_view(item):\n label = appui.HStack([\n appui.Text(item[\"title\"])\n .frame(max_width=appui.infinity, alignment=\"leading\"),\n appui.Text(item[\"subtitle\"]).foreground_color(\"secondaryLabel\"),\n ])\n return appui.NavigationLink(destination=detail_view(item), label=label)\n\n\ndef body():\n return appui.NavigationStack(\n appui.List([\n appui.Section(\"入口\", [\n appui.ForEach(items, row_builder=row_view, key=item_key),\n ])\n ]).navigation_title(\"列表\")\n )\n\n\nappui.run(body, state=state) 预期效果:列表显示三行入口,点击任一行进入详情页;详情页按钮会更新打开状态。 发布前检查 检查项 合格标准 入口 脚本末尾只调用一次 `appui.run(...)` 呈现 默认 `presentation='sheet'`;全屏可用 `'fullscreen'` 或 `'fullscreen_with_close'` 页面 `body()` 或 `body(state)` 返回 AppUI View 状态 回调修改 `State`,多字段更新用 `batch_update(...)` 按钮 `action` 传命名函数,不写 `action=save()` 输入 `on_change` 接收新值并写回 `State` 列表 `ForEach` 使用稳定 `key` 失败路径 预览空白:确认 body() 返回 View,且脚本末尾调用了 appui.run(...)。 按钮没反应:确认 action 传函数本身,而不是函数调用结果。 输入不更新:确认 on_change 的参数名接收新值,并写回 State。 列表详情错乱:确认 ForEach 使用稳定业务 id,不使用会变化的 index。 页面越来越难维护:回到 AppUI UI 模式,先选页面结构。 API 参考 继续查 AppUI API 地图。需要判断该用哪个运行时时,先看 运行时选择。" + }, + { + "id": "miniapp-choose-runtime", + "title": "运行时选择", + "subtitle": "判断该用 appui、widget、ui、scene 还是 console。", + "section": "appui / 开始", + "url": "pages/miniapp-choose-runtime/", + "text": "运行时选择 判断该用 appui、widget、ui、scene 还是 console。 appui 开始 Runtime Decision 运行时 appui scene console web 怎么选 表单 导航 媒体 图表 运行时选择 MiniApp 先选运行时,再写代码。运行时选错会让布局难维护、交互不可用、系统能力接不上。 默认判断 大多数“像 App 一样能点、能输入、能保存状态”的需求,默认选 appui。只有当需求明确是小组件、游戏绘制、Pythonista 兼容 UI 或纯输出脚本时,才换运行时。 决策表 需求 首选运行时 不建议 原生表单、列表、导航、设置页、工具页 `appui` 用 HTML 模拟 iOS 设置页 主屏、锁屏、StandBy 小组件 `widget` 用 AppUI 写桌面 Widget 命令输出、批处理、日志 `console` 为纯输出脚本硬做 UI 绘制、小游戏、精灵、连续触摸 `scene` 用 AppUI 手写游戏循环 Pythonista 兼容的命令式界面脚本 `ui` 和 `appui` 混用通配导入 已有 Web 页面或必须使用 DOM/CSS/JS HTML / `WebView` 为普通工具页引入 Web 结构 相机、定位、通知、存储等系统能力 `appui` + 对应模块 在 Web 页面里绕过原生能力 AppUI 骨架 import appui\n\nstate = appui.State(status=\"Ready\")\n\n\ndef run_action():\n state.status = \"Action completed\"\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"MiniApp\", [\n appui.LabeledContent(\"Runtime\", value=\"appui\"),\n appui.LabeledContent(\"Status\", value=state.status),\n appui.Button(\"Run\", action=run_action)\n .button_style(\"bordered_prominent\"),\n ])\n ]).navigation_title(\"运行时\")\n )\n\n\nappui.run(body, state=state) 预期效果:预览显示一个原生表单页面,点击 Run 后状态变为 Action completed。 不同运行时的边界 widget 用于主屏、锁屏和 StandBy 小组件。它没有普通页面输入和长列表交互,适合信息展示、参数配置、时间线刷新和受控桌面按钮。 console 用于一次性脚本、批处理、日志输出和转换任务。用户只需要看输出时,不要硬做 AppUI。 scene 用于持续绘制、游戏、精灵、物理和触摸循环。它不是设置页、列表页或表单页的替代品。 ui 用于 Pythonista 兼容的命令式界面脚本。新 MiniApp 默认不要从 ui 开始;需要原生 App 风格页面时用 appui。 HTML / WebView 用于已有 Web 页面、必须复用 DOM/CSS/JS 的内容,或需要展示外部网页。普通 iOS 风格工具页不建议用 Web 结构模拟。 选择规则 不确定时先选 appui,因为它覆盖表单、列表、导航、设置和大多数工具页。 用户要求“桌面小组件、主屏卡片、锁屏信息”,选 widget。 用户要求“小游戏、画布、碰撞、持续动画”,选 scene。 只有输出、批处理或转换任务时,选 console。 需要设备能力时,先查 原生能力入口。 失败路径 页面像网页而不是原生 App:回到 appui,使用 NavigationStack、Form、List。 小组件脚本无法输入文字:这是 widget 的边界;需要输入时做 AppUI 页面。 动画不连续:AppUI 适合界面状态动画;游戏循环和持续绘制交给 scene。 命令脚本维护成本变高:如果只是输出文本,保持 console,不要额外做页面。 相关文档 文档 用途 [快速上手](appui-quickstart.md) 跑通第一个 AppUI MiniApp。 [AppUI UI 模式](miniapp-appui-ui-patterns.md) 选择页面结构和常见模式。 [AppUI API 地图](miniapp-appui-api-index.md) 按任务查 API。 [MiniApp 开发排错](miniapp-dev-troubleshooting.md) 排查入口、状态、布局和系统能力。" + }, + { + "id": "appui-guide-thinking", + "title": "appui 思维方式", + "subtitle": "从命令式 UI 切到 State 驱动的声明式 View 树。", + "section": "appui / 指南", + "url": "pages/appui-guide-thinking/", + "text": "appui 思维方式 从命令式 UI 切到 State 驱动的声明式 View 树。 appui 指南 appui concept state 思维方式 声明式 State body View appui 思维方式 appui 的核心规则很简单:body() 返回一棵 View 树,状态变化后重新计算这棵树。不要手动创建、保存、移动原生控件;只描述当前状态下界面应该是什么样。 预期效果 运行示例后会看到计数、登录分支和导航路径随状态变化即时刷新,页面代码始终只描述当前 View 树。 先记住这 4 条 规则 含义 View 是描述 `Text`、`Button`、`VStack` 只是声明界面结构。 State 是事实源 用户输入、选择、列表数据放进 `State` / `ReactiveState`。 body 要可重复执行 `body()` 里不要写网络请求、文件写入、定时器创建等副作用。 修饰符有顺序 `.padding().background()` 和 `.background().padding()` 的视觉结果不同。 从命令式切到声明式 命令式 UI 常见写法是“找到控件,然后改它”。appui 的写法是“改状态,然后让界面重新声明”。 import appui\n\nstate = appui.State(count=0)\n\n\ndef increment():\n state.count += 1\n\n\ndef body():\n return appui.VStack([\n appui.Text(f\"Count: {state.count}\")\n .font(\"largeTitle\")\n .bold(),\n appui.Button(\"Add\", action=increment)\n .button_style(\"bordered_prominent\"),\n ], spacing=16).padding()\n\n\nappui.run(body, state=state) 这段代码里没有“更新 label 文本”的命令。按钮只修改 state.count,下一次重建时 Text 自然显示新值。 View 树 View 可以嵌套,也可以根据状态返回不同分支。 import appui\n\nstate = appui.State(logged_in=False)\n\n\ndef log_in():\n state.logged_in = True\n\n\ndef body():\n if state.logged_in:\n content = appui.Text(\"已登录\").font(\"title2\")\n else:\n content = appui.Button(\"登录\", action=log_in)\n\n return appui.VStack([\n appui.Text(\"欢迎\").font(\"largeTitle\").bold(),\n content,\n ], spacing=20).padding()\n\n\nappui.run(body, state=state) 条件分支应该返回完整的 View。不要在 body() 外保存某个 View 再反复修改它。 State 先行 写界面前先列状态字段: 场景 推荐状态 普通字段、表单、开关 `State` 高频数值,如 slider、拖动、图表数据 `ReactiveState` 不触发重建的对象句柄 `Ref` 列表 / 字典增删改 `ObservableList` / `ObservableDict` 状态写入通常放在按钮、输入框绑定、手势或定时器回调里。多个字段一起改时用 state.batch_update(...),避免中间状态触发多次重建。 修饰符链 修饰符返回新的 View 描述,因此可以连续调用: (\n appui.Text(\"Hello\")\n .font(\"title\")\n .foreground_color(\"white\")\n .padding()\n .background(\"systemBlue\")\n .corner_radius(12)\n) 常用顺序是:内容样式 尺寸/间距 背景/边框 交互/导航。遇到视觉不对,优先检查修饰符顺序。 导航也是数据 导航栈不要当成“打开页面”的命令集合,而是一个路径状态。 import appui\n\npath = appui.NavigationPath()\nstate = appui.State()\n\n\ndef open_settings():\n path.append({\"tag\": \"settings\"})\n\n\ndef go_back():\n path.pop()\n\n\ndef settings_destination(data):\n return appui.VStack([\n appui.Text(\"设置\").font(\"title2\"),\n appui.Button(\"返回\", action=go_back),\n ], spacing=12).padding()\n\n\ndef body():\n return appui.NavigationStack(\n content=appui.VStack([\n appui.Text(\"首页\").navigation_title(\"示例\"),\n appui.Button(\"去设置页\", action=open_settings),\n ], spacing=16).padding(),\n path=path,\n destinations={\n \"settings\": settings_destination,\n },\n )\n\n\nappui.run(body, state=state) destinations 的回调会接收 data 参数;即使暂时不用,也要写成命名函数,避免把页面构造逻辑藏在内联回调里。 常见误区 误区 改法 在 `body()` 里创建定时器或发请求 放到 `effect`、按钮回调或生命周期回调。 把 View 保存到全局变量里再修改 保存状态,不保存 View。 列表原地改了但界面不刷新 使用 `ObservableList`,或重新赋值一个新列表。 参数名不确定 以 API 参考和代码补全为准。 一个 `body()` 写成几百行 拆成普通 Python 函数返回子 View。 下一步 状态管理 了解 State、ReactiveState、Ref 的边界。 布局系统 学会 Stack、Grid、ScrollView 和安全区。 导航与呈现 学会 NavigationStack、sheet、alert、popover。" + }, + { + "id": "appui-guide-interaction", + "title": "交互与回调", + "subtitle": "按钮、输入、on_change、sheet 和行级回调的可靠写法。", + "section": "appui / 指南", + "url": "pages/appui-guide-interaction/", + "text": "交互与回调 按钮、输入、on_change、sheet 和行级回调的可靠写法。 appui 指南 appui callback button interaction 回调 callback lambda Button on_change on_dismiss 交互与回调 触摸、长按、拖拽与捏合等交互通过视图修饰符绑定到 Python 回调。键盘与焦点用 .focused 与 .keyboard_dismiss 控制;上下文菜单用 .context_menu;需要与系统手势共存时用 .simultaneous_gesture 或 .high_priority_gesture。 预期效果 示例会展示点击、长按、焦点、键盘、拖拽和行级动作的反馈路径。 概念速览 能力 API 点击 / 长按 `.on_tap(action)`、`.on_long_press(...)` 拖移 / 捏合 / 旋转 `.on_drag`、`.on_magnification`、`.on_rotation` 组合手势 `.simultaneous_gesture(...)`、`.high_priority_gesture(...)` 菜单 `.context_menu(content=[...])` 焦点与键盘 `.focused(...)`、`.keyboard_dismiss(mode)` 禁用 `.disabled(value)` 气泡菜单 `.popover(is_presented, content, on_dismiss)` 搜索和刷新 `.searchable(...)`、`.refreshable(...)` 列表动作 `.swipe_actions(...)` 手势优先级 普通子视图手势:默认由子控件(如 Button)消费点击。 simultaneous_gesture:与视图自身手势并行,适合在可滚动区域附加轻量检测。 high_priority_gesture:优先于子视图;仅在确有需要时使用,否则容易导致按钮点不动。 context_menu 的 content 是 Button 视图列表,不要用任意字符串代替 Button。 基础示例 点击计数、长按提示、context_menu 弹出操作。示例使用命名回调,预览里每个动作都有状态反馈。 import appui\n\nstate = appui.State(count=0, hint=\"\")\n\n\ndef add_count():\n state.count += 1\n\n\ndef reset_count():\n state.batch_update(count=0, hint=\"已归零\")\n\n\ndef double_count():\n state.batch_update(count=state.count * 2, hint=\"已加倍\")\n\n\ndef show_long_press_hint():\n state.hint = \"长按已触发\"\n\n\ndef body():\n block = (\n appui.RoundedRectangle(corner_radius=16)\n .fill(\"systemBlue\")\n .frame(width=140, height=90)\n .on_tap(add_count)\n .on_long_press(action=show_long_press_hint, min_duration=0.4)\n .context_menu(content=[\n appui.Button(\"归零\", action=reset_count),\n appui.Button(\"加倍\", action=double_count),\n ])\n )\n\n return appui.NavigationStack(\n appui.ScrollView(\n appui.VStack([\n appui.Text(f\"计数: {state.count}\").font(\"title\"),\n appui.Text(state.hint or \"点击或长按蓝色块\")\n .font(\"footnote\")\n .foreground_color(\"secondaryLabel\"),\n block,\n ], spacing=20).padding()\n )\n .keyboard_dismiss(\"interactive\")\n .navigation_title(\"交互示例\")\n )\n\n\nappui.run(body, state=state) 焦点、键盘和 Popover focused(key=..., equals=...) 适合多个输入字段之间切换焦点。keyboard_dismiss(\"interactive\") 通常加在 ScrollView 或列表外层。 import appui\n\nstate = appui.State(\n active_field=\"name\",\n name=\"Ada\",\n city=\"上海\",\n show_pop=False,\n lock_inputs=False,\n gesture_log=\"\",\n)\n\n\ndef set_name(value):\n state.name = value\n\n\ndef set_city(value):\n state.city = value\n\n\ndef focus_name():\n state.active_field = \"name\"\n\n\ndef focus_city():\n state.active_field = \"city\"\n\n\ndef set_lock_inputs(value):\n state.lock_inputs = bool(value)\n\n\ndef open_popover():\n state.show_pop = True\n\n\ndef close_popover():\n state.show_pop = False\n\n\ndef log_long_press():\n state.gesture_log = \"叠加长按\"\n\n\ndef popover_content():\n return appui.VStack([\n appui.Text(\"Popover / Sheet 内容\").font(\"headline\"),\n appui.Button(\"关闭\", action=close_popover),\n ], spacing=12).padding()\n\n\ndef body():\n popover_trigger = (\n appui.Text(\"点我打开 Popover\")\n .padding()\n .background(\"systemGray5\", corner_radius=8)\n .popover(is_presented=state.show_pop, on_dismiss=close_popover, content=popover_content)\n .on_tap(open_popover)\n .simultaneous_gesture(\n gesture=\"long_press\",\n callback=log_long_press,\n min_duration=0.35,\n )\n .disabled(state.lock_inputs)\n )\n\n return appui.NavigationStack(\n appui.ScrollView(\n appui.VStack([\n appui.TextField(\"姓名\", text=state.name, on_change=set_name)\n .text_field_style(\"rounded_border\")\n .focused(key=\"name\", equals=state.active_field)\n .on_submit(focus_city),\n appui.TextField(\"城市\", text=state.city, on_change=set_city)\n .text_field_style(\"rounded_border\")\n .focused(key=\"city\", equals=state.active_field),\n appui.HStack([\n appui.Button(\"编辑姓名\", action=focus_name).button_style(\"bordered\"),\n appui.Button(\"编辑城市\", action=focus_city).button_style(\"bordered\"),\n ], spacing=12),\n appui.Toggle(\"锁定输入\", is_on=state.lock_inputs, on_change=set_lock_inputs),\n appui.Text(state.gesture_log or \"在灰色区域长按可触发 simultaneous_gesture\")\n .font(\"caption\")\n .foreground_color(\"secondaryLabel\"),\n popover_trigger,\n ], spacing=16).padding()\n )\n .keyboard_dismiss(\"interactive\")\n .navigation_title(\"焦点与手势\")\n )\n\n\nappui.run(body, state=state) 拖拽、缩放和旋转 on_drag、on_magnification、on_rotation 的回调由运行时传入测量值或字典。实际项目里先把返回值显示出来,再按需要解析字段。 def drag_changed(value):\n state.gesture_log = str(value)\n\n\ndef drag_ended(value):\n state.gesture_log = f\"ended: {value}\"\n\n\nview.on_drag(on_changed=drag_changed, on_ended=drag_ended) 如果子控件本身需要点击,谨慎使用 high_priority_gesture,它会优先于子视图手势。 行级动作和刷新 列表行的交互要绑定稳定 id,不要依赖过滤后的下标。下拉刷新使用 .refreshable(action=...)。 import appui\n\nstate = appui.State(\n query=\"\",\n selected=\"None\",\n refresh_count=0,\n rows=[\n {\"id\": \"api\", \"title\": \"API reference\", \"done\": False},\n {\"id\": \"ui\", \"title\": \"Preview UI\", \"done\": True},\n {\"id\": \"docs\", \"title\": \"Docs guide\", \"done\": False},\n ],\n)\n\n\ndef row_key(row):\n return row[\"id\"]\n\n\ndef set_query(value):\n state.query = value\n\n\ndef filtered_rows():\n keyword = state.query.strip().lower()\n if not keyword:\n return state.rows\n return [row for row in state.rows if keyword in row[\"title\"].lower()]\n\n\ndef refresh_rows():\n state.refresh_count += 1\n\n\ndef select_row(row_id):\n for row in state.rows:\n if row[\"id\"] == row_id:\n state.selected = row[\"title\"]\n break\n\n\ndef toggle_row(row_id):\n state.rows = [\n {**row, \"done\": not row[\"done\"]} if row[\"id\"] == row_id else row\n for row in state.rows\n ]\n\n\ndef row_view(row):\n def select_current():\n select_row(row[\"id\"])\n\n def toggle_current():\n toggle_row(row[\"id\"])\n\n icon = \"checkmark.circle.fill\" if row[\"done\"] else \"circle\"\n return (\n appui.Button(\n action=select_current,\n content=appui.HStack([\n appui.Label(row[\"title\"], system_image=icon),\n appui.Spacer(),\n appui.Text(\"Open\").foreground_color(\"secondaryLabel\"),\n ]),\n )\n .button_style(\"plain\")\n .swipe_actions(actions=[\n appui.Button(\"Toggle\", action=toggle_current),\n ])\n )\n\n\ndef body():\n return appui.NavigationStack(\n appui.List([\n appui.Section(\"Rows\", [\n appui.ForEach(filtered_rows(), row_builder=row_view, key=row_key)\n ]),\n appui.Section(\"State\", [\n appui.LabeledContent(\"Selected\", value=state.selected),\n appui.LabeledContent(\"Refresh count\", value=str(state.refresh_count)),\n ]),\n ])\n .searchable(text=state.query, on_change=set_query)\n .refreshable(action=refresh_rows)\n .navigation_title(\"Callbacks\")\n )\n\n\nappui.run(body, state=state) 常见误区 on_long_press 与 context_menu 都使用长按语义;同时启用时要实测是否冲突。 .focused(key=..., equals=...) 的 equals 应绑定到 State 中的当前字段名。 simultaneous_gesture 的 gesture 常用 'tap'、'long_press'、'magnification'。 high_priority_gesture 过度使用会导致子按钮点击失效。 keyboard_dismiss 通常加在 ScrollView 或列表外层。 行级按钮要操作稳定 id,不要操作过滤后的下标。 延伸阅读 修饰符 API:查看手势、焦点和键盘相关修饰符签名。 呈现 API:查看 sheet、alert、refresh、swipe action。" + }, + { + "id": "appui-guide-lists", + "title": "列表与表单", + "subtitle": "List、ForEach、Form、Section、搜索、刷新和滑动操作。", + "section": "appui / 指南", + "url": "pages/appui-guide-lists/", + "text": "列表与表单 List、ForEach、Form、Section、搜索、刷新和滑动操作。 appui 指南 appui list form List ForEach Form Section searchable refreshable 列表与表单 List 与 Form 是展示结构化数据的首选:List 适合可选中、滑动操作的行;Form 适合设置页分组。ForEach 把 Python 序列映射为多行视图;Section 提供分组标题与脚注。修饰符 .list_style、.swipe_actions、.refreshable、.searchable 直接链在列表(或承载搜索的外层容器)上;若要显式包裹整行,也可使用 SwipeActions(content=..., leading=..., trailing=...)。当前框架 不提供 List / ForEach 原生 on_delete 契约,删除动作请放在行级滑动按钮中。 预期效果 示例会展示原生 List 的搜索、刷新、空状态、行操作和长列表承载方式。 概念速览 能力 API 列表 `List(content)` 数据驱动行 `ForEach(data, row_builder, key=...)` 表单 `Form(content)`、`Section(\"标题\", content)`、`Section(content, header=...)` 样式 / 交互 `.list_style(style)`、`.swipe_actions`、`.refreshable(action)`、`.searchable(text, on_change)` 行外观 `.list_row_background(color)`、`.list_row_separator(visibility)` 与 AppUI 的对应关系 List / Form 都是 纵向 容器;Form 默认呈现分组设置样式,但仍可用 Section 组织子控件。 ForEach 不是布局,它把 Python 可迭代对象 展开 为多个子视图,因此必须保证 row_builder(item) 每次返回一棵合法的 View 子树。 .searchable 是 修饰符 :搜索框属于页面的一部分,过滤逻辑仍由你在 body() 里用 State 完成(见进阶示例)。 State 对嵌套 list / dict 有深度观察;为减少多余刷新,批量修改请用 batch_update。 list_style 常用取值 automatic、inset、inset_grouped、grouped、plain、sidebar。设置页多用 inset_grouped,侧边栏列表可用 sidebar。 基础示例 静态 Form + Section,以及一个简单的 List。 import appui\n\nstate = appui.State(notify=True, note=\"\")\n\n\ndef set_note(value):\n state.note = value\n\n\ndef set_notify(value):\n state.notify = value is True or value == \"True\" or value == \"true\"\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form(\n [\n appui.Section(\n \"账户\",\n [\n appui.TextField(\n \"备注\",\n text=state.note,\n on_change=set_note,\n ).text_field_style(\"rounded_border\"),\n ],\n ),\n appui.Section(\n \"通知\",\n [\n appui.Toggle(\n label=\"接收提醒\",\n is_on=state.notify,\n on_change=set_notify,\n ),\n ],\n footer=\"关闭后仍保留本地数据。\",\n ),\n ]\n )\n .navigation_title(\"表单示例\")\n )\n\n\nappui.run(body, state=state) 进阶示例 ForEach 动态行、.swipe_actions 删除、.searchable 过滤、.refreshable 下拉刷新、.list_style('inset_grouped'),以及 .list_row_background / .list_row_separator。 下面示例刻意把 过滤逻辑 放在 body() 顶部函数 filtered_items() 中,而不是藏在内联回调深处,便于单元测试与复用;refreshable 仅更新提示字符串,避免阻塞 UI 线程。 import appui\n\nstate = appui.State(\n items=[\"买牛奶\", \"写 appui 教程\", \"跑步三公里\"],\n query=\"\",\n refreshing_hint=\"\",\n)\n\n\ndef delete_item(name):\n remaining = [x for x in state.items if x != name]\n state.items = remaining\n\n\ndef set_query(value):\n state.query = value\n\n\ndef refresh_list():\n state.refreshing_hint = \"已触发刷新回调(可在此拉取网络数据)\"\n\n\ndef item_key(item):\n return item\n\n\ndef row_view(item):\n def delete_current():\n delete_item(item)\n\n return (\n appui.Text(item)\n .list_row_background(\"secondarySystemBackground\")\n .list_row_separator(\"hidden\")\n .swipe_actions(\n edge=\"trailing\",\n content=[\n appui.Button(\n \"删除\",\n role=\"destructive\",\n action=delete_current,\n )\n ],\n )\n )\n\n\ndef filtered_items():\n q = state.query.strip().lower()\n if not q:\n return list(state.items)\n return [x for x in state.items if q in x.lower()]\n\n\ndef body():\n rows = filtered_items()\n return appui.NavigationStack(\n appui.VStack(\n [\n appui.Text(\"在搜索框输入以过滤\").font(\"caption\").foreground_color(\"secondaryLabel\"),\n appui.List(\n [\n appui.ForEach(\n rows,\n row_builder=row_view,\n key=item_key,\n )\n ]\n )\n .searchable(\n text=state.query,\n on_change=set_query,\n )\n .refreshable(action=refresh_list)\n .list_style(\"inset_grouped\"),\n appui.Text(state.refreshing_hint or \"下拉列表以触发 refreshable\")\n .font(\"footnote\")\n .foreground_color(\"systemOrange\"),\n ],\n spacing=8,\n )\n .padding()\n .navigation_title(\"列表与搜索\")\n )\n\n\nappui.run(body, state=state) 常见误区 ForEach 必须提供稳定 key :字符串列表可用命名函数返回字符串本身;对象列表应使用唯一 id,否则删除 / 重排时行状态会错乱。 .searchable 修饰的是「承载列表的视图」 :通常与 List 链在一起;搜索文本要存入 State 并在 body() 里用同一字段过滤数据。 swipe_actions 挂在「行视图」上 :如示例中对 Text(item) 链式调用;挂在 List 整表上不会给每行单独滑动按钮。 原地修改 state.items 列表 :state.items.remove(x) 若列表是普通 list 可能不会触发深度观察;推荐 state.items = new_list 或 batch_update(与 State 刷新规则一致)。 Section 的参数顺序 :推荐 Section(\"标题\", [views], footer=\"...\");需要关键字时再用 Section(content=[views], header=\"标题\")。嵌套在 Form 中时务必传入 视图列表 。 refreshable 的回调尽量短 :下拉刷新结束后系统才会收起转圈;若在回调里做长时间阻塞,界面会看起来「卡住」。可在回调里只更新标志位,把重活放到线程(注意线程安全与 State 更新方式)。 list_row_background 颜色与深色模式 :硬编码 F2F2F7 在深色模式下可能对比不佳;可改用语义色如 secondarySystemBackground。 同一行不要同时依赖横向 on_drag 与滑删 :系统滑动按钮与自定义水平拖拽共用手势通道;建议拆成不同交互区域或移除该行的滑动按钮。 练习题 把 List 改为 List + NavigationLink,每行进入只读详情页。 在 swipe_actions 的 edge='leading' 侧增加「完成」按钮,并用 .tint('green') 区分颜色。 用 appui.LabeledContent 在 Form 最后一节展示当前过滤结果条数。 附录:仅构建 List 树(不调用 run) import appui\n\ndata = [\"a\", \"b\"]\n\n\ndef text_row(value):\n return appui.Text(value)\n\n\ndef text_key(value):\n return value\n\n\ntree = appui.List(\n [\n appui.ForEach(\n data,\n row_builder=text_row,\n key=text_key,\n )\n ]\n)\nprint(tree) 延伸阅读(仓库内) 完整页面可以组合 ForEach、swipe_actions、searchable 与 list_style。 可选:LazyVStack 承载超长列表 当行数可能上千时,可将 ForEach 放进 ScrollView + LazyVStack,以减轻一次性构建整表的开销。入口写法见 appui 概览。 import appui\n\nstate = appui.State(rows=list(range(60)))\n\n\ndef lazy_row(value):\n return appui.Text(f\"第 {value} 行\").padding(edges=\"horizontal\")\n\n\ndef lazy_key(value):\n return value\n\n\ndef body():\n return appui.NavigationStack(\n appui.ScrollView(\n appui.LazyVStack(\n [\n appui.ForEach(\n state.rows,\n row_builder=lazy_row,\n key=lazy_key,\n )\n ],\n spacing=6,\n )\n )\n .navigation_title(\"懒加载列表示例\")\n )\n\n\nappui.run(body, state=state) 排错清单(自查) 现象 可能原因 处理方向 搜索框无反应 `on_change` 未写回 `State` 确保 `setattr(state, 'query', v)` 滑动无删除按钮 `swipe_actions` 挂在 `List` 上 移到行视图 误以为支持原生删除 直接寻找 `on_delete` / `.onDelete` 改用行级 `.swipe_actions(...)` 或 `SwipeActions(...)` 同一行横向拖拽失效 该行同时配置了 `on_drag` 与 `swipe_actions` 保留其一,或把拖拽手势移到子区域 下拉不触发 `refreshable` 未链到可滚动父级 与 `List` / `ScrollView` 链式连接 过滤结果不刷新 `filtered` 未依赖 `state` 把过滤函数放在 `body()` 内读取最新 `state`" + }, + { + "id": "appui-guide-animation", + "title": "动画", + "subtitle": "animate、animation、transition、matched geometry 和 phase animator。", + "section": "appui / 指南", + "url": "pages/appui-guide-animation/", + "text": "动画 animate、animation、transition、matched geometry 和 phase animator。 appui 指南 appui animation animate animation transition phase_animator matched_geometry_effect 动画 appui 提供两类动画能力: 隐式动画 (.animation(type, value) 在依赖变化时插值)与 显式动画 (appui.animate(action, type) 包裹一次状态更新)。此外还有 过渡 (.transition)、 变换 (.scale_effect、.rotation_effect、.rotation_3d_effect)、 共享几何 (.matched_geometry_effect)、 内容过渡 (.content_transition)与 相位动画 (.phase_animator)。 从 AppUI Presentation Engine 起,show_ 、 _presented 等呈现状态字段变化会自动触发 spring 动画;sheet / alert / cover 的进出通常不必再手写 appui.animate()。 第四档 PresentationCoordinator 会在注册表有效时跳过 body() 与整树 JSON,直接更新原生 presentation binding;可用 appui.presentation_present / appui.presentation_dismiss_all 显式控制。完整规范见 Presentation Engine Spec。 预期效果 示例会展示隐式动画、显式动画、转场和共享几何效果如何响应状态变化。 概念速览 能力 API 隐式动画 `.animation(type='spring', value=...)`,`type` 可为 `default`、`linear`、`easeIn`、`easeOut`、`easeInOut`、`spring`、`interpolatingSpring` 显式动画 `appui.animate(action, type)` 出现 / 消失 `.transition(type)`:`opacity`、`slide`、`scale`、`move`、`push`、`identity` 2D / 3D 变换 `.scale_effect`、`.rotation_effect`、`.rotation_3d_effect` 共享元素 `.matched_geometry_effect(ns_id, namespace, is_source)` 文本或数字切换 `.content_transition(type)` 循环呼吸效果 `.phase_animator(phases)` 隐式 vs 显式:怎么选 隐式 :适合「依赖项变化 → 自动插值」的 UI,如计数、开关、列表插入;把 .animation(..., value=state.x) 放在 依赖该字段的子树 上。 显式 :适合一次事务内多处 State 同步更新(例如先收起面板再改标题)。把更新逻辑放进命名函数,再用 appui.animate(update_state, \"spring\") 保证同一帧动画曲线一致。 animation 的 type 取值 default、linear、easeIn、easeOut、easeInOut、spring、interpolatingSpring。未列出的字符串请避免使用,以免系统忽略。 基础示例 用 appui.animate 更新计数,并对数字附加 .animation('spring', value=...)。 import appui\n\nstate = appui.State(score=0)\n\n\ndef decrease_value():\n state.score -= 1\n\n\ndef increase_value():\n state.score += 1\n\n\ndef decrease_score():\n appui.animate(decrease_value, \"easeOut\")\n\n\ndef increase_score():\n appui.animate(increase_value, \"spring\")\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack(\n [\n appui.Text(f\"得分: {state.score}\")\n .font(\"largeTitle\")\n .bold()\n .animation(\"spring\", value=state.score),\n appui.HStack(\n [\n appui.Button(\n \"−\",\n action=decrease_score,\n ).button_style(\"bordered\"),\n appui.Button(\n \"+\",\n action=increase_score,\n ).button_style(\"bordered_prominent\"),\n ],\n spacing=24,\n ),\n ],\n spacing=24,\n )\n .padding()\n .navigation_title(\"显式动画\")\n )\n\n\nappui.run(body, state=state) 进阶示例 transition + scale_effect + rotation_effect + rotation_3d_effect + matched_geometry_effect + content_transition + phase_animator。 import appui\n\nstate = appui.State(show_panel=False, label=\"轻点切换\")\n\n\ndef toggle_panel():\n state.batch_update(\n show_panel=not state.show_panel,\n label=\"面板已打开\" if not state.show_panel else \"面板已关闭\",\n )\n\n\ndef body():\n panel = (\n appui.RoundedRectangle(corner_radius=18)\n .fill(\"systemTeal\")\n .frame(width=120, height=120)\n .scale_effect(0.92)\n .rotation_effect(6)\n .matched_geometry_effect(ns_id=\"card\", namespace=\"demo\", is_source=True)\n .transition(\"scale\")\n )\n placeholder = (\n appui.Circle()\n .fill(\"systemGray4\")\n .frame(width=120, height=120)\n .matched_geometry_effect(ns_id=\"card\", namespace=\"demo\", is_source=False)\n .transition(\"opacity\")\n )\n return appui.NavigationStack(\n appui.ScrollView(\n [\n appui.VStack(\n [\n appui.Text(state.label)\n .font(\"title2\")\n .content_transition(\"interpolate\"),\n appui.Button(\n action=toggle_panel,\n content=appui.Text(\"切换面板\").padding(),\n ).button_style(\"bordered_prominent\"),\n panel if state.show_panel else placeholder,\n appui.Text(\"3D 倾斜\")\n .font(\"footnote\")\n .foreground_color(\"secondaryLabel\"),\n appui.Rectangle()\n .fill(\"systemOrange\")\n .frame(width=100, height=44)\n .rotation_3d_effect(18, x=0, y=1, z=0),\n appui.Text(\"相位动画\")\n .font(\"footnote\")\n .foreground_color(\"secondaryLabel\"),\n appui.Capsule()\n .fill(\"systemPink\")\n .frame(width=160, height=36)\n .phase_animator([0, 0.4, 1.0, 0.4, 0]),\n ],\n spacing=20,\n )\n .padding()\n ]\n )\n .navigation_title(\"动画组合\")\n )\n\n\nappui.run(body, state=state) 常见误区 value 与隐式动画 :.animation(..., value=state.x) 仅在 value 引用发生变化时触发动画;若传入常量则不会按预期刷新。 appui.animate 的第一个参数 :必须是 无参可调用对象 (推荐命名函数里批量改 State),不要写成 appui.animate(state.count + 1)。 matched_geometry_effect :成对视图需 相同的 namespace 与 ns_id ,且通常配合显式布局;在复杂导航栈里要注意 is_source 切换时机。 phase_animator :依赖 iOS 17+ 能力,在旧系统上可能降级或无动画。 transition 与条件渲染 :过渡只在视图插入/移除时生效;若仅修改子视图文字,应优先 .content_transition。 interpolatingSpring 与 spring :二者曲线不同;若出现振荡或回弹过大,优先改回 default 或 easeInOut 验证是否由曲线本身引起。 rotation_3d_effect 与透视 :极端角度可能导致子视图不可读;建议限制在 ±45° 内做 UI 提示级动效。 练习题 用 ZStack 叠放两个 Text,通过 state 切换 id(...),并分别设置 .transition('slide') 与 .transition('move')。 给按钮增加 .sensory_feedback(style='success', trigger=str(state.score)),观察与 animate 联动的手感。 对照 入口函数 API,说明为何在回调里应优先使用 batch_update。 延伸阅读(仓库内) 修饰符 API:查看 animation、transition、phase_animator 等签名。 附录:transition 与 opacity 最小片段 import appui\n\nflag = True\n\n\ndef toggle():\n global flag\n flag = not flag\n\n\nroot = appui.Group(\n [\n appui.Text(\"A\").opacity(1).transition(\"opacity\"),\n appui.Text(\"B\").transition(\"push\"),\n ]\n)\nassert root is not None 调试建议 若动画「时有时无」,先在浏览器式调试思路下 二分法 排查:去掉 matched_geometry_effect 与 phase_animator 等高级修饰符,仅保留 .animation(..., value=...),确认基础路径正常后再逐项加回。 DrawingContext 与动画的关系 本章专注视图修饰符与 animate();若需要在画布上做连续帧动画,常见做法是:用 State 保存相位或采样数组,在 Timer 或后台线程里更新 State,由 Canvas(..., commands=...) 或 DrawingContext 重建命令列表。此类模式属于「数据驱动重绘」,而不是 transition 插值。 与 ReactiveState / 实时属性通道 高频控件(如 Slider)在 ReactiveState 场景下可能走实时属性通道;此时 .animation(..., value=...) 仍绑定在普通 Text 上即可。避免同一字段既高频写入,又驱动整棵页面频繁重建。若动画曲线和预期不一致,先拆分「需要动画的只读展示」与「高频写控件」,再查看 状态 API。" + }, + { + "id": "appui-guide-charts", + "title": "图表与绘图", + "subtitle": "Chart、Canvas、DrawingContext 和 Path 的使用模式。", + "section": "appui / 指南", + "url": "pages/appui-guide-charts/", + "text": "图表与绘图 Chart、Canvas、DrawingContext 和 Path 的使用模式。 appui 指南 appui chart canvas Chart Canvas DrawingContext Path 图表 画布 图表与画布 Chart 提供系统图表视图,用字典列表作为数据源,指定 x / y 键与 type。Canvas 用绘制命令渲染自定义图形,可用 DrawingContext 链式生成命令,或直接传入 commands 字典列表。Path 则用于矢量路径(move / line / close 等)。 预期效果 示例会展示柱状/折线图、Canvas 绘制和 Path 图形在页面中的结果。 概念速览 能力 API 统计图 `Chart(data, x, y, type='bar', color=None, series=None)` 画布绘制 `DrawingContext().fill_rect(...)...` 与 `Canvas(width, height, context=ctx)` 原始命令 `Canvas(width, height, commands=[{'op': 'fill_rect', ...}, ...])` 矢量路径 `Path(commands=[...], fill=..., stroke=..., line_width=...)` 五种 Chart 类型的直观含义 bar :离散类别对比,适合少量枚举横轴(如月份、地区)。 line :连续或有序横轴上的趋势;与 series 组合可绘制多条线。 area :与折线类似,但填充下方区域,强调「累积感」或占比。 point :强调散点分布,适合样本量不大时的离群观察。 rule :水平或垂直参考线,适合阈值、平均线等「标线」场景(数据需自行构造为常数 y 或 x)。 Canvas 两条路径 声明式 :DrawingContext 链式调用,最后交给 Canvas(..., context=ctx);可读性高,适合中等数量图元。 命令列表 :直接维护 commands 列表,适合波形、频谱等高频追加场景。 基础示例 同一组销售数据,切换 Chart 的 type。 import appui\n\nstate = appui.State(chart_kind=\"bar\")\n\nSALES = [\n {\"m\": \"1月\", \"v\": 12},\n {\"m\": \"2月\", \"v\": 19},\n {\"m\": \"3月\", \"v\": 15},\n {\"m\": \"4月\", \"v\": 22},\n]\n\n\ndef set_chart_kind(value):\n state.chart_kind = value\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack(\n [\n appui.Picker(\n label=\"图表类型\",\n selection=state.chart_kind,\n options=[\"bar\", \"line\", \"area\", \"point\", \"rule\"],\n on_change=set_chart_kind,\n ).picker_style(\"segmented\"),\n appui.Chart(\n data=SALES,\n x=\"m\",\n y=\"v\",\n type=state.chart_kind,\n color=\"systemBlue\",\n ).frame(height=220),\n ],\n spacing=16,\n )\n .padding()\n .navigation_title(\"Chart 类型\")\n )\n\n\nappui.run(body, state=state) 进阶示例 多系列折线(series 键)、完整 DrawingContext 演示、Canvas 原始 commands、Path 三角形。 import appui\n\nstate = appui.State()\n\nMULTI = [\n {\"day\": \"周一\", \"amount\": 3, \"team\": \"A\"},\n {\"day\": \"周二\", \"amount\": 5, \"team\": \"A\"},\n {\"day\": \"周一\", \"amount\": 4, \"team\": \"B\"},\n {\"day\": \"周二\", \"amount\": 2, \"team\": \"B\"},\n]\n\n\ndef body():\n ctx = appui.DrawingContext()\n ctx.fill_rect(0, 0, 300, 120, color=\"secondarySystemBackground\")\n ctx.stroke_rect(8, 8, 100, 60, color=\"label\", line_width=1)\n ctx.fill_circle(150, 40, 22, color=\"systemRed\")\n ctx.stroke_circle(150, 40, 28, color=\"systemOrange\", line_width=2)\n ctx.fill_ellipse(190, 15, 50, 50, color=\"systemPurple\")\n ctx.stroke_ellipse(190, 15, 50, 50, color=\"systemYellow\", line_width=2)\n ctx.line(10, 100, 290, 100, color=\"separator\", line_width=1)\n ctx.fill_text(\"DrawingContext\", 12, 88, color=\"label\", font_size=14)\n ctx.fill_path([(200, 75), (260, 110), (170, 110)], color=\"systemGreen\", close=True)\n ctx.stroke_path([(20, 70), (60, 95), (40, 50)], color=\"systemBlue\", line_width=2, close=True)\n ctx.arc(80, 95, 18, start_angle=0, end_angle=270, color=\"systemTeal\", line_width=2, fill=False)\n ctx.rounded_rect(\n 230, 70, 60, 40,\n corner_radius=10,\n color=\"systemIndigo\",\n line_width=2,\n fill=False,\n )\n ctx.gradient_rect(120, 72, 90, 36, colors=[\"systemPink\", \"systemMint\"], vertical=True)\n\n wave_commands = [\n {\"op\": \"fill_rect\", \"x\": 0, \"y\": 0, \"w\": 300, \"h\": 80, \"c\": \"systemGray6\"},\n ]\n for i in range(40):\n wave_commands.append(\n {\n \"op\": \"fill_rect\",\n \"x\": i * 7.5,\n \"y\": 40 + (i % 5) * 6,\n \"w\": 5,\n \"h\": 12 + (i % 7) * 2,\n \"c\": \"systemCyan\",\n }\n )\n\n triangle = appui.Path(\n commands=[\n {\"move\": [40, 20]},\n {\"line\": [80, 20]},\n {\"line\": [60, 60]},\n {\"close\": True},\n ],\n fill=\"systemYellow\",\n stroke=\"systemOrange\",\n line_width=2,\n )\n\n return appui.NavigationStack(\n appui.ScrollView(\n [\n appui.VStack(\n [\n appui.Text(\"多系列折线 (series)\").font(\"headline\"),\n appui.Chart(\n data=MULTI,\n x=\"day\",\n y=\"amount\",\n type=\"line\",\n series=\"team\",\n color=\"systemBlue\",\n ).frame(height=200),\n appui.Text(\"DrawingContext → Canvas\").font(\"headline\"),\n appui.Canvas(width=300, height=120, context=ctx).corner_radius(12),\n appui.Text(\"commands 列表 → Canvas\").font(\"headline\"),\n appui.Canvas(width=300, height=80, commands=wave_commands).corner_radius(8),\n appui.Text(\"Path 矢量\").font(\"headline\"),\n triangle.frame(width=120, height=80),\n ],\n spacing=20,\n )\n .padding()\n ]\n )\n .navigation_title(\"图表与画布\")\n )\n\n\nappui.run(body, state=state) 常见误区 Chart 使用参数名 type= :不是 mark;x / y 为数据字典中的键名字符串。 Canvas 命令字典的字段 :手写 commands 时使用 'op', 'x', 'y', 'w', 'h', 'c' 等键。 Path 命令格式 :appui 的 Path 使用 {'move': [x, y]}、{'line': [x, y]}、{'close': True} 等形式,与 DrawingContext 的 op 字典不同。 性能 :高频更新(如波形)可优先 Canvas + commands 列表局部重建;Chart 更适合中等规模静态或慢变数据。 系统版本 :Chart 依赖 iOS 16+,Canvas 依赖 iOS 15+;在过低版本设备上可能空白或降级。 series 与颜色 :多系列时 color 仍可作为默认着色提示,具体调色策略由系统图表渲染决定;若发现图例颜色异常,应先检查数据中 series 字段是否稳定存在。 练习题 将 Chart 的 type 改为 'rule',用水平参考线标注「目标值」常量(在数据中复制同一 y)。 用 DrawingContext 绘制一条正弦折线(多点 line 或 stroke_path)。 把 Path 的 commands 改为带 curve / arc 的复杂轮廓(若当前环境支持对应序列化键)。 附录:最小 Canvas 与 Chart import appui\n\nc = appui.Canvas(\n width=120,\n height=40,\n commands=[{\"op\": \"fill_rect\", \"x\": 0, \"y\": 0, \"w\": 120, \"h\": 40, \"c\": \"systemBlue\"}],\n)\ng = appui.Chart(\n data=[{\"x\": 1, \"y\": 2}],\n x=\"x\",\n y=\"y\",\n type=\"point\",\n color=\"systemRed\",\n)\nprint(c, g) 数据字典约定(给 Chart) Chart 期望 data 为 list[dict] ,且每个字典至少包含你在 x=、y= 上指定的键;若使用 series=,则每个字典还应包含该键以便分组。键名建议使用 ASCII 标识符 (如 month、sales),可减少跨端序列化问题。 DrawingContext 生成的 op 一览 下列为 DrawingContext 常用命令,便于手写 commands 数组时对照: 链式方法 `op` 字段 说明 `fill_rect` `fill_rect` 填充轴对齐矩形 `stroke_rect` `stroke_rect` 描边矩形 `fill_circle` `fill_circle` 填充圆 `stroke_circle` `stroke_circle` 描边圆 `fill_ellipse` `fill_ellipse` 填充椭圆外接矩形 `stroke_ellipse` `stroke_ellipse` 描边椭圆 `line` `line` 线段 `fill_text` `fill_text` 文本 `fill_path` `fill_path` 多边形填充 `stroke_path` `stroke_path` 折线描边 `arc` `arc` 弧或扇形 `rounded_rect` `rounded_rect` 圆角矩形 `gradient_rect` `gradient_rect` 线性渐变矩形" + }, + { + "id": "appui-guide-media", + "title": "媒体与系统集成", + "subtitle": "图片、相册、相机、地图、视频和 WebView 的页面模式。", + "section": "appui / 指南", + "url": "pages/appui-guide-media/", + "text": "媒体与系统集成 图片、相册、相机、地图、视频和 WebView 的页面模式。 appui 指南 appui media photo camera PhotoPicker CameraPicker AsyncImage MapView VideoPlayer WebView 媒体 appui 中与图片、相册、相机、文件导入、视频、网页与地图相关的视图。下列示例均可在应用内 AppUI 预览环境运行。 预期效果 示例会展示图片、网络图、相册、文件、相机、视频、网页和地图控件如何嵌入 AppUI 页面。 设计原则 资源图 用 Image(name=...); SF Symbols 用 Image(system_name=...)。 网络图 用 AsyncImage,占位与失败视图通过子视图传入。 相册 / 相机 返回的是设备上的 文件路径字符串 (由系统写入临时目录后回调)。 Files App / 文档提供方 用 FileImporter,默认复制到 App 可访问位置后返回路径列表。 WebView 只能二选一:url 或 html。 MapView 的 markers 为字典列表,键包括 latitude、longitude、title。 Image:本地资源与 SF Symbol Image 支持链式修饰:resizable()、aspect_ratio(ratio, content_mode)、symbol_rendering_mode(mode)、image_scale(scale)。 import appui\n\nstate = appui.State(kind=\"SF Symbol\")\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.Text(state.kind).font(\"headline\"),\n appui.Image(system_name=\"star.fill\")\n .resizable()\n .aspect_ratio(content_mode=\"fit\")\n .symbol_rendering_mode(\"palette\")\n .image_scale(\"large\")\n .frame(width=56, height=56)\n .foreground_color(\"systemYellow\"),\n appui.Label(\"设置\", system_image=\"gear\"),\n ], spacing=16).padding()\n .navigation_title(\"Image\")\n )\n\n\nappui.run(body, state=state, presentation=\"sheet\") content_mode 为 'fit' 或 'fill'。symbol_rendering_mode 常用:'hierarchical'、'palette'、'multicolor'(未识别时回退为单色)。 image_scale 为 'small'、'medium'(默认)、'large'。 AsyncImage:网络图片 参数:url、placeholder、error_view、content_mode、on_success、on_failure。占位与错误视图为 子节点 (前两个子视图依次为占位、错误)。 import appui\n\nstate = appui.State(status=\"等待加载\")\n\n\ndef image_loaded():\n state.status = \"图片已加载\"\n\n\ndef image_failed():\n state.status = \"图片加载失败\"\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.AsyncImage(\n url=\"https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png\",\n placeholder=appui.ProgressView(label=\"Loading\"),\n error_view=appui.Image(system_name=\"exclamationmark.triangle\"),\n content_mode=\"fit\",\n on_success=image_loaded,\n on_failure=image_failed,\n ).frame(height=120),\n appui.LabeledContent(\"状态\", value=state.status),\n ], spacing=16).padding()\n .navigation_title(\"AsyncImage\")\n )\n\n\nappui.run(body, state=state, presentation=\"sheet\") PhotoPicker:相册 selection_limit:1 表示单选;0 表示不限制(以系统行为为准)。filter:'images'、'videos'、'all'。on_picked 收到 路径列表 。 import appui\n\nstate = appui.State(paths=[])\n\n\ndef on_picked(paths):\n state.paths = paths or []\n\n\ndef body():\n return appui.VStack([\n appui.Text(\"已选: \" + (\" | \".join(state.paths) if state.paths else \"无\")).font(\"caption\"),\n appui.PhotoPicker(\n selection_limit=1,\n filter=\"images\",\n on_picked=on_picked,\n label=appui.Label(\"选择照片\", system_image=\"photo.on.rectangle\"),\n ),\n ], spacing=12).padding()\n\nappui.run(body, state=state, presentation=\"sheet\") FileImporter:文件导入 allowed_types 可传类型名、扩展名或 MIME 类型,例如 'text'、'pdf'、'csv'、'image/png'。allows_multiple=True 允许多选。copy=True 是默认值,表示先复制进 App 可访问位置,再把路径列表传给 on_picked。 import appui\n\nstate = appui.State(files=[])\n\n\ndef on_files(paths):\n state.files = paths or []\n\n\ndef body():\n rows = [\n appui.Text(path).font(\"caption\").line_limit(1)\n for path in state.files\n ]\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"导入\", [\n appui.FileImporter(\n allowed_types=[\"text\", \"pdf\", \"csv\"],\n allows_multiple=True,\n on_picked=on_files,\n label=appui.Label(\"选择文件\", system_image=\"doc.badge.plus\"),\n ),\n ]),\n appui.Section(\"文件\", rows or [\n appui.ContentUnavailableView(\"暂无文件\", system_image=\"doc\", description=\"从系统文件选择器导入\")\n ]),\n ]).navigation_title(\"文件导入\")\n )\n\n\nappui.run(body, state=state, presentation=\"sheet\") 文件选择由系统界面完成;用户取消时通常不会触发有效路径。需要读取文件内容时,在回调里保存路径,再在按钮动作、刷新函数或后台流程中读取,避免在 body() 里同步读大文件。 CameraPicker:相机 source:'camera' 或 'front'。media_type:'photo' 或 'video'。on_captured 收到 单个路径字符串 。 import appui\n\nstate = appui.State(last=\"\")\n\n\ndef on_captured(path):\n state.last = path or \"\"\n\n\ndef body():\n return appui.VStack([\n appui.Text(state.last or \"尚未拍摄\").font(\"caption\"),\n appui.CameraPicker(\n source=\"camera\",\n media_type=\"photo\",\n on_captured=on_captured,\n label=appui.Label(\"拍照\", system_image=\"camera.fill\"),\n ),\n ], spacing=12).padding()\n\nappui.run(body, state=state, presentation=\"sheet\") VideoPlayer:AVKit url 可为远程 HTTPS 或应用内可访问的本地文件名。autoplay、loop、show_controls 控制播放行为。 只展示视频时直接用 VideoPlayer(url=...)。如果页面需要播放/暂停/seek、保存进度、倍速、PiP 状态或切集,使用 PlayerController 并传给 VideoPlayer(player=player);AppUI 新页面不要再 import avplayer 控制同一块内嵌视频。 import appui\n\nstate = appui.State(status=\"可播放\")\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.VideoPlayer(\n url=\"https://media.w3.org/2010/05/sintel/trailer.mp4\",\n autoplay=False,\n loop=False,\n show_controls=True,\n ).frame(height=220),\n appui.LabeledContent(\"状态\", value=state.status),\n ], spacing=16).padding()\n .navigation_title(\"VideoPlayer\")\n )\n\nappui.run(body, state=state, presentation=\"sheet\") WebView:URL 或 HTML import appui\n\nstate = appui.State()\n\ndef body():\n return appui.WebView(\n html=\"内嵌 HTML
\"\n ).frame(height=360).padding()\n\nappui.run(body, state=state, presentation=\"sheet\") 远程页面示例:appui.WebView(url=\"https://www.apple.com\").frame(height=360)。 MapView:地图与标注 默认中心为旧金山坐标。span 为经纬跨度。map_style 可为 'automatic'(默认,不传样式)、'standard'、'satellite'、'hybrid'。 import appui\n\nstate = appui.State()\n\ndef body():\n return appui.MapView(\n latitude=37.7749,\n longitude=-122.4194,\n span=0.08,\n markers=[\n {\"latitude\": 37.78, \"longitude\": -122.40, \"title\": \"标记 A\"},\n ],\n map_style=\"standard\",\n ).frame(height=280).padding()\n\nappui.run(body, state=state, presentation=\"sheet\") 与旧版 ui 模块对照(迁移) ui 为 Pythonista 风格命令式 API;同一应用内可对照理解差异: import ui\n\nv = ui.View()\nb = ui.Button(title=\"Go\")\n\n\ndef on_button(sender):\n pass\n\n\nb.action = on_button\nv.add_subview(b)\n# v.present('sheet') # 需在 AppUI 预览环境中调用 appui 用声明式 body() 返回 View,由 appui.run(body, state=..., presentation=\"sheet\") 呈现。 小结 视图 典型用途 `Image` 资源图、SF Symbol、缩放与宽高比 `AsyncImage` 网络图片与阶段 UI `PhotoPicker` / `CameraPicker` 系统相册与相机,路径回调 `FileImporter` 系统文件选择器,路径列表回调 `VideoPlayer` 流媒体或本地视频 `WebView` `url` 或 `html` 嵌入网页 `MapView` 坐标、跨度、标注与样式 更完整的参数说明见同目录下的 appui ref media.md。" + }, + { + "id": "appui-guide-realtime", + "title": "实时快路径", + "subtitle": "高频状态更新、二进制属性通道和实时 UI 的边界。", + "section": "appui / 指南", + "url": "pages/appui-guide-realtime/", + "text": "实时快路径 高频状态更新、二进制属性通道和实时 UI 的边界。 appui 指南 appui realtime performance 实时快路径 ReactiveState 高频 实时 性能 实时快路径 实时快路径是 appui 的高频界面更新通道。它的目标不是让你手动操作更新机制,而是在状态变化很频繁时,让已经显示出来的原生控件尽量只更新必要属性。 你可以把它理解为:首屏仍然由 appui 正常构建,运行时如果只是文本、滑块、开关、进度、图片等属性变化,系统会优先走更轻的更新路径。 预期效果 示例会展示普通 State 与 ReactiveState 在高频属性更新时的可见差异。 什么时候有用 滑块、计时器、仪表盘、进度条持续变化。 文本、标签、数值、状态灯需要频繁刷新。 页面结构基本不变,只是已有控件的值在变。 你希望减少整棵界面反复重建带来的卡顿。 什么时候不需要关注 页面只是普通表单、设置页或低频按钮操作。 你正在增删列表行、切换导航根、打开 sheet 或重排布局。 你的界面每次变化都需要重新组织视图结构。 这些场景继续使用普通 State 和 appui 组件即可。实时快路径会在适合的地方自动参与,不需要你把简单界面写复杂。 推荐写法 优先从清晰的状态模型开始: import appui\n\nstate = appui.State(progress=0.4, title=\"下载中\")\n\ndef body():\n return appui.VStack([\n appui.Text(state.title),\n appui.ProgressView(value=state.progress),\n appui.Button(\"增加进度\", action=bump),\n ], spacing=16).padding()\n\ndef bump():\n state.progress = min(1.0, state.progress + 0.1)\n if state.progress >= 1.0:\n state.title = \"已完成\"\n\nappui.run(body, state=state, presentation=\"sheet\") 如果一个值会高频变化,再考虑 ReactiveState。它适合把频繁变化的字段和界面控件绑定起来,让系统选择更轻的刷新方式。 import appui\n\nstate = appui.ReactiveState(value=0.5)\n\n\ndef value_label():\n return f\"{state.value:.0%}\"\n\n\ndef body():\n return appui.VStack([\n appui.Text(\"音量\"),\n appui.Slider(**appui.bind(state, \"value\"), label=\"音量\"),\n appui.Text(value_label()),\n ], spacing=16).padding()\n\nappui.run(body, state=state, presentation=\"sheet\") 使用原则 先写正确的 appui 界面,再优化高频字段。 高频值用 ReactiveState 或组件已有绑定,不要自己维护一套刷新系统。 结构变化交给普通 State 重建,属性变化交给系统尽量增量刷新。 不要依赖未公开常量或未文档化接口。 和性能优化的关系 实时快路径只解决“已有控件高频改值”的成本。真正流畅的界面还需要: 列表使用 List / ForEach,不要用大量 VStack 模拟。 大文件、网络、图片处理放到后台任务。 避免在 body() 中做复杂计算、排序或同步文件读取。 控件数量很大时,优先拆分页面或懒加载。 更多整体建议见 性能实践。 系统实时能力 如果实时数据来自相机、传感器、麦克风、键盘高度、深浅色变化或照片选择,请看 iOS 原生能力。这些能力需要明确的启动和停止路径,不适合在普通 body() 重建循环中轮询。" + }, + { + "id": "appui-guide-navigation", + "title": "导航与页面结构", + "subtitle": "TabView、NavigationStack、NavigationLink 和 toolbar 的组合方式。", + "section": "appui / 指南", + "url": "pages/appui-guide-navigation/", + "text": "导航与页面结构 TabView、NavigationStack、NavigationLink 和 toolbar 的组合方式。 appui 指南 appui navigation tabview toolbar 导航 TabView NavigationStack NavigationLink toolbar 页面 导航与页面结构 导航的目标是让页面关系清楚:栈式跳转用 NavigationStack,多 tab 用 TabView,iPad 双栏用 NavigationSplitView,临时任务用 sheet / alert / popover。 预期效果 示例会展示固定跳转、数据驱动导航、Tab、sheet 和 alert 的页面层级。 选择表 需求 API 页面逐层进入、支持返回 `NavigationStack` 固定列表进入详情 `NavigationLink` 通过数据驱动跳转 `NavigationPath` + `destinations` 底部标签页 `TabView` / `Tab` iPad / Mac 侧栏 + 详情 `NavigationSplitView` 临时编辑页 `.sheet(...)` 强制流程 `.full_screen_cover(...)` 确认、错误、危险操作 `.alert(...)` / `.confirmation_dialog(...)` 小浮层 `.popover(...)` NavigationStack 最小写法是给根内容包一层 NavigationStack,再在子页面里设置标题。 import appui\n\nstate = appui.State(last_opened=\"None\")\n\n\ndef mark_opened(title):\n state.last_opened = title\n\n\ndef settings_page():\n def open_settings():\n mark_opened(\"设置\")\n\n return appui.Form([\n appui.Section(\"设置\", [\n appui.Text(\"这里是详情内容\"),\n appui.Button(\"记录打开\", action=open_settings),\n ]),\n appui.Section(\"状态\", [\n appui.LabeledContent(\"Last opened\", value=state.last_opened),\n ]),\n ]).navigation_title(\"设置\")\n\n\ndef about_page():\n return appui.Text(\"实时快路径 appui\").padding().navigation_title(\"关于\")\n\n\ndef body():\n return appui.NavigationStack(\n appui.List([\n appui.NavigationLink(title=\"设置\", destination=settings_page()),\n appui.NavigationLink(title=\"关于\", destination=about_page()),\n ]).navigation_title(\"首页\")\n )\n\n\nappui.run(body, state=state) 固定页面跳转用 NavigationLink;动态路由用 NavigationPath。 NavigationPath NavigationPath 适合“从按钮打开指定页面”“根据 tag 和 data 打开详情”等数据驱动场景。 import appui\n\npath = appui.NavigationPath()\nstate = appui.State(opened=\"None\")\n\n\ndef open_settings():\n path.append({\"tag\": \"settings\", \"data\": None})\n\n\ndef open_user():\n path.append({\"tag\": \"user\", \"data\": 42})\n\n\ndef go_back():\n path.pop()\n\n\ndef go_home():\n path.pop_to_root()\n\n\ndef settings_destination(data):\n return appui.VStack([\n appui.Text(\"设置\").font(\"title2\"),\n appui.Button(\"返回\", action=go_back),\n ], spacing=12).padding().navigation_title(\"设置\")\n\n\ndef user_destination(user_id):\n return appui.VStack([\n appui.Text(f\"用户 {user_id}\").font(\"title2\"),\n appui.Button(\"返回首页\", action=go_home),\n ], spacing=12).padding().navigation_title(\"用户\")\n\n\ndef body():\n return appui.NavigationStack(\n content=appui.VStack([\n appui.Text(\"首页\").font(\"title2\"),\n appui.Button(\"打开设置\", action=open_settings).button_style(\"bordered\"),\n appui.Button(\"打开用户 42\", action=open_user).button_style(\"bordered_prominent\"),\n ], spacing=16).padding().navigation_title(\"示例\"),\n path=path,\n destinations={\n \"settings\": settings_destination,\n \"user\": user_destination,\n },\n )\n\n\nappui.run(body, state=state) path.append({\"tag\": \"...\", \"data\": ...}) 的 tag 用来找目标构建函数,data 会传给目标函数。 TabView Tab 适合并列的顶层区域,不要用来承载一次性流程。每个 tab 中如果还需要进入详情,再单独放自己的 NavigationStack。 import appui\n\nstate = appui.State(selection=0)\n\n\ndef set_selection(value):\n state.selection = value\n\n\ndef home_view():\n return appui.NavigationStack(\n appui.List([\n appui.Section(\"Home\", [\n appui.Text(\"Home content\")\n ])\n ]).navigation_title(\"首页\")\n )\n\n\ndef settings_view():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Settings\", [\n appui.LabeledContent(\"Selected tab\", value=str(state.selection))\n ])\n ]).navigation_title(\"设置\")\n )\n\n\ndef body():\n return appui.TabView(\n selection=state.selection,\n on_change=set_selection,\n tabs=[\n appui.Tab(\"首页\", system_image=\"house\", tag=0, content=home_view()),\n appui.Tab(\"设置\", system_image=\"gearshape\", tag=1, content=settings_view()),\n ],\n )\n\n\nappui.run(body, state=state) Sheet、Alert、ConfirmationDialog 弹层用状态控制显示和关闭。复杂 sheet 内容抽成命名函数。 import appui\n\nstate = appui.State(show_sheet=False, show_alert=False, name=\"\")\n\n\ndef set_name(value):\n state.name = value\n\n\ndef open_sheet():\n state.show_sheet = True\n\n\ndef close_sheet():\n state.show_sheet = False\n\n\ndef open_alert():\n state.show_alert = True\n\n\ndef close_alert():\n state.show_alert = False\n\n\ndef editor_sheet():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"编辑\", [\n appui.TextField(\"名称\", text=state.name, on_change=set_name),\n appui.Button(\"完成\", action=close_sheet).button_style(\"bordered_prominent\"),\n ])\n ]).navigation_title(\"编辑\")\n )\n\n\ndef body():\n root = (\n appui.VStack([\n appui.Button(\"编辑\", action=open_sheet).button_style(\"bordered\"),\n appui.Button(\"删除\", action=open_alert)\n .foreground_color(\"systemRed\"),\n ], spacing=16)\n .padding()\n .sheet(is_presented=state.show_sheet, on_dismiss=close_sheet, content=editor_sheet)\n .alert(\n \"确认删除?\",\n is_presented=state.show_alert,\n on_dismiss=close_alert,\n actions=[\n appui.Button(\"取消\", role=\"cancel\", action=close_alert),\n appui.Button(\"删除\", role=\"destructive\", action=close_alert),\n ],\n message=\"删除后无法恢复。\",\n )\n )\n return appui.NavigationStack(root.navigation_title(\"呈现\"))\n\n\nappui.run(body, state=state) 普通编辑用 sheet,破坏性操作先 alert 或 confirmation dialog。 NavigationSplitView NavigationSplitView 适合大屏。窄屏上系统会自动退化为栈式体验。 appui.NavigationSplitView(\n sidebar=appui.List([...]).navigation_title(\"项目\"),\n detail=appui.Text(\"选择一个项目\").navigation_title(\"详情\"),\n) 如果目标设备主要是 iPhone,不要为了“看起来高级”强行使用 SplitView。 常见问题 问题 处理 页面没有标题 在内容 View 上加 `.navigation_title(...)`。 返回后状态丢失 把状态放在外层 `State`,不要在目标页面函数里重新创建。 `destinations` 回调报参数错 回调写成接收 `data` 的命名函数。 sheet 点了没反应 确认 `is_presented` 读取的是状态字段,关闭时会把字段改回 `False`。 Tab 和导航栈混乱 Tab 做顶层分区,Stack 做分区内跳转。 工具栏图标按钮报错 `Button` 的图标放在 `content=Image(...)` 或 `content=Label(...)`。 下一步 布局系统 组织可编辑数据。 交互与回调 处理点击、拖拽、菜单、焦点。 API:导航 查询完整签名。" + }, + { + "id": "appui-guide-layout", + "title": "布局系统", + "subtitle": "用原生容器搭列表、表单、网格和可滚动页面。", + "section": "appui / 指南", + "url": "pages/appui-guide-layout/", + "text": "布局系统 用原生容器搭列表、表单、网格和可滚动页面。 appui 指南 appui layout list form grid 布局 List Form Section LazyVGrid ScrollView 布局系统 appui 的布局优先用组合:Stack 负责方向,ScrollView 负责滚动,Grid 负责网格,frame/padding/background 负责局部尺寸和外观。列表和设置页优先使用系统容器,复杂视觉再用自定义布局。 预期效果 示例会展示 Stack、ScrollView、Grid、Form 和 GeometryReader 在不同页面结构中的可见布局结果。 容器速查 容器 适合场景 常用参数 `VStack` 垂直排列 `spacing`, `alignment` `HStack` 水平排列 `spacing`, `alignment` `ZStack` 层叠覆盖 `alignment` `ScrollView` 长内容和自定义滚动页 `axes`, `shows_indicators` `LazyVStack` / `LazyHStack` 长列表自定义布局 `spacing`, `alignment` `LazyVGrid` / `LazyHGrid` 卡片网格 `columns` / `rows`, `spacing` `Grid` / `GridRow` 表格式二维布局 行列组合 `List` 原生动态列表 `Section`, `ForEach` `Form` 设置页和编辑页 `Section` `GeometryReader` 需要父容器尺寸 `on_change` 或 geometry 回调 Stack Stack 解决 80% 的局部布局。用 VStack 组织纵向内容,用 HStack + Spacer 组织行。 import appui\n\nstate = appui.State(selected=\"Revenue\")\n\n\ndef select_revenue():\n state.selected = \"Revenue\"\n\n\ndef select_orders():\n state.selected = \"Orders\"\n\n\ndef card(title, value, action):\n content = appui.VStack([\n appui.Text(title).font(\"caption\").foreground_color(\"secondaryLabel\"),\n appui.Text(value).font(\"title2\").bold(),\n ], alignment=\"leading\", spacing=6)\n\n return (\n appui.Button(action=action, content=content)\n .button_style(\"plain\")\n .padding(14)\n .frame(max_width=appui.infinity, alignment=\"leading\")\n .background(\"secondarySystemBackground\", corner_radius=12)\n )\n\n\ndef body():\n return appui.NavigationStack(\n appui.VStack([\n appui.Text(\"Dashboard\").font(\"largeTitle\").bold(),\n appui.HStack([\n card(\"Revenue\", \"$12.4k\", select_revenue),\n card(\"Orders\", \"284\", select_orders),\n ], spacing=12),\n appui.ZStack([\n appui.Rectangle().fill(\"systemBlue\").frame(height=120).corner_radius(16),\n appui.Text(f\"Selected: {state.selected}\").foreground_color(\"white\").bold(),\n ]),\n ], alignment=\"leading\", spacing=16)\n .padding()\n .navigation_title(\"Stack\")\n )\n\n\nappui.run(body, state=state) 只有 Stack 难以表达时,再使用 Grid 或 GeometryReader。 ScrollView 与长内容 长列表优先用 List;需要完全自定义外观时再用 ScrollView + LazyVStack。 import appui\n\nstate = appui.State(selected=\"None\")\n\n\ndef select_row(index):\n state.selected = f\"Item {index}\"\n\n\ndef row(index):\n def select_current():\n select_row(index)\n\n return appui.Button(\n action=select_current,\n content=appui.HStack([\n appui.Text(str(index))\n .frame(width=36, height=36)\n .background(\"systemBlue\", corner_radius=18)\n .foreground_color(\"white\"),\n appui.VStack([\n appui.Text(f\"Item {index}\").bold(),\n appui.Text(\"Subtitle\").font(\"caption\").foreground_color(\"secondaryLabel\"),\n ], alignment=\"leading\"),\n appui.Spacer(),\n ], spacing=12).padding(vertical=6),\n ).button_style(\"plain\")\n\n\ndef body():\n return appui.NavigationStack(\n appui.ScrollView(\n appui.LazyVStack([row(i) for i in range(1, 31)], spacing=8).padding(),\n axes=\"vertical\",\n )\n .safe_area_inset(\n edge=\"bottom\",\n content=appui.Text(f\"Selected: {state.selected}\")\n .padding()\n .frame(max_width=appui.infinity, alignment=\"leading\")\n .background(material=\"regularMaterial\"),\n )\n .navigation_title(\"ScrollView\")\n )\n\n\nappui.run(body, state=state) 底部操作条优先用 safe_area_inset,不要靠固定 offset 硬顶。 Grid 两三列卡片用 LazyVGrid 最直接;需要严格行列对齐时用 Grid/GridRow。 import appui\n\nstate = appui.State(selected=\"None\")\n\n\ndef select_tile(index):\n state.selected = f\"Card {index}\"\n\n\ndef tile(index):\n def select_current():\n select_tile(index)\n\n content = appui.VStack([\n appui.Image(system_name=\"square.grid.2x2\").font(\"title2\"),\n appui.Text(f\"Card {index}\").font(\"caption\"),\n ], spacing=8)\n\n return (\n appui.Button(action=select_current, content=content)\n .button_style(\"plain\")\n .frame(max_width=appui.infinity, min_height=92)\n .background(\"secondarySystemBackground\", corner_radius=12)\n )\n\n\ndef body():\n columns = [appui.flexible(), appui.flexible()]\n return appui.NavigationStack(\n appui.ScrollView(\n appui.VStack([\n appui.LabeledContent(\"Selected\", value=state.selected),\n appui.LazyVGrid(\n columns=columns,\n spacing=12,\n content=[tile(i) for i in range(1, 9)],\n ),\n ], spacing=16).padding()\n ).navigation_title(\"Grid\")\n )\n\n\nappui.run(body, state=state) List 和 Form 普通数据列表使用 List + Section + ForEach,设置和编辑页使用 Form + Section。这两个容器负责原生滚动、分组、键盘避让和辅助功能。 import appui\n\nstate = appui.State(query=\"\", notifications=True)\n\n\ndef set_query(value):\n state.query = value\n\n\ndef set_notifications(value):\n state.notifications = value\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Search\", [\n appui.TextField(\"Query\", text=state.query, on_change=set_query),\n ]),\n appui.Section(\"Preferences\", [\n appui.Toggle(\n \"Notifications\",\n is_on=state.notifications,\n on_change=set_notifications,\n ),\n ], footer=f\"Query: {state.query or '-'}\"),\n ]).navigation_title(\"Form\")\n )\n\n\nappui.run(body, state=state) 不要把每个设置项包成自绘卡片,也不要把 List 放进 ScrollView 或 Section 里。 frame、padding、background 常见顺序: (\n view\n .padding()\n .frame(max_width=appui.infinity, alignment=\"leading\")\n .background(\"secondarySystemBackground\")\n .corner_radius(12)\n) 需求 写法 占满宽度 `.frame(max_width=appui.infinity)` 固定高度 `.frame(height=120)` 左对齐 `.frame(max_width=appui.infinity, alignment=\"leading\")` 卡片内边距 `.padding()` 放在 `.background()` 前 外边距 `.padding()` 放在 `.background()` 后 GeometryReader 只有在需要父容器尺寸时使用 GeometryReader 或 .on_geometry(...)。不要把整页都包进 GeometryReader,它会改变布局提案,容易让子视图占满空间。 def remember_size(info):\n print(info[\"width\"], info[\"height\"])\n\n\nappui.GeometryReader(\n content=appui.Text(\"Measure me\"),\n on_change=remember_size,\n) 常见问题 问题 检查 背景只包住文字 `.padding()` 是否放在 `.background()` 前。 宽度没有撑开 是否设置了 `.frame(max_width=appui.infinity)`。 长内容滑不动 是否外层使用 `ScrollView` 或 `List`。 底部按钮挡住内容 使用 `safe_area_inset(edge=\"bottom\")`。 网格宽度不均 检查 `appui.flexible()` 列配置和 spacing。 列表不像原生 是否用 `ScrollView + VStack` 模拟了动态列表。 下一步 导航与页面结构 组织多页面结构。 布局 API 查看完整签名。 修饰符参考 查询 frame、padding、overlay、safe_area_inset。" + }, + { + "id": "appui-guide-performance", + "title": "性能与实时界面", + "subtitle": "普通 State、Timer、ReactiveState 和实时快路径的选择。", + "section": "appui / 指南", + "url": "pages/appui-guide-performance/", + "text": "性能与实时界面 普通 State、Timer、ReactiveState 和实时快路径的选择。 appui 指南 appui performance realtime reactive 性能 实时 ReactiveState 实时快路径 Timer 仪表盘 性能与实时界面 appui 的性能优化优先从界面结构、状态粒度和后台任务开始。大多数页面不需要特殊技巧,只要避免在 body() 里做重活,就能保持流畅。如果页面有高频属性更新,再看 实时快路径。 预期效果 示例会展示列表筛选、状态粒度、Timer 和高频属性更新的性能写法。 优先优化的地方 场景 推荐做法 列表数据 使用 `List + ForEach`,并提供稳定 key 设置页 使用 `Form + Section` 多字段状态变化 使用 `state.batch_update(...)` 派生搜索结果 使用普通函数或 `computed` 计时器和进度 模块级 `Timer`,回调中更新状态 高频数值 先用 `State` 做正确,再按需切到 `ReactiveState` 大文件读取 放到按钮动作、任务或后台流程中 图片和网络 先异步加载,再把结果写入状态 避免在 body 中做重活 body() 应该只描述界面。不要在里面同步读取大文件、下载网络内容、递归扫描目录、创建 Timer 或做复杂排序。 import appui\n\nstate = appui.State(items=[\"main.py\", \"notes.md\", \"data.json\"], selected=\"None\")\n\n\ndef item_key(name):\n return name\n\n\ndef select_item(name):\n state.selected = name\n\n\ndef row_view(name):\n def select_current():\n select_item(name)\n\n return appui.Button(\n action=select_current,\n content=appui.Text(name).frame(max_width=appui.infinity, alignment=\"leading\"),\n ).button_style(\"plain\")\n\n\ndef body():\n return appui.NavigationStack(\n appui.List([\n appui.Section(\"Files\", [\n appui.ForEach(state.items, row_builder=row_view, key=item_key)\n ]),\n appui.Section(\"State\", [\n appui.LabeledContent(\"Selected\", value=state.selected)\n ]),\n ]).navigation_title(\"Files\")\n )\n\n\nappui.run(body, state=state, presentation=\"sheet\") 如果数据来自耗时操作,先在事件中准备好,再更新状态。 控制状态刷新范围 把变化频繁的值放到明确字段里。一次动作改多个字段时用 batch_update,避免中间状态反复重建。 import appui\n\nstate = appui.State(count=0, message=\"Ready\")\n\n\ndef plus():\n state.batch_update(count=state.count + 1, message=\"Updated\")\n\n\ndef reset():\n state.batch_update(count=0, message=\"Reset\")\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Counter\", [\n appui.LabeledContent(\"Count\", value=str(state.count)),\n appui.HStack([\n appui.Button(\"增加\", action=plus).button_style(\"bordered_prominent\"),\n appui.Button(\"重置\", action=reset).button_style(\"bordered\"),\n ], spacing=12),\n ], footer=state.message)\n ]).navigation_title(\"Refresh Scope\")\n )\n\n\nappui.run(body, state=state, presentation=\"sheet\") 列表和大数据 大列表优先使用原生列表组件。过滤、排序、分组这类派生数据不要在每个 row 里重复计算。 import appui\n\nrows = [{\"id\": index, \"title\": f\"Item {index}\"} for index in range(1, 101)]\nstate = appui.State(query=\"\", rows=rows, selected=\"None\")\n\n\ndef set_query(value):\n state.query = value\n\n\n@appui.computed(state, depends_on=[\"query\", \"rows\"])\ndef filtered_rows():\n keyword = state.query.strip().lower()\n if not keyword:\n return state.rows\n return [row for row in state.rows if keyword in row[\"title\"].lower()]\n\n\ndef row_key(row):\n return row[\"id\"]\n\n\ndef select_row(row):\n state.selected = row[\"title\"]\n\n\ndef row_view(row):\n def select_current():\n select_row(row)\n\n return appui.Button(\n action=select_current,\n content=appui.HStack([\n appui.Text(row[\"title\"]).frame(max_width=appui.infinity, alignment=\"leading\"),\n appui.Image(system_name=\"chevron.right\").foreground_color(\"tertiaryLabel\"),\n ]),\n ).button_style(\"plain\")\n\n\ndef body():\n visible = filtered_rows()\n return appui.NavigationStack(\n appui.List([\n appui.Section(f\"{len(visible)} results\", [\n appui.ForEach(visible, row_builder=row_view, key=row_key)\n ]),\n appui.Section(\"Selection\", [\n appui.LabeledContent(\"Selected\", value=state.selected)\n ]),\n ])\n .searchable(text=state.query, on_change=set_query)\n .navigation_title(\"Large List\")\n )\n\n\nappui.run(body, state=state, presentation=\"sheet\") 不要把大量行直接塞进普通 VStack 再放进 ScrollView。列表需要稳定的行身份和系统级复用能力。 Timer 和中频刷新 Timer 放模块级,启动和停止由回调控制,不要在 body() 里创建。 import appui\n\nstate = appui.State(seconds=0, running=False)\n\n\ndef tick():\n if state.running:\n state.seconds += 1\n\n\ntimer = appui.Timer(interval=1.0, action=tick)\n\n\ndef start():\n state.running = True\n timer.start()\n\n\ndef stop():\n state.running = False\n timer.stop()\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"Timer\", [\n appui.LabeledContent(\"Seconds\", value=str(state.seconds)),\n appui.HStack([\n appui.Button(\"Start\", action=start).button_style(\"bordered_prominent\"),\n appui.Button(\"Stop\", action=stop).button_style(\"bordered\"),\n ], spacing=12),\n ], footer=\"Running\" if state.running else \"Stopped\")\n ]).navigation_title(\"Timer\")\n )\n\n\nappui.run(body, state=state) 高频属性更新 当页面结构稳定、只是已有控件的值持续变化时,可以使用 实时快路径模式。它适合进度、滑块、仪表盘、计时器和状态文本。 关键原则: 先保证普通 State 版本正确。 高频字段再改成 ReactiveState 或组件绑定。 不要手动调用未公开接口。 结构变化仍然交给普通刷新。 import appui\n\nstate = appui.ReactiveState(\n title=\"Meter\",\n level=(0.0, 1),\n peak=(0.0, 2),\n)\n\n\ndef update_meter(level, peak):\n state.level = level\n state.peak = peak 重型原生视图 VideoPlayer、MapView、WebView、相机预览这类视图不要放进普通列表行里滚动。把它们放在稳定区域,让下方的控制项使用 Form 或 List。 场景 推荐 视频播放 顶部固定 `VideoPlayer`,下方 `Form` 控制区 地图选点 顶部 `MapView`,下方坐标和操作 WebView 帮助页 `WebView` 独立稳定区域 图表仪表盘 `Chart` 或 `Canvas` 放在固定高度卡片中 小结 body() 只做界面描述。 数据准备、文件读取、网络请求放到界面构建之外。 结构变化用普通状态刷新。 多字段变化用 batch_update。 长列表用 List + ForEach + stable key。 高频属性变化看 实时快路径;相机、传感器、音频或系统状态流看 iOS 原生能力。" + }, + { + "id": "appui-guide-darkmode", + "title": "深色模式与语义色", + "subtitle": "语义颜色、材质背景、强制色彩方案和符号配色。", + "section": "appui / 指南", + "url": "pages/appui-guide-darkmode/", + "text": "深色模式与语义色 语义颜色、材质背景、强制色彩方案和符号配色。 appui 指南 appui color darkmode dark mode 深色模式 semantic color preferred_color_scheme background 深色模式 在 appui 中,外观由 系统语义色字符串 、 环境色方案 与 材质背景 共同决定。优先使用语义色和材质,让页面自动适配浅色、深色和高对比度环境。 预期效果 示例会展示语义色、材质背景、强制色彩方案和符号配色在浅色/深色下的表现。 系统语义色(随浅色/深色变化) 在 foreground_color、background(color=...)、fill、stroke 等接受颜色的位置,可直接传入下列字符串(不区分大小写;部分名称支持 snake_case 别名,如 secondary_label)。 背景与分组 systemBackground(别名:system_background、background) secondarySystemBackground(secondary_system_background、secondary_background) tertiarySystemBackground(tertiary_system_background、tertiary_background) systemGroupedBackground、secondarySystemGroupedBackground、tertiarySystemGroupedBackground 标签与分隔 label、secondaryLabel、tertiaryLabel、quaternaryLabel separator、opaqueSeparator placeholderText(placeholder_text、placeholder) 填充层级 systemFill(system_fill、fill) secondarySystemFill、tertiarySystemFill、quaternarySystemFill 系统调色板 systemBlue、systemRed、systemGreen、systemOrange、systemPurple systemPink、systemYellow、systemTeal、systemCyan、systemIndigo systemGray、systemBrown、systemMint import appui\n\nstate = appui.State()\n\ndef body():\n return (appui.VStack([\n appui.Text(\"主标签色\").foreground_color(\"label\"),\n appui.Text(\"次要说明\").foreground_color(\"secondaryLabel\"),\n appui.Divider(),\n appui.Text(\"强调链接\").foreground_color(\"systemBlue\"),\n ], spacing=12)\n .padding()\n .frame(max_width=400)\n .background(color=\"systemGroupedBackground\", corner_radius=12))\n\nappui.run(body, state=state, presentation=\"sheet\") preferred_color_scheme:强制浅色或深色 对子树应用 .preferred_color_scheme(scheme),scheme 为 'light' 或 'dark'。 import appui\n\nstate = appui.State(force=\"dark\")\n\n\ndef set_light():\n state.force = \"light\"\n\n\ndef body():\n return (appui.VStack([\n appui.Text(\"强制深色环境下的卡片\").foreground_color(\"label\"),\n appui.Button(\"切换为浅色预览\", action=set_light),\n ], spacing=16)\n .padding()\n .background(color=\"secondarySystemBackground\", corner_radius=16)\n .preferred_color_scheme(state.force))\n\nappui.run(body, state=state, presentation=\"sheet\") 材质背景:background(material=...) 使用 模糊材质 时,不要同时传 color。material 常用取值如下: `material` 参数值 说明 `ultra_thin` 或 `ultra_thin_material` `ultraThinMaterial` `thin` 或 `thin_material` `thinMaterial` `regularMaterial`、`regular` 或 `regular_material` `regularMaterial` `thick` 或 `thick_material` `thickMaterial` `ultra_thick` 或 `ultra_thick_material` `ultraThickMaterial` import appui\n\nstate = appui.State()\n\ndef body():\n return (appui.VStack([\n appui.Text(\"玻璃质感背景\").foreground_color(\"label\"),\n appui.Text(\"副标题\").font(\"caption\").foreground_color(\"secondaryLabel\"),\n ], spacing=8)\n .padding(20)\n .background(material=\"regularMaterial\", corner_radius=14))\n\nappui.run(body, state=state, presentation=\"sheet\") background 还可配合 gradient、opacity、corner_radius 等参数;详见 View.background 的 appui.py 文档字符串。 实践建议 优先语义色 :卡片与列表用 secondarySystemGroupedBackground、label 等,减少硬编码 RRGGBB。 对比度 :在 preferred_color_scheme('dark') 下仍用 label / secondaryLabel 分层,而不是纯灰。 材质与圆角 :background(material='regular', corner_radius=12) 在圆角矩形内裁剪材质,视觉更统一。 调试 :用 preferred_color_scheme 固定一种方案,截图对比浅色与深色下的 systemFill 层级是否清晰。 import appui\n\n# 仅构建视图树,验证修饰符链可序列化。\ndef body():\n return appui.Text(\"Hi\").padding().background(material=\"thin\", corner_radius=8)\n\ntree = body()\nassert tree is not None 与 SF Symbols 搭配 导航与媒体类符号在深浅背景下都清晰,例如 chevron.left、play.fill、gearshape.fill。配合 Image(system_name=...) 与 symbol_rendering_mode('hierarchical') 可得到分层着色效果。 import appui\n\nstate = appui.State()\n\ndef body():\n return appui.HStack([\n appui.Image(system_name=\"sun.max.fill\").foreground_color(\"systemYellow\"),\n appui.Image(system_name=\"moon.fill\").foreground_color(\"systemIndigo\"),\n ], spacing=24).padding()\n\nappui.run(body, state=state, presentation=\"sheet\") 更多媒体与符号用法见 appui guide media.md。" + }, + { + "id": "appui-guide-hotreload", + "title": "热重载", + "subtitle": "hot_reload 的启用方式、状态保留和调试边界。", + "section": "appui / 指南", + "url": "pages/appui-guide-hotreload/", + "text": "热重载 hot_reload 的启用方式、状态保留和调试边界。 appui 指南 appui hot_reload hot_reload 热重载 run State 热重载 appui.run(body_func, state=None, hot_reload=False, presentation='sheet') 在 hot_reload=True 时监视调用脚本文件,保存后自动重新执行脚本并刷新界面,便于迭代 UI。body_func 可以写成 body(),也可以写成 body(state)。 预期效果 示例会展示热重载保留 State、刷新页面结构,并在错误时保持预览可恢复。 启用方式 import appui\n\nstate = appui.State(count=0)\n\n\ndef increment():\n state.count += 1\n\n\ndef body():\n return appui.VStack([\n appui.Text(f\"count = {state.count}\").font(\"title2\"),\n appui.Button(\"+1\", action=increment)\n .button_style(\"bordered_prominent\"),\n ], spacing=16).padding()\n\nappui.run(body, state=state, hot_reload=True, presentation=\"sheet\") 要点: 监视的是 run() 调用栈所在文件 (inspect.stack),因此热重载入口应放在 真实脚本 中,而不是交互式 stdin。 保存文件后,运行环境会尽快重新加载脚本并刷新界面;触发速度取决于当前设备和文件系统。 状态保留:仅 State 自动合并 热重载重新执行脚本后,若旧页面和新页面都使用 State ,会按字段名保留仍然存在的状态值,从而让计数器、开关等继续保持当前值。 ReactiveState 不会自动合并 到新实例;热重载后若脚本重新构造 ReactiveState(),字段会回到脚本初值,除非你在脚本里自行从文件恢复。 \"\"\"说明:热重载后 State 合并逻辑只针对 appui.State。\"\"\"\nimport appui\n\ns = appui.State(a=1)\nsnapshot = s.to_dict()\nassert snapshot[\"a\"] == 1 (在应用内以 hot_reload=True 运行时可观察计数在保存后仍连续。) 开发流程建议 将 body 与小组件函数放在同一 .py 文件,run(..., hot_reload=True) 置于 if __name__ == \"__main__\": 中(若在应用内直接调用亦可)。 每次保存后,运行环境会重新读取当前文件并绑定新的 body。 首帧会完整刷新界面树,避免旧界面结构影响新代码。 出错时会发生什么 若新代码 语法错误 或 导入失败 ,异常会显示在预览错误面板;进程不退出,修正后再保存即可。 import appui\n\nstate = appui.State(ok=True)\n\ndef body():\n return appui.VStack([\n appui.Text(\"保存本文件即可触发热重载\").font(\"footnote\"),\n appui.Button(\"关闭\", action=appui.dismiss).button_style(\"bordered\"),\n ], spacing=12).padding()\n\nappui.run(body, state=state, hot_reload=True, presentation=\"sheet\") 调试技巧 打印 :保存后可在控制台观察重载成功或错误信息。 按钮短暂不可点 :保存瞬间正在替换回调,等界面刷新完成后再点一次。 不要用{safe_body}
\n本周进展良好。
\", path)\npdf.preview(path) AppUI 示例 创建与预览放在按钮回调;界面展示页数与提取摘要。 import appui\nimport os\nimport pdf\n\nDOC = os.path.join(os.path.expanduser(\"~/Documents\"), \"pdf-demo.pdf\")\n\nstate = appui.State(\n pages=\"—\",\n preview=\"未创建\",\n snippet=\"点击按钮生成示例 PDF\",\n)\n\n\ndef create_demo():\n pdf.create_from_text(\n DOC,\n \"PythonIDE PDF 示例\\n\\n这是第二段文字。\",\n title=\"PDF 演示\",\n )\n meta = pdf.info(DOC) or {}\n text = pdf.extract_text(DOC) or \"\"\n state.batch_update(\n pages=str(meta.get(\"page_count\", \"—\")),\n preview=\"已生成\",\n snippet=text[:120] + (\"…\" if len(text) > 120 else \"\"),\n )\n\n\ndef open_preview():\n if not os.path.exists(DOC):\n state.snippet = \"请先生成 PDF\"\n return\n pdf.preview(DOC)\n state.preview = \"已打开 QuickLook\"\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"操作\", [\n appui.Button(\"生成示例 PDF\", action=create_demo)\n .button_style(\"bordered_prominent\"),\n appui.Button(\"QuickLook 预览\", action=open_preview),\n ]),\n appui.Section(\"状态\", [\n appui.LabeledContent(\"页数\", value=state.pages),\n appui.LabeledContent(\"文件\", value=state.preview),\n appui.Text(state.snippet).foreground_color(\"secondaryLabel\"),\n ]),\n ]).navigation_title(\"PDF\")\n )\n\n\nappui.run(body, state=state) API 参考 速查 API 作用 `create_from_text(path, text, title=None)` 纯文本 → PDF `create_from_images(path, image_paths)` 多图 → 多页 PDF `from_html(html, path)` HTML 字符串 → PDF `extract_text(path)` 提取全文 `info(path)` 元数据 `page_count` / `title` / `author` / `encrypted` `page_image(path, index=0, scale=2.0)` 单页渲染为 PNG `bytes` `preview(path)` 弹出 QuickLook 预览 创建 pdf.create_from_text(\"/path/note.pdf\", \"Hello\\nWorld\", title=\"Note\")\npdf.create_from_images(\"/path/album.pdf\", [\"/path/a.jpg\", \"/path/b.jpg\"])\npdf.from_html(\"等待操作
\n\n\"\"\"\n\n\ndef body():\n return appui.NavigationStack(\n appui.WebView(html=html).navigation_title(\"JS API\")\n )\n\n\nappui.run(body) 写 JS API 的心智模型 宿主:JS API 只在应用提供的 WebView 环境里存在。 触发:剪贴板、通知、打开 URL、文件写入等能力由点击或明确动作触发。 回调:多数系统能力是回调式 API,先更新 DOM,再记录必要日志。 数据:小状态用 localStorage,复杂数据和敏感数据交给 Python 模块。 边界:WebView 负责展示和轻交互,业务流程由 AppUI 或 Python 承载。 console 输出 console.log(\"Hello\")\nconsole.warn(\"警告\")\nconsole.error(\"错误信息\") alert / confirm / prompt alert(\"提示\")\n\nconfirm(\"确定吗?\", function(ok) {\n console.log(ok)\n})\n\nprompt(\"请输入\", \"默认\", function(text) {\n console.log(text)\n}) 剪贴板 getClipboard(function(text) {\n console.log(text)\n})\n\nsetClipboard(\"复制的内容\") localStorage localStorage.setItem(\"key\", \"value\")\nvar value = localStorage.getItem(\"key\")\nlocalStorage.removeItem(\"key\") 文件读写 readFile(\"a.txt\", function(err, content) {\n console.log(err || content)\n})\n\nwriteFile(\"b.txt\", \"内容\", function(err) {\n console.log(err || \"ok\")\n})\n\nlistDir(\".\", function(err, jsonText) {\n console.log(err || JSON.parse(jsonText))\n}) fetch 网络请求 fetch(\"https://httpbin.org/get\")\n .then(function(body) {\n console.log(body)\n })\n .catch(function(err) {\n console.error(err)\n }) 通知 requestNotificationPermission(function(granted) {\n if (granted) {\n scheduleNotification(\"标题\", \"内容\", 3)\n }\n}) 设备与系统 console.log(getBatteryLevel(), isCharging())\nconsole.log(getDeviceInfo())\nopenURL(\"https://apple.com\")\nopenSettings() 常用工具 vibrate(\"light\")\n\nvar now = getCurrentTime()\nconsole.log(formatDate(now, \"yyyy-MM-dd HH:mm\"))\n\nconsole.log(hash(\"hello\", \"sha256\"))\n\nqrcode(\"https://www.python.org\", function(err, base64) {\n console.log(err || base64)\n}) 定时器 var timer = setTimeout(function() {\n console.log(\"1 秒后\")\n}, 1000)\n\nvar interval = setInterval(function() {\n console.log(\"每 2 秒\")\n}, 2000)\n\nclearTimeout(timer)\nclearInterval(interval) 截图和加载提示 showLoading(\"处理中...\")\n\ntakeScreenshot(function(err, base64) {\n hideLoading()\n console.log(err ? err : \"base64 长度:\" + base64.length)\n}) 失败路径 情况 应该怎么处理 API 未定义 确认代码运行在应用提供的 WebView 环境里,而不是普通外部浏览器。 回调没有触发 把结果写到页面元素或 console,先确认按钮事件已绑定。 文件或通知失败 检查用户动作、权限和文件路径;失败时显示简短错误。 请求复杂接口 复杂 HTTP、鉴权和上传流程优先交给 Python `network` 模块。 发布前检查 检查项 合格标准 环境 页面在 AppUI `WebView` 中运行,不依赖外部浏览器注入 API。 触发 剪贴板、通知、打开 URL 和文件写入由用户点击触发。 回调 成功、取消和失败都更新页面元素,不只写 console。 数据 secret 不放进 `localStorage`,复杂数据交给 Python 保存。 边界 复杂 HTTP、权限和原生能力不堆在网页脚本里。 使用规则 JS API 是应用提供的接口,不等同于浏览器完整 Web API。 文件 API 适合读写脚本工作区里的小文件,不适合大文件流式处理。 fetch 是轻量 GET 辅助函数;复杂请求优先用 Python network 模块。 弹窗、通知、剪贴板这类能力必须由明确用户动作触发。 如果页面不需要 DOM,优先使用 Python 原生模块和 AppUI 组件。" + }, + { + "id": "shortcuts-guide", + "title": "快捷指令", + "subtitle": "系统快捷指令集成、自动化和 URL 跳转。", + "section": "自动化与扩展 / 快速上手", + "url": "pages/shortcuts-guide/", + "text": "快捷指令 系统快捷指令集成、自动化和 URL 跳转。 自动化与扩展 快速上手 Shortcuts Automation Siri 快捷指令 automation siri url scheme run script 快捷指令 系统快捷指令集成、自动化和 URL 跳转。 预期效果 示例会展示系统快捷指令入口和 Python shortcuts 模块的最小调用。 先选入口 目标 推荐方式 从系统快捷指令里运行 Python 脚本 使用“运行 Python 脚本”动作。 从 App 内按钮触发已有快捷指令 在 AppUI 回调里调用 `shortcuts.run_shortcut(...)`。 打开网页、深链或系统设置 调用 `shortcuts.open_url(...)` 或 `shortcuts.open_settings()`。 需要表单、确认和结果展示 用 AppUI 承载页面,把快捷指令能力放进按钮。 需要长期后台自动化 使用系统快捷指令自动化,不把循环写进前台页面。 什么时候用 想从 Python 脚本触发一个已经存在的系统快捷指令。 想打开 URL、深链或当前 App 的设置页。 想把 PythonIDE 的脚本能力接入 iOS 快捷指令自动化。 可用动作 动作 用途 运行 Python 代码 执行短代码片段 运行 Python 脚本 执行工作区 .py 文件并传参 在应用中运行 打开 App 运行需要输入或 UI 的脚本 获取脚本输出 读取不等待运行后的输出 创建 Python 脚本 创建 .py 文件后再运行 测试代码 print(\"hello\") Python 模块示例 import shortcuts\n\nshortcuts.run_shortcut(\"Daily Log\")\nshortcuts.open_url(\"https://www.apple.com\")\nshortcuts.open_settings() AppUI 中的推荐接法 import appui\nimport shortcuts\n\nstate = appui.State(status=\"等待操作\")\n\n\ndef run_daily_log():\n ok = shortcuts.run_shortcut(\"Daily Log\")\n state.status = \"已触发\" if ok else \"未能触发\"\n\n\ndef open_settings():\n shortcuts.open_settings()\n state.status = \"已打开设置\"\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"快捷指令\", [\n appui.Button(\"运行 Daily Log\", action=run_daily_log),\n appui.Button(\"打开设置\", action=open_settings),\n ]),\n appui.Section(\"状态\", [\n appui.Text(state.status).foreground_color(\"secondaryLabel\"),\n ]),\n ]).navigation_title(\"快捷指令\")\n )\n\n\nappui.run(body, state=state) 预期效果:页面提供两个明确按钮;触发失败不会中断页面,状态区域会显示结果。 失败路径 情况 应该怎么处理 系统入口不可用 显示不可用状态,不重复触发打开动作。 用户取消或没有配置 保持原页面状态,并提示需要先配置对应系统能力。 回调没有返回预期结果 记录简短状态,避免把长日志或敏感内容展示给用户。 需要复杂界面 用 `appui` 承载页面,把自动化能力放在按钮回调里调用。 使用规则 run_shortcut(...) 需要用户设备上已经存在同名快捷指令。 打开 URL 和运行快捷指令都应该由明确操作触发,不要在导入模块时自动执行。 如果要做多步骤界面流程,优先用 AppUI;快捷指令适合系统自动化入口。 发布前检查 检查项 合格标准 快捷指令名称 设备上已经存在同名快捷指令。 触发时机 运行快捷指令和打开 URL 都由按钮、菜单或系统动作触发。 结果反馈 成功、取消、未配置和不可用状态都有简短提示。 页面边界 多步骤交互放在 AppUI,不把 UI 流程塞进系统动作。 相关文档 文档 用途 [shortcuts](shortcuts-module) 运行快捷指令、打开 URL、进入设置。" + }, + { + "id": "c-extensions-module", + "title": "c_extensions", + "subtitle": "内置扩展库清单、可用范围和替代建议。", + "section": "自动化与扩展 / 模块", + "url": "pages/c-extensions-module/", + "text": "c_extensions 内置扩展库清单、可用范围和替代建议。 自动化与扩展 模块 Packages Built-in Native Extension c_extensions 内置库 numpy pandas matplotlib 预编译扩展 c_extensions 内置 C 扩展库清单、可用范围与替代建议。 文档页,不是可导入模块。 边界 :不要写 import c_extensions。第三方含 C/Rust/native 扩展的包,仅 已内置 或当前环境能安装的可用;opencv、torch、scipy 等桌面常见包通常无法直接 pip 到 iOS。优先用内置包、纯 Python 或 PythonIDE 原生模块。 模块概览 项 说明 导入 无;使用真实包名如 `import numpy` 适合做什么 判断能否 `import`、选替代路线、写降级逻辑 调用时机 脚本开头 `try/except ImportError` 推荐顺序 查下表 → 尝试 import → 失败则换路线 OCR/ML 用 [vision](vision-module)、[coreml](coreml-module),非 pip 装 CV 包 快速开始 安全使用已内置的 numpy: try:\n import numpy as np\nexcept ImportError:\n np = None\n\nvalues = [1, 2, 3, 4, 5]\nif np is None:\n mean = sum(values) / len(values)\nelse:\n mean = float(np.array(values).mean())\nprint(mean) AppUI 示例 在界面展示「内置包可用 / 未内置包需替代」的检查结果(逻辑放按钮回调)。 import appui\n\nstate = appui.State(\n numpy_ok=\"—\",\n cv2_ok=\"—\",\n hint=\"点击检查\",\n)\n\nBUILTIN_TRY = [\n (\"numpy\", \"numpy_ok\"),\n (\"cv2\", \"cv2_ok\"),\n]\n\n\ndef check_imports():\n for module, attr in BUILTIN_TRY:\n try:\n __import__(module)\n setattr(state, attr, \"可导入\")\n except ImportError:\n setattr(state, attr, \"未内置\")\n\n if state.numpy_ok == \"可导入\":\n state.hint = \"数组统计可用 numpy\"\n elif state.cv2_ok == \"未内置\":\n state.hint = \"图像处理请用 vision / vision_helper / coreml\"\n else:\n state.hint = \"检查完成\"\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"检查\", [\n appui.Button(\"检查常见包\", action=check_imports)\n .button_style(\"bordered_prominent\"),\n ]),\n appui.Section(\"结果\", [\n appui.LabeledContent(\"numpy\", value=state.numpy_ok),\n appui.LabeledContent(\"cv2\", value=state.cv2_ok),\n appui.Text(state.hint).foreground_color(\"secondaryLabel\"),\n ], footer=\"c_extensions 是文档索引,不是 import 名。\"),\n ]).navigation_title(\"C 扩展\")\n )\n\n\nappui.run(body, state=state) API 参考 已内置 C 扩展库(节选) 包 典型用途 `numpy` 数组、矩阵、统计 `pandas` 表格数据处理 `matplotlib` 绘图(亦可 AppUI Chart) `cryptography` / `bcrypt` / `argon2-cffi` 加密与哈希 `msgpack` / `msgspec` / `ujson` / `rapidjson` 高性能序列化 `zstandard` / `xxhash` 压缩与哈希 `PyYAML` / `ruamel.yaml` YAML `regex` / `tornado` / `httptools` 工具库 完整列表见应用内文档生成源;新增内置包以运行时为准。 常见未内置包 cv2、opencv、scipy、scikit learn、sklearn、tensorflow、torch、torchvision、lxml、psutil、h5py、numba、cython、gevent 等。 替代路线 需求 优先选择 数组、统计 `numpy`、`pandas` 绘图 `matplotlib`、[appui](appui) `Chart` OCR、条码、人脸 [vision](vision-module)、[vision_helper](vision-helper) 图片分类模型 [coreml](coreml-module) 哈希、压缩 `xxhash`、`zstandard` iOS 设备能力 对应原生模块 降级模式 try:\n import numpy as np\nexcept ImportError:\n np = None\n\ndef mean_value(values):\n if np is None:\n return sum(values) / len(values)\n return float(np.array(values).mean()) 常见错误 错误写法 后果 修正 `import c_extensions` 模块不存在 使用真实包名 桌面 pip 经验直接套 iOS 安装失败 查内置表或换原生模块 无 `ImportError` 处理 脚本直接崩溃 `try/except` + 纯 Python 降级 用未内置 CV 包做 OCR 不可用 [vision](vision-module) 相关文档 文档 用途 [coreml](coreml-module) 端侧图像模型 [vision_helper](vision-helper) Vision 检测 API [原生能力入口](miniapp-native-capabilities) iOS 模块索引 先选路线 路线 何时选 内置原生模块 优先查 [iOS 原生能力](ios-native.md) pip / 纯 Python 仅在沙盒允许且已有 wheel 时 C 扩展编译 仅高级场景;先评估维护成本 发布前检查 确认目标能力是否已有官方模块或 AppUI 组件 在真机验证权限、离线场景与失败返回值 不要把未审核的扩展当作 App Store 可发布方案 预期效果 运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。" + }, + { + "id": "keyboard-module", + "title": "keyboard", + "subtitle": "编辑器自定义键盘工具栏与插入动作。", + "section": "自动化与扩展 / 模块", + "url": "pages/keyboard-module/", + "text": "keyboard 编辑器自定义键盘工具栏与插入动作。 自动化与扩展 模块 Toolbar Snippet Cursor keyboard keyboard toolbar snippet insert_text insert_snippet move_cursor 编辑器键盘 keyboard 脚本编辑器键盘工具栏:自定义快捷键按钮、插入文本与代码片段。 边界 :作用于 PythonIDE 脚本编辑器 键盘上方工具栏,不是 AppUI 小应用内键盘。配置代码应在编辑器中运行的脚本里执行;AppUI 示例仅演示配置写法。 模块概览 项 说明 导入 `import keyboard` 适合做什么 Tab 缩进、常用关键字、代码片段、光标跳转 调用时机 编辑器打开前或脚本开头注册按钮 推荐顺序 `clear()`(可选)→ `add_button` / `add_group` → 回调里 `insert_text` 回调 `action` 为 Python 函数,点击时在当前编辑器执行 快速开始 在编辑器脚本中注册工具栏按钮: import keyboard\n\n\ndef insert_tab():\n keyboard.insert_text(\" \")\n\n\ndef insert_print():\n keyboard.insert_text(\"print()\")\n\n\ndef insert_def_snippet():\n keyboard.insert_snippet(\"def ${1:name}(${2:args}):\\n ${0:pass}\")\n\n\nkeyboard.clear()\nkeyboard.add_button(\"Tab\", action=insert_tab)\nkeyboard.add_button(\"print\", action=insert_print)\nkeyboard.add_button(\"def\", action=insert_def_snippet) 分组按钮: import keyboard\n\n\ndef insert_import():\n keyboard.insert_text(\"import \")\n\n\ndef insert_if():\n keyboard.insert_text(\"if :\\n \", offset=-6)\n\n\nkeyboard.add_group(\"常用\", buttons=[\n keyboard.Button(\"import\", action=insert_import),\n keyboard.Button(\"if\", action=insert_if),\n]) AppUI 示例 说明:以下界面展示推荐配置; 实际生效需在编辑器中运行同等 keyboard 代码 。 import appui\n\nstate = appui.State(\n config_status=\"未应用\",\n snippet_preview=\"def name(args):\\n pass\",\n)\n\n\ndef show_config_hint():\n state.config_status = (\n \"请在编辑器脚本中执行 keyboard.add_button;\"\n \"AppUI 内不会修改编辑器工具栏。\"\n )\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"说明\", [\n appui.Text(\n \"keyboard 模块自定义脚本编辑器键盘工具栏,\"\n \"不作用于本 AppUI 界面内的输入框。\"\n ).foreground_color(\"secondaryLabel\"),\n appui.Button(\"查看配置说明\", action=show_config_hint)\n .button_style(\"bordered_prominent\"),\n ]),\n appui.Section(\"推荐配置示例\", [\n appui.LabeledContent(\"Tab\", value='insert_text(\" \")'),\n appui.LabeledContent(\"print\", value='insert_text(\"print()\")'),\n appui.Text(state.snippet_preview)\n .font(\"caption\")\n .foreground_color(\"secondaryLabel\"),\n ]),\n appui.Section(\"状态\", [\n appui.Text(state.config_status).foreground_color(\"secondaryLabel\"),\n ], footer=\"在编辑器新建脚本,粘贴「快速开始」代码后运行一次即可。\"),\n ]).navigation_title(\"键盘工具栏\")\n )\n\n\nappui.run(body, state=state) API 参考 速查 API 作用 `add_button(label, action=None, icon=None)` 添加单个按钮 `add_group(name, buttons)` 添加按钮组 `set_buttons(buttons)` 替换全部按钮 `remove_button(label)` 移除按钮 `clear()` 清空自定义按钮 `insert_text(text, offset=0)` 插入文本,可选光标偏移 `insert_snippet(template)` 插入片段(`$1`、`$0` 占位) `move_cursor(offset)` 移动光标 `Button(label, action=None, icon=None)` 按钮描述类 insert_text / insert_snippet keyboard.insert_text(\" \") # Tab\nkeyboard.insert_text(\"if :\\n \", offset=-6) # 光标回到条件处\nkeyboard.insert_snippet(\"def ${1:name}():\\n ${0:pass}\")\nkeyboard.move_cursor(-3) icon 可选 SF Symbol 名称。 生命周期 同一 label 对应稳定 btn_{label} ID;remove_button / clear 会注销回调。 常见错误 错误写法 后果 修正 在 AppUI 里期望改工具栏 不生效 在编辑器脚本中注册 重复 `add_button` 同名 覆盖或重复 先 `clear()` 或 `remove_button` `action` 未调用 `insert_*` 按钮无效果 回调里写插入逻辑 与系统键盘混淆 概念错误 本模块仅编辑器 accessory 工具栏 相关文档 文档 用途 [console](console-module) 控制台弹窗 [appui](appui) 小应用界面(非编辑器工具栏) 预期效果 运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。" + }, + { + "id": "objc-util-module", + "title": "objc_util", + "subtitle": "Objective-C Runtime 调用与系统框架访问。", + "section": "自动化与扩展 / 模块", + "url": "pages/objc-util-module/", + "text": "objc_util Objective-C Runtime 调用与系统框架访问。 自动化与扩展 模块 Runtime ObjC objc util objective-c runtime selector class objc_util Objective C Runtime 访问层:调用系统类、selector、Foundation/UIKit 对象与 block。 边界 : 高级能力入口 ;拍照、网络、定位等优先用已封装模块。UIKit 调用须 @on_main_thread;block 回调用 retain_global 保活。 模块概览 项 说明 导入 `from objc_util import ObjCClass, ns, on_main_thread, ...` 适合做什么 访问未封装系统 API、转换 ObjC 对象、创建 block 调用时机 UI 相关放主线程;长期回调对象要保活 推荐顺序 确认无公开模块 → `ObjCClass` → 调用 selector Selector Python 方法名中 `_` 对应 ObjC 的 `:` 快速开始 读取设备信息: from objc_util import ObjCClass\n\nUIDevice = ObjCClass(\"UIDevice\")\ndevice = UIDevice.currentDevice()\nprint(device.name())\nprint(device.systemVersion()) Python 对象转 Objective C: from objc_util import ObjCClass, ns, nsurl\n\npayload = ns({\"name\": \"Ada\", \"level\": 3})\nurl = nsurl(\"https://www.python.org\")\nprint(url.absoluteString()) AppUI 示例 通过 Runtime 读取设备名(只读、放按钮回调)。 import appui\nfrom objc_util import ObjCClass, on_main_thread\n\nstate = appui.State(\n device_name=\"—\",\n os_version=\"—\",\n status=\"点击读取\",\n)\n\n\n@on_main_thread\ndef read_device():\n UIDevice = ObjCClass(\"UIDevice\")\n device = UIDevice.currentDevice()\n state.device_name = str(device.name())\n state.os_version = str(device.systemVersion())\n state.status = \"已读取\"\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"设备\", [\n appui.Button(\"读取设备信息\", action=read_device)\n .button_style(\"bordered_prominent\"),\n ]),\n appui.Section(\"结果\", [\n appui.LabeledContent(\"名称\", value=state.device_name),\n appui.LabeledContent(\"系统\", value=state.os_version),\n appui.Text(state.status).foreground_color(\"secondaryLabel\"),\n ]),\n ]).navigation_title(\"ObjC Runtime\")\n )\n\n\nappui.run(body, state=state) API 参考 速查 分组 API Runtime `ObjCClass`、`ObjCInstance`、`sel`、`load_framework` 转换 `ns(py_obj)`、`nsurl(url_or_path)`、`nsdata_to_bytes`、`uiimage_to_png` 线程 `on_main_thread(func)` 生命周期 `retain_global(obj)`、`release_global(obj)` Block `ObjCBlock(func, restype=None, argtypes=None)` 结构体 `CGPoint`、`CGRect`、`CGSize`、`UIEdgeInsets`、`NSRange` 等 ObjCClass 与 selector NSDictionary = ObjCClass(\"NSDictionary\")\nobj = NSDictionary.dictionaryWithObject_forKey_(\"value\", \"key\")\n# 对应 dictionaryWithObject:forKey: 主线程 from objc_util import ObjCClass, on_main_thread\n\nUIColor = ObjCClass(\"UIColor\")\n\n@on_main_thread\ndef blue():\n return UIColor.systemBlueColor() 常用转换 函数 用途 `ns({\"a\": 1})` dict/list/str → Foundation 对象 `nsurl(\"https://...\")` 创建 NSURL `nsdata_to_bytes(data)` NSData → bytes 常见错误 错误写法 后果 修正 未封装能力直接用 Runtime 脆弱、难维护 优先 [photos](photos-module)、[network](network-module) 等 UIKit 非主线程调用 崩溃或无效 `@on_main_thread` block 未 `retain_global` 回调失效 保活到流程结束 猜测 selector 签名 运行时错误 对照 Apple 文档逐项验证 调用私有 API 审核风险 仅用公开 API 相关文档 文档 用途 [vision](vision-module) OCR(内部可用 objc_util) [appui](appui) 声明式 UI,替代手写 UIKit [原生能力入口](miniapp-native-capabilities) 已封装模块索引 预期效果 运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。" + }, + { + "id": "shortcuts-module", + "title": "shortcuts", + "subtitle": "运行快捷指令、打开 URL、进入设置。", + "section": "自动化与扩展 / 模块", + "url": "pages/shortcuts-module/", + "text": "shortcuts 运行快捷指令、打开 URL、进入设置。 自动化与扩展 模块 Run URL Settings shortcuts 快捷指令模块 url settings run shortcut shortcuts 系统快捷指令与外链:运行快捷指令、打开 URL、进入本 App 设置页。 边界 :run_shortcut() / open_url() 会 离开当前 App 或跳转系统界面,必须由用户点击触发,不要在 body() 或页面加载时自动调用。run_shortcut(name) 依赖用户在「快捷指令」App 中已存在 同名 指令。 模块概览 项 说明 导入 `import shortcuts` 适合做什么 衔接系统自动化、打开网页/Deep Link、跳转设置 调用时机 按钮回调里调用 推荐顺序 确认用户意图 → `run_shortcut` / `open_url` / `open_settings` → 更新状态 返回值 三个 API 均返回 `bool` 表示是否成功发起 快速开始 下面脚本打开网页并进入 App 设置: import shortcuts\n\nok = shortcuts.open_url(\"https://www.apple.com\")\nprint(\"打开 URL:\", ok)\n\nok = shortcuts.open_settings()\nprint(\"打开设置:\", ok) 运行已创建的快捷指令(名称须完全一致): import shortcuts\n\nok = shortcuts.run_shortcut(\"每日记录\")\nprint(\"已启动:\", ok) AppUI 示例 外跳操作全部放在按钮回调;结果写回 State。 import appui\nimport shortcuts\n\nstate = appui.State(status=\"等待操作\")\n\n\ndef open_website():\n ok = shortcuts.open_url(\"https://www.apple.com\")\n state.status = \"已打开网页\" if ok else \"打开 URL 失败\"\n\n\ndef open_settings():\n ok = shortcuts.open_settings()\n state.status = \"已打开设置\" if ok else \"打开设置失败\"\n\n\ndef run_daily_log():\n ok = shortcuts.run_shortcut(\"每日记录\")\n state.status = \"已启动快捷指令\" if ok else \"启动失败(检查名称是否存在)\"\n\n\ndef body():\n return appui.NavigationStack(\n appui.Form([\n appui.Section(\"外链\", [\n appui.Button(\"打开 Apple 官网\", action=open_website)\n .button_style(\"bordered_prominent\"),\n appui.Button(\"打开本 App 设置\", action=open_settings),\n ]),\n appui.Section(\"快捷指令\", [\n appui.Button(\"运行「每日记录」\", action=run_daily_log),\n appui.Text(\n \"需先在系统「快捷指令」App 中创建同名指令\"\n ).font(\"caption\").foreground_color(\"secondaryLabel\"),\n ]),\n appui.Section(\"状态\", [\n appui.Text(state.status).foreground_color(\"secondaryLabel\"),\n ], footer=\"所有外跳必须由用户点击触发。\"),\n ]).navigation_title(\"快捷指令\")\n )\n\n\nappui.run(body, state=state) API 参考 速查 API 作用 `run_shortcut(name)` 按名称运行快捷指令 → `bool` `open_url(url)` 打开网页 / Deep Link / App Scheme → `bool` `open_settings()` 打开本 App 系统设置页 → `bool` run_shortcut run_shortcut(name) bool ok = shortcuts.run_shortcut(\"My Workflow\") 会跳转到「快捷指令」App 执行;名称不匹配时可能打开但不执行目标流程。 open_url open_url(url) bool shortcuts.open_url(\"https://example.com\")\nshortcuts.open_url(\"mailto:support@example.com\")\nshortcuts.open_url(\"pythonide://some-path\") # 示例 scheme 支持 https://、mailto:、tel: 及第三方 App 的 URL Scheme。 open_settings open_settings() bool — 打开当前 App 在 iOS 设置中的页面。 常见错误 错误写法 后果 修正 在 `body()` 里 `open_url()` 页面一加载就跳走 放进按钮回调 猜测快捷指令名称 无法执行 使用用户已有的精确名称 用 Shortcuts 做核心业务 难维护、难调试 优先 PythonIDE 原生模块 不告知用户将离开 App 体验突兀 按钮文案写清楚 相关文档 文档 用途 [dialogs](dialogs-module) App 内确认后再外跳 [network](network-module) App 内 HTTP 请求 [原生能力入口](miniapp-native-capabilities) MiniApp 场景配方 预期效果 运行示例后,界面应出现文档描述的目标结果;若与预期不符,先看「失败路径」并按返回值或日志排查。" + } +] diff --git a/docs/donate-qr.jpeg b/docs/donate-qr.jpeg deleted file mode 100644 index 3d54098..0000000 Binary files a/docs/donate-qr.jpeg and /dev/null differ diff --git a/docs/favicon.png b/docs/favicon.png deleted file mode 100644 index a59e509..0000000 Binary files a/docs/favicon.png and /dev/null differ diff --git "a/docs/html\351\242\204\350\247\210.png" "b/docs/html\351\242\204\350\247\210.png" deleted file mode 100644 index e064304..0000000 Binary files "a/docs/html\351\242\204\350\247\210.png" and /dev/null differ diff --git a/docs/i/index.html b/docs/i/index.html new file mode 100644 index 0000000..b3eefc8 --- /dev/null +++ b/docs/i/index.html @@ -0,0 +1,19 @@ + + + + + +微信等 App 内浏览器可能拦截跳转。请使用右上角菜单选择“在默认浏览器中打开”,再点击“在 PythonIDE 中打开”。
掌上的 Python & JavaScript 开发环境
-让编程从电脑走到手机与平板 · Write, Run, Debug on iOS
-不是把电脑版塞进手机,而是专为触摸屏和移动场景重新设计的编程环境。
-代码执行不依赖任何服务器,无网络也能用。
NumPy、pandas、Matplotlib、Pillow、aiohttp 生态等,10–100 倍加速。
常用库开箱即用,搜索 PyPI 在线安装。
Agent 模式可直接协助处理项目任务,改代码、排查问题、配合远程工作流。
灵动岛、Siri 快捷指令、x-callback-url。
Face ID 锁定,本地运行,代码不外传。
Scene 模块 + SpriteKit,物理引擎、粒子、动画。
Python 创建 iOS 主屏幕 Widget,声明式布局。
SSH2、SFTP 与 WebDAV 云盘;一键部署、实时监控、AI 智能运维。

首页 · 文件管理、颜色标记、置顶、搜索

编辑器 · 语法高亮、智能补全、快捷输入栏

控制台 · Rich 彩色输出、多控制台切换

AI Agent · 14 工具自动操作、Diff 对比、一键修复

库管理 · PyPI 搜索安装、热门库分类

工具箱 · 编解码、JSON、API 调试、正则

HTML 预览 · 全屏网页渲染、双指缩放

Markdown 渲染 · 实时渲染、代码块、表格

新建文件 · 多种格式、颜色标记

设置 · 外观、AI 配置、应用锁定
写完脚本直接部署到服务器 — 完整 SSH2 协议,告别 App 切换。
-ANSI 256 色、多主题切换(Dracula / Solarized / Monokai)、命令历史、快捷键栏。
远程浏览、在线编辑、上传下载、创建删除、chmod 权限管理。
选择本地项目一键上传到服务器,自动安装依赖并执行。
CPU、内存、磁盘、网络流量仪表盘,5 秒自动刷新。
生成 Ed25519 / RSA / ECDSA 密钥对,导入导出,SHA256 指纹。
用自然语言协助部署、诊断和维护服务器任务。
内置原生模块与云盘等功能的说明文档在 GitHub 仓库持续更新,以下为常用入口。
- -App Store 评分 · GitHub Star · 应用内捐赠(棒棒糖 / 鸡腿 / 奶茶)
-
-扫码赞赏 · Thank you ♥
-Python,从灵感开始的地方
为 iPhone、iPad 与 Mac 打造的原生 Python 工作空间。编写、运行、学习,并与 AI 一起把想法做出来。
开发者入口
通过一条命令,把 PythonIDE 官方 Skills 添加到你的 Agent 环境。
npx skills add Python-IDE/pythonide-skills -g提前体验 PythonIDE 即将推出的能力,并参与完善下一个版本。
关注开发进展、查看源码、提交问题,并参与项目讨论。
一个工作空间
PythonIDE 把运行、原生界面、AI、文件、包管理与分享放在一起,同时保留 Apple 平台应有的简洁与顺手。
编辑并运行脚本,管理文件与包,让创作留在你正在使用的设备上。
使用平台 AI、自有模型服务或受支持的端侧模型,结合项目上下文解释、修改和构建。
AppUI 将 Python 描述转换为 Apple 平台的原生界面、预览与交互工具。
通过熟悉的系统流程使用本地文件、iCloud 文稿、导入、下载与项目文件夹。
通过明确授权,把脚本连接到快捷指令、小组件、媒体、传感器、网络工具等原生能力。
在带有作者身份、人工审核与记录机制的社区中发布有价值的脚本与作品。
PythonIDE
隐私
本政策说明哪些内容保留在设备上、哪些内容只在你使用在线功能时发送,以及你可以如何控制它们。
本政策适用于 PythonIDE App、pythonide.xin、账户与社区服务、PythonIDE AI 代理及官方支持渠道。你主动选择的第三方服务适用其各自政策。
你的脚本、项目文件、编辑器状态、已安装包信息、书签、下载内容、本地运行输出和大部分偏好设置保存在本地。若你把文稿保存到 iCloud Drive 或其他文件提供商,该提供商会依据你的账户与设置处理它。
+当你使用“通过 Apple 登录”或社区账户功能时,我们可能处理 Apple 用户标识、会话令牌、Apple 提供时的姓名与邮箱、头像、用户名、账户状态,以及维护账户安全与防滥用所需的记录。
+你主动提交的内容可能包括源码、标题、描述、分类、封面或预览素材、作者资料、审核状态、处理原因、使用统计与相关时间。通过审核的内容会公开展示,并可能按界面提示被其他用户复制或导入。
+付款资料由 Apple 处理。PythonIDE 仅处理加载商品、验证购买、恢复权益、同步订阅状态、响应退款、防止滥用与排查问题所需的最少 StoreKit 交易和权益信息,包括商品标识、签名交易数据、交易标识、环境、到期或撤销状态及 App 范围内的计费身份。
+服务器可能处理请求时间、接口路径、响应状态、App 版本、用于账户完整性的设备或安装标识、设备认证结果、额度使用与精简错误信息。这些记录用于运行服务、防欺诈与故障排查,不用于广告画像。
+当你发起在线 AI 请求时,PythonIDE 会发送完成该请求所需的内容。根据你的选择,这可能包括提示词、所选对话历史、所选代码或文件、图片或附件、工具结果、模型设置与生成回复。
+AI 对话与执行轨迹会在本地保存,以便连续使用和排查问题。App 内 AI 分析属于本地运行记录,文件路径会以摘要形式记录而非原文;在线模型提供方可能依据自身政策独立保存数据。发送机密材料前,请阅读专门的《AI 服务与数据使用说明》。
+ +PythonIDE 可向脚本与 AppUI 作品提供可选的原生能力。只有当你或你选择运行的脚本调用相关功能时,系统才会请求权限;拒绝权限会阻止该功能访问受保护资源。
+社区或导入脚本可能请求网络或系统能力。PythonIDE 提供权限边界,但你仍应检查不受信任的代码,并仅授予必要权限。
我们仅会为提供你请求的功能、运行与保护服务、履行法律义务或保护用户与 PythonIDE 而共享必要数据。相关接收方可能包括:
+PythonIDE 不出售个人信息,也不会为定向广告把私有代码或 AI 提示词披露给广告商。
+本地数据会保留到你删除、清除 App 数据或卸载 App 为止,并可能受 iCloud 或文件提供商同步影响。账户与社区数据在账户有效期间保留,之后仅在安全、争议处理、服务完整性或法律义务所需范围内继续保存;备份副本与防滥用记录可能需要额外时间到期。
+你可以在 App 内永久删除 PythonIDE 账户。当前流程会通过 Apple 重新验证身份、撤销有效会话、移除个人资料字段、从正常服务中移除已发布脚本与待审提交,并安排相关清理。删除 PythonIDE 账户不会自动取消 Apple 订阅。
+ +PythonIDE 是通用编程工具,并非面向低龄儿童。若当地法律要求监护人同意,未成年人应在取得同意后使用在线账户、社区、购买与 AI 功能。如你认为未成年人未经适当授权提供了个人信息,请联系我们。
+我们采用传输加密、系统安全能力、StoreKit 签名数据、启用时的设备认证、访问控制与防滥用措施。任何服务都无法保证绝对安全。在线提供方与基础设施可能在你所在地区以外处理数据,并受其条款和适用保障措施约束。
+当功能、服务商或法律要求变化时,我们可能更新本政策。重要变更会更新页面顶部日期,并在适当时通过 App 或网站提示。中英文版本旨在表达同一政策;如文字存在差异,在法律允许范围内以中文版本为准。
+开发者:张文禄 · 邮箱:jinwandalaohu940@gmail.com
安全
PythonIDE 结合 Apple 平台安全、本地优先存储、签名服务请求与审核控制;实际安全仍取决于你选择的代码、服务商与权限。
如你认为发现了 PythonIDE App 或官方服务的漏洞,请在公开披露前私下报告。请包含受影响版本或 URL、影响、完整复现步骤、尽量减少访问真实数据的证明,以及安全联系方式。
不得访问其他用户数据、破坏服务、维持访问、植入恶意软件、实施社会工程、公开凭证或以威胁方式索取报酬。获得足以说明问题的证据后应停止测试。
我们会确认可信报告,依据严重程度与现有信息调查,并在适当时协调合理披露时间。本页不构成漏洞奖金承诺,也不在适用法律已有权利之外额外承诺安全港计划。