阅读视图

发现新文章,点击刷新页面。

支持鸿蒙!开源三个月,uView Pro 开源库近期更新全面大盘点,及未来计划

uView Pro 开源近三个月以来,收到了良好的反馈和迭代。目前 uView Pro 已经迭代了 40+ 个版本,平均每两天就会发布版本,主要是优化性能、新增\增强组件功能、bug修复、兼容性完善等。

所以目前 uView Pro 在稳定性、功能性与跨平台兼容性方面已经有了良好的表现。主要实现了 APP、鸿蒙、微信、支付宝、头条等小程序平台的兼容,后续也会继续进行迭代。

本文基于最近的 changelog 汇总,面向开发者与项目贡献者,系统介绍新增组件、关键修复、工具能力以及如何在项目中快速体验这些特性,并提供示例代码与资源链接,方便你在实际工程中落地使用。

image.png

一、总体概览

目前最新版本(0.3.15 及此前若干小版本)覆盖三大方向:

  • 平台兼容与 bug 修复:适配更多小程序平台(包括鸿蒙/各小程序支持的完善),修复了 canvas 渲染、表单响应、picker 初始化、组件兼容性等若干跨端问题。
  • 新组件与用户体验优化:推出并增强若干特色组件,如 u-fab(悬浮按钮)、u-textu-loading-popupu-textareau-safe-bottomu-status-baru-root-portal,以满足常见 UI 场景需求。
  • 工具链与框架能力:增强 http 插件与 useCompRelation(组件关系管理 Hooks),使业务层网络请求与复杂组件协作更便捷。

接下来我们把重点放在新增与优化的功能、示例使用以及工程实践建议上。

详情可查看官网及近期更新日志:uviewpro.cn/

二、亮点功能与新增组件(逐个拆解)

1) u-fab(悬浮按钮)

简介:u-fab 是面向移动端常见的悬浮操作入口,支持多种预设定位、拖动吸边(autoStick)以及 gap 属性的精细化配置。该组件在交互与无障碍体验上进行了增强,能兼容多端布局差异。

主要特性:

  • 预设 position(如右下、左下、右中等)便于在不同 UI 布局中快速放置。
  • 支持 gap 的对象式配置(top/right/bottom/left),使 demo 与真实项目兼容性更好。
  • autoStick:拖动后自动吸边,提升交互体验。

示例:

示例(Vue 3 Composition API):

<template>
<u-fab position="right-bottom" :gap="gapObj" :draggable="true" :autoStick="true">
<template #default>
<u-button shape="circle" size="mini" type="primary" @click="onFabClick">
                <u-icon name="thumb-up" size="40"></u-icon>
            </u-button>
</template>
</u-fab>
</template>

<script setup lang="ts">
import { ref } from 'vue';
const gapObj = { top: 20, right: 16, bottom: 16, left: 16 };
function onFabClick() {
uni.showToast({ title: '悬浮按钮点击' });
}
</script>

建议:在移动端应结合 safe area(如 u-safe-bottom)与页面常驻按钮布局谨慎使用 u-fab,避免遮挡关键内容。

更多用法请参考文档:uviewpro.cn/zh/componen…

12.png

2) u-text

简介:u-text 提供更灵活的文字样式与插槽支持,能在长文本、富文本展示场景中替代常规标签并统一样式控制。

主要特性:

  • 支持默认插槽与多种文本截断/换行策略。
  • 更友好的样式穿透能力,方便主题化。

示例:

<!-- 主题颜色文字 -->
<u-text text="主色文字" type="primary"></u-text>

<!-- 拨打电话 -->
<u-text mode="phone" text="15019479320"></u-text>

<!-- 日期格式化 -->
<u-text mode="date" text="1612959739"></u-text>

<!-- 超链接 -->
<u-text mode="link" text="Go to uView Pro docs" href="https://uviewpro.cn"></u-text>

<!-- 姓名脱敏 -->
<u-text mode="name" text="张三三" format="encrypt"></u-text>

<!-- 显示金额 -->
<u-text mode="price" text="728732.32"></u-text>

<!-- 默认插槽 -->
<u-text class="desc">这是一个示例文本,支持自定义插槽与样式</u-text>

更多用法请参考文档:uviewpro.cn/zh/componen…

9.png

3) u-loading-popup

简介:一个可配置的加载弹窗组件,支持多种加载风格与遮罩配置,方便替代项目中散落的 loading 逻辑。

示例(最小用法):

<!-- 默认纵向加载 -->
<u-loading-popup v-model="loading" text="正在加载..." />
<!-- 横向加载 -->
<u-loading-popup v-model="loading" direction="horizontal" text="正在加载..." />

更多用法请参考文档:uviewpro.cn/zh/componen…

11.png

4) u-textarea

简介:独立的 u-textarea 组件从 u-input 中拆分而来,增强了字数统计、伸缩、和独立样式控制能力,满足复杂表单与长文本输入场景。

示例:

<!-- 字数统计 -->
<u-textarea v-model="content" :maxlength="500" count />

<!-- 自动高度 -->
<u-textarea v-model="content" placeholder="请输入内容" autoHeight></u-textarea>

更多用法请参考文档:uviewpro.cn/zh/componen…

13.png

5) u-safe-bottom 与 u-status-bar

用途:与设备安全区(notch/safearea)相关的布局组件,用来保证底部/状态栏的展示在不同平台上都不会被遮挡或错位。适配了多端差异(iOS、Android、不同小程序宿主)。

如果有需要,您可以在任何地方引用它,它会自动判断在并且在 IPhone X 等机型的时候,给元素加上一个适当 底部内边距,在 APP 上,即使您保留了原生安全区占位(offset设置为auto),也不会导致底部出现双倍的空白区域,也即 APP 上 offset 设置为 auto 时。

<template>
  <view>
    ......
    <u-safe-bottom></u-safe-bottom>
  </view>
</template>

更多用法请参考文档:uviewpro.cn/zh/componen…

6) u-root-portal

简介:提供将节点传送到根节点的能力(Portal 模式),适用于模态、全局浮层等需要脱离当前 dom 层级的场景,兼容多端实现细节。

根节点传送组件仅支持微信小程序、支付宝小程序、APP和H5平台,组件会自动根据平台选择合适的实现方式:

这类场景最常见的例子就是全屏的模态框。理想情况下,我们希望触发模态框的按钮和模态框本身的代码是在同一个单文件组件中,因为它们都与组件的开关状态有关。

<u-button type="primary" @click="show = true">显示弹窗</u-button>
<u-root-portal v-if="show">
  <view class="modal">
    <view class="modal-content">
      <text>这是一个全局弹窗</text>
      <u-button @click="show = false">关闭</u-button>
    </view>
  </view>
</u-root-portal>

更多用法请参考文档:uviewpro.cn/zh/componen…

7) 自定义主题

uView Pro 目前可以自定主题色,字体颜色,边框颜色等,所有组件内部的样式,都基于同一套主题,比如您修改了primary主题色,所有用到了primary颜色 的组件都会受影响。

由于 uView 官方版本,组件内部存在许多硬编码颜色配置,无法动态根据 scss 变量,现在,我们可以统一跟随主题配置了。

通过官网主题颜色配置完后,在页面底部下载文件,会得到一个名为uview-pro.theme.scssuview-pro.theme.ts的文件。

配置 scss 变量

/* uni.scss */
@import 'uview-pro/theme.scss';

配置 ts 变量

// main.ts
import { createSSRApp } from 'vue'
import App from './App.vue'
import theme from '@/common/uview-pro.theme'
import uViewPro from 'uview-pro'

export function createApp() {
  const app = createSSRApp(App)
  // 引入uView Pro 主库,及theme主题
  app.use(uViewPro, { theme })
  return {
    app
  }
}

以上步骤完成之后,所有颜色均跟随主题色。

更多用法请参考文档:uviewpro.cn/zh/guide/th…

8.png

8) 自定义样式

uView Pro 默认提供了一套美观且统一的组件样式,但在实际项目开发中,往往需要根据业务需求进行个性化定制。参考自定义主题。

然而,如果仅是需要覆盖组件的默认样式,或增加样式,uView Pro 则支持两种主流的自定义样式方式,灵活满足各种场景:

目前,所有组件均支持 custom-class 样式穿透和 custom-style 内联样式

<view class="my-page">
    <!-- custom-class 样式穿透 -->
    <u-button custom-class="my-btn"></u-button>

    <!-- 自定义内联样式 -->
    <u-button
        custom-style="background: linear-gradient(90deg,#2979ff,#00c6ff);color:#fff;border-radius:8px;"
    ></u-button>
</view>

<style lang="scss">
.my-page {
  :deep(.my-btn) {
    background-color: #2979ff;
    color: #fff;
    border-radius: 8px;
  }
}
</style>

更多用法请参考文档:uviewpro.cn/zh/guide/st…

三、工具链改进与新能力

1) http 插件(httpPlugin)

简介:提供统一的请求封装,支持 TypeScript、Vue3、组合式 API,插件化、全局配置、请求/响应拦截器、请求元信息类型(toast/loading 灵活控制),开箱即用,便于在项目中进行全局化网络管理。。

示例:基本请求

import { http } from 'uview-pro'

// GET
http.get('/api/user', { id: 1 }).then(res => {
  /* ... */
})

// POST
http.post('/api/login', { username: 'xx', password: 'xx' }).then(res => {
  /* ... */
})

// PUT/DELETE
http.put('/api/user/1', { name: 'new' })
http.delete('/api/user/1')

高级:支持请求拦截器、全局错误处理与 meta 配置,适合接入鉴权、重试、限流等策略。

最佳实践:定义拦截器配置 => 注册拦截器 => 统一 API 管理

定义拦截器配置

import type { RequestConfig, RequestInterceptor, RequestMeta, RequestOptions } from 'uview-pro'
import { useUserStore } from '@/store'

// 全局请求配置
export const httpRequestConfig: RequestConfig = {
  baseUrl,
  header: {
    'content-type': 'application/json'
  },
  meta: {
    originalData: true,
    toast: true,
    loading: true
  }
}

// 全局请求/响应拦截器
export const httpInterceptor: RequestInterceptor = {
  request: (config: RequestOptions) => {
    // 请求拦截
    return config
  },
  response: (response: any) => {
    // 响应拦截
    return response.data
  }
}

注册拦截器:

import { createSSRApp } from 'vue'
import uViewPro, { httpPlugin } from 'uview-pro'
import { httpInterceptor, httpRequestConfig } from 'http.interceptor'

export function createApp() {
  const app = createSSRApp(App)

  // 注册uView-pro
  app.use(uViewPro)

  // 注册http插件
  app.use(httpPlugin, {
    interceptor: httpInterceptor,
    requestConfig: httpRequestConfig
  })

  return { app }
}

统一 API 管理

// api/index.ts
import { http } from 'uview-pro'

export const login = data => http.post('/api/login', data,  { meta: { loading: true, toast: true } })
export const getUser = id => http.get('/api/user', { id },  { meta: { loading: false } })

以上示例为经典最佳实践,更多用法请查看 http 插件文档:uviewpro.cn/zh/tools/ht…

2) useCompRelation(组件关系管理 Hooks)

目的:替代传统的 provide/inject 在多平台(尤其是一些小程序宿主)可能存在的兼容问题,提供更可靠的父子组件连接和事件广播机制。

应用场景:复杂表单、级联菜单、带有子项动态增删的组件集合等。

父组件示例(伪代码):

import { useParent } from 'uview-pro';

const { children, broadcast } = useParent('u-dropdown');

// 广播调用子组件函数
broadcast('childFunctionName', { payload });

// 收集所有子组件指定值
function getChildrenValues() {
    let values: any[] = [];
    children.forEach((child: any) => {
        if (child.getExposed?.()?.isChecked.value) {
            values.push(child.getExposed?.()?.name);
        }
    });
}

子组件示例(伪代码):

const { parentExposed, emitToParent } = useChildren('u-dropdown-item', 'u-dropdown');

// 触发父组件的函数
emitToParent('parentFunctionName');

// 获取父组件的变量
const activeColor = computed(() => parentExposed.value?.activeColor);

更多用法请参考组件源码:useCompRelation.ts

3) 提供 llms.txt

llms.txt的作用是什么,一般它用来告诉大模型是否允许抓取网站数据用于训练的文件,类似于 robots.txt 控制爬虫权限,因此 uView Pro 也提供了即时更新的 llms.txt 文件,便于训练大模型,更好的为我们服务,链接如下:

uviewpro.cn/llms.txt

uviewpro.cn/llms-full.t…

四、多脚手架支持

1) create-uni

create-uni 提供一键生成、模板丰富的项目引导能力,旨在增强 uni-app 系列产品的开发体验,官网:uni-helper.cn/create-uni/…

pnpm create uni <项目名称> --ts -m pinia -m unocss -u uview-pro -e

表示:

  • 启用 TypeScript
  • 集成 ESLint 代码规范
  • 启用 pinia
  • 集成 unocss
  • 选择 uview-pro组件库

6.png

如果你想用 create-uni 交互式创建一个项目,请执行以下命令:

pnpm create uni

进入交互式选择界面,选择 uView Pro 模板或组件,其他的相关插件可按需选择:

2.png

image.png

使用 create-uni 快速创建 uView Pro Starter 启动模板,请执行以下命令:

pnpm create uni <项目名称> -t uview-pro-starter

4.png

使用 create-uni 快速创建 uView Pro 完整组件演示模板,请执行以下命令:

pnpm create uni <项目名称> -t uview-pro-demo

5.png

2) unibest

unibest 是目前最火的 uni-app 脚手架,它是菲鸽大佬联同众多 uni-app 开发者共同贡献的 uni-app 框架,集成了最新技术栈和开发工具,官网:unibest.tech/

如果你想用 unibest 和 uView Pro 来创建项目,请执行以下命令:

一行代码创建项目:

pnpm create unibest <项目名称> -t base-uview-pro

1.png

交互式创建项目:

pnpm create unibest

选择 base-uview-pro 模板:

3.png

3) 官方cli

第一种:创建以 javascript 开发的工程

npx degit dcloudio/uni-preset-vue#vite my-vue3-project

第二种:创建以 typescript 开发的工程

npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project

引入uview—pro组件库即可,不再过多介绍,可参考快速配置:uviewpro.cn/zh/componen…

五、近期修复若干关键问题

  • u-circle-progress 的 canvas 渲染问题已修复,解决了微信小程序 canvas 2D 在不同平台上下文差异导致的绘制异常。
  • u-form 相关多个修复:处理 model 替换导致校验失效、resetFields 修复、u-form-item 样式与光标问题修复,提升表单在小程序端兼容性。
  • picker、index-list、popup 等组件的跨端兼容修复,减少在头条/支付宝/微信等宿主上的差异表现。

这些修复的综合效果是:在多端使用 uView‑Pro 构建页面时,出现的平台差异与边缘 bug 大幅减少,开发成本降低。

六、跨平台支持说明

当前 uView‑Pro 已兼容并在以下平台进行适配与测试:

  • 鸿蒙(HarmonyOS)
  • Android(原生应用及 WebView)
  • iOS(原生应用及 WebView)
  • 微信小程序
  • 支付宝小程序
  • 头条小程序

后续仍然会对多端小程序兼容性的持续投入,很多修复直接针对宿主差异展开(例如 Canvas 行为、provide/inject 实现差异、样式差异等)。

近期在鸿蒙6.0系统上运行uView Pro源码,效果还不错,如下:

7.png

七、未来计划

根据规划,未来几个方向包括:

  • 持续优化现有组件,新增组件,提升用户体验
  • 国际化(i18n)支持:统一组件的语言切换能力,方便多语言产品线接入。
  • 暗黑模式(Dark Mode):与运行时主题切换能力结合,提供暗色皮肤一键切换体验。
  • 优化现有平台兼容性,扩展更多平台的适配测试(保持对小程序宿主的兼容修复)。
  • uni-app x 支持:目前还在调研中。
  • mcp 支持。

八、结语

如果你在项目中使用到以上组件或工具,并希望参与贡献,请参考仓库的贡献指南。欢迎提 issue、提交 PR,或在插件市场与社区中反馈使用体验。

刚刚,阿里千问 APP 开启公测,要做中国版ChatGPT | 附实测

前几天,阿里「千问计划」被曝光。消息称,他们要基于全球性能第一的开源模型 Qwen3 打造个人 AI 助手「千问 APP」。

而就在刚刚,靴子终于落地。

阿里官宣千问 APP 公测版上线。除了聊天足够聪明外,「能办事」将是千问 APP 未来发展的一个重要方向。

我们也第一时间分别对千问和 ChatGPT 进行了简单的测试,看看这个后来者到底有什么底气。

屋里三盏灯,屋外三个开关,一个开关仅控制一盏灯,屋外看不到屋里。怎样只进屋一次,就知道哪个开关控制哪盏灯?

这是一道经典的逻辑题。两者都给出了正确答案,即利用灯泡的温度差异来判断:

