0%

同一个需求,三种做法,完全不同的结果。这篇文章分享我们终端团队从”让 AI 写代码”到”让 AI 参与工程全流程”的实践路径。


写在前面

我们是一个终端团队,工作区下面挂了多个组件仓——基础层、业务层、调试工具,宿主仓负责装配。

过去一年,我们逐步把 AI 编程助手接入了开发流程。不是简单让 AI 写代码,而是从需求抓取到代码归档,每个环节都有 AI 参与

这篇文章会讲四件事:

  1. 三种模式有什么不同——纯人肉 / Vibe Coding / AI 全流程工程化
  2. 为什么 Vibe Coding 不够用——我们踩过的核心问题
  3. 全流程工程化具体怎么做——每个环节的工具、规则和产物
  4. 怎么一步步走到这里——学习路线和经验教训

一、三种模式:同一个需求,三种命运

产品在钉钉丢过来一个 PRD:「换肤配置」——用户可以为游戏选择不同的皮肤主题。需求涉及:UI 换肤逻辑、主题资源包下载、配置后台对接、异常降级。跨了宿主仓和业务组件仓。

同样的需求,三种模式下会发生什么?

模式定义

模式 一句话 AI 的角色
纯人肉 从头到尾自己干 没有 AI
Vibe Coding 让 AI 写代码,我指挥 打字员,你说它写
AI 全流程工程化 AI 参与每个环节,受规则约束 全流程协作者,有边界有规矩

逐环节对比

1. 需求获取

纯人肉 Vibe Coding 全流程工程化
PRD 获取 手动阅读,脑子记 复制文字粘贴给 AI 自动截图抓取,AI 可读
API 获取 手动抄字段 复制粘贴 自动抓取,结构化 JSON
原型图 截图存本地 AI 看不到 合并截图,AI 可读
信息完整度 依赖人的记忆力 只有文字,丢图 完整保留

2. 需求评审

纯人肉 Vibe Coding 全流程工程化
谁在审 开发自己审 没人审 AI 先审,结构化评审
什么时候发现问题 做到一半 测试阶段 编码之前
返工成本 中等 最高(写完重来) 最低(提前拦截)

3. 方案设计

纯人肉 Vibe Coding 全流程工程化
有没有方案文档 在脑子里 没有 有,结构化产物
AI 什么时候介入 不介入 直接写代码 先设计,再实现
方向偏了什么时候发现 写到一半 写完 动手之前

4. 编码实现

纯人肉 Vibe Coding 全流程工程化
代码产出速度
是否符合项目规范 靠人记 经常不符合 规则约束,首次即对
修规范问题的成本 无(自己写的) 高(可能和写代码一样久) 低(规则前置约束)
净效率 基线 看起来快,实际未必 最高

5. 代码审查

纯人肉 Vibe Coding 全流程工程化
审查覆盖面 看什么算什么 表面风格 领域专项 + 项目约束
能否发现性能问题 靠经验 几乎不能 内置检查项
结果可执行性 模糊 模糊 带严重级别,明确动作

6. 工程规范

纯人肉 Vibe Coding 全流程工程化
分支名/Commit 规范 靠自觉 AI 不知道规范 规则约束,自动合规
组件联调 手动重复操作 AI 不会 一个命令自动化

7. 变更归档

纯人肉 Vibe Coding 全流程工程化
方案文档 有,结构化归档
设计取舍记录 在脑子里/会议纪要 design.md
新人接手成本

同一个需求,从收到 PRD 到上线

纯人肉:5 天

Day 1 看 PRD,脑子想方案 → Day 2-3 写代码 → Day 4 做到一半发现 PRD 没写异常降级,停下来等产品 → Day 5 补完逻辑,提交 MR。没有方案文档,半年后新人问「为什么这么设计」,没人答得上来。

Vibe Coding:看起来 2 天,实际 4 天

Day 1 把 PRD 文字贴给 AI,产出很快 → Day 2 发现 AI 把代码写错了仓,用了错误的技术栈,手动改 → Day 3 测试发现异常降级没做,补代码 → Day 4 AI review 说「代码不错」,但测试提了 5 个 bug,逐个修。没有方案文档,没有需求评审记录。

AI 全流程工程化:3 天(含前期准备)

Day 1 上午抓 PRD 和 API,AI 评审发现 3 个 P0 阻塞项 → Day 1 下午拿评审报告找产品补需求,同时走变更流程:proposal → design → tasks → Day 2 按任务清单编码,AI 在规则约束下首次即对仓、对技术栈 → Day 3 AI 审查发现 1 个严重项,修完提交 MR,归档变更。3 天完成,产出不仅是一段代码,还有需求评审报告、方案文档、任务清单、代码审查记录、能力基线。


二、为什么 Vibe Coding 不够用

看到上面的对比,你可能会问:Vibe Coding 不是挺快的吗?为什么还要搞全流程工程化?

我们不是一开始就做全流程工程化的。路径是这样的:

1
纯人肉 → 觉得效率低 → 引入 AI → Vibe Coding → 发现问题 → 全流程工程化

从纯人肉到 Vibe Coding 的动力很简单:AI 写代码快,这谁都知道。但从 Vibe Coding 到全流程工程化,原因更值得说:

1. AI 写得快但改得慢

它不知道团队的规范——用哪个网络库、解析库、埋点 SDK。产出越多,修正成本越高。你花了和 AI 写代码一样长的时间,在修这些”风格不对”的问题。

2. AI 不会质疑需求

你让它做,它就做。PRD 没写异常降级?它不管。交互细节缺失?它按自己理解补。做到一半发现方向偏了,浪费更大。

3. AI 没有上下文

PRD 在钉钉,API 在 ShowDoc,设计稿在 Figma——这些都在外部系统里,AI 看不到。写出来的代码凭想象,复制粘贴又丢图丢结构。

4. AI 审查自己写的代码是无效的

让 AI review 自己写的代码,它当然觉得不错。通用 review 说”这个函数有点长”——帮助不大。真正的问题没人发现:启动链路阻塞、UI 线程卡顿、网络层不合规。

5. 没有文档的代码就是技术债

AI 写的代码尤其如此——因为没人完整理解它。半年后新人问「为什么这么设计」,git log 里只有一堆看不出意图的 commit。


全流程工程化的本质,不是给 AI 加更多限制,而是把 AI 从”打字员”升级为”带规矩的协作者”:

  • 它知道该改哪个仓(归属路由)
  • 它知道该用什么技术栈(规则约束)
  • 它知道需求有没有漏洞(PRD 评审)
  • 它知道代码有没有隐患(领域化审查)
  • 它知道做完之后要留下什么(变更归档)

三、全流程工程化:具体怎么做

1. 工具选型:三把刀,但只磨一块磨刀石

团队里 Cursor、Claude Code、Kiro 都在用,不强求统一工具,但有一个原则必须统一:AI 规则只维护一份,所有工具共享

