普通视图

发现新文章,点击刷新页面。
昨天以前首页

一次NSMutableAttributedString误用的思考

作者 UTF_8
2026年5月31日 16:09

缘起一次简单的测试,代码如下:


1. NSString *yyStr = @"[YYLabel]:This string is used for test YYLabel attributes, which affect the displsy content. add some substring for truncating";

2. NSString *uiStr = @"[UILabel]:This string is used for test UILabel attributes, which affect the displsy content. add some substring for truncating";

3. NSMutableAttributedString *yyText = [self lineBreakTextWithString2:yyStr];
4. NSMutableAttributedString *uiText = [self lineBreakTextWithString1:uiStr];

// 设置 UILabel 的 lineBreakModel 为 NSLineBreakByCharWrapping.
5. NSMutableParagraphStyle *uiParagraphStyle = [[NSMutableParagraphStyle alloc] init];
6. uiParagraphStyle.lineBreakMode = NSLineBreakByCharWrapping;
7. [uiText addAttribute:NSParagraphStyleAttributeName value:uiParagraphStyle range:NSMakeRange(0, uiText.length)];

8. self.uiContentLabel.attributedText = uiText;
9. self.uiContentLabel.numberOfLines = 2;

// 设置 YYLabel 的 lineBreakModel 为 NSLineBreakByCharWrapping.
10. yyText.yy_lineBreakMode = NSLineBreakByCharWrapping;

11. self.yyContentLabel.attributedText = yyText;
12. self.yyContentLabel.numberOfLines = 2;

结果如下图所示:

IMG_9711.jpg

可以看出 UILabel(YYLabel的展示,YYLabel本身的代码有问题,这里不作讨论,如果有兴趣可以查看我的微博) 展示的第二行并不是 NSLineBreakByCharWrapping 的效果,按理说应该是按照 char 截断。 为此看了下按照上述代码,都在哪里执行了设置 lineBreakMode。

###### 1 ######
// 设置 UILabel 的 lineBreakModel 为 NSLineBreakByCharWrapping.
NSMutableParagraphStyle *uiParagraphStyle = [[NSMutableParagraphStyle alloc] init];
uiParagraphStyle.lineBreakMode = NSLineBreakByCharWrapping;
[uiText addAttribute:NSParagraphStyleAttributeName value:uiParagraphStyle range:NSMakeRange(0, uiText.length)];

###### 2 ######
// 设置 YYLabel 的 lineBreakModel 为 NSLineBreakByCharWrapping.
yyText.yy_lineBreakMode = NSLineBreakByCharWrapping;

// 当设置 YYLabel 的 attributeText 属性时,内部会先 mutableCopy yyText。
self.yyContentLabel.attributedText = yyText;
_innerText = yyText.mutableCopy;

###### 3 ######
// 设置 _innerText.yy_lineBreakMode,_lineBreakMode 默认为 NSLineBreakByTruncatingTail
_innerText.yy_lineBreakMode = _lineBreakMode;
switch (_lineBreakMode) {
    case NSLineBreakByWordWrapping:
    case NSLineBreakByCharWrapping:
    case NSLineBreakByClipping: {
        ###### 4 ######
        // 设置 _innerText.yy_lineBreakMode
        _innerText.yy_lineBreakMode = _lineBreakMode;
    } break;
    case NSLineBreakByTruncatingHead:
    case NSLineBreakByTruncatingTail:
    case NSLineBreakByTruncatingMiddle: {
        ###### 4 ######
        // 设置 _innerText.yy_lineBreakMode
        _innerText.yy_lineBreakMode = NSLineBreakByWordWrapping;
    } break;
    default: break;
}

从上面的代码可以看出,uiText,yyText 和 innerText 均设置了 lineBreakModel。可以看出 UILabel 的表现更像是 NSLineBreakByWordWrapping,而在给 YYLabel 设置 attributedText 后,确实会走到_innerText.yy_lineBreakMode = NSLineBreakByWordWrapping;的逻辑。这里我开始猜想内部是不是有这么一种共享 attributes 的机制。

在 Xcdoe debug 时,NSMutableAttributedString 的有一个为类型为 NSMutableRLEArray 的 mutableAttributes 的属性,如下图所示:

截屏2026-05-31 14.47.36.png

在上述代码执行到创建好 uiText 和 yyText后,也即第 4 行代码执行完毕,未开始执行第 5 行代码,断点在此处。打印出 uiText 和 yyText 的 mutableAttributes 的信息。如下图所示:

  • uiText

截屏2026-05-31 14.47.11.png

  • yyText

截屏2026-05-31 14.46.42.png

从上面可以看出 uiText 和 yyText 的 mutableAttributes 数组内的元素相同,均为 0x106b68bc0。从这里可以看出 uiText 和 yyText 共享了 attributes。

代码继续执行下去,断点在第 8 行,也即 uiText 添加了 paraStyle 属性。打印出 uiText 和 yyText 的 mutableAttributes 的信息。如下图所示:

  • uiText

截屏2026-05-31 14.57.40.png

  • yyText

截屏2026-05-31 14.57.53.png

从上面可以看出 uiText 和 yyText 的 mutableAttributes 数组内的元素地址不相等,数组元素内容也不同。uiText 数组元素多了 paraStyle attributes 信息

代码继续执行下去,当执行完第 10 行代码,也即设置完 yyText 的 yy_lineBreakMode 属性。断点在 11 行,打印结果如下图所示:

  • uiText

截屏2026-05-31 15.04.00.png

  • yyText

image.png

从上面的打印结果可以看出,uiText 和 yyText 的 mutableAttributes 数组内的一个元素地址相同,均为 0x106951020从这里可以推测:uiText 和 yyText 内部共享了 attributes。但是这是怎么造成的呢?下面会给出我的猜测。

