阅读视图

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

在学校和工作中遇到的一些git用法

一、git的基础用法

常用 Git 命令清单

1、提交流程

//查看当前哪些文件发生更改
git status   

//将本地代码 添加到 暂存区  [全文件名:添加这一个文件   .:所有的发生更改文件都添加到暂存区]
git add 全文件名|.

//将暂存区的代码 提交到暂存区--生成版本号
git commit -m "提交版本备注信息"

//将有版本的暂存区代码 推送到远程仓库的固定分支
git push origin master

2、拉取流程

  • 第一次克隆别人的项目
git clone 别人项目的地址(https://gitee.com/.....)
  • 拉取最新版本
git pull origin master 

3、小组项目

如果小组项目 在提交前 需要先拉取项目

  • 提交流程(个人经验---不一定正确)
//先拉取一次项目
git pull origin master

git add .
git commit -m "xxx"

//再拉取一次项目
git pull origin master

//最后在push
git push origin master

4、git commit -m 规范(常用)

git commit -m "feat:新功能"

git commit -m "fix:修补bug"

5、其他语句

  • 切换分支:git checkout 分支名
  • 创建分支:git branch 分支名
  • 删除分支:git branch -d 分支名
  • 合并:git merge 分支名

7.png

6、合并分支到主分支

git merge 要合并的分支

然后git push

二、git的其他用法

1、在vscode 使用git

在我们创建完项目后,将代码传上 git 后,我们再进项目修改会发现在一些代码文件的右边后有一些不同颜色的大写字母1.png就像这样

  • 关于字母的解释【网上找的】

  • A:A是Added的缩写。表示这个文件是新增的,它在本地仓库中不存在,但是已经被添加到暂存区,等待提交。
  • U:U是Untracked的缩写。表示这个文件是未跟踪的,它在本地仓库中不存在,也没有被添加到暂存区,需要你手动添加或忽略。
  • M:M是Modified的缩写。表示这个文件是被修改的,它在本地仓库中存在,但是已经被修改
  • D:D是Deleted的缩写。表示这个文件是被删除的,它在本地仓库中存在,但是已经被删除
1、基础用法

2.png

点击提交后会弹出对话框 【是】

3.png 【同步上去】

4.png

【确定】

5.png

此外,也可以直接点提交按钮进行提交

  • 合并分支【在合并之前可以先 git fetch一下】

  • git fetch可以定期检查远程仓库是否有新的提交,以保持你的本地仓库与远程仓库保持同步

    如果遇到代码冲突,要先解决冲突

6.png

2、git commit --amend 【使用强制推送的时候,一定要保证分支是私有的】

当我们代码已经提交之后,后面又去改了代码,不想重新commit (创建新的节点)就可以使用

git commit --amend
  • 步骤

    • git add #暂存修改
    • git commit --amend #修改最后一次的commit
    • git push --force #强制推送 (覆盖远程)
  • 如果遇到git 操作之后有一堆信息显示,但是不是报错,是正常信息,可以使用 :wq 退出

3、git rebase 变基分支

合并自己的分支用rebase,基于自己的分支进行rebase,不能是共有分支,必须是私有分支

变基就相当于把自己的分支进行一个更新

  • 步骤(假设自己分支名为feature,主分支为master)

    • 1、当要改自己的分支,但是自己的分支落后于主分支,将自己分支的代码先上传到远程分支仓库feature
    • 2、切到主分支msater,获取最新的代码 git fetch -p 之后合并
    • 3、本地master分支更新之后,切回到feature分支,进行变基分支 git rebase msater,将master分支更新的变基到feature分支上面
    • 现在feature本地分支是最新的,但是可能会和远程feature分支有冲突,所以不要合并,直接git push origin --force
  • 如果出现冲突,就查看冲突的地方,进行更改

4、git cherry-pick

假设我们现在有两个分支,一个master分支、一个feature分支

我们在feature分支上面提交了a和b,但是我们只想将b的修改合并到master上面,这个时候就可以用到 git cherry-pick

  • 步骤

    • 切换到需要pick的分支,比如上面所举例的master分支

    • git cherry-pick 【commit的提交的hash】如下图 git cherry-pick ac53f7c0fdfd2efa236e5dccb01ee39c8af1c443

cherry-pick1.png

5、git stash 暂存文件

假设现在有两个分支,一个dev1,一个dev2 我们在dev2里面改了代码,发现应该在dev1里面改这个代码的时候 或者,你要去dev1,但是不想讲dev2的代码提交到远程分支 这时候就可以用到git stash

  • 步骤

    • git stash (在dev2 分支执行)
    • git checkout dev1(切换到 dev1 分支)
    • git stash pop (在dev1 分支执行)

一个专业的前端如何在国内安装 `bun`

image.png

本文以 macOS 为例,但思路也适用于 Windows 系统。

本文思路适用于任何使用 bash 安装的命令。学会后一劳永逸!

对于 bun 我们有多种安装方式,可以使用现有的包管理器比如 npm i -g bun

但是有个麻烦事:npm 一般是通过 nvm 安装的,如果 nvm 切换版本,则无法使用 buncommand not found: bun),还得继续 npm i 安装一遍,颇为麻烦。

所以 bun 官方一般推荐通过 shell 脚本的方式安装,以下安装命令来自 bun 官网 bun.com/docs/instal…

curl -fsSL https://bun.com/install | bash # for macOS, Linux, and WSL

[!TIPS] 其实 Windows 系统安装了 git bash 上述命令也是可用的。

但是如果直接运行我们会发现超时报错。通过下载安装脚本 bun.com/install 和搜索关键词 github 我们在 101 行发现:

GITHUB=${GITHUB-"https://github.com"}

github_repo="$GITHUB/oven-sh/bun"

原因很清楚了国内无法访问 github,修复也很简单找一个 proxy 即可,这里我用的是 gh-proxy.com/ (2025-09 可用)。而且可以预留了环境变量 GITHUB

GITHUB=${GITHUB-"https://github.com"} 意思是如果环境变量存在 GITHUB 则使用,否则兜底到 "https://github.com",对于 JS 的默认值(GITHUB = GITHUB || "https://github.com")。

第一步:移除无用包

可选。主要是为了删除无用包减少磁盘浪费,以及避免冲突。切换到曾经安装过 bun 的 node.js 版本。

nvm use 20 # 切换到之前安装过 bun 的 node.js 版本
npm uninstall -g bun

第二步:替换成可用 proxy

因为这里 bun 做得特别好,相对于 pnpm 安装脚本来说(pnpm 安装详见我的上一篇文章)预留了环境变量 GITHUB,也就是我们可以灵活替换 github 地址,相当于给了我们留了一个『后门』,于细微初见架构。故我们有三种安装 bun 的方式,从简单到容易。

方式一:环境变量 + 远程脚本

充分利用环境变量特点。

GITHUB='https://gh-proxy.com/https://github.com' curl -fsSL https://bun.com/install | bash # for macOS, Linux, and WSL

方式二:环境变量 + 本地脚本

下载 bun.com/install 到本地并取名 bun-install.sh 并修改成如下:

[!TIPS] 注意:.sh 结尾可选,因为 bash 命令会将其当做脚本运行,这里稳妥起见还是 .sh 结尾。

GITHUB='https://gh-proxy.com/https://github.com' bash ~/Downloads/bun-install.sh

会自动将下载地址改成(macOS 系统下):

# 此处无需我们修改,只是阐述背后发生的事情
- https://github.com/oven-sh/bun/releases/latest/download/bun-darwin-aarch64.zip
+ https://gh-proxy.com/https://github.com/oven-sh/bun/releases/latest/download/bun-darwin-aarch64.zip

执行效果:

######################################################################### 100.0%
bun was installed successfully to ~/.bun/bin/bun

Added "~/.bun/bin" to $PATH in "~/.zshrc"

To get started, run:

  exec /bin/zsh
  bun --help

确实看到 .zshrc 文件尾部增加了:

# bun completions
[ -s "/Users/legend80s/.bun/_bun" ] && source "/Users/legend80s/.bun/_bun"

# bun
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"

方式三:修改本地脚本

- GITHUB=${GITHUB-"https://github.com"}
+ GITHUB=${GITHUB-"https://gh-proxy.com/https://github.com"}

然后执行:

bash ~/Downloads/bun-install.sh

效果

重新开一个 terminal 让更新后的 .zshrc 生效或者直接 source .zshrc 然后,

试试 bun -v 输出 1.2.22(2025-09-27)。

再试试 bunx bunx ydd -e -s -c=a bun

一样成功 🎉。

总结

本文我们学会了不修改 shell 脚本安装开源命令,得益于环境变量以及默认值的灵活性。

啥时候给 pnpm 的脚本提个 issue 或 PR 让它也增加环境变量这样就更方便了。

Git和GitHub终极秘籍:50个命令让你从新手秒变专家

一、基本的 Git 命令

  1. 初始化一个新的 git 存储库

    git init
    
  2. 克隆现有存储库

    git clone https://github.com/user/repo.git
    
  3. 检查您的存储库的状态

    git status
    
  4. 暂存所有提交更改

    git add .
    
  5. 用有意义的信息做出承诺

    git commit -m "Add feature X with proper validation"
    
  6. 将提交推送到远程分支

    git push origin main
    
  7. 从远程提取最新更改

    git pull origin main
    
  8. 创建一个新分支并切换到它

    git checkout -b feature/new-ui
    
  9. 切换分支

    git checkout develop
    
  10. 在本地删除分支

    git branch -d feature/old-branch
    

二、GitHub 特定的命令和技巧

  1. 使用 GitHub CLI 创建新存储库

    gh repo create my-new-project --public --description "A new repository"
    
  2. 在 GitHub 页面上打开当前存储库

    gh repo view --web
    
  3. 列出 repo 的所有拉取请求

    gh pr list
    
  4. 在本地签出拉取请求

    gh pr checkout 42
    
  5. 通过 CLI 创建拉取请求

    gh pr create --title "Add new login flow" --body "Implemented OAuth2 login" --base main --head feature/login
    

三、分支与合并

  1. 将分支合并到当前分支中

    git merge feature/new-ui
    
  2. 将当前分支变基到 main

    git rebase main
    
  3. 如果发生冲突,则中止合并或变基

    git merge --abort
    

四、撤消和重置

  1. 提交前取消暂存文件

    git reset HEAD <file>
    
  2. 丢弃文件中未暂存的更改

    git checkout -- <file>
    
  3. 通过创建新提交来恢复提交

    git revert <commit_hash>
    
  4. 重置提交和更改(危险:谨慎使用)

    git reset --hard HEAD~1
    

五、有用的 Git 别名示例

  1. 快速状态命令

    git config --global alias.s 'status -s'
    
  2. 用图表和颜色记录一条线

    git config --global alias.lg "log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"
    

六、管理远程存储库

  1. 添加远程源

    git remote add origin https://github.com/user/repo.git
    
  2. 更改远程源的 URL

    git remote set-url origin git@github.com:user/repo.git
    
  3. 移除遥控器

    git remote remove origin
    

七、标记发布

  1. 创建带注释的标签

    git tag -a v1.0 -m "Version 1.0 release"
    
  2. 将标签推送到遥控器

    git push origin v1.0
    
  3. 删除远程标签

    git push --delete origin v1.0
    