1
2
3
4
5
.cursor/rules/*.mdc          ←  核心规则定义(唯一真实来源)

├──镜像──→ .kiro/steering/*.md (Kiro 消费)

└──分流──→ CLAUDE.md (Claude Code 消费)

规则写在 .cursor/rules/,这是唯一的”磨刀石”。.kiro/steering/ 是镜像给 Kiro 用的,CLAUDE.md 是分流入口告诉 Claude Code 遇到什么问题去看哪个规则。

早期各子仓自己带了 .cursorrules.kiro/.trae/,结果不同工具看到的规则不一致,AI 行为不可预测。后来我们专门做了一个变更把子仓的 AI 配置全部清理掉,统一收敛到工作区根目录。教训:子仓零配置,根目录统一定义。

2. 多仓架构:让 AI 知道”这事不归你管”

AI 助手最常犯的错:你让它修一个属于组件仓的 bug,它直接在宿主仓里加了个 workaround。这在多仓架构里是灾难。

我们给 AI 写了一个归属判断路由器,而不是把所有规则扁平堆在一起:

1
2
3
4
5
6
任务进来
├── 宿主仓的事? → 看宿主仓规则
├── 基础层的事? → 看基础层规则
├── 业务层的事? → 看业务层规则
├── 调试工具的事? → 看调试工具规则
└── 共享基线 → 始终生效

AI 在动手之前会先走这个路由,判断该改哪里。越界改仓的情况大幅减少。

3. 需求阶段:把外部文档搬进 AI 的视野

我们的 PRD 写在钉钉文档,API 文档写在 ShowDoc。这些都在外部系统里,AI 看不到。

两个 Playwright 自动化技能解决了这个问题:

  • dd-prd-claw:钉钉文档 → 慢滑截图 → 去重合并 → AI 可读的完整 PRD(包括原型图和标注)
  • dd-api-claw:ShowDoc → 提取文本 → 结构化 JSON(同时产出纯文本和 JSON,方便不同场景消费)

抓取后的文档就在工作区里,AI 助手可以直接读取。不需要人工搬运。

4. PRD 评审:让 AI 先审需求,再写代码

开发做到一半才发现 PRD 没写清楚某个交互细节——这是最常见的返工原因。

dd-prd-judge 对抓取的 PRD 做实现前评审:

评审维度 审什么
用户流程 核心路径完整吗?分支流程覆盖了吗?
交互规格 状态切换、动画、手势写清楚了吗?
业务规则 计算逻辑、边界条件定义了吗?
数据契约 字段定义、必填/选填、枚举值给了吗?
异常处理 断网、超时、降级描述了吗?
验收标准 有没有可执行的测试用例?

风险分级:P0(阻塞开发,必须补)→ P1(必然返工,当前迭代补)→ P2(不阻塞,开发中补)→ P3(建议优化,后续处理)。

实际案例:「换肤配置」PRD 评审得分 58/100,标注了多个 P0 阻塞项——用户流程不完整、交互规格缺失、异常处理未覆盖。这些问题在编码前就被拦截了。

5. 变更管理:让每一步都有据可查

以前做功能,经常是”PRD 丢过来 → 直接写代码 → 写完发现方向偏了”。我们引入了 spec-driven 模式,给变更流程加上结构化中间产物:

1
2
3
4
5
6
7
8
9
10
11
12
13
proposal.md ──→ delta spec ──→ design.md ──→ tasks.md
│ │
│ 为什么要改? 具体怎么实现? │
│ 改什么能力? 任务拆分 │
│ │ │
│ ▼ ▼
│ 实现 & verify
│ │
│ ▼
│ archive(归档)
│ │
│ ▼
└── ── ── ── ── ── ──→ sync delta → main spec
产物 回答的问题
proposal.md 为什么要做?改什么?影响范围?
delta spec 新增/变更了什么能力?
design.md 技术方案是什么?影响哪些模块?
tasks.md 具体要做哪些事?优先级怎么排?

归档后,delta spec 合入 main spec。下次再改同一块能力时,AI 能看到历史基线。半年后新人接手,看归档就知道当初的设计取舍。

最大的变化是:**AI 不再直接写代码,而是先过一遍”为什么要做、怎么做、分几步”**。

6. 工程自动化:把团队规范写进 AI 的肌肉记忆

Git 工作流规范

规范 规则 AI 行为
分支名 feature/[任务号]-中文描述 AI 创建分支时自动遵循
Commit -#任务号 提交信息 AI 写 commit 时自动带任务号
推送保护 禁止推 master/develop AI 推送前检查分支名

多仓组件联调

联调时切本地源码,以前要手动读 Podfile 版本 → 去组件仓 checkout 对应 tag → 写本地配置文件,多个组件仓重复操作。现在一个命令搞定,支持 --dry-run 预览。

AI 生成的 commit 和分支不再需要人工修正格式。

7. 代码审查:不是通用 review,是领域专家 review

通用 AI 代码审查会说”这个函数太长了”——帮助不大。我们关心的是:会不会影响启动速度?会不会卡 UI 线程?有没有走规定的网络层?

以下是我们在移动端的实践,思路可迁移到其他终端领域:

dd-code-review 内置了质量六维模型:

维度 典型问题(以移动端为例) 严重级别
启动性能 应用启动时同步读磁盘/发请求 [严重]
流畅度 列表滚动时重复创建视图 [主要]
稳定性 强制解包服务端可选字段 [严重]
耗电 后台未停止的定时器 [主要]
网络 相同请求未复用缓存 [次要]
发布风险 Release 包含调试入口 [严重]

还内置了项目级约束检查:网络请求是否走了规定的网络层?解析是否用了规定的解析库?埋点是否接了规定的监控 SDK?

审查结果带严重级别:[严重] 必须修、[主要] 应当修、[次要] 建议修、[挑刺] 可忽略。不再是泛泛的代码风格建议,而是带业务上下文的可执行行动项。

8. 全流程串起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
钉钉 PRD ──┐                              ┌── 代码审查 ──→ 合入
│ │
ShowDoc ───┤ │
▼ │
┌──────────┐ ┌─────┴─────┐
│ PRD 抓取 │ │ 编码实现 │
│ + API抓取 │ │ (规则约束) │
└────┬─────┘ └─────┬─────┘
│ │
▼ │
┌──────────┐ ┌─────┴─────┐
│ PRD 评审 │ │ 工程自动化 │
│ (门禁) │ │ (Git/组件) │
└────┬─────┘ └─────┬─────┘
│ │
▼ ▼
┌──────────┐ ┌───────────┐
│ 方案提议 │──────────────────→│ 变更归档 │
│ + 设计 │ │ + Spec同步 │
│ + 任务拆分 │ └───────────┘
└──────────┘

◄─────── spec-driven 变更流程 ──────────────────►

一句话:需求进来 → 先抓取 → 再评审 → 然后设计拆任务 → 编码(受规则约束+工程自动化)→ 审查 → 归档。AI 参与了每一个环节。


四、学习路线:从用到工程化

前面是我们的实践结果,但团队不是一步到位的。这条路怎么走?我们总结了三个阶段:

阶段一:上手(1-2 周)—— 先用起来

目标:让 AI 参与每天的开发工作,建立基本手感

  • 两个工具都要会:Cursor 擅长编辑器内实时辅助,Claude Code 擅长终端级多文件任务,从真实小任务开始
  • 核心习惯:小步给指令,每步 review——不要一次性甩一个大需求
  • 给项目写 Rules(Cursor 的 .cursor/rules / Claude Code 的 CLAUDE.md),让 AI 懂你的项目约定
  • 关键体感:Rules 越具体,AI 输出越稳定

自测验证:

验证项 达标线
两个工具都能用 Cursor 能独立完成行内编辑 + 上下文引用;Claude Code 能产出可直接 review 的 PR
工具选择合理 回顾最近 5 次任务,至少 3 次选择合理(编辑器内小改用 Cursor,跨文件/终端任务用 Claude Code)
Rules 生效 有 Rules 时 AI 输出明显更贴合项目约定

阶段二:提效(2-4 周)—— 改变工作方式

目标:从”AI 写代码”升级为”你写 Spec,AI 实现”

最核心的方法论是 Spec 编程

1
需求 → 写 Spec → AI 出方案 → 你确认 → AI 写代码 → 你审查

Spec 越清楚,AI 输出越靠谱;模糊输入 = 随机输出。

Prompt 质量是 Spec 编程的基础:

  • 好的 Prompt = 清晰目标 + 充分上下文 + 明确约束
  • 太模糊或太详细都不行,给方向和边界,让 AI 决定实现
  • 常见反例:
    • “优化一下这个函数” → AI 不知道优化性能?可读性?兼容性?
    • “帮我写个登录页” → 缺少技术栈、设计规范、接口约定,输出不可控
    • “按照第一行到第三行的方式,写第四行” → 逐行指挥,不如描述整体模式让 AI 批量处理

自测验证:

验证项 达标线
Spec 驱动开发 产出 Spec 文档 + AI 生成的代码,代码一次通过 review 或仅需微调
Prompt 质量 结构化 Prompt 输出的可用性明显高于随意版
Spec 精度 精确版 Spec 的返工次数明显少于粗略版

阶段三:整合(持续)—— 让 AI 融入工具链

目标:AI 不只是聊天窗口,而是连接设计、数据、服务的入口

  • MCP:让 AI 直连 Figma、数据库、内部 API,从”盲猜”变”有据可依”
  • Skills / Hooks:把重复动作自动化——代码审查、安全检查、循环任务
  • Rules 迭代:阶段一写了初版 Rules,现在根据 AI 犯过的错持续迭代,让 Rules 越来越精准

自测验证:

验证项 达标线
Skills 使用 每个 Skill 产出可观察的结果(如 /simplify 后代码行数减少、/review 发现实际问题)
Rules 迭代 至少完成 2 次”犯错→补 Rule→验证生效”的闭环
MCP 接入 AI 借助 MCP 产出的结果准确性高于无 MCP 时

贯穿始终的关键认知

认知 说明
你决策,AI 执行 你负责思考和决策,AI 负责执行和扩展
Spec > 逐行指挥 写清楚”要什么”,比指挥”怎么写”效率高得多
Rules 是杠杆 一次写好,每次对话都生效,投入产出比最高
小步迭代 一次一个明确指令,逐步推进,每步确认
审查不可省略 AI 生成的代码必须 review,你是最终责任人

五、踩过的坑和当前局限

踩过的坑

教训
规则扁平堆砌 AI 不知该看哪条,经常忽略重要约束 → 改为分层路由(归属判断)
让 AI 直接写代码 写完发现方向偏了,返工更大 → 先走 proposal → design → tasks
通用代码审查 帮助不大,开发同学不当回事 → 改为领域化审查

当前局限

局限 说明
PRD 抓取依赖 Playwright 钉钉文档结构变化时脚本需同步更新
规则镜像需人工维护 .cursor/rules/.kiro/steering/ 目前是手动镜像,存在不一致风险
AI 归属判断偶尔误判 复杂跨仓改动时,路由判断可能不够精准
PRD 评审深度有限 AI 能发现缺什么,但不能替代产品补全需求细节

六、三种模式的适用场景

没有银弹,三种模式各有适合的场景:

模式 适合什么 不适合什么
纯人肉 探索性原型、个人项目、极简改动 团队协作、多仓架构、长期维护
Vibe Coding 一次性脚本、学习练手、简单独立功能 多仓项目、有规范的团队、需要长期维护的代码
全流程工程化 团队项目、多仓架构、长期迭代、多人协作 一个人临时写个脚本(前期投入不值得)

判断标准很简单:这段代码的生命周期有多长? 只需要跑一次,Vibe Coding 够了;需要活半年以上,全流程工程化才划算。


Vibe Coding 让 AI 替你打字,全流程工程化让 AI 替你思考——而且是有规矩地思考。

1 背景

最近半年做了一次大的业务架构重构,总体涉及 2w 多行代码,整个弄完以后个人对客户端架构侧的基本概念又有了一些新的理解。

2 问题

模块功能大概是类似音视频上下滑列表,整体有两大块,上下滑容器详情页。页面结构如上图(示意图)所示。

fgwGFS.md.png

原始的框架核心类如上图所示:

  1. 上下滑容器类,各种跟上下滑相关的逻辑都放在这个视图控制器中,业务方继承上下滑容器类,则他拥有上下滑能力,并且可以通过该业务子类重写父类里面的方法,来达到业务逻辑的定制;
  2. 详情页接口类,是一个纯接口。业务方通过实现该接口里面的各种方法把详情页融入上下滑框架;
  3. 接口回调是一个代理接口类,主要用于返回详情页事件,让容器感知各种详情页事件,执行对应操作。

经过长期业务迭代,发现上下滑容器类耦合严重,究其原因主要是两方面:

  1. 附加功能和基本功能堆叠,即上下滑容器职责不单一,整个代码接近 2k 行,包含滑动,刷新,触底,网络监控,快速起播,缓存等等许多逻辑。这直接导致我们添加、删除、修改上面的业务逻辑非常不方便。
  2. 上下滑容器承载了详情页的业务逻辑,即上下滑容器里面直接关联了详情页,详情页和上下滑容器通过详情页回调通信。这样使得上下滑容器里面的业务层和功能层耦合了,因为上下滑容器既要实现功能层的滑动框架等基本功能,也需要关注详情页的个性化事件进行对应处理。

3 附加功能和基本功能堆叠的解法——模块、插件与服务

3.1 经典做法——分类

针对附加功能和基本功能堆叠这个问题,我们有一个经典做法——分类。把上下滑容器页按照功能拆分为主类和分类。就我们这个上下文来说,上下滑承接这种逻辑可能在主类里面,其他诸如缓存逻辑、预加载逻辑、刷新控制逻辑等都可以独立为分类。这种做法的好处就是简单清晰。不过此方案至少存在两个缺陷:

  1. 如果业务方想删除某个附加逻辑,则仍然需要继承整个页面容器后覆盖一个个分类函数,后续调试维护不是很方便;
  2. 如果业务方想新增独立的附加逻辑,首先是需要建立一个业务侧分类,然后需要在主类里面导入这个新的业务分类,并在合适的时机调用这些分类方法。这里就存在主类对业务方分类的反向依赖。

    3.2 聚合关联业务逻辑——模块化

经典方案的缺陷本质是分类依赖了主类的时机和数据。比如,就预加载分类来说,主类在视图生命周期和上下滑切换这两个时机需要调用预加载分类的预加载方法。针对这个问题,我们可以使用观察者模式,即把主类当做主题,抛出时机和数据,分类作为观察者接受时机和数据执行动作,以此解耦。更进一步,观察者可以不用是一个分类,只要是一个简单的对象类就可以。我们暂且把这个类称为模块类,它聚合了一块独立的业务逻辑,感知容器类的事件和数据执行具体的动作。

fgw1df.png

3.3 标准化输入输出——插件化

标准化容器类的数据和事件,比如提供统一的生命周期,上下滑,网络,展示等事件。同时,附加模块对外提供统一的 API 接口。再加上附加模块本身就是观察者,天然可插拔。这样一个模块就相当于上下滑主题的插件。

fgw3o8.png

3.4 依赖接口不依赖实现——服务化

针对某些场景,业务方需要整体重写某个插件,比如要重写预加载插件。如果我们在框架的其他类里面直接依赖了具体的预加载插件类,则这件事变得很艰难。比较优雅的做法是预加载抽出一个接口,插件类实现该接口。其他地方通过服务发现访问插件接口而不是直接调用某个具体的插件类。同时插件接口通常比较简单,方便下沉分层。这个思路其实是借鉴组件分层,组件接口下沉,接口库和实现库分层。

fgwleP.png

4 容器承载详情页业务逻辑的解法——功能层与业务层分离

fgwJJg.md.png

4.1 问题的分析

另外一个问题是上下滑容器承载了独特详情页的逻辑,即原始的框架是上下滑容器里面直接关联了详情页,详情页通过回调告知上下滑容器自己发生的事件,上下滑容器收到对应事件后执行调用。这样使得上下滑容器里面的业务层和功能层耦合了,因为上下滑容器既要实现功能层的滑动框架等基本功能,也需要关注详情页的个性化事件进行对应处理。

4.2 来自 IGList 的启发——功能层和业务层分离

针对这个问题,我们可以直接借鉴 IGList 的思想,IGList 中每个详情页被 SectionController 管理起来了,框架只感知 SectionController,不感知详情页。因此,新上下滑容器不是直接访问详情页,而是中间有一层业务层 ViewItem。ViewItem 组合一个详情页并且接受对应的事件回调。接到事件回调后,可以调用功能层的各种插件服务。这样使得上下滑容器变成一个纯粹的功能层,不再感知具体业务。综上,上下滑容器变成纯粹的功能类,业务这部分由 ViewItem 承接起来了,因为是 ViewItem 感知了详情页事件并进行逻辑判断后调用上下滑容器的基础功能去执行。具体如上图所示。

5 总结

本次记录了一次业务架构重构的技术侧构思,原来对一些架构名词的认知还是停留在纸面,经过此次实践理解更加深刻了。

1 背景

同快手App一样,快手极速版App也会参加2020央视春晚活动,考虑到春晚活动时产品DAU会突增,有大量的网络请求从客户端发出,容易打死服务端,导致客户端界面展示异常,核心功能不可用,春节活动无法顺利开展等。因此通过对客户端API网络请求进行精细化控制减少对服务端的压力,保障核心功能和春节活动变得非常重要。

2 关键问题

整个App稳定性保障客户端侧的关键问题大致有三个:

  1. 任务相关:具体实施哪些行为来控制API请求;
  2. 组织相关:整件事情涉及的人数广团队多,包括基础团队和各个业务团队,整个几百人,如何推进保证最终达成目标;
  3. 效率相关:在任务实施的过程中,如何保障质量效率。

3 任务相关

具体实施哪些行为来控制API请求呢?我们认为大致有两件事要做:

  1. 设计并用代码实现一套行之有效的稳定性策略来限制不必要的请求以及保障真正重要的请求
  2. 确保这套策略确实能达到效果且对App核心数据无明显负向

3.1 策略

经过讨论,我们决定使用一套json来同时下发稳定性策略和春晚活动策略,这样只要保证这套json能正确触达用户,整个春晚活动就能玩得转。同时这套策略也必须要保障能正确触达用户,不然后面的事情就无法玩转。

3.1.1 实时高可用

首先需要保障策略高可用,在路径侧我们大概做了四层,第一层是从接口下拉的策略配置,第二层是从CDN下拉的策略配置,第三层是本地缓存的上次成功从接口获取的配置,第四层是客户端本地兜底配置,每个策略自带版本号,高版本可覆盖低版本,反之则不行。同时在实时触达方面,我们大概做了两件事,首先每次冷启动需要拉取策略,其次通过APP的心跳接口给客户端下达最新版本号,客户端对比本地版本号和网络号后决定是否获取最新策略。

3.1.2 API限流

针对一些运营类接口比如用户引导弹窗配置接口或者是App体验优化类的接口比如启动直接获取魔法表情列表,离线包配置信息,这些接口可以做限流。具体是把App的生命周期划分为几个关键的时机,改造业务请求带上请求时机标记,则API限流策略可以通过配置使得指定API在部分时机内不发出请求,直接返回。可能的关键时机包括:冷启动、首页创建、切换前台、切换后台、登录、登出、获取到AB、网络状态改变、默认等等。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"api_degrade": [
{
"paths": [
"/operation/foo",
"/optimize/foo"
],
"time": [
"cold_start",
"homepage_create",
"foreground",
"background",
"login",
"logout",
"after_ab"
]
}
]

3.1.3 API延迟打散

一些客户端日志上报请求会定时上报日志,如果这些请求被限流,则在限流时间内日志会堆积在本地,一旦API限流放开限制,则这些高频的定时上报日志请求会在短时间内把大量堆积在本地的日志发送到服务端,首先这些日志的上行流量比较大,其次这些日志几乎会在放开限制的同时向服务端发送请求,造成服务端高峰值。为了削峰,我们做了API的延迟打散,即把请求延迟到一个随机时间后再发送。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"api_delay_and_rand": [
{
"feature": [
"push"
],
"delay_time": 10000,
"rand_time": 10000
},
{
"feature": [
"client_log"
],
"delay_time": 10000,
"rand_time": 120000
}
]

3.1.4 API最小请求间隔

部分请求可能在切换前后台时发送或者有本地轮询,如果一旦频繁切换前后台或者轮询频率过高,则依然有可能把服务端打垮。因此我们做了一套兜底的策略,即限制每个请求的最小请求间隔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"api_min_request_interval": [
{
"paths": [
"/foreground/foo"
],
"time": 30000,
"ignore_time": [
"default"
]
},
{
"paths": [
"/roll/foo",
],
"time": 60000
}
]

3.1.5 API转CDN

针对一些核心的基础功能接口,比如发现,同城,热门或者是一些春节活动相关核心接口,需要强化保护策略。具体是采用CDN兜底,如果API请求失败,则拉取CDN数据,或者甚至可以不请求API直接请求CDN,当然这是针对API大规模不可用的一种预案。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"api_to_cdn": [
{
"path": "/infra/foo",
"cdns": [
"a1.static.com",
"a2.static.com"
],
"cdn_path": "/{cdn}/degradation/infra/foo.json",
"use_api_first": true
},
{
"path": "/spring_festival/foo",
"cdns": [
"a1.static.com",
"a2.static.com"
],
"cdn_path": "/{cdn}/degradation/spring_festival/foo.json",
"use_api_first": true
}
]

3.2 验证

做好策略后,我们需要验证策略的效果。要确保这些稳定性策略确实生效,并且对App核心数据没有明显负向。

3.2.1 本地测试

策略的本地测试主要通过网络代理来监听客户端的API请求情况,判断具体场景下的限流是否生效,或者API请求是否转换为CDN请求等。同时端上在一些代码核心节点,比如策略被更新、某个API命中了某个具体策略,等等打上端日志来辅助QA判断或者研发定位问题。

3.2.2 线上演练

降级演练也是必须操作,通过演练来验证降级效果发现降级中的问题。我们进行了2次大的演练,元旦,小年,还有一些常规的演练。大致发现了有些API降级不生效、降级恢复后峰值过高等问题。

3.3.2 AB实验

我们预先选择了一系列的运营类、性能优化类、以及部分功能类的API接口作为降级备选对象,按照预估的影响范围有梯度的做好三套配置。把这三套配置接入到实验平台,加上base组一共四组进行对照,大致判断降级后对App核心数据的影响,比如播放时长,新增次留等等。最终选出两组配置。

4 组织相关

不管是策略的实现还是验证以及最终上线都需要人来做。春晚API稳定性保障这件事显然不是一个人能搞定的,那是怎么划分任务,做到人事匹配,顺利落地呢?

4.1 职责划分

大略分为两类人,一类是owner团队,对整个事情的最终落地负责。主要包括服务端,客户端和QA,负责讨论策略,开发代码,组织各种策略验证以及上线策略等等。另外一类是配合团队,来自各业务线,负责配合把这个事情落地到各业务线,他们大多数是来自业务线团队的资深工程师或技术负责人,做的事情主要是向owner团队提供必要信息,比如梳理某个API的上下行流量,是否可以在启动时屏蔽某API等等。

4.2 流畅协作

跨团队参与的项目,必定存在很多信息不对称的问题,简单信息群聊或者线下单对,重要信息通过开会来同步。整个项目大致有如下几种会议。

  1. 日例会,与会人员多是owner团队内部成员,会议集中在策略设计、研发和本地测试期间,基本是在讨论设计缺陷、研发bug的解决情况等等。更多是一种决策性的会议,群策群力来解决策略落地的各种技术问题。比如,刚开始设计的协议可能比较冗余,会占用较多的下行带宽,在每日会议中提出了新的设计等等。
  2. 复盘会,每次演练都会发现问题,这种问题会梳理出来,然后分发给业务线,业务线派代表来参会,主要是定位问题产生的原因并提出解决方案。比如,某些业务侧API降级不生效,某些API在降级完成后,立刻出现尖峰等等。
  3. 布道会,布道会的主要目标是让相关人员学习特定的知识或技能,比如内部新开发的策略修改上线工具如何操作,这些需要业务线和owner团队的成员都要会用。
  4. 协调会,这种会议的主要作用是确立共同的目标和行动方针,达到认识和行动的协调一致,比如告知大家xx号会进行演练,演练的大致时间和流程安排,需要参加的人员等等。

5 工具链建设

除了任务本身很重要以外,还有一块是给任务提效的工具的建设,保障项目落地的质量与效率。

5.1 策略修改上线

策略修改上线有几个难点:

  1. 策略本身是一个很复杂的json,涉及的字段有几百个;
  2. 里面的有些字段是特定格式,比如跟时间挂钩,还有些字段是enum,只有特定的几个有意义的值;
  3. 策略上线之前还有diff配置,以及review的需求,不能简单直接上线。

为了解决以上痛点 ,我们做了一个系统工具来简化策略的配置与上线流程,目标是避免格式出错,且标准化上线流程,大致操作是界面化输入,不用直接操作json,自动diff配置发送给reviewer,reviewer确认才能上线。

5.2 代码同步优化

快手App和快手极速版App是两拨人在维护,参与到owner团队的也是两拨人。主要的代码实现由快手App的同学完成,极速版App的同学更多的时间在做代码同步以及功能可用性验证的工作。因此实时监听快手App的代码变更变的很重要。我们专门做了个gitlab hook,监听快手App的基础库升级情况,发现指定同学的升级会推送一条消息到快手极速版的群里,实时查看代码判断是否跟春节稳定性相关。

6 总结

图片

2020春节已过,快手极速版春节客户端稳定性保障项目也最终成功上线。很幸运能有机会参与到这样一个有纪念意义的项目,事后反思推一个技术项目的方法好像也就大概这三部曲,任务分拆,人事匹配,提质提效。

1 背景

在日常开发中,我们经常会碰到一类具有如下特征的需求。一,业务复杂,动辄开发70 ~ 80人日(客户端30 ~ 40,服务端30 ~ 40) 。二,周期短,PM要求下个版本或者下下个版本必须上线,通常一个迭代给客户端开发就12 ~ 13天左右。三,多人参与,基于前两个特征,客户端要在指定时间内完成指定需求,必须要多个人参与项目。如果客户端不具备复杂、短周期、多人项目工程化能力,让项目delay或者加班堆人日解决问题,就会成为业务发展的瓶颈,备受各方吐槽。

2 原因分析

这里我们仅讨论项目迭代中的开发阶段。先分析一下大需求delay或者加班堆人日的原因。我认为原因大致有两点。一、缺乏有效估时;二、缺乏有效监督与纠偏

2.1 缺乏有效估时

  1. 需求点把握不足,进行拆解估时时候,经验不足的同学偏乐观,凭借自己对相关业务需求的大致理解估时,很容易需求点把握不足,从而估时不足。
  2. 缺乏架构设计,在项目开始的时候,缺乏整体架构设计,整体认知不足,不能高屋建瓴。
  3. 缺乏核心流程分析,对需求的核心流程没有画出流程图,很容易出现后期编码流程跑不通。
  4. 估时没有考虑到人员交流成本,估计时间的时候要考虑到交流沟通的时间耗散。

2.2 缺乏有效监督与纠偏

  1. 没有里程碑或者里程碑过于简略,完全没有里程碑几乎必然导致项目的延期,一个合理的里程碑,简单来说就是什么时间做什么事达到什么效果。
  2. 进度滞后不能预警和纠偏,没有全方位跟踪项目进度,进行进度滞后预警,以及安排项目纠偏。

3 针对性措施

3.1 有效估时

3.1.1 需求点把握

  1. 逐行校对需求文档、交互稿、设计稿、API文档,确保不会遗漏需求点。
  2. 理解需求,对需求的理解跟其他业务合作方要一致,如跟PM和API一致等。
  3. 吃透需求,把需求点跟对应代码上下文进行映射。

3.1.2 架构设计

业务需求架构设计,大致分为两个部分,界面和业务。界面又包括偏交互和偏视觉。业务也能进行具体的模块拆分。架构的时候要进行合理的模块细分,充分暴露大方向上的问题。

3.1.3 核心流程图

核心流程至少要涉及API的调用时机和页面跳转以及页面刷新等。

3.1.4 合理人员划分减少沟通成本

偏界面和偏业务都要有一个整体负责人,尽量不要产生跨细分模块的交流。

3.2 监督与纠偏

3.2.1 合理的里程碑

合理的里程碑设置时间点考虑因素大致有两点。一、API可用性,即API提测时间,提测bug率等。二、QA用例可用性。里程碑不要缺乏必要信息,要可以度量,简单来说就是什么时间什么事怎么算做成了。一种有效的方式是基于Case来评判。

3.2.2 进度监控预警与纠偏

当发生不预期事件,导致估计时间与时间所用时间产生偏差时,要及时预警与纠偏。常见的不预期事件有两类,第一是编码过程中踩坑,第二是跟PM需求点理解不一致。出现这些事件首先要跟其他业务方沟通,看看能不能规避。如果不能规避,看看是否在对应里程碑能Cover住,不能Cover住要及时调整里程碑。

4 总结

本文从实际出发分析了复杂、短周期、多人项目在项目迭代中的开发阶段产生delay或者加班的原因,并总结了一些可行的针对性措施,希望能提高生产率,降低风险,尽量减少不必要的加班。

1 引言

据不完全统计,自12年参加实习到14年正式工作以及15年10月来到美团,我大小参加过约80场面试,其中约20场是以面试官的身份进行。下面分享一下我作为面试官的一些思考与总结。

2 基础知识

2.1 相关定义

一般来说,在接触到一个新事物的时候,我们总习惯先对相关理论知识进行学习。比如,最基本的,要了解面试、面试者和面试官的定义,以及三者的关系。相关细节我们不展开,可以直接Google。

2.2 面试流程

按照时间先后顺序,面试大约有如下环节。首先,流程发起,即上级或者HR发起流程告诉你于X时间,Y地点,面试Z人,跟记叙文三要素一样,通常此时你会看到该面试者的简历。其次,面试前准备,面试前准备主要是针对面试者的简历,找出一些你感兴趣的地方,根据这些兴趣点预先想好一些问题并记录下来,作为面试过程中的参考问题。再次,面试过程,即面试官跟面试者问答的过程,此时需要简要记录问答过程,并有理有据的做出合理判断。最后,结论输出,根据面试过程中面试者对相关问题的回答来确认该面试者是否通过面试并邮件周知相关人员(附上面试记录)。

3 一些考察点

3.1 流程熟悉度

流程熟悉度类问题适用于有一定工作年限或者是有项目管理经验的面试者。基础能力模型是熟悉项目迭代流程,如需求,排期,开发,测试,灰度,上线等。判断一个面试者是否真正参与过这些流程有一个比较简单的方式,就看他使用的工具。比如,在排期阶段我们使用的工具比较简单就是wiki文档+日历+jira。专业一点的团队可能会使用OmniPlan。再比如,开发中他们使用git吗?是gitflow来协作的吗?对CI是否了解,知道CI系统的组成元素吗?TestCase是通过专业系统给出的吗,还仅仅是word文档?对后台日志系统是否熟悉?鉴于这些东西我也只是粗浅了解,就不过多展开。

3.2 业务理解

业务理解度问题适用于每个业务开发者。他是否对自己所做的业务熟悉,能屡清楚业务流程吗?知道业务都有哪些坑吗?针对这种坑有采取措施吗?他的这些措施是否会产生副作用?他自己清楚副作用吗?几乎没有完美的方案,有的只是适合当前业务的方案。举个例子,某次一个做打车软件的面试者,说他针对订单状态机紊乱的问题做了状态机切换的客户端校验。这种手段当然能解决状态机紊乱,但是可能带来的副作用就是客户端强感知业务状态变化固化业务逻辑。面试者对具体方案的细节点处理是否了解。再举一个例子,有一次一个面试者说他参与设计了一套换肤系统。恰好我也做过类似模块。那么他对资源包格式,断点下载,并发下载,下载进度交互,下载解压失败处理,这些东西是否真正了解。询问对方领域技术模型,比如打车电商这些APP,一般都会涉及状态机模型。工具类App,可能会涉及网络资源配置模型。资讯类App,一般会涉及缓存模型。他在模型描述过程中的理由是否充分成为业务理解度的重要指标。

3.3 知识储备

基础知识这个一般问的也比较多,基本就是分两大块,iOS相关和CS基础,这种问题基本网上都有相关资料,这里也不过多展开。一般就是看谁的储备多。

3.4 通用技术

经常有面试者在简历上写他参与团队的Code Review,那么他经常Review出哪些问题呢?针对这些问题,他是否有总结,形成规范,达成一致,方便后来人。还有一些同学在简历上写他进行崩溃的处理,那么他是否有对crash类型,crash日志格式,常见的crash有总结,是否规约化了。还有就是性能优化,基本的套路是问题发现,原理调研,业界方案,方案选择,效果验证, 规约化,工具化。然后就是二次封装,经常会有开发者在简历上写熟悉XX开源库的原理,那么他是否参与过开源库的二次封装呢?比如网络库,通参添加,GET网络请求去重,服务器宕机处理,这些点是怎么搞的。SDWebImage,图片尺寸参数什么,失败retry什么的怎么做的?这些点都能体现水平。

4 后记

这篇博文是17年5月为团队培养新面试官所作,距今半年有余。这半年随着个人眼界和水平的提升,对面试这件事又有些新的认识。比如文中尚未提及问题解决能力,这个点这里也不详谈,之后的面试中会重点关注。

1 前言

WebViewJavascriptBridge是iOS/OSX平台上支撑Obj-C和UIWebViews/WebViews JavaScript互发消息的库。目前主流App几乎都是某种程度的Hybrid App,该库因而得到广泛应用。

2 基础知识

在学习该库之前我们必须了解一些基础知识。主要包含前端和Native两大部分。

2.1 前端部分——HTML

Keypoint:

  • <script> 标签包裹的是JavaScript代码
  • window、iframe
  • setTimeout(0)

2.2 前端部分——JavaScript

Keypoint:

  • JavaScript函数、对象

资料:

2.3 Native部分-关于UIWebView

1
2
3
4
5
6
7
8
9
__TVOS_PROHIBITED @protocol UIWebViewDelegate <NSObject>

@optional
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;
- (void)webViewDidStartLoad:(UIWebView *)webView;
- (void)webViewDidFinishLoad:(UIWebView *)webView;
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error;

@end

UIWebView的代理UIWebViewDelegate,会在UIWebView各个事件节点收到回调消息。其中最重要的是- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType

当UIWebView加载URL page或者iframe设置src的时候,UIWebViewDelegate都会执行该回调。

3 WebViewJavascriptBridge设计

在分析WebViewJavascriptBridge源码之前,我们先聊一下WebViewJavascriptBridge设计。

3.1 整体框架

图片

WebViewJavascriptBridge整体框架如上图所示。

包含4部分:

  • 前端业务逻辑
  • 前端js bridge基础设施
  • Native js bridge基础设施
  • Native业务逻辑

3.2 js bridge基础设施

总的来说js bridge基础设施主要由3部分组成:

  1. 消息流: FE和Native之间的消息传递过程;
  2. 消息体(message):message即Native和前端消息流中的消息体,主要有4个部分:函数名、参数、回调ID、响应ID;
  3. 消息队列(FE message queue):前端消息队列用来暂存前端到Native的消息体。

3.2.1 消息流

消息流如下图所示。

图片

从图中我们可以看出消息流有两个参与者,即调用方被调方调用方发起请求,收到对方的回调消息。被调方收到请求,执行请求,发送回调消息。Native和FE都可能是调用方和被调用方,所以Native和FE都至少包含两部分功能:

  • send(发送自己的调用请求到对端)
  • receive(收到了来自对端的调用请求)

3.2.2 消息体

消息体有四个成员:

  1. 函数名
  2. 参数
  3. callbackID
  4. responseID

其中函数名和参数都很好理解。这里我们主要说一下callbackID和responseID。

调用方在发起调用的同时设置回调块,该回调块在被调方执行完任务后再执行。具体的实现手段是,调用方在拼接消息体的时候,把回调块管理起来,并设置一个唯一的ID, 放到消息体的callbackID上面。 此时被调方收到的消息包含callbackID,在执行完成对应函数后,会生成一个应答消息,告知对方自己已经执行完成,这个应答消息也是一个消息体,该消息体的responseID设置为其所应答消息的callbackID,表示对该消息的应答。这时,调用方收到应答消息,检查responseID,匹配后找到之前对应的回调块并执行。

样例如图所示:
图片

4 WebViewJavascriptBridge实现

4.1 消息流和消息队列实现

消息流(前端到Native)

前端到Native的消息流由隐藏的iframe发起。每次调用js bridge函数时设置iframe的src,然后,Native的UIWebViewDelegate收到- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType回调,在回调上加一些额外的逻辑区分,Native就知道前端发起了js bridge函数调用。

消息流(Native到前端)

Native到前端的消息流比较简单。它是由UIWebView本身完成。UIWebView的- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script可以直接执行js命令。Native要调用前端的方法时,可以把方法转化为js命令直接调用。

消息队列(FE message queue)

前端消息队列用来暂存前端到Native的消息体。
相关的点如下:

  • 前端设置iframe的src之前会先把消息存到消息队列;
  • Native收到回调后,调用相关js命令从前端获取消息队列,得到消息队列后,按照消息队列的每条消息执行相应操作——函数调用。

4.2 前端js bridge源码

4.2.1 send

参考前端到Native消息流。send通过iframe设置src和messageQueue缓存消息体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 前端调用Native
function callHandler(handlerName, data, responseCallback) {
if (arguments.length == 2 && typeof data == 'function') {
responseCallback = data;
data = null;
}

var message = { handlerName:handlerName, data:data };
if (responseCallback) { // 回调管理
var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
responseCallbacks[callbackId] = responseCallback;
message['callbackId'] = callbackId;
}
sendMessageQueue.push(message);
messagingIframe.src = 'wvjbscheme://__WVJB_QUEUE_MESSAGE__';
}

// 获取并清空message queue,暴露给OC
function _fetchQueue() {
var messageQueueString = JSON.stringify(sendMessageQueue);
sendMessageQueue = [];
return messageQueueString;
}

4.2.2 receive

参考Native到前端的消息流。receive通过registerHandler注册js bridge函数,通过_handleMessageFromObjC方法执行messageHandlers里面的函数体。

1
2
3
4
5
// 前端注册js bridge方法供OC调用,比OC直接调用js普通方法好在对回调的支持上面。
var messageHandlers = {};
function registerHandler(handlerName, handler) {
messageHandlers[handlerName] = handler;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// OC调用处理,暴露给OC
function _handleMessageFromObjC(messageJSON) {
var message = JSON.parse(messageJSON);
var messageHandler;
var responseCallback;

if (message.responseId) { // 回调管理(responseId匹配查找)=> message有responseId表示是一个回调调用
responseCallback = responseCallbacks[message.responseId]; // 这个responseId必须要与当时消息寄送时所填写的responseId一致
if (!responseCallback) {
return;
}
responseCallback(message.responseData);
delete responseCallbacks[message.responseId];
} else {
if (message.callbackId) {
var callbackResponseId = message.callbackId;
responseCallback = function(responseData) {
var message = { handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData };
sendMessageQueue.push(message);
messagingIframe.src = 'wvjbscheme://__WVJB_QUEUE_MESSAGE__';
};
}

// messageHandlers在这里
var handler = messageHandlers[message.handlerName];
if (!handler) {
console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
} else {
handler(message.data, responseCallback);
}
}
}

4.3 Native js bridge源码

4.3.1 send

参考Native到前端的消息流。Native的send是先拼接出js命令,再直接执行stringByEvaluatingJavaScriptFromString

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (void)sendData:(id)data responseCallback:(WVJBResponseCallback)responseCallback handlerName:(NSString*)handlerName {
NSMutableDictionary* message = [NSMutableDictionary dictionary];

if (data) {
message[@"data"] = data;
}

if (responseCallback) {
NSString* callbackId = [NSString stringWithFormat:@"objc_cb_%ld", ++_uniqueId];
self.responseCallbacks[callbackId] = [responseCallback copy];
message[@"callbackId"] = callbackId;
}

if (handlerName) {
message[@"handlerName"] = handlerName;
}
[self _queueMessage:message];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//命令拼接
- (void)_dispatchMessage:(WVJBMessage*)message {
NSString *messageJSON = [self _serializeMessage:message pretty:NO];
[self _log:@"SEND" json:messageJSON];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];

NSString* javascriptCommand = [NSString stringWithFormat:@"WebViewJavascriptBridge._handleMessageFromObjC('%@');", messageJSON];
if ([[NSThread currentThread] isMainThread]) {
[self _evaluateJavascript:javascriptCommand];

} else {
dispatch_sync(dispatch_get_main_queue(), ^{
[self _evaluateJavascript:javascriptCommand];
});
}
}
1
2
3
4
5
//命令执行
- (NSString*) _evaluateJavascript:(NSString*)javascriptCommand
{
return [_webView stringByEvaluatingJavaScriptFromString:javascriptCommand];
}

4.3.2 receive

参考FE到Native消息流。

1
2
3
4
//Native js bridge方法管理(给js用的)
- (void)registerHandler:(NSString *)handlerName handler:(WVJBHandler)handler {
_base.messageHandlers[handlerName] = [handler copy];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {

// core code {
NSURL *url = [request URL];
__strong WVJB_WEBVIEW_DELEGATE_TYPE* strongDelegate = _webViewDelegate;
if ([_base isCorrectProcotocolScheme:url]) {
if ([_base isQueueMessageURL:url]) {
// 获取JS的messageQueue
NSString *messageQueueString = [self _evaluateJavascript:[_base webViewJavascriptFetchQueyCommand]];
[_base flushMessageQueue:messageQueueString];
}
return NO;
}
// core code }

}
1
2
3
-(NSString *)webViewJavascriptFetchQueyCommand {
return @"WebViewJavascriptBridge._fetchQueue();";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
- (void)flushMessageQueue:(NSString *)messageQueueString{
if (messageQueueString == nil || messageQueueString.length == 0) {
NSLog(@"WebViewJavascriptBridge: WARNING: ObjC got nil while fetching the message queue JSON from webview. This can happen if the WebViewJavascriptBridge JS is not currently present in the webview, e.g if the webview just loaded a new page.");
return;
}

// 拿到消息,按照消息handler
id messages = [self _deserializeMessageJSON:messageQueueString];
for (WVJBMessage* message in messages) {
if (![message isKindOfClass:[WVJBMessage class]]) {
NSLog(@"WebViewJavascriptBridge: WARNING: Invalid %@ received: %@", [message class], message);
continue;
}
[self _log:@"RCVD" json:message];

NSString* responseId = message[@"responseId"];
if (responseId) { // 如果是应答消息则执行并结束
WVJBResponseCallback responseCallback = _responseCallbacks[responseId];
responseCallback(message[@"responseData"]);
[self.responseCallbacks removeObjectForKey:responseId];
} else { // 如果是普通调用消息,则根据是否需要对其应答做相应处理
WVJBResponseCallback responseCallback = NULL;
NSString* callbackId = message[@"callbackId"];
if (callbackId) {
responseCallback = ^(id responseData) {
if (responseData == nil) {
responseData = [NSNull null];
}

WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
[self _queueMessage:msg];
};
} else {
responseCallback = ^(id ignoreResponseData) {
// Do nothing
};
}

// 在messageHandlers里面查找handler
WVJBHandler handler = self.messageHandlers[message[@"handlerName"]];

if (!handler) {
NSLog(@"WVJBNoHandlerException, No handler for message from JS: %@", message);
continue;
}
// 执行handler
handler(message[@"data"], responseCallback);
}
}
}

5总结

本文从JS bridge的基础知识讲到WebViewJavascriptBridge的源码实现。涉及的点有消息流,消息体,消息队列等。其中比较有意思的是回调实现原理。算是对自己阅读代码的一个记录。

1 理解自身内容尺寸约束与抗压抗拉

自身内容尺寸约束:一般来说,要确定一个视图的精确位置,至少需要4个布局约束(以确定水平位置x、垂直位置y、宽度w和高度h)。但是,某些用来展现内容的用户控件,例如文本控件UILabel、按钮UIButton、图片视图UIImageView等,它们具有自身内容尺寸(Intrinsic Content Size),此类用户控件会根据自身内容尺寸添加布局约束。也就是说,如果开发者没有显式给出其宽度或者高度约束,则其自动添加的自身内容约束将会起作用。因此看似“缺失”约束,实际上并非如此。

关于自身内容尺寸约束,简单来说就是某些用来展现内容的用户控件,它们会根据自身内容尺寸添加布局约束。

自身内容尺寸约束的抗挤压与抗拉抻效果。弹簧会有自身固有长度,当有外力作用时,弹簧会抵抗外力作用,尽量接近固有长度。
抗拉抻:当外力拉长弹簧时,弹簧长度大于固有长度,且产生向内收的力阻止外力拉抻,且尽量维持长度接近自身固有长度。 
抗挤压:当外力挤压弹簧时,弹簧长度小于固有长度,且产生向外顶的力阻止外力挤压,且尽量维持长度接近自身固有长度。

关于抗压抗拉,就是布局冲突需要牺牲某些控件的某些宽度或者高度约束时,抗压高的控件越不容易被压缩,抗拉高的控件越不容易被拉升。即自身布局对抗外界布局的能力。

样例:

一种常见的业务场景是用户修改地址,在输入新地址之前先读取用户之前的地址作为填充。UI实现是水平平行的UILabel和UITextField。
代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
- (NSString *)aLongAddress
{
return @"A long long long long long long long long long address";
}

- (NSString *)aShortAddress
{
return @"A short address";
}

- (void)sampleCode
{
UIView *layoutView = [UIView new];
layoutView.frame = CGRectMake(0, 200, [UIScreen mainScreen].bounds.size.width, 100);
layoutView.backgroundColor = [[UIColor alloc] initWithRed:0.5 green:0.5 blue:0.5 alpha:0.5];
[self.view addSubview:layoutView];

UILabel *address = [[UILabel alloc] init];
[layoutView addSubview:address];
address.text = @"地址:";
address.backgroundColor = [UIColor blueColor];
[address mas_makeConstraints:^(MASConstraintMaker *make) {
make.centerY.equalTo(layoutView);
make.left.equalTo(layoutView).offset(10);
}];

UITextField *addressTextField = [[UITextField alloc] init];
[layoutView addSubview:addressTextField];
addressTextField.returnKeyType = UIReturnKeyDone;
addressTextField.font = [UIFont systemFontOfSize:15];
addressTextField.clearButtonMode = UITextFieldViewModeWhileEditing;
addressTextField.layer.borderWidth = 1 / [UIScreen mainScreen].scale;
addressTextField.layer.borderColor = [[[UIColor alloc] initWithRed:1 green:1 blue:0 alpha:1] CGColor];
addressTextField.layer.cornerRadius = 3;
addressTextField.text = [self aLongAddress];
[addressTextField mas_makeConstraints:^(MASConstraintMaker *make) {
make.height.equalTo(address);
make.centerY.equalTo(address);
make.right.equalTo(layoutView.mas_right).offset(-10);
make.left.equalTo(address.mas_right).offset(10);
}];
}

此处使用了UILabel的自身内容尺寸约束,当houseNumberTextField.text = [self aShortAddress]UI表现正常。

但,当houseNumberTextField.text = [self aLongAddress]时会出现address UILabel被挤压掉的情况,如下图所示:

1

原因是address Label的水平抗压缩没有设置。

在address Label创建的时候添加如下代码[address setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]则显示正常。

另,在某些情况下存在view被拉升,极有可能是没有设置抗拉升,此处不一一列举。

附,抗压抗拉相关API如下:

1
2
3
4
5
6
7

- (UILayoutPriority)contentHuggingPriorityForAxis:(UILayoutConstraintAxis)axis NS_AVAILABLE_IOS(6_0);
- (void)setContentHuggingPriority:(UILayoutPriority)priority forAxis:(UILayoutConstraintAxis)axis NS_AVAILABLE_IOS(6_0);

- (UILayoutPriority)contentCompressionResistancePriorityForAxis:(UILayoutConstraintAxis)axis NS_AVAILABLE_IOS(6_0);
- (void)setContentCompressionResistancePriority:(UILayoutPriority)priority forAxis:(UILayoutConstraintAxis)axis NS_AVAILABLE_IOS(6_0);

2 NSLayoutConstraint只能修改constant

NSLayoutConstraint即自动布局的约束类,它是自动布局的关键之一。该类有如下属性我们需要重点关注。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
NS_CLASS_AVAILABLE_IOS(6_0)
@interface NSLayoutConstraint : NSObject

// other code

@property UILayoutPriority priority;
@property BOOL shouldBeArchived;

/* accessors
firstItem.firstAttribute {==,<=,>=} secondItem.secondAttribute * multiplier + constant
*/
@property (readonly, assign) id firstItem;
@property (readonly) NSLayoutAttribute firstAttribute;
@property (readonly) NSLayoutRelation relation;
@property (nullable, readonly, assign) id secondItem;
@property (readonly) NSLayoutAttribute secondAttribute;
@property (readonly) CGFloat multiplier;