如果对一个 string 执行 copy,会不会共享 attributes 呢? 代码继续执行到第 11 行 self.yyContentLabel.attributedText = yyText; 内部会执行 _innerText = yyText.mutableCopy; 打印的信息如下:

  • yyText(也即下图显示的 attributedText,- (void)setAttributedText:(NSAttributedString *)attributedText

截屏2026-05-31 15.11.06.png

  • innerText

截屏2026-05-31 15.11.22.png

从上面的比较结果可以看出,mutable string 不会创建新的 attributes 元素。所以至此 uiText,yyText 和 innerText 的 mutableAttributes 数组共享一套 attributes 数组元素

所以 UILabel 的 lineBreakMode 表现为 NSLineBreakByTruncatingMiddle 就不奇怪了,因为最终 innerText 的 yy_lineBreakMode 按照 YYLabel 的内部逻辑就是设置为 NSLineBreakByTruncatingMiddle,_innerText.yy_lineBreakMode = NSLineBreakByWordWrapping;

那么问题回到为什么 yyText.yy_lineBreakMode = NSLineBreakByCharWrapping; 执行完之后,yyText 和 uiText 就共享一套 attributes 属性。

先从 CoreText 中的理念入手,在 CoreText 有 CTRun的概念,简单说就是:对于一个 attributeString,其中连续的字符具备相同的attribuites,就生成一个 CTRun。

以 "Hello World" 字符串入手,假如整个字符串的字体都为 FontA,但是 "Hello " 颜色为 blue,而 “World” 为 red。那么针对 "Hello World" 生成的 attributes 数组元素就会有两个,一个包括(FontA,颜色 blue,子字符串range1),另外一个(FontA,颜色 red,子字符串range2)。

系统内部为了优化内存,针对 attributes 设定了一套共享策略。 可将 attributes 分为可变和不可变,区分的依据就是:比如 paraStyle 设定的是 NSMutableParagraphStyle,那么就是可变的。如果是 NSParagraphStyle 则表示的就是不可变的。注意这里可变和不可变不是指内存上不可更改,而是一种描述

在系统内部维护了一套 mutable attributes,使用 hash 存储,key 为各个 attribute 的hash 结果

我们来分析前面的代码,因为最开始 uiText 和 yytext 未设定 paraStyle,所以使用的都是 immtuable attributes。当 uiText 设定 NSMutableParagraphStyle,uiText 的 attributes 不能再和 yyText 共享,需要创建新的 attributes 元素,并且是 mutable,存储到系统内部的 mutable attributes hash中

当 yyText 设定 yy_lineBreakMode 的值和 uiText 设定的 lineBreakMode 一致时,先去把更改的 attribute 元素计算 hash,并去 mutable attributes table 中查找是否有对应的 value(如果不是mutable则不要去查找,直接自己独自一份)。如果找到,则直接共享 attribute 元素,如果没有,则独自创建一份(根据自身是否是 mutabel 决定是否放入 mutabel attribute table)。

所以至此,yyText 和 uiText 已经共享了一套 attributes 元素,后续如果不打破这种关系,yyText 针对 lineBreakMode 的更改也会影响到 UILabel 的展示。

如果把 uiText 设定的 paraStyle 设定为 immutable,结果就会不一样。 代码修改为:

[uiText addAttribute:NSParagraphStyleAttributeName value:uiParagraphStyle.copy range:NSMakeRange(0, uiText.length)];

结果如下图所示:可以看到 UILabel 已经是正常的展示了。因为当 yyText 设定 lineBreakMode 时,重新生成的 attribute 元素是不可变的,不能和其它 text 共享。所以后续 yyText 的后续更改都不影响 UILabel 的展示

IMG_9712.jpg

针对上面提到的计算 hash 作为 key,可以再做一个测试,保持最开始的代码不变,仅改成:NSLineBreakByWordWrapping,而不是之前的NSLineBreakByCharWrapping

yyText.yy_lineBreakMode = NSLineBreakByWordWrapping;

截屏 2026-05-31 16.02.43.png

这里也从侧面证明我的猜想,根据 attribute 的值计算整体的 hash 值作为 key,去获取对应 attribute 元素。如果没有,则创建新的 attribute 元素,也即不是 uiText 和 yyText 共享 attributes 元素的目的。

最后还是对于 NSMutableAttributedString 的误用,尤其在设定 paraStyle 的时候,如果不希望意外的修改,最好传入一个 immutable paraStyle。

harmony-next.skills 为 AI 而生!

作者 归故里
2026年5月21日 00:58

如果你最近让 AI 帮你写 HarmonyOS NEXT 代码,大概率遇到过同一个问题:模型很自信,但答案不一定落得回真实文档。

它可能会记错 @ohos.* 模块路径,混用旧版本 API,给出不存在的 ArkUI 参数,或者在 NDK、DevEco Studio、模拟器调试这些边界场景里凭经验补全。尤其是 DevEco 模拟器接口和 DevEco Studio IDE 接口,很多能力不在常规 API 手册里,只靠模型记忆很容易把路径、版本、私有协议和风险边界混在一起。

所以我整理了一个给 AI 编程助手使用的技能库:harmony-next.skills

它不是给人从头翻的文档合集,而是给 Gemini CLI、Claude Code、Codex 这类 Agent 使用的 HarmonyOS NEXT 本地检索层。目标很直接:让 Agent 在回答之前先找到真实文档路径,再读取对应内容,最后基于可追溯的资料写代码、解释 API 或执行本地诊断。

项目地址:

github.com/linhay/harm…

为什么需要这个库

HarmonyOS NEXT 开发里,很多问题看起来只是“查一下 API”,实际会牵出一串上下文:

  • 这个能力属于哪个 Kit?

  • 当前文档快照是否覆盖 API 23?

  • @ohos.* 模块、ArkUI 组件、NDK 头文件是否真实存在?

  • 旧文档链接是否已经迁移到新的 Markdown 页面?

  • DevEco Studio、HarmonyOS Emulator、hdcuitest 能不能在本机自动化验证?

  • DevEco 模拟器的 HVD、target、截图、layout、日志链路该怎么被 Agent 安全调用?

  • DevEco Studio IDE 里的 CodeGenie、MCP、Inspector、Profiler、Doctor 等能力该怎么先静态识别,再决定是否进入真实执行?

  • AI 生成的代码到底是基于真实文档,还是基于模型记忆猜出来的?

harmony-next.skills 想解决的不是“把文档再复制一份”,而是把这些问题变成 Agent 能执行的检索流程。

它把 HarmonyOS NEXT API 12-23 的离线参考资料、Kit 导航、任务导航、全库索引、工具链说明和自动化边界组织在一起。Agent 不需要一上来全量读文档,也不需要凭关键词乱搜,而是按固定路径逐步缩小范围:


SKILL.md

  -> KITS.md / TASK_MAP.md

  -> INDEX.md

  -> 目标 Markdown 正文

这套流程的核心只有一句话:先找路径,再读内容。

它和普通文档镜像有什么不同

普通文档镜像解决的是“离线能不能看”。harmony-next.skills 更关心另一个问题:AI Agent 能不能稳定使用。

所以它不只包含文档正文,还补了几层面向 Agent 的结构:

第一层是 SKILL.md。它告诉 Agent 什么时候应该使用这个技能,遇到 ArkTS、ArkUI、NDK、DevEco Studio、模拟器、hdcuitest 等问题时该怎么路由。

第二层是 KITS.mdTASK_MAP.md。一个按 Kit 缩小范围,一个按开发任务反查关键词。比如你问生命周期、媒体、网络、UI、发布、NDK,不需要从几千个文件里盲搜。

第三层是全库索引。当前参考库包含 3,693 份 Markdown,其中 3,666 份在 JsEtsAPIReference/ 下。Agent 会先命中路径,再打开少量目标文件读取细节。

第四层是验证与维护脚本。参考资料发生迁移或批量改写后,可以用脚本重建索引、检查旧路径残留、审计内部链接是否被误改成纯文本。

这让它更像一套 Agent 用的 HarmonyOS NEXT 知识基础设施,而不是静态资料包。

适合哪些场景

如果你主要用 AI 做 HarmonyOS NEXT 开发,这个库会在这些场景里很有用。

写 ArkTS / ArkUI 时,可以让 Agent 先确认组件、装饰器、状态管理、导航、UIAbility、Want 等 API 的真实位置和版本差异,再生成代码。

做 NDK / Node-API / C API 时,可以通过索引把头文件映射到真实的 topics/**/<header>.h.md 页面,避免旧路径、旧头文件和新文档结构混在一起。

排查工具链问题时,可以查签名、调试、发布、性能、模拟器、真机、hdcaabmhiloghidumper 等本地链路,而不是让模型凭其他平台经验套答案。

DevEco 模拟器接口是这个库里很值得看的部分。它把免 IDE 启动 HarmonyOS Emulator、HVD 枚举、多实例、HDC target 选择、启动等待、uitest dumpLayout、截图、日志采集、应用安装启动这些动作整理成 Agent playbook。重点不只是列命令,而是把每个动作放进可审计的执行模式里:什么时候只做只读探测,什么时候可以保存截图和 layout,什么时候进入 UI 自动化,什么时候需要 diagnostic 或 break-glass 标记。

DevEco Studio IDE 接口也是一个单独亮点。库里整理了 CodeGenie、本地 RAG、MCP、LanceDB、devecostudio://、Previewer、ArkUI Inspector、Profiler、Doctor、Diagnostic、FaultLog、UxTestService 等入口线索。这里的处理方式比较克制:先确认版本和安装路径,默认做静态只读分析,不把私有接口包装成稳定公共 API;涉及 GUI、本地服务、设备连接、用户缓存、聊天历史、模型配置或外部 provider 时,必须明确目标、读取范围、产物目录和脱敏边界。

做自动化 smoke 时,可以复制内置的 Empty Ability 最小工程模板,用 ohpm installhvigorw --mode module、HDC 安装启动、uitest dumpLayout、截图和点击事件完成最小验证。

如果你在研究 DevEco Studio 或 HarmonyOS Emulator 的本地能力,这两份 playbook 会比单纯搜索命令更有用。它们把“能不能做”“怎么做”“做完留下什么证据”“哪些内容不能泄露”放在一起,适合 Agent 长时间跑本地自动化时使用。

最近版本重点

当前本地版本是 v1.3.7

这一版新增了可复制的 HarmonyOS NEXT Empty Ability 最小测试工程模板,和前面两份 DevEco playbook 正好能接起来。默认配置是:

  • bundleName:com.example.emptyability

  • module:entry

  • ability:EntryAbility

  • compatible SDK:5.0.0(12)

  • target SDK:5.0.0(12)

它的价值不是“又多了一个 demo”,而是给 Agent 一个可以复制、构建、安装、启动、dump layout、点击验证的最小闭环。需要适配 API 22 等目标环境时,可以在复制出的 fixture 中覆盖 SDK 版本,例如 6.0.2(22),并通过 DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk 指向本机 SDK 根目录。

也就是说,Agent 不只是会回答“应该怎么做”,还能在有设备或模拟器的环境里做一轮可回归的 smoke 验证。

怎么接入

Gemini CLI 可以直接安装:


gemini skills install https://github.com/linhay/harmony-next.skills --path harmony-next --scope user

Claude Code 可以下载仓库里的 harmony-next/ 技能目录,放到对应技能目录里使用。也可以把它作为项目上下文附加:


git clone https://github.com/linhay/harmony-next.skills.git

claude --add-dir /path/to/harmony-next.skills/harmony-next

Codex 目前可以把 harmony-next/ 放到官方 skill 扫描路径,例如:


git clone https://github.com/linhay/harmony-next.skills.git

mkdir -p "$HOME/.agents/skills"

ln -s "$(**pwd**)/harmony-next.skills/harmony-next" "$HOME/.agents/skills/harmony-next"

团队项目也可以把 harmony-next/ 复制或软链到目标仓库的 .agents/skills/harmony-next,让项目内的 Agent 自动发现。

我希望它带来的变化

AI 编程助手真正有价值的地方,不是“更会编”,而是能进入一个可验证的工程流程。

在 HarmonyOS NEXT 这种快速演进的生态里,Agent 如果没有本地知识源,很容易把旧经验、旧 API、其他平台的模式和当前项目混在一起。短期看像是节省时间,长期看会增加排查成本。

harmony-next.skills 想把这件事往前推一步:让 Agent 先检索、再引用、再实现、最后验证。

如果你正在用 AI 写 HarmonyOS NEXT 应用,或者正在搭自己的 Agent 开发工作流,可以把这个库接进去试试。它不替代官方文档,也不替代工程判断,但它能让 AI 的回答更少凭空补全,更容易追溯,也更适合放进长期维护的开发链路里。

项目地址:

github.com/linhay/harm…

你还在手动敲命令部署?GitHub Actions 让你 push 即上线,摸鱼时间翻倍

作者 kyriewen
2026年5月9日 23:08

你改完代码,打开终端,输入 npm run build,然后 FTP 上传,或者登录服务器 git pull。这一套操作每天重复 N 次,不累吗?今天我们来把“部署”这件事自动化——用 GitHub Actions,只要你 git push,代码自动测试、自动打包、自动发到服务器。以后你只管写代码,上线交给机器人。

前言

我见过太多团队还停留在“手工部署”时代:上线先发个群消息“我要部署了,大家别动”,然后手动打包、上传、解压、重启。万一忘了执行某个步骤,线上就挂了。

GitHub Actions 就是你的免费 DevOps 机器人。它能监听 GitHub 上的事件(push、pull request、issue),然后执行你写好的自动化脚本。我们只需要写一个 YAML 文件,放在 .github/workflows 目录下,剩下的全部自动。

今天我们就来写一个完整的工作流:当推送到 main 分支时,自动运行测试、构建、并部署到服务器(或 Vercel / 阿里云 OSS)。全程保姆级,复制粘贴就能用。

一、准备工作:你需要什么?

  • GitHub 仓库(私有或公开都可以)。
  • 一台服务器(或云存储,如阿里云 OSS、Vercel)。
  • 如果部署到自己的服务器,需要服务器的 SSH 密钥(免密登录)。

如果你没有服务器,可以用 Vercel(个人项目免费,连 GitHub 自动部署也是免费的,甚至不需要写 Actions——但为了教学,我们还是会演示自定义部署到服务器的流程)。

二、基础工作流:跑测试 + 打包

在项目根目录创建 .github/workflows/deploy.yml

name: CI/CD Pipeline

on:
  push:
    branches: [ main ]   # 当推送到 main 分支时触发
  pull_request:
    branches: [ main ]   # PR 时也跑测试,但不部署

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: 检出代码
        uses: actions/checkout@v4

      - name: 安装 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: 安装依赖
        run: npm ci

      - name: 运行测试
        run: npm test

  build:
    runs-on: ubuntu-latest
    needs: test   # 测试通过后才构建
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      - run: npm ci
      - run: npm run build
      - name: 上传构建产物(给后续部署用)
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

提交这个文件后,每次 git push main,GitHub 就会自动跑测试和构建。你可以在仓库的 Actions 标签页看到实时日志。

三、部署到自己的服务器(通过 SSH)

deploy.yml 中增加一个 job,依赖 build,然后通过 SSH 把构建产物上传到服务器。

首先在 GitHub 仓库 Settings → Secrets and variables → Actions 中添加几个密钥:

  • SERVER_HOST:你的服务器 IP
  • SERVER_USERNAME:登录用户名(如 root、ubuntu)
  • SSH_PRIVATE_KEY:服务器的私钥内容(复制 ~/.ssh/id_rsa 整个内容)

然后在 deploy.yml 中添加:

  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'push'   # 只有 push 时部署,PR 不部署
    steps:
      - name: 下载构建产物
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist

      - name: 通过 SSH 部署
        uses: easingthemes/ssh-deploy@main
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          ARGS: "-rlgoDzvc -i --delete"
          SOURCE: "dist/"
          REMOTE_HOST: ${{ secrets.SERVER_HOST }}
          REMOTE_USER: ${{ secrets.SERVER_USERNAME }}
          TARGET: "/var/www/myapp/"   # 服务器上的目标目录

这样,每次 git push main,代码会自动出现在 /var/www/myapp 中。如果服务器上跑着 Nginx,刷新页面就是新版。

如果想要重启 PM2 进程,可以在部署步骤后加一个 exec 命令:

      - name: 重启 PM2 服务(如果后端是 Node)
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            pm2 restart myapp

四、部署到 Vercel(更简单)

如果你的项目是前端静态站点,Vercel 本身就是和 GitHub 集成的。但你也可以手动写 Actions 来调用 Vercel CLI。不过更推荐直接在 Vercel 网站导入 GitHub 仓库,它会自动监听 main 分支并部署,连 YAML 都不用写。

如果你坚持要用 Actions 调用 Vercel:

      - name: 部署到 Vercel
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID}}
          vercel-project-id: ${{ secrets.PROJECT_ID}}
          vercel-args: '--prod'