打开一个开关等待几分钟,然后关闭它,再打开第二个开关,进屋后就能通过灯的亮灭和温度判断三个开关。虽说千问的回答更详细一些,但对于这种思路题来说,只要核心逻辑正确即可。这一轮算是打平。

「解释为什么生食和熟食必须使用不同的砧板与刀具。」

这道题就拉开差距了。GPT-5.1 Auto 的回答比较简单,基本是从知识库里挑选了一些标准答案,谈交叉污染、细菌传播等常识,多少有些敷衍的意思。

而千问的表现让人眼前一亮:它懂得主动搜索,引用了 14 篇相对权威的资料信息,甚至还配上了对应的图片、文章等富媒体内容。内容丰富且有据可查。这一轮,千问明显更胜一筹。

最后一道题是个硬核编程题:用一个 HTML 文件实现一个 Game Boy 模拟器,包含俄罗斯方块、宝可梦、塞尔达等经典游戏的全功能模拟,所有按钮可触控也可键盘操作。

▲左为 Qwen,右为 ChatGPT

从最终效果看,两者各有千秋。千问在视觉效果上做得不错,界面还原度挺高,确实抓住了 Game Boy 的感觉。

GPT-5.1 Auto 的界面设计有些敷衍,俄罗斯方块虽然能跑起来,但实际上只有一个方块能移动,功能并没有真正实现。但客观来说,在单个 HTML 文件里完整还原 Game Boy 模拟器本身就是极高难度的任务,两者都没能做到尽善尽美。

这一轮也算平局。

ChatGPT 已经是全球 AI 的代名词,豆包和元宝背靠抖音、微信这种日活数亿的超级应用。千问没有这种「天然流量池」,这就决定了它必须在产品体验上做出显著差异化,才能说服用户专门下载一个新应用。

从产品策略来看,国内外 AI 助手已经走上了不同的路径。

ChatGPT 相对独立,主要靠订阅费和 API 调用盈利。而国内的 AI 助手则更强调生态整合:元宝可以直接在微信内使用,豆包即梦和抖音内容创作工具打通,千问如果成功,可能会直接导向淘宝购物。

这种「AI+X」的组合模式——AI+电商、AI+社交、AI+内容创作,本身就是巨大的商业价值,而不只是依赖订阅费。

实际上,过去阿里的 AI 更多集中在 B 端领域,Qwen 模型技术实力很强,在全球开源社区的下载量已经是第一,但普通消费者感受不到。而在 AI 领域,用户的品牌忠诚度很薄弱,哪个好用就用哪个。

千问的机会窗口可能很窄,如果不能迅速打出知名度,之后再追就更困难了。

不过机会也确实存在。全球范围内,对 AI 产品有需求但被 ChatGPT 的价格或使用门槛挡在外面的用户,其实数量庞大。如果千问能够以更友好的价格、更低的使用门槛、更强的多语言支持,去覆盖这部分市场,可能会找到一个差异化的生存空间。

真正的较量或许才刚刚开始,千问 APP 的推出也意味着阿里真正开始重视 AI 的下半场,即让 AI 走进普通人的日常。而对我们用户来说,全球市场上多一个能打的选手,对所有人都是好事。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


我开发了一款关于病历管理的app:安康记

上线了!上线了!基于Vibe Coding我终于也能独立开发出一款属于自己的app-安康记,目前终于成功上架App Store啦。

独立开发app这个事情我很早之前就有点念头,但学习Swift过程还挺痛苦的,总觉的各种语法糖很膈应,导致进度缓慢,后面就一直搁置了。ChatGPT出来之后也尝试了一点,但还是觉得当时的AI能力不够,并且纯靠聊天还是不太行,自从Cursor、Windsurf、CC、Codex这一类AI开发工具出来之后,Vibe Coding的概念也随之火热,我想也是时候重启自己的开发计划了,花了接近两个月的晚上和周末时间,终于将其开发完成并上架!