/* Unlike the other properties, the constant may be modified after constraint creation. Setting the constant on an existing constraint performs much better than removing the constraint and adding a new one that's just like the old but for having a new constant.
*/
@property CGFloat constant;

/* The receiver may be activated or deactivated by manipulating this property.  Only active constraints affect the calculated layout.  Attempting to activate a constraint whose items have no common ancestor will cause an exception to be thrown. Defaults to NO for newly created constraints. */
@property (getter=isActive) BOOL active NS_AVAILABLE(10_10, 8_0);

// other code

@end

布局公式:firstItem.firstAttribute {==,<=,>=} secondItem.secondAttribute * multiplier + constant

解释:firstItem与secondItem分别是界面中受约束的视图与被参照的视图。

注意:当使用代码来修改约束时,只能修改约束的常量值constant。一旦创建了约束,其他只读属性都是无法修改的,特别要注意的是比例系数multiplier也是只读的。

Masonry是基于NSLayoutConstraint等类的封装,也正是如此,我们在调用- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *))block的时候也只能更新NSLayoutConstraint中的@property CGFloat constant

MASViewConstraint找到如下代码可以佐证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
- (void)install {

// other code

MASLayoutConstraint *existingConstraint = nil;
if (self.updateExisting) { //如果是update,则去匹配对应的existingConstraint
existingConstraint = [self layoutConstraintSimilarTo:layoutConstraint];
}
if (existingConstraint) { //找到了existingConstraint,最终也只更新了existingConstraint.constant
// just update the constant
existingConstraint.constant = layoutConstraint.constant;
self.layoutConstraint = existingConstraint;
} else { //没有找到existingConstraint,添加一个新的约束
[self.installedView addConstraint:layoutConstraint];
self.layoutConstraint = layoutConstraint;
[firstLayoutItem.mas_installedConstraints addObject:self];
}
}