五、部署到阿里云 OSS(静态网站托管)

阿里云 OSS 支持静态网站。我们可以用 aliyun-cli 同步文件:

      - name: 安装阿里云 CLI
        run: npm install -g @alicloud/oss

      - name: 同步到 OSS
        run: |
          oss cp dist/ oss://my-bucket/ -r --force --access-key-id ${{ secrets.OSS_KEY_ID }} --access-key-secret ${{ secrets.OSS_KEY_SECRET }} --endpoint oss-cn-hangzhou.aliyuncs.com

六、进阶:分环境部署(dev/staging/prod)

你可以通过分支名来区分环境:

  • main 分支 → 生产环境
  • develop 分支 → 测试环境

on.push.branches 里可以写多个分支,然后在 job 里根据 github.ref_name 做判断,选择不同的服务器目录或环境变量。

七、常见坑点

  • 密钥泄露:永远不要把 SSH 私钥、密码明文写在代码里,要用 GitHub Secrets。
  • 构建产物太大upload-artifact 可能较慢,对于几 MB 的项目还好,几百 MB 建议直接推送到服务器或云存储。
  • 权限问题:确保服务器上目标目录有写入权限。
  • 缓存依赖:可以加 actions/cache 来缓存 node_modules,每次 build 快很多。

八、总结:让机器人替你干活

  • 写好 .github/workflows/deploy.yml,push 即触发。
  • 用 Secrets 存放敏感信息。
  • 可以串联测试、构建、部署,还能加个钉钉/飞书通知。

从此你只需要 git add . && git commit -m "fix: xxx" && git push,然后去倒杯水。回来群里就会收到:“生产环境部署成功,版本 v1.2.3”。不用记命令、不用等上传、不怕忘步骤。这才是现代前端该有的体验。

如果你觉得今天的“自动化”够解放双手,点个赞让更多人看到。评论区聊聊:你经历过哪些手工部署的惨案?

初学者对与.gitignore应该有的了解

作者 Rkgua
2026年5月8日 17:12

在项目中添加 .gitignore 文件,核心作用就是为版本控制系统建立一道“过滤网”,主动告诉 Git 哪些文件不需要被纳入管理。

基本用法讲解

.gitignore 的语法其实非常直观,核心就是通过一些特殊符号来告诉 Git 哪些文件需要“屏蔽”。针对你提到的 */ 以及如何忽略文件夹,接下来较为实用的语法规则和具体示例:

忽略文件夹与特定文件

  • 忽略文件夹:在文件夹名字后面加上斜杠 /
    • 例如:logs/ 会忽略项目中所有名为 logs 的文件夹及其内部的所有文件。
  • 忽略特定文件:直接写文件名。
    • 例如:.env 会忽略所有层级下名为 .env 的文件。

通配符 *** 的妙用

  • 单星号 *:匹配任意多个字符(但不跨目录)。
    • 例如:*.log 会忽略所有以 .log 结尾的文件(如 debug.logerror.log)。
    • 例如:temp* 会忽略所有以 temp 开头的文件或文件夹。
  • **双星号 ****:匹配任意多级目录(跨目录递归匹配)。
    • 例如:**/build/ 会忽略项目里任何层级下名为 build 的文件夹(无论是根目录的 /build 还是深层的 /src/components/build)。