八、GitHub Actions 片段

  1. Node.js项目的基本工作流程

    name: Node CI
    
    on: [push, pull_request]
    
    jobs:
      build:
        runs-on: ubuntu-latest
    
        steps:
          - uses: actions/checkout@v3
          - uses: actions/setup-node@v3
            with:
              node-version: '16'
          - run: npm install
          - run: npm test
    
  2. 在多个 Node 版本上运行测试

    strategy:
      matrix:
        node-version: [12, 14, 16]
    
    steps:
      - uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
    
  3. GitHub Actions 中的缓存依赖项

    - name: Cache NPM modules
      uses: actions/cache@v3
      with:
        path: ~/.npm
        key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
        restore-keys: |
          ${{ runner.os }}-npm-
    
  4. 上传工件

    - uses: actions/upload-artifact@v3
      with:
        name: my-artifact
        path: path/to/file
    
  5. 在 GitHub Actions 中通知 Slack

    - name: Slack Notification
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        fields: repo,message,commit,author
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
    

九、GitHub CLI 片段

  1. 列出当前用户的存储库

    gh repo list --limit 10
    
  2. 创建一个新问题

    gh issue create --title "Bug report" --body "There is an issue with login"
    
  3. 查看拉取请求详细信息

    gh pr view 123 --web
    
  4. 关闭问题

    gh issue close 42
    
  5. 将自己分配给一个问题

    gh issue edit 42 --add-assignee @me
    