// 除了constant,其它都一样的约束是Similar约束
- (MASLayoutConstraint *)layoutConstraintSimilarTo:(MASLayoutConstraint *)layoutConstraint {
// check if any constraints are the same apart from the only mutable property constant

// go through constraints in reverse as we do not want to match auto-resizing or interface builder constraints
// and they are likely to be added first.
for (NSLayoutConstraint *existingConstraint in self.installedView.constraints.reverseObjectEnumerator) {
if (![existingConstraint isKindOfClass:MASLayoutConstraint.class]) continue;
if (existingConstraint.firstItem != layoutConstraint.firstItem) continue;
if (existingConstraint.secondItem != layoutConstraint.secondItem) continue;
if (existingConstraint.firstAttribute != layoutConstraint.firstAttribute) continue;
if (existingConstraint.secondAttribute != layoutConstraint.secondAttribute) continue;
if (existingConstraint.relation != layoutConstraint.relation) continue;
if (existingConstraint.multiplier != layoutConstraint.multiplier) continue;
if (existingConstraint.priority != layoutConstraint.priority) continue;

return (id)existingConstraint;
}
return nil;
}

样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@interface ViewController ()

@property (nonatomic, strong) UILabel *lbl;
@property (nonatomic, strong) UIButton *btn;

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.