斜杠 / 的路径限定作用

  • 开头的 /:表示只匹配项目根目录下的文件或文件夹。
    • 例如:/dist/ 只会忽略根目录下的 dist 文件夹,但不会忽略 src/dist/
    • 例如:/config.json 只会忽略根目录下的 config.json 文件。
  • 结尾的 /:明确表示这是一个文件夹,而不是同名文件。
    • 例如:docs/ 会忽略名为 docs 的文件夹,但不会忽略名为 docs 的纯文本文件。

取反符号 !(在原来基础上的例外规则)

  • 感叹号 !:表示“不要忽略”,即把前面规则忽略掉的文件重新捞回来。
    • 例如:
      *.log       # 忽略所有 .log 文件
      !important.log  # 但是!important.log 这个文件要保留,交给 Git 管理
      

综合举例

# 1. 忽略操作系统自动生成的系统文件
.DS_Store
Thumbs.db

# 2. 忽略所有的依赖包文件夹(前端 node_modules,Python venv)
node_modules/
venv/

# 3. 忽略编译打包后的产物文件夹(dist 或 build)
dist/
build/

# 4. 忽略所有日志文件,但保留根目录下的 error.log 用于排查线上问题
*.log
!/error.log

# 5. 忽略所有 .env 开头的环境配置文件(保护密钥)
.env
.env.*

# 6. 忽略所有 IDE 的配置文件
.vscode/
.idea/

添加它的具体用途和好处体现在以下 4 个方面:

1. 保持仓库整洁,聚焦核心代码