App Store地址: 安康记(apps.apple.com/cn/app/%E5%…

appstore

这个app的念头起源于我自己的需求,作为一个动不动就省点小毛病然后还总是往医院跑的人,其实经常是同一个病或者症状接近,但总是忘了此前是如何治疗的,目前经常去的那家医院有个自己的小程序,能看到很多检查和药物记录,但是没有就诊记录,而且如果有时候去了别的医院,那么这些记录也不能放在一起,网上搜了一些关于病历管理的app,要么UI不喜欢要么交互不喜欢,那么是时候自己整起来了!

首先整体功能设计还是得靠自己,中间可以跟AI沟通让他提一些建议,但核心的功能点还是需要自己来出。这个app的核心思想还是: 病历->就诊->药物/检查。 也就是说当你生病时,可以创建一次当前病情的病历,若去医院就诊,则绑定在该次病历下,去医院可能会有药物处方和检查项目,这些均可以记录在案。

app的设计思路整理好了之后,可以通过AI工具先帮你使用前端工具生成一个app的原型图,然后再基于这个原型去使用Swift来开发,这样对于开发的准确效果会比直接跟他说要可控一点,这也是我初期直接让AI上手干发现效果不太好才采用的方案,也推荐大家可以试试这么干。

另外我本人主力使用的AI编辑器是Windsurf,但到中后期的时候我几乎已经完全替换成了Codex,因为我发现codex编写Swift代码的效果竟然比Windsurf要好,哪怕都使用OpenAI的模型,甚至Windsurf使用Claude,也是codex的效果更好,这个其实有点超出我的认知,我也是中间因为Windsurf的点数用完了,临时使用Codex上阵才发现的,但平时工作中开发Java或者Python时,Windsurf的效果还是感觉挺好的,不怎么会使用Codex,如果有使用Claude Code或者Cursor的朋友,也可以说说自己有没有感觉某个工具对于Swift开发更友好。

安康记目前支持创建多个用户,也就是说可以一个人来管理家中多人的病历,对于家中有老人小孩的朋友们还是很有帮助的(PS:多用户需要会员)。另外对于医院科室和医生的管理,原本计划是直接从网上公开数据集中导入一些当前国内的医院科室和医生,但是发现数据量太大,而且需要维护,所以最终还是选择自己手动输入,可以自己先创建好了再用,也可以创建就诊记录的时候快捷创建。对于药物和检查项目还提供了快捷输入功能方便输入一些如用药途径药品规格等常见的信息(PS:快捷输入也需要会员)。而且app的所有数据存储都是基于本地的,只会通过iCloud同步,因此使用者们都无需担心隐私问题,对我个人而言,不用花钱买服务器也是极好的。

setting

目前这个app还只是一个初版,当前的设计流程还欠缺几个功能,包括个人独立用药,即生病了也不一定去医院,可能自己在家吃一些药物,这些药物也应当可以直接记录而不是通过就诊记录。此外这个app还应当会支持体检项目,即专门记录个人阶段性的体检报告。这些功能会放在接下来的版本陆续去更新,另外现在的UI我其实谈不上多满意,但一直没找到好的方向去优化,后面也会继续尝试提升UI上的体验。

下个版本预计会在2-4周左右推出,公众号也会同步推送更新日志,后续也可能会公开需求池和开发计划,希望大家可以下载app进行体验,有什么需求或者建议可以通过留言或在app中的意见反馈中提出。谢谢大家 ღ( ´・ᴗ・` )。

解决 uniapp H5 与原生应用通信的坑:一个经过实战验证的解决方案

解决 uniapp H5 与原生应用通信的坑:一个经过实战验证的解决方案

📖 前言

在开发 uniapp 项目时,你是否遇到过这样的问题:将 uniapp 打包成 H5 后,需要向原生应用发送消息,但发现常用的 uni.postMessagewindow.parent.postMessage 方法都不起作用?

这个问题困扰了我很久,经过大量的测试和验证,我终于找到了唯一有效的解决方案,并基于此封装了自定义的 webUni 通信工具。今天就来分享这个经过实战验证的解决方案。

🔍 问题背景

在混合开发场景中,我们经常需要将 uniapp 打包成 H5,然后嵌入到不同的容器中:

  1. 原生 App 的 WebView:H5 页面需要与原生应用进行通信
  2. 微信小程序的 web-view:H5 页面需要与小程序进行通信

常见的通信需求包括:

  • 关闭 WebView
  • 传递登录信息
  • 同步页面状态
  • 触发原生功能

❌ 常见的失败尝试

在寻找解决方案的过程中,我尝试了多种方法,但都失败了:

方法一:使用 uni.postMessage

// ❌ 在 H5 环境下无法正常工作
uni.postMessage({
  data: {
    action: 'closeWebView'
  }
})

方法二:使用 window.parent.postMessage

// ❌ 在 H5 环境下无法正常工作
window.parent.postMessage({
  type: 'message',
  data: { action: 'closeWebView' }
}, '*')

方法三:使用 plus.webview.postMessage

// ❌ 在 H5 环境下 plus 对象不存在
plus.webview.postMessage({
  data: { action: 'closeWebView' }
})

✅ 唯一有效的解决方案

经过大量测试验证,我找到了不同环境下的有效通信方式:

  1. App 环境:使用 webUni.postMessage 方式
  2. 微信小程序 web-view 环境:使用 wx.miniProgram.postMessage 方式

核心原理

我基于不同环境的通信机制封装了一个统一的工具对象,核心特性包括:

  1. 自动环境检测:自动判断运行环境(App、微信小程序、普通 H5)
  2. 手动指定环境:支持通过参数显式指定目标环境(推荐使用,避免检测问题)
  3. 多端兼容:支持多种原生容器(DCloud、5+ Runtime、微信小程序等)
  4. 统一 API:提供统一的接口,无需关心底层实现
  5. 消息封装:将消息封装成标准格式传递给对应容器

微信小程序特殊处理

重要发现:在微信小程序的 web-view 中,如果只调用 wx.miniProgram.postMessage,消息会在页面销毁后才被小程序接收到。这是微信官方的已知问题。

解决方案:在发送消息后,立即调用 wx.miniProgram.navigateBack(),这样消息会立即被小程序接收。插件提供了 autoNavigateBack 选项来自动处理这个问题。

实现代码(自定义封装摘录)

// ✅ 支持手动指定环境或自动检测环境的完整实现
const webviewPostMessage = {
    // 静态方法:主动检测环境
    detectEnv: function() {
        const env = {};
        // 检测App环境
        if (window.plus) {
            env.plus = true;
            env.app = true;
        }
        // 检测微信小程序环境
        else if (window.wx && window.wx.miniProgram) {
            env.wechat = true;
            env.miniprogram = true;
        }
        // 其他为普通H5环境
        else {
            env.h5 = true;
        }
        return env;
    },
    
    // 发送消息方法(支持手动指定环境)
    postMessage: function(options) {
        try {
            if (!options || typeof options !== 'object') {
                console.warn('[webview-postmessage] postMessage 需要传入一个对象参数');
                return;
            }
            if (!options.data) {
                console.warn('[webview-postmessage] postMessage 需要传入 data 属性');
                return;
            }
            
            // 优先使用手动指定的环境
            const manualEnv = options.env;
            
            // 根据手动指定的环境或自动检测来决定发送方式
            if (manualEnv === 'mp-weixin') {
                console.log('[webview-postmessage] 手动指定微信小程序环境');
                // 强制使用微信小程序方式发送消息
                try {
                    wx.miniProgram.postMessage({ data: options.data });
                    
                    // 如果设置了autoNavigateBack为true,则自动调用navigateBack
                    if (options.autoNavigateBack === true) {
                        setTimeout(() => {
                            wx.miniProgram.postMessage({ data: options.data });
                        }, 100);
                    }
                } catch (wechatError) {
                    console.error('[webview-postmessage] 微信小程序环境发送消息失败:', wechatError);
                    // 失败后尝试回退到原始方式
                    webUni.postMessage(options);
                }
            } else if (manualEnv === 'app') {
                console.log('[webview-postmessage] 手动指定App环境');
                // 强制使用App方式发送消息
                webUni.postMessage(options);
            } else {
                // 自动检测环境(向后兼容)
                if (window.wx && window.wx.miniProgram) {
                    console.log('[webview-postmessage] 自动检测到微信小程序环境');
                    wx.miniProgram.postMessage({ data: options.data });
                    
                    if (options.autoNavigateBack === true) {
                        setTimeout(() => {
                            wx.miniProgram.postMessage({ data: options.data });
                        }, 100);
                    }
                } else {
                    console.log('[webview-postmessage] 自动检测到App或普通H5环境');
                    webUni.postMessage(options);
                }
            }
        } catch (error) {
            console.error('[webview-postmessage] 发送消息失败:', error);
        }
    }
};

// 使用方式1:手动指定环境(推荐)
webviewPostMessage.postMessage({
    data: {
        action: 'closeWebView'
    },
    env: 'app'  // 手动指定环境为App
})

// 使用方式2:手动指定微信小程序环境
webviewPostMessage.postMessage({
    data: {
        action: 'closeWebView'
    },
    env: 'mp-weixin',  // 手动指定环境为微信小程序
    autoNavigateBack: true  // 自动调用navigateBack
})

// 使用方式3:自动检测环境(兼容性模式)
webviewPostMessage.postMessage({
    data: {
        action: 'closeWebView'
    }
})

🚀 自研通信插件(uni_modules 版本)

目前我将这套封装以 uni_modules/webview-postmessage 的形式维护在项目里,尚未发布到 npm 或插件市场。想要使用时,只需把整个目录复制到自己的项目中即可。

获取方式

  1. 在代码仓库中找到 uni_modules/webview-postmessage
  2. 将该目录复制到你项目的 uni_modules
  3. 若需要分享给团队,可直接 zip 打包该目录发送

基础使用

强烈推荐手动指定宿主类型!

import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

// 推荐:手动指定App环境
webviewPostMessage.postMessage({ 
  data: { 
    action: 'closeWebView' 
  },
  env: 'app'  // 手动指定环境为App
})

// 推荐:手动指定微信小程序环境(并自动返回)
webviewPostMessage.postMessage({ 
  data: { 
    action: 'closeWebView' 
  },
  env: 'mp-weixin',  // 手动指定环境为微信小程序
  autoNavigateBack: true  // 自动调用 navigateBack,确保消息立即被接收
})

// 不推荐:自动检测环境(可能存在兼容性问题)
/*
webviewPostMessage.postMessage({ 
  data: { 
    action: 'closeWebView' 
  } 
})
*/

Vue3 Composition API 示例

<template>
  <view>
    <button @click="sendMessage">发送消息</button>
    <button @click="sendMessageAndBack">发送消息并返回</button>
  </view>
</template>

<script setup>
import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

// 普通发送消息 - 推荐手动指定环境类型
const sendMessage = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'closeWebView',
      // 可以传递其他数据
      token: 'xxx',
      userId: 123
    },
    env: 'app'  // 手动指定环境为App
  })
}

// 微信小程序环境:发送后自动返回(推荐手动指定环境类型)
const sendMessageAndBack = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'closeWebView',
      token: 'xxx',
      userId: 123
    },
    env: 'mp-weixin',  // 手动指定环境为微信小程序
    autoNavigateBack: true  // 自动返回,确保消息立即被接收
  })
}
</script>

Vue2 Options API 示例

<template>
  <view>
    <button @click="handleClose">关闭页面</button>
  </view>
</template>

<script>
import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

export default {
  methods: {
    handleClose() {
      webviewPostMessage.postMessage({
        data: {
          action: 'closeWebView'
        },
        env: 'app'  // 推荐:手动指定环境为App
      })
    }
  }
}
</script>

💡 实际应用场景

场景一:关闭 WebView

// 用户点击返回按钮时关闭 WebView - 推荐手动指定环境
const handleBack = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'closeWebView'
    },
    env: 'app'  // 手动指定环境为App
  })
}

场景二:传递登录信息

// 登录成功后,将 token 传递给原生应用 - 推荐手动指定环境
const handleLogin = async (username, password) => {
  const result = await loginAPI(username, password)
  
  webviewPostMessage.postMessage({
    data: {
      action: 'login',
      token: result.token,
      userId: result.userId,
      userInfo: result.userInfo
    },
    env: 'app'  // 手动指定环境为App
  })
}

场景三:同步页面状态

// 页面加载完成后通知原生应用 - 推荐手动指定环境
onMounted(() => {
  webviewPostMessage.postMessage({
    data: {
      action: 'pageLoaded',
      pageId: 'home',
      timestamp: Date.now()
    },
    env: 'app'  // 手动指定环境为App
  })
})

场景四:触发原生功能

// 需要调用原生分享功能 - 推荐手动指定环境
const handleShare = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'share',
      title: '分享标题',
      content: '分享内容',
      url: 'https://example.com'
    },
    env: 'app'  // 手动指定环境为App
  })
}

📋 API 说明

postMessage(options)

向原生应用或微信小程序发送消息(推荐手动指定环境类型)

参数:

参数 类型 必填 说明
options Object 消息选项
options.data Object 要发送的数据对象
options.env String 推荐 手动指定环境类型:'mp-weixin'(微信小程序)、'app'(App环境) - 推荐使用,避免自动检测问题
options.autoNavigateBack Boolean 是否在微信小程序环境中自动调用 navigateBack(解决消息延迟问题,默认 false)

示例:

// App 环境 - 推荐手动指定环境
webviewPostMessage.postMessage({
  data: {
    action: 'customAction',
    type: 'userAction',
    value: 'someValue',
    // 可以传递任意 JSON 可序列化的数据
    nested: {
      key: 'value'
    }
  },
  env: 'app'  // 手动指定环境为App
})

// 微信小程序环境,发送后自动返回 - 推荐手动指定环境
webviewPostMessage.postMessage({
  data: {
    action: 'closeWebView',
    token: 'xxx'
  },
  env: 'mp-weixin',  // 手动指定环境为微信小程序
  autoNavigateBack: true  // 自动调用 navigateBack,确保消息立即被接收
})

⚠️ 注意事项

  1. 适用于 H5 环境:此插件主要用于 uniapp 打包成 H5 后的消息通信

    • App 环境:使用 webUni.postMessage
    • 微信小程序 web-view 环境:使用 wx.miniProgram.postMessage
    • 需要引入 jweixin.js 才能支持微信小程序环境
  2. 必须传递 data 属性:postMessage 方法必须传入包含 data 属性的对象

  3. 数据格式:data 可以是任意 JSON 可序列化的对象

  4. 强烈推荐手动指定宿主类型:为了避免自动环境检测可能出现的问题(如环境特征不明显导致检测失败),强烈推荐通过 env 参数手动指定当前环境类型为 'app''mp-weixin'

  5. 环境选择指南:在 App 中嵌套运行时指定为 'app',在微信小程序 web-view 中运行时指定为 'mp-weixin'

  6. 微信小程序消息延迟问题:在微信小程序的 web-view 中,如果只发送消息不调用 navigateBack,消息会在页面销毁后才被接收。建议使用 autoNavigateBack: true 选项自动处理

  7. 原生应用/小程序需要监听消息:需要在对应端实现消息监听逻辑来接收消息

🧪 测试验证

此插件经过以下环境测试:

  • ✅ uniapp 打包成 H5
  • ✅ App 环境(Android/iOS)
  • ✅ 微信小程序 web-view 环境
  • ✅ 微信浏览器
  • ✅ Chrome 浏览器
  • ✅ Safari 浏览器
  • ✅ Android 浏览器
  • ✅ iOS Safari
  • ✅ Vue2 和 Vue3

❓ 常见问题

Q: 为什么不能使用 uni.postMessage

A: 经过测试,uni.postMessage 在 H5 环境下无法正常工作,只有 webUni.postMessage 方式有效。这是因为 uniapp 在 H5 环境下的消息通信机制与 App 端不同。

Q: 支持哪些平台?

A: 插件支持以下平台:

  • App 环境:使用 webUni.postMessage(DCloud、5+ Runtime)
  • 微信小程序 web-view 环境:使用 wx.miniProgram.postMessage(需要引入 jweixin.js)
  • 普通 H5 环境:使用 webUni.postMessagewindow.parent.postMessage

注意:虽然插件支持自动检测环境,但强烈推荐通过 env 参数手动指定环境类型,以避免检测问题。

Q: 微信小程序中消息为什么在页面销毁后才收到?

A: 这是微信官方的已知问题。在 web-view 中只调用 postMessage 时,消息会被缓存,直到页面销毁才被小程序接收。解决方案是在发送消息后立即调用 wx.miniProgram.navigateBack(),插件提供了 autoNavigateBack: true 选项来自动处理这个问题。

Q: 如何在微信小程序环境中使用?

A: 需要确保在 H5 页面中引入了 jweixin.js(微信 JS-SDK)。可以通过以下方式引入:

<!-- 在 template.h5.html 中引入(Vue2) -->
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>

<!-- 或者在 Vue3 项目中,在 main.js 中动态引入 -->

然后在代码中使用:

import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

// 发送消息并自动返回(解决延迟问题)
webviewPostMessage.postMessage({
  data: { action: 'closeWebView' },
  autoNavigateBack: true
})

Q: 可以传递什么类型的数据?

A: 可以传递任何 JSON 可序列化的数据,包括对象、数组、字符串、数字等。

Q: 原生应用如何接收消息?

A: 原生应用需要在 WebView 中实现消息监听。以 Android 为例:

webView.addJavascriptInterface(new Object() {
    @JavascriptInterface
    public void postMessage(String message) {
        // 处理接收到的消息
        JSONObject data = new JSONObject(message);
        String action = data.optString("action");
        // ... 处理逻辑
    }
}, "webUni");

Q: 微信小程序如何接收消息?

A: 在小程序页面的 web-view 组件上绑定 @message 事件:

<template>
  <web-view :src="url" @message="onMessage"></web-view>
</template>

<script>
export default {
  methods: {
    onMessage(e) {
      // e.detail.data 是一个数组,包含所有发送的消息
      const messages = e.detail.data || [];
      messages.forEach(msg => {
        if (msg.action === 'closeWebView') {
          uni.navigateBack();
        }
        // 处理其他消息...
      });
    }
  }
}
</script>

📦 项目状态

  • 存放位置uni_modules/webview-postmessage
  • 使用方式:直接复制到业务项目的 uni_modules 目录
  • 插件市场:已发布到 uni-app 插件市场
  • 维护方式:通过 changelog.md 记录改动,手动同步到各个项目
  • 最新版本:v1.4.0
  • 最新版本特性:支持手动指定环境类型,优化了错误处理和文档说明

🎯 总结

通过本文的分享,我们了解到:

  1. 问题根源

    • uniapp 在 H5 环境下的消息通信机制与 App 端不同
    • 微信小程序 web-view 环境有特殊的通信方式和延迟问题
  2. 解决方案

    • App 环境:使用 webUni.postMessage
    • 微信小程序环境:使用 wx.miniProgram.postMessage + navigateBack(解决延迟)
    • 支持手动指定环境类型,避免自动检测可能出现的问题
  3. 最佳实践

    • 强烈推荐手动指定环境类型,通过 env 参数明确指定 'app''mp-weixin'
    • 封装统一的 API,自动检测环境作为备用方案
    • 提供 autoNavigateBack 选项解决微信小程序消息延迟问题
    • 以插件形式提供,方便使用和维护

希望这个解决方案能够帮助到遇到同样问题的开发者。如果你觉得有用,欢迎 Star 和分享!

🔗 相关链接


如果这篇文章对你有帮助,欢迎点赞、收藏、评论! 🎉

微信与苹果就小程序支付达成和解,iOS用户有望在小程序内直接使用苹果支付

今日,苹果公司正式发布《小程序合作伙伴计划》,为长期悬而未决的iOS小程序支付问题画上句号。这一官方公告,标志着小程序在苹果生态中的地位获得正式认可,同时也为开发者(宿主App)指明了清晰的合规路径。

图片.png

一、苹果推出《小程序合作伙伴计划》

什么是小程序合作伙伴计划
小程序合作伙伴计划,是苹果制定的一套小程序支付合规流程,面对的对象是宿主App开发者(微信、抖音)。

小程序合作伙伴计划有哪些内容
1、写明了技术侧需要做哪些改造——高级商务API、年龄评级API、苹果内购系统、监听苹果退款。 2、加入该计划需要向苹果提交申请。
3、必须使用苹果内购,支持创建”小程序通用内购商品“,比如创建”6元商品“,宿主App里的小程序都用这个内购商品。内购商品需苹果审核。

该计划的核心就是必须使用苹果支付,以方便苹果对支付进行监管(抽成)。高级商务API就是干这个事情的,宿主App调用高级商务API来完成内购支付。

目前,双方博弈的结果是苹果对宿主App(微信)抽成15%。注意,这是苹果对微信抽成15%,落到小程序开发者那边肯定是大于等于15%的。

二、苹果更新了专门针对小程序的审核条款

苹果为了配合上述内容,同时修改了审核指南,并于今日(11月14日)向全员开发者发送了邮件通知。下面我列出了和小程序有关的改动:

应用程序审查指南已经修订,以支持更新的政策,并提供澄清。 请查看以下更改:
1.2.1(a):此新指南规定,创建者应用程序必须为用户提供一种方法来识别超过应用程序年龄评级的内容,并使用基于验证或声明年龄的年龄限制机制来限制未成年用户访问。
4.7:明确HTML5和JavaScript迷你应用和迷你游戏在指南范围内。
4.7.2:澄清在未经Apple事先许可的情况下,提供未嵌入二进制文件的软件的应用程序不得扩展或向软件公开本机平台api或技术。
4.7.5:澄清提供非嵌入式软件的应用程序必须为用户提供一种方法来识别超出应用程序年龄评级的内容,并使用基于验证或声明年龄的年龄限制机制来限制未成年用户访问。

审核条款主要强调了年龄分级不允许原生代码的热更新。原生代码热更新(OC、Swift层面的,游戏更新资源文件不算)一直是苹果不允许的,小程序有点灰色地带,苹果在这里着重强调一下。

针对小程序的审核条款完整内容,详见苹果审核指南4.7部分

三、微信:将尽快为小程序开发者提供接入服务

《微信公开课》公众号今日也发文回应,”欢迎苹果对小程序和小游戏开发者的支持,乐见苹果推出‘小程序合作伙伴计划’。我们将尽快为开发者提供接入服务,共同建设一个健康繁荣的生态。

微信的回应有点微妙啊。微信用的是”欢迎“,而不是”感谢“,”乐见“这个词更是体现出我只有一丢丢满意。看来微信从实力的角度出发,对这15%不太满意啊。

图片.png

四、小程序开发者需要做什么

暂时不用做什么,上述内容都是苹果和宿主App(微信)之间的事情。

现在只需要等微信那边完成技术改造后,更新小程序的对文档、政策文档,小程序开发者届时在完成适配工作。

改造完成后的流程可能会变成:小游戏内可以直接拉起苹果支付进行付款,开发者和微信分成,微信和苹果进行分成。也不排除另外一种模式:小游戏内直接拉微信支付,微信把支付数据上报给苹果,苹果和微信分成。个人还是觉得走苹果支付的可能性更大一点。

对于上述变动,不知道小程序开发者是高兴呢,还是不高兴呢?

好处是iOS端小程序终于可以正大光明的进行支付了,而不是像之前那样”躲躲藏藏“,这点对于用户体验上肯定是更好了;坏处是,之前用的奇巧淫技不用分成或者分成很低,现在需要额外交至少15%的苹果税。

欢迎在评论区留言,谈谈你的感受。

参考来源
【苹果官方】小程序合作伙伴计划
【苹果官方】苹果审核指南
【公众号】苹果官宣:支持iOS小程序小游戏开通支付,抽成15%

《uni-app跨平台开发完全指南》- 07 - 数据绑定与事件处理

引言:在上一章节中,我们详细介绍了页面路由与导航的相关知识点。今天我们讨论的是数据绑定与事件处理,深入研究数据是如何流动、用户交互如何响应的问题。我们平时用的app比如说输入框中打字,下方实时显示输入内容。这个看似简单的交互背后,隐藏着前端框架的核心思想——数据驱动视图

对比:传统DOM操作 vs 数据驱动

graph TB
    A[传统DOM操作] --> B[手动选择元素]
    B --> C[监听事件]
    C --> D[直接修改DOM]
    
    E[数据驱动模式] --> F[修改数据]
    F --> G[框架自动更新DOM]
    G --> H[视图同步更新]

在传统开发中,我们需要:

// 传统方式
const input = document.getElementById('myInput');
const display = document.getElementById('display');

input.addEventListener('input', function(e) {
    // 手动更新DOM
    display.textContent = e.target.value; 
});

而在 uni-app 中:

<template>
  <input v-model="message">
  <div>{{ message }}</div>
</template>

<script>
export default {
  data() {
    return {
      // 只需关注数据,DOM自动更新
      message: '' 
    }
  }
}
</script>

这种模式的转变,正是现代前端框架的核心突破。下面让我们深入研究其实现原理。


一、响应式数据绑定

1.1 数据劫持

Vue 2.x 使用 Object.defineProperty 定义对象属性实现数据响应式,让我们通过一段代码来加深理解这个机制:

// 响应式原理
function defineReactive(obj, key, val) {
  // 每个属性都有自己的依赖收集器
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log(`读取属性 ${key}: ${val}`)
      // 依赖收集:记录当前谁在读取这个属性
      dep.depend()
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log(`设置属性 ${key}: ${newVal}`)
      if (newVal === val) return
      val = newVal
      // 通知更新:值改变时通知所有依赖者
      dep.notify()
    }
  })
}

// 测试
const data = {}
defineReactive(data, 'message', 'Hello')
data.message = 'World'    // 控制台输出:设置属性 message: World
console.log(data.message) // 控制台输出:读取属性 message: World

1.2 完整的响应式系统架构

graph LR
    A[数据变更] --> B[Setter 触发]
    B --> C[通知 Dep]
    C --> D[Watcher 更新]
    D --> E[组件重新渲染]
    E --> F[虚拟DOM Diff]
    F --> G[DOM 更新]
    
    H[模板编译] --> I[收集依赖]
    I --> J[建立数据与视图关联]

原理说明

  • 当对响应式数据进行赋值操作时,会触发通过Object.defineProperty定义的setter方法。
  • setter首先比较新旧值是否相同,如果相同则直接返回,避免不必要的更新。
  • 如果值发生变化,则更新数据,并通过依赖收集器(Dep)通知所有观察者(Watcher)进行更新。
  • 这个过程是同步的,但实际的DOM更新是异步的,通过队列进行批量处理以提高性能。

1.3 v-model 的双向绑定原理

v-model 不是魔法,而是语法糖:

<!-- 这行代码: -->
<input v-model="username">

<!-- 等价于: -->
<input 
  :value="username" 
  @input="username = $event.target.value"
>

原理分解:

sequenceDiagram
    participant U as 用户
    participant I as Input元素
    participant V as Vue实例
    participant D as DOM视图
    
    U->>I: 输入文字
    I->>V: 触发input事件,携带新值
    V->>V: 更新data中的响应式数据
    V->>D: 触发重新渲染
    D->>I: 更新input的value属性

1.4 不同表单元素的双向绑定

文本输入框

<template>
  <view class="example">
    <text class="title">文本输入框绑定</text>
    <input 
      type="text" 
      v-model="textValue" 
      placeholder="请输入文本"
      class="input"
    />
    <text class="display">实时显示: {{ textValue }}</text>
    
    <!-- 原理展示 -->
    <view class="principle">
      <text class="principle-title">实现原理:</text>
      <input 
        :value="textValue" 
        @input="textValue = $event.detail.value"
        placeholder="手动实现的v-model"
        class="input"
      />
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      textValue: ''
    }
  }
}
</script>

<style scoped>
.example {
  padding: 20rpx;
  border: 2rpx solid #eee;
  margin: 20rpx;
  border-radius: 10rpx;
}
.title {
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
}
.input {
  border: 1rpx solid #ccc;
  padding: 15rpx;
  border-radius: 8rpx;
  margin-bottom: 20rpx;
}
.display {
  color: #007AFF;
  font-size: 28rpx;
}
.principle {
  background: #f9f9f9;
  padding: 20rpx;
  border-radius: 8rpx;
  margin-top: 30rpx;
}
.principle-title {
  font-size: 24rpx;
  color: #666;
  display: block;
  margin-bottom: 15rpx;
}
</style>

单选按钮组

<template>
  <view class="example">
    <text class="title">单选按钮组绑定</text>
    
    <radio-group @change="onGenderChange" class="radio-group">
      <label class="radio-item">
        <radio value="male" :checked="gender === 'male'" /></label>
      <label class="radio-item">
        <radio value="female" :checked="gender === 'female'" /></label>
    </radio-group>
    
    <text class="display">选中: {{ gender }}</text>
    
    <!-- 使用v-model -->
    <text class="title" style="margin-top: 40rpx;">v-model简化版</text>
    <radio-group v-model="simpleGender" class="radio-group">
      <label class="radio-item">
        <radio value="male" /></label>
      <label class="radio-item">
        <radio value="female" /></label>
    </radio-group>
    
    <text class="display">选中: {{ simpleGender }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      gender: 'male',
      simpleGender: 'male'
    }
  },
  methods: {
    onGenderChange(e) {
      this.gender = e.detail.value
    }
  }
}
</script>

<style scoped>
.radio-group {
  display: flex;
  gap: 40rpx;
  margin: 20rpx 0;
}
.radio-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
</style>

复选框数组

<template>
  <view class="example">
    <text class="title">复选框数组绑定</text>
    
    <view class="checkbox-group">
      <label 
        v-for="hobby in hobbyOptions" 
        :key="hobby.value"
        class="checkbox-item"
      >
        <checkbox 
          :value="hobby.value" 
          :checked="selectedHobbies.includes(hobby.value)"
          @change="onHobbyChange($event, hobby.value)"
        /> 
        {{ hobby.name }}
      </label>
    </view>
    
    <text class="display">选中: {{ selectedHobbies }}</text>
    
    <!-- v-model简化版 -->
    <text class="title" style="margin-top: 40rpx;">v-model简化版</text>
    <view class="checkbox-group">
      <label 
        v-for="hobby in hobbyOptions" 
        :key="hobby.value"
        class="checkbox-item"
      >
        <checkbox 
          :value="hobby.value" 
          v-model="simpleHobbies"
        /> 
        {{ hobby.name }}
      </label>
    </view>
    
    <text class="display">选中: {{ simpleHobbies }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      hobbyOptions: [
        { name: '篮球', value: 'basketball' },
        { name: '阅读', value: 'reading' },
        { name: '音乐', value: 'music' },
        { name: '旅行', value: 'travel' }
      ],
      selectedHobbies: ['basketball'],
      simpleHobbies: ['basketball']
    }
  },
  methods: {
    onHobbyChange(event, value) {
      const checked = event.detail.value.length > 0
      if (checked) {
        if (!this.selectedHobbies.includes(value)) {
          this.selectedHobbies.push(value)
        }
      } else {
        const index = this.selectedHobbies.indexOf(value)
        if (index > -1) {
          this.selectedHobbies.splice(index, 1)
        }
      }
    }
  }
}
</script>

<style scoped>
.checkbox-group {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}
.checkbox-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
</style>

二、事件处理

2.1 事件流:从点击到响应

浏览器中的事件流包含三个阶段:

graph TB
    A[事件发生] --> B[捕获阶段 Capture Phase]
    B --> C[目标阶段 Target Phase]
    C --> D[冒泡阶段 Bubble Phase]
    
    B --> E[从window向下传递到目标]
    C --> F[在目标元素上触发]
    D --> G[从目标向上冒泡到window]
解释说明:

第一阶段: 捕获阶段(事件从window向下传递到目标元素) 传递路径:Window → Document → HTML → Body → 父元素 → 目标元素; 监听方式:addEventListener(event, handler, true)第三个参数设为true;

第二阶段: 目标阶段(事件在目标元素上触发处理程序) 事件处理:在目标元素上执行绑定的事件处理函数,无论是否使用捕获模式; 执行顺序:按照事件监听器的注册顺序执行,与捕获/冒泡设置无关;

第三阶段: 冒泡阶段(事件从目标元素向上冒泡到window) 传递路径:目标元素 → 父元素 → Body → HTML → Document → Window; 默认行为:大多数事件都会冒泡,但focus、blur等事件不会冒泡;

2.2 事件修饰符原理详解

事件修饰符

.stop 修饰符原理

// .stop 修饰符的实现原理
function handleClick(event) {
  // 没有.stop时,事件正常冒泡
  console.log('按钮被点击')
  // 事件会继续向上冒泡,触发父元素的事件处理函数
}

function handleClickWithStop(event) {
  console.log('按钮被点击,但阻止了冒泡')
  event.stopPropagation() 
  // 事件不会继续向上冒泡
}

事件修饰符对照表

修饰符 原生JS等价操作 作用 使用场景
.stop event.stopPropagation() 阻止事件冒泡 点击按钮不触发父容器点击事件
.prevent event.preventDefault() 阻止默认行为 阻止表单提交、链接跳转
.capture addEventListener(..., true) 使用捕获模式 需要在捕获阶段处理事件
.self if (event.target !== this) return 仅元素自身触发 忽略子元素触发的事件
.once 手动移除监听器 只触发一次 一次性提交按钮

2.3 综合案例

<template>
  <view class="event-demo">
    <!-- 1. .stop修饰符 -->
    <view class="demo-section">
      <text class="section-title">1. .stop 修饰符 - 阻止事件冒泡</text>
      <view class="parent-box" @click="handleParentClick">
        <text>父容器 (点击这里会触发)</text>
        <button @click="handleButtonClick">普通按钮</button>
        <button @click.stop="handleButtonClickWithStop">使用.stop的按钮</button>
      </view>
      <text class="log">日志: {{ logs }}</text>
    </view>

    <!-- 2. .prevent修饰符 -->
    <view class="demo-section">
      <text class="section-title">2. .prevent 修饰符 - 阻止默认行为</text>
      <form @submit="handleFormSubmit">
        <input type="text" v-model="formData.name" placeholder="请输入姓名" />
        <button form-type="submit">普通提交</button>
        <button form-type="submit" @click.prevent="handlePreventSubmit">
          使用.prevent的提交
        </button>
      </form>
    </view>

    <!-- 3. .self修饰符 -->
    <view class="demo-section">
      <text class="section-title">3. .self 修饰符 - 仅自身触发</text>
      <view class="self-demo">
        <view @click.self="handleSelfClick" class="self-box">
          <text>点击这个文本(自身)会触发</text>
          <button>点击这个按钮(子元素)不会触发</button>
        </view>
      </view>
    </view>

    <!-- 4. 修饰符串联 -->
    <view class="demo-section">
      <text class="section-title">4. 修饰符串联使用</text>
      <view @click="handleChainParent">
        <button @click.stop.prevent="handleChainClick">
          同时使用.stop和.prevent
        </button>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      logs: [],
      formData: {
        name: ''
      }
    }
  },
  methods: {
    handleParentClick() {
      this.addLog('父容器被点击')
    },
    handleButtonClick() {
      this.addLog('普通按钮被点击 → 会触发父容器事件')
    },
    handleButtonClickWithStop() {
      this.addLog('使用.stop的按钮被点击 → 不会触发父容器事件')
    },
    handleFormSubmit(e) {
      this.addLog('表单提交,页面可能会刷新')
    },
    handlePreventSubmit(e) {
      this.addLog('使用.prevent,阻止了表单默认提交行为')
      // 这里可以执行自定义的提交逻辑
      this.submitForm()
    },
    handleSelfClick() {
      this.addLog('.self: 只有点击容器本身才触发')
    },
    handleChainParent() {
      this.addLog('父容器点击事件')
    },
    handleChainClick() {
      this.addLog('按钮点击,但阻止了冒泡和默认行为')
    },
    addLog(message) {
      this.logs.unshift(`${new Date().toLocaleTimeString()}: ${message}`)
      // 只保留最近5条日志
      if (this.logs.length > 5) {
        this.logs.pop()
      }
    },
    submitForm() {
      uni.showToast({
        title: '表单提交成功',
        icon: 'success'
      })
    }
  }
}
</script>

<style scoped>
.event-demo {
  padding: 20rpx;
}
.demo-section {
  margin-bottom: 40rpx;
  padding: 20rpx;
  border: 1rpx solid #e0e0e0;
  border-radius: 10rpx;
}
.section-title {
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
  font-size: 28rpx;
}
.parent-box {
  background: #f5f5f5;
  padding: 20rpx;
  border-radius: 8rpx;
}
.log {
  display: block;
  background: #333;
  color: #0f0;
  padding: 15rpx;
  border-radius: 6rpx;
  font-family: monospace;
  font-size: 24rpx;
  margin-top: 15rpx;
  max-height: 200rpx;
  overflow-y: auto;
}
.self-box {
  background: #e3f2fd;
  padding: 30rpx;
  border: 2rpx dashed #2196f3;
}
</style>

三、表单数据处理

3.1 复杂表单设计

graph TB
    A[表单组件] --> B[表单数据模型]
    B --> C[验证规则]
    B --> D[提交处理]
    
    C --> E[即时验证]
    C --> F[提交验证]
    
    D --> G[数据预处理]
    D --> H[API调用]
    D --> I[响应处理]
    
    E --> J[错误提示]
    F --> J

3.2 表单案例

<template>
  <view class="form-container">
    <text class="form-title">用户注册</text>
    
    <!-- 用户名 -->
    <view class="form-item" :class="{ error: errors.username }">
      <text class="label">用户名</text>
      <input 
        type="text" 
        v-model="formData.username" 
        placeholder="请输入用户名"
        @blur="validateField('username')"
        class="input"
      />
      <text class="error-msg" v-if="errors.username">{{ errors.username }}</text>
    </view>

    <!-- 邮箱 -->
    <view class="form-item" :class="{ error: errors.email }">
      <text class="label">邮箱</text>
      <input 
        type="text" 
        v-model="formData.email" 
        placeholder="请输入邮箱"
        @blur="validateField('email')"
        class="input"
      />
      <text class="error-msg" v-if="errors.email">{{ errors.email }}</text>
    </view>

    <!-- 密码 -->
    <view class="form-item" :class="{ error: errors.password }">
      <text class="label">密码</text>
      <input 
        type="password" 
        v-model="formData.password" 
        placeholder="请输入密码"
        @blur="validateField('password')"
        class="input"
      />
      <text class="error-msg" v-if="errors.password">{{ errors.password }}</text>
    </view>

    <!-- 性别 -->
    <view class="form-item">
      <text class="label">性别</text>
      <radio-group v-model="formData.gender" class="radio-group">
        <label class="radio-item" v-for="item in genderOptions" :key="item.value">
          <radio :value="item.value" /> {{ item.label }}
        </label>
      </radio-group>
    </view>

    <!-- 兴趣爱好 -->
    <view class="form-item">
      <text class="label">兴趣爱好</text>
      <view class="checkbox-group">
        <label 
          class="checkbox-item" 
          v-for="hobby in hobbyOptions" 
          :key="hobby.value"
        >
          <checkbox :value="hobby.value" v-model="formData.hobbies" /> 
          {{ hobby.label }}
        </label>
      </view>
    </view>

    <!-- 提交按钮 -->
    <button 
      @click="handleSubmit" 
      :disabled="!isFormValid"
      class="submit-btn"
      :class="{ disabled: !isFormValid }"
    >
      {{ isSubmitting ? '提交中...' : '注册' }}
    </button>

    <!-- 表单数据预览 -->
    <view class="form-preview">
      <text class="preview-title">表单数据预览</text>
      <text class="preview-data">{{ JSON.stringify(formData, null, 2) }}</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        username: '',
        email: '',
        password: '',
        gender: 'male',
        hobbies: ['sports']
      },
      errors: {
        username: '',
        email: '',
        password: ''
      },
      isSubmitting: false,
      genderOptions: [
        { label: '男', value: 'male' },
        { label: '女', value: 'female' },
        { label: '其他', value: 'other' }
      ],
      hobbyOptions: [
        { label: '运动', value: 'sports' },
        { label: '阅读', value: 'reading' },
        { label: '音乐', value: 'music' },
        { label: '旅行', value: 'travel' },
        { label: '游戏', value: 'gaming' }
      ]
    }
  },
  computed: {
    isFormValid() {
      return (
        !this.errors.username &&
        !this.errors.email &&
        !this.errors.password &&
        this.formData.username &&
        this.formData.email &&
        this.formData.password &&
        !this.isSubmitting
      )
    }
  },
  methods: {
    validateField(fieldName) {
      const value = this.formData[fieldName]
      
      switch (fieldName) {
        case 'username':
          if (!value) {
            this.errors.username = '用户名不能为空'
          } else if (value.length < 3) {
            this.errors.username = '用户名至少3个字符'
          } else {
            this.errors.username = ''
          }
          break
          
        case 'email':
          if (!value) {
            this.errors.email = '邮箱不能为空'
          } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
            this.errors.email = '邮箱格式不正确'
          } else {
            this.errors.email = ''
          }
          break
          
        case 'password':
          if (!value) {
            this.errors.password = '密码不能为空'
          } else if (value.length < 6) {
            this.errors.password = '密码至少6个字符'
          } else {
            this.errors.password = ''
          }
          break
      }
    },
    
    async handleSubmit() {
      // 提交前验证所有字段
      this.validateField('username')
      this.validateField('email')
      this.validateField('password')
      
      // 报错直接返回
      if (this.errors.username || this.errors.email || this.errors.password) {
        uni.showToast({
          title: '请正确填写表单',
          icon: 'none'
        })
        return
      }
      
      this.isSubmitting = true
      
      try {
        // 接口调用
        await this.mockApiCall()
        
        uni.showToast({
          title: '注册成功',
          icon: 'success'
        })
        
        // 重置表单
        this.resetForm()
        
      } catch (error) {
        uni.showToast({
          title: '注册失败',
          icon: 'error'
        })
      } finally {
        this.isSubmitting = false
      }
    },
    
    mockApiCall() {
      return new Promise((resolve) => {
        setTimeout(() => {
          console.log('提交的数据:', this.formData)
          resolve()
        }, 2000)
      })
    },
    
    resetForm() {
      this.formData = {
        username: '',
        email: '',
        password: '',
        gender: 'male',
        hobbies: ['sports']
      }
      this.errors = {
        username: '',
        email: '',
        password: ''
      }
    }
  }
}
</script>

<style scoped>
.form-container {
  padding: 30rpx;
  max-width: 600rpx;
  margin: 0 auto;
}
.form-title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  margin-bottom: 40rpx;
  color: #333;
}
.form-item {
  margin-bottom: 30rpx;
}
.label {
  display: block;
  margin-bottom: 15rpx;
  font-weight: 500;
  color: #333;
}
.input {
  border: 2rpx solid #e0e0e0;
  padding: 20rpx;
  border-radius: 8rpx;
  font-size: 28rpx;
}
.form-item.error .input {
  border-color: #ff4757;
}
.error-msg {
  color: #ff4757;
  font-size: 24rpx;
  margin-top: 8rpx;
  display: block;
}
.radio-group {
  display: flex;
  gap: 40rpx;
}
.radio-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
.checkbox-group {
  display: flex;
  flex-wrap: wrap;
  gap: 20rpx;
}
.checkbox-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
  min-width: 150rpx;
}
.submit-btn {
  background: #007AFF;
  color: white;
  border: none;
  padding: 25rpx;
  border-radius: 10rpx;
  font-size: 32rpx;
  margin-top: 40rpx;
}
.submit-btn.disabled {
  background: #ccc;
  color: #666;
}
.form-preview {
  margin-top: 50rpx;
  padding: 30rpx;
  background: #f9f9f9;
  border-radius: 10rpx;
}
.preview-title {
  font-weight: bold;
  margin-bottom: 20rpx;
  display: block;
}
.preview-data {
  font-family: monospace;
  font-size: 24rpx;
  color: #666;
  word-break: break-all;
}
</style>

四、组件间通信-自定义事件

4.1 自定义事件原理

4.2 以计数器组件为例

<!-- 子组件:custom-counter.vue -->
<template>
  <view class="custom-counter">
    <text class="counter-title">{{ title }}</text>
    
    <view class="counter-controls">
      <button 
        @click="decrement" 
        :disabled="currentValue <= min"
        class="counter-btn"
      >
        -
      </button>
      
      <text class="counter-value">{{ currentValue }}</text>
      
      <button 
        @click="increment" 
        :disabled="currentValue >= max"
        class="counter-btn"
      >
        +
      </button>
    </view>
    
    <view class="counter-stats">
      <text>最小值: {{ min }}</text>
      <text>最大值: {{ max }}</text>
      <text>步长: {{ step }}</text>
    </view>
    
    <!-- 操作 -->
    <view class="quick-actions">
      <button @click="reset" size="mini">重置</button>
      <button @click="setToMax" size="mini">设为最大</button>
      <button @click="setToMin" size="mini">设为最小</button>
    </view>
  </view>
</template>

<script>
export default {
  name: 'CustomCounter',
  props: {
    // 当前值
    value: {
      type: Number,
      default: 0
    },
    // 最小值
    min: {
      type: Number,
      default: 0
    },
    // 最大值
    max: {
      type: Number,
      default: 100
    },
    // 步长
    step: {
      type: Number,
      default: 1
    },
    // 标题
    title: {
      type: String,
      default: '计数器'
    }
  },
  data() {
    return {
      currentValue: this.value
    }
  },
  watch: {
    value(newVal) {
      this.currentValue = newVal
    },
    currentValue(newVal) {
      // 设置限制范围
      if (newVal < this.min) {
        this.currentValue = this.min
      } else if (newVal > this.max) {
        this.currentValue = this.max
      }
    }
  },
  methods: {
    increment() {
      const newValue = this.currentValue + this.step
      if (newValue <= this.max) {
        this.updateValue(newValue)
      }
    },
    
    decrement() {
      const newValue = this.currentValue - this.step
      if (newValue >= this.min) {
        this.updateValue(newValue)
      }
    },
    
    updateValue(newValue) {
      this.currentValue = newValue
      
      // 触发自定义事件,通知父组件
      this.$emit('input', newValue)  // 用于 v-model
      this.$emit('change', {         // 用于普通事件监听
        value: newValue,
        oldValue: this.value,
        type: 'change'
      })
    },
    
    reset() {
      this.updateValue(0)
      this.$emit('reset', { value: 0 })
    },
    
    setToMax() {
      this.updateValue(this.max)
      this.$emit('set-to-max', { value: this.max })
    },
    
    setToMin() {
      this.updateValue(this.min)
      this.$emit('set-to-min', { value: this.min })
    }
  }
}
</script>

<style scoped>
.custom-counter {
  border: 2rpx solid #e0e0e0;
  border-radius: 15rpx;
  padding: 30rpx;
  margin: 20rpx 0;
  background: white;
}
.counter-title {
  font-size: 32rpx;
  font-weight: bold;
  text-align: center;
  display: block;
  margin-bottom: 25rpx;
  color: #333;
}
.counter-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 30rpx;
  margin-bottom: 25rpx;
}
.counter-btn {
  width: 80rpx;
  height: 80rpx;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 36rpx;
  font-weight: bold;
}
.counter-value {
  font-size: 48rpx;
  font-weight: bold;
  color: #007AFF;
  min-width: 100rpx;
  text-align: center;
}
.counter-stats {
  display: flex;
  justify-content: space-around;
  margin-bottom: 25rpx;
  padding: 15rpx;
  background: #f8f9fa;
  border-radius: 8rpx;
}
.counter-stats text {
  font-size: 24rpx;
  color: #666;
}
.quick-actions {
  display: flex;
  justify-content: center;
  gap: 15rpx;
}
</style>

4.3 父组件使用

<!-- 父组件:parent-component.vue -->
<template>
  <view class="parent-container">
    <text class="main-title">自定义计数器组件演示</text>
    
    <!-- 方式1:使用 v-model -->
    <view class="demo-section">
      <text class="section-title">1. 使用 v-model 双向绑定</text>
      <custom-counter 
        v-model="counter1" 
        title="基础计数器"
        :min="0" 
        :max="10"
        :step="1"
      />
      <text class="value-display">当前值: {{ counter1 }}</text>
    </view>
    
    <!-- 方式2:监听 change 事件 -->
    <view class="demo-section">
      <text class="section-title">2. 监听 change 事件</text>
      <custom-counter 
        :value="counter2"
        title="高级计数器"
        :min="-10"
        :max="20"
        :step="2"
        @change="onCounterChange"
      />
      <text class="value-display">当前值: {{ counter2 }}</text>
      <text class="event-log">事件日志: {{ eventLog }}</text>
    </view>
    
    <!-- 方式3:监听多个事件 -->
    <view class="demo-section">
      <text class="section-title">3. 监听多个事件</text>
      <custom-counter 
        v-model="counter3"
        title="多功能计数器"
        @reset="onCounterReset"
        @set-to-max="onSetToMax"
        @set-to-min="onSetToMin"
      />
      <text class="value-display">当前值: {{ counter3 }}</text>
    </view>
    
    
    <view class="demo-section">
      <text class="section-title">4. 计数器联动</text>
      <custom-counter 
        v-model="masterCounter"
        title="主计数器"
        @change="onMasterChange"
      />
      <custom-counter 
        :value="slaveCounter"
        title="从计数器"
        :min="0"
        :max="50"
        readonly
      />
    </view>
  </view>
</template>

<script>
import CustomCounter from '@/components/custom-counter.vue'

export default {
  components: {
    CustomCounter
  },
  data() {
    return {
      counter1: 5,
      counter2: 0,
      counter3: 10,
      masterCounter: 0,
      slaveCounter: 0,
      eventLog: ''
    }
  },
  methods: {
    onCounterChange(event) {
      console.log('计数器变化事件:', event)
      this.counter2 = event.value
      this.addEventLog(`计数器变化: ${event.oldValue}${event.value}`)
    },
    
    onCounterReset(event) {
      console.log('计数器重置:', event)
      this.addEventLog(`计数器重置为: ${event.value}`)
    },
    
    onSetToMax(event) {
      console.log('设置为最大值:', event)
      this.addEventLog(`设置为最大值: ${event.value}`)
    },
    
    onSetToMin(event) {
      console.log('设置为最小值:', event)
      this.addEventLog(`设置为最小值: ${event.value}`)
    },
    
    onMasterChange(event) {
      this.slaveCounter = Math.floor(event.value / 2)
    },
    
    addEventLog(message) {
      const timestamp = new Date().toLocaleTimeString()
      this.eventLog = `${timestamp}: ${message}\n${this.eventLog}`
      
      // 增进日志长度
      if (this.eventLog.split('\n').length > 5) {
        this.eventLog = this.eventLog.split('\n').slice(0, 5).join('\n')
      }
    }
  }
}
</script>

<style scoped>
.parent-container {
  padding: 30rpx;
  max-width: 700rpx;
  margin: 0 auto;
}
.main-title {
  font-size: 40rpx;
  font-weight: bold;
  text-align: center;
  margin-bottom: 40rpx;
  color: #333;
  display: block;
}
.demo-section {
  margin-bottom: 50rpx;
  padding: 30rpx;
  border: 2rpx solid #e0e0e0;
  border-radius: 15rpx;
  background: #fafafa;
}
.section-title {
  font-size: 28rpx;
  font-weight: bold;
  color: #007AFF;
  display: block;
  margin-bottom: 25rpx;
}
.value-display {
  display: block;
  text-align: center;
  font-size: 28rpx;
  margin-top: 20rpx;
  color: #333;
}
.event-log {
  display: block;
  background: #333;
  color: #0f0;
  padding: 20rpx;
  border-radius: 8rpx;
  font-family: monospace;
  font-size: 22rpx;
  margin-top: 15rpx;
  white-space: pre-wrap;
  max-height: 200rpx;
  overflow-y: auto;
}
</style>

五、性能优化

5.1 数据绑定性能优化

graph TB
    A[性能问题] --> B[大量数据响应式]
    A --> C[频繁的重新渲染]
    A --> D[内存泄漏]
    
    B --> E[Object.freeze 冻结数据]
    B --> F[虚拟滚动]
    
    C --> G[计算属性缓存]
    C --> H[v-once 单次渲染]
    C --> I[合理使用 v-if vs v-show]
    
    D --> J[及时销毁事件监听]
    D --> K[清除定时器]

5.2 优化技巧

<template>
  <view class="optimization-demo">
    <text class="title">性能优化</text>
    
    <!-- 1. 计算属性缓存 -->
    <view class="optimization-section">
      <text class="section-title">1. 计算属性 vs 方法</text>
      <input v-model="filterText" placeholder="过滤文本" class="input" />
      
      <view class="result">
        <text>过滤后数量(计算属性): {{ filteredListLength }}</text>
        <text>过滤后数量(方法调用): {{ getFilteredListLength() }}</text>
      </view>
      
      <button @click="refreshCount">刷新计数</button>
      <text class="hint">打开控制台查看调用次数</text>
    </view>
    
    <!-- 2. v-once 静态内容优化 -->
    <view class="optimization-section">
      <text class="section-title">2. v-once 静态内容</text>
      <view v-once class="static-content">
        <text>这个内容只渲染一次: {{ staticTimestamp }}</text>
      </view>
      <button @click="updateStatic">更新静态内容(不会变化)</button>
    </view>
    
    <!-- 3. 大数据列表优化 -->
    <view class="optimization-section">
      <text class="section-title">3. 大数据列表渲染</text>
      <button @click="loadBigData">加载1000条数据</button>
      <button @click="loadOptimizedData">加载优化后的数据</button>
      
      <!-- 普通渲染 -->
      <view v-if="showNormalList">
        <text>普通渲染({{ normalList.length }}条):</text>
        <view v-for="item in normalList" :key="item.id" class="list-item">
          <text>{{ item.name }}</text>
        </view>
      </view>
      
      <!-- 虚拟滚动优化 -->
      <view v-if="showOptimizedList">
        <text>虚拟滚动渲染({{ optimizedList.length }}条):</text>
        <view class="virtual-list">
          <view 
            v-for="item in visibleItems" 
            :key="item.id" 
            class="list-item optimized"
          >
            <text>{{ item.name }}</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      filterText: '',
      refreshCount: 0,
      staticTimestamp: new Date().toLocaleTimeString(),
      normalList: [],
      optimizedList: [],
      showNormalList: false,
      showOptimizedList: false,
      visibleItems: [],
      bigData: []
    }
  },
  computed: {
    // 计算属性会自动缓存,只有依赖变化时才重新计算
    filteredListLength() {
      console.log('计算属性被执行')
      const list = this.generateTestList()
      return list.filter(item => 
        item.name.includes(this.filterText)
      ).length
    }
  },
  methods: {
    // 方法每次调用都会执行
    getFilteredListLength() {
      console.log('方法被调用')
      const list = this.generateTestList()
      return list.filter(item => 
        item.name.includes(this.filterText)
      ).length
    },
    
    generateTestList() {
      return Array.from({ length: 100 }, (_, i) => ({
        id: i,
        name: `项目 ${i}`
      }))
    },
    
    refreshCount() {
      this.refreshCount++
    },
    
    updateStatic() {
      this.staticTimestamp = new Date().toLocaleTimeString()
    },
    
    loadBigData() {
      this.showNormalList = true
      this.showOptimizedList = false
      
      // 生成大量数据
      this.normalList = Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        name: `数据项 ${i}`,
        value: Math.random() * 1000
      }))
    },
    
    loadOptimizedData() {
      this.showNormalList = false
      this.showOptimizedList = true
      
      // 使用 Object.freeze 避免不必要的响应式
      this.optimizedList = Object.freeze(
        Array.from({ length: 1000 }, (_, i) => ({
          id: i,
          name: `数据项 ${i}`,
          value: Math.random() * 1000
        }))
      )
      
      // 虚拟滚动:只渲染可见项
      this.updateVisibleItems()
    },
    
    updateVisibleItems() {
      // 简化的虚拟滚动实现
      this.visibleItems = this.optimizedList.slice(0, 20)
    },
    
    // 防抖函数优化频繁触发的事件
    debounce(func, wait) {
      let timeout
      return function executedFunction(...args) {
        const later = () => {
          clearTimeout(timeout)
          func(...args)
        }
        clearTimeout(timeout)
        timeout = setTimeout(later, wait)
      }
    }
  },
  
  // 组件销毁时清理资源
  beforeDestroy() {
    this.normalList = []
    this.optimizedList = []
    this.visibleItems = []
  }
}
</script>

<style scoped>
.optimization-demo {
  padding: 30rpx;
}
.title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  display: block;
  margin-bottom: 40rpx;
}
.optimization-section {
  margin-bottom: 40rpx;
  padding: 30rpx;
  border: 1rpx solid #ddd;
  border-radius: 10rpx;
}
.section-title {
  font-weight: bold;
  color: #007AFF;
  display: block;
  margin-bottom: 20rpx;
}
.input {
  border: 1rpx solid #ccc;
  padding: 15rpx;
  border-radius: 6rpx;
  margin-bottom: 15rpx;
}
.result {
  margin: 15rpx 0;
}
.result text {
  display: block;
  margin: 5rpx 0;
}
.hint {
  font-size: 24rpx;
  color: #666;
  display: block;
  margin-top: 10rpx;
}
.static-content {
  background: #e8f5e8;
  padding: 20rpx;
  border-radius: 6rpx;
  margin: 15rpx 0;
}
.list-item {
  padding: 10rpx;
  border-bottom: 1rpx solid #eee;
}
.list-item.optimized {
  background: #f0f8ff;
}
.virtual-list {
  max-height: 400rpx;
  overflow-y: auto;
}
</style>

总结

通过以上学习,我们深入掌握了 uni-app 中数据绑定与事件处理的核心概念:

  1. 响应式原理:理解了 Vue 2.x 基于 Object.defineProperty 的数据劫持机制
  2. 双向绑定v-model 的本质是 :value + @input 的语法糖
  3. 事件系统:掌握了事件流、修饰符及其底层实现原理
  4. 组件通信:通过自定义事件实现子父组件间的数据传递
  5. 性能优化:学会了计算属性、虚拟滚动等优化技巧

至此数据绑定与时间处理就全部介绍完了,如果觉得这篇文章对你有帮助,别忘了一键三连~~~ 遇到任何问题,欢迎在评论区留言讨论。Happy Coding!

不止腾讯!苹果应用生态罕见开放,小程序终于「合法」存在

苹果终于对「小程序」松手了。

就在刚刚,苹果悄然发布了一个可能会改变应用生态的计划——小程序合作伙伴计划(Mini Apps Partner Program),正式宣布为小程序建立制度化的合规框架。

「小程序」是介于网页与 App 之间的轻应用形态——用 HTML5 和 JavaScript 写成,即开即用,轻得像一张网页,却能完成一次支付、一次服务流程、一次游戏体验。

 

另昨晚彭博社报道,腾讯与苹果达成协议,苹果将可以从微信小程序和小游戏中抽成 15%。小程序合作伙伴计划得以在中国推行,与该协议的关系巨大。

小程序的商业化曾被挡在 iOS 门外

在中国,小程序在微信、抖音、支付宝里已经演化出数以亿计的日活,早已成为移动互联网的基础设施。但在 iOS 上,它一直活得很「边缘」。

开发者可以用 HTML5 把它嵌进一个 App 里,却无法触达系统能力:不能用苹果的内购体系、不能管理年龄层级、不能透明呈现商品目录,也无法建立稳健商业模式。

能做的,往往只有一件事——靠广告吃饭。

轻游戏只能加激励视频,工具类只能上 banner。想要靠内容或服务收费却几乎没有路径。更别说要搞生态、搞分发、搞平台,几乎是天方夜谭。

苹果过去并不欢迎「App 里的 App」,也不希望任何超级 App 在 iOS 内部复制一个「小程序平台」。这让许多开发者困在尴尬地带:小程序的商业化是刚需,但 iOS 的制度无法容纳。

直到今天。

小程序的形态,在苹果那里第一次被正式写下来

在这次发布中,苹果第一次给出官方表达:Mini App 是「基于 Web 技术,如 HTML5 和 JavaScript 构建的自包含体验」,它不直接出现在 App Store,而是分发在一个更大的宿主 App 内,让用户无需安装即可访问内容、服务或小游戏。

也就是说,小程序在 iOS 上终于不是灰色地带,而是被承认、被规范、被商业化的应用形态。

奇妙的是,它看起来像中国人早已熟悉的小程序,但逻辑完全是「苹果式的」。

它所有规则、能力、开放边界都基于苹果过去几年陆续构建的一套基础 API:包括集成高级商务 API、采用声明年龄范围 API。

「Advanced Commerce API」高级商务 API 是苹果过去一年最重要的商业基础设施,专为「内容目录庞大、复杂、动态」的 App 设计。小程序使用它意味着:支付必须走苹果官方通道,商品、价格、内容目录必须透明,购买记录、退款、消费状态必须可追踪。这给了小程序正式商业化能力,也给了苹果监管底层数据的方式

此外,宿主应用必须支持「Declared Age Range API」来标注年龄范围。过去小程序全靠宿主 App 的年龄分级,而现在系统允许一个 13+ 的 App 托管一个 18+ 的小游戏,只要它使用 Declared Age Range API 进行自动识别与访问控制。系统会在不暴露隐私的前提下根据用户年龄自动放行或拦截,让开发者能提供真正适龄的内容。

另外,苹果还要求宿主 App 必须同时提供 iOS 与 iPadOS 版本——也就是说,一个想托管小程序的平台型应用,不能只在 iPhone 上存在,而必须在 iPad 上保持完整体验。这既保证了用户在不同设备上的一致性,也让小程序的分发能力不再被设备类型割裂。

这套政策组合,几乎等于把小程序拉进了 App Store 的监管系统里。

焦点来了:苹果把抽成直接降到 15%

当宿主 App 满足了苹果上述所有要求,它就能加入 Mini Apps Partner Program。而苹果给的回报非常直接:

小程序(由其他开发者制作)的数字商品收入,只有 15% 抽成。

 

考虑到传统 App 内购抽成是 30%,而小程序等轻量应用的收入通常来自长尾的服务或小游戏,抽成直接腰斩,会对开发者产生立竿见影的吸引力。

苹果为什么在这个时刻「放行」?

从苹果的角度看,这一步其实是多重力量共同作用的结果。

监管当然是最现实的一条。欧盟的 DMA 要求平台开放支付、开放分发,苹果需要在不失去生态控制权的前提下提供更灵活的应用形态。小程序让用户可以在不侧载、不跳出 App Store 系统的情况下,获得更轻的服务;开发者可以在一个更低成本的体系里分发功能;而所有的支付、审核和关键 API,仍然牢牢掌握在苹果手中。

这是一种「可控的开放」。

另一股力量来自应用形态本身。全球的超级 App 都在向平台化演变,必然会在它们内部生长出轻应用层。

过去苹果最担心的,是任何一个 App 变成「App Store 内的 App Store」,削弱系统层的主导权。但当生态已经走到这一步,它选择的不是继续阻挡,而是把规则写清楚、把入口设好:你可以托管小程序,但你必须接入我的年龄机制、支付机制、审核机制,你必须告诉我用户买了什么、退了什么,你必须用我能理解、能监管的方式运行。

第三股力量更隐蔽,却可能影响更长远 —— AI。

应用正在被拆解成一个个可调度的 action,而 Mini App 的轻量特征,恰好让它成为系统级 AI 最容易调用的能力单元。当未来的 Siri、未来的系统级智能助手帮用户完成任务时,它们不需要唤醒一个完整 App,有一个小程序式的组件就够了:订一张票、查一个订单、完成一次支付、打开一段小游戏。

小程序不是未来界面的终点,但它很可能是未来「意图层」和「功能层」之间的桥。

对行业有什么影响?

这是一次看似温和、实则深远的变化。Web 技术在移动端沉寂已久,如今又被苹果重新推回舞台:轻量工具、便民服务、小型功能闭环、休闲 H5 游戏,都将迎来新的分发路径。如果说十年前乔布斯说「Web App 才是未来」是一个过早的预言,那么今天,它终于有了重写的可能性。

小游戏行业也会被直接改写。过去 HTML5 游戏在 iOS 上几乎没有商业化空间,如今只要挂靠在宿主应用里,就能用 Web 技术运行,用苹果的支付变现,用 15% 的抽成回收收益,这将让大量国内轻游戏团队在全球市场重新获得机会。

甚至连微信的小程序国际化,也因此出现了新的可能:微信国际版如果选择接入这个计划,就能够在苹果允许的框架下托管小程序,并使用苹果支付来结算。小程序的海外模式,也许会因为这次变化而迎来新的版本。

苹果真正想构建的是新的「生态层级」

所有路径最终都会回到苹果那里。小程序不是另一个微信生态,它是苹果为自己的生态重建的「第二层」:上承 AI,下接 App,轻量、可控、可审查,既吸收来自监管和行业的外部压力,也为未来的交互方式预留了足够的空间。

小程序最终还是进入了 iOS,只是它进入的是一个被苹果重新设计过的世界。

在中国,小程序已经是基础设施;在苹果这里,它也许会成为一个新层级:

原生 App 是厚重的、深能力的层;Mini App 是轻量、快速、可分发的层;而最上层,是苹果正在押注的 AI 调度。

苹果并没有复制微信的小程序生态,而是在 iOS 的技术传统里重新发明了一个轻应用世界。它既像是为监管准备的缓冲区,也像是为 AI 时代铺好的底层胶合层。

而这一切,从一个曾经被迫靠广告维生的小程序,获得正式身份开始。

从技术的旁观者与记录者,成为技术影响生活方式的实践者。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


淘宝 9 块 9 的 DeepSeek,撕开了魔幻世界的一角

前言

现在的世界是越来越魔幻,早就超出了常人的理解范畴。

我最近老刷淘宝,昨天不知哪来的兴致,没什么特定缘由,纯粹好奇当下的相关生态,便鬼使神差在搜索栏敲了 “DeepSeek”。翻了没两下,一家店的标题瞬间抓住了他的眼球 ——“无需部署、打开即用,全程不卡顿”,售价才 10 块钱,付款人数却已经破了 1000。

盯着页面琢磨了半天,越想越纳闷:不用买家本地部署,还能保证立刻响应,这到底是什么神仙操作?

要知道,DeepSeek 的热度虽已过去半年,但直到现在,偶尔还是能见到这玩意儿的身影,甚至可能让人猛地想起当初被它支配的日子。

于是点进去了,这数据还确实挺好的,DeepSeek的需求还是旺。图片

按捺不住满心的好奇,我干脆直接下了单、付了款。

收到货后

可等卖家发来所谓的 “产品”,当场就懵了 —— 那瞬间的冲击感,让差点以为自己不小心闯进了平行宇宙,满脑子都是 “这到底是啥?!”

图片

我第一反应直接懵了:这难道是遇到活菩萨了?居然自己部署了 DeepSeek,还开了公网供大家免费似的用?这算力得有多足啊?

有这资源,哥们儿直接去卖算力不比这 10 块钱一单赚得多?

可等定睛一看,瞬间愣住了 —— 这网址怎么这么眼熟?再仔细瞅了瞅,好家伙,居然是n.cn?!

**这不是...360的纳米搜索???图片

我点开另一个网址。

图片

我一时间无语凝噎,我的大脑宕机了整整10秒钟。我想到了这个事情比较抽象,但是,我没想到能抽象到这种地步。因为,反差感极强,我本来会以为,哥们就卖链接,肯定不少差评,但,事情不太一样了起来。这个评论区,几乎全是好评。

图片

甚至有300个88VIP的好评。我一条一条的看了下去,我感觉,这个世界在我的脑中,好像更魔幻,更立体了。有用户留言说,确实不卡,输出的代码很规范。图片

“可以啊,是免费的r1,跟描述一致,性价比很高,不用买会员了也。” 图片

**不用买会员了。我不知道你们是什么感受,我突然鼻子一酸。我看到的,忽然是一个个非常活生生、立体形象的人。

或许对于这些Ta们来说:白天在公司,他得忍着老板的 PUA 强撑着干活,连反驳的底气都没有 —— 这份薪资微薄的工作,是他在四五线小城唯一的糊口依靠。

晚上回到十几平米的出租屋,狭小的空间里塞满了不甘。他太想改变命运了,太怕一辈子困在原地,所以总琢磨着学门新本事,抓住点什么。

AI、大模型、席卷时代的技术革命,这些词像针一样扎着他的心。他知道这是翻身的机会,可每一次听说,都伴随着深深的恐慌 —— 怕自己追不上这股浪潮,怕被时代彻底抛弃。

可真要迈出脚步,才发现前路全是望不到头的坎:官网得靠 “魔法上网” 才能访问,他摸不着门道;会员订阅几十上百块一个月,抵得上他好几天的饭钱,根本舍不得花;那些部署教程里的术语,像天书一样晦涩,硬生生把他挡在门外,连入门的缝隙都不给留。

好不容易扒到 DeepSeek 和 ChatGPT 的官网,他咬碎了牙才掏出不便宜的会员费,以为终于摸到了门槛。可实际用起来,却满是失望 —— 不仅卡顿得让人抓狂,历史记录里还莫名冒出不属于自己的内容,钱花得冤枉又憋屈。

一次次尝试,一次次被现实打回原形。他就像个被遗弃在站台外的人,眼睁睁看着时代的列车呼啸而过,自己却连上车的资格都没有。那种求而不得的无力感,沉甸甸压在胸口,让他喘不过气。

直到那天在淘宝刷到那个 9.9 元的商品,“无需部署”“打开即用”“立刻响应” 这几个字,像黑夜里的一束光,瞬间照亮了他的渴望。

他反复看了好几遍商品页面,纠结了很久 —— 兜里的钱每一分都要算计,他怕这又是一场骗局,怕最后连这两顿拼好饭的钱都打了水漂。可对改变的渴望,终究压过了所有顾虑。

下单后,他攥着手机等回复,收到可直接点开的链接时,手指都带着点颤抖。怀着忐忑点进去,居然真的能用,还异常流畅。

那一刻,他紧绷的肩膀突然垮了下来,长长舒了一口气,眼眶甚至有点发热。他觉得自己终于花最少的钱,攥住了那张三寸见方的、通往新世界的船票。

满心欢喜地跑到评论区,他认认真真打下 “性价比很高,不用买会员了”,字里行间全是满足。他甚至偷偷窃喜,觉得自己占了天大的便宜,用 9.9 元就撬动了原本遥不可及的生产力工具。

他把这个链接小心翼翼收进浏览器书签,像珍藏一件稀世珍宝。这是他在残酷生活里,拼尽全力才抓住的小小捷径,是支撑他继续往前走的一点希望。

可他不知道,这份被他视若珍宝的 “机会”,本就明晃晃摊在阳光下,对所有人免费开放。他只是恰好站在了信息的阴影里,没见过那片触手可及的光明,只能靠着这点 “微光”,笨拙又执着地追赶着时代的脚步。

AI的发展,永不止步。 愿所有人都能跟上时代的洪流,用自己信息差打出一片新的天地!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

猿族代码战记:Mutex 升级版——守护 Swift 并发的“香蕉仓库”

在这里插入图片描述

🦍 引子

旧金山废墟的猿族技术区,金属支架撑起的荧光屏泛着冷光,首席 Swift 架构师科巴的指节因攥紧终端而发白 —— 食物计数系统又出问题了。

在这里插入图片描述

刚录入的 27 根香蕉,刷新页面竟变成 29,再点一下又跳回 28,旁边年轻猿工程师紧张地挠着头:“科巴大人,不会是小猩猩偷偷改数据吧?” 科巴瞪了他一眼:“是‘并发幽灵’!自从用 Actor 保护状态,简单的计数全成了麻烦 —— 查个库存要写await,就像咱们去仓库拿根香蕉,得先找凯撒签字、找后勤登记,折腾半小时!”

在本堂猩猩课堂中,您将学到如下内容:

  • 🦍 引子
  • 🛡️ 第一章:Actor 的麻烦 —— 被异步绑架的简单需求
  • 🔧 第二章:Mutex 实战 —— 零 bug 香蕉计数器
  • 📱 第三章:适配 SwiftUI—— 让 @Observable “看见” 变化
  • ⚔️ 第四章:抉择 ——Mutex vs Actor
  • 🌟 结尾:代码丛林的生存法则

今天,他要拿出压箱底的 “轻量武器” Mutex,让代码既能挡住并发风险,又能像猿族奔袭般迅猛如潮。

🛡️ 第一章:Actor 的麻烦 —— 被异步绑架的简单需求

科巴拉过一把生锈的金属椅坐下,指尖在键盘上敲出 Actor 代码:“你们看,要改香蕉数量,必须写await counter.addBanana()—— 就一个破赋值操作,硬生生被拖进异步队列!

他顿了顿,指着屏幕上的@MainActor标签,“就算把计数器绑在主线程,其他哨站的猿想查库存,还是得等主线程‘有空’—— 这和把仓库钥匙只给主营地的猿,其他猿只能站在门口等,有啥区别?”

在这里插入图片描述

旁边的猿工程师小声问:“那以前咱们是怎么处理的?”

“以前靠 GCD 串行队列!” 科巴一拍桌子,“就像给仓库配个专属管理员,所有拿香蕉的请求都排队,谁也别插队。但队列太重了,现在 Swift 出了 Mutex—— 它是‘轻量级锁’,只护一小块状态,操作完自动解锁,还不用写一堆异步代码!”

在这里插入图片描述

🔧 第二章:Mutex 实战 —— 零 bug 香蕉计数器

科巴清了清嗓子,手指在键盘上飞快跳动,边写边讲解:“Mutex 的核心是withLock方法 —— 它会先‘抢锁’,确保当前只有一个线程能操作状态,操作完不管成功或失败,都会自动‘释放锁’,绝不会像手动加锁那样,忘了解锁导致整个系统卡死。”

在这里插入图片描述

很快,FoodCounter类的代码出现在屏幕上:

class FoodCounter {
    // 初始化 Mutex,把初始香蕉数设为0——相当于给空仓库装了把新锁
    private let mutex = Mutex(0)
    
    // 增加香蕉:开锁、给库存+1、自动锁门
    func addBanana() {
        mutex.withLock { count in
            count += 1 // 操作超简单,就像把香蕉放进仓库,一秒搞定
        }
    }
    
    // 减少香蕉:逻辑和加香蕉一样,只是把+1改成-1
    func removeBanana() {
        mutex.withLock { count in
            count -= 1
        }
    }
    
    // 读取库存:重点!读操作也要走 withLock,防止读的时候正好在写(比如刚加了半根香蕉)
    var bananaCount: Int {
        mutex.withLock { count in
            return count // 只读取,不修改,但也要保证独占访问
        }
    }
}

“千万别犯懒!” 科巴突然提高声音,“有猿觉得‘读操作不用锁’,结果读的时候正好赶上写,拿到的是‘脏数据’—— 上次有个猿没加锁读库存,以为还有 10 根香蕉,结果实际只剩 2 根,导致整个哨站的猿饿了半天!”

在这里插入图片描述

他演示了如何使用计数器,代码简洁得让猿工程师们发出惊叹:

let counter = FoodCounter()

counter.bananaCount = 10 // 直接赋值,不用等异步
print(counter.bananaCount) // 立刻输出10,没有半点延迟
counter.addBanana()

print(counter.bananaCount) // 输出11,实时更新

在这里插入图片描述

📱 第三章:适配 SwiftUI—— 让 @Observable “看见” 变化

正当猿族为新计数器欢呼时,负责 SwiftUI 仪表盘的猿跑过来:“科巴大人,计数器接入界面后,香蕉数变了,界面却一动不动!” 科巴凑过去看了眼平板 —— 屏幕上的数字始终停留在 10,哪怕点了 “加香蕉” 按钮也没反应。

在这里插入图片描述

“这是因为 @Observable ‘瞎’了!” 科巴很快找到问题,“Mutex 保护的是内部的库存数,库存变了,但 Mutex 本身没变化 ——@Observable 只能‘看见’对象属性的直接修改,看不到 Mutex 里面的小动作。”

他伸手在键盘上敲了几行代码,给bananaCountgetset加了 “传令兵”:

@Observable
final class FoodCounter: Sendable { // 加Sendable,允许计数器跨线程传递
    private let mutex = Mutex(0)
    
    var bananaCount: Int {
        get {
            // 告诉@Observable:“有人在读香蕉数量啦,记下来!”
            self.access(keyPath: \.bananaCount)
            return mutex.withLock { $0 }
        }
        set {
            // 告诉@Observable:“香蕉数量要变了,准备更新界面!”
            self.withMutation(keyPath: \.bananaCount) {
                mutex.withLock { count in
                    count = newValue
                }
            }
        }
    }
    
    // 省略addBanana和removeBanana...
}

“这俩方法是 @Observable 宏自动加的‘钩子’,” 科巴解释,“access告诉框架‘有人在读数据’,withMutation告诉框架‘数据要改了’—— 这样界面就能跟 Mutex 里的库存同步,点一下按钮,数字立刻更新,童叟无欺!”

在这里插入图片描述

⚔️ 第四章:抉择 ——Mutex vs Actor

科巴把猿族工程师召集到一起,在黑板上画了张对比表,用炭笔重重标出关键差异:

对比维度 Mutex(轻量锁) Actor(异步卫士)
代码风格 同步代码,不用写await,清爽直接 强制异步,处处要await,略显繁琐
适用场景 保护 1-2 个简单属性(如计数)、操作耗时极短 保护复杂对象(如网络管理器)、操作耗时较长(如下载图片)
线程行为 抢不到锁会 “阻塞”(等锁释放) 抢不到隔离权会 “挂起”(不阻塞线程)
学习成本 低,API 简单,上手快 高,要理解隔离域、Sendable 等概念

“选哪个不是看‘谁更强’,而是看‘谁更适合’!” 科巴敲了敲黑板,“如果你的需求像‘数香蕉’一样简单,不想写一堆异步代码,就用 Mutex—— 它是‘贴身短刀’,快准狠;如果你的需求是‘跟人类服务器同步数据’,要处理一堆异步逻辑,就用 Actor—— 它是‘坚固盾牌’,能扛住复杂并发。”

在这里插入图片描述

他顿了顿,补充道:“我通常会两种都试一下,哪个写起来顺手就用哪个。比如这次的计数器,用 Mutex 写出来的代码比 Actor 简洁一半,还不用处理异步等待,那肯定选 Mutex 啊!”

🌟 结尾:代码丛林的生存法则

科巴把最后一行代码提交到猿族的代码仓库,终端屏幕上的香蕉计数稳定跳动 —— 从 100 跳到 101,又跳到 102,那是远方哨站的猿刚入库的香蕉,正通过 Mutex 守护的代码,实时同步到主营地的仪表盘。

在这里插入图片描述

他走到窗边,看着外面:凯撒正带领年轻的猿族围着平板学习 Swift,阳光透过废墟的缝隙洒在他们身上,像给代码世界镀上了一层金光。

科巴拉过身边的年轻猿工程师,指着屏幕上的 Mutex 代码说:“咱们猿族在丛林里生存,不会拿长矛去抓兔子,也不会拿匕首去对付狮子 —— 代码世界也一样,没有‘最强的工具’,只有‘最适合当下的工具’。Mutex 是短刀,适合近距离快速解决问题;Actor 是盾牌,适合抵御大规模的并发攻击。懂取舍,会选工具,才是真 - 正的工程师。”

在这里插入图片描述

平板上的计数又跳了一下,这次是 103—— 猿族的食物储备越来越多,他们的 Swift 代码,也在 Mutex 和 Actor 的守护下,越来越稳固。

那么,各位微秃小猩猩,你们学“废”了吗?感谢观看,下次再会啦!8-)

Thread.sleep 与 Task.sleep 终极对决:Swift 并发世界的 “魔法休眠术” 揭秘

在这里插入图片描述

📜 引子:霍格沃茨的 “并发魔咒” 危机

在霍格沃茨城堡顶层的 “魔法程序与咒语实验室” 里,金色的阳光透过彩绘玻璃洒在悬浮的魔法屏幕上。哈利・波特正对着一段闪烁着蓝光的 Swift 代码抓耳挠腮,罗恩在一旁急得直戳魔杖 —— 他们负责的 “魁地奇赛事实时计分器” 又卡住了。

赫敏抱着厚厚的《Swift 并发魔法指南》凑过来,眉头紧锁:“肯定是上次加的‘休眠咒语’出了问题!我早就说过 Thread.sleep 像‘摄魂怪的拥抱’,会吸干线程的活力,你们偏不信!

在这里插入图片描述

这时,实验室的门 “吱呀” 一声开了,负责教授高阶魔法编程的菲尼亚斯・奈杰勒斯・布莱克(没错,就是那位爱吹牛的前校长幽灵)飘了进来,黑袍在空气中划出一道残影。

在本堂魔法课中,您将学到如下内容:

  • 📜 引子:霍格沃茨的 “并发魔咒” 危机
  • 🧙‍♂️ 开篇:“休眠魔咒” 的污名化 ——Task.sleep 真不是 “过街老鼠”
  • 🔍 迷雾初探:为何 Task.sleep 总出现在 “实用魔咒” 里?
  • 🧵 核心奥秘:任务与线程的 “从属关系”—— 不是 “替代”,而是 “调度”
  • 💤 危险实验:Thread.sleep 的 “沉睡诅咒”—— 吸干线程,卡住全局
  • ✨ 救赎之光:Task.sleep 的 “智能休眠”—— 只停任务,不放线程
  • 📜 终极戒律:Swift 并发的 “不可违背法则”—— 避坑指南
  • 🌟 结尾:魔法与代码的共通之道 —— 细节定成败

“一群小笨蛋,连‘休眠魔咒’的门道都没摸清,还想搞定魁地奇的实时数据?今天就给你们上一课 ——Thread.sleep 和 Task.sleep 的终极区别,搞懂了它,你们的计分器才能像火弩箭一样流畅!”


🧙‍♂️ 开篇:“休眠魔咒” 的污名化 ——Task.sleep 真不是 “过街老鼠”

在 Swift 魔法世界里,“让代码暂停执行” 这事儿,历来被视为 “禁忌操作”—— 毕竟谁也不想自己的魔法程序突然 “卡壳”,就像罗恩上次在魔药课上把坩埚炸了一样狼狈。

但菲尼亚斯的第一句话就颠覆了众人认知:“别一提‘休眠’就谈虎色变!你们总觉得 Task.sleep 和 Thread.sleep 是一丘之貉,其实前者根本没你们想的那么‘不靠谱’,今天咱们就扒掉它俩的‘魔法伪装’,看看谁才是真正的‘捣蛋鬼’。”

首先得明确一点:在 Swift 里让代码 “歇口气” 的法子不止一种,但 Thread.sleep 早就因为 “破坏力太强” 而被老法师们拉入了 “慎用清单”。而 Task.sleep 呢?虽然也常被用来实现 “防抖”(比如防止用户疯狂点击魁地奇计分按钮)或 “任务超时”(比如等待球员数据加载的时限),却总因为和 Thread.sleep 沾了 “sleep” 二字,被不少新手当成 “洪水猛兽”。

在这里插入图片描述

“这就像把‘荧光闪烁’和‘阿瓦达索命’归为一类 —— 纯属谬以千里!” 菲尼亚斯敲了敲魔法屏幕,上面立刻浮现出两行发光的文字,“关键区别,全藏在 Swift 并发世界里‘任务’和‘线程’的运作逻辑里,这可是你们之前逃课没学的重点!

🔍 迷雾初探:为何 Task.sleep 总出现在 “实用魔咒” 里?

哈利举手提问:“教授,我上次在论坛上看别人写‘魁地奇进球防抖’的代码,十篇有九篇用了 Task.sleep,这是为啥呀?”

菲尼亚斯飘到哈利身边,用魔杖一点屏幕,一段代码立刻跳了出来:

// 魁地奇进球防抖逻辑:防止用户1秒内重复点击“进球”按钮
func handleGoalTap() {
    // 先取消之前可能还在等待的任务(类似“解除旧咒语”)
    currentDebounceTask?.cancel()
    // 新建一个任务,让它“休眠”1秒后再执行真正的计分逻辑
    currentDebounceTask = Task {
        do {
            // Task.sleep 的参数是纳秒,这里1_000_000_000纳秒 = 1秒
            // 重点:这里休眠的是“任务”,不是“线程”!
            try await Task.sleep(nanoseconds: 1_000_000_000)
            // 休眠结束后,执行计分(比如格兰芬多得分+10)
            updateScore(for: .gryffindor, points: 10)
        } catch {
            // 如果任务被取消(比如用户1秒内又点了一次),就不执行计分
            print("防抖任务被取消,避免重复计分")
        }
    }
}

“看到没?” 菲尼亚斯的声音里带着得意,“这种场景下,Task.sleep 就像‘时间转换器’—— 让任务先‘暂停’一会儿,既不会耽误其他代码运行,还能精准控制逻辑触发时机。要是换成 Thread.sleep,你们的计分器早就像被施了‘石化咒’一样动不了了!”

在这里插入图片描述

🧵 核心奥秘:任务与线程的 “从属关系”—— 不是 “替代”,而是 “调度”

要搞懂两者的区别,首先得打破一个 “根深蒂固” 的误区 —— 很多新手以为 “Swift 并发里,任务取代了线程”,就像当年巫师用魔杖取代了木棍一样。

菲尼亚斯听到这话,差点笑出了幽灵特有的 “滋滋” 声:“简直是无稽之谈!任务和线程根本不是‘替代关系’,而是‘调度与被调度’的关系,就像魁地奇比赛里,球员(任务)需要骑着扫帚(线程)才能上场,你能说球员取代了扫帚吗?”

在这里插入图片描述

他用魔杖在空中划出两张魔法图,左边是 “无 Swift 并发时代”,右边是 “Swift 并发时代”:

时代 调度工具 执行载体 核心逻辑
无 Swift 并发 Dispatch Queues(飞路网信使队列) Thread(魔法信使) 用 “飞路网队列” 给 “魔法信使” 分配任务,信使跑完一个再跑下一个
Swift 并发 Task(魔法任务卷轴) Thread(魔法信使) 用 “任务卷轴” 给 “魔法信使” 分配任务,信使可以随时切换卷轴,不用等一个跑完

简单说,以前是‘一个信使只能扛一个包裹’,现在是‘一个信使能扛多个包裹,还能随时换着扛’!” 菲尼亚斯解释道,“不管有没有 Swift 并发,你们都不用直接‘创造信使’(管理线程)—— 以前靠‘飞路网队列’安排信使干活,现在靠‘任务’安排。这才是正确的‘心智模型’,要是理解错了,后面的内容就像听‘蛇佬腔’一样难懂!”

💤 危险实验:Thread.sleep 的 “沉睡诅咒”—— 吸干线程,卡住全局

为了让大家直观感受 Thread.sleep 的 “破坏力”,菲尼亚斯启动了一个 “魔法实验”:他召唤出 4 个 “魔法信使”(对应程序的 4 个线程),每个信使负责处理 3 个 “任务”(比如更新计分、播放欢呼声、记录数据等)。

“看好了,这 4 个信使就是你们程序的‘全部运力’,就像霍格沃茨只有 4 辆‘夜骐马车’负责运输一样。” 菲尼亚斯说着,给其中一个信使施了 “Thread.sleep 咒语”—— 只见那个信使立刻停下脚步,抱着包裹原地 “昏睡” 过去,不管其他任务怎么 “喊” 它,都纹丝不动。

在这里插入图片描述

“现在问题来了!” 菲尼亚斯的声音突然变得严肃起来,“原本 4 个信使能轻松搞定 12 个任务,现在少了 1 个,剩下 3 个得扛 12 个任务 —— 这就像让罗恩一个人搬 10 箱魔药材料,不累死才怪!”

更可怕的还在后面:当他给 4 个信使都施了 “Thread.sleep 咒语” 后,所有信使都昏睡过去,屏幕上的任务进度条瞬间变成了红色,魁地奇计分器的数字停在 “40:30” 不动了,连背景音乐都卡住了。

“这就是 Thread.sleep 的‘致命缺陷’!” 菲尼亚斯的魔杖指向昏睡的信使,“它会让整个‘信使’(线程)彻底休眠,期间既不能处理‘飞路网队列’的活,也不能跑其他‘任务’—— 就像被摄魂怪吸走了所有活力!

GCD 时代还好,因为它会‘临时召唤新信使’(新建线程),虽然效率低,但至少不会全卡住;可 Swift 并发不轻易‘加信使’,线程数量是固定的,要是所有信使都睡了,你们的程序就会像被施了‘统统石化’,直到信使醒来才能动 —— 这要是在魁地奇决赛上,观众不得把球场拆了才怪?!”

✨ 救赎之光:Task.sleep 的 “智能休眠”—— 只停任务,不放线程

就在哈利和罗恩倒吸一口凉气时,菲尼亚斯挥了挥魔杖,解除了 “Thread.sleep 诅咒”,然后启动了第二个实验 —— 给任务施 “Task.sleep 咒语”。

同样是 4 个信使,12 个任务。当菲尼亚斯对其中一个 “计分任务” 施咒后,神奇的事情发生了:那个任务暂时 “消失” 了,但执行它的信使没有昏睡,反而立刻拿起了下一个 “播放欢呼声” 的任务,继续干活!

在这里插入图片描述

“看到没?这就是 Task.sleep 的‘智慧’!” 菲尼亚斯的声音里满是赞叹,“它休眠的是‘任务’,不是‘线程’—— 就像让一个球员暂时下场休息,但他的扫帚不会闲着,会立刻交给另一个球员继续比赛!”

他进一步解释道:Task.sleep 本质是 “让当前任务暂时放弃线程的使用权”,线程会立刻被 “调度中心” 分配给其他等待的任务,既不会浪费 “信使资源”,也不会耽误整体进度。 就像赫敏在图书馆查资料时,会把笔记本借给哈利记笔记,而不是抱着笔记本发呆 —— 这才是 Swift 并发的 “高效精髓”!

菲尼亚斯又展示了一段对比代码,清晰标出了两者的区别:

// 🔴 危险!Thread.sleep 的错误示范:让线程昏睡1秒,期间啥也干不了
func badSleepExample() {
    Thread.sleep(forTimeInterval: 1.0) // 这里会让当前线程彻底休眠1秒
    print("1秒后才会打印这句话,但线程休眠期间,其他任务全卡住!")
}

// 🟢 安全!Task.sleep 的正确示范:只休眠任务,线程去干别的
func goodSleepExample() async throws {
    try await Task.sleep(nanoseconds: 1_000_000_000) // 1秒 = 1e9纳秒
    // 休眠期间,执行这个任务的线程已经去处理其他任务了
    print("1秒后打印这句话,但线程没闲着,效率拉满!")
}

📜 终极戒律:Swift 并发的 “不可违背法则”—— 避坑指南

实验结束后,菲尼亚斯飘到实验室中央,黑袍无风自动,活像个即将宣布 “三强争霸赛” 规则的裁判:“现在给你们立下 Swift 并发的‘第一戒律’——在 Swift 并发代码里,永远、永远、永远不要用 Thread.sleep,只用 Task.sleep!

在这里插入图片描述

他特意加重了 “永远” 两个字,眼神扫过罗恩(毕竟罗恩上次就犯过这错):“我很少说‘永远’,但这次必须说 ——Thread.sleep 就像‘未经许可使用时间转换器’,看似能解决问题,实则会引发连锁反应,把整个程序的并发逻辑搅得一团糟。而 Task.sleep 是‘被官方认可的休眠术’,既安全又高效。”

但菲尼亚斯话锋一转,表情又变得严肃:“不过,你们也别把 Task.sleep 当成‘万能解药’!要是有人写代码时说‘不加个 0.01 秒的休眠,这段逻辑就跑不通’—— 这绝对是‘治标不治本’!”

在这里插入图片描述

他举例:比如有人发现 “更新计分后立刻刷新 UI 会卡顿”,就加了 Task.sleep (0.01),看似解决了问题,实则掩盖了 “UI 更新和数据计算没在正确队列执行” 的根本问题 —— 就像罗恩为了掩盖魔药熬糊的事实,往里面加了 “香精”,闻着香,喝下去照样会拉肚子。

“真正的高手,会找到问题的根源,而不是用‘休眠’来藏拙。” 赫敏听到这话,立刻点了点头,偷偷把自己笔记里 “临时加 0.01 秒休眠” 的注释划掉了。

🌟 结尾:魔法与代码的共通之道 —— 细节定成败

当实验室的钟声响起时,哈利已经把 “魁地奇计分器” 的代码改好了 —— 他用 Task.sleep 替代了原来的 Thread.sleep,还修复了隐藏的 “队列串行化问题”。

运行代码的瞬间,屏幕上的计分器流畅地跳动着,格兰芬多的分数从 40 变成 50 时,背景立刻响起了欢呼声,没有一丝卡顿。

菲尼亚斯看着屏幕,满意地捋了捋不存在的胡子:“记住,小巫师们,魔法的真谛在于‘理解每一个咒语的本质’—— 你知道‘除你武器’是缴械,‘昏昏倒地’是催眠,才不会用错场合。编程亦如是:Thread.sleep 是‘困住信使的枷锁’,会让你的程序陷入停滞;而 Task.sleep 是‘给任务的智能休战符’,能让并发逻辑如凤凰涅槃般流畅自如。”

在这里插入图片描述

他最后一挥魔杖,魔法屏幕上浮现出一行金色的大字:“Swift 并发的战场里,选对‘休眠术’,你的代码才能像火弩箭一样,快得让对手望尘莫及;选错了,便是万丈深渊的卡顿,让用户对你的程序‘敬而远之’。”

哈利、罗恩和赫敏对视一眼,都露出了恍然大悟的笑容 —— 原来编程和魔法一样,细节里藏着成败的关键,而今天这堂 “休眠术” 课,无疑给他们的 “魔法编程手册” 添上了至关重要的一页。

那么,各位秃头魔法师,你们学“废”了吗?

感谢观看,我们下次再会吧!8-)

【大话码游之 Observation 传说】下集:破咒终局了,天眼定乾坤

在这里插入图片描述

⚡️ 引子:内存魔咒锁盘丝,旧妖狂笑待崩盘

上回说到,至尊宝用 “信号仓库” 暂时破解了旧观老妖的 “信号失踪” 计,正得意间,盘丝洞的地砖突然开始冒黑烟 —— 观气道人被 “内存魔咒” 缠上,变成了不死不休的僵尸进程,就算把月光宝盒砸成碎片,后台里的计数观测还在疯跑!

“哈哈哈!” 旧观老妖踩着黑烟狂笑,手里的破葫芦(withObservationTracking)都笑出了裂纹,“让你用新法宝!这‘强引用捆仙绳’一旦缠上,别说你这臭猴子,就算如来佛祖来了也解不开!等内存仙力耗尽,整个盘丝洞都得炸成原子!”

在这里插入图片描述

紫霞仙子急得用紫青宝剑砍代码,火花四溅却毫无用处:“亲爱的!这可怎么办?老祖的秘籍里没说这招啊!”

在本篇西游外传中,您将学到如下内容:

  • ⚡️ 引子:内存魔咒锁盘丝,旧妖狂笑待崩盘
  • 7️⃣ 破解内存魔咒:三招斩断强引用捆仙绳
  • 第一招:Task 初始化,弱引用先行
  • 第二招:循环内解包,见好就收
  • 第三招:闭包弱引用,釜底抽薪
  • 8️⃣ 天眼通的终极形态:多属性观测,一网打尽
  • 9️⃣ 终局对决:旧妖溃败,天眼定乾坤
  • 🏁 尾声:新篇待启,仙法无边

至尊宝攥着金箍棒,额头上青筋暴起:“妖魔鬼怪都给我听着!今天就算拆了这破代码,我也得把这魔咒破了!”

在这里插入图片描述


7️⃣ 破解内存魔咒:三招斩断强引用捆仙绳

就在此时,云端传来菩提老祖的洪钟之音:“痴儿!慌什么!这‘内存魔咒’看着吓人,实则有三招可破!且听我道来 ——”

在这里插入图片描述

第一招:Task 初始化,弱引用先行

老祖掷下第一道金光,照在 Task 的初始化代码上:

// 错误示范:Task强引用self,等于给观气道人戴了紧箍咒,永远摘不下来
Task { 
  // 这里的self是强引用,哪怕外面的观气道人被销毁,Task还抱着self不放
  let values = Observations { self.counter.count }
  for await value in values { /* 处理信号 */ }
}

// 正确示范:用[weak self]给Task松绑,像给捆仙绳抹了润滑油
Task { [weak self] in // 关键!弱引用self,Task不绑架观气道人
  guard let self else { return } // 先确认观气道人还在,不在就直接跑路
  let values = Observations { [weak self] in 
    self?.counter.count ?? 0 
  }
  for await value in values { /* 处理信号 */ }
}

在这里插入图片描述

“记住!” 老祖的声音震得洞顶掉灰,“Task 这东西,就像个贪财的小妖,你不给它套‘弱引用紧箍咒’,它就会把 self 死死攥在手里,就算主人(观气道人)死了,它还抱着尸体不放!”

第二招:循环内解包,见好就收

金光再闪,照向 for await 循环:

// 错误示范:循环外强解包self,等于给魔咒上了双保险
Task { [weak self] in
  guard let self = self else { return } // 在这里强解包,等于重新捆紧绳子
  for await value in values {
    // 就算观气道人后来被销毁,self还被循环攥着,内存泄漏没跑
    print(self.counter.count)
  }
}

// 正确示范:循环内按需解包,用完就扔
Task { [weak self] in
  let values = Observations { self?.counter.count ?? 0 }
  for await value in values {
    guard let self else { break } // 每次循环都检查:主人不在了?立马停手!
    print(self.counter.count)
    // 处理完就放手,绝不纠缠
  }
}

在这里插入图片描述

紫霞仙子恍然大悟:“哦!就像我给你送吃的,你吃完了就该把碗还给我,总抱着碗不放,我怎么再给别人送啊!”

第三招:闭包弱引用,釜底抽薪

最后一道金光劈向 Observations 的闭包:

// 错误示范:闭包强引用self,形成“观气道人→Task→闭包→观气道人”的死亡循环
let values = Observations { 
  self.counter.count // 这里的self是强引用,等于给魔咒加了锁
}

// 正确示范:闭包也用[weak self],从根源上断了循环
let values = Observations { [weak self] in // 闭包也弱引用,釜底抽薪
  self?.counter.count ?? 0 
}

在这里插入图片描述

“这三招齐出,” 老祖总结道,“就像给捆仙绳剪了三刀,强引用的循环链条一断,观气道人该投胎投胎,该销毁销毁,内存魔咒自然破解!”

8️⃣ 天眼通的终极形态:多属性观测,一网打尽

破解了内存魔咒,至尊宝突然一拍大腿:“对了老祖!要是我想同时盯着好几个属性变化,比如计数和宝盒的能量值,这天眼通能行吗?”

“问得好!” 老祖赞许道,“这正是天眼通比旧观气术厉害百倍的地方 —— 它能同时观测多个属性,只要你在闭包里碰过的,一个都跑不了!”

在这里插入图片描述

说着,紫霞给计数仙核加了个新属性,演示起多属性观测:

// 升级后的计数仙核,多了个能量值属性
@Observable 
class Counter {
  var count: Int
  var power: Int = 100 // 月光宝盒的能量值
}

// 天眼通同时观测count和power
let values = Observations { [weak self] in
  guard let self else { return (0, 0) }
  // 闭包里访问了两个属性,天眼通会同时盯着它们
  return (self.counter.count, self.counter.power) 
}

// 只要count或power变了,仙流就会发信号
for await (count, power) in values {
  print("次数:\(count), 能量:\(power)")
}

旧观老妖看得眼睛发直:“不可能!我那破葫芦(withObservationTracking)要同时盯两个属性,得写 twice 代码,还经常串线!这新法宝怎么能这么丝滑?”

“因为天眼通是‘属性感知雷达’,” 老祖解释道,“闭包里访问多少属性,它就自动布多少个监测点,不管你最后返回啥,只要碰过的属性变了,立马报警 —— 比哮天犬的鼻子还灵!”

在这里插入图片描述

9️⃣ 终局对决:旧妖溃败,天眼定乾坤

“不!我不甘心!” 旧观老妖见底牌被破,掏出最后一招 —— 疯狂修改计数和能量值,想让仙流过载崩溃。

可至尊宝早已用三招破解了内存魔咒,又靠着多属性观测稳稳接住所有信号。屏幕上的日志整整齐齐,没有一个遗漏,没有一丝卡顿。

在这里插入图片描述

“不可能… 我的时代… 怎么会结束…” 旧观老妖的黑气越来越淡,手里的破葫芦咔嚓一声裂成两半,“想当年,我 withObservationTracking 横行江湖的时候,你们这些小娃娃还没出生呢… 现在… 唉…”

在这里插入图片描述

随着一声叹息,旧观老妖化作一缕青烟消散,只留下一句回荡的遗言:“记住… 技术迭代如江水东流… 不跟上,就只能被拍在沙滩上…”

盘丝洞的黑烟渐渐散去,月光宝盒的计数恢复正常,内存仙力平稳流动。至尊宝搂着紫霞仙子,看着屏幕上顺畅运行的代码,嘿嘿一笑:“看来这 Xcode 26 的天眼通,还真不是盖的!”

在这里插入图片描述

🏁 尾声:新篇待启,仙法无边

紫霞仙子把玩着老祖留下的秘籍,突然发现最后一页有行小字:“天眼通初成,然仙法无穷。他日或有‘多线程仙流分流术’‘信号重放真经’问世,有缘者自得之。”

在这里插入图片描述

至尊宝凑过去一看,眼睛发亮:“多线程分流?那岂不是能让观测速度再快十倍?”

“傻猴子,” 紫霞笑着敲他的脑袋,“先把眼下的观气术练熟吧!说不定哪天,又有更厉害的妖魔鬼怪等着咱们呢!”

在这里插入图片描述

月光透过盘丝洞的窗棂,照在代码上,反射出金色的光芒。属于 Observations 的时代,才刚刚开始。而那些藏在技术深处的奥秘,还等着后来者一一揭开…

感谢各位宝子们的观看,下次我们再会吧!8-)

(全剧终)

AppStore卡审44小时的产品,重新提交后已经过审了。

背景

上回书说到本来应该从从容容、游刃有余的迭代,在进入正在审核之后历时了44个小时之后被拒审核。

反倒是成了匆匆忙忙、连滚带爬! 翻车的原因是因为内购恢复购买机制,不适适用于按照时间购买的产品。

苹果原文的意思,如果时间消耗类的产品需要恢复购买,那么需要新的恢复机制,而不应该要求用户强制登录Apple ID的方式重新获取资格。

另外,文末分享一个卡审一个月的截图

添加恢复购买的原因

之所以添加了这种恢复机制,是因为没有添加的时候也被会被拒审。

这就是苹果审核人员的多样性不加的时候说你不符合业务模式,添加了又说你机制不合理~ 这就很苹果!

整改策略

删! 没有什么需要过多解释的内容,每个审核人员的对于产品的理解程度都不一样,正所谓千人千面。所以在应对不同的审核人员的时候,就需要按照审核员的需求走。

做一个听话懂事的乖宝宝! 不要作,更不要叛逆。 尤其是说一些过激言论。

例如:之前的版本怎么怎么样!之前都过了啥啥! 嘴上吐吐槽就好了,别来真的。

这就好比上班看见某些领导就感觉晦气!看到某些同事就心烦!但是,面子还得过得去。

重新提审核

重新提交审核之后,正常排队等待了2天。

从进入审核到通过,耗时18分钟

毕竟邮件本身也有延时,那么实际过审时间基本上是在15分钟左右这样。

wecom-temp-256859-1c7b057b1be85deb28460a4a898f4591.jpg

所以,不用畏惧卡审的状态,心态要放平。没有问题的产品,苹果也不会鸡蛋里挑骨头。

最长卡审

审核一个月.jpg

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

大部分人不知道的 AI 抖音,居然是最「反抖音」的产品

前段时间,有媒体爆料说 OpenAI 打算推出一个 AI 抖音,没想到隔天,这个「AI 抖音」Sora 就上线了。

和抖音一样的竖屏滚动、关注与推荐切换,几乎就是一个抖音复刻。唯一不同的是,Sora 里的每个视频,都是 AI 生成的。

当我们默认就把 Sora 叫做 AI 抖音时,有读者在我们的留言区评论,「其实已经有个 App 叫 AI 抖音了。

起初我以为,这是哪个小团队,在蹭抖音热度取的名字。没想到这个 AI 抖音,竟然是抖音官方出品的 App,并且已经推出有一段时间了。

它的下载量只有四千万,甚至不及抖音的一个零头。但也足够说明,相当一部分用户体验过它,只是被我们忽略了。

和 Sora 要做的 AI 抖音短视频产品不同,这个 AI 抖音主打的是「会思考的抖音。

根据下载页面的介绍,「会思考」的含义,是对比传统的关键词检索,AI 抖音会利用大模型的深度理解能力,帮我们找到更满意的搜索结果,和更全面的 AI 总结回答。

听起来已经完全脱离了短视频产品的功能和形态,不像那些极速版、火山版、抖音精选之类的产品,AI 抖音看起来不是用来「刷」的。

▲ 从 App 分类也能看到,极速版这些仍然属于娱乐类别,但是 AI 抖音在「工具」分类下

我下载了这个真正的 AI 抖音,想看看,当它不再让人上瘾时,还能让人留下来吗。

省流版 | 三句话看懂「AI 抖音」能做什么

1、AI 搜索是主要功能,直接生成详细的文字版总结,再给出相关的视频,能深度思考。

2、使用任务助手,能进行深度研究,通过抖音搜索和全网搜索,生成一份可交互的在线报告。

3、整理视频合辑,创建 AI 视频笔记本,AI 抖音会自动归纳总结合辑内视频,提供文字和图解报告。

给抖音搜索加了个 AI 总结

安装包 800 MB,比微信还大。第一眼看到这体积,我就有种不祥的预感,这不是个轻量级工具。

打开首页的那一刻,我才发现,尽管大小差不多,但这确实不是那个熟悉的抖音。首先是整体的布局变得非常简洁,底部那些 Tab 菜单全部取消,以一个输入框和 AI 抖音的 Logo 替代。

内容的展示,也从短视频应用常见的全屏滚动,改成了能展示更多内容的卡片式布局。顶部也不会切换到关注、推荐之类的视频流,而是右滑直接显示提问记录,左滑显示个人中心。

我翻了几页,点击左下角的 AI 图标会弹出一个「找点提问好灵感」的提示,指引我们在输入框里面,搜入搜索的关键词。

双击图标,会自动刷新当前页面,内容的推荐是跟随抖音账号,但是推荐机制也变了一小部分,它更愿意给我们图文,而不是视频。

抱着试一试的心态,我们随便问了 AI 抖音几个问题。

像是最近一些化妆品里检测出苏丹红,我们直接输入「苏丹红」,AI 抖音的做法是先给我们一段文字总结,像在用百度、或者 ChatGPT 之类的产品。

点击「深度解答」,它会像 DeepSeek 的深度思考一样,展示详细的思考过程。此外,它搜索的范围不只是抖音上的视频,而是全网的内容都会搜刮进来,大多数是主流的中文媒体,来源可靠。

这个输入框不仅仅是一个搜索的输入,更像是一个对话框。在当前搜索里,我们可以继续「接着问」,AI 抖音会自动地把我们之前的问题,整合起来,而不需要再次重复。

我们还问了它一些,平时用抖音可能会搜索的话题,像是吃喝玩乐、知识科普等等。

相比较于在搜索结果里,一个一个视频点击播放,靠用户自己去提取视频内的有用信息,AI 抖音则是利用大模型的能力,提升了我们找视频的效率。

此外,如果是抖音,我们想要搜索 A,很大程度会被抖音的推荐机制,引导到 C,然后根本忘记了最先要搜索的东西,开始刷一些不相干的视频。

AI 抖音在一定程度上,也解决了类似的「分心」问题,用经过「思考」的、准确的文字内容,把我们留在当前问题,然后再通过一些视频、图文来辅助理解。

除了在回答的最后,使用深度解答的功能。底部的输入框,也能直接启用深度思考,以及切换到任务助手的模式。

抖音版 Agent,能做深度研究

一个总结性的回答,是目前很多社交媒体软件都有的功能。最常用的应该是微博,AI 智搜会总结各方的回应,并收集对应的素材内容。很多时候,我吃瓜都变成了直接看微博的 AI 回答。

小红书也有类似的功能,尤其是对于攻略、流程、以及各种问答,小红书能自动整理相关笔记,然后生成一份文字回答。

AI 抖音也在尝试这样的引入,但又不止于此。

我体验了深度思考旁边的「任务助手」,发现这就是抖音最好的深度研究工具。

当我输入「十五全运会有哪些特别值得一看的比赛」,它会先生成一份大纲,告诉我们它会如何研究这个任务。我们可以直接对话,进行任务的修改。

点击开始任务之后,AI 抖音的任务助手,就会自动使用抖音搜索和全网搜索两个工具,来一步步完成之间设置的任务。

最后的结果是一份可以交互的报告。报告的所有部分,都可以点击查看更多信息,或者跳转到对应的抖音视频。

我们还尝试了一个更贴近普通用户需求的任务,比如输入「帮我规划一个三天两夜的西安旅游攻略」。

任务助手同样先生成了一份大纲,包含景点、美食、交通和住宿几个方面。我们继续和它对话,让它「增加一个必吃的老字号小吃环节」。

点击开始后,能看到抖音搜索,找到了很多相关的 Vlog 探店,而全网搜索则补充景点的官方开放时间和门票信息。

这份攻略报告同样非常实用,点击「兵马俑」,能看到相关的视频介绍;点击泡馍,又能跳转到高赞的食评视频。

比起我们自己在 App 里一个一个搜、最后被不相关的视频带跑偏,效率提高非常明显。

视频知识库?AI 笔记

除了深度思考的问题、深度研究,AI 抖音的「会思考」还体现在它的视频分类,有「观看历史」、「我的收藏」、和「我的点赞」,还有一个「AI 笔记本」。

和收藏、点赞并列在一起,AI 笔记本也是一个视频合辑,不过这个合辑有了 AI 的帮忙。

▲除了 AI 笔记本,侧滑还有显示能「发布新作品」,其中有各种模板、直播等等。这也是为什么 AI 抖音的应用大小,能和正式版抖音差不多的原因。

我们从历史浏览里面找到一些视频,添加到笔记,AI 会自动分析我们选择的视频,然后生成一份文字报告和图片报告。

或者只添加一个视频,然后让 AI 抖音帮我们总结这个视频,像是一些长达一小时的演讲视频。

▲在右下角可切换文字或者图解

AI 笔记本的作用,其实就是一个能思考的收藏夹,把不同视频里的观点,整合成一个更完整的答案。

目前,AI 抖音还是一个在快速迭代的产品,我们在 10 月中旬体验这款产品的时候,里面还有一个「查看图解版回答」的选项,它能够将 AI 抖音的回答,转成信息更明显、色彩更多元、更便于阅读的卡片式图片。

▲之前对 AI 抖音提问「Apple 最近的新品」,生成的图解版回答

目前这项功能,在最新版本的 AI 抖音已经下线了;连同每个回答最后面的「发抖音」按钮,也一同被迭代了。

之前的「发抖音」功能,是 AI 抖音会将 AI 生成的内容,自动转成抖音长文,视频底部点击查看原文,能跳转到生成回答的链接。

▲底部的快速反馈,只提供了点踩的按钮;长按回答才能选择点赞喜欢。

抖音大概是真的想把这个产品,做成一个工具属性更强,能够真正帮到用户提升搜索体验。就连每个回答的最后的反馈,也只有一个点「踩」,而没有点赞。

在正式版本的抖音 App 里,已经支持 AI 搜索,深度思考等,AI 笔记则还没上线。

▲ 在抖音的搜索界面,右下角有一个语音搜索和 AI 抖音的选项,点击 AI 抖音,能体验到 App 内的部分功能

AI 抖音更像是抖音对未来的演练场。就像微博、小红书、微信都在做的那样;AI,正在悄悄改变我们使用互联网的方式。

前几天刷到一个很有意思的视频,内容是「当人们事事都依靠 ChatGPT 时」,每说一句话都要先问 ChatGPT。

▲ instagram@theharrisalterman,原文链接查看视频

虽然视频是在讽刺 AI 的出现,让大家变得更少思考,更不会说话。但视频也精准地捕捉到了一个现实,或者说共鸣,就是我们开始习惯,让 AI 成为任何信息处理的第一站,无论是搜索、总结还是创作。

对于抖音这个庞大的视频内容帝国来说,它能让大多数人上瘾的本质,是它不需要我们主动去找,要刷什么视频;而是在它的首页推荐里,它给我们什么,我们就刷什么,并且不设置退出机制。

但「找」视频比「刷」视频难多了,AI 抖音正是想帮我们把「找」视频,也变得同样简单和高效。

如果说抖音的算法逻辑是「无限满足」,让你在信息流里躺平就能获得快乐,那 AI 抖音的逻辑,几乎是反着来的:它在「制造门槛」

这就是 AI 抖音最有意思的地方,它在主动稀释自己最核心的资产——用户的沉浸时间。这是一种颇具勇气的「自我背叛」。

当所有 App 都在追求极致的丝滑和上瘾时,抖音内部却孵化了一个鼓励主动探索、甚至有点「反效率」的产品。它的使命并不是取代抖音或对抗算法,而是给算法一些多样性。

当 AI 时代迎来内容爆炸,「信息降噪」更成为一种刚需。

内容消费的下一个版本,不只是让你刷得更多,而是让你看得更明白。这些变化已经悄悄在我们熟悉的应用产品里发生。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


❌