self.btn = [UIButton buttonWithType:UIButtonTypeCustom];
self.btn.backgroundColor = [UIColor blueColor];
[self.btn setTitle:@"按钮" forState:UIControlStateNormal];
[self.btn addTarget:self action:@selector(onTest:) forControlEvents:UIControlEventTouchDown];
[self.view addSubview:self.btn];
[self.btn mas_updateConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.view).offset(200);
make.centerX.equalTo(self.view);
make.size.mas_equalTo(CGSizeMake(100, 33));
}];

self.lbl = [[UILabel alloc] init];
self.lbl.text = @"一个label";
self.lbl.backgroundColor = [UIColor redColor];
self.lbl.textAlignment = NSTextAlignmentCenter;
[self.view addSubview:self.lbl];
[self.lbl mas_updateConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.view).offset(300);
make.centerX.equalTo(self.view);
make.size.equalTo(self.btn);
}];
}

- (void)onTest:(id)sender
{
[self.lbl mas_updateConstraints:^(MASConstraintMaker *make) {
make.size.mas_equalTo(CGSizeMake(200, 100));
}];
}

@end

当按钮被按下时,控制台出现如下警告

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2016-08-03 18:49:13.110 layout[47924:2886276] Unable to simultaneously satisfy constraints.
Probably at least one of the constraints in the following list is one you don't want.
Try this:
(1) look at each constraint and try to figure out which you don't expect;
(2) find the code that added the unwanted constraint or constraints and fix it.
(
"<MASLayoutConstraint:0x7ffecb632470 UIButton:0x7ffecb4f28e0.width == 100>",
"<MASLayoutConstraint:0x7ffecb637550 UILabel:0x7ffecb637030.width == UIButton:0x7ffecb4f28e0.width>",
"<MASLayoutConstraint:0x7ffecb71fc10 UILabel:0x7ffecb637030.width == 200>"
)