在实际开发中,项目里总会产生大量不需要版本控制的“噪音文件”。比如:

  • 编译缓存与临时文件:如 Python 的 __pycache__/*.pyc,或者编辑器的临时备份文件(.swp)。
  • IDE 个人配置:如 .vscode/.idea/ 等文件夹,这些通常是个人的开发环境偏好,不需要强加给团队成员。 通过 .gitignore 过滤掉这些文件,执行 git status 时界面会非常干净,让你能一眼看到真正需要提交的核心代码变更。

2. 保护敏感信息,防止意外泄露

这是 .gitignore 极其重要的安全作用。项目中经常包含一些绝对不能公开的私密文件,例如:

  • 环境变量与密钥:如 .env 文件,里面通常存放着数据库密码、API 密钥等。
  • 本地私有配置:如 config.local.json 等。 如果不添加忽略规则,一旦手滑执行 git add . 并推送到 GitHub 等公开仓库,这些敏感信息就会彻底泄露,带来极大的安全隐患。

3. 减小仓库体积,提升协作效率

很多依赖包或构建产物的体积非常庞大。比如前端项目中的 node_modules/ 文件夹,或者 AI 项目中的模型权重文件(*.pt, *.bin)。

  • 如果不忽略它们,仓库体积会急剧膨胀。
  • 团队成员在拉取代码(git clonegit pull)时,需要下载这些动辄几百 MB 甚至几个 GB 的无用文件,导致速度极慢。
  • 此外,庞大的文件还会严重拖慢 CI/CD(自动化构建与部署)的流程。

4. 避免环境差异引发的冲突

不同开发者的电脑系统(Windows、Mac、Linux)或 IDE 版本不同,自动生成的系统文件或配置文件往往也不一样。比如 Mac 系统会自动生成 .DS_Store,Windows 会生成 Thumbs.db。如果不忽略这些文件,团队成员之间会频繁产生毫无意义的文件冲突,增加合并代码的麻烦。


还有一个非常重要的实践建议:

.gitignore 只对未跟踪(Untracked)的文件生效。因此,在项目创建初期就第一时间添加并配置好 .gitignore 是最佳实践

如果等项目做了一半,不小心把 node_modules.env 提交进了仓库,后续再想通过 .gitignore 去忽略它们就会非常麻烦(必须使用 git rm --cached 手动清理历史记录)。提前配置好,能从源头上规避掉这些棘手的麻烦,所以配置项的详细用法和最佳实践,请参考官方文档:git-scm.com/docs/gitign…

基于 Markdown-It 的无序列表折叠插件

作者 WindRunnerMax
2026年5月6日 11:00

当前Markdown已经成为最好的编程语言,同样的Md也成为了产品文档最需要支持的格式,特别是面向开发者的文档。实际上很多情况下编程和文档的场景是非常类似的,因此在时代的推动下,原生支持Md生产和消费的文档系统的需求重新出现。

在这里我们关注于API文档类型的展示,在OpenAIClaudeAPI文档中,可以看到其表达参数列表的形式类似折叠列表。而观察原始的Md文档,就可以看出其参数列表的形式是无序列表,因此我们也实现类似的功能来将无序列表转换为折叠列表展示。

实际上,将无序列表渲染成折叠列表这件事,本身还是面向开发者阅读的,如果单纯是面向AI来消费,则仅提供纯文本的Md内容即可。目前来看,同时需要面向开发者和AI的状态应该还需要存在较长的时间,因此实现一套Md渲染器还是有必要的。

解析规则

首先我们需要分析无序列表结构及其解析后的HTML,基本的无序列表结构如下所示:

- 0 
- 1 
  - 1.1 
  - 1.2 
    - 1.2.1 
    - 1.2.2 
  - 1.3 
    with desc
    - 1.3.1 
    - 1.3.2 
- 2
<ul>
  <li>0</li>
  <li> 1
    <ul>
      <li>1.1</li>
      <li> 1.2
        <ul>
          <li>1.2.1</li>
          <li>1.2.2</li>
        </ul>
      </li>
      <li> 1.3 <br /> with desc
        <ul>
          <li>1.3.1</li>
          <li>1.3.2</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>2</li>
</ul>

可以看出示例中存在三级ul元素结构嵌套,以及描述内容的li元素,我们需要根据不同的情况来解析。理论上而言,只有存在嵌套结构的li元素才需要解析为折叠结构,其子元素内起始到ul之间的内容需要作为标题,ul内元素则作为折叠展开的内容。

通常来说,实现类似手风琴的效果,大概会主动管理状态,用div等元素来绘制折叠面板,然后主动处理点击事件,来切换折叠展开的状态。不过,HTML原生支持了details元素以及summary元素,我们可以借助原生元素来实现折叠列表的效果,其主要优点是:

  • 简单易用,通常情况下不需要主动管理状态,仅需要维护DOM结构。
  • 无需处理事件,特别是在SSR的情况下,不需要再hydrate注入事件。
  • 原生支持搜索,使用浏览器搜索时,可以自动展开包含搜索关键词的折叠列表。
<details>
  <summary>Details</summary>
  Something more.
</details>

那么根据以上的HTML结构,我们可以根据无序列表的结构,转换为details+summary元素的结构。观察其结构,我们可以实现如下转换规则:

  • ul元素作为折叠展开的内容,这里可以自定义为block元素,也可以保持ul元素。
  • li元素内存在嵌套的直属ul元素时,该li元素需要转换为details元素。
  • 转换的details元素的子元素,从起始到ul元素之间的内容,需要包装summary元素。

根据上述的转换规则,我们可以将最开始的无序列表HTML内容转换为details + summary元素的结构:

  • 渲染示例
  • 1
    • 1.1
    • 1.2
      • 1.2.1
      • 1.2.2
      1.3
      with desc
      • 1.3.1
      • 1.3.2
  • 2
<ul>
  <li>渲染示例</li>
  <details>
    <summary>1</summary>
    <ul>
      <li>1.1</li>
      <details>
        <summary>1.2</summary>
        <ul>
          <li>1.2.1</li>
          <li>1.2.2</li>
        </ul>
      </details>
      <details>
        <summary>1.3 <br /> with desc</summary>
        <ul>
          <li>1.3.1</li>
          <li>1.3.2</li>
        </ul>
      </details>
    </ul>
  </details>
  <li>2</li>
</ul>

元素重建

在设计好HTML结构的转换规则后,我们需要在MarkdownIt的基础上实现转换逻辑。在MdIt中提供了诸多时机的Hook函数,我们需要根据处理的时机来实现转换逻辑,通常来说应该尽可能在后处理阶段来实现相关逻辑,这里我们分别实现解析后处理和渲染时处理。

渲染时处理

因此,我们首先来看仅渲染阶段的rule处理逻辑,在上述的转换规则中,将ul元素转换为block元素,以及将li元素渲染为details元素,这两点是没什么问题的。然而,为子节点包装summary元素,则是比较麻烦的。

在仅渲染阶段,这件事并非不能实现,但是却容易破坏MdIt的线性解析模式。如果这是个递归结构,则仅需要将其节点包一层DOM元素即可,而在线性结构中,包装一层summary元素需要在li_open追加<summary>元素,在ul_open前置</summary>元素。

mdIt.renderer.rules.bullet_list_open = (tokens: Token[], idx: number) => {
  const current = tokens[idx];
  for (let i = idx - 1; i >= 0; i--) {
    const token = tokens[i];
    if (token.level < current.level - 1) break;
    if (token.type === "list_item_open" && token.level === current.level - 1) {
      return "</summary>" + "<ul class=\"bullet-summary-group\">";
    }
  }
  return "<ul class=\"bullet-summary-group\">";
};
mdIt.renderer.rules.list_item_open = (tokens: Token[], idx: number) => {
  const current = tokens[idx];
  for (let i = idx + 1; i < tokens.length; i++) {
    const token = tokens[i];
    if (token.level <= current.level)  break;
    if (token.type === "bullet_list_open" && token.level === current.level + 1) {
      return "<details>" + "<summary>";
    }
  }
  return "<li>";
};
mdIt.renderer.rules.list_item_close = (tokens: Token[], idx: number) => {
  const prevToken = tokens[idx - 1];
  if (prevToken && prevToken.tag === "ul") return "</details>";
  return "</li>";
};

虽然这种模式实现起来简单,理论上也并没有什么问题。然而这里存在的问题是,如果我们需要判断大多情况下保持无序列表,仅表达API参数时才将其渲染为折叠列表,那么此时我们在ul元素上方添加@bullet-summary指令来指定渲染模式。

@bullet-summary
- ul
   - li
   - li

那么此时问题在于,如何判断现在现在嵌入的ul元素需要渲染为折叠列表。那么在渲染时机,取得这个渲染指令并不是很容易,因为其本身是扁平的,那么每次调度rule渲染时,都需要迭代向上查找该指令。而如果在渲染时处理p元素的话,则在消费时实现写数据,有点反逻辑。

mdIt.renderer.rules.list_item_open = (tokens: Token[], idx: number) => {
  const current = tokens[idx];
  for (let i = idx - 1; i >= 0; i--) {
    // 找到该组顶级 ul 元素, 检查其前置 @bullet-summary 指令 
  }
};

mdIt.renderer.rules.bullet_list_open = (tokens: Token[], idx: number) => {
  // 检查其前置元素是否为 @bullet-summary 指令, 此时在 env 设置变量
};
mdIt.renderer.rules.bullet_list_close = (tokens: Token[], idx: number) => {
  // 检查其匹配的 ul env 设置的环境变量, 此时在 env 清理环境变量
};

解析时处理

MdIt的解析过程中,除了渲染时的rule处理逻辑,还可以在解析阶段后处理Token,此时可以找到相关指令再实现相关的转换逻辑。由于我们并不没有额外实现新的语法,指令更多是起到了标记的作用,因此不需要时机解析内容,而是重新组织Tokens

那么此时,我们先来判断一下指令标记,如果匹配到了该标记,则需要进入到重建Tokens的阶段。不过在此之前,我们需要将该指令节点隐藏,不过如果渲染指令是注释类型的话,倒是可以直接隐藏而无需特殊处理。

// paragraph_open
//   inline: @bullet-summary
// paragraph_close
// bullet_list_open
if (
  token.content === identifier &&
  token.type === "inline" &&
  nextToken &&
  nextStep2Token &&
  nextToken.type === "paragraph_close" &&
  nextStep2Token.type === "bullet_list_open"
) {
  prevToken && (prevToken.hidden = true);
  (token.hidden = true) && (token.children = []);
  nextToken && (nextToken.hidden = true);
  rebuildUlTokens(state, i + 2);
}

紧接着,我们需要找到该节点的对应close节点,以此来圈定具体需要处理的范围。说起来,由于MdIt的解析是线性的,虽然规避了递归的问题,但是最差情况下时间复杂度还是O(n)。此外,由于token.level并不太准确,因此还需要维护一个栈深度来记录当前的层级。

const baseType = openToken.type.slice(0, -5);
const closeType = baseType + "_close";
// open      1
// start iterator
//   open    2
//   close   1
// close     0
// end iterator
let level = 1;
for (let i = openIdx + 1; i < tokens.length; i++) {
  const token = tokens[i];
  if (token.type === openToken.type) {
    level++;
  } else if (token.type === closeType) {
    level--;
    if (level <= 0) return i;
  }
}
return -1;

接下来,需要对ul元素做一些修改,主要是为ul加入class属性,用以指定样式。然后维护一个栈,来记录li元素相互对应的节点。此外,这里有个重要的点是要从后向前遍历,以免前置内容的修改影响后续节点的处理,特别是在插入元素的情况下。

const stack: Token[] = [];
// 从后向前遍历, 避免修改后, 影响后续 i 遍历
for (let i = closeIdx; i >= startIdx; i--) {
  const token = tokens[i];
  if (token.type === "bullet_list_open") {
    token.attrJoin("class", "bullet-summary-group");
  }
  if (token.type === "list_item_close") {
    stack.push(token);
  }
  if (token.type === "list_item_open") {
    const peer = stack.pop();
    rebuildLiTokens(state, i, peer, actions);
  }
}

在匹配到list_item_open节点时,就需要重建li元素结构了,这部分就会更复杂一些。首先我们创建一个对应元素区域的迭代器,来遍历openclose之间的所有节点。迭代器中重要的实现是要携带相关的meta信息,辅助计算层级关系。

let depth = 0;
for (let i = openIdx; i < tokens.length; i++) {
  const token = tokens[i];
  if (token.nesting >= 0) {
    depth++;
  }
  yield { token, depth: depth - 1, idx: i, serial: i - openIdx };
  if (token.nesting <= 0) {
    depth--;
    if (depth <= 0) break;
  }
}

li节点区域遍历过程中,我们需要根据depth来判断其直属子元素。如果直属子元素为ul,则代表该li元素嵌套了无序列表,这样就需要将其转换为details元素。注意,这里修改其type不应该影响外层的栈,需要注意保持关系正确。

// 查找 li 下的子项, 主要目的是检查其直属子元素
for (const node of walker) {
  const k = node.idx;
  const tokenK = node.token;
  if (node.depth !== 1) continue;
  // 直属的 ul 子项, 若是存在则需要转换为 details 组
  if (tokenK.type === "bullet_list_open") {
    // i 的 li 元素需要变为 details 元素
    liToken.type = "li_details_open";
    liToken.tag = "details";
  }
}

接下来,我们需要为i - k之间的元素创建summary元素,用以指定折叠标题。这里是最难以处理的点,因为不仅是修改内容,还需要插入新的token。并且需要对其peer节点进行处理,将其token.type转换为li_details_close元素。

// 为 i - k 之间的元素创建 summary
const sOpen = new state.Token("li_summary_open", "summary", 1);
const sClose = new state.Token("li_summary_close", "summary", -1);
// 现在 peer 是 i 之后的元素, 不会影响原始遍历 li 的栈平衡
if (peer) {
  peer.type = "li_details_close";
  peer.tag = "details";
}
// 处理 summary 元素的插入位置
actions.push({ idx: openIdx + 1, token: sOpen });
actions.push({ idx: k, token: sClose });

上述的actions是需要关注的点,我们并不会直接修改tokens数组,因为此时修改tokens数组会导致其长度发生变化,从而影响到后续节点的遍历,以及插入位置的计算。在这里我们统一处理插入行为,这里需要关注的是按索引从大到小排序, 后索引的元素, 不影响前索引的元素。

actions
  .sort((a, b) => b.idx - a.idx)
  .forEach(action => {
    tokens.splice(action.idx, 0, action.token);
  });

最后,由于我们插入了新的层级,我们需要将内部的level也更新一下。因此从这里也可以看出来level并不是那么准确,如果注册的插件并没有处理好level的话,则会影响到后续依赖该字段的插件。

// 处理 summary 及其内部元素的 level
sOpen.level = liToken.level + 1;
sClose.level = liToken.level + 1;
for (let i = openIdx + 1; i < k; i++) {
  const token = tokens[i];
  token.level = (token.level || 0) + 1;
}

CSS 样式

实际上,由于不同浏览器的details + summary元素的默认样式不同,因此需要对其样式进行统一化处理。不过,这部分主要是由组件库来实现的,我们只需要关注其基本功能即可。此外提一下,summary还是需要一个border样式的,特别是存在多行内容的情况下。

/*
 * Add the correct display in Edge, IE 10+, and Firefox.
 */
details {
  display: block;
}

/*
 * Add the correct display in all browsers.
 */
summary {
  display: list-item;
}

总结

在这里我们基于MdIt,解析了基础的无序列表结构,并且观察了其层级关系,设计出了一套DOM结构转换规则。基于此分别使用纯渲染模式以及解析后处理模式,实现了无序列表折叠插件,这种结构表达在思维导图和API参数表达中非常有用。

实际上,我们实现的插件还有很多可以优化的地方。首先我们可以将结构化表达和渲染时表达结合起来,在解析后处理时仅需要将需要相关token写入标记,在渲染时处理标签结构即可。此外,结构处理写入的时候实际上应该将所有变更统一处理,以避免影响现有遍历和判断,理论上应该引入OT-JSON来处理各个变更之间的相互影响。