十、GitHub Markdown 片段

  1. 任务列表语法

    - [x] Item 1 done
    - [ ] Item 2 pending
    
  2. 表格示例

    | Feature  | Supported |
    | -------- | --------- |
    | Login    | ✅        |
    | Signup   | ❌        |
    
  3. 嵌入图像

    ![Alt text](https://via.placeholder.com/150)
    

十一、合作与审查

  1. 通过 CLI 合并拉取请求

    gh pr merge 123 --squash --delete-branch
    
  2. 请求团队成员审核

    gh pr review 123 --request "username"
    
  3. 查看 PR 中的更改

    gh pr diff 123
    

十二、使用 GitHub Actions 实现自动化

  1. PR 上的自动标签

    name: Label PRs
    
    on:
      pull_request:
        types: [opened, synchronize]
    
    jobs:
      label:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/labeler@v3
    
  2. 自动关闭过时问题

    name: Close stale
    
    on:
      schedule:
        - cron: '0 0 * * 0'
    
    jobs:
      stale:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/stale@v3
    

十三、Git 提示与技巧

  1. 显示目录中每个文件的上次提交

    git ls-files | xargs -I{} git log -1 --format="%h %ad %an" -- {}
    
  2. 以交互方式压缩最后 N 个提交

    git rebase -i HEAD~3
    

alien-signals 系列 —— 认识下一代响应式框架

前段时间,社区掀起了一股 alien-signals 的热潮,其惊人的性能表现让许多开发者印象深刻。作为一名前端开发者,我深入研究了它的源码,探索其性能优势的秘密。

本文将开启一个全新的系列,深入剖析 alien-signals 的使用方法、实现原理和性能优化等内容。让我们一起揭开 alien-signals 的神秘面纱。

为什么要关注 alien-signals?

在深入技术细节之前,让我们先看看 alien-signals 在性能基准测试中的表现。根据 js-reactivity-benchmark 的数据,alien-signals 在各项性能指标上都遥遥领先:

image.png

各响应式框架性能对比,alien-signals 在各项指标上遥遥领先

这样的性能表现,加上 Vue 3.6 即将采用 alien-signals 重构响应式系统的消息,使得它成为了前端社区的焦点。

Signals 概念

如果对 Signals 概念还不熟悉,推荐先阅读我之前的文章:理解 Signal 是如何工作的,其中详细介绍了 Signals 的基本原理和工作机制。

简单来说,Signals 是一种响应式编程模式,它允许我们创建可观察的值和计算属性,当依赖发生变化时自动更新。

响应式库

先简单了解一下什么是响应式框架:

Reactivity is the future of JS frameworks! Reactivity allows you to write lazy variables that are efficiently cached and updated, making it easier to write clean and fast code. —— 引自Super Charging Fine-Grained Reactive Performance · milomg.dev

响应式是 JS 框架的未来!响应性允许您编写有效缓存和更新的惰性变量,从而更轻松地编写干净、快速的代码。

而且响应式库是 Solid、Qwik、Vue 和 Svelte 等现代 Web 组件框架的核心。在某些情况下,您可以将细粒度的响应式状态管理添加到其他库,例如 Lit 和 React。

关于响应式算法的深入探讨,推荐阅读 深入探索细粒度响应式框架的性能优化 ,其中详细对比了 MobX、Preact Signals 和 Reactively 三种主流算法的实现原理。

响应式框架的目标

响应式库的目标是在响应式函数的源发生变化时运行响应式函数。

此外,响应式库还应该是:

  • 高效:永远不要过度执行响应式元素(如果它们的源没有改变,请不要重新运行)
  • 无故障:永远不要允许用户代码看到只有一些响应式元素更新的中间状态(当你运行响应式元素时,每个源都应该更新)

响应式框架的分类

在深入 alien-signals 之前,我们需要了解响应式框架的基本分类。

  • 按执行时机分类

    响应式框架根据执行时机可以分为两大类:

    1. Lazy(惰性) :只在结果被访问时才进行计算,延迟执行,按需计算的思想能有效减少冗余计算。

      • 代表:Solid.js、Preact Signals、Vue、alien-signals
    2. Eager(即时性) :数据变化时立即计算,实时响应,但可能导致频繁计算的性能问题。

      • 代表:MobX

    💡 关于 Solid 的 Signals 的原理,可以参考我的另一篇文章:Solid 之旅 —— Signal 响应式原理

  • 按更新传播算法分类

    根据 维基百科对响应式编程的定义,更新传播算法可以分为三种模式:

    1. Push-based(推送模型)

      • 相当于 Eager 模型
      • 依赖项变化时立即推送完整的变化信息给订阅者
      • 类似于服务端向客户端的 SSE 推送机制
    2. Pull-based(拉取模型)

      • 相当于 Lazy 模型
      • 只在需要时(如读取 computed 值)才拉取依赖项的变化
      • 类似于客户端向服务端的轮询机制
    3. Push-pull hybrid(推拉混合模型)

      • 结合了推送和拉取的优点
      • Push 阶段:依赖项变化时推送脏标记,通知订阅者需要更新
      • Pull 阶段:订阅者在需要时拉取具体的变化值

Vue Signals 进化论

要理解 alien-signals 的由来,我们需要回顾 Vue 响应式系统的演进历程。这部分内容深受 Vue Signals 进化论系列文章 的启发。

  • Vue 3.5:借鉴 Preact Signals

    Vue 3.5 的响应式重构直接受到了 Preact Signals 的启发,主要借鉴了两个核心设计:

    1. 版本计数(Version Counting) :用于快速判断依赖是否发生变化
    2. 双向链表结构:高效管理依赖关系

    这些设计思想将在后续的系列文章中详细解析。

  • Vue 3.6:拥抱 alien-signals

    alien-signals 在继承 Preact Signals 优秀设计的基础上,进行了大量性能优化:

    • 延续双向链表设计:保持了高效的依赖管理
    • 简化节点属性:减少内存占用
    • 贴近 Vue 的命名规范:更好的生态融合
    • 极致的性能优化:包括模拟递归调用栈、内存对齐、位运算等技巧

    虽然这些优化在一定程度上牺牲了代码的可读性,但换来的是卓越的运行时性能。

使用

基础 API 使用

alien-signals 提供了简洁直观的 API:

import { signal, computed, effect } from 'alien-signals';

// 创建响应式信号
const count = signal(1);

// 创建计算属性  
const doubleCount = computed(() => count() * 2);

// 创建副作用
effect(() => {
  console.log(`Count is: ${count()}`);
}); 
// 输出: Count is: 1

// 读取计算属性
console.log(doubleCount()); 
// 输出: 2

// 更新信号值
count(2); 
// 触发 effect,输出: Count is: 2

// 计算属性自动更新
console.log(doubleCount()); 
// 输出: 4

Effect Scope

alien-signals 还提供了作用域管理功能,方便批量管理副作用:

import { signal, effect, effectScope } from 'alien-signals';

const count = signal(1);
// 创建作用域
const stopScope = effectScope(() => {
  effect(() => {
    console.log(`Count in scope: ${count()}`);
  });
  // 输出: Count in scope: 1
});

count(2);
// 输出: Count in scope: 2

// 停止作用域内的所有副作用
stopScope();

count(3);
// 不再有输出,因为 effect 已被清理

架构设计

了解了基本使用后,让我们简单地了解 alien-signals 的架构设计。alien-signals 采用了优雅的双层架构设计:

1. System 层(响应式系统核心)

提供了 createReactiveSystem() 工厂函数,用于构建自定义响应式 API:

import { createReactiveSystem } from './system.js';

const {
  link,  // 建立依赖关系
  unlink,  // 解除依赖关系
  propagate,  // 传播更新
  checkDirty,  // 检查脏状态
  endTracking,  // 结束依赖追踪
  startTracking,  // 开始依赖追踪
  shallowPropagate,  // 浅层传播
} = createReactiveSystem({
  update,  // 更新回调
  notify,  // 通知回调
  unwatched,  // 未观察状态回调
});

2. Surface API 层(面向用户的 API)

基于 System 层构建的标准响应式 API,包括 signal、computed、effect 等。您可以参考 alien-signals 的实现 来构建自己的响应式 API。

总结

本文介绍了 alien-signals 的背景、核心概念以及基本使用方法。我们了解到:

  • alien-signals 是当前性能最优秀的响应式框架之一
  • 它继承了 Preact Signals 的优秀设计,并进行了极致的性能优化
  • Vue 3.6 即将采用它来重构响应式系统,足见其价值
  • 其双层架构设计既保证了性能,又提供了良好的扩展性

在下一篇文章中,我将深入 alien-signals 的 system 层,详细剖析其响应式机制的实现原理,包括依赖追踪、更新传播、脏检查等核心算法。敬请期待!

参考资料

  1. Vue Signals 进化论(v3.5):Preact 重构启示录
  2. Vue Signals 进化论(v3.6):Alien Signals 终局之战?
  3. Reactive programming - Wikipedia
  4. Super Charging Fine-Grained Reactive Performance
  5. stackblitz/alien-signals: 👾 The lightest signal library

阿权的开发经验小集

小集是日常开发中遇到问题的小结,或许可以帮助你少走一些弯路~

Git

跟踪上游分支

背景:执行 pull 没拉下新代码,但远端确实是有更新的。

目标:恢复与上游的同步。

git branch -u origin/branch_name

删除分支

目标:本地分支和远程分支一起删除。

# 删除本地分支
git branch -d localBranchName

# 删除远程分支
git push origin --delete remoteBranchName

空提交

背景:有些操作需要通过 git 提交记录的更新来触发,这里通过一个不影响代码的空提交触发。

git commit --allow-empty -m "Empty-Commit"

Tag vs. Branch

背景:Tag 和 Branch 在执行 git 命令时常常不需要显式说明,但当两者同名时就需要显式声明。

Tag 是记录一个 commit 点,Branch 是记录一个 commit 序列串。

  • branch 的特点是该分支的指针的位置随着提交不断更新,一般是存储在 refs/heads/
  • tag 的特点与分支恰恰相反,指向的 commit 不会随着新的提交去更新。一般是存储在 refs/tags/

git merge 可以合并 tag 或 branch。若出现 tag 和 branch 重名的 case,可以通过补全路径处理:

# push
git push origin :refs/heads/branch_name
git push origin :refs/tags/tag_name

# merge
git merge refs/heads/branch_name
git merge refs/tags/tag_name

回退合并

背景:执行了 merge 操作希望回退到 merge 前的 commit。

# 场景:合并操作还没完成,希望中断并回退到合并前的状态。
# 中断当前正在合并还没提交的分支的合并操作
git merge --abort

# 场景:合并操作已完成,甚至已经 push 到远程,希望回退到合并前的状态。
# 回退刚才已经提交的第一个合并
git reset --merge HEAD~1

# 若合并还没 push 到远程,经过上面操作后,分支可能会落后于远程分支,所以还要同步一遍,以确保跟远程分支同步。
git pull

# 场景:需要将本地的覆盖远程分支状态
# force push
git push --force
git push --force-with-lease # 更安全,会检查远程是否有新提交(有则拒绝 push)

Rebase vs. Merge

merge rebase
作用 创建一个新的 “合并提交”(merge commit),将两个分支的历史记录连接起来,保留双方完整的提交历史(包括分支的分叉和合并节点)。 将当前分支的所有提交 “移植” 到目标分支的最新提交之后,改写当前分支的提交历史,使历史呈现线性(无合并提交)。
优点 完整保留操作分支的所有提交历史,仅新增一个合并提交。遇到冲突只需解决一次。 历史记录整洁,合并后历史呈线性,没有多余的合并提交。rebase 过程中可以通过 --interactive(交互式)对提交进行压缩、修改、删除,让历史更清晰(例如将多个 “修复 bug” 的小提交合并为一个有意义的大提交)。
缺点 频繁合并会产生大量 “合并提交”,主分支历史可能出现很多分叉节点,长期来看难以快速理解项目演进脉络。不能对当前分支的提交进行压缩、修改(如需优化提交历史,需额外操作)。 rebase 会修改当前分支的提交哈希,因为提交被 “移植” 到了新的基础上。 甚至会修改提交顺序。可能需要多次解冲突,如果多个提交与目标分支有冲突,需要逐个提交解决冲突。修改提交顺序后会引入更多冲突。
选择 新手优先 整洁优先

选择:

多人共用一分支 单人单分支
保留完整分支历史 merge rebase
分支历史不重要 rebase rebase

为了平衡提交历史的简洁性与准确性(少点冲突),合并代码时可以这样做:先使用 rebase,遇到冲突时 abort 回退,切换为 merge,然后解冲突。

注意:主分支被团队所有人依赖,应尽可能使用 merge。

从分支维度:可以简单约定合并策略,主分支用 merge,功能分支用 rebase,来平衡两者的优缺点。

解冲突最佳实践

基本常识:

  • HEAD/ours 是指自己的改动;origin/theirs 是指上游的改动。

操作原则:

  • 只有是自己写的才应用自己的改动,否则应用上游的改动。
  • 存疑的(自己写的混合了他人写的,应用上游后编译不过)应保留两者的修改,对于不确定要丢弃的代码用段落注释(合并代码少用行注释)。

最佳实践:

  1. 处理资源/二进制文件,简单选择用自己的还是用上游的。
  2. 处理文本:以行甚至段落为单位,选用自己的还是上游的版本。
  3. 处理存疑文本修改:以行为单位,不选用自己和上游的版本,直接编写预期的文本。
  4. 对解完冲突的文件暂存修改(git add)。
  5. 解完 git 仓库本次所有冲突后,提交修改(git commit)。

推荐工具:vscode。使用 vscode 打开 git 仓库,并用其解冲突。

修改作者信息

背景:提交完代码了才发现用错了邮箱提交,例如:外部仓库使用了公司邮箱提交了代码,需要改用私人邮箱。

Git 会为每一次提交记录提交者的姓名和邮箱,这是本地 Git 配置的 “身份标识”,用于区分不同开发者的提交。

如何修改:

  1. git log 查看 commit id
  2. git rebase -i <最早commit> 重新设置基准线
  3. git commit --amend --author="Author Name autolinkemail@address.comautolink"来修改 commit
  4. ``git rebase --continue` 移动到下个 commit 作为基准线

例子:如当前历史为 A-B-C(HEAD),我想修改 B 和 C,这两个 commit 的作者。

  1. git rebase -i A,即要修改的最早提交的前一个节点。
    1. 如果想改 A 则使用git rebase -i --root
  2. pick 改为 edit。按 ESC,输入:wq。保存修改。
  3. 现在你已经开始可以修改,此时当前 commit 为 B。
  4. git commit --amend --author="Author Name autolinkemail@address.comautolink" 修改 B 的提交。
  5. git rebase --continue 定位到 C
  6. git commit --amend --author="Author Name autolinkemail@address.comautolink" 修改 C 的提交。
  7. git rebase --continue 修改已完成。
  8. git push -f 提交代码,大功告成。

文件修改检测不到

背景:本地文件有修改,但 Git 检测不到了。重启似乎就可以检测得到,但只能一次有效。

排查:去检查文件所在的路径,与 Git 识别的路径是否有大小写的差异。Git 区分大小写差异,但系统不区分,所以会有 gap,目录名只改了大小写,会导致一些奇怪的问题。

解决:把有大小写的路径段重命名。改大小写名称时,先重命名为临时名,再改为正确的大小写,分两次提交以避免文件系统的不识别。

iOS

留心延迟执行的代码

代码里看到延时执行要谨慎,非常可能是枯叶掩埋的陷阱。

延时执行可能是能解决作者提交时遇到的问题。但随着业务发展,可能后续那次修改后,延时执行就兜不住了。

  1. 首先自己不要写延时执行代码,不要期望延时能根治某个问题,延时能绕过的问题一般是执行时机、时序问题,应找到合适的时机执行逻辑。
  2. 其次看到别人写的延时代码要十分谨慎,可以先不去改别人写的延时代码,但尽可能不要依赖延时执行的时机做后续的逻辑,应自己找到合适的时机编写自己的代码。

主队列执行时序问题

public extension DispatchQueue {
    private static var token: DispatchSpecificKey<Void> = {
        let key = DispatchSpecificKey<Void>()
        DispatchQueue.main.setSpecific(key: key, value: ())
        return key
    }()
    
    static var isMain: Bool {
        DispatchQueue.getSpecific(key: Base.token) != nil
    }
    
    static func onMainQueue(_ work: @escaping @convention(block) () -> Void) {
        if isMain {
            work()
        } else {
            DispatchQueue.main.async(execute: work)
        }
    }
}

通过标记 DispatchQueue.main 队列可以准确判断当前执行的队列是否是主队列。使用 onMainQueue 方法可以确保让任务在主线程和主队列中执行。这个做法在不要求时序的场景下,确实是最保险的。要保证时序性,就要重新思考了。

主队列只能在主线程中执行。主线程是 runloop 机制,DispatchQueue.main.async 就是把任务(一段代码)放入到下一个 runloop 中执行。主线程还会执行其他队列,如在主线程中 sync 执行一个普通 serial queue,这个 queue 也是在主线程中执行,但就不是主队列了,上面的 isMain 方法会判断为 false。如果这种 case 在需要任务按照严格时序执行的场景下,就会出现时序错乱的问题。因为这里会把一些在主线程但不在主队列的任务错误地放置到下一个 runloop 中执行。

相反要考虑时序性,只需要使用 Thread.isMainThread 就能准确识别当前是否是主线程了,绝大数 UI 场景都适用。

public extension UtilExtension where Base: DispatchQueue {
    static func onMainThread(_ work: @escaping @convention(block) () -> Void) {
        if Thread.isMainThread {
            work()
        } else {
            DispatchQueue.main.async(execute: work)
        }
    }
}

使用锁,最终目的是为了解决竞态条件。

相关链接:

锁从基本原理上可分为互斥锁和自旋锁,其他类型的锁如:条件锁、递归锁、信号量,甚至是 GCD 的队列都是基于这两个基本锁的封装或扩展。

互斥锁 Mutex Lock 自旋锁 Spin Lock
原理 当线程尝试获取锁时,若锁已被占用,该线程会进入休眠状态(阻塞),直到锁被释放后被唤醒。 线程在获取锁失败时不会休眠,而是通过循环(忙等待)不断检查锁状态。
特性 互斥锁会休眠线程,避免了 CPU 空转,但涉及线程上下文切换,可能带来性能开销。适合高竞争或长时间持有。 自旋锁保持线程活跃,避免了上下文切换,但长时间等待会消耗 CPU 资源,适用于锁持有时间短的场景。适合短时间锁竞争。
具体实现 不可重入锁(非递归锁):线程必须释放锁后才能再次获取,否则会死锁。NSLockpthread_mutex(默认模式)可重入锁(递归锁) :允许同一线程多次获取同一锁而不死锁。NSRecursiveLock@synchronized条件锁:基于条件变量实现,线程需等待特定条件满足后才能继续执行。NSConditionNSConditionLock,需与互斥锁配合使用。 iOS 中早期使用OSSpinLock,但因优先级反转问题被废弃;现推荐使用os_unfair_lock作为轻量级替代。读写锁:允许并发读取(多个读线程),但写入时需独占资源。属于自旋锁的特殊形式,例如pthread_rwlock
信号量

通过计数器控制并发访问数量,底层可能依赖互斥锁实现,所以如果重入会死锁

class Lock {
    private let semaphore = DispatchSemaphore(value: 1)
    func lock() {
        semaphore.wait()
    }
    func unlock() {
        semaphore.signal()
    }
    @inline(__always)
    final func performLocked<T>(_ action: () -> T) -> T {
        self.lock(); defer { self.unlock() }
        return action()
    }
}
同步队列

同步队列通过在一个串行队列中执行操作,也可以实现资源安全访问。

同步执行在一段时间内不会切换线程,异步执行会切线程,但在队列执行的任务还是串行的。这个这个特性,可以实现异步锁。但就会发生上下文切换,即线程切换。

Xcode

手动安装模拟器

背景:新安装 Xcode 时总要额外下载一个与该 Xcode 版本匹配的模拟器,这个过程总是很久。可以试试手动下载。

  1. 官网下载 Xcode 对应的模拟器版本:developer.apple.com/download/al…
  2. 执行命令:
# 需要先选定操作的 Xcode
sudo xcode-select -s /Applications/Xcode_16.1.app
xcodebuild -runFirstLaunch
xcodebuild -importPlatform "$HOME/Downloads/iOS_18.1_Simulator_Runtime.dmg"

启动 Xcode 即可。

安装系统不支持的 Xcode 版本

背景:新系统不能打开旧 Xcode。

plutil -replace CFBundleVersion -string 30000 /Applications/Xcode.app/Contents/Info.plist

查找 setter

背景:希望找到某属性所有修改的地方。

可以将属性改写成计算属性,这样就可以单独查找 setter 的调用栈。

img

转码控制台中的 JSON

背景:Xcode 控制台输出 json 常常是转义过的,配合 vscode 可以还原出原始的 json。

拷贝到 vscode,结合 Text Power Tools 插件,使用 json 解析。

  1. 去除头尾到双引号。
  2. 右键:Text Power Tools > Encode/decode > Unescape JSON escaped text

Swift 语言

KVO 备忘

背景:Swift 的 KVO 语法常常检索不到。

// 定义可 KVO 监听的属性变量
@objc dynamic var myDate

// 监听,options 若不设置,change 的 oldValue、newValue 为 nil
observation = observe(\.objectToObserve.myDate, options: [.old, .new]) { object, change in
    print("myDate changed from: \(change.oldValue!), updated to: \(change.newValue!)")
}

Using Key-Value Observing in Swift | Apple Developer Documentation

枚举语义

  • enum 表达互斥的
  • 表达常量或常量表达式,其关联值都是常量,都需要构造的时候确定。
  • indirect 修饰 case 或 enum 可以在关联值使用自身类型,即表达递归语义。常用于常量表达式的表达。

特性:

  • 便捷的构造,直接点语法直接构建。类比到 struct/class 的静态方法/属性。
  • 关联值可忽略标签,直接用类型表达。

数组在遍历中删除元素

背景:遍历数组并删除元素一不小心就会数组越界。

可以通过以下方式规避:

  1. 使用高阶函数直接创建/修改一个符合条件的数组。如 filterremoveAll(where:)
  2. 反向遍历,可以安全地按索引删除元素,如 reversed()

枚举 raw value 不能是表达式

枚举 raw value 在定义的时候等号右侧不可以是表达式,而是一个字面常量,不可加条件。

读取大文件

可以使用 FileHandleInputStream 来读取大文件。

它们之间存在一些主要的不同:

  1. 使用方式InputStream 是基于流的,可以连续读取数据,这对于处理大文件或网络数据非常有用,因为你不需要一次性将所有数据加载到内存中。另一方面,FileHandle 允许你更精细地控制文件访问,例如,你可以选择从文件的任何位置开始读取或写入数据。
  2. 数据处理:使用 InputStream 时,你需要自己处理数据缓冲区的分配和释放。使用 FileHandle 时,你可以直接获取 Data 对象,而无需关心底层的内存管理。
  3. 可用性InputStream 可以处理来自各种来源的数据,如文件、网络数据或内存中的数据。而 FileHandle 主要用于文件操作。
  4. 错误处理InputStream 有一个 streamError 属性,可以用来检查在读取或写入过程中是否发生错误。FileHandle 的方法则会抛出异常,需要使用 trycatch 来处理。

InputStream 使用实例:github.com/gonsolo/gon…

guard let s = InputStream(fileAtPath: path) else {
    throw PbrtScannerError.noFile
}
stream = s
stream.open()
if stream.streamStatus == .error {
    throw PbrtScannerError.noFile
}
var bytes = Array<UInt8>(repeating: 0, count: bufferLength)
buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferLength)
buffer.initialize(from: &bytes, count: bufferLength)
bufferIndex = 0
bytesRead = stream.read(buffer, maxLength: bufferLength)

FileHandle 的使用:

let fileURL = URL(fileURLWithPath: "path/to/file")
if let fileHandle = try? FileHandle(forReadingFrom: fileURL) {
    let data = fileHandle.readData(ofLength: 12)
    // 处理读取到的数据
    fileHandle.closeFile()
}

省略 inout 参数

背景:inout 参数是不能设置默认值的,但有时候想让其成为可选参数。

把 inout 参数改成 UnsafeMutablePointer 类型可以做成像默认参数的省略用法,如:

func checkIfSupport(draft: Data, isSingle: inout Bool) -> Bool
func checkIfSupport(draft: Data, isSingle: UnsafeMutablePointer<Bool>? = nil) -> Bool

参考:option type - Swift optional inout parameters and nil - Stack Overflow

不建议在 extension 中重写

swift2 - Overriding methods in Swift extensions - Stack Overflow

使用 @objc 修饰的方法即使定义在 extension 中,也能被重写。@objc 可以直接修饰 extension。类似的,NSObject 子类定义的 objc 方法也可以在 extension 中重写。

// MARK: Override
extension ExportViewControllerNew {
    override var preferredStatusBarStyle: UIStatusBarStyle {
        .lightContent
    }
}

这样写会把方法暴露给 Runtime。

但不太建议这么做,似乎不太正统的方式。需要重写的方法还是应放到类的定义中。

Decodable 详细使用

定義 Decodable 的 init(from:) 解析 JSON | by 彼得潘的 iOS App Neverland | 彼得潘的 Swift iOS App 開發問題解答集 | Medium

我在想,为什么不用 ObjectMapper 呢?

weak 对象所在的作用域结束后还不销毁

对于 Swift 中的对象,其销毁时机与作用域有关,但不是唯一决定因素。对象的生命周期是由引用计数(reference counting)管理的。当一个对象的强引用计数降至零时,该对象会被销毁。以下是一些可能导致对象未在作用域结束时被销毁的情况:

  1. 强引用计数:当对象的作用域结束时,如果对象的强引用计数不为零,对象不会被立即销毁。这可能是因为在作用域外还有其他地方保持着对该对象的强引用。
  2. 强引用循环:当对象之间存在强引用循环时,即使它们的作用域已经结束,对象也不会被销毁。强引用循环会导致内存泄漏,因为对象互相保持强引用,使得它们的引用计数永远不会降至零。这时,需要使用 weakunowned 关键字来解决强引用循环问题。
  3. 延迟释放:Swift 使用自动引用计数(ARC)来管理内存。ARC 通常在对象不再需要时立即释放内存,但在某些情况下,ARC 可能会延迟释放对象。这种延迟释放可能会导致对象在作用域结束后仍然存在。

虽然作用域对于对象的销毁有一定影响,但对象的生命周期主要还是由引用计数管理。因此,在编写 Swift 代码时,需要特别注意避免强引用循环和内存泄漏。

获取代码位置

"\(#function) @\(#fileID):\(#line):\(#column)"

类判等

对于类实例,判断是否相同,可以简单以地址区分,使用 === 运算符比较。

if b === Test.self {
    print("yes")
} else {
    print("no")
}

ios - Comparing types with Swift - Stack Overflow

打印地址

有时候我们想直接打印对象的地址,可以这么做:

// 方式一
let s = Struct() // Struct
withUnsafePointer(to: s) {
    print(String(format: "%p", $0)
}

// 方式二
func printPointer<T>(ptr: UnsafePointer<T>) {
    print(ptr)
}
printPointer(ptr: &x)

// 方式三
///
/// http://stackoverflow.com/a/36539213/226791
///
func addressOf(_ o: UnsafeRawPointer) -> String {
    let addr = unsafeBitCast(o, to: Int.self)
    return String(format: "%p", addr)
}

func addressOf<T: AnyObject>(_ o: T) -> String {
    let addr = unsafeBitCast(o, to: Int.self)
    return String(format: "%p", addr)
}
  
// 方式三
Unmanaged.passUnretained(self).toOpaque()

参考:

获取磁盘空间

背景:快速获取与系统设置计算方式一致的剩余磁盘空间。

let fileURL = URL(fileURLWithPath:"/")
do {
    let values = try fileURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
    if let capacity = values.volumeAvailableCapacityForImportantUsage {
        print("Available capacity for important usage: \(capacity)")
    } else {
        print("Capacity is unavailable")
    }
} catch {
    print("Error retrieving capacity: \(error.localizedDescription)")
}

[SWIFT] Get available disk space w… | Apple Developer Forums

Checking Volume Storage Capacity | Apple Developer Documentation

return

背景:当想通过插入个 return 来提前中断代码,结果发现 return 后面的代码被执行了。

return 下一行接个表达式,下一行的表达式也会被执行。因此要避免这种情况应写成:

func returnInTheMiddle() {
  print("This is called as expected")
  return;
  print("This is called as well")
}

returnInTheMiddle()

Return keyword and following expression - Mateusz Karwat

因此 return 充当个截断的语句时,警告应该是这样的:

Code after 'return' will never be executed

而不是:

Expression following 'return' is treated as an argument of the 'return'

当然,有返回值的就不会出现上面的歧义。

didSet loop

背景:发现在 disSet 中调用 set 逻辑不会循环调用,但在 didSet 中调用一个方法,在其中调用 set 就会造成循环调用。

didSet 观察器会将旧的属性值作为参数传入,可以为该参数指定一个名称或者使用默认参数名 oldValue。如果在 didSet 方法中再次对该属性赋值,那么新值会覆盖旧的值。

按照上面的意思,隐含表达了在 didSet 中再次对属性赋值不会再触发 didSet,更不会陷入循环调用。但这也是仅限于 didSet 内,如下的 case,还是会陷入循环调用中:

class Manager {
    var isEnable: Bool = true {
        didSet {
            updateEnableState()
        }
    }
    
    func updateEnableState() {
        print("isEnable: \(isEnable)")
        isEnable = true
    }
}

let manager = Manager()
manager.isEnable = true

所以要进行属性值处理,需在 didSet 中完成,而不能新建一个方法。

另外,在构造方法中对属性赋值,也不会触发观察器的执行。

URL 语义化

不要直接使用 String 表达 URL 的组成部分以及解析 URL,而是使用这些类:URL、URLComponents、URLQueryItem。

你会发现 NSString 的“Working with Paths”章节的 API 在 String 上都移除了,这是因为这些 API 使用 URL 可以更准确地表达语义:

/// NSString Working with Paths
class func path(withComponents: [String]) -> String
var pathComponents: [String]
var lastPathComponent: String
var pathExtension: String
func appendingPathComponent(String) -> String
func appendingPathExtension(String) -> String?
var deletingLastPathComponent: String
var deletingPathExtension: String

扩展管理:使用“命名空间”

背景:扩展方法太多,希望对扩展方法归类拆分。

Swift 没有 C++ 的命名空间,但可以用类型仿照一个,实现访问权限的收拢。

下面代码对原本在 MediaContext 扩展的 maxWidth 方法转移到了 MediaContext.VideoWrapper。

// 建立个命令空间
private extension MediaContext {
    struct VideoWrapper {
        let base: MediaContext
    }
    
    var video: VideoWrapper {
        VideoWrapper(base: self)
    }
}

// 在命名空间内写扩展方法
private extension MediaContext.VideoWrapper {
    func maxWidth() -> CGFloat {
        max(base.contentWidth(of: .video, flag: .normal), base.globalContentWidth())
    }
}

使用:

class ClipController {
    let context: MediaContext
    
    func readWidth() {
        // 调用
        let width = context.video.maxWidth()
    }
}

结构体默认构造函数不能跨模块使用

结构体定义了属性,就会自动有个默认的按属性顺序的构造函数,但这个默认构造函数只能在结构体定义的 Module 中能访问,在别的 Module 无法访问,需显示声明。

Default initializer is inaccessible

获取类型信息

模块类名:

String(reflecting: type(of: receiver))

获取地址:

Unmanaged.passUnretained(receiver).toOpaque()

Error.localizedDescription

自己实现一个 Error 并实现 localizedDescription 属性,并不能正常调用。

struct StringError: Error {
    let content: String
    var localizedDescription: String { content }
}
print("错误".makeError().localizedDescription) // 会输出:"The operation couldn’t be completed. (InfraKit.StringError error 1.)"

defer

A defer statement is used for executing code just before transferring program control outside of the scope that the defer statement appears in.

即 deder 定义的代码在作用域结束的时候会调用。

从语言设计上来说,defer 的目的就是进行资源清理和避免重复的返回前需要执行的代码,而不是用来以取巧地实现某些功能。这样做只会让代码可读性降低。

defer 放在函数末尾相当于没写,应尽可能放在靠前的地方。

以前很单纯地认为 defer 是在函数退出的时候调用,并没有注意其实是当前 scope 退出的时候调用这个事实,造成了这个错误。在 ifguardfortry 这些语句中使用 defer 时,应该要特别注意这一点。

关于 Swift defer 的正确使用 | OneV's Den

另一方面,利用这个特性,把锁的加锁和解锁放在同一行是个比较不错的实践,这样作用域内(从该代码开始到作用域结束)的代码都加锁了,而且即使后面 guard 语句提前返回了,也不担心出现加锁了忘记解锁的问题。

locker.lock(); defer { locker.unlock() }

🔜

💬高频复用又经常忘记的代码

Hashable 实现

Hashable 继承于 Equatable,所以两者都要实现。

import Foundation

struct Person: Hashable {
    var name: String
    var age: Int

    // 实现 == 操作符
    static func == (lhs: Person, rhs: Person) -> Bool {
        return lhs.name == rhs.name && lhs.age == rhs.age
    }

    // 实现 hash(into:) 方法
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
        hasher.combine(age)
    }
}

let person1 = Person(name: "Alice", age: 30)
let person2 = Person(name: "Bob", age: 25)
let person3 = Person(name: "Alice", age: 30)

let peopleSet: Set<Person> = [person1, person2, person3]
print(peopleSet) // 输出: [Person(name: "Alice", age: 30), Person(name: "Bob", age: 25)]

💬调试

Swift 符号断点似乎要重新编译?

否则不生效?

img

💬注释

文档注释标记

一般规则:Tag: Content

/**
    两个整数相加
    # 加法(标题一)
    这个方法执行整数的加法运算。
    ## 加法运算(标题二)
    想加个试试看

    中间隔着一个横线
    ***

代码块的*使用*方法:
``(不用添加括号)`

        let num = func add(a: 1, b: 2)
        // print 3
    ``(不用添加括号)`

    - c: 参数一
    - d: 参数二
    - f: 参数三

    - Parameters:
        - a: 加号左边的整数
        - b: 加号右边的整数
    - Throws: 抛出错误,此方法不抛出错误,只为另外演示注释用法。
    - Returns: 和

    - Important: 注意这个方法的参数。
    - Version: 1.0.0
    - Authors: Wei You, Fang Wang
    - Copyright: 版权所有
    - Date: 2020-12-28
    - Since: 1949-10-01
    - Attention: 加法的运算
    - Note: 提示一下,用的时候请注意类型。
    - Remark: 从新标记一下这个方法。
    - Warning: 警告,这是一个没有内容的警告。
    - Bug: 标记下bug问题。
    - TODO: 要点改进的代码
    - Experiment: 试验点新玩法。
    - Precondition: 使用方法的前置条件
    - Postcondition:使用方法的后置条件
    - Requires: 要求一些东西,才能用这个方法。
    - Invariant: 不变的
 */
func add(a: Int, b: Int) throws -> Int {
    return a + b
}

更多:

代码冲突

使用段落注释可以避免一些代码合并的冲突,但同时也会让你容易忽略掉注释内容的变更。

💬泛型

范型类型不支持存储属性
Static stored properties not supported in generic types

所以想要在扩展中定义存储属性,要么放到具体的类中,要么定一个 fileprivate 的全局变量,再用一个计算属性中转一下(不推荐)。

泛型扩展声明

以下两种形式指定范型类型的扩展都支持且等价:

// 定义 UtilExtension 的 UIViewController 及其子类的泛型类型
extension UtilExtension<UIViewController> {}
extension UtilExtension where Base: UIViewController {}
容器元素类型不能为范型

背景:希望一个包含泛型实例的数组能声明为泛型类型的数组。

struct Car<T> {
    let p: T
}

let arr = [
    Car(p: 45),
    Car(p: "String"),
    Car(p: [1]),
] as [Any]

// 实际的类型
[
    Car<Int>(...),
    Car<String>(...),
    Car<Array<Int>>(...),
]

容器是范型的,其类型必须确定,Swift 不能识别不同的范型类型,这样只会被认为是 Any 类型,因为泛型的具体实例之间没有继承关系,也没有公共遵循的协议。

使用范型可以还原类型

相比使用协议,使用范型可以还原类型。示例:

func addTargetAction(for controlEvents: UIControl.Event, _ action: @escaping (Base) -> Void) -> RemovableControlTarget<Base>

换到 C++ 的概念,就把泛型理解为模板吧,具体使用泛型时,即确定泛型类型时,其实就是泛型定义的占位符(如:T)替换成具体的类型。

💬闭包

嵌套函数循环引用陷阱

函数在 Swift 中几乎等同于闭包,从调用的视角,函数除了可以使用参数名称、参数标签外,与闭包无异。如下代码的 ②③ 的定义就是等价的。嵌套函数定义和使用都很方便,但嵌套函数的自动捕获的机制容易造成循环引用。

var button: UIButton!

override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = .u.systemCyan
    
    // ② 嵌套函数,也会自动捕获 self
    func printButtonNested() {
        print("🚧 button: \(self.button!)")
    }
    
    // ③ printButtonNested 等同于定义个捕获实例变量的闭包常量
    let printButtonNested0 = { [self] in
        print("🚧 button: \(self.button!)")
    }
    
    // ④ 比较保险的是定义成弱引用捕获变量的闭包,使用 weak self 打破循环引用
    let printButtonClosure = { [weak self] in guard let self else { return }
        print("🚧 button: \(self.button!)")
    }
    
    let button = makeButton(title: "Tap", action: printButtonClosure)
    self.button = button
}

// ① 实例方法,自动捕获 self
func printButton() {
    print("🚧 button: \(self.button!)")
}

上面 makeButton 方法会将 action 传入的闭包让 button 持有,button 被 self 持有,若 action 传入闭包强捕获了 self,就会造成循环引用。

所以如果将上面的 ①②③ 传入 makeButton 方法都会造成循环引用,Xcode 不会给任何警告或报错。

最佳实践:对于要传递的函数/闭包,应如 ④ 这样定义成闭包,并使用捕获列表,弱引用声明需要捕获的值。类似的若需要捕获一些可能触发循环引用的的引用类型值,也需要在捕获列表中弱引用声明。

闭包中的 self 判断可能不会中断点
let updateSelectedSegmentIfNeeded = { [weak self] (new: LVMediaSegment) in
    guard let self = self else { return }
    guard panel.isShowing else { return }
    
    panel.disableAdjust()
    
    self.viewModel.updateSelectedSegment(new)
    panel.reset(dataSource: self.viewModel)    // reset后会自动enable adjust
}

闭包中的第一行 guard let self = self else { return } 可能不会中断点,需要对下一行下断点。这个情况在自定义 tool chain 中可能会比较常见。

@escaping 等价于 optional?

背景:以下代码都能通过编译,看起来用 Optional 包一下闭包就不用写 @escaping 了?

var actionHandler: (() -> Void)?

func a(action: @escaping () -> Void) {
    actionHandler = action
}

func b(action: (() -> Void)?) {
    actionHandler = action
}

function - Swift optional escaping closure parameter - Stack Overflow

Swift 如何给回调添加 @escaping 和 optional | Gpake's

可以理解为 Optional 把闭包包装成一个 enum,闭包已经不再是参数列表中了。所被包装的闭包成了 Optional enum 的关联值,其实是个枚举实例的成员了,跟属性类似,默认就是 eacaping。所以 Optional 的闭包已经是 escaping 语义了。

💬分支处理技巧

if/guard/case let/var

在所有分支语句中,包含 if/guard/switch,都可以用 let 创建一个符合条件的常量。

从 Swift 5.7 开始,if let a = a 的形式可以写成 if let a

注意 guard let/var 和 if let/var 在作用域上会有些细微的差别:

  • guard 创建的常量/变量作用域是当前行代码到结尾,可以覆盖前面的参数列表,但不能覆盖前面定义的常量/变量。
    • 但 else 里面不能访问 guard let 创建的常量。
  • if 创建的常量/变量作用域是后续紧接着的花括号,所以即使前后出现同名常量/变量也不会编译冲突。
if ↔︎ guard

guard 的语义:确保后续语句都是基于 guard 条件为 true 的前提。

实际使用中经常需要对 if 和 guard 相互转换:

// 对于提前退出的 case
guard condition else { return }
// 等同于
if !condition { return }

简单记忆:相同效果的语句,guard 和 if 后面的条件刚好相反

对于提前退出的 if 语句其实可以不改写成 guard,有些改写反而降低了可读性。例如表达“如果满足 A 条件就退出”,这样直接写成 if 就好;如果表达“确保后续的代码都满足 B 条件(否则退出)”,这样则考虑写成 guard 语句。

但嵌套的 if 语句改写成 guard 则有利于让代码更清晰。

带关联值枚举判等

背景:枚举只要有一个带关联值的 case,该枚举就不能使用 == 判等(除非该枚举实现了 Equatable)。

需修改判断方式:

if effectType == .prop
// ⬇️
if case .prop = effectType

具体实例:

// 未遵循 Equatable 的枚举
enum Message {
    case text(String)
    case attachment(name: String, size: Int)
    case timestamp(Date)
}

let message: Message = .attachment(name: "report.pdf", size: 10240)

// 1. 仅匹配枚举类型,忽略关联值
if case .attachment = message {
    print("这是一个附件消息") // 会执行
}

// 2. 匹配枚举类型并绑定关联值(可用于后续判断)
if case .attachment(let name, let size) = message {
    print("附件名:\(name),大小:\(size)") // 会执行
}

// 3. 匹配枚举类型并判断关联值条件
if case .attachment(_, let size) where size > 5000 {
    print("大附件(超过5000字节)") // 会执行
}
// 条件等同于:
// if case .attachment(_, let size), size > 5000 {

// 4. 完全匹配关联值(需手动判断)
if case .attachment(let name, let size) = message, 
   name == "report.pdf", 
   size == 10240 {
    print("匹配到指定附件") // 会执行
}

同时也应注意到,这样的表达式只能在 if/guard 后面使用,它不是个逻辑表达式,不能赋值到布尔量的。

How to compare enum with associated values by ignoring its associated value in Swift? - Stack Overflow

switch-case
作为右值

当然 if 语句也可以,多用于常量的定义。

let menuIdentifier: MenuIdentifier = switch entrance {
case .global: .effectRoot
case .video: .videoEffectRoot
case .subVideo: .subVideoEffectRoot
}
case let

case let 是创建变量,这其中用法很丰富。

可以做类型转换:

var imageData: Data? = nil
switch mediaAsset {
case let asset as ImageDataAsset:
    imageData = asset.data
    if let carttonImageFilePath = asset.cartoonFilePath, let cartoonImage = UIImage(contentsOfFile: carttonImageFilePath) {
        imageData = cartoonImage.pngData()
    }
case let asset as DraftImageAsset:
    imageData = asset.photo.resize(limitMaxSize: size).pngData()
case let asset as DataAsset:
    imageData = asset.data
default:
    break
} 

注意这里是直接使用 as 关键字,而不是 as?,与 if/gruard let 的变量定义有差别。

case range

做值域 case 划分,case 后可接 range,需要有个起点:

func calculateUserScore() -> Int {
    let diff = abs(randomNumber - Int(bullsEyeSlider.value))
    switch diff {
    case 0:
        return PointsAward.bullseye.rawValue
    case 1..<10:
        return PointsAward.almostBullseye.rawValue
    case 10..<30:
        return PointsAward.close.rawValue
    default:
        return 0
    }
} 

区间判断对类型为整型的就比较好处理,如果是浮点数,就不一定能满足需求,因为它不能表达 if value > 0.1 的语义,即至少有一个起点,这就要求这些 case 排列是从小到大排列。但也不是不行,如:

var progress: CGFloat!
switch CGFloat(progress) {
case 0 ... 0.25:
    barColor = .red
case 0.25 ... 0.5:
    barColor = .yellow
default:
    break
}

因为 case 0 占用了 0.25,所以 case 1 是不会匹配 0.25 的。

注意:分支判断需要覆盖所有值域。

💬Dictionary map

背景:批量修改字典 key、value;重建字典。

  1. 使用 mapValues(_:) 方法:
    1. 仅能修改值,过程中无法对 key 访问。
let dictionary = ["foo": 1, "bar": 2, "baz": 5]

let newDictionary = dictionary.mapValues { value in
    return value + 1
}
//let newDictionary = dictionary.mapValues { $0 + 1 } // also works

print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]
  1. 【不推荐】使用 map + init(uniqueKeysWithValues:)
    1. 会中间生成个 tuple array,需要多一步转换。
let dictionary = ["foo": 1, "bar": 2, "baz": 5]

let tupleArray = dictionary.map { (key: String, value: Int) in
    return (key, value + 1)
}
//let tupleArray = dictionary.map { ($0, $1 + 1) } // also works

let newDictionary = Dictionary(uniqueKeysWithValues: tupleArray)

print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]
  1. 【推荐】使用 reduce 方法:
    1. 通过元组的方式遍历整个字典,注意两个 reduce 方法的异同,根据使用场景来选择:
      • reduce(_:_:):闭包中每次都需要返回每次修改的片段值。
      • reduce(into:_:):【更推荐】闭包中直接对结果重新赋值,无须返回。
let dictionary = ["foo": 1, "bar": 2, "baz": 5]
let newDictionary = dictionary.reduce([:]) { (partialResult: [String: Int], tuple: (key: String, value: Int)) in
    var result = partialResult
    result[tuple.key] = tuple.value + 1
    return result
}
print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]

let dictionary = ["foo": 1, "bar": 2, "baz": 5]
let newDictionary = dictionary.reduce(into: [:]) { (result: inout [String: Int], tuple: (key: String, value: Int)) in
    result[tuple.key] = tuple.value + 1
}
print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]
  1. 另外起一个字典变量在遍历中重新赋值:
let dictionary = ["foo": 1, "bar": 2, "baz": 5]

var newDictionary = [String: Int]()
for (key, value) in dictionary {
    newDictionary[key, default: value] += 1
    //newDictionary[key] = value + 1
}

print(newDictionary) // prints: ["baz": 6, "foo": 2, "bar": 3]

💬区间

关系

背景:如果准确表达区间

RangeExpression
    ClosedRange
    PartialRangeFrom
    PartialRangeThrough
    PartialRangeUpTo
    Range

# 闭合区间。表达:min <= value <= max。支持遍历。
struct ClosedRange<Bound> where Bound : Comparable
3...5 # 字面量,定义了运算符 ...
    // from Range
    init(Range<Bound>)

# 单侧区间。表达:min <= value。
struct PartialRangeFrom<Bound> where Bound : Comparable
5...

# 单侧区间。表达:value <= max。
struct PartialRangeThrough<Bound> where Bound : Comparable
...5.0

# 单侧区间。表达:value < max。
struct PartialRangeUpTo<Bound> where Bound : Comparable

# 半开区间。表达:min <= value < max。支持遍历。
struct Range<Bound> where Bound : Comparable
0.0..<5.0 # 字面量,定义了运算符 ..<
    # from NSRange
    init?(NSRange, in: String)
    init?(NSRange)
    // from CloseRange
    init(ClosedRange<Bound>)
使用场景
作为 Collection

ClosedRange、Range 都遵循 Collection 协议,可以作为集合使用。常见的用于遍历:

let range: ClosedRange = 0...10
print(range.first!) // 0
print(range.last!) // 10

let names = ["Antoine", "Maaike", "Jaap"]
for index in 0...2 {
    print("Name \(index) is \(names[index])")
}
// Name 0 is Antoine
// Name 1 is Maaike
// Name 2 is Jaap

当然,也可以转换成数组:

let intArray: [Int] = Array(min...max)
取集合子集
let names = ["Antoine", "Maaike", "Jaap"]
print(names[0..<names.count]) // ["Antoine", "Maaike", "Jaap"]
print(names[...2]) // ["Antoine", "Maaike", "Jaap"]
print(names[1...]) // ["Maaike", "Jaap"]

// 字符串会比较特别
let emojiText = "🚀launcher"
let endIndex = emojiText.index(emojiText.startIndex, offsetBy: 7)
let range: Range<String.Index> = emojiText.startIndex..<endIndex
print(emojiText[range]) // 🚀launch
与 NSRange 互转

背景:在字符串处理中,Range 经常要与 NSRange 相互转换,这是两个完全不同的结构体。

// Range -> NSRange
NSRange(range, in: title)

// NSRange -> Range
Range(nsRange, in: title)

具体应用:

public extension String {
    var nsRange: NSRange {
        NSRangeFromString(self)
    }
    
    /// Range<String.Index> -> NSRange
    func nsRange(from range: Range<String.Index>) -> NSRange {
        return NSRange(range, in: self)
    }
    
    /// NSRange -> Range<String.Index>
    func range(from nsRange: NSRange) -> Range<String.Index>? {
        return Range(nsRange, in: self)
    }
}

// 使用示例
let str = "测试转换 Range 和 NSRange"
if let subRange = str.range(of: "转换") {
    let nsR = str.nsRange(from: subRange)
    print("NSRange: location=\(nsR.location), length=\(nsR.length)")
    
    if let convertedRange = str.range(from: nsR) {
        print(str[convertedRange]) // 输出 "转换"
    }
}

注意:String 中的 NSRange 基本是 NSString 使用的,都是基于 UTF-16 编码单元。

// 下面两行代码等价
NSRangeFromString(self)
NSRange(location: 0, length: self.utf16.count)

🔜

🚩PromiseKit

设计思想借鉴
  • 异步/同步逻辑原子化。对一段逻辑封装,统一返回 Promise 泛型,可以让这部分逻辑更容易被外部集成、调用和线程切换。
    • 是 async await 的平替。
    • 逻辑封装方法中,甚至不用指定队列执行,可以在 then 等 API 调用时再切换执行的队列。
  • 同步转异步思路:把终点信号放到闭包返回出去。
  • 短路求值/最小化求值:遇到错误直接忽略后续代码,更安全、高效、易读。
    • 使用返回错误直接终止后续代码逻辑。
    • 链式调用中途的 promise 发生错误也直接终止后续 promise 任务的执行。
API 备忘
  • 提供的 API 大多在其 body 闭包参数中写逻辑,所以最简单使用 PromiseKit API 的方式就只关注 body 闭包的出参和入参即可。
  • API 都提供 on: DispatchQueue? = conf.Q.returnflags: DispatchWorkItemFlags? = nil 的入参,用于配置逻辑 body 闭包执行的队列。

API body 闭包签名:

# Promise
resolver: (Resolver<T>) throws -> Void
pipe: (Result<T>) -> Void

# Thenable
pipe: (Result<T>) -> Void
then: (T) throws -> U: Thenable
map: (T) throws -> U
compactMap: (T) throws -> U?
done: (T) throws -> Void
get: (T) throws -> Void
tap: (Result<T>) -> Void

# CatchMixin
catch: (Error) -> Void
recover: (Error) throws -> U: Thenable
recover: (Error) -> Guarantee<T>
recover: (Error) -> Void
ensure: () -> Void
ensureThen: () -> Guarantee<Void>
finally: () -> Void

# Guarantee
resolver: ((T) -> Void) -> Void
pipe: (Result<T>) -> Void
done: (T) -> Void
get: (T) -> Void
map: (T) -> U
then: (T) -> Guarantee<U>

不常用 API body 闭包签名:

# Thenable where T: Sequence
mapValues/flatMapValues: (T.Iterator.Element) throws -> U
compactMapValues: (T.Iterator.Element) throws -> U?
thenMap/thenFlatMap: (T.Iterator.Element) throws -> U
filterValues: (T.Iterator.Element) -> Bool

# Guarantee where T: Sequence
mapValues/flatMapValues: (T.Iterator.Element) -> U
compactMapValues: (T.Iterator.Element) throws -> U?
thenMap/thenFlatMap: (T.Iterator.Element) -> Guarantee<U>
filterValues: (T.Iterator.Element) -> Bool
sortedValues: (T.Iterator.Element, T.Iterator.Element) -> Bool

不用处理/可忽略返回值的接口:

catch -> PMKFinalizer
finally -> Void
cauterize -> PMKFinalizer # 用于消费/忽略掉 catch 中的错误处理

工具性接口:

firstly # 语法糖
DispatchQueue.global().async(.promise) # 直接切队列构造 Promise/Guarantee
race # 完成其中一个 Promise/Guarantee 就能获得结果
when # 完全全部 Promise/Guarantee 才能获得结果

所以总的来说,仅有这么几个关键词:

  • resolver:构建 Promise/Guarantee 时传递结果。
  • pipe:连接结果。
  • then:做下一步的异步任务,连接另一类型的 Thenable,即 Promise/Guarantee。
  • map/compactMap:成功结果值转换,与 then 的区别是返回值类型,而不是 Thenable。
  • done:无返回值的成功结果处理。与 catch 互斥。
  • catch:失败结果处理。与 done 互斥。
  • recover:修复/忽略/消费 部分/全部 错误。
  • ensure/finally:有结果就执行,无论是成功还是失败结果。
  • get/tap:旁路处理成功值,不影响流程。
  • racewhen:组合多个 Promise/Guarantee。
使用构造函数快速创建

快速创建:

func verify(completion: @escaping (()) -> Void) {}
func fetch(completin: @escaping (String) -> Void) {}

_ = Promise { verify(completion: $0.fulfill) }
_ = Guarantee { verify(completion: $0) }
_ = Guarantee { seal in
    verify {
        seal(())
    }
}
_ = Guarantee { fetch(completin: $0) }
抛错

then 闭包中返回 promise,若需中断/抛错,可以:

  • return Promise.init(error:):包装错误直接返回。
  • throw Error:个人更推荐。Swift 中更自然、通用的抛错语句。

上述的抛错相对于整个方法体/函数体来说也是短路求值,即不会执行语句后续的代码。相对比自己加个 failure: @escaping (Error) -> Void 闭包回调更加安全和易用。闭包调用不紧接 return 就造成范围之外的代码逻辑的执行。

扩展:在自己的封装的方法中,也可以加上(-> 前)throws 关键词使其成为 throwing 函数。日常在设计 API、逻辑时也多多使用 throw Error 的方式来抛错。外部使用时不需要处理错误则直接 try? func 忽略。

throwing 函数的优势:

  • 可以使用抛错来代替 return nil,这样定义函数返回值也更容易使用非 Optional 的类型。
  • 短路求值。
  • 外部调用可选地、规范地处理错误。
错误定义

一个 Service 可以定义一组错误(enum)。

也可以直接使用 PromiseKit 自身定义的错误:PMKError。

  • returnedSelf
  • badInput
  • cancelled

值得借鉴:定义错误时可遵循 LocalizedError 协议,提供 errorDescription 错误描述。可以借鉴 PMKError 同时实现 CustomDebugStringConvertible 和 LocalizedError 协议,更便于 lldb 输出。

忽略错误

Thenable 处理后返回的都是自身,即 Promise/Guarantee。Promise 链式调用一般都需要处理错误,若错误已在 recover 中或别处已处理,需要忽略错误处理环节,可使用 CatchMixin.cauterize() 代替 catch 语句。

切换执行的线程队列

PromiseKit API 都提供 on: DispatchQueue? = conf.Q.return,默认是主队列。要切换其他队列可直接传入 on 参数,如 .then(on: .global()) {}

插入旁路逻辑

对于一些不影响主流程链路的操作,如计时、埋点、log,我们不应直接在主流程链路中插入代码,可以使用 get/tap 旁路地插入代码,也方便移除和屏蔽。

常见编译报错

cannot conform to 'Thenable' when I try to use Promise functions

出现这样的错误大概率是用 then 拼接了不返回 Promise 的函数。解决方法也很简单:

They replaced that usage of then { } with done { }.

firstly {
    promiseGetJWTToken()
}.done { tokenJWT in
    // use your token
}

🚩ObjectMapper

ObjectMapper 最巧妙之处是用自定义运算符 <- 连接了属性和对应的解析方式,将赋值引用与属性类型通过运算符传递到解析方式中,避开了 Codable 还需要定义 CodingKey 的额外操作。

自定义解析

自定义解析的最佳时机是 BaseMappable.mapping(map:)

官方给出的自定义参数是在对 map 取下标时传入 TransformOf 实例,如:

let transform = TransformOf<Int, String>(fromJSON: { (value: String?) -> Int? in 
    // transform value from String? to Int?
    return Int(value!)
}, toJSON: { (value: Int?) -> String? in
    // transform value from Int? to String?
    if let value = value {
        return String(value)
    }
    return nil
})

id <- (map["id"], transform)

查看源码,其实还有更进阶的方式。

🔜 后续有空再展开

扩展支持 plist 序列化反序列化

源码中通过 Mapper 作为解析管理类,通过这个类,甚至可以添加一个扩展,支持 plist 的序列化和反序列化。

//  Mapper+PropertyList.swift

import Foundation
import ObjectMapper

public extension Mapper {
    // MARK: 反序列化
    
    static func parsePropertyList(data: Data) -> [String: Any]? {
        let parsed: Any?
        do {
            parsed = try PropertyListSerialization.propertyList(from: data, format: nil)
        } catch {
            print(error)
            parsed = nil
        }
        return parsed as? [String: Any]
    }
    
    func map(propertyList data: Data) -> N? {
        guard let parsed = Mapper.parsePropertyList(data: data) else { return nil }
        return map(JSON: parsed)
    }
    
    // MARK: 序列化
    
    static func toPropertyList(_ propertyListObject: Any, format: PropertyListSerialization.PropertyListFormat = .xml) -> Data? {
        guard PropertyListSerialization.propertyList(propertyListObject, isValidFor: format) else { return nil }
        let data: Data?
        do {
            data = try PropertyListSerialization.data(fromPropertyList: propertyListObject, format: format, options: 0)
        } catch {
            print(error)
            data = nil
        }
        return data
    }
    
    func toPropertyList(_ object: N, format: PropertyListSerialization.PropertyListFormat = .xml) -> Data? {
        let JSONDict = toJSON(object)
        return Mapper.toPropertyList(JSONDict as Any, format: format)
    }
}

public extension Mappable {
    init?(propertyList data: Data, context: MapContext? = nil) {
        guard let obj: Self = Mapper(context: context).map(propertyList: data) else { return nil }
        self = obj
    }
    
    func toPropertyListData(format: PropertyListSerialization.PropertyListFormat = .xml) -> Data? {
        Mapper().toPropertyList(self, format: format)
    }
}

使用:

let newSong: Song = makeModel(json: jsonText)!

// 序列化到 plist
guard let data = decodedSong.toPropertyListData() else { return }
print("🚧 song plist: \(String(data: data, encoding: .utf8))")

// 从 plist 反序列化
let songFromPlist = Song(propertyList: data)
dump(songFromPlist, name: "songFromPlist")

LLDB

类型转换
p import Lib
po unsafeBitCast(address, to: Type.self)
刷新 UI
e CATransaction.flush()
符号断点

系统 API 或闭源 API 断点需要下符号断点。

遇到 OC 接口,需要 OC 的符号。如:

PHImageManager.h:188
- (PHImageRequestID)requestPlayerItemForVideo:(PHAsset *)asset options:(nullable PHVideoRequestOptions *)options resultHandler:(void (^)(AVPlayerItem *__nullable playerItem, NSDictionary *__nullable info))resultHandler API_AVAILABLE(macos(10.15));

“Copy Symbol Name”或“Copy Qualified Symbol Name”
requestPlayerItemForVideo:options:resultHandler:

CocoaPods

新建文件

Xcode 文件区(project navigator)展示的目录有两种类型,在引入之初就决定了:

img

group folder reference
使用场景 最常用。代码、资源引入无脑选它。 蓝色图标。仅用于资源,如 bundle 资源。
细分 img对应目录的 Group:创建即创建本地同名目录。无对应目录的 Group:虚拟的目录,无对应的本地目录。图标左下角有小三角或箭头。可与其他 group 同名。
更新逻辑 外部更新不会同步。引入时目录文件的结构就确定了,后续文件在 Xcode 外部增删不会同步到 Xcode 中,需手动 add files。Pod install 之所以会更新 group 中内容是因为根据本地目录重建了 group。Xcode 内更新:对应目录的 Group:重命名会直接修改本地目录名。添加文件添加到对应目录中。无对应目录的 Group:可以随意重命名。添加文件会添加到项目根目录。 相互更新。

而 Pod 可能存在两种 Group。所以为了确保新建文件位置正确。新建文件直接在源文件对应目录创建文件,再引入。避免因为目录不在源码目录中而导致 pod install 后索引不到。

访问权限

Pod 作为 Swift Module,所以当设计的类是其他 Module 使用的,则一定要声明为 public!

UI

布局区域、响应区域、展示区域

一般来说,布局区域 = 响应区域 = 展示区域。即一般场景只要布局好视图,基本不用修改响应区域和展示区域,一旦要求响应区域、展示区域和布局区域不一致时,是时候将这三者解耦,单独考虑。

  • 布局区域:1:1 对应还原到设计稿。
    • 相关 API:auto layout、UIView.intrinsicContentSizeUIView.frameUIView.bounds
  • 响应区域:根据 UX 要求扩大或缩小。
    • 相关 API:UIView.point(inside:with:)UIView.hitTest(_:with:)
  • 展示区域:按照设计稿扩大或缩小。
    • 相关 API:UIView.clipsToBoundsUIView.maskCALayer.masksToBoundsCALayer.mask

通过修改对应 API 来修改对应的区域,三者相互独立解耦。

弹簧动画

usingSpringWithDampingUIView 的一个动画方法,用于创建一个弹簧动画。usingSpringWithDamping 方法接受两个参数:dampingRatioinitialSpringVelocity,分别用于指定弹簧动画的阻尼比和初始速度。

  • dampingRatio:阻尼比,用于指定弹簧动画的震荡程度,取值范围为 0.0 到 1.0。当阻尼比为 0.0 时,动画会无限振荡;当阻尼比为 1.0 时,动画会立即停止。建议值为 0.7 到 0.8,较小的值会使动画更加弹性,较大的值会使动画更加刚性。
  • initialSpringVelocity:初始速度,用于指定弹簧动画的初始速度,取值范围为任意值。初始速度为正数时,视图会向上移动;初始速度为负数时,视图会向下移动。建议值为 0,因为较大的值可能会导致动画过快或过慢。

以下是一个示例代码,演示如何使用 usingSpringWithDamping 方法来创建一个弹簧动画:

UIView.animate(withDuration: 1.0, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0, options: [], animations: {
    // 在此处设置视图的动画效果
    view.transform = CGAffineTransform(translationX: 0, y: 100)
}, completion: nil)

在上面的示例中,我们使用 usingSpringWithDamping 方法来创建一个弹簧动画,并将阻尼比设置为 0.7,初始速度设置为 0。在动画块中,我们将视图的 transform 属性设置为一个平移变换,使其向下移动 100 个像素。

需要注意的是,当我们使用 usingSpringWithDamping 方法时,我们需要根据实际情况来选择合适的阻尼比和初始速度。建议在实际开发中进行多次测试和调整,以达到最佳的动画效果。

TextView 根据内容自动增高

背景:希望根据用户输入内容的来实时更新 text view 高度布局。

didChange 回调中重新计算高度,然后更新 textView 高度布局。计算高度如:

let minHeight: CGFloat = Layout.TextView.minHeight
let maxHeight: CGFloat = Layout.TextView.maxHeight
let containerFrame = promptInputView.frame
if editText.isEmpty {
    return minHeight
} else {
    let constraintSize = CGSize(width: containerFrame.width, height: 1000)
    let size = promptInputView.textView.sizeThatFits(constraintSize)
    return min(max(size.height, minHeight), maxHeight)
}

maxHeight 用于实现把 text view 自动拉高到一个最大高度后,开始滚动内容。

ScrollView 居中

背景:让 scroll view 中的内容保持居中。

需要重新计算 cntent size 来设置 inset 实现居中。

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
    guard collectionView.numberOfSections == 1 else { return .zero }

    var viewPortSize = collectionView.bounds.size
    let contentInset = collectionView.contentInset
    viewPortSize.width -= contentInset.horizontal
    viewPortSize.height -= contentInset.vertical
    let count = collectionView.numberOfItems(inSection: 0)
    let contentWidth = CGFloat(count) * UI.itemSize.width + CGFloat(count - 1) * UI.itemSpacing
    let contentHeight = UI.itemSize.height
    var insets = UIEdgeInsets(inset: UIView.defaultOutlineWidth)
    if viewPortSize.width > contentWidth {
        insets.left = (viewPortSize.width - contentWidth) / 2
        insets.right = insets.left
    }
    if viewPortSize.height > contentHeight {
        insets.top = (viewPortSize.height - contentHeight) / 2
        insets.bottom = insets.top
    }
    return insets
}

监听页面页面过渡动画完成

背景:在页面 pod 动画完成后执行逻辑。

func dismissToPresent(completion: @escaping () -> Void) {
    guard let topVC = UIViewController.ibaseTopViewController else { return }
    if let vc = topVC.presentingViewController {
        CATransaction.begin()
        vc.dismiss(animated: false)
        let nav = vc as? UINavigationController ?? vc.navigationController
        nav?.popToRootViewController(animated: false)
        CATransaction.setCompletionBlock(completion)
        CATransaction.commit()
    } else {
        DispatchQueue.main.async(execute: completion)
    }
}

设置行高

背景:自定义行高。

通过配置 NSMutableParagraphStyle 到富文本的 paragraphStyle 中:

func makeText(_ text: String, font: UIFont, lineHeight: CGFloat, color: UIColor) -> NSAttributedString {
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.lineSpacing = 0
    paragraphStyle.maximumLineHeight = lineHeight
    paragraphStyle.minimumLineHeight = lineHeight

    return NSAttributedString(string: text, attributes: [
        .paragraphStyle: paragraphStyle,
        .foregroundColor: color,
        .font: font,
    ])
}

叠加与遮罩

overlay:

  • 叠加效果。
  • 只能加,不能减。

mask:

  • 切除某部分,或让某部分变得透明。
  • 只能减,不能加。
颜色叠加

同色叠加,底部纯色,叠层透明度不同看不出效果。

#FFFFFF33 = #FFFFFFFF - #000000CC # 顺序是从底往上

叠加是减法?越叠越暗。

CGMutablePath

CGMutablePath add arc 会接上之前线段的末尾,若是想画一段一段的圆弧,可能不符合预期,需要再添加 move 逻辑。

CALayer 似乎不能重写构造函数

视图不展示问题排查

可按照以下思路排查:

  1. 对象不在视图层级中(可能没 addSubview):lookin 找到对应的 view 对象。
  2. 视图隐藏:alpha == 0isHidden == true
  3. frame 是否正常:
    1. w/h 为 0 都会表现为视图不展示。
    2. 超出父视图可能会被裁切。
  4. 确定是否有 mask:mask alpha 为 0 也会导致不展示。

获取 icon 名称

在 lookin 中定位到 UIImageView,输出其 image 属性,即可在描述中看到 icon 名称。

<UIImageView: 0x2aea83990> image
<UIImage:0x281b19b00 named(org.cocoapods.LVEditor: ic_none_d) {20, 20} renderingMode=automatic>

storyboard 不支持 Swift 嵌套类型

img

storyboard 设置 Class 时不支持 Swift 的嵌套类型,且必须勾选“Inhert Module From Target”,否则将出现以下错误:

[Storyboard] Unknown class _TtC5UILab22PageDataViewController in Interface Builder file.

storyboard/xib 这套 GUI 布局应该也是差不多要退出历史舞台了。

找到焦点视图

找到当前处于焦点的视图,可对当前 UIWidnow 对象调用 firstResponder 扩展方法:

public extension UIView {
    /// SwifterSwift: Recursively find the first responder.
    func firstResponder() -> UIView? {
        var views = [UIView](arrayLiteral: self)
        var index = 0
        repeat {
            let view = views[index]
            if view.isFirstResponder {
                return view
            }
            views.append(contentsOf: view.subviews)
            index += 1
        } while index < views.count
        return nil
    }
}

// 判断当前是否是焦点视图
xx == window?.firstResponder

SVG 路径绘制

一些走过的弯路:

最佳实践:

SVG Converter,直接从文本编辑器打开 SVG,把其中的 viewBox 和路径参数拷贝出来,到这个网站进行转换。

备选方案:

参考 SVGKit 源码,使用代码直接从 SVG 读取并生成路径。

其他第三方组件:

UIView drawRect 透明

需要额外设置 UIView 的 backgroundColor 属性为 .clear,单单在 drawRect 方法中做操作是做不到的。

💬UITableView

通过 auto layout 自适应高度

UITableViewCell 直接与 contentView 添加布局约束即可实现自适应高度。难搞的是 UITableView 的其他子部件。

header view 的特殊处理

header view 本身是不支持自动布局的,所以要特殊处理一番。

func setAndLayoutTableHeaderView(header: UIView) {
    self.tableHeaderView = header
    self.tableHeaderView?.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        header.widthAnchor.constraint(equalTo: self.widthAnchor)
    ])
    header.setNeedsLayout()
    header.layoutIfNeeded()
    header.frame.size =  header.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
    self.tableHeaderView = header
}

或者在 layoutSubviews 中更新:

func updateTableHeaderSize() {
    if let topView = tableHeaderView {
        let targetSize = bounds.size
        topView.frame.size = topView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
    }
}
reuse view

主要是解决 UIView-Encapsulated-Layout-Width 和 UIView-Encapsulated-Layout-Height 问题。

所以,基本的解法是降低发生冲突方向布局的优先级。但这样会有不确定性,不确定 break 掉的约束会是什么效果。

另一种方案是配置约束时考虑 width 会变成 0 的 case,确保各种约束(如缩进)不会导致某个 view 的 width 为负数。然后在 layoutSubviews 方法中更新约束到目标效果,或干脆直接重建约束。

💬UITableViewCell

设置背景色

backgroundColor 无效时,设置 backgroundView。目前发现 UITableViewHeaderFooterView 子类设置 backgroundColor 无效。

取消高亮
selectionStyle = .none

不行的话,在 prepareForReuse 中也设置下。

💬UIView 生命周期

获得上屏 view
  1. init + main.async

要获得显示在屏幕上的 View,最简单粗暴的方式是在初始化的位置,加个 DispatchQueue.main.async 闭包。

优点:

  • 确保只执行一次

缺点:

  • 不确定是否真的布局完成;
  • 只适合那种初始化就配置好视图的情况。
  1. didMoveToWindow

另外,还可以在 didMoveToWindow() 方法中写相关的逻辑,这时的 next responder 是能拿到的。

优点:

  • 确保已经添加到视图。
  • 视图可以在任意时机布局。

缺点:

  • 可能会执行多次。

💬布局

UIView 如何防止被挤压

UIView 可以通过设置抗压缩和抗拉伸属性来防止被挤压。抗压缩属性表示视图不想缩小到比其内容更小的程度,而抗拉伸属性表示视图不想被拉伸到比其内容更大的程度。可以使用setContentCompressionResistancePriority(_:for:)方法设置抗压缩属性,使用setContentHuggingPriority(_:for:)方法设置抗拉伸属性。这些方法都需要传入一个优先级参数,优先级越高,视图越不容易被压缩或拉伸。默认的优先级为 750 和 250,可以通过设置更高的优先级来防止视图被挤压。

例如,如果您想防止一个 UILabel 的内容被压缩,可以使用以下代码:

label.setContentCompressionResistancePriority(.required, for: .horizontal)

如果您想防止一个 UIView 被拉伸,可以使用以下代码:

view.setContentHuggingPriority(.required, for: .horizontal)

请注意,这些方法只适用于使用 Auto Layout 进行布局的视图。如果您使用的是 Autoresizing Mask,则可以使用autoresizingMask属性来设置视图的自动调整大小行为。

setContentHuggingPriority(_:for:)setContentCompressionResistancePriority(_:for:) 是 Auto Layout 中非常重要的两个方法,它们可以用来控制视图的自适应大小。以下是更详细的介绍和效果:

setContentHuggingPriority(_:for:)

setContentHuggingPriority(_:for:) 方法用于设置视图的抱紧优先级。抱紧优先级决定了视图在自适应大小时的最小大小限制。具体来说,它控制了视图在拉伸时的行为。

  • UILayoutPriority.required:视图的大小必须等于或大于其内容的最小大小。这是默认的优先级。
  • UILayoutPriority.defaultHigh:视图的大小可以小于其内容的最小大小,但不能小于其他具有较低抱紧优先级的视图。
  • UILayoutPriority.defaultLow:视图的大小可以小于其内容的最小大小,并且可以小于其他具有较高抱紧优先级的视图。

例如,在一个水平方向的 UIStackView 中,如果一个视图的抱紧优先级设置为 .required,则它的宽度不会小于其内容的最小宽度。如果一个视图的宽度抱紧优先级设置为 .defaultLow,则它的宽度可以更小,以适应其父视图的大小。

setContentCompressionResistancePriority(_:for:)

setContentCompressionResistancePriority(_:for:) 方法用于设置视图的压缩阻力优先级。压缩阻力优先级决定了视图在自适应大小时的最大大小限制。具体来说,它控制了视图在压缩时的行为。

  • UILayoutPriority.required:视图的大小必须等于或大于其内容的最小大小。这是默认的优先级。
  • UILayoutPriority.defaultHigh:视图的大小可以小于其内容的最小大小,但不能小于其他具有较低压缩阻力优先级的视图。
  • UILayoutPriority.defaultLow:视图的大小可以小于其内容的最小大小,并且可以小于其他具有较高压缩阻力优先级的视图。

例如,在一个水平方向的 UIStackView 中,如果一个视图的压缩阻力优先级设置为 .required,则它的宽度不会小于其内容的最小宽度。如果一个视图的宽度压缩阻力优先级设置为 .defaultHigh,则它的宽度可以更小,以适应其父视图的大小。

需要注意的是,抱紧优先级和压缩阻力优先级通常是成对使用的,以确保视图在自适应大小时的行为符合预期。例如,在一个水平方向的 UIStackView 中,一个视图的抱紧优先级设置为 .required,压缩阻力优先级设置为 .defaultHigh,则它的宽度在拉伸时会尽可能地保持其内容的最小宽度,而在压缩时会尽可能地保持其内容的最大宽度。

参考资料:

  1. AutoLayout - 内容压缩阻力(Content Compression Resistance)和内容吸附(Content Hugging)
  2. UIView.AutoresizingMask
  3. setContentCompressionResistancePriority(_:for:)
  4. setContentHuggingPriority(_:for:)

若出现没有自动跟随尺寸变化,检查确保全部使用了 equalTo!!!

布局更新时机
  1. layoutSubviews

放心在这里更新 auto layout 的约束常量,这不会出发循环调用。

  1. didMoveToWindow

这是 UI 更新布局的最晚时机,这时 superview、responder 都已经有值,但这时 auto layout 可能还没完成布局,要获得 auto layout 后到布局可以在下一次 runloop 中获取。

这个方法调用时机很巧妙,当 view appear/disappear 的时候也会被调用,因为这时的 window 对象会置为 nil,这时就可以把 controller 生命周期的事情归还到 UIView 中来做。

获取自动布局后的 frame
  1. 强制布局

调用 setNeedsLayout() + layoutIfNeeded(),触发同步布局。然后获取 view 的 frame。

  1. 获得布局后的尺寸

调用 systemLayoutSizeFitting(_:) 方法,获取基于当前约束的视图的最佳大小。该方法只是做计算而已,并没有进行布局。

targetSize:偏好的视图尺寸。要获得尽可能小的视图,设置为 UIView.layoutFittingCompressedSize。要获得尽可能大的视图,则设置为 UIView.layoutFittingExpandedSize

label.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)

另外,使用 UIView.sizeThatFits 也可达到同样的效果。

label.sizeThatFits(.zero)

注意这里返回的是 CGSize。

自动布局更新

要控制局部 UI,尽量使用 UIStackView 和约束常量(NSLayoutConstraint.constant)来实现布局更新,而不是使用 snp.remakeConstraints。而 snp.updateConstraints 更不建议使用,因为需要了解之前是怎么布局的,也是只能更新约束常量,且跟之前的布局强强耦合,容易出错,不好维护。

不要尝试给系统的 layout guide 添加约束

UILayoutGuide 的作用如其名,是布局参照,如画图时的辅助线。当使用 layout guide 编写布局约束时,应永远把 layout guide 作为宾语,而不是主语。

let contentGuide = scrollView.contentLayoutGuide
// 不能这样做!!
contentGuide.snp.makeConstraints { make in
    make.edges.equalTo(label)
}

// 而是改成这样
label.snp.makeConstraints { make in
    make.edges.equalTo(contentGuide)
}

如果是自建的一个 layout guide,则可以且优先作为主语进行布局,即先画好辅助线,再使用辅助线布局其他视图。

macOS

命令行工具执行异步代码

相关链接:

大概有几种方式:

  • 阻塞进程,让其不退出。
  • run in main runloop.

使用信号量阻塞:

var semaphore = DispatchSemaphore(value: 0)
runAsyncTask { // 完成回调
    // 释放,退出
    semaphore.signal()
}
// 阻塞不退出
semaphore.wait()

使用 runloop,run in main runloop:

//...your magic here
// add a little 🤓iness to make it fun at least...
RunLoop.main.run(until: Date() + 0x10)  //oh boi, default init && hex craze 🤗
// yeah, 16 seconds timeout

// or even worse (!)
RunLoop.main.run(until: .distantFuture)

dispatchMain:

runAsyncTask { // 完成回调
    // 退出
    exit(EXIT_SUCCESS)
}

// Run GCD main dispatcher, this function never returns, call exit() elsewhere to quit the program or it will hang
dispatchMain()
❌