Will attempt to recover by breaking constraint
<MASLayoutConstraint:0x7ffecb71fc10 UILabel:0x7ffecb637030.width == 200>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKit/UIView.h> may also be helpful.
2016-08-03 18:49:13.111 layout[47924:2886276] Unable to simultaneously satisfy constraints.
Probably at least one of the constraints in the following list is one you don't want.
Try this:
(1) look at each constraint and try to figure out which you don't expect;
(2) find the code that added the unwanted constraint or constraints and fix it.
(
"<MASLayoutConstraint:0x7ffecb612bc0 UIButton:0x7ffecb4f28e0.height == 33>",
"<MASLayoutConstraint:0x7ffecb625300 UILabel:0x7ffecb637030.height == UIButton:0x7ffecb4f28e0.height>",
"<MASLayoutConstraint:0x7ffecb486f10 UILabel:0x7ffecb637030.height == 100>"
)

Will attempt to recover by breaking constraint
<MASLayoutConstraint:0x7ffecb486f10 UILabel:0x7ffecb637030.height == 100>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKit/UIView.h> may also be helpful.

原因是,lbl创建时其size约束是make.size.equalTo(self.btn),但btn被点击时,企图去update size约束为make.size.mas_equalTo(CGSizeMake(200, 100)),然而无法找到existingConstraint,因此实际上是额外添加了一个约束make.size.mas_equalTo(CGSizeMake(200, 100))出现了布局冲突。

这件事可以这么看,NSLayoutConstraint只能修改constant决定了mas_updateConstraints的实现方式为:找到既有约束就去改变constant找不到既有约束就添加新约束。

3 被Masonry布局的view一定要与比较view有共同的祖先view

这句话比较拗口,其中涉及三类view,解释如下。

  1. 被Masonry布局的view:执行了- (NSArray *)mas_makeConstraints:(void(^)(MASConstraintMaker *make))block- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *make))block - (NSArray *)mas_remakeConstraints:(void(^)(MASConstraintMaker *make))block等函数的view。
  2. 比较view:以上3函数block块里面出现的view。
  3. 共同的祖先view:【1】和【2】的共同祖先view。

样例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)sampleCode
{
UIView *v0 = [UIView new];
[self.view addSubview:v0];

UIView *v1 = [UIView new];
[v0 addSubview:v1];
[v1 mas_makeConstraints:^(MASConstraintMaker *make) {
make.size.mas_equalTo(CGSizeMake(10, 10));
}];

UIView *v2 = [UIView new];
[v0 addSubview:v2];
[v2 mas_makeConstraints:^(MASConstraintMaker *make) {
make.size.equalTo(v1);
}];
}

针对如下代码块来说

1
2
3
4
5
UIView *v2 = [UIView new];
[v0 addSubview:v2];
[v2 mas_makeConstraints:^(MASConstraintMaker *make) {
make.size.equalTo(v1);
}];

v2是被Masonry布局的view,v1是比较view,v0是共同的祖先view。

样例2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@implementation AutoLayoutViewController

- (void)viewDidLoad
{
[super viewDidLoad];

[self useMasonryWithoutSuperView];
}

- (void)useMasonryWithoutSuperView
{
UIView *masView = [UIView new];
[masView mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.view);
}];
}

@end

以上代码执行时会crash,crash log如下:

1
2
2016-08-04 00:52:47.542 CommonTest[1731:22953] *** Assertion failure in -[MASViewConstraint install], /Users/shuncheng/SourceCode/SampleCode/AutoLayout/Pods/Masonry/Masonry/MASViewConstraint.m:338
2016-08-04 00:52:47.548 CommonTest[1731:22953] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'couldn't find a common superview for <UIView: 0x7fa59bd30dd0; frame = (0 0; 0 0); layer = <CALayer: 0x7fa59bd2f3c0>> and <UIView: 0x7fa59bd30c60; frame = (0 0; 414 736); autoresize = W+H; layer = <CALayer: 0x7fa59bd24780>>'

crash的原因显而易见,即,masView(被Masonry布局的view)与self.view(比较view)没有共同祖先view,因为masView没有父view,所以它和self.view必然没有共同祖先view。

被Masonry布局的view没有添加到superview上其实比较容易被发现,最怕的是出现如样例3一样的鬼畜情况。

样例3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@implementation AutoLayoutViewController

- (void)viewDidLoad
{
[super viewDidLoad];

[self sampleCode];
}

- (void)sampleCode
{
AutoLayoutViewController * __weak weakSelf = self;
[fooNetworkModel fetchData:^{
AutoLayoutViewController * self = weakSelf;
[AutoLayoutViewController showSampleViewAtView:self.view];
}];
}

+ (void)showSampleViewAtView:(UIView *)view
{
UIView *v1 = [UIView new];
[view addSubview:v1];
[v1 mas_makeConstraints:^(MASConstraintMaker *make) {
make.size.mas_equalTo(CGSizeMake(10, 10));
}];

UIView *v2 = [UIView new];
[view addSubview:v2];
[v2 mas_makeConstraints:^(MASConstraintMaker *make) {
make.size.equalTo(v1);
}];
}

@end

以上代码通常不会出错,但是一种异常情况是:在AutoLayoutViewController析构后,网络数据返回,此时AutoLayoutViewController * self = weakSelfself == nil。执行[AutoLayoutViewController showSampleViewAtView:nil],则会出现【样例2】一样的crash。

原因是:v1和v2都没有添加到view上去(因为view为空)所以make.size.equalTo(v1)会出错(v1和v2没有共同的父view)。由此也引申到weakSelf的副作用,即必须要确保weakSelf是nil时,执行逻辑完全没有问题(目前已经两次被坑)。