每日一题

参考

Harbeth:高性能Metal图像处理库,让你的图片处理速度飞起来!

2026年4月1日 10:40

🎯 项目简介

Harbeth是一个基于Metal的高性能图像处理库,为iOS和macOS开发者提供了一套简洁、高效的图像滤镜解决方案。它不仅支持传统的图像滤镜效果,还能处理HDR图像,让你的应用在图像处理方面如虎添翼。

用故障艺术美学建立动态RGB通道分离 实时检测边缘并添加霓虹灯发光效果
ShiftGlitch.gif EdgeGlow.gif

✨ 核心功能与优势

1. 高性能Metal渲染

  • 利用Metal GPU加速,处理速度比CPU实现快10-50倍
  • 支持命令缓冲区池管理,优化GPU资源使用
  • 双缓冲技术,进一步提升处理性能

2. 丰富的滤镜效果

  • 基础滤镜:亮度、对比度、饱和度、色相调整
  • 高级效果:高斯模糊、锐化、边缘检测、色调映射
  • 创意滤镜:复古、赛博朋克、电影效果、HDR增强
  • 自定义滤镜:支持自定义Metal着色器

超过 200+ 内置滤镜,组织成直观的类别,涵盖从基本颜色调整到高级艺术效果的各种功能

3. HDR图像处理

  • 支持rgba16Float和rgba32Float格式的HDR纹理
  • 内置HDR到SDR的色调映射算法
  • 保留HDR图像的细节和动态范围

4. 易用的API设计

  • 链式调用风格,代码简洁易读
  • 统一的输入输出接口,支持多种图像类型
  • 异步处理支持,避免主线程阻塞

5. 跨平台支持

  • 同时支持iOS、macOS和tvOS
  • 适配不同设备的Metal性能特性
  • 自动处理设备内存限制

🚀 快速开始

安装方式

// Swift Package Manager
.package(url: "https://github.com/yangKJ/Harbeth.git", from: "0.0.1")

// CocoaPods
pod 'Harbeth'

基础使用示例

import Harbeth

// 加载图像
let image = UIImage(named: "example")!

// 创建滤镜
let filter = C7Brightness(brightness: 0.2)

// 应用滤镜
let result = try? HarbethIO(element: image, filter: filter).output() as? UIImage

// 显示结果
imageView.image = result

链式滤镜示例

// 组合多个滤镜
let filters: [C7FilterProtocol] = [
    C7Brightness(brightness: 0.1),
    C7Contrast(contrast: 1.2),
    C7Saturation(saturation: 1.3),
    C7GaussianBlur(radius: 2.0)
]

// 应用滤镜链
let result = try? HarbethIO(element: image, filters: filters).output() as? UIImage

🎨 高级特性

1. 自定义Metal着色器

// 创建自定义滤镜
struct CustomFilter: C7FilterProtocol {
    var modifier: ModifierEnum {
        return .compute(kernel: "customKernel")
    }
    
    var factors: [Float] = [0.5, 0.5, 0.5]
}

// 应用自定义滤镜
let customFilter = CustomFilter()
let result = try? HarbethIO(element: image, filter: customFilter).output() as? UIImage

2. HDR图像处理

// 加载HDR图像
let hdrImage = UIImage(named: "hdr_example")!

// 应用HDR到SDR转换
let hdrFilter = HDRToSDR()
let result = try? HarbethIO(element: hdrImage, filter: hdrFilter).output() as? UIImage

3. 实时处理

// 实时处理相机捕获的图像
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
    // 异步处理
    HarbethIO(element: sampleBuffer, filter: C7Vibrance(vibrance: 0.5)).transmitOutput { result in
        switch result {
        case .success(let processedBuffer):
            // 处理成功,显示结果
            DispatchQueue.main.async {
                self.previewLayer.enqueue(processedBuffer)
            }
        case .failure(let error):
            print("处理失败: \(error)")
        }
    }
}

⚡ 性能优势

Harbeth在性能方面的表现令人印象深刻:

  • 处理速度:比Core Image快3-5倍,比CPU处理快10-50倍
  • 内存使用:智能纹理池管理,减少内存分配
  • 电池消耗:优化的GPU使用,降低能耗
  • 大图像处理:支持处理高分辨率图像和视频帧

📱 适用场景

Harbeth适用于各种需要图像处理的场景:

  1. 照片编辑应用:快速应用滤镜效果
  2. 相机应用:实时预览和处理
  3. 视频编辑:逐帧处理视频
  4. AR/VR应用:实时图像处理
  5. 社交媒体:快速滤镜效果
  6. HDR图像处理:专业图像处理

🖥️ macOS 支持

Harbeth 完全支持 macOS 平台,为桌面应用提供强大的图像处理能力,打造原生、优化的用户体验:

🎨 macOS 展示

探索 Harbeth 在 macOS 上的强大功能:

🌟 总结

Harbeth是一个功能强大、性能优异的Metal图像处理库,它不仅提供了丰富的滤镜效果,还支持HDR图像处理,为iOS和macOS开发者提供了一套完整的图像处理解决方案。

无论是快速原型开发还是生产环境应用,Harbeth都能满足你的需求。它的高性能特性让图像处理不再成为应用的性能瓶颈,而简洁的API设计则让开发过程更加愉快。

如果你正在寻找一个强大而灵活的图像处理库,Harbeth绝对值得尝试!

📁 项目链接

  • GitHub: github.com/yangKJ/Harb…
  • 文档: 详细的API文档和使用示例
  • 示例应用: 包含多种使用场景的示例代码

让Harbeth为你的应用添加绚丽的图像处理能力,让每一张图片都成为艺术品! 🎨✨

iOS开发有什么好用的图片浏览器?

作者 囧叔
2026年2月11日 11:17

年更博主终于推出新版本,JXPhotoBrowser v4.0 全面重构焕新!

JXPhotoBrowser 是一个轻量级、可定制的 iOS 图片/视频浏览器,实现 iOS 系统相册的交互体验。支持缩放、拖拽关闭、自定义转场动画等特性,架构清晰,易于集成和扩展。同时支持 UIKitSwiftUI 两种调用方式(SwiftUI 通过桥接层集成,详见 Demo-SwiftUI 示例工程)。

首页列表 图片浏览 下拉关闭
homepage.png browsing.png pull_down.png

核心设计

  • 零数据模型依赖:框架不定义任何数据模型,业务方完全使用自己的数据结构,通过 delegate 配置 Cell 内容。
  • 图片加载完全开放:框架不内置图片加载逻辑,业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。
  • 极简 Cell 协议JXPhotoBrowserCellProtocol 仅包含 browsertransitionImageView 两个属性,将浏览器与具体 Cell 实现解耦,既可以直接使用内置的 JXZoomImageCell,也可以实现完全自定义的 Cell。
  • 协议驱动的数据与 UI 解耦JXPhotoBrowserDelegate 只关心数量、Cell 与转场,不强制统一的数据模型。

功能特性

  • 多模式浏览:支持水平(Horizontal)和垂直(Vertical)两个方向的滚动浏览。
  • 无限循环:支持无限循环滚动(Looping),无缝切换首尾图片。
  • 手势交互
    • 双击缩放:仿系统相册支持双击切换缩放模式。
    • 捏合缩放:支持双指捏合随意缩放(1.0x - 3.0x)。
    • 拖拽关闭:支持下滑手势(Pan)交互式关闭,伴随图片缩小和背景渐变效果。
  • 转场动画
    • Fade:经典的渐隐渐现效果。
    • Zoom:类似微信/系统相册的缩放转场效果,无缝衔接列表与大图。
    • None:无动画直接显示。
  • 浏览体验优化:基于 UICollectionView 复用机制,内存占用低,滑动流畅。
  • 自定义 Cell 支持:内置图片 JXZoomImageCell,也支持通过协议与注册机制接入完全自定义的 Cell(如视频播放 Cell)。
  • Overlay 组件机制:支持按需装载附加 UI 组件(如页码指示器、关闭按钮等),默认不装载任何组件,零开销。内置 JXPageIndicatorOverlay 页码指示器。

核心架构

  • JXPhotoBrowserViewController:核心控制器,继承自 UIViewController。内部维护一个 UICollectionView 用于展示图片页面,负责处理全局配置(如滚动方向、循环模式)和手势交互(如下滑关闭)。
  • JXZoomImageCell:可缩放图片展示单元,继承自 UICollectionViewCell 并实现 JXPhotoBrowserCellProtocol。内部使用 UIScrollView 实现缩放,负责单击、双击等交互。通过 imageView 属性供业务方设置图片。
  • JXImageCell:轻量级图片展示 Cell,不支持缩放手势,适用于 Banner 等嵌入式场景。内置可选的加载指示器(默认不启用),支持样式定制。
  • JXPhotoBrowserCellProtocol:极简 Cell 协议,仅需 browser(弱引用浏览器)和 transitionImageView(转场视图)两个属性即可接入浏览器,另提供 photoBrowserDismissInteractionDidChange 可选方法响应下拉关闭交互,不强制依赖特定基类。
  • JXPhotoBrowserDelegate:代理协议,负责提供总数、Cell 实例、生命周期回调(willDisplay/didEndDisplaying)以及转场动画所需的缩略图视图等,不强制要求统一的数据模型。
  • JXPhotoBrowserOverlay:附加视图组件协议,定义了 setupreloadDatadidChangedPageIndex 三个方法,用于页码指示器、关闭按钮等附加 UI 的统一接入。
  • JXPageIndicatorOverlay:内置页码指示器组件,基于 UIPageControl,支持自定义位置和样式,通过 addOverlay 按需装载。

依赖

  • 框架本身依赖:UIKit(核心),无任何第三方依赖
  • 图片加载:框架不内置图片加载逻辑,业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。
  • 示例工程:
    • Demo-UIKit:UIKit 示例,使用 CocoaPods 集成,依赖 Kingfisher 加载图片,演示完整功能(图片浏览、视频播放、Banner 轮播等)。
    • Demo-SwiftUI:SwiftUI 示例,使用 SPM 集成,演示如何通过桥接层在 SwiftUI 中使用 JXPhotoBrowser(媒体网格、设置面板、图片浏览)。
    • Demo-Carthage:UIKit 示例,使用 Carthage 集成。首次使用需在 Demo-Carthage 目录下执行 carthage update --use-xcframeworks --platform iOS 构建框架。

隐私清单(Privacy Manifest)

本框架已包含 PrivacyInfo.xcprivacy 隐私清单文件,符合 Apple 自 2024 年春季起对第三方 SDK 的隐私清单要求。

JXPhotoBrowser 不追踪用户、不收集任何数据、不使用任何 Required Reason API,隐私清单中所有字段均为空声明。通过 CocoaPods、SPM 或 Carthage 集成时,隐私清单会自动包含在框架中,无需额外配置。

系统要求

  • iOS 12.0+
  • Swift 5.4+

安装

CocoaPods

在你的 Podfile 中添加:

pod 'JXPhotoBrowser', '~> 4.0.1'

注意:Xcode 15 起默认开启了 User Script SandboxingENABLE_USER_SCRIPT_SANDBOXING=YES),该沙盒机制会阻止 CocoaPods 的 Run Script 阶段(如 [CP] Copy Pods Resources[CP] Embed Pods Frameworks 等)访问沙盒外的文件,导致编译失败。需要在编译 Target 的 Build Settings 中将 ENABLE_USER_SCRIPT_SANDBOXING 设置为 NO

Target → Build Settings → Build Options → User Script Sandboxing → No

Swift Package Manager

在 Xcode 中:

  1. 选择 File > Add Package Dependencies...
  2. 输入仓库地址:https://github.com/JiongXing/PhotoBrowser
  3. 选择版本规则后点击 Add Package

或在 Package.swift 中添加依赖:

dependencies: [
    .package(url: "https://github.com/JiongXing/PhotoBrowser", from: "4.0.1")
]

Carthage

在你的 Cartfile 中添加:

github "JiongXing/PhotoBrowser"

然后运行:

carthage update --use-xcframeworks --platform iOS

构建完成后,将 Carthage/Build/JXPhotoBrowser.xcframework 拖入 Xcode 工程的 Frameworks, Libraries, and Embedded Content 中,并设置为 Embed & Sign

手动安装

Sources 目录下的所有文件拖入你的工程中。

快速开始

基础用法

import JXPhotoBrowser

// 1. 创建浏览器实例
let browser = JXPhotoBrowserViewController()
browser.delegate = self
browser.initialIndex = indexPath.item // 设置初始索引

// 2. 配置选项(可选)
browser.scrollDirection = .horizontal // 滚动方向
browser.transitionType = .zoom        // 转场动画类型
browser.isLoopingEnabled = true       // 是否开启无限循环

// 3. 展示
browser.present(from: self)

实现 Delegate

遵守 JXPhotoBrowserDelegate 协议,提供数据和转场支持:

import Kingfisher // 示例使用 Kingfisher,可替换为任意图片加载库

extension ViewController: JXPhotoBrowserDelegate {
    // 1. 返回图片总数
    func numberOfItems(in browser: JXPhotoBrowserViewController) -> Int {
        return items.count
    }
    
    // 2. 提供用于展示的 Cell
    func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell {
        let cell = browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell
        return cell
    }
    
    // 3. 当 Cell 将要显示时加载图片
    func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {
        guard let photoCell = cell as? JXZoomImageCell else { return }
        let item = items[index]
        
        // 使用 Kingfisher 加载图片(可替换为 SDWebImage 或其他库)
        let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: item.thumbnailURL.absoluteString)
        photoCell.imageView.kf.setImage(with: item.originalURL, placeholder: placeholder) { [weak photoCell] _ in
            photoCell?.setNeedsLayout()
        }
    }
    
    // 4. (可选) Cell 结束显示时清理资源(如取消加载、停止播放等)
    func photoBrowser(_ browser: JXPhotoBrowserViewController, didEndDisplaying cell: JXPhotoBrowserAnyCell, at index: Int) {
        // 可用于取消图片加载、停止视频播放等
    }
    
    // 5. (可选) 支持 Zoom 转场:提供列表中的缩略图视图
    func photoBrowser(_ browser: JXPhotoBrowserViewController, thumbnailViewAt index: Int) -> UIView? {
        let indexPath = IndexPath(item: index, section: 0)
        guard let cell = collectionView.cellForItem(at: indexPath) as? MyCell else { return nil }
        return cell.imageView
    }
    
    // 6. (可选) 控制缩略图显隐,避免 Zoom 转场时视觉重叠
    func photoBrowser(_ browser: JXPhotoBrowserViewController, setThumbnailHidden hidden: Bool, at index: Int) {
        let indexPath = IndexPath(item: index, section: 0)
        if let cell = collectionView.cellForItem(at: indexPath) as? MyCell {
            cell.imageView.isHidden = hidden
        }
    }
    
    // 7. (可选) 自定义 Cell 尺寸,默认使用浏览器全屏尺寸
    func photoBrowser(_ browser: JXPhotoBrowserViewController, sizeForItemAt index: Int) -> CGSize? {
        return nil // 返回 nil 使用默认尺寸
    }
}

在 SwiftUI 中使用

JXPhotoBrowser 是基于 UIKit 的框架,在 SwiftUI 项目中可通过桥接方式集成。Demo-SwiftUI 示例工程演示了完整的集成方案。

核心思路

  1. 网格和设置面板使用纯 SwiftUI 实现(LazyVGridPickerAsyncImage 等)
  2. 全屏图片浏览器通过桥接层调用 JXPhotoBrowserViewController
  3. 创建一个 Presenter 类实现 JXPhotoBrowserDelegate,获取当前 UIViewController 后调用 browser.present(from:)

桥接层示例

import JXPhotoBrowser

/// 封装 JXPhotoBrowserViewController 的创建、配置和呈现
final class PhotoBrowserPresenter: JXPhotoBrowserDelegate {
    private let items: [MyMediaItem]

    func present(initialIndex: Int) {
        guard let viewController = topViewController() else { return }

        let browser = JXPhotoBrowserViewController()
        browser.delegate = self
        browser.initialIndex = initialIndex
        browser.transitionType = .fade
        browser.addOverlay(JXPageIndicatorOverlay())
        browser.present(from: viewController)
    }

    func numberOfItems(in browser: JXPhotoBrowserViewController) -> Int {
        items.count
    }

    func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell {
        browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell
    }

    func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {
        guard let photoCell = cell as? JXZoomImageCell else { return }
        // 加载图片到 photoCell.imageView ...
    }
}

在 SwiftUI View 中调用

struct ContentView: View {
    // 持有 presenter(JXPhotoBrowserViewController.delegate 为 weak,需要外部强引用)
    @State private var presenter: PhotoBrowserPresenter?

    var body: some View {
        LazyVGrid(columns: columns) {
            ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
                AsyncImage(url: item.thumbnailURL)
                    .onTapGesture {
                        let p = PhotoBrowserPresenter(items: items)
                        presenter = p
                        p.present(initialIndex: index)
                    }
            }
        }
    }
}

注意JXPhotoBrowserViewControllerdelegateweak 引用,必须在 SwiftUI 侧用 @State 持有 Presenter 实例,否则它会在创建后立即被释放。

关于 Zoom 转场

Demo-SwiftUI 示例工程未演示 Zoom 转场动画,默认使用 Fade 转场。