4 不要被update迷惑

这里说的update有两层含义:

  1. UIView的方法- (void)updateConstraints NS_AVAILABLE_IOS(6_0)
  2. Masonry的方法- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *make))block

这里先来讨论一下UIView的- (void)updateConstraints方法。

- (void)updateConstraints方法是用来更新view约束的,它有一个常见的使用场景——批量更新约束。比如你的多个约束是由多个不同的property决定,每次设置property都会直接更新局部约束。这样效率不高。不如直接override- (void)updateConstraints方法,在方面里面对property进行判断,每次设置property的时候调用一下- (void)setNeedsUpdateConstraints。伪代码如下:

优化前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@implementation AutoLayoutView

- (void)setFactor1:(NSInteger)factor1
{
_factor1 = factor1;

if (_factor1满足条件) {
更新约束1
}
}

- (void)setFactor2:(NSInteger)factor2
{
_factor2 = factor2;

if (_factor2满足条件) {
更新约束2
}
}

- (void)setFactor3:(NSInteger)factor3
{
_factor3 = factor3;

if (_factor3满足条件) {
更新约束3
}
}

@end

优化后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@implementation AutoLayoutView

- (void)setFactor1:(NSInteger)factor1
{
_factor1 = factor1;

[self setNeedsUpdateConstraints];
}

- (void)setFactor2:(NSInteger)factor2
{
_factor2 = factor2;

[self setNeedsUpdateConstraints];
}

- (void)setFactor3:(NSInteger)factor3
{
_factor3 = factor3;

[self setNeedsUpdateConstraints];
}

- (void)updateConstraints
{
if (self.factor1满足) {
更新约束1
}

if (self.factor2满足) {
更新约束2
}

if (self.factor3满足) {
更新约束3
}

[super updateConstraints];
}

@end

注意:一种有误区的写法是在- (void)updateConstraints方法中进行初次constraint设置,这是不被推荐的。推荐的写法是在init或者viewDidLoad中进行view的初次constraint设置。

Masonry的方法- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *make))block我们在第二节已经讨论过了。刚接触自动布局和Masonry的同学很容易跟着感觉在- (void)updateConstraints函数里面调用Masonry的- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *make))block。实际上两者并没有必然联系。大多数情况在- (void)updateConstraints里面调用- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *make))block很有可能产生布局冲突。

样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 头文件
typedef NS_ENUM(NSUInteger, AutoLayoutType) {
HorizontalLayout,
VerticalLayout,
};

@interface AutoLayoutView : UIView

@property (nonatomic, strong) UILabel *name;
@property (nonatomic, strong) UILabel *address;

@property (nonatomic, assign) AutoLayoutType layoutType;

@end

// 实现文件
@implementation AutoLayoutView

- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
_name = [[UILabel alloc] init];
[self addSubview:_name];

_address = [[UILabel alloc] init];
[self addSubview:_address];

[_name mas_updateConstraints:^(MASConstraintMaker *make) {
make.left.top.equalTo(self);
}];
}
return self;
}

- (void)updateConstraints
{
if (self.layoutType == HorizontalLayout) {
// // 此处误用mas_updateConstraints
[self.address mas_updateConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.name);
make.left.equalTo(self.name.mas_right).offset(10);
}];
} else {
// 此处误用mas_updateConstraints
[self.address mas_updateConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.name);
make.top.equalTo(self.name.mas_bottom).offset(10);
}];
}

[super updateConstraints];
}

- (void)setLayoutType:(AutoLayoutType)layoutType
{
_layoutType = layoutType;

[self setNeedsUpdateConstraints];
}

@end

// 外部调用代码
- (void)sampleCode
{
AutoLayoutView *view = [[AutoLayoutView alloc] init];
view.name.text = @"name";
view.address.text = @"address";
[self.view addSubview:view];
[view mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.view);
make.size.mas_equalTo(CGSizeMake(200, 300));
}];

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
view.layoutType = VerticalLayout; //修改布局方式后,出现布局冲突
});
}

5 总结

本文梳理了一下自动布局和Masonry使用的误区。在基本概念没搞清的情况下,很容易犯错。总结起来就如下4点:

  1. 理解自身内容尺寸约束与抗压抗拉
  2. NSLayoutConstraint只能修改constant和- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *make))block实现细节之间的关系
  3. 被Masonry布局的view一定要与比较view有共同的祖先view
  4. 区分UIView的- (void)updateConstraints方法和- (NSArray *)mas_updateConstraints:(void(^)(MASConstraintMaker *make))block

6 参考资料

WWDC-Mysteries of Auto Layout, Part 2

1 removeObjectAtIndex和removeObject的不同之处

removeObjectAtIndex:

Removes the object at index .
To fill the gap, all elements beyond index are moved by subtracting 1 from their index.

Important:Important
Raises an exception NSRangeException if index is beyond the end of the array.

删除指定NSMutableArray中指定index的对象,注意index不能越界。

removeObject:

Removes all occurrences in the array of a given object.
This method uses indexOfObject: to locate matches and then removes them by using removeObjectAtIndex:. Thus, matches are determined on the basis of an object’s response to the isEqual: message. If the array does not contain anObject, the method has no effect (although it does incur the overhead of searching the contents).

删除NSMutableArray中所有isEqual:待删对象的对象

从API文档可以看出,两者之间的主要区别是removeObjectAtIndex:最多只能删除一个对象,而removeObject:可以删除多个对象(只要符合isEqual:的都删除掉)。

2 在NSMutableArray循环中删除对象

2.1 可能多删的做法

删除数组中的第一个@”remove”

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)removeObjectsUseFor
{
NSMutableArray *contents = [@[@"how", @"to", @"remove", @"remove", @"object"] mutableCopy];
for (NSInteger i = 0; i != contents.count; ++i) {
NSString *var = contents[i];
if ([var isEqualToString:@"remove"]) {
[contents removeObject:var];
break;
}
}

NSLog(@"%@", contents);
}

结果如下:

1
2
3
4
5
2016-07-31 21:14:13.541 RemoveObject[5862:310398] (
how,
to,
object
)

removeObject:的说明中可以看出,removeObject:不仅删除该对象本身,而且删除NSMutableArray中所有isEqual:待删对象的对象

2.2 可能漏删的做法

删除数组中所有的@”remove”

1
2
3
4
5
6
7
8
9
10
11
12
- (void)removeObjectsUseFor
{
NSMutableArray *contents = [@[@"how", @"to", @"remove", @"remove", @"object"] mutableCopy];
for (NSInteger i = 0; i != contents.count; ++i) {
NSString *var = contents[i];
if ([var isEqualToString:@"remove"]) {
[contents removeObjectAtIndex:i];
}
}

NSLog(@"%@", contents);
}

输出:

1
2
3
4
5
6
2016-07-31 21:19:59.615 RemoveObject[5886:315162] (
how,
to,
remove,
object
)

2.3 引发崩溃的做法

删除数组中所有的@”remove”

1
2
3
4
5
6
7
8
9
10
11
- (void)removeObjectsUseForIn
{
NSMutableArray *contents = [@[@"how", @"to", @"remove", @"object"] mutableCopy];
for (NSString *var in contents) {
if ([var isEqualToString:@"remove"]) {
[contents removeObject:var];
}
}

NSLog(@"%@", contents);
}

输出:崩溃

1
2016-07-31 21:27:40.337 RemoveObject[5915:321407] *** Terminating app due to uncaught exception 'NSGenericException', reason: '*** Collection <__NSArrayM: 0x7f9388c95580> was mutated while being enumerated.'

不要在for in 循环中删除数组内部对象。

2.4 正确但别扭的做法

删除数组中所有的@”remove”

1
2
3
4
5
6
7
8
9
10
11
12
- (void)removeObjectsReversed
{
NSMutableArray *contents = [@[@"how", @"to", @"remove", @"remove", @"object"] mutableCopy];
for (NSInteger i = contents.count - 1; i >= 0; --i) {
NSString *var = contents[i];
if ([var isEqualToString:@"remove"]) {
[contents removeObjectAtIndex:i];
}
}

NSLog(@"%@", contents);
}

输出:

1
2
3
4
5
2016-07-31 21:31:37.655 RemoveObject[5934:325316] (
how,
to,
object
)

倒序删除,正确但有点别扭!

2.5 优雅的做法

1
2
3
4
5
6
7
8
9
10
11
12
- (void)removeObjectsUseEnumration
{
NSMutableArray *contents = [@[@"how", @"remove", @"to", @"remove", @"object"] mutableCopy];
NSIndexSet *indexSet =
[contents indexesOfObjectsPassingTest:^BOOL(NSString * _Nonnull var, NSUInteger idx, BOOL * _Nonnull stop) {
return [var isEqualToString:@"remove"];
}];
[contents removeObjectsAtIndexes:indexSet];

NSLog(@"%@", indexSet);
NSLog(@"%@", contents);
}

输出:

1
2
3
4
5
6
2016-07-31 22:10:42.404 RemoveObject[6014:338210] <NSIndexSet: 0x7fb73a516040>[number of indexes: 2 (in 2 ranges), indexes: (1 3)]
2016-07-31 22:10:42.404 RemoveObject[6014:338210] (
how,
to,
object
)

先通过indexesOfObjectsPassingTest:把待删除对象的index找出来,再调用removeObjectsAtIndexes:进行一次性删除。

3 总结

  1. 不建议在NSMutableArray循环中使用removeObject:删除该NSMutableArray内部对象,此举可能引发误删,如2.1所示;
  2. 不建议在NSMutableArray的for in 循环中删除对象,此举可能引发崩溃,如2.3所示;
  3. 建议删除NSMutableArray内部对象时,先拿到待删对象的index,再进行一次性删除,如2.5所示。

1 frame

layer相对其父坐标系的位置。包括矩形左上角点,矩形宽高。值得注意的是layer被旋转后的宽高。如下图所示,bounds是40*50,frame是62*64。

![CALayer-frame](/assets/img/postImage/iOS frame、bounds、anchorPoint、position以及transform/CALayer-frame.jpg)

2 bounds

layer相对其内部坐标系的位置。

3 anchorPoint

layer的锚点(默认是{0.5, 0.5},即在layer的中部)相对其内部单位坐标系的位置。锚点就是layer旋转的中点。左上角是{0, 0},右下角是{1,1}。值得注意的是锚点的值可以比0小,比1大,例如{-0.5, 1.5},如此layer旋转可以围绕外部某个点

![CALayer-anchorPoint](/assets/img/postImage/iOS frame、bounds、anchorPoint、position以及transform/CALayer-anchorPoint.png)

4 position

layer的锚点相对其外部坐标系的位置。

1
2
3
4
5
6
7
CALayer *layer = [[CALayer alloc] init];
[self.view.layer addSublayer:layer];
layer.backgroundColor = [[UIColor redColor] CGColor];
layer.bounds = CGRectMake(0, 0, 200, 60);
// 在不改变锚点的情况下设置layer居中
layer.position = CGPointMake(layer.superlayer.frame.size.width / 2, layer.position.y); // 水平居中
layer.position = CGPointMake(layer.position.x, layer.superlayer.frame.size.height / 2); // 垂直居中