原因:Zoom 转场依赖 thumbnailViewAt delegate 方法返回列表中缩略图的 UIView 引用,框架通过该引用计算动画起止位置并构建临时动画视图。而 SwiftUI 的 AsyncImage 等原生视图无法直接提供底层 UIView 引用。

如需自行实现:可将缩略图从 AsyncImage 替换为 UIViewRepresentable 包裹的 UIImageView,从而获取真实的 UIView 引用,再通过 thumbnailViewAtsetThumbnailHidden 两个 delegate 方法提供给框架即可。具体的 Zoom 转场接入方式可参考 Demo-UIKit 示例工程。

JXImageCell 加载指示器

JXImageCell 内置了一个 UIActivityIndicatorView 加载指示器,默认不启用。适用于 Banner 等嵌入式场景下展示图片加载状态。

启用加载指示器

let cell = browser.dequeueReusableCell(withReuseIdentifier: JXImageCell.reuseIdentifier, for: indexPath) as! JXImageCell

// 启用加载指示器
cell.isLoadingIndicatorEnabled = true
cell.startLoading()

// 图片加载完成后停止
cell.imageView.kf.setImage(with: imageURL) { [weak cell] _ in
    cell?.stopLoading()
}

自定义样式

通过 loadingIndicator 属性可直接定制指示器的外观:

cell.loadingIndicator.style = .large       // 指示器尺寸
cell.loadingIndicator.color = .systemBlue  // 指示器颜色

自定义 Cell

框架支持两种方式创建自定义 Cell:

方式一:继承 JXZoomImageCell(推荐)

继承 JXZoomImageCell 可自动获得缩放、转场、手势等功能。以 Demo 中的 VideoPlayerCell 为例,它继承 JXZoomImageCell 并添加了视频播放能力:

class VideoPlayerCell: JXZoomImageCell {
    static let videoReuseIdentifier = "VideoPlayerCell"
    
    private var player: AVPlayer?
    private var playerLayer: AVPlayerLayer?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // 自定义初始化:添加 loading 指示器等
    }
    
    /// 配置视频资源
    func configure(videoURL: URL, coverImage: UIImage? = nil) {
        imageView.image = coverImage
        // 创建播放器并开始播放...
    }
    
    /// 重写单击手势:暂停视频或关闭浏览器
    override func handleSingleTap(_ gesture: UITapGestureRecognizer) {
        if isPlaying {
            pauseVideo()
        } else {
            browser?.dismissSelf()
        }
    }
}

方式二:实现协议(完全自定义)

直接实现 JXPhotoBrowserCellProtocol 协议,获得完全的自由度:

class StandaloneCell: UICollectionViewCell, JXPhotoBrowserCellProtocol {
    static let reuseIdentifier = "StandaloneCell"
    
    // 必须实现:弱引用浏览器(避免循环引用)
    weak var browser: JXPhotoBrowserViewController?
    
    // 可选实现:用于 Zoom 转场动画,返回 nil 则使用 Fade 动画
    var transitionImageView: UIImageView? { imageView }
    
    let imageView = UIImageView()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // 自定义初始化
    }
    
    // 可选实现:下拉关闭交互状态变化时调用
    // isInteracting 为 true 表示用户正在下拉(图片缩小跟随手指),false 表示交互结束(回弹恢复)
    // 适用于在拖拽关闭过程中暂停视频、隐藏附加 UI 等场景
    func photoBrowserDismissInteractionDidChange(isInteracting: Bool) {
        // 例如:下拉时暂停视频播放
    }
}

注册和使用自定义 Cell

let browser = JXPhotoBrowserViewController()

// 注册自定义 Cell(必须在设置 delegate 之前)
browser.register(VideoPlayerCell.self, forReuseIdentifier: VideoPlayerCell.videoReuseIdentifier)

browser.delegate = self
browser.present(from: self)

// 在 delegate 中使用
func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell {
    let cell = browser.dequeueReusableCell(withReuseIdentifier: VideoPlayerCell.videoReuseIdentifier, for: indexPath) as! VideoPlayerCell
    cell.configure(videoURL: url, coverImage: thumbnail)
    return cell
}

Overlay 组件

框架提供了通用的 Overlay 组件机制,用于在浏览器上层叠加附加 UI(如页码指示器、关闭按钮、标题栏等)。默认不装载任何 Overlay,业务方按需装载

使用内置页码指示器

框架内置了 JXPageIndicatorOverlay(基于 UIPageControl),一行代码即可装载:

let browser = JXPhotoBrowserViewController()
browser.addOverlay(JXPageIndicatorOverlay())

支持自定义位置和样式:

let indicator = JXPageIndicatorOverlay()
indicator.position = .bottom(padding: 20)  // 位置:底部距离 20pt(也支持 .top)
indicator.hidesForSinglePage = true         // 仅一页时自动隐藏
indicator.pageControl.currentPageIndicatorTintColor = .white
indicator.pageControl.pageIndicatorTintColor = .lightGray
browser.addOverlay(indicator)

自定义 Overlay

实现 JXPhotoBrowserOverlay 协议即可创建自定义组件:

class CloseButtonOverlay: UIView, JXPhotoBrowserOverlay {
    
    func setup(with browser: JXPhotoBrowserViewController) {
        // 在此完成布局(如添加约束)
    }
    
    func reloadData(numberOfItems: Int, pageIndex: Int) {
        // 数据或布局变化时更新
    }
    
    func didChangedPageIndex(_ index: Int) {
        // 页码变化时更新
    }
}

// 装载
browser.addOverlay(CloseButtonOverlay())

多个 Overlay 可同时装载,互不干扰:

browser.addOverlay(JXPageIndicatorOverlay())
browser.addOverlay(CloseButtonOverlay())

保存图片/视频到相册

框架本身不内置保存功能,业务方可自行实现。Demo 中演示了通过长按手势弹出 ActionSheet 保存媒体到系统相册的完整流程。

前提:需要在 Info.plist 中配置 NSPhotoLibraryAddUsageDescription(写入相册权限描述)。

核心步骤

  1. 添加长按手势:在自定义 Cell 中添加 UILongPressGestureRecognizer
  2. 弹出 ActionSheet:通过 browser 属性获取浏览器控制器来 present。
  3. 请求权限并保存:使用 PHPhotoLibrary 请求权限,下载后写入相册。

示例:在自定义 Cell 中长按保存

以 Demo 中的 VideoPlayerCell 为例,继承 JXZoomImageCell 后添加长按保存能力:

import Photos

class VideoPlayerCell: JXZoomImageCell {
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // 添加长按手势
        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
        scrollView.addGestureRecognizer(longPress)
    }
    
    @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
        guard gesture.state == .began else { return }
        
        let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
        alert.addAction(UIAlertAction(title: "保存视频", style: .default) { [weak self] _ in
            self?.saveVideoToAlbum()
        })
        alert.addAction(UIAlertAction(title: "取消", style: .cancel))
        
        // 通过 browser 属性获取浏览器控制器来 present
        browser?.present(alert, animated: true)
    }
    
    private func saveVideoToAlbum() {
        guard let url = videoURL else { return }
        
        // 1. 请求相册写入权限
        PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
            guard status == .authorized || status == .limited else { return }
            
            // 2. 下载视频(远程 URL 需先下载到本地)
            URLSession.shared.downloadTask(with: url) { tempURL, _, _ in
                guard let tempURL else { return }
                
                // 3. 写入相册
                PHPhotoLibrary.shared().performChanges({
                    PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: tempURL)
                }) { success, error in
                    // 处理结果...
                }
            }.resume()
        }
    }
}

保存图片的流程类似,将下载部分替换为图片写入即可:

// 下载图片数据
URLSession.shared.dataTask(with: imageURL) { data, _, _ in
    guard let data, let image = UIImage(data: data) else { return }
    
    PHPhotoLibrary.shared().performChanges({
        PHAssetChangeRequest.creationRequestForAsset(from: image)
    }) { success, error in
        // 处理结果...
    }
}.resume()

常见问题 (FAQ)

Q: Zoom 转场动画时图片尺寸不对或有闪烁现象?

A: 这通常是因为打开浏览器时,目标 Cell 的 imageView 还没有设置图片,导致其 bounds 为 zero。

解决方案:在 willDisplay 代理方法中,确保同步设置占位图。例如使用 Kingfisher 时:

func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {
    guard let photoCell = cell as? JXZoomImageCell else { return }
    
    // 同步从缓存取出缩略图作为占位图
    let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: thumbnailURL.absoluteString)
    photoCell.imageView.kf.setImage(with: imageURL, placeholder: placeholder) { [weak photoCell] _ in
        photoCell?.setNeedsLayout()
    }
}

这样可以确保转场动画开始时,Cell 已经有正确尺寸的图片,动画效果更加流畅。

项目开源地址

github.com/JiongXing/P…

❌
❌