5 transform (3D变换)

坐标变换,变换公式如下:

![CALayer-transform](/assets/img/postImage/iOS frame、bounds、anchorPoint、position以及transform/CALayer-transform.jpg)

样例:

scale变换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CALayer *layer = [[CALayer alloc] init];
[self.view.layer addSublayer:layer];
layer.backgroundColor = [[UIColor redColor] CGColor];
layer.bounds = CGRectMake(0, 0, 200, 60);
layer.position = CGPointMake(200, 200);
layer.transform = CATransform3DMakeScale(2, 0.5, 1); // x拉升为之前的2倍,y压缩为之前的1 / 2, z不变

NSLog(@"CALayer frame : %@", NSStringFromCGRect(layer.frame));
NSLog(@"CALayer bounds : %@", NSStringFromCGRect(layer.bounds));
NSLog(@"CALayer position : %@", NSStringFromCGPoint(layer.position));
NSLog(@"CALayer anchorPoint : %@", NSStringFromCGPoint(layer.anchorPoint));

//2016-07-27 21:10:38.034 CommonTest[47569:5204919] CALayer frame : { {0, 185}, {400, 30} }
//2016-07-27 21:10:38.034 CommonTest[47569:5204919] CALayer bounds : { {0, 0}, {200, 60} }
//2016-07-27 21:10:38.035 CommonTest[47569:5204919] CALayer position : {200, 200}
//2016-07-27 21:10:38.035 CommonTest[47569:5204919] CALayer anchorPoint : {0.5, 0.5}

旋转变换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
CGFloat Radian(CGFloat angle)
{
return angle * M_PI / 180.f;
}

CALayer *layer = [[CALayer alloc] init];
[self.view.layer addSublayer:layer];
layer.backgroundColor = [[UIColor redColor] CGColor];
layer.bounds = CGRectMake(0, 0, 200, 60);
layer.position = CGPointMake(200, 200);
layer.transform = CATransform3DMakeRotation(Radian(30), 0, 0, 1); //绕着Z轴顺时针旋转30°

NSLog(@"%@ %@ %@ %@", @(layer.transform.m11), @(layer.transform.m12), @(layer.transform.m13), @(layer.transform.m14));
NSLog(@"%@ %@ %@ %@", @(layer.transform.m21), @(layer.transform.m22), @(layer.transform.m23), @(layer.transform.m24));
NSLog(@"%@ %@ %@ %@", @(layer.transform.m31), @(layer.transform.m32), @(layer.transform.m33), @(layer.transform.m34));
NSLog(@"%@ %@ %@ %@", @(layer.transform.m41), @(layer.transform.m42), @(layer.transform.m43), @(layer.transform.m44));
NSLog(@"CALayer frame : %@", NSStringFromCGRect(layer.frame));
NSLog(@"CALayer bounds : %@", NSStringFromCGRect(layer.bounds));
NSLog(@"CALayer position : %@", NSStringFromCGPoint(layer.position));
NSLog(@"CALayer anchorPoint : %@", NSStringFromCGPoint(layer.anchorPoint));

//2016-07-27 21:24:13.989 CommonTest[48025:5281194] 0.8660254037844387 0.4999999999999999 0 0
//2016-07-27 21:24:13.990 CommonTest[48025:5281194] -0.4999999999999999 0.8660254037844387 0 0
//2016-07-27 21:24:13.990 CommonTest[48025:5281194] 0 0 1 0
//2016-07-27 21:24:13.990 CommonTest[48025:5281194] 0 0 0 1
//2016-07-27 21:24:13.990 CommonTest[48025:5281194] CALayer frame : { {98.397459621556123, 124.01923788646684}, {203.20508075688778, 151.96152422706632} }
//2016-07-27 21:24:13.990 CommonTest[48025:5281194] CALayer bounds : { {0, 0}, {200, 60} }
//2016-07-27 21:24:13.991 CommonTest[48025:5281194] CALayer position : {200, 200}
//2016-07-27 21:24:13.991 CommonTest[48025:5281194] CALayer anchorPoint : {0.5, 0.5}

6 CALayer的frame决定因素

view或者layer的frame其实不是一个独立的属性,它是由bounds、position和transform决定的虚拟属性。因此,一旦以上3个属性发生变化frame就会变化。相反地,一旦改变frame,那么bounds、position和transform也可能变化。

The frame is not really a distinct property of the view or layer at all; it is a virtual property, computed from the bounds, position, and transform, and therefore changes when any of those properties are modified. Conversely, changing the frame may affect any or all of those values, as well.

7 参考文档

Core Animation Programming Guide

《iOS Core Animation Advanced Techniques》

1 说好的格式呢

1.1 static NSString *这种变量的命名尽量以k开头。

1
2
3
4
5
static NSString * const WMHomePageFlowPath = @"WMHomePageFlowPath";`

变为

static NSString * const kWMHomePageFlowPath = @"kWMHomePageFlowPath";

1.2 定义常量最好用static const

1
2
3
4
5
#define TOP_VIEW_FIX_HEIGHT 116.0

变为

static const CGFloat kTopViewFixHeight = 116.0f;

1.3 宏定义的名称全大写字母

1.4 使用现代OC语法

1
2
3
4
5
[eventDictionary setObject:code forKey:@"code"];

变为

eventDictionary[@"code"] = code;
1
2
3
4
5
[cellIndexs objectAtIndex:cellIndexs.count - 1];

变为

cellIndexs[cellIndexs.count - 1];

2 并没有什么卵用

2.1 函数传block参数,无意义的^{}不应该存在

1
[UIView animateWithDuration:0.1 animations:^{}];// 无意义

2.2 IDE自动生成的无用函数应该删除

1
2
3
- (void)didReceiveMemoryWarning {// IDE自动生成的函数,没用到就删了
[super didReceiveMemoryWarning];
}

2.3 无意义的判断

1
2
3
if (bottomImageView) {
bottomImageView.frame = imageViewRect;
}

3 良の实践

3.1 字面量字典或者字面量数组语法使用要注意nil变量

1
2
3
4
5
6
7
NSDictionary *URLParameters = @{@"address" : address,
@"keyword" : keyword,
@"poi_page_index" : poiPageIndex,
@"poi_page_size" : poiPageSize,
@"food_page_index" : foodPageIndex,
@"food_page_size" : foodPageSize};
// 以上代码在address、keyword、poiPageIndex、poiPageSize、foodPageIndex、foodPageSize任意一个变量为nil的时候会crash

一种经常出现的crash是使用字面量字典或者字面量数组的时候误添加值为nil的变量,从而直接崩溃。

3.2 各种type需要enum化

1
2
3
4
5
if (self.poi.deliveryType == 1) { //magic number,enum化
deliveryTimeView = [self viewForMTDelivery];
} else {
deliveryTimeView = [self viewForDeliveryTime];
}

3.3 恰当运用三目操作符

1
2
3
4
5
6
7
8
9
10
if (poi.picture.length > 0) {
[self.headImgView sd_setImageWithURL:[NSURL URLWithString:poi.picture]];
} else {
[self.headImgView sd_setImageWithURL:[NSURL URLWithString:defaultImage]];
}

变为

NSString *imgURLStr = poi.picture.length > 0 ? poi.picture : defaultImage;
[self.headImgView sd_setImageWithURL:[NSURL URLWithString:imgURLStr]];

3.4 同一个类的不同函数多次用到同一个常量,该常量应该升级为.m文件static const常量

1
titleLabel.centerY = 22.5;// 45 / 2.0 ? 建议45可以提出来,好多地方都用到了

3.5 一个函数内部有意义的量应该升级为const常量

1
2
3
4
5
6
7
8
9
10
11
12
1
if (distance > 500000) {
distance = 500000;
}
// 这些数字用 static const NSInteger命名一下?或者加个注释?

2
if (mutebelArray.count > 10) {//只保留10个搜索历史
NSRange removeRange = NSMakeRange(10, mutebelArray.count - 10);
[mutebelArray removeObjectsInRange:removeRange];
}
// 10提出来,kMaxRetainedSearchKeyCount = 10

3.6 protocol中申明为@optional的方法必须在调用处先用一下respondsToSelector判断

1
2
3
4
5
6
7
8
@protocol WMFoodListViewControllerDelegate <NSObject>

@optional
- (void)incFood:(WMOrderedSkuInfo *)orderedSku count:(NSInteger)count inBucket:(NSInteger)bucketNum animated:(BOOL)animated originalRect:(CGRect)originalRect;
- (void)decFood:(WMOrderedSkuInfo *)orderedSku count:(NSInteger)count;

@end
// 检查一下这些optional的代理方法。是否用respondsToSelector保护了

3.7 常用UI量应该变为宏定义

1
2
3
4
5
6
7
8
9
10
//ui
NaviBar高度
#ifndef WM_NAVIBAR_HEIGHT
#define WM_NAVIBAR_HEIGHT (WM_ABOVE_IOS(7) ? 64:44)
#endif

//1点的线宽
#ifndef WM_SINGLE_LINE_WEIGHT
#define WM_SINGLE_LINE_WEIGHT (1 / [UIScreen mainScreen].scale)
#endif

3.8 非必要不要在头文件里面直接#import其他头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
某.h文件内容如下。。。

#import <Foundation/Foundation.h>
#import "WMOrder.h"
#import "WMDeliveryInfo.h"

@interface WMOrderPreviewInfo : NSObject

@property (nonatomic,strong) WMOrder *order;
@property (nonatomic, strong) WMDeliveryInfo *deliveryInfo;

@end

变为

@class WMOrder
@class WMDeliveryInfo

@interface WMOrderPreviewInfo : NSObject

@property (nonatomic,strong) WMOrder *order;
@property (nonatomic, strong) WMDeliveryInfo *deliveryInfo;

@end

尽量使用“向前声明”,减少类之间的编译耦合度。

3.9 非必要不使用下划线访问变量

除了初始化函数,其他各处非必要不使用下划线访问变量。不要直接访问property的内置的私有变量,不管读写,都用self.propertyName。否则在把代码重构为block的时候很容易忘记添加self,而引起循环引用。而通常使用self.propertyName访问变量的话,在代码进行block重构时,更容易发现错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@interface Test ()

@property (nonatomic, assign) NSInteger age;

@end

@impliment Test

- (void)fooDelegate:(NSInteger)theAge
{
_age = theAge;
}

@end

[WMAlert showWithAction:^(NSInteger theAge) {
_age = theAge; //maybe cause retain cycle
}];

3.10 返回BOOL的方法设计和使用时要特别注意

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
设计一个方法,来表示对象中有没有内容

@interface Foo : NSObject

-(BOOL)isEmpty;

@end

如果判断这个对象是不是“空”很容易写出如下代码

if ([foo isEmpty]) {
//当foo为“空”的时候执行一些动作
}

这种判断方式有个问题,即当foo为nil的时候会存在误判,很明显foo为nil时,该对象在语义上是“空”,但是它不会进入该if分支,因为[nil isEmpty]返回NO.

因此,正确的写法应该是:

if (foo == nil || [foo isEmpty]) {
//当foo为“空”的时候执行一些动作
}

追根溯源还是这种签名设计方式有漏洞,试想OC的NSString并没有一个isEmpty方法,是否是基于这种考虑。