URL: https://onevcat.com/2025/04/llmtxt/index.html.md Published At: 2025-04-01 20:00:00 +0900 # 通过 llms.txt 引导 AI 高效使用网站内容 > 作为示例,本站也开始提供 `llms.txt` 和 `llms-full.txt` 的支持,可以参看下面的链接获取相关文件。 > > llms.txt > > llms-full.txt ## 什么是 llms.txt 大型语言模型(LLMs)是截止至训练日期时的人类知识的总集。而如果想要精确地解决更加实时的问题(比如在进行代码生成、研究辅助等任务中),我们可以通过搜索最新知识,依赖网络信息,来极大提升模型的准确性。然而,标准的 HTML 内容通常包含导航元素、JavaScript、CSS 和其他对于 LLMs 而言非必要的信息。这些冗余信息会在对话中占据 LLMs 有限的上下文窗口,也会干扰和降低处理效率。此外,LLMs 直接抓取和解析完整的 HTML 页面效率也很低下。为了应对这些挑战,`llms.txt` 应运而生,它是一个[正在讨论的标准(参见 llms-txt.org)](https://llmstxt.org/),旨在为 LLMs 提供一个简洁、专业的网站内容概述,以单一且易于访问的 Markdown 文件格式呈现。`llms.txt` 就像一个网站的“指南”,引导 AI 系统找到站点上的关键信息,并以易于阅读和分析的结构化格式提供这些信息。 ### llms.txt 的目的与益处 在使用带有搜索的 LLMs,或者是通过其他的搜索服务 API 将搜索内容传递给不带搜索的 LLMs 时,被搜索的网站内容是面向人类进行渲染的,它包含大量无关的视觉要素(CSS 样式,动画等)和功能性的内容(JavaScript,操作交互等),且主题往往不太明确,这会给 LLMs 增加理解难度。我们需要一种更加面向 AI 的网站内容检索方式。 `llms.txt` 是**放置在网站根目录下的一个文件**,它的核心目标是向 LLMs 提供结构化的、机器可读的信息,从而帮助它们在推理阶段更有效地利用网站内容。与面向搜索引擎的 `robots.txt` 和 `sitemap.xml` 不同,`llms.txt` 专为推理引擎优化,它的目标是通过以 AI 能够高效处理的格式提供内容结构,解决了 AI 相关的挑战。这种(还在提出阶段的)标准标志着一种向 AI 优先的文档和内容策略的转变。随着 AI 在网络内容消费中扮演越来越重要的角色,针对 AI 的理解进行优化将变得与针对人类用户和搜索引擎进行优化同样关键。 网站所有者也能从实施 `llms.txt` 中获益匪浅:AI 聊天机器人更有可能在其回复中引用那些提供了 `llms.txt` 文件的网站,从而提高网站在 AI 平台上的可见性。优化后的 `llms.txt` 文件还有可能在 AI 驱动的搜索体验中带来更好的可见性、更高的排名和更强的可发现性。 更清晰地理解 `llms.txt` 在网络标准中的定位,可以参考下表: | **名称** | **目的** | **目标受众** | **格式** | | ------------- | ---------------------------------- | ------------ | -------- | | `robots.txt` | 控制搜索引擎爬虫对网站的访问 | 搜索引擎 | 文本 | | `sitemap.xml` | 列出网站上所有可索引的页面 | 搜索引擎 | XML | | `llms.txt` | 为大型语言模型提供结构化的内容概述 | 大型语言模型 | Markdown | ## 解剖 llms.txt 标准:关键组件与结构 `llms.txt` 标准现在定义了两种不同的文件:`/llms.txt` 和 `/llms-full.txt`。 - `/llms.txt` 提供了一个精简的网站文档导航视图,旨在帮助 AI 系统快速理解网站的结构,它通过链接提供了一个简洁、结构化的关键内容概述。 - `/llms-full.txt` 是一个包含所有文档内容的综合性文件,它将所有文档整合到一个单独的 Markdown 文件中,这个文件需要“包罗万象”,因此尺寸一般比较大。 这两种版本的存在使得网站所有者可以根据不同的用例和内容结构,选择向 LLMs 提供的信息详细程度。对于拥有大量文档的网站,以导航为中心的 `/llms.txt` 可以提供快速的路线图,而 `/llms-full.txt` 则提供完整的原始内容以供深入处理。 ### llms.txt 的内容 对于 `/llms.txt` 文件,其必须遵循特定的 Markdown 语法和结构。 1. 文件应以网站或项目名称的一级标题(`#`)开头,随后可以有一个简短的项目描述的块引用(`>`),通常一到三句话即可。 2. 接下来的内容应使用二级标题(`##`)组织,例如“文档”、“示例”等,用于列出文档链接,创建逻辑区块(例如,“主要文档”、“产品”) 3. 这个二级标题下应该是一个列表,它包含相应的链接和简短的描述,格式为 `- [文档名称](URL): 简短描述`。此处的 URL 应该是等同于为人类准备的网页地址所等同的该文档的 md 格式文件。 4. 此外,还可以包含其他的二级区块,例如“可选资源”、或“隐私政策”等其他部分。一般会使用 `## Optional` 部分用于表示当上下文长度受限时可以省略的次要链接。 一个典型且简单的 `llms.txt` 文件内容如下: ```md # 我的博客 > 这是我的博客,用来记录一些个人感兴趣的内容。 ## 博客文章 - [什么是 llms.txt](https://example.com/article-1.md): 介绍什么是 llms.txt 以及它的主要特性 - [从监督学习到加强学习](https://example.com/article-2.md): 回顾和比较训练 LLM 时的主流学习方法和变迁过程 ## 其他页面 - [关于我](https://example.com/about/index.html.md): 介绍博客站点作者:简单的生平和其他著作。 - [许可证](http://example.com/license/index.html.md): 描述网站内容的许可证信息 ## 可选 - [GitHub 源码](https://github.com/example): 网站源代码 ``` 这种结构化的 Markdown 格式确保了 LLMs 可以轻松地解析和理解 `/llms.txt` 文件的内容和层级结构。Markdown 清晰的语法和明确的层级关系(标题、列表、链接)为 AI 模型提供了一种一致且易于处理的格式,从而减少了歧义并提高了信息提取的效率。 ### llms-full.txt 的内容 相比之下,`/llms-full.txt` 的格式则更为直接,它是一个**包含所有文档内容**的单一、全面的 Markdown 文件。它应包含网站的所有文档,并整合到一个单独的 Markdown 文件中。为了优化 AI 的处理效率,建议移除文件中的非必要标记和脚本。最简单的方法,就是将所有驱动站点的内容(比如很多 markdown 文件)进行合并,然后生成这个巨大的内容文件。 `/llms-full.txt` 为 LLMs 提供了对完整内容的直接访问,无需进行额外的导航。对于需要深入理解所有可用信息的任务,以单一、清晰的格式提供完整的文档对于 LLMs 来说非常有益。常见的例子,比如某个技术产品(可以是编程语言,库,或者是面向用户的软件等)将文档和说明合并到一个 `llms-full.txt` 中,以供 AI 进行参考。 ### 创建和放置 llms.txt 按照上述内容创建好文件后,就可以将它们放置在网站的根目录下,并公开给互联网访问了。llms.txt 的标准约定了文件的位置就是网站根目录,这类似于 `robots.txt` 和 `sitemap.xml` 的存放方式,简化了 LLMs 的发现过程。 另外,我们还可以在服务器配置中添加 `X-Robots-Tag: llms-txt` HTTP Header。这个值可以向 LLM 发出信号,表明网站提供了 `llms.txt` 文件。这并不是一个严格要求,但这个 header 值可以明确表明网站已采用 `llms.txt` 标准。 我们可以使用一些工具来验证某个网站是否实现了 llms.txt 标准,比如[这个 extension](https://chromewebstore.google.com/detail/llmstxt-checker/klcihkijejcgnaiinaehcjbggamippej?pli=1)。 ## 在 AI 中使用 llms.txt 和主动抓取网络的搜索引擎或者爬虫不同,目前 LLMs 还不会自动发现和索引 `llms.txt` 文件(毕竟 llms-txt 还没有成为被完全认可的标准)。因此,我们还需要手动将文件内容提供给 AI 系统。这可以通过以下方式完成: 1. 直接向可以访问互联网的 AI 提供 `llms.txt` 或 `llms-full.txt` 文件的链接; 2. 对不能访问互联网的 AI,将 `llms.txt` 文件的内容直接复制到提示词中; 3. 或者,如果 AI 工具支持文件上传功能,则可以使用该功能上传 `llms.txt` 文件。 目前该标准仍处于早期阶段,但随着标准的普及,我们可能会看到 AI 系统发展出自动发现和使用 `llms.txt` 文件的能力,这会类似于搜索引擎处理 `robots.txt` 和 `sitemap.xml` 的方式。 值得注意的是,一些工具和平台已经开始支持 `llms.txt` 的集成。例如,Cursor 等平台允许用户添加和索引第三方文档(包括 `/llms-full.txt`),并在聊天中使用它们作为上下文。也有一些类似于 [llms.txt hub](https://llmstxthub.com/) 的网站为 llms.txt 提供了更方便的检索方式。 这些平台的出现表明人们越来越认识到 `llms.txt` 的价值。随着更多 AI 工具和平台集成对 `llms.txt` 的支持,其采用率和实用性可能会显著提高。因此,尽早完成适配,可能会对你的网站内容在 AI 时代占有一席之地提供帮助。 ### llms.txt 工具与实际案例 目前有多种工具可以帮助生成 `llms.txt` 文件,从而简化了创建过程,使得网站所有者更容易采用该标准。例如: - dotenv 开发的 `llmstxt` 是一个开源的命令行工具,可以基于网站的 `sitemap.xml` 文件生成 `llms.txt`。 - Firecrawl 也提供了一个名为 `llmstxt` 的工具,它使用 Firecrawl 的爬虫来生成 `llms.txt` 文件。Firecrawl 还提供了一个功能齐全的 AI 爬虫,可以创建 `llms.txt` 文件,并为大型平台提供在线生成器。 - Mintlify 是一个文档平台,内置了 `llms.txt` 的生成功能,可以为托管的文档自动生成 `/llms.txt` 和 `/llms-full.txt`。 - Microsoft 的 MarkItDown、Jina AI 的 Reader API 等,可以把任意内容转换为 Markdown,适合用来从没有直接纯文字驱动的网站生成 llms-full.txt。 - 也有一些 WordPress 插件可以用来创建和管理 `/llms.txt` 文件。 下表列出了一些可用于生成 `llms.txt` 文件的工具: | **工具名称** | **描述** | **生成方法** | **参考链接** | | ----------------------- | ----------------------- | --------------------------- | ------------------------------------------------------------ | | `llmstxt` by dotenv | 开源命令行工具 | 基于 `sitemap.xml` 文件生成 | https://github.com/dotenvx/llmstxt | | `llmstxt` by Firecrawl | 使用 Firecrawl 爬虫生成 | 抓取网站内容 | https://llmstxt.firecrawl.dev/ | | Mintlify | 文档平台 | 自动生成 | https://mintlify.com/ | | MarkItDown by Microsoft | 内容转换为 Markdown | 手动转换内容 | https://github.com/microsoft/markitdown | | SLM (Reader API) by Jina AI | 内容转换为 Markdown | 手动转换内容 | https://jina.ai/reader/ | | LLMs.txt Generator | WordPress 插件 | 自动创建和管理 | https://wordpress.org/plugins/llms-txt-generator/ | 当然,这个领域还有很多空白。因此,为你喜欢的网站内容生成工具添加 llms-txt 的支持,也许是一个你向这个项目开始进行贡献的很好的途径。 许多网站已经开始实施 `llms.txt` 标准,并展示了其在不同场景下的实际应用。我们可以在 [llms.txt hub](https://llmstxthub.com) 上找到很多现成的示例,比如: - [Cloudflare](https://llmstxthub.com/website/cloudflare) - [Anthropic](https://docs.anthropic.com/llms.txt) - [Perplexity](https://llmstxthub.com/website/perplexity) - [ElevenLabs](https://llmstxthub.com/website/elevenlabs) - [Cursor](https://llmstxthub.com/websites/cursor) 这个标准非常适合用来帮助 LLM 在上下文中索引文档,如果你的网站也实现了 llms.txt,不妨尝试将它们也提交到 llms.txt hub,让大家更容易发现它! ### 维护有效的 llms.txt 为了确保 `llms.txt` 文件的有效性,定期更新至关重要。当网站结构发生变化时,应及时更新 `llms.txt` 文件,以确保 AI 系统获得最新的信息。我们应该使用自动化的工具来生成和更新 `llms.txt`。过时的 `llms.txt` 文件可能会误导 LLMs,反而抵消其带来的益处。 在 `/llms.txt` 文件中,应有选择地包含最重要的资源,并将不太重要的内容放在可选部分。`/llms.txt` 的目标是提供精简的概述,因此只包含最关键的资源可以使其保持简洁有效。 对于 `/llms-full.txt` 文件,建议对其进行优化以提高 AI 的处理效率:移除不必要的标记和脚本,使 AI 模型能够专注于重要的核心内容。 ## 总结:面向 AI 的网络内容 采用 `llms.txt` 标准为 LLMs 和网站所有者都带来了显著的优势。对于 LLMs 而言,它提供了结构化的、易于理解的内容概述,提高了信息检索的效率和准确性。对于网站所有者而言,它可以提高在 AI 平台上的可见性,优化资源利用,并可能带来潜在的 SEO 优势和用户信任度的提升。 `llms.txt` 代表着一种向 AI 优先的文档策略的转变。正如面向搜索引擎的 SEO 曾经至关重要一样,拥有 AI 可读的内容在很近的未来也会变得举足轻重。随着越来越多的网站采用该文件,相信新的工具和最佳实践将会涌现。`llms.txt` 为帮助 AI 系统更好地理解和利用网络内容(特别是技术文档和 API)提供了一个切实可行的解决方案。各行各业对 `llms.txt` 的采用正在加速。通过提供对机器友好数据的确定性访问,`llms.txt` 降低了延迟,提高了准确性,并使组织能够站在 LLM 优化网络的前沿。 可以预见,随着 AI 与网络的融合不断加深,和 [MCP 一样](https://onevcat.com/2025/02/mcp/),`llms.txt` 也会成为一个越来越重要的标准。 URL: https://onevcat.com/2025/02/mcp/index.html.md Published At: 2025-02-23 12:20:00 +0900 # MCP 是什么,现状和未来 MCP (Model Context Protocol,模型上下文协议) 是由 Anthropic 在 2024 年底推出的一种开放协议,它通过提供一种标准化的接口,旨在通过标准化的接口实现大语言模型 (LLM) 与外部数据源及工具的无缝集成。 最初推出时,仅有 Claude 的桌面应用支持,市场反响平平,且不乏质疑之声。但近期,随着诸多 AI 编辑器 (如 Cursor、Windsurf,甚至 Cline 等插件) 纷纷加入对 MCP 的支持,其热度逐渐攀升,已然展现出成为事实标准的潜力。本文将帮助你快速了解 MCP 是什么、它的功能,以及笔者对未来的预测与展望。 ### 三分钟看懂 MCP LLM 的模型参数蕴含丰富的通用知识,但通常无法掌握以下两类信息: - LLM 无法访问你的专属内容,例如文件系统中的文件、数据库里的订单,或私有 wiki 和笔记中的文本。 - 若无法联网,LLM 也无法获取实时信息,例如当前股价、最新财报、明日天气预报或前沿科技新闻。 此外,LLM 的核心功能是生成 token 和提供答案,因此它无法直接执行一些精细且需操作的具体任务,这也不是当前 LLM 的强项: - 比如调用其他服务的 API 帮你完成订餐和购票。 - 又比如比较 9.8 和 9.11 哪个大,或者计算 Strawberry 中到底有几个 r ![](/assets/images/2025/llm-compare-number.png) ![](/assets/images/2025/llm-count-letters.png) MCP 提供一套标准协议来解决这些问题。简而言之,它通过一组外部工具,帮助 LLM 获取其无法直接知晓的信息或者难以执行的操作。 下面是一个基本的 MCP 工作流程图,其中: - User:当然就是用户你啦。 - MCP Client:实现了 MCP 的客户端,也就是上面提到的 Claude 桌面 app,Cursor 等一众 app,以及未来可能进行支持的各个 Chat box app 等。 - MCP Server:通常是一段运行在本地的 Python 或 JavaScript 代码。为确保安全,Anthropic 当前仅支持 MCP 在本地运行。该 Server 既可执行本地操作 (如浏览文件系统),也能通过网络访问 API (包括第三方 API 或远程数据库)。 - 支持了 MCP 的 LLM。当前主要是 Claude 的系列模型。 启动客户端后,客户端读取配置文件,连接 server 并按照协议获取工具列表。和传统一问一答或者推理模型不同,当存在可用的 MCP 工具时,在发送用户问题时,需要把可用工具列表一并发送。LLM 将判断是否需要调用工具完成任务,并把这个指示返回给客户端。客户端如果接受到需要调用工具的指示,则按照 LLM 的指示和 MCP 中规定的调用方式,配置好参数联系 server 进行工具调用,并将调用结果再次发给 LLM,组织出最后的答案。 MCP 的具体使用以及 Server 和 Client 的实现方法并非本文重点。Anthropic 作为该协议的主要推动者,提供了详尽的文档和 SDK,开发者只需遵循这些资源即可轻松实现 MCP。 - [Model Context Protocol 介绍](https://modelcontextprotocol.io/introduction) - [MCP Server 开发](https://modelcontextprotocol.io/quickstart/server) - [MCP Client 开发](https://modelcontextprotocol.io/quickstart/client) ### 现状 MCP 因能有效弥补 LLM 的部分缺陷,逐渐从最初的质疑转为广受认可与欢迎。社区对 MCP 也有 awesome 的定番 repo 和同好社群,可供搜索已有的 server。最后,像是 Cline 这样的插件,甚至提供了 MCP Market。 - [awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers) - [MCP Directory](https://mcp.so/) - [Cline - MCP Marketplace](https://github.com/cline/mcp-marketplace) 不过,MCP 目前仍存在一些不足: #### 安全性 vs 易用性 MCP 的设计初衷是通过本地运行服务器来确保用户数据的安全性,避免将敏感信息直接发送给 LLM,这在理论上是一个强有力的安全保障。然而,尽管服务器被限制在本地运行,其权限范围却相当广泛,例如可以非沙盒化地访问文件系统,这可能成为安全隐患。 对普通用户来说,判断 MCP 服务器是否安全颇具挑战,因他们往往缺乏评估代码或行为所需的技术能力。此外,当前 MCP 的认证和访问控制机制仍处于初步阶段,缺乏详细的规范和强制性要求。 根据当前安全标准,用户需手动克隆仓库、安装依赖并运行代码来启动 MCP Server。Cline 提供的 MCP Market 则可以让用户一键部署服务器。这种“应用商店”式的体验极大降低了技术门槛,让普通用户也能快速上手。然而,这种便利性也带来了双刃剑效应:一键部署虽然方便,但可能加剧安全隐患,因为用户可能在不完全理解服务器功能或来源的情况下就运行它。 目前,Anthropic 仅支持本地运行服务器,并计划于 2025 年上半年正式支持远程部署。不过,像是 Cloudflare 已率先推出了[远程部署 MCP 服务器](https://developers.cloudflare.com/agents/capabilities/mcp-server/)的功能,这不仅提升了易用性,还为未来的云端集成铺平了道路。但是如何在安全和易用之间寻找平衡,有没有人愿意花大力气在架设“商店”的同时进行必要的代码审核,将会是个难题。 #### 开放标准 vs AI 竞赛 目前,支持 MCP 的客户端数量非常有限,如果更多的 LLM chat app 甚至各 LLM 的 web app 能够集成 MCP,并提供一些默认的 MCP 服务器,将显著增强 LLM 的能力并推动生态发展。然而,定义和主导 MCP 的 Anthropic 自身也是模型厂商,其他模型提供商很大可能并不愿意让自家生态接入到 MCP 里。这对普通消费者来说当然不是利好,如果各家厂商更愿意提供自己的解决方案,那么混乱的生态将会成为进一步发展的阻碍。 最后,Anthropic 作为 MCP 的主要推动者,其闭源模型的背景及其 CEO 的[右倾立场](https://darioamodei.com/on-deepseek-and-export-controls)可能对这一开放协议的长期发展构成风险。MCP 作为一个开源协议,需要广泛的社区支持和信任来维持生态系统的生命力,而 Anthropic 的闭源文化可能让一些开发者对其主导地位产生疑虑。虽然开源社区强调开放和包容,但 MCP 的未来仍高度依赖 Anthropic 的持续投入。 ### 未来 不管是使用 XML 还是 JSON,不管是本地二进制中寻找 symbol 还是通过 HTTP 进行交换,我们一路走来,早已习惯了使用结构化的数据格式和预先定义的 API 完成各种任务。在 API 的调用方和提供方,都需要人工维护及稳定的接口契约来规定参数类型、格式、调用方式。 随着 LLM 和 AI 时代的到来,无论是 function calling 还是 MCP 定义的协议,都迈出了新一步:它们在现有 API 上新增了 AI 友好层 (如自然语言代理端点),实现了对传统 API 设计的渐进式改进。在调用 API 时,我们使用自然语言描述,并交由 LLM 为我们生成结构化的调用方法和参数,从而简化了 API 使用侧的负担。 但是当前 MCP 下的服务提供方 (也就是 server) 依然需要人为开发。个人预测,接下来的革命可能会发生在 API 供给侧:我们为什么一定需要结构化的调用?随着 LLM 能力越来越强,如果某些经过特别训练能够完成任务的 LLM 自身就具备当前 MCP Server 的能力,那么我们是不是可以借助模型间的对话,直接完成任务? 我愿意把这种未来形态叫做 “原生的 AI API”:各个模型理解自己擅长的能力范围,人类在使用模型时,呈现的形态同时接触分布式的多个模型,人类与一个通用模型对话,然后通用模型再选择具体的擅长该任务的“专用”模型直接进行对话或者 Token 交换。 ![](/assets/images/2025/ai-api.png) 乍听之下,这似乎与 MoE (Mixture of Experts) 相似,但这里的“专家”并非同一 LLM 中的参数,而是可能运行于其他云端甚至本地的独立模型。各部分通过自主协商来确定数据交换的方式,并可以自主寻找或者委托更合适的 agent 来完成任务。这样,多个 agent 自主组合服务链,并将信息提供给别的 agent 或者 LLM 使用。 > MCP Server 在一定程度上已经接近这个设想了,不过它现在并不是模型,只是一段固定的带有“副作用”的代码。另外,MCP Server 的通讯和发现也都是单向的。 MCP 可能只是最终通往 AI 原生 API 旅途中最初的一小步,不过多年后我们回望,可能这也会是相当重要的一步。 URL: https://onevcat.com/2025/02/reasoning-model/index.html.md Published At: 2025-02-11 22:25:00 +0900 # 关于推理模型的一些误解和盲区 DeepSeek 给国内带来的 AI 普及和升级还在持续,虽然对于 AI 从业者和一些一直关注前沿的科技工作者来说,不论是传统 LLM 还是推理模型都不是什么太新鲜的概念了,但是对于行业外的长辈和小辈,或者是专注点刚被吸引到 AI 的业内人士来说,DeepSeek,特别是 DeepSeek-R1 的出现和爆火,可能是他们第一次真正在生活和工作里认真地接触和使用 AI。 可最近在和很多朋友交流的过程中,我也发现了大家 (其实有时候也包括我自己) 对推理式模型存在着一些常见的误解。本来其实这些疑问也可以通过询问 AI 得到解答,但是我还是把它们整理汇总一下放在这里,一方面为新接触 AI 的小伙伴们提供参考,另一方面也算是为给这个世界提供一些语料。 ## 对比推理模型和通用模型 最常见的一个现象,就是大家都倾向于无脑点上“深度思考 (R1)”。其实这个行为有待商榷。 ### 推理式模型一定比通用模型好吗? 不一定。它们有各自擅长的方向,无脑使用推理模型并不可取。 比如 OpenAI o1 和 DeepSeek-R1 这样的推理模型,在数学、逻辑、代码等可以明确定义正确与否的领域,表现远超通用 AI。但是在创意、写作、翻译等这类“偏文科”和内容创造性的方面,通用 AI 可能表现得更好。究其原因,这是训练方法和训练数据的不同。 通用大模型更多是使用全网文本+多领域语料综合训练。而推理模型,我们暂时不清楚 o1 的训练方式,但是 R1 在推理方面采用了纯的强化学习 (RL),而非人类的监督微调 (SFT);在 RL 部分,采用的数据也大多是数学和代码这类强逻辑的语料。这样的训练方法,所得到的自然是能力不同的模型。 ### 那就是说我需要精心选择用哪种模型? 这倒也没有。 因为 R1 其实是在 V3 的基础上训练出来的,所以它当然也具备了 V3 的一些能力,只不过是侧重不同。相信 OpenAI 的 o1/o3 这样的推理模型,也不会是凭空而起的,它们应该也是基于前一代的通用模型进行训练的。因此虽然在本文中区分了“推理模型”和“通用模型”,但是更正确的说法应该是 R1 或者 o1 其实是“带有推理能力”的通用模型。 所以如果需要和希望进行推理,则使用推理模型会得到好得多的结果。 ### 那还是无脑选推理模型就行了呗! 非也。 推理模型因为有额外的推理过程,响应速度会更慢,成本也更高。相信大家这近在使用 DeepSeek 时也深有体会,R1 常常“服务器繁忙,请稍后再试”,但 V3 就好很多。所以在响应时间比较重要的情景下 (比如使用 AI 架设客服),通用模型能提供快得多的速度。 另外就是上面提到过的,进行创意性工作时,通用模型的没有受到太多“理科思维”影响的大脑,可能可以提供更好的创意。比如用同样的“以雪景和冰冻的河水为题,写一首借景抒情的七言绝句。”为要求,让 R1 和 V3 作诗一首,可以对比一下结果: > DeepSeek-R1 (用时 51.6 秒) > > --- > > 飞花一夜下瑶台,冻合冰河未拟开。 > > 欲问天涯何处是,琼林尽染待春来。 > DeepSeek-V3 (用时 1 秒) > > --- > > 寒风裹雪落长天,河水冰封漫白烟。 > > 野树凝霜鸦雀静,苍茫一片锁江川。 从景至情,遣词造句上 R1 显是落了下风,更遑论 R1 接近一分钟的耗时对比 V3 一秒出诗的洒脱。 ### 有没有什么两全其美的方法 业界已经有一些混合架构的趋势,比如使用通用 LLM 进行问题理解,然后把逻辑思考部分拆分给推理模型,最后再用通用模型组织语言润色输出,这样我们可能就可以兼得鱼与熊掌。这类混合架构是一个研究方向,但是市面上好像还没有成熟的消费级产品,也许可以期待一下。 在这样的新产品出现之前,可能人类还能够再发挥一段时间的主观能动性:对于需要探索需求边界和创意性工作时,手动切换到通用模型,然后在识别出需要精准推理的子任务时,再切换到专用模型。 ## 关于 AI 的日常使用 ### 使用网页版和官方 app,还是使用 API 最方便的当然是使用网页版和对应的官方 app,可以最快最直接地用上,也能在未来新特性推出时第一时间跟进。但是官方 app 隐藏了很多的细节和选项,比如定义聊天温度或者上下文数量;而使用 API 的话,可以让你根据需求自行设置这些参数。 > 声明:下面一段中提到的一些 app 和服务,是笔者认为的同类中的优秀者。笔者没有收任何广告费! 当然,使用 API 并不意味着需要你开发一个自己的聊天 app,市面上已经有不少优秀的解决方案了,比如 [Cherry Studio](https://cherry-ai.com/)、[Chatbox AI](https://chatboxai.app/zh#)、[OpenCat](https://opencat.app/zh-Hans/)、[NextChat](https://github.com/ChatGPTNextWeb/NextChat?tab=readme-ov-file) 和 [ChatWise](https://chatwise.app/) 等等。它们一般都支持填入 API Key 的无缝集成。如果你同时需要使用和管理不同平台的 API Key,选择一些聚合类的平台,比如 [SiliconFlow](https://siliconflow.cn/zh-cn/)、[OpenRouter](https://openrouter.ai/) 或者 [OneAPI](https://github.com/songquanpeng/one-api)。 使用 API 和第三方工具能够让你实现一些更进阶的能力:比如定义和保存不同的专门负责某一项事务的聊天角色,这样你就不需要每次为了类似任务重复输入提示词了。 另外,它们也会允许你调整使用模型时的参数,来获得更精准的能力。 ### 自定义温度 通用模型中一个很重要参数:温度 (temperature)。温度控制文本生成的随机性,温度越高,得到的回答越发散,或者说越具有创造性;而温度越低,回答则越聚焦和合理。官方网页和 app 提供的一般都是通用问答的温度值,而如果想要更改温度值,往往我们就需要使用 API 了。一般情况下的温度设置可以参考: | **温度值** | **生成效果** | **典型场景** | |------------|------------------------------|-------------------------| | **T→0** | 选择最高概率词(贪婪搜索) | 代码生成/数学解题 | | **01** | 放大低概率词可能性 | 诗歌创作/头脑风暴 | | **T→∞** | 接近均匀随机采样 | 艺术实验性输出 | 提供一些直观的例子,来看看上面“以雪景和冰冻的河水为题,写一首借景抒情的七言绝句。”的问题,在 V3 不同温度值下的表现和简单解说 (我个人的理解)。 > V3 @ 温度 0.1 > > --- > > 雪落千山鸟绝踪,冰封万里水无声。 > > 寒梅傲立迎风笑,独钓孤舟一老翁。 0.1 温度下出现了很多古诗词中已有的意境:千山鸟绝,冰封万里,寒梅独傲,孤舟笠翁。这些都是训练语料中的文字,在这里变成了毫无新意也无创造性的堆砌。另外,没有押韵,过低的温度下也完全没看到抒情的描写,可谓完全失败。 > V3 @ 温度 0.4 > > --- > > 雪压冰河两岸平,风摇玉树鸟飞惊。 > > 寒光冷色无人渡,唯有孤舟伴月明。 0.4 温度有所好转,已经表现出前两句写景,后两句抒情的典型绝句特征了。但是雪啊冰啊风啊树啊的直接描写还是太普通。抒情句更着重内心的孤独与坚守,孤舟明月也是常用意向。 > V3 @ 温度 0.7 > > --- > > 寒风裹雪落长天,河水冰封漫白烟。 > > 野树凝霜鸦雀静,苍茫一片锁江川。 0.7 已经达到一般对话的默认温度。几个动词的选择比较巧妙,而抒情部分不太明确,似乎全在写景。 > V3 @ 温度 1.3 > > --- > > 昨夜风吹雪缦徊,晨玫满树率先开。 > > 冰封五里银河水,未见乌鹊架桥来。 1.3 温度下的表现相当优秀。第二句“玫”字“曰石之美者”,是指美玉:“早晨整棵树都开满了洁白的美玉”,比喻令人拍案叫绝。第三句的“冰封银河水”表现也相当大气。最后抒情表达则含蓄地抒发了对美好事物未及时出现的淡淡惆怅,全诗相当优秀。 > V3 @ 温度 1.8 > > --- > > 玉英昨夜下天台,万里河山俱白头。 > > 更有严寒流不住,凌澌声断使人愁。 1.8 温度几乎没有直接写景了,大部分都在抒情。天台山观雪,可见山上山下俱白。“凌澌”指的是流动的冰凌,冰凌断裂的声音让人无法诉说,只能寄托深切的愁思。虽然抒情,但是其实和写景部分的关系已经不大了:和二句“万里河山”等意向对应的情感应该更加磅礴,但是实际却已经走偏。 > V3 @ 温度 2.0 > > --- > > 琼妃凌塔咏志高,笑唤薛崔斗绿腰。 > > 偶是普通欢聚叶,邮苗韵拨碧壖飙。 不知所云,显然此时模型已经被玩坏了。 诗词鉴赏会到此结束,有点扯远了...不过至此,应该可以明确看出温度对回答质量和倾向的影响了。 在使用 V3 时,模型的温度参数设置可以参考[官方文档](https://api-docs.deepseek.com/zh-cn/quick_start/parameter_settings)。不同的模型会有不同的推荐温度,但是范围上来说都是类似的。更重要的是根据模型的输出,来合理调整温度,以期达到自己的目的: - 在**数理,法律,严格的专业知识问答**中,选择**降低的温度**; - 而在**发散性思维,头脑风暴,创意性工作**时,可以适当**调高温度**。 ### 为什么推理模型不支持温度 如果你看过 o1 或者 R1 的[使用文档](https://api-docs.deepseek.com/zh-cn/guides/reasoning_model),可能会注意到它们专门写明了: > **不支持的参数**:`temperature` 推理任务 (数学计算、逻辑推理、代码生成) 通常需要唯一正确答案,温度参数引入的随机性可能导致错误或矛盾结果。这些任务中,1+2 就应该等于 3,9.11 就应该比 9.8 要小,它们有着唯一的答案。在推理模型训练时,也是以这个前提进行并发展出模型的推理能力的。温度的设置在这种情况下变得没有意义:推理模型追求精确收敛,通用模型更强调可控的多样性。所以,问题又回到了模型选择上来:我们应根据任务类型 (解题或是创作) 进行权衡。 ### 不同的话题,是不是应该新开一个聊天页面? 是的! 模型为了表现得拥有记忆,在默认情况下,当你发送新的一条消息时,前几次的问答内容也会被一并发送,以模拟“模型记得你刚才问了什么,所以你们是在聊天”的**假象**。用 DeepSeek 官方的一张图来说明,模型是没有记忆的,实际发生的事情类似这样: ![](/assets/images/2025/deepseek_r1_multiround.png) 也就是说,之前的问题和回答,可能会影响后续的回答结果。当然,这个上下文是有上限的,只会带上前面若干次 (一般是五到十条) 的内容。有限的上下文,也是聊着聊着 AI 会表现得“忘事儿”的原因。 另一方面,在聊天对话过程中切换话题时,尽管模型通常能够识别话题的转变,但仍存在混淆的可能性。这种混淆在推理模型中尤为明显,可能导致模型的推理过程出现偏差。因此,为确保对话的连贯性和准确性,建议在开启新话题时新建一个会话,以便模型从初始状态进行理解。同时,保留已有的会话记录,便于未来需要时继续之前的对话,这不仅提高了对话的灵活性,也增强了体验。 ### 模型群聊和对话分支 使用 API 或者第三方 app 还有一些其他好处,比如同时接入多个大模型,向它们投放相同的问题,然后再对结果进行对比和采信。“兼听则明,偏信则暗”的古训在此时完美地得到了诠释: ![](/assets/images/2025/ai-group-chat.png) 另外一个个人非常喜欢的使用方式,是对话分支。在进行 AI 问答时,特别是学习和调研某方面的内容时,往往我会遵循“先总体后局部”的方式。也就是先让模型提供一个总括,它简略地包含若干要点,提供一种综述性的知识。而在之后,我可能会对其中某几项感兴趣,进行深入问答。但是默认的单一的聊天会话会导致上下文丢失,让我一次只能专注于一个方面。此时,使用支持分支的工具,可以让我们在保持上下文清晰的情况下,对所有的方面 (以及进一步衍生出来的话题) 逐个展开深入,而不需要担心丢失上下文。比如下面这样的使用方式: ![](/assets/images/2025/branch-chat.png) > 图中服务是 flowith.io,这里是我的[邀请链接](https://flowith.io/invitation?code=SR927Q),如果有需要的话可以使用。当然,我前面提到的一些其他工具也有提供类似的功能,但是可视化上 flowith 做到了顶尖。 ## 关于 R1 和社区提供的蒸馏模型 ### R1 蒸馏模型是什么,有什么用 所谓知识蒸馏,你可以理解为在训练新模型 (专业术语称为“学生模型”) 的时候,是通过向已有的模型 (或者叫做 “教师模型”) 提问来进行的。学生模型在提问和对比教师模型“答案”的过程中,不断调整自己的参数,让结果尽量逼近教师模型。 DeepSeek 在发布 R1 时,同时提供了几个蒸馏模型,像是 `DeepSeek-R1-Distill-Qwen-7B` 或者 `DeepSeek-R1-Distill-Llama-70B`。一个我常看到的误解,是认为这些参数较小的模型 (分别有 7B 个参数和 70B 参数),是通过蒸馏满血版 R1 (671B 参数) 得到的。其实不然,它们分别是通过蒸馏 Qwen 和 Llama 得到的。总结整个 R1 训练的整个过程: ![](/assets/images/2025/r1-train-process.png) 在 R1 蒸馏的过程中,DeepSeek 团队提到了“使用 R1 的样本数据”,但是基底模型还 Llama 和 Qwen。简单来说,可以认为通过思考链数据,DeepSeek 将 Llama 和 Qwen 这样的通用模型,蒸馏出了思考能力,让它们变身成了推理模型。类似的手段其实可以使用在其他任何模型上,现实是已经有一些团队[在进行相关尝试](https://unsloth.ai/blog/r1-reasoning)了。 那么这些蒸馏模型有什么用呢? 最大的意义首先是有限资源下的部署:更小的参数意味着更低的运行需求,而同样参数的推理模型,表现普遍要比通用模型要优秀。一个开源、可以本地运行、而且效果还不错的 AI,将极大促进社会各个层面对于 AI 化的适配。 其次是提供给更多研究者和开发人员学习及探索的机会:不论是进一步微调,还是进行领域适配和改进,它们都是不错的参考和对比。另外,这些模型相当于对照组:因为明面上这样的蒸馏没有涉及到加强学习 (RL),所以它们相当于一种“只用 SFT 能走多远”的实验对照,可以帮助研究者了解不借助 RL 时,SFT 到什么程度能赋予模型推理能力。 虽然 R1 的 Distill 系列模型并非直接通过 DeepSeek-R1 蒸馏,但 R1 的产品协议明确用户可进行“模型蒸馏”,也就是说大家可以自由地利用模型输出 (甚至包括思考链的内容)、通过模型蒸馏等方式训练其他模型。这极大降低了模型训练的门槛,必定将会促进更多推理模型涌现。 ### HuggingFace 上的社区模型都是什么意思 如果你到 HuggingFace [搜索“DeepSeek”](https://huggingface.co/models?search=deepseek),除了官方版本的模型外,你还会看到很多社区版本的模型。当前,大部分模型通过 [GGUF 格式](https://huggingface.co/docs/hub/gguf)发布,比如这个[社区版本的模型](https://huggingface.co/lmstudio-community/DeepSeek-R1-Distill-Qwen-7B-GGUF)。在 Model Card 中,我们会看到更多的版本,比如: ![](/assets/images/2025/model-card.png) 这里的 3-bit,4-bit 等,指模型经过**量化 (Quantization)** 处理后参数的存储位数。量化是模型压缩的常见技术,它将原先模型的每个参数从高精度 (比如 16 位的浮点数) 转换为低精度 (比如 4 位整数),从而减少模型体积和计算资源需求。但同时,参数精度的损失也意味着结果精度的损失。另外,模型文件上的大写字母,代表量化时采用的方法,具体情况可以参考[相关说明](https://huggingface.co/docs/hub/gguf#quantization-types)。 ### 自己部署 R1 蒸馏模型的设备需求 在本机部署模型的教程已经有一大堆了,无非就是 [ollama](https://github.com/ollama/ollama) 啊 [llama.cpp](https://github.com/ggerganov/llama.cpp) 之类,在此不再赘述。如果想要一键无脑,那么 [LM Studio](https://lmstudio.ai/) 是更好的选择,直接下载,加载,GUI 界面以及提供 API 一气呵成,是尝鲜把玩的首选。 对于 14B 版本的 4bit 量化模型来说,在 16GB 的丐版 Mac mini 上部署是没有压力的,速度也完全可以接受。而往上一层的 32B,32GB 的 mac 也比较吃力,可能需要 48GB 的内存。70B 的“高配版”蒸馏在 4bit 下可以跑在 64GB 统一内存的 mac 上。如果想要在 Windows 环境和显卡上运行,则可以把要求对应到 VRAM + RAM 上进行评估。没有进行过量化的 70B 模型,需要内存 180GB,目前常见的消费级单机应该只有 192GB 内存的 Mac Studio Ultra 能跑,略过不表。 不过,我们需要对这些蒸馏模型的能力有一定认识:它们性能也许无法达到你的期望,特别是当使用过满血版的 R1 或是其他同等和更高等水平的模型后,落差感会更大。有朋友总结了一个很有意思的参数能力对照表,分享给大家: > 1B:电子鹦鹉,简单判断对错 > > 7-10B:单词机,抽关键词,多模态场景,语音,图文,过滤 > > 30B:仅次于通用,路由,前置,抽正文,总结,超大上下文 > > 70B:通用模型替代,什么都干 > > 600B+:线上 API 投入实际生产 ### 怎么满世界都在说集成 DeepSeek-R1 蹭热度的同时,其实也是在进行技术科普。虽然大部分集成可能只是小参数甚至量化版,但是这可以提高 AI 的全民参与度,让这项技术非常迅速地普及。低成本优势和开源特性,使得 R1 的接入异常方便,就算不是自己部署,企业或者个人也完全有能力承担 API 的调用开销。这种像水电一样的基础设施式的推广,会让终端应用爆发,最终促进整个行业发展,形成燎原之势和独特且深入人心的生态。 关于这方面的讨论,也可以参看我之前的[一篇关于 AI 的文章](/2025/01/deepseek-ai/)。 URL: https://onevcat.com/2025/01/deepseek-ai/index.html.md Published At: 2025-01-31 00:15:00 +0900 # DeepSeek,大国竞争,以及国运 DeepSeek R1 的横空出世,如同在人工智能竞赛场中引爆了一颗中子弹,其冲击波正重塑着全球 AI 产业的权力版图。近期全球科技界围绕这一事件的讨论持续升温:从冯骥[在社交媒体上](https://weibo.com/6603744955/PbpQT8pqY)将 R1 定义为 “国运级别的科技成果” 的激情宣言,到 OpenAI 与微软联合[指控数据违规](https://www.inc.com/ben-sherry/openai-seems-concerned-that-deepseek-copied-their-work/91140698)的博弈;从 [nature 盛赞](https://www.nature.com/articles/d41586-025-00229-6)其“重新定义AI普惠化可能”的技术突破,到 Anthropic CEO [亲自撰文](https://darioamodei.com/on-deepseek-and-export-controls)贬损其技术价值并鼓吹硬件禁运。这场多维度的攻防战,早已超越了单纯的技术讨论,而演变为一场围绕科技话语权的战略博弈。 在这场 AI “登月竞赛”中,DeepSeek 通过开源策略完成了一次漂亮的战略突围。当 R1 不仅开源还开放了包含思考链的模型蒸馏权限时,实质上是将 AI 时代的“技术火种”播撒给了整个生态。这种“普罗米修斯式”的技术民主化,正在引发链式反应:昔日垄断巨头从容尽失地被迫从技术神殿走下,转而诉诸地缘政治与贸易管制等非技术手段。这种战略转向本身,恰是中国 AI 产业突破技术封锁的生动注脚。历史总是惊人地循环往复:六十年前,美苏太空竞赛最终以阿波罗计划改写人类文明史收场,甚至开启了苏联解体的序幕;而六十年后,当 R1 以开源生态挑战既有秩序时,华盛顿是否正在经历第二个“[斯普特尼克时刻](https://zh.wikipedia.org/zh-cn/%E5%8F%B2%E6%99%AE%E5%B0%BC%E5%85%8B%E5%8D%B1%E6%A9%9F)”?更关键的是,在去全球化浪潮与国内政治极化的双重压力下,美国还能否以政府之力复现冷战时那令人惊叹的技术动员能力? 以中国 AI 产业界的迭代速度,在 R1 开源协议的催化下,我们或将见证推理模型领域的“寒武纪爆发”。未来半年内,各大厂商的类 R1 的推理模型很可能呈现指数级增长,而后续创新者极有可能在性价比维度持续突破。个人认为,在未来半年内,以下几个关键变量将决定竞赛走向: 1. 技术代差的保卫战斗:当前 OpenAI o3 与 Claude 4 的升级承诺仍停留在纸面阶段。若美国团队无法在短时间内推出代际差明显的“杀手级模型”,其现有 $20-$200/月甚至更高的订阅模式将面临结构性崩溃的风险。 2. 商业模式的范式转移:当开源模型以趋近于零的边际成本冲击市场时,OpenAI 等企业精心构建的“合规壁垒”与所谓的“数据民主”叙事将遭遇根本性质疑。R1 创造的不仅是技术替代方案,更构建了“技术普惠”的道德制高点——这种价值观层面的降维打击,可能比单纯的技术参数更具破坏性。对普通用户而言,价格远比模型能多跑几分或者数据合规性来得重要。而面对这种情况,如果没有决定性的性能优势,传统闭源模型厂商可能只能被迫选择降价或者开源。 3. 生态系统的马太效应:若 DeepSeek 或是其他中国团队能维持创新速度,在美国反应过来之前就持续迭代出低成本开源且更强大的 R2 或者 R3 模型,那全球 AI 产业格局将会迎来不可逆的重构。闭源商业模型的订阅机制和商业模式将彻底崩塌。 4. 硬件突围的第二战场:中国在 7nm 以下制程的突破固然关键,但短期内更现实的破局点可能在专用推理芯片领域。像 Groq 的 LPU 架构启示我们:通过算法-芯片的协同设计,是有可能在现有较低工艺水平下实现数量级的能效提升的。这种“软件定义硬件”的突围路径,或许比单纯去追逐通用 GPU 更具价值。如果国内出现对标类似 Groq 的企业,并在国家战略层级整合资源,那很可能会直接结束比赛。 相比 OpenAI 高昂的订阅费用,DeepSeek 正在切实努力让推理式 AI 像水和电一样,成为全球普惠的基础设施。DeepSeek 的 AI 服务正处于“基础设施化”的临界点。当推理成本降至接近免费时,AI 应用将会真正渗透到社会的每个方面:从教育公平到医疗诊断,从工业质检到农田耕种,从长辈的日常搜索到孩子的求知探索。AI 将不再只是专属于白领精英的工具,而是所有人的智能助手。 在这场竞争中,硅基智能究竟会被用来重新定义孙子兵法中"不战而屈人之兵"的当代内涵,还是被用来跨越五月花号的科德角海岸去重塑新的文明版图等高线?人类到底是用 AI 这团火焰阔步迈向星辰大海,还是焚毁自身退回到肖维岩洞?这些答案在当下还不得而知,但是 AI 时代“登月竞赛”的帷幕显然已经拉开,在这个技术奇点临近的历史时刻,我们既是见证者,更是参与者。或许,我们正站在新文明纪元的门槛上——门后的世界充满未知,但可以确定的是:这场始于硅基智能的竞赛,终将重新定义人类文明的高度与未来。 URL: https://onevcat.com/2024/12/2024-final/index.html.md Published At: 2024-12-13 11:00:00 +0900 # 2024 年终总结 去年因懒癌发作,没能写年终总结。事后回想,错失了一次宝贵的记录机会,实在懊悔。于是今年决定提早起笔,希望能趁着头脑中的理性还没被假期的欢愉冲散之前,能把一些有印象有意义的内容刻印下来,也方便今后某天心血来潮时能够回顾。 随意聊几个今年生活里遇到的话题吧,包含的内容可能比较杂乱,想法也比较主观,但是都算是当下这个时间点从自己角度展开的一些观察和想法。当然,最后再照惯例列一列书评游戏番剧推荐啥的,以供参考... ## 总结 ### 关于职业和未来发展 ![](/assets/images/2024/2024-final-work.png) 在职场摸爬滚打也已经十多个年头了。我自己一向自认是没什么进取心,得过且过的类型。正好这几年也遇上疫情,长期在家工作,不幸养成了只要能划水就坚决不努力的糟糕特质,所以不管在职级上还是实际的成果上,其实这两年都没取得什么太大进步。很多时候也就只能用“我取得了绝佳的 work life balance”来自我安慰。 要说感悟的话,相比于以前,自己做事的风格显然改变了许多:以前更多的是追求快速,而现在更喜欢追求全盘考虑和稳重。我曾自己调侃过,十年前一晚上能解三个 issue,而现在三晚上能搞定一个 issue 就不错了。一方面可能是随着年纪增长,普通人始终很难抵抗人性中对于不确定性的恐惧;另一方面,也许还是多少受到日本环境的影响,希望能尽可能把事情一次性做到“圆满”。 很多时候,无论是写一段代码还是打造一个产品,我常常抱着“先这么搞着,之后再完善”的想法。然而,现实往往并不如人所愿。这种“之后再完善”的计划,有时变成了永远无法实现的空想,有时则因拖延太久,留下的问题反而需要花费更大的代价去修正。无论是哪种情况,结果都带来了不必要的精神负担和无尽的自我内耗。 让我举个具体的例子。熟悉我的读者可能知道,我曾经写过几本技术类书籍。当时的计划是每年定期更新,以跟上技术发展的脚步。刚开始的两年,我还能勉强保持节奏,但很快发现,这种想法就像是在追逐奔跑的火车——技术发展速度太快,而我的时间和精力却有限,让我切实体会到了“生也有涯,而知也无涯,以有涯随无涯,殆已”的古训。更糟的是,当初的自信让我高估了自己能长期坚持的能力。更新计划逐渐被搁置,书中的内容也慢慢与时代脱节。几年后,当我试图重新拾起这些项目时,才意识到,回头补救的成本已经高得令人望而却步。 总结这些经历,我逐渐明白了一个道理:“临时方案”往往成本最高。与其把希望寄托在未来的“优化”,不如从一开始就用心去做,追求一个自己满意的结果。这并不是否定“快速尝试迅速失败”这种理念,你依然可以迅速地实现一些东西,比如先做个“垃圾”出来验证自己的想法。但是与此同时,如果你的目标是长远发展,而不是短期冒险,那么一开始就不要对这个“垃圾”抱有过高期望,在做垃圾的同时,选择一个更稳健的节奏、尽可能追求卓越,反而可能将结果导向一个更坚实可期的未来。 每个人的角色和目标不同,自然也会产生不同的侧重和选择,重要的是在前行中平衡速度与质量。正如打磨一件精致的艺术品,虽然需要投入更多时间和精力,但最终的作品却能经得起时间的考验,也会让自己感到更为充实和自豪。真正的长远之道,不仅在于快速起步,更在于稳步走远。这也是已经在职场十多年,回望后再次出发时,我给自己定下的目标。 ### 关于 AI 和代际理解 ![](/assets/images/2024/2024-final-ai.png) 近年来谈及科技发展,AI无疑是无法绕开的核心话题。以 Token 生成为基础的大型语言模型(LLM)已经用其成功证明了自己的价值,成为行业的绝对主流。事实上,这篇文章也经过了 AI 的润色和修饰。生成式 AI 在文本翻译、知识检索等传统领域,正以摧枯拉朽之势取代传统工具。而如何将其能力进一步拓展到更具专业性的工作场景,已经成为当前备受关注的热门课题。 在程序设计领域,我时常看到类似“素人设计师使用 AI 一天上架 App Store”或“零基础新人策划四小时完成一个网站”这样的宣传案例。虽然这些标题听起来颇具噱头,但我愿意相信它们的真实性。毕竟,在我使用 AI 的实际体验中,尤其是像 Cursor 或 Windsurf 这样的“代写类”工具时,也常被它们启动任务的高效速度所震撼和折服。许多以往令人头疼的枯燥任务——搭建工程脚手架、按照文档解析数据结构、或者编写单元测试——在正确的提示词和使用方法下,AI 都能高质量地完成,大幅减少了我的工作负担。同样,一些小型任务,比如从零开发一个工具脚本,让它以 pipeline 的方式串联三到四个独立任务,过去我可能需要数小时才能构建完善,而现在借助 AI,仅需十几分钟就能完成。 尤其令人感慨的是,从 ChatGPT 3.5 的问世到如今各类 AI 模型百花齐放、整合工具层出不穷,仅仅过去了短短两年。这种突飞猛进的进步速度,既让人惊叹,也让人充满期待。 然而,在处理大型项目或复杂的工程和代码结构时,AI 的局限性也显而易见。尤其是在涉及用户交互和图形界面的客户端开发中,这种不足更加突出。使用 AI 协助完成复杂代码任务,常让我想起徒手抓鱼的情景:你能感觉到滑溜溜的鱼儿贴着指尖来回游动,成功似乎就是那么触手可及,但当你想要抓起鱼时,却又总是差那么一点。AI 的表现亦是如此:它能够给出一些在特定方向上颇有价值的建议,让人觉得离答案很近,但你总还是需要来回修改纠正,才有可能达到理想的效果。 这种体验往往与开发者的技术背景密切相关。如果你对相关任务(例如编程语言、API 使用或架构设计)有较深的理解,你可能会更深刻地感受到这种“似是而非、若隐若现”的状态。而如果缺乏这方面的专精和判断所需要的经验,你则可能更倾向于信任 AI 提供的答案。需要特别说明,这样的信任潜藏着巨大的隐患:即使只是一个小模块,如果你并没有真正理解它的逻辑和内容,那么未来维护和改进这段代码时,问题可能会如滚雪球般扩大。当然,反过来说,如果你永远不需要维护一段代码,那么 AI 确实能提供极大助力。 但现实世界中,软件开发往往是一项长期且持续的工作。当前世代的生成式 AI,所拥有的对话窗口和上下文记忆是有限的,这意味着它无法对你的整个项目拥有全面而深刻的理解。这种局限性导致它在短期提供帮助后,可能为长期维护埋下隐患。一段时间后,当你回过头试图修改或优化那段代码时,才会发现理解和扩展它所需的成本远高于预期。 > 当我写这段代码时,只有上帝和 AI 知道我干了什么。现在只有上帝知道了。 为了不让这样的情况出现,要么你还是需要对生成内容有基本的掌握和理解,要么你只能等待更加成熟的技术(比如让 AI 能进行长期记忆或者快速回想)。现有的技术可以通过一些“取巧”的方式接近这一效果,但远远不够理想。AI 辅助的程序开发在面临大型系统的开发和维护上,还有很大的进步空间。 从人工智能到短视频、推荐算法,再到更早些的移动计算,这些技术正悄然推动着人类社会的代际更迭。有个网络段子调侃道:“80后90后是第一代会用电脑的,也是最后一代会用电脑的。” 虽然有些夸张,但确实点出了成长于移动计算时代的“原生世代”与前几代人在看待世界时的不同视角。在 AI 时代,这种趋势不仅会延续,甚至可能进一步加速。 作为先进生产力和工具的代表,AI 的普及注定将深刻改变和重塑我们的生活和工作方式。如何掌握这些新工具、如何在全新的时代中适应并前行,或许会成为未来几十年里有识之士们无法回避的重要课题。作为 80 后的“老年人”,积极观察新世代同学们的行动,随时向他们请教和学习,避免被甩出太远,将是接下来攸关生存的大事。 ### 关于健康和个人修养 ![](/assets/images/2024/2024-final-health.jpg) 幸运的是,虽然偶尔会感冒咳嗽,但全家人整体还算健康。唯一担忧的,是自己的一些旧伤加上最近不太健康的生活习惯,带来的各种身体上的疼痛。十年前,我不太能理解“健康就是最大的财富”这句话,总觉得那些每天喊着头疼腰酸的人有些矫情。如今轮到自己经历了才明白,作为一个长期伏案工作的程序员,我其实已经对“十有八九得腰疼”这件事有心理准备了。可真到自己身上时,依然让人心情复杂。 最近经常看到一些令人唏嘘的事情,比如“谁也不知道意外和明天哪个先来”之类的故事,或者“至亲至爱病情突然恶化”的消息。情感上,我一向觉得自己是个冷漠且不太善于共情的人,但这些案例还是让我深受触动。我无法想象,也不愿意去设想,如果类似的事情发生在我身上,我会如何面对。 个人的渺小在这样的情境下尤为明显。无论我们如何讴歌生命的伟大或珍视人生的独特体验,在那种压倒性的灾难和不可抗拒的自然规律面前,能做的依然少之又少。这份无力感,恰似站在浩瀚星空下的一粒微尘,既真实又无奈。看似平淡和平凡的生活,又是多少人所羡慕的梦与远方? 回到自己的情况:为了有所改善,其实从桌子椅子到床垫枕头,能换的早就折腾了一圈,但效果也就那么回事。保持运动、规律饮食和充足睡眠,减脂减重减轻身体负担,这些道理我都懂,可实际做起来却并不容易。现在最怕的就是每年的体检:医生总是安慰说“数据都在误差范围内”,可年复一年,“误差”一点点地累积,渐渐发现各种指标似乎越来越难拉回标准值的轨道。距离绝望倒是还有些空间,但是必须作为重要课题开始关心了。 ### 关于投资和世界趋势 ![](/assets/images/2024/2024-final-stock.jpg) 作为工薪阶层,我们的主要收入来源自然还是工资奖金。对于上班族而言,投资更像是一个补充性质的理财手段,不应该也不能过分依赖它来改变生活质量。不过,2024年确实是一个充满机遇的特殊年份。即便是像我这样天生厌恶风险的保守派,在这一年也运气不错,收获了几次令人欣喜的投资回报,其中不乏 100% 到 300% 的可观收益。这样的结果让我对投资生涯有了更深的思考。 回顾这一年的投资历程,好像成功的关键其实也都是些老生常谈的原则: - 始终坚持投资自己能够真正理解的标的 - 重点关注能代表先进生产力的企业和行业 - 保持耐心,做时间的朋友 - 始终警惕贪婪心理,克服人性弱点 这段时间的投资经历让我深刻体会到:稳健的投资策略加上克制的心态,才是上班族最务实的理财之道。我并不是什么财经专业的资深人士,如果你打算开始进行一些投资,我也不太可能给出什么太好的建议。不过有几点,我会觉得如果我当初开始投资时有人提点的话,会少走很多弯路,在此也共享给读者: - 投资是一种规模效应,小资金就算获取高收益,也会因为本金太少而成为鸡肋。小资金更适合用来学习和积累经验。与此同时,工薪族更需要的是投资自己,通过工资和储蓄来获取相对大一些的本金,并进行风险规划。当本金足够多,你才有资格承受一定亏损,也才有获取利润的机会。 - 如果难以抉择标的,那么要么定投大的指数基金,要么可以按照自己生活中遇到的优秀产品和体验来进行选择。比如觉得某件商品的购买体验很棒,或者打心底渴望某个产品,那么不妨也配置一些相关企业的股票并忘掉它。每半年或者一年再进行一次评估和调正就好,没有必要每天看盘。 - 在你理解某个金融产品或手段 (可能是一支 ETF,或者是像 VIX 这样的信心指数,又或者是期权杠杆等概念) 之前,不要盲目购买和使用它们。 最重要的是要始终保持理性,做好风险管理。在投资之前,要清醒地评估自己的承受能力,做好最坏打算,确保就算本金全失也不会影响生活。那么只要全球和平发展的大环境不变,坚持正确的投资理念,获得合理回报就只是时间问题。(当然,如果世界真的天翻地覆,那么对普通人来说,钱财可能就不是最重要的考虑了) 关于世界发展的趋势,东西对抗的格局下,漫长的暴风雨正在酝酿,乌云的边缘已然清晰可见。我们曾在“黄金时代”的暖阳下自由穿梭,但短时间内显然已经不可能回到十年前甚至五年前那样了。川普的二次当选在我看来几乎是必然,这对世界来说倒不一定是件坏事。但可以肯定的是,未来四年世界也不太可能往融合的方向发展了。 对于像我这样站在冲突前线的普通人来说,这场风暴意味着怎样的代价,未来世界的割裂会到何种程度,是一件令人担忧的事情。希望人类能尽快航出这片风暴,希望在不远处我们就能找到世界平衡的那条地平线。而在此之前,也许能做的只有积极拥抱可能的变化,并在需要的时候对自己也进行革新和改变。 ## 一句话总结 说了这么多,按照上面四点,给自己定一下未来十年的行动指导方针吧: **脚踏实地,求知若渴;修身养性,拥抱变化。** 当然,这些说显得有点高大上,不利于实践...具体化通俗化一下,也许可以归纳成这样的版本: **放弃做梦,菜就多问;能躺就躺,随波逐流。** 怎么突然好像看上去舒服多了? ## 书评 每次写这个都很惭愧。今年也没看很多书,没有理由借口,就是自己懒。希望明年能更充实一些。这个列表之所以看起来有点长,是因为它包含了今年和去年两年的书(去年没写总结,也就没有写书单,追根究底还是懒)。 #### [最好的告别](https://book.douban.com/subject/26576861/) > 关于衰老与死亡,你必须知道的常识 一本讨论衰老和死亡,有关临终关怀的书。成书虽然是十多年前,但是当时很多超前的概念和观点,在今天看来可能正好。观察不同文明、不同时代对于生死观的诠释,去思考和理解衰老及死亡,最后的目标,则是为了活得更好。 #### [止损](https://book.douban.com/subject/26824891/) > 如何克服贪婪和恐惧 通过实际的交易案例,让读者了解亏损和如何控制亏损。在混沌市场里的决策本身就是非常困难的,提前制订一些交易规则,并严守它们,保持清醒和纪律,克服贪婪和恐惧,正是减少亏损的秒则。 #### [码书](https://book.douban.com/subject/27176880/) > 编码与解码的战争 一本关于密码学的科普读物,通过很有趣的历史故事,生动通俗地讲述密码技术的进化过程和原理。可以理解和掌握从最原始的位移或者字典,到最常见的大数分解和椭圆曲线,再到最先进的各类量子密码,你可以看到一条清晰的密码发展脉络。 #### [活法](https://book.douban.com/subject/34887257/) 稻盛和夫的畅销书,虽然整本都是鸡汤,但是思想却相当朴实。说白了就是砥砺人格,做一个内心诚实的人,并珍惜每一个瞬间。年轻时经常恃才自傲,后来才发现磊落厚重的活法才是更珍贵的品性。这本书的不少内容,都与自己的经历产生了共鸣。 #### [事实](https://book.douban.com/subject/36614098/) > 用数据思考,避免情绪化决策 帮助建立理性决策思维的一本书,可以培养一些更加合理的看待世界的方法:比如如何避免单一视角进行观察,如何使用全面的数据进行分析,如何避免在压力和焦躁情绪下做出决策等等。最开始是为了缓解压力在看,但最后却发现书中内容不仅限于投资决策,而是提供了更加通用的方法论。 #### [众妙之门](https://book.douban.com/subject/34969187/) > 走进量子信息宇宙 虽说“随机过程随机过,量子力学量力学”,但量子物理包括量子信息,是我从大学开始就很感兴趣的话题。量子信息学很多观点,比如宇宙的随机性,感觉和生物起源有着异曲同工之妙。有些问题是不是真的是人类所不可认知的,到底有没有一个宇宙的通用法则,到底怎样理解无穷的信息。如果对这些比较“虚”的话题感兴趣,那么这是一本不可错过的书。 #### [时势](https://book.douban.com/subject/36673627/) > 周期波动下的国家、社会和个人 按照国家,总结和解释了经济发展的规律和特点。书里充分考虑了像是地缘政治、历史经纬等等因素,对每个国家和社会的发展和特定现象进行了合理解释。从这些解释,我们可以抽取出一些思考方法,并以此对世界趋势进行判断。 #### [Build a Large Language Model (From Scratch)](https://book.douban.com/subject/36808317/) 一句句教你怎么从零开始做一个 naive 版本的 ChatGPT。让你不需要看论文也能理解和掌握像是 Transformer 啊 Attention 啊 Token 啊这样的基础概念,以及 LLM 的简单工作方式。认真读完的话,你就比世界上绝大多数人更懂 AI 了。 #### [人间草木](https://book.douban.com/subject/27049320/) > 汪曾祺散文精选集 散文集,算是给自己补充一些人文读物的输入。基本是乡土基调,文字质朴而富有生活气息,像是带着清晨露水的山间野草。在冰冷的现代都市生活里,可以提醒自己要重新学会用温和、细心的目光观察周遭的世界,感受生活本身蕴含的诗意与力量。 #### [参与感](https://book.douban.com/subject/25942507/) > 小米口碑营销内部手册 营销理念的说明,基本是教你怎么把自己和自己的产品“推销”出去。包含了一些小米创立和公司早期的小故事,可以看到产品和企业的理念是如何相辅相成发展的脉络。挺有意思。 #### [混沌](https://book.douban.com/subject/35595861/) > 开创一门新科学 稳居科普读物排行榜30余年的一本关于混沌科学的科普书籍。介绍了什么是混沌,以及它在各个领域的存在。复杂系统背后的精确运作,不知道是不是我的错觉还是认知不足,但混沌的理念总感觉和道家思想有些“不谋而合”? #### [Building a Second Brain](https://book.douban.com/subject/35761116/) > A Proven Method to Organize Your Digital Life 一本教你用电子笔记的书。如果你还在各种笔记软件之间左右横跳,犹豫不决到底要怎么构建属于自己的知识库,那么这本书应该可以提供一些建议。不过科技真是日新月异,书中提到的很多分类整理方法和体系构建,可能会逐渐被 AI 取代。 #### [经济学的思维方式](https://book.douban.com/subject/30274068/) 大学经济学读物,用“正确和科学”的方式补充一些基本概念,重新审视自己对于这些话题的认知是否正确。作为国民级别的“通识读物”,书本身写的比较贴近生活,对很多复杂概念的解释,能让人掌握到经济社会运行的基本方式。 ## 娱乐方面 玩得看得也不少,但不是人生的重点了,挑几个印象深刻的写一下吧,平凡作品就跳过了。 ### 游戏 #### 黑神话:悟空 没什么好说的,今年的现象级游戏。算是等到了真正意义上的第一款国产3A,文化玩法上的亲切感是国外3A无法带来的。希望这只是一个开始,以后如果能有更多企业带来这样的质量的作品,那可就太棒了。 #### 塞尔达传说:智慧的再现 塞尔达终于成为了塞尔达传说的主角。节奏和体量上来说,是中规中矩的塞尔达团队练手作品,没什么瑕疵,但也算不上惊艳。二三十小时的塞尔达体验,肯定是值回票价的。期待明年新款 NS 和新的塞尔达。 #### 潜水员戴夫 前期节奏相当好,第一次下深海的恐惧记忆犹新。但是后面节奏突然慢下来,新体验也变少了,就回归到普通的独立游戏水平了。整体表现还是满意的,游戏风格也很独特。 #### 彼方的她 - Aliya 很特别的一款游戏,玩法和剧情展开都有点像《Lifeline》,通过类似短信的对话方式与角色互动,影响剧情发展。不过本作对情感的展开要细腻得多(毕竟对面是美少女),所以后劲也相对大一些。那种等着游戏里的人物联系你的感觉,提供了很奇特的体验。 ### 番剧 今年追番强度下降了,有很多没看...不过发现自己品味有些变化。年纪大了以后反而更喜欢看青春校园恋爱番了?? 也只列出印象还不错的,能推荐的其实都太有名了,所以就列一个名字,不再简介了,排名不分先后,也不带链接。 - 败犬女主太多了! - 青之箱 - 单间,光照尚好,附带天使。 - 药师少女的独语 - 迷宫饭 - 葬送的芙莉莲 - 夜晚的水母不会游泳 - 胆大党 - 不时用俄语小声说真心话的邻桌艾莉同学 - 精灵幻想记 S2 今年就这样,我们明年再见! URL: https://onevcat.com/2024/11/type-as-state/index.html.md Published At: 2024-11-11 21:00:00 +0900 # 编译器,靠你了!使用类型改善状态设计 在程序的开发和运行过程中,人往往是最不可靠的环节:一个不小心,逻辑错误(也就是 bug!)可能会悄然保留下来并进入最终的产品。与此相对,编译器要可靠得多。如果程序中存在错误,编译器通常会直接阻止生成产品。Swift 拥有非常强大的类型系统,通过它,我们可以尝试将一些运行时的逻辑“封装”到类型系统中,从而在编译期提前发现潜在的问题和错误。这种依靠类型系统来“保存”逻辑的设计方式可以称为类型状态。 ## 一个简单例子:端到端加密 ### 定义和使用 这个例子源自实际工作的需求。假设我们需要设计一个客户端之间的消息系统,并支持端到端加密:也就是说,这些消息可能包含用户的隐私敏感内容。在用户设备上,这些消息可以以明文形式显示,但一旦需要离开用户设备、发送到服务端(并进一步传递到另一个目标客户端),则必须加密。如果错误地将未加密的信息发送出去,可能会带来安全隐患,甚至损害用户的信任。 一个“简洁”的设计思路是设计一个带有状态的 `Message`,它包含文本并用一个状态来表示是否已加密: ```swift struct Message { enum State { case raw case encrypted } private var text: String private var state: State init(rawText: String) { text = rawText state = .raw } } ``` 在此基础上,添加 `encrypt` 和 `send` 方法。 ```swift mutating func encrypt() { if state == .raw { text = text.encrypted() } state = .encrypted } func send() { if state == .encrypted { Service.send(text) } } ``` 在 `encrypt` 中,我们检查了 `state`,当它是 `.raw` 时才进行加密,这可以避免对已经加密过的文本进行重复加密;在 `send` 里,我们再次检查了 `state`,并当它在 `.encrypted` 时才发送。 一切看起来都没问题,按照正常流程,生成的 `Message` 可以在加密后发送: ```swift var message = Message(rawText: "Credit Number: 12345") message.encrypt() message.send() ``` 多次调用 `encrypt`,改变调用的顺序,都不会出现什么大问题(虽然看上去有点糟糕): ```swift // 情况 1,不会被多次加密 message.encrypt() message.encrypt() message.send() // 情况 2,明文不会被发送 message.send() message.encrypt() message.send() ``` ### 问题 这种实现方式存在一个潜在问题,即我们依赖运行时的状态逻辑来决定行为。与编译时的保证相比,运行时状态较为脆弱。 #### 问题例子 1 由于缺乏编译期的保障,这种方式在重构过程中很容易引入人为错误。例如,假设某天我们在 `State` 中新增了一个成员 `.secret`: ```diff enum State { case raw case encrypted + case secret } + init(secretText: String) { + text = secretText + state = .secret + } ``` 这时,`encrypt` 方法就失效了! ```swift mutating func encrypt() { // .secret 不是 .raw。不走加密 if state == .raw { text = text.encrypted() } state = .encrypted } var message = Message(secretText: "Hey, my sweet!") message.encrypt() // text 没加密,但 state 更新了 message.send() // 未加密文本被发送出去了!危! ``` > 如果要进行正确的实现,我们需要仔细阅读 `encrypt`,并在其中添加合适的状态检查和加密操作。如果代码库再复杂一点,并且长时间不维护相关代码,或者是突然接手,那往往会非常困难。 #### 问题例子 2 对于重构而言,如果测试用例不完善,这种基于状态判断的代码也相当危险。例如,在一次重构中不小心删掉了某些代码: ```diff mutating func encrypt() { - if state == .raw { text = text.encrypted() - } state = .encrypted } ``` 如果 `encrypt` 被多次调用,就会导致消息被多次加密,从而发送错误的加密信息。 ```swift message.encrypt() // 得到正确密文 message.encrypt() // 对密文再次加密 message.send() // 接收端无法解密 ``` 类似的问题还有很多,随着类型复杂度的增加,会出现更多类似情况,这里就不再一一列举了。 ## 可行的解决方案:用类型来定义状态 产生上述问题的根本原因在于,我们试图用**同一个类型实例中的状态来区分其能执行的操作**。类型系统应当充当能力的蓝图,当一个类型的实例不应被 “send” 或 “encrypt” 时,这些操作就不应出现在蓝图中。 ### 用类型状态解决问题 最简单的解决方案是将 `Message` 拆分成两个不同的类型:`RawMessage` 和 `EncryptedMessage`,并分别只在相关类型上定义 `encrypt` 和 `send` 方法。不过,借助 Swift 强大的泛型系统,我们可以通过一个泛型参数更好地表达这种设计思路。考虑以下代码: ```swift enum Raw { } enum Encrypted { } struct Message { private(set) var text: String } ``` 对于未加密文本,可以通过 `T == Raw` 的扩展,为它添加初始化方法和 `encrypt`: ```swift extension Message where T == Raw { init(rawText: String) { text = rawText } func encrypted() -> Message { .init(text: text.encrypted()) } } ``` 而对于已加密文本,它唯一需要的只有一个 `send`: ```swift extension Message where T == Encrypted { func send() { Service.send(text) } } ``` 如此一来,我们就将状态相关的逻辑“编码”到类型中了。唯一能让编译器通过的调用方式,就是生成 `Message`,加密,最后发送: ```swift Message(rawText: "Credit Number: 12345") .encrypted() .send() ``` 像是多次加密,忘了加密,或者颠倒调用顺序,现在都不可能发生了: ```swift Message(rawText: "Credit Number: 12345") .encrypted() .encrypted() // 编译错误,只有 Raw 有这个方法 .send() Message(rawText: "Credit Number: 12345") .send() // 编译错误,只有 Encrypted 有这个方法 ``` 这样,我们就得到了一个编译时就保证安全的 `Message` 类型。 ### 实际使用,添加 `.secret` 之前我们提到过添加一个 `.secret` case,它是一个比 `Encrypted` 更高的安全等级,我们希望它能做到两点。 1. 比 `Encrypted` 更复杂的加密:比如对使用 `encrypted` 得到的密文用不同的密钥再加密一次。 2. 实现“发后即焚”:发送以后在本机销毁这个 `Message`,不留下痕迹。 作为练习,我们先来看第一点。有了前面的架构,添加这个 `Secret` 简直是“无脑”的: ```swift enum Secret { } extension Message where T == Encrypted { // ... func secreted() -> Message { .init(text: text.secretEncrypted()) } } extension Message where T == Raw { // ... func secreted() -> Message { encrypted().secreted() } } ``` 最后,为 `Message` 也定义一个 `send`: ```swift extension Message where T == Secret { func send() { Service.send(text) } } ``` 使用起来也非常直接,不论是从 Raw 还是 Encrypted,我们都可以安全地得到用于发送的二次加密的信息: ```swift Message(rawText: "Hey, my sweet!") .encrypted() .secreted() .send() Message(rawText: "Hey, my sweet!") .secreted() .send() ``` 不需要再去关心加密解密和当前状态,类型系统保证了我们从一开始就不可能写出错误的代码,同时也大大降低了重构和添加新功能时的风险。 ### 使用 `~Copyable` “发后即焚”这个功能我们还没有实现。对于发送操作而言,`Message` 和 `Message` 并没有区别。尽管我们在上面的代码中通过链式调用直接进行了发送,但在发送前仍然可以保留中间状态,并在发送后读取并存储消息内容。编译器并不会阻止我们这样操作: ```swift let message = Message(rawText: "Hey, my sweet!") .encrypted() .secreted() message.send() // 虽然加密了但是被存下来了!危! writeToFile(message.text) ``` 当然,你可能会认为这是逻辑错误或误用:毕竟,只要我们不写出这样的代码,就不会引发安全问题。然而,这一假设并不可靠,我们需要更稳妥的保障。没错,或许你已经想到了,[上一篇文章](/2024/11/noncopyable/)中提到的不可复制类型,正是在这个场景下编译器能够提供的可靠保障。 将 `Message` 扩展为 `~Copyable`,然后在 `Message` 的 `send` 前加上 `consuming` 关键字,大功告成! ```diff - struct Message { + struct Message: ~Copyable { private(set) var text: String } extension Message where T == Secret { - func send() { + consuming func send() { Service.send(text) } } ``` 现在,发送 secret message 后,对该消息的进一步访问将不再被允许: ```swift let message = Message(rawText: "Hey, my sweet!") .encrypted() .secreted() message.send() // 'message' used after consume writeToFile(message.text) ``` ## 总结 通过使用类型系统对关键逻辑进行编码,借助编译器的力量来减轻大脑负担,无论是从心智模型还是维护难度来看,都是有益的。如果有合适的场景,不妨尝试这种编程方式,相信它会让开发过程更加轻松。 URL: https://onevcat.com/2024/11/noncopyable/index.html.md Published At: 2024-11-02 23:30:00 +0900 # 逆流而上的设计 - Swift 所有权和 ~Copyable 在 Rust 中,绝对安全和高效的内存使用得益于其[独特的所有权(ownership)设计](https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html)。七年前,Swift 团队发布了[《所有权宣言》](/2017/02/ownership/),以前瞻性的方式介绍了 Swift 中关于值的内存管理变化的一系列愿景。Swift 5.9 中(以和宣言里略微不同的语法)实现了这一愿景,引入了不可复制类型的标记 `~Copyable`(non-copyable),以与 Rust 截然不同的(打补丁的)方式实现了更精确的所有权控制。在今年的 Swift 6 中,之前类型扩展(extension)和泛型(generic)不支持 `~Copyable` 的不足也得到了解决,`~Copyable` 的可用性得到了提升。回顾 `~Copyable` 及其设计,它为 Swift 引入了一种全新的设计思路:以往的协议是“为类型增加功能”,而 `~Copyable` 则是“为类型解除限制”。本文尝试解释 `~Copyable` 的设计和工作方式,以帮助读者更好地理解并利用这个特性。 ## `~Copyable` 的工作方式 首先需要强调的是,即使目前完全不了解 `Copyable` 和 `~Copyable`,以及相关的 `consuming` 和 `borrowing` 等关键字,也不太会影响您成为一名优秀的 Swift 开发者或者继续保持这样的身份。在 Swift 学习的初期阶段,您无需完全理解其内存模型。然而,了解这些工具并在适当时机使用它们,不仅能帮助您编写更可靠、高效的代码,还能为您提供额外的设计思路。 我们先从一些最基础的知识开始,这也是纯新人容易搞混和迷惑的一个重要话题,值和引用。如果您对这些内容已经非常熟悉,也可以直接跳到[下一小节](#隐式-copyable-和不可复制的-copyable)。 ### 值类型和引用类型 在 Swift 中,`struct` 和 `enum` 等是值类型,一个值类型的变量持有的是代表值本身的内存块。比如一个字符串,或者一个枚举成员: ```swift let s1 = "Hello" let e1 = Day.monday ``` 当我们将这些变量赋值给其他变量,或是作为函数调用时的参数进行传递时,实际上这部分内存发生了复制。 ```swift var s2 = s1 func nextDay(of day: Day) -> Day { // `day` in the method body is a copy of content. } nextDay(e1) ``` 在这些代码中,存在两个 `"Hello"` 和两个 `Day.monday`, 它们存在于内存的不同位置,是不同的值。 ![](/assets/images/2024/value-type.png) > 当然,Swift 中有被称为“写时复制” (copy on write, CoW) 的优化。对于某些数据结构,虽然表面上它们是值类型,但是在赋值或者传递时就进行无条件复制会造成大量性能损耗,因此它们会在内容被更改时才进行复制。不过这不是本文的话题,请允许我跳过。 而引用类型(主要是 `class`,`actor` 等)的行为则与此大相径庭。引用类型的变量所持有的内容是一个内存地址,并由运行时维护引用计数。将一个引用类型变量赋值给其他变量或者传递时,实际的内存并不会被复制,被复制的只有这个内存地址,且它的引用计数增加。 ```swift class MyView: UIView { } let v1 = MyView() let v2 = v1 func sample(_ view: MyView) { // `view` in the method is a copy of address (or, pointer). } sample(v1) ``` 这段代码所对应的关系示意图为: ![](/assets/images/2024/ref-type.png) 不论对于以 `struct` 为代表的值类型还是以 `class` 为代表的引用类型,在赋值或传递时发生“复制”,是普遍现象。区别在于复制的是具体的值的内容,还是一个指针地址。这也决定了在赋值或者传递发生后,我们对这些值进行修改时,到底修改的是哪些内容:在上面的例子中,对 `s2` 的修改,比如为它指定一个新的字符串,并不会对 `s1` 造成影响(它们是两个不同的值,且内容现在也不相同了);但是如果我们对 `v2` 中 `MyView` 的某个属性进行修改,`v1` 和 `view` 都会获取到修改后的属性,因为这三个变量保存了同样的地址,它们所指向的内容是同一个 `MyView`。 ### 隐式 `Copyable` 和不可复制的 `~Copyable` 如果不加以声明,Swift 中所有东西都是可以被复制的。虽然大部分时候我们不太在意,但事实是对于 struct 或 enum 的复制并不是完全免费的。小体积的结构姑且不论,对于包含有很多数据,占用很多内存的值类型来说,复制的开销有时候并不能完全忽略;另外,有一些情况下我们会想要确保某个值只被使用一次。这些情况下,可以引入“不可复制的类型”,来明确表达值的所有权。 Swift 5.9 开始,为了规定类型的所有权,引入了两个协议:`Copyable` 和 `~Copyable`。两个协议都和 `Sendable` 类似,是不需要任何具体实现的标记类型。`Copyable` 代表可以被复制,它是 Swift 中所有类型和协议的默认行为,一般不需要额外声明。比如,下面的这些代码都是彼此等效的: ```swift struct A { } struct A: Copyable { } protocol B { } protocol B: Copyable { } func foo(t: T) { } func foo(t: T) where T: Copyable { } ``` 如果你不希望某个类型可以被复制,那么可以将它声明为遵守 `~Copyable`。 `~Copyable` 是一个非常特殊的协议,虽然 `~` 符号确实表示“否定”,但是如果我们简单地把它看作是 `Copyable` 取反的话,可能会影响正确的理解。以往我们在使用协议时,它们所做的一般是“赋予类型某种能力”。比如 `Encodable` 赋予类型被编码的能力,`CustomStringConvertible` 赋予类型转换为字符串的能力。同理,`Copyable` 赋予了类型可被复制的能力。于是,你可能会简单地认为,`~Copyable` 做的事情是和普通协议类似,是赋予这些类型**“不可被复制”**的能力。不过如果你仔细考虑,会发现“不可被复制”**并不是**具有某种能力,而恰恰相反,它表达的是**不具有某种能力**。在其他普通协议的情况下,“不具备某种能力”是常态,是不需要特别说明的,只有满足协议的特定类型才具备这些能力。类比过来,其实 `~Copyable` 所定义的范围正式这种不具备能力的“常态”。只不过是由于语言现状下,`Copyable` 已经是应用于所有类型上的不需要明白写出的隐式默认实现,因此我们才需要使用 `~Copyable` 来做到和其他 protocol 相反的事情:它用来解除 `Copyable` 的能力,让一个类型回到“原本”的不具有可复制能力的状态。 如果用一张图来描述 `~Copyable`,`Copyable` 和 `Encodeable` 的关系,它会是这样的: ![](/assets/images/2024/protocol-relationship.png) > 我们刚才说过,Swift 中所有类型和协议都默认有 `Copyable`。`Encodable` 的定义其实可以认为是: > > ```swift > protocol Encodable: Copyable { } > ``` > > 因此,它在关系图上是 `Copyable` 的一部分。 ## 所有权转移关键字 `~Copyable` 的值是不会被复制的,不过我们可以搭配一些关键字,稍微改变它的使用方式。具体来说,使用 `~Copyable` 时的关键字可能存在于三种位置:变量前,参数中和方法前。 ### 用在变量前 一般是赋值时,比如等号的右侧,我们可以在赋值 `~Copyable` 值前加上 `consume`,来代表这个值的内容已经被消耗: ```swift struct S: ~Copyable { func foo() { } } let s1 = S() // 报错:'s1' used after consume let s2 = consume s1 s1.foo() ``` 已消耗的变量将不可以再被使用,在上例中你可以理解为 `s1` 的内容被“移动”到了 `s2`,而原来的 `s1` 则处于类似于一种“没有初始化”的无效状态。编译器将确保它不会再被使用。 ![](/assets/images/2024/copy-invalid-value.png) > 如果你有兴趣,可以尝试用 `swiftc -emit-sil` 来生成对应的 [SIL](https://github.com/swiftlang/swift/blob/main/docs/SIL.rst)。事实上在 SIL 中,变量被使用后,会立即被 `debug_value undef` 命令撤销变量赋值,从而导致后续如果用到了这个变量,编译就无法完成。当前,对于使用被消耗后 `~Copyable` 的错误,编译器会将错误显示在变量被创建的位置。这并不是很直观,也许今后会有所改善。 在上面这样的变量赋值语句中,`consume` 是可以被省略的。比如,下面的代码相互等价: ```swift let s2 = consume s1 let s2 = s1 var s3 = consume s2 var s3 = s2 ``` 不仅是普通的变量赋值,在涉及到 `if let` 这样的可选值时,`consume` 也可以被省略: ```swift let s4: S? = S() if let s = s4 { // 等价于 if let s = consume s4 s4!.foo() // 错误。s4 已经被消耗了 s.foo() // s 可以正常使用 } ``` 但是也有不可以被省略的时候。比如要是想自动将 `S` 值转为 `S?`: ```swift let s1 = S() var s5: S? s5 = s1 // 报错:Implicit conversion to 'S?' is consuming s3 = consume s1 // 正确 ``` 另外,有一些特殊情况,编译器会为我们自动按照不消耗 (consume) 而是借用 (borrowing) 的方式来使用变量,比如赋值给 `_` 或者针对 enum 进行 `switch`: ```swift let s1 = S() _ = s1 s1.foo() // OK // 但是 let _ = s1 则会消耗 enum E { case a func bar() } let e1 = E.a switch e1 { case .a: break } e1.bar() // OK ``` 如果我们想要这样的下划线赋值或者 switch 语句也按照消耗内容的方式工作的话,可以明确加上 `consume`: ```swift _ = consume s1 s1.foo() // error switch consume e1 { // ... } e1.bar() // error ``` #### 小结 不论是否明确添加 `consume`,编译器能确保对于 `~Copyable` 变量的任何使用要么是 consume 的,要么是 borrowing 的,它们**都可以确保复制不会发生**。但是对于是否确实 consume 了原来的变量,则根据情况不同会有不同的行为。实际上,整个语言中 `consume` 是不是可以忽略不写,不写的时到底代表是 consume 还是 borrowing,可以说设计得比较混乱。如果对此介意,觉得隐式的 `consume` 关键字烧脑的话,不妨在使用时把需要消耗的地方都明确写出来。这样做相比默认情况下时而省略时而必须的状况来说,可能会更加清晰。 ### 用在参数中 把 `~Copyable` 的值用在函数参数中则要明确得多,它要求我们必须从 `consuming`,`borrowing` 和 `inout` 中选择一个。 #### consuming 函数将会接管变量所有权,这相当于所有权发生转移。在函数内部,函数拥有这个输入参数的所有权:函数可以对它进行消耗(比如赋值,或者调用一次其他消耗函数等): ```swift let s = S() consumeS(s) let s1 = s // Fail,s 已经被 consumeS 消耗了。 func consumeS(_ s: consuming S) { let s1 = s // OK } ``` 要注意,即使 `consumeS` 的函数体内没有实际消耗掉 `s`,它的所有权也不会返回给调用者: ```swift let s = S() consumeS(s) let s1 = s // Fail,s 已经被 consumeS 消耗了。 func consumeS(_ s: consuming S) { // let s1 = s } ``` #### borrowing 暂时借出所有权,函数在内部可以访问 `s`,但是不能消耗它。也就是说,当参数是 borrowing 时,我们可以读取和使用它: ```swift let s = S() borrowS(s) let s1 = s // OK,borrowS 不会消耗 s func borrowS(_ s: borrowing S) { s.foo() // foo 是 S 上的非消耗函数 } ``` 但是不能将它进行 consume 赋值或者用它当作 consuming 参数来调用别的函数: ```swift func borrowS(_ s: borrowing S) { let s2 = s // 错误:借出的 s 不能被消耗 } ``` #### inout 这个关键字应该很熟悉了,原先的变量会被直接替换掉,在函数返回后,原有变量名所持有的已经是完全不同的新的值了。调用者对这个新的值拥有所有权。 ```swift var s = S() inoutS(&s) let s1 = s // OK,新的 s 在此处被消耗 func inoutS(_ s: inout S) { s = S() } ``` 当然了,在函数体内,原本的 s 的所有权发生了转移:原本值的所有权归函数所有,也会被正常消耗: ```swift var s = S() inoutS(&s) let s1 = s // OK,新的 s 在此处被消耗 func inoutS(_ s: inout S) { let s2 = s // s.foo() <- 将会报错,所有权已经交给了 s2 s = S() } ``` ### 用于方法前 另一种所有权关键字出现的地点是 `~Copyable` 类型内部的方法前:我们可以在 `func` 前加上 `consuming` 或 `borrowing`。**默认不添加关键字时,相当于 `borrowing`**: ```swift struct S: ~Copyable { consuming func consumeSelf() { } borrowing func borrowSelf() { } // 相当于 borrowing func func implictBorrowSelf() { } } ``` 在方法前添加所有权关键字,相当于为 `self` 参数添加一个关键字。举例来说,下面这两种写法和调用方式其实是等价的: ```swift struct S: ~Copyable { consuming func consumeSelf() { } } func consumeS(_ s: consuming S) { } let s1 = S() // s1 (对于 S.consumeSelf 来说,它就是 `self`) s1.consumeSelf() let s2 = s1 // 错误,s1 已经被 `consumeSelf` 消耗了 let s3 = S() consumeS(s3) let s4 = s3 // 错误,s3 已经被 `consumeS` 消耗了 ``` 只要把对方法的调用,看作是第一个参数是 `self` 的静态方法,就能将这种情况归纳到上面的“用在参数中”中了: ```swift // consuming func consumeSelf() 相当于: func consumeSelf(self: consuming S) { } // borrowing func borrowSelf() 相当于: func borrowSelf(self: borrowing S) { } // func implictBorrowSelf() 相当于: func implictBorrowSelf(self: borrowing S) { } ``` 在这些方法内部,`self` 也遵循同样的所有权行为: ```swift consuming func consumeSelf() { let s = self // 可以消耗,`self` 的所有权归函数自身 self.foo() // 错误,`self` 已经被消耗了 } borrowing func borrowSelf() { let s = self // 错误,`self` 是借入的,不能被消耗。 } ``` ### deinit 和 discard #### deinit 的时机 对普通的 Copyable 的 struct 或者 enum,编译器不允许我们添加 `deinit`。但对于 `~Copyable` 的类型,因为它在 stack 上有 alloc 行为,我们可以向其中添加 `deinit`。如果在生命周期结束时,这个 `~Copyable` 变量都没有被消耗的话,`deinit` 就将被调用: ```swift struct S: ~Copyable { deinit { print("Deinit") } func foo() { print("foo") } } func sample1() { print("sample1 start") let s = S() print("foo start") s.foo() print("foo end") print("sample1 end") } // 打印: // sample1 start // foo start // foo // foo end // sample1 end // Deinit ``` `S.deinit` 将在能够确定 `s` 的生命周期结束时调用(对应了 SIL 中的 `release_value` 指令)。在这里,由于 `S.foo` 是 borrowing 方法,`s` 将一直持有自己的所有权,直到 `sample1` 结束,因此 "Deinit" 将在 sample1 结束后才调用。 如果 `S` 中的是一个 consuming 函数,情况则不同: ```swift struct S: ~Copyable { deinit { print("Deinit") } consuming func bar() { print("bar") } } func sample2() { print("sample2 start") let s = S() print("bar start") s.bar() print("bar end") print("sample2 end") } // 打印: // sample2 start // bar start // bar // Deinit // bar end // sample2 end ``` 上例中,`bar` 被标记为 `consuming`,它获取 `self` 的所有权。即使在 `bar` 的函数体中我们没有进一步转移这个所有权,它依然被消耗了:于是 "Deinit" 将在从 `bar` 函数返回时被调用。这些行为赋予了 `~Copyable` 的 struct 或 enum 类似于 class 类型的能力。 #### discard 不过和 class 不同的是,Swift 给予了 `~Copyable` 类型放弃 `deinit` 调用的手段。我们可以在 `consuming` 方法中使用 `discard self` 来“放弃”对于 `self` 的所有权,从而让 `deinit` 不被调用。上例中,在 `bar` 中添加 `discard self`,`deinit` 就将不会再被调用: ```diff consuming func bar() { print("bar") + discard self } ``` ## 在泛型或扩展中使用 ~Copyable Swift 5.9 中尚不支持在泛型和扩展中使用 `~Copyable`,这使得 `~Copyable` 在很长时间内难堪大用。不过这一问题在 Swift 6.0 中得到了解决,这使得 `~Copyable` 已经完备。 ### 扩展边界,突破 Copyable 约束 要记住,`Copyable` 是默认存在于所有类型声明中的。也就是说,对于一个平平无奇的泛型函数: ```swift func foo(t: T) -> Void ``` 实际上,它的完整声明是: ```swift func foo(t: T) -> Void ``` 类似地,在定义协议时,`Copyable` 也是默认的: ```swift protocol P { } // 实际上,它是 protocol P: Copyable { } ``` 如果我们在使用泛型或者定义协议时,希望它们是不可复制的,我们需要明确地写出来: ```swift func bar(t: consuming T) -> Void protocol Q: ~Copyable { } ``` 然后我们就可以按照一般的方式通过 `~Copyable` 来使用它们了: ```swift struct S: ~Copyable { } bar(t: S()) extension S: Q { } ``` ### ~Copyable 的设计哲学 看起来一切都很和谐,直到有一天我们发现,似乎一般的 `Copyable` 值也可以使用这些定义。比如 `Int` 就是一个完美的可复制值: ```swift bar(t: 1) extension Int: Q { } ``` 我们来翻译翻译: > 接受 `~Copyable` 的 `bar` 函数,现在接受了一个 `Copyable` 的 Int 值。 > > 一个 `Copyable` 的 Int 类型,符合 `~Copyable` 的协议 Q。 似乎哪里不对? 如果你这么想,就说明你还没有理解 `~Copyable`。在本文开篇,我们就提到过,`~Copyable` 的重点在于: {: .alert .alert-info} 以往的协议是“为类型增加功能”,而 `~Copyable` 则是“为类型解除限制”。 我们可以把 `Copyable` 当作一个普通的协议,但是**不可以也不应该**把 `~Copyable` 也当成一个普通的协议:`~Copyable` 是不带有任何限制的全集,而 `Copyable` 仅仅是其中一个特别的子集。所以,上面的两句翻译,真正的正确的读法应该是: > 一个可以接受包括 `Copyable` 在内的任意值的函数 `bar`,现在接受了一个 `Copyable` 的 Int 值。 > > 一个已经满足了 `Copyable` 协议的类型 Int,现在又满足了一个没有什么特别限制的协议 Q。 “当执行 `bar` 或者实现 `Q` 的时候,`~Copyable` 的存在解放了原本的隐式 `Copyable` 约束条件,给了像是 `S` 这样的 `~Copyable` 的具体类型能够作为参数执行 `bar` 或者去实现 `Q` 的机会”,这才是 `~Copyable` 在泛型或协议约束中的含义。如果在处理不可复制值的泛型和扩展相关的问题时遇到烧脑的部分,按照这个思路也许会更容易捋清其中各种关系。 ## 总结 Copyable,Move-only 或者所有权的概念,不管名字怎么叫,在程序设计领域也不算新奇了。和 Rust 不同,Swift 没有强制要求我们使用和明确所有权。在默许开发者使用 `Copyable` 来完成绝大多数任务的同时,Swift 为更加精确的内存管理留出了 `~Copyable` 这个足够用的语言工具。 在需要的地方,如果不可复制特性确实能起到帮助,那使用它将会提升代码的效率和正确性。最常见的场景大致有两个: 1. 资源独占:当需要确保某个资源只能被一个持有者使用时,比如文件句柄、数据库连接、硬件设备访问等。这样可以在编译时就防止意外的资源共享。 2. 精确控制:对于使用值类型建模,但需要精确控制释放时机和生命周期的对象,比如自定义的内存管理、缓存系统等。 使用 `~Copyable` 时,需要清楚地理解什么时候发生了所有权转移,什么时候值的生命周期结束,以及用在泛型和协议时一些“违反直觉”的特性。因此,优先考虑普通的值类型,理解所有权这一工具的基本使用方式,只在确实需要独占资源或严格控制生命周期时才使用 `~Copyable`,可能会是更加平滑和正确的方式。 URL: https://onevcat.com/2024/07/swift-6/index.html.md Published At: 2024-07-30 22:00:00 +0900 # Swift 6 适配的一些体会以及对现状的小吐槽 最近对手上的两三个项目进行了 Swift 6 的迁移,整体过程并不算顺利,颇有一种梦回 Swift 3 的感觉。不过,最终还是有所收获和心得。趁着记忆还新鲜,我想稍微总结一下。此外,针对目前社区里的一些声音,以及自己这些年的感受,我会在文章后半部分对 Swift 生态进行一些不太重要的小唠叨。 ## Swift 6 迁移 Swift 6 的最大“卖点”当然是并发编程和编译时就能保证的完全线程安全,这也是在进行 Swift 6 迁移时开发者工作量最大的来源。通过引入一系列语言工具 (主要是 actor 隔离和 Sendable 标注),Swift 6 在开启完全的严格并发检查 (也就是`-strict-concurrency=complete`) 时,理想状态下可以完全确保在编译阶段就将数据竞争 (data race) 和线程问题排除掉。 关于 Swift 并发编程,我之前写过一些关于[并发初步](https://onevcat.com/2021/07/swift-concurrency/)以及[结构化并发](https://onevcat.com/2021/09/structured-concurrency/)的文章。对于 actor、Sendable 的概念及其如何确保数据竞争不再发生,我在[《Swift 异步和并发》](https://objccn.io/products/async-swift)中也有所介绍。近几年的 WWDC 上,Apple 通过[若干](https://developer.apple.com/videos/play/wwdc2021/10133/) [session](https://developer.apple.com/videos/play/wwdc2022/110351/),向开发者介绍了这些概念。这些内容并不是本文的重点,而且我会假设你已经了解了这些内容,并正打算对你的项目进行 Swift 6 的迁移。 在进行迁移时,最好的阅读材料显然是官方的 [Migrating to Swift 6 文档](https://www.swift.org/migration/documentation/migrationguide/)。如果你没有时间仔细通读所有话题,至少你应该逐条确认 [Common Compiler Errors](https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/commonproblems) 和 [Incremental Adoption](https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/incrementaladoption) 这两篇指南里的内容,只要你理解了背后的故事,它们应该可以帮你正确完成绝大部分工作。 ## 迁移体会 **结论先行:**Swift 6 的迁移并不算是乐事,但由于 Apple 明确表示 Swift 6 是可选择的,并且编译器级别依然提供对 Swift 4 及以上版本的兼容选项,所以大多数项目并没有迫切需要进行迁移。此外,可以看出无论是 Apple 官方还是社区,其实都没有完全准备好进行迁移:大量的官方框架和类型缺乏 Sendable 标记,部分使用例没有特别好的对应办法等。虽然迁移可能带来一些诱人的好处,但所需的投入成本也相当可观。因此,如果你还在犹豫是否要现在就进行迁移,我个人建议可以再等一两年。 不过,因为有这样的选择,如果不做任何改变,你会发现现写的代码在未来迁移时会变成“技术债”。因此,我推荐的方式是,如果要编写新代码,不妨将其引入新的 target 中,并开始按照 Swift 6 以及严格并发模型的写法进行书写。这样在未来需要迁移时,就不至于在新代码上也要重复工作。 **迁移体会:**如果你的项目有一定规模,当你首次将 `-strict-concurrency` 选项设置为 complete 时,必然会遇到一大堆错误。不过,这甚至还不是最让人沮丧的时刻。勾选“Continue building after errors”选项,并按编译器提示修改几处错误后,你可能会发现错误数量不仅没有减少,反而增加了。这往往是最绝望的时刻。然而,只要坚持下去,你会发现需要做的事情其实相对重复且单调。对于普通的 app 来说,主要任务无外乎以下三项: - 添加 `@MainActor` - 标记 `Sendable` - 将回调函数改写成 async,并考虑在哪里加 `Task` 作为异步入口 如果你已经准备好现在就进行迁移,下面的一些小心得可能对你有帮助。这些心得不仅是对上面提到的三个任务的解释,也是对官方指南的补充。如果你还没有决定迁移,确实可以再等一段时间:因为无论是官方还是社区,目前都缺乏实际的迁移经验,有些工具甚至还未准备就绪。在未来很长一段时间内,Swift 编译器肯定会继续提供对 Swift 5 的版本兼容。你只需对新代码进行适配,而对于老代码,我们还有足够的时间进行观察。 ## 一些小心得 ### @MainActor,后向兼容和确保 main queue View、View Controller 以及 UIKit 的其他类型都默认添加了 `@MainActor`。如果你需要在其他非 MainActor 部分的代码中调用它们,要么需要使用 `Task`,要么需要为自己的代码也添加 `@MainActor`,以确保同样的隔离域。而进一步,调用你自己的被标记为 `@MainActor` 代码的地方可能也要做出同样的选择:要么开始一个 `Task`,要么将自己添加到 Main Actor 隔离域中去。从某种意义上来说,`@MainActor` 会在项目中“传染”。 从安全角度看,这种“传染”是合理的:主线程安全可以说是 UI 应用中最重要的线程安全问题之一。但如果你准备迁移的是一个比较底层的模块,并需要为某些方法标记 `@MainActor`,这种“传染”将会立即造成问题:依赖这个模块的用户必须被迫立即做出选择,否则编译器会报错。当你无法决定其他模块的迁移计划时(在稍大一些的团队协作项目中,这种情况很常见),保留原来的方法,只是将它标记为“弃用”,并同时提供一个新的标记为 `@MainActor` 的方法,是相对现实的做法。 ```swift @available(*, deprecated, message:"Use the main actor version.") func myMethod() { // 避免重复,将原实现移到 myMethodOnMain 中 // 不过因为我们从原先的非 Main Actor 环境里调用了 Main Actor 里的方法,会编译报错 myMethodOnMain() } @MainActor func myMethodOnMain() { // ..其他被隔离在 MainActor 中的 UI 操作 } ``` 但 `myMethod` 里的调用标记为 `@MainActor` 的 `myMethodOnMain` 也是无法成功的,我们需要一些额外手段来绕开编译器的过于严格的机制。官方给出的方式是 [`MainActor.assumeIsolated`](https://developer.apple.com/documentation/swift/actor/assumeisolated(_:file:line:)): ```swift func myMethod() { MainActor.assumeIsolated { myMethodOnMain() } } ``` `assumeIsolated` 当然可以同步地给我们一个 main actor 隔离域,但是这完全依赖于开发者的判断。如果不小心从其他隔离域(或者说,main thread 以外)进行调用,那就直接 crash 了。更温柔一点的做法是使用 [`assertIsolated`](https://developer.apple.com/documentation/swift/actor/assertisolated(_:file:line:)):来让调用者在开发时得到一些提示: ```swift func myMethod() { MainActor.assertIsolated("This method is expected to be called in main thread!") // ... } ``` 然而,单靠 `assertIsolated` 仍无法解决 `myMethodOnMain` 调用的问题。在实践中,对于 Main Actor,我们可以结合这两者,并加上线程判断,写一个临时方法。这样既能在迁移过程中对非主线程的调用进行断言(assert),又能尽量保持原有代码的正常运行。例如: ```swift extension MainActor { static func runSafely(_ block: @MainActor () -> T) throws -> T { if Thread.isMainThread { return MainActor.assumeIsolated { block() } } else { MainActor.assertIsolated("This method is expected to be called in main thread!") return DispatchQueue.main.sync { MainActor.assumeIsolated { block() } } } } } ``` 不过需要特别说明,这种方式并不是特别安全。为了确保在主线程上执行并获取返回值,我们只能使用 `DispatchQueue.main.sync`,但这样实际上很容易导致死锁: ```swift DispatchQueue.global().async { try? MainActor.runSafely { DispatchQueue.main.sync { print("hello") } } } ``` 只要我们在 `runSafely` 里再次向主队列提交一个 `sync` 操作,就会导致严重的问题。如果项目中没有使用主队列的 `sync` 操作,那么这种方式可以作为过渡时期的暂行手段。但一旦迁移完成,最好尽快删除这样的代码:actor 隔离和 Dispatch queue 的隔离天然不兼容。 ### Sendable class 以及 @unchecked Sendable 对于能够轻松标记为 `Sendable` 的类型,比如只含有值类型变量的 struct 或者只含有值类型关联值的 enum,添加 `Sendable` 是无痛的。但是,对于大部分的 class,只要其中含有 var 变量,编译器就无法将它接受为 `Sendable`。在这种情况下,如果我们确实希望这个 class 类型可以跨越隔离域,我们只能在类型内部实现线程安全机制。 对于 class 内部的变量,实现线程安全最简单和直接的方式莫过于加锁。如果你的项目是从 iOS 16 开始的,那么使用 [`OSAllocatedUnfairLock`](https://developer.apple.com/documentation/os/osallocatedunfairlock) 应该是一个不错的选择。它提供的 `withLock` 闭包让开发者可以用相对安全和先进的语法操作锁的生命周期。将你的 class 中的 var 都替换成带有 `OSAllocatedUnfairLock` 的 let 后,整个 class 就可以是 Sendable 的了: ```swift-diff enum State: Sendable { case yes case no } final class A: Sendable { - var state: State + let state: OSAllocatedUnfairLock init(state: State) { - self.state = state + self.state = OSAllocatedUnfairLock(initialState: state) } func update(newState: State) { - state = newState + state.withLock { state in + state = newState + } } } ``` 如果你还需要兼容 iOS 16 之前的系统,那么可以选择其他的锁,或者是更传统的用 dispatch queue 来隔离访问: ```swift private let queue = DispatchQueue(label: "private queue") var _state: State var state: State { get { queue.sync { _state } } set { queue.sync { _state = newValue } } } ``` 但是这样的后果是,我们只能将这个类标记为 `@unchecked Sendable`。添加 `@unchecked Sendable` 并不是一件值得羞耻的事情,这相当于将以前只能写在文档中的“该类型是线程安全的”声明明确地告诉编译器。然而,这确实阻止了编译器在我们对这个类进行后续变更时进行提醒和检查。此外,基于队列的方式也可能带来一些性能问题,还需要继续观察。如果有条件,依赖加锁或者进一步尝试使用 actor,仍然会是更优的解决方案。 ### 尽量避免 Sendable 的回调 项目中 async 普及之前,一定会有大量遗留的基于 completion handler 的代码。而在适配 Swift 6 时,也经常会遇到需要将某个闭包 (closure) 标记为 `@Sendable` 的情况。对于在 escaping closure 里使用了非 Sendable 的变量的情况而言,编译器确实无法判断 closure 是否跨越了隔离域,而如果我们能人为保证这一点的话,就可以通过为闭包添加 `@Sendable` 来给编译器提示。 但是和 `@MainActor` 的情况类似,闭包的 `@Sendable` 标记也很容易在项目里"传染"。而这带来了更多的需要检查的 case,以及更多原本不必要的 `Sendable` 类型适配。为了避免这种不必要的“跨域”,一个可行方法是尽量用 async 来重写这些带回调的方法。把在不同 actor 间切换 (即 actor hopping) 的工作交给运行时 (runtime) 来解决,这样我们可以省去很多标记 `@Sendable` 闭包的额外工作。 ### deinit 问题 关于 deinit 的隔离问题,早在 2021 年 Swift 并发刚推出时就已在[社区中展开了讨论](https://forums.swift.org/t/deinit-and-mainactor/50132):当前 deinit 是无法被 actor 隔离的。因为 deinit 是一个运行时的特性,可能发生在不同线程,因此在编译器层面无法确定 `deinit` 的隔离域。这导致了一些在 Swift 5 时代时没有问题的代码 (其实严格来说,是有数据安全问题的),在 Swift 6 时代却没办法书写。在 `deinit` 中,我们一般会进行类似资源释放,如果 `deinit` 里用到了 actor 实例中的被隔离的存储属性,它就将无法被用在 `deinit` 里。[官方文档](https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/commonproblems#Non-Isolated-Deinitialization)中当前给出的方法是把需要隔离的值捕获到一个 `Task` 里: ```swift actor BackgroundStyler { private let store = StyleStore() deinit { // no actor isolation here, so none will be inherited by the task Task { [store] in await store.stopNotifications() } } } ``` 但是如果 `StyleStore` 不是 Sendable 的话,这个方法也无法绕开 `deinit` 限制。特别是如果我们想要在 `deinit` 里清理的类型不属于我们自己,而是引用了其他框架中的非 Sendable 类型时,几乎就束手无策了。 一种变通方法是利用 `withoutActuallyEscaping` 来["欺骗"编译器](https://github.com/onevcat/Kingfisher/blob/ee44579d71cf7ad21046b829aed074c0229a6ec6/Sources/Views/AnimatedImageView.swift#L485-L493),让它无视掉隔离域的检查。这虽然让可能的数据竞争延续下来了,但至少不会比原来变得更差。如果我们能确定 deinit 发生的线程的话,在运行时也将不存在风险。 然而,关于 deinit 的隔离问题,在社区争论两年之后,似乎终于快要有结论了。之前被驳回的关于 [deinit 隔离的 proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md),最近也开始了[第二次 review](https://forums.swift.org/t/second-review-se-0371-isolated-synchronous-deinit/73406)。如果一切顺利,我们也许可以在后续的 Swift 版本中看到 deinit 隔离的实现,这应该是严格并发中的一块重要拼图。 ## 关于 Swift 语言现状 从 [2014 年发布](https://onevcat.com/2014/06/my-opinion-about-swift/)第一天开始,我就开始关注和书写 Swift 代码。转眼间,Swift 已经成为我的主要编程语言十年了。回首这段历程,我经历了早期“每年学一门新语言”的适应期 (也可以说是“镇痛期”),见证了 Swift 开源和 ABI 稳定的里程碑,亲历了 Concurrency 的实现和 Swift 6 的变革。现在回看这门语言一路走来的历程和重要节点,不禁对编程语言的发展路径有了更深刻的体会。 如果要用一句话来评价现在的 Swift,我会想说,它已经复杂到和一开始的 Swift 完全不同了。这个复杂度对于新人来说已经足够困难了。数一数最近几年的新增特性,从 [Result Builder](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0289-result-builders.md) 开始,一路有 [Property Wrapper](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0258-property-wrappers.md),再到[宏](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0382-expression-macros.md)以及[并发编程](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/),甚至还有 [ownership](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0377-parameter-ownership-modifiers.md),不得不承认,Swift 团队真的非常努力:他们以令人称赞的效率为这门语言添加了许多的新特性,这些新特性也或多或少地借鉴了其他语言 (特别是 Rust) 的优秀之处。然而,当我们回顾这些特性时,会发现如果不使用 SwiftUI,这些特性大部分对于编写 app 这一 Swift 的主要任务来说其实没有发挥太大作用。绝大部分情况下,我们还是在使用 Swift 3 或者 4 就已经定型的语法来编写 app。 而在另一维度,这些复杂度不可避免地带来一些问题: - 比如对于一个大型 app 来说,几乎每个 Swift 版本的编译速度都在退化。大公司往往需要专门的编译工程师来监测和优化编译速度。 - 而对于小开发者来说,强制的数据竞争安全则时常会打断创作时的[心流状态](https://zh.wikipedia.org/zh-cn/%E5%BF%83%E6%B5%81%E7%90%86%E8%AB%96):编译错误会把开发者硬生生从创意性的工作中打断,转而去思考那些可能万年难得一见甚至根本就不存在的数据竞争问题。 - 复杂项目中 Xcode 的代码补全,SwiftUI 的预览,甚至是 LLDB 断点的速度,这些极其影响开发者体验的部分迟迟没有改善 (当然,可能这并不能怪罪 Swift。但是 Objective-C 时代确实这些问题并没有现在这样明显)。 个人的感想:Swift 现今的发展似乎并没有把绝对重点放在“帮助开发者更好更快地完成 app”上。我不清楚具体原因,但不论是团队更看重 KPI (比如一定要改造语言并匆忙发布 SwiftUI,或者是要把并发编程的饼尽早做出来),还是急于在别的领域“大显身手” (比如 Server Side 或者其他操作系统平台),我个人在 Swift 6 下写代码时,似乎并没有感觉到比 Swift 3 或者 4 时更快乐。 事实上,Apple 平台开发者面临着越来越重的学习负担。初学者不光需要学习基本语法,值类型和引用类型,以及基于 protocol 的编程思想这些基础内容,还要面临各种看不明白的宏和 Property Wrapper,最后甚至需要理解和正确使用 actor 和 Sendable 来在各个隔离域之间舞蹈并取悦编译器 (这绝对是中高级开发者才应该考虑的内容了)。十年积累如我,都略感力不从心,我很难想像刚接触 Swift 的新人在开发 app 时所面临的困难。 移动平台的原生开发日渐式微,这是所有从业者都无法逃避的市场规律和技术前提。Apple Vision Pro 设备向我们展示的新愿景似乎也还远不是人人能够负担的未来。我很好奇在 Swift 6 开启了一个绝对安全的并发先河之后,这门语言今后会何去何从,又会如何继续进化。无论如何,Swift 的未来无疑将继续影响和塑造 Apple 开发者的工作方式和应用的开发模式,让我们拭目以待。 URL: https://onevcat.com/2024/04/swift-log/index.html.md Published At: 2024-04-11 22:00:00 +0900 # SwiftLog 和 OSLog:选择、使用以及坑 如果你还在用 `NSLog` 或者 `print` 打 log,那也许这篇文章正适合你,可以帮你转型到新的 log 方式。如果你已经在使用 `OSLog` 的相关功能,那也许本文可以帮助你加深理解,以及找到一些“进阶”用法。 ## 选择:SwiftLog 和 OSLog 的区别 两者都是 Swift 中与 log 有关的框架。在进行选择时,我们的首要任务就是理清它们的区别。“SwiftLog 和 OSLog 我应该选哪个”,也是我在参加一个聚会时经常听到的问题。 ### SwiftLog 是前端 SwiftLog 首次发布于 2019 年,是一个 Swift server 主导的项目。它的目的是提供一个统一的日志记录接口,让包括服务器 app、命令行工具以及 iOS 和 macOS app 等各种使用 Swift 语言的场合下 (但主要是 server!),能使用同样的方法记录日志。SwiftLog 本身是一个 log **前端**框架,这意味着它需要搭配后端使用:例如将日志输出到控制台、文件、数据库或远程日志收集服务。SwiftLog 注重模块化,允许开发者通过更换后端来灵活调整日志记录的行为。 ### OSLog 是平台绑定的方案 OSLog 有着更长的历史,和 SwiftLog 只提供前端不同,它是一套绑定平台的完整的日志系统。OSLog 最初在 Objective-C 时代就已存在,它主要用于在 Apple 的各个平台上提供统一和高效的日志记录功能,特别强调了性能和隐私。随着 Swift 的发展,OSLog 也逐渐提供了更加 Swift 风格的 API,以减轻使用者的负担。OSLog 不仅支持日志的基本记录功能,还内置了对数据敏感性的处理、日志分类和过滤等高级功能。是更适合于一般 iOS / macOS 等 Apple 平台开发者的工具。 ### 区分使用场景 综上,如果有下面需求时,我们可以使用 SwiftLog: - **跨平台应用或者服务器开发**:对于那些不仅运行在 iOS 或 macOS,还需要在 Linux 或其他平台上运行的 Swift 应用,SwiftLog 提供了一个统一的日志记录接口,SwiftLog 搭配上合适的后端显然会成为最优先的的选择。 - **需要高度定制的日志管理**:如果你的项目需要将 log 输出到远程服务器、数据库或自定义格式的文件等,SwiftLog 的可扩展性可以满足要求。社区已经为 SwiftLog [提供了一些后端](https://github.com/apple/swift-log?tab=readme-ov-file#available-logging-backends-for-applications),你也可以通过实现自己的日志后端或利用现有的后端插件来满足这些需求。 而相对地,OSLog 更多地用在: - **专注于 Apple 平台的应用开发**:OSLog 提供统一的 log 方式,并被搭载在系统中,所以对于只在 Apple 系统中运行的应用,OSLog 是更好的选择:容易集成,性能优秀,强大的日志筛选,以及隐私保护。 - **希望尽量减少依赖**:并不是说在 Apple 平台的开发中就不能使用 SwiftLog,但是这并没有必要。诚然我们可以使用 SwiftLog 作为前端,并接一个 OSLog 作为后端,来达到类似的效果,但是这引入了对 SwiftLog 的额外依赖。对于一般 app 可能影响不大 (大概几十 KB 的 binary size 增加),但是对于框架类型的项目来说,额外的依赖往往意味着额外的复杂度。以简单即美的设计哲学来说,除非有很强的理由 (比如确实需要在非 Apple 平台运行,或者需要多个 log 接收方),否则应该尽量避免引入 SwiftLog。 ### 为什么应该避免使用 print 因为 print 根本不是为收集日志设计的: - SwiftLog 和 OSLog 都支持不同的日志级别(如 error、warning、info、debug 等)。Log 分级是最基础的功能,可以让开发者轻易地对 log 进行过滤并追踪问题。但是 print 只是简单地把字符串打印出来,出现问题时难以过滤。 - SwiftLog 和 OSLog 尽量减少了日志记录对应用性能的影响。特别是 OSLog,它与系统日志数据库紧密集成,能够有效管理日志数据,即使在大量日志输出的情况下也能保持应用性能。而频繁调用 `print` 会严重影响性能。 - SwiftLog 和 OSLog 自动记录必要的 metadata,比如进程、子系统和类别等。这使得在复杂系统中管理和检索日志成为可能,也可以让我们在设备外部通过别的工具查看和筛选日志。 - SwiftLog 和 OSLog 可以通过在输出 log 时设定敏感信息和隐私保护,确保它们不会被无意间泄漏。另外,SwiftLog 提供了可扩展性,以对应不同的 log 后端。这些都是 print 不具备的。 我们应该让 `print` 回归它的本质:一个简单的将字符串打印到标准输出的语言特性,而不应该让它参与到 log 记录或者作为 debug 工具使用。 ## 使用:OSLog 输出日志 在 Apple 平台开发,最经常接触和使用的日志系统应该还是 OSLog,因此在本文后面部分我们都会将重点放在 OSLog 上。 ### 使用实例 和把大象放进冰箱类似,使用 OSLog 输出日志只有三步: 1. 引入 `OSLog` 框架: ```swift import OSLog ``` 2. 创建 `Logger`: ```swift let logger = Logger( subsystem: "logger.onevcat.com", category: "main" ) ``` 3. 输出日志: ```swift logger.info("This is an info") logger.warning("Ummm...seems not that good...") logger.fault("Something really BAD happens!!") ``` 在 Xcode 的 Console 中,我们可以看到类似这样的效果: ![](/assets/images/2024/log-in-xcode.png) 打印出的 log 不仅包含了设定的信息,也包括了重要的 meta data,比如进程名 `OSLogSample` (不同于主 app 的 extension 的进程,或者是一些系统进程,也会打印日志。可以靠这个进程名进行筛选),子系统 (subsystem) 名 `logger.onevcat.com` (一般使用“逆域名”的形式,代表进程中的子系统),以及类别名 `main` 都可以显示出来。在 Console 的搜索栏内,我们也可以指定各类过滤器,通过 log 级别,子系统等进行查找,从而快速定位所需要的 log。 另外,当光标悬停在某一条输出时,在右下角还会显示这条输出对应的代码的位置,点击即可在编辑器中跳转到对应位置,十分方便。 如果你的输出看起来比这个简单得多,可以检查一下 Xcode Console 左下角的设置中,是否打开了对应的显示选项: ![](/assets/images/2024/log-metadata.png) ### 使用 Console.app 确认和过滤 log 使用 `print` 时,我们只能在连上 Xcode 调试期间用 Xcode 自带的 Console 来确认打印出的 log。使用 OSLog 则可以让我们在不依赖 Xcode 时,用系统的 Console.app 程序也可以看到打印出的内容。当我们在使用企业版或TestFlight进行测试分发等没有条件进行 debug 的情况下,OSLog 就非常实用了。 默认情况下 Console.app 可能只能输出警告和错误这样等级较高的日志,如果你希望让 [`Logger.info(_:)`](https://developer.apple.com/documentation/os/logger/3551618-info) 和 [`Logger.debug(_:)`](https://developer.apple.com/documentation/os/logger/3551615-debug) 这样的“低级别”日志也显示出来,你需要在 Action 菜单中勾选对应的选项。 ![](/assets/images/2024/log-lowlevel.png) > 在中文系统里,这两个选项被翻译成了“包括简介信息”和“包括调试信息”,虽然翻译没错,但是很难第一时间和它们实际所做的事情关联起来。 Console.app 的搜索框也十分强大,它也可以用来像 Xcode 的 console 搜索那样按照日志的 process 或者 subsystem 等信息进行过滤。比如在英文系统下,使用 `s:logger.onevcat.com` 就可以过滤对应 subsystem 的日志。 关于 Console.app 的搜索,有非常多的“隐藏小技巧”,比如组合多个 query,query 取非,使用简写,保存特定搜索以方便之后重用等。如果感兴趣,我建议您可以查看 Apple 提供的[帮助文档](https://support.apple.com/en-my/guide/console/cnslbf30b61a/1.1/mac/14.0)以及[属性简写](https://support.apple.com/en-my/guide/console/cnsl707fe51a/mac),来帮助你提高效率。 > 上述两个文档也提供了中文版本的翻译。如果你使用中文版的系统,你应该参考的是中文版的[属性简写](https://support.apple.com/zh-cn/guide/console/cnsl707fe51a/mac)文档:相较于英文版使用 `p` 代表 process,`s` 代表 subsystem,中文版要使用的是 `进` (代表进程) 和 `子` (代表子系统),使用 `p` 或 `s` 的话都会被识别为 “Any”。 > > 这种本地化的简化方式个人并不喜欢,在使用非英文系统时,我会选择在系统设置中的 通用 -> 语言与地区 -> 应用程序 选项中,把“控制台.app”设定为英文,以达到在不同语言环境下的统一。 ### 代码读取 #### 直接在 iOS 上读取当前进程 除了在 Xcode console 或者 Console.app 中确认外,我们还可以通过使用 `OSLogStore` 来读取这些日志。在 iOS 系统中,我们可以读取当前进程的日志信息: ```swift let store = try OSLogStore(scope: .currentProcessIdentifier) let predicate = NSPredicate(format: "subsystem == 'logger.onevcat.com'") let logs = try store.getEntries(matching: predicate) ``` 在获取 `logs` 时,我们使用 `NSPredicate` 来对海量 log 进行匹配。注意,我们应该直接使用 `getEntries` 的 `matching` 来获取所需要的日志;先获取所有 log,然后再使用标准库中 `Array.filter` 的方法一般在性能上是不可行的。 通过在终端中输入 `log help predicates`,可以确认 `NSPredicate` 构建查询条件中能接受的关键字和它们的类型。它会给出类似这样的结果: ``` $ log help predicates valid predicate fields: ... category (string) composedMessage (string) ... logType (log type) ... subsystem (string) ``` 一些简单和常用的 predicate 例子: ```swift NSPredicate(format: "subsystem == 'logger.onevcat.com'") NSPredicate(format: "composedMessage CONTAINS 'BAD'") NSPredicate(format: "category == 'main'") NSPredicate(format: "logType >= error") NSPredicate(format: "subsystem == 'logger.onevcat.com' AND logType >= error") ``` 如果你对 `NSPredicate` 不熟悉,想要了解更多,可以参考[这个](https://academy.realm.io/posts/nspredicate-cheatsheet/)总结得很好的 cheatsheet。 我们使用 `Logger` 打印出来的日志信息都是 `OSLogEntryLog` 类型,不过 `getEntries` 可能会包含其他更多类型的日志 (比如用于测量性能的 [`OSSignposter`](https://developer.apple.com/documentation/os/ossignposter) 等)。想要获取我们使用 `Logger` 的一般方式所打印的那些日志,可以进行类型转换: ```swift for item in logs { guard let log = item as? OSLogEntryLog else { continue } print("[\(log.subsystem)]: \(log.level) \(log.composedMessage)") } ``` #### 导出 iOS 上的 log 并读取 除了上面看到的 `OSLogStore(scope: .currentProcessIdentifier)` 以外,`OSLogStore` 还提供了另一个初始化方法,它接受一个 url:`OSLogStore.init(url:)`。如果我们能给出一个指向有效 `.logarchive` bundle 的 URL,我们就可以用同样的 API 读取日志内容。 但不幸的是,当前 iOS 并没有提供直接把日志导出为 logarchive 的能力。我们只能通过将设备连接到一台 mac 上,然后使用类似下面的命令来将一段时间内的日志导出: ``` sudo log collect --device-name iPhone --last 1m --output logs.logarchive ``` 导出后,我们便可以直接使用 Console.app 来打开并确认内容了。当然,有需要的话,我们也可以在 macOS 或者甚至 iOS 设备上使用 `OSLogStore.init(url:)` 来打开这个文件,并用代码读取日志内容。 ## 坑:OSLog 的一些潜在问题 截止至本文发布时 (2024-04-09),OSLog 及其周边的配套设施或多或少存在一些问题。粗看起来,有一些是明显的 bug,应该会随着工具链的迭代逐渐得到修复;有些的话则是设计上的妥协甚至是“有意为之”。我们姑且都在这里列举一下。 #### 框架中的 log 调用无法定位文件和行数 如前所述,在 Xcode console 中查看 log 时,将光标悬停在某个 log 上,该行右下角将会显示类似 "ViewController.swift 21:16" 这样的按钮,表明这条日志的输出来源。点击这个按钮,则会在编辑器中打开对应位置。 ![](/assets/images/2024/log-in-xcode.png) 然而,如果这些 log 是由某个 package 或者 framework 输出的,即便我们有完整的调试信息甚至是源代码,在 log 过程中这些元数据也会丢失。比如,当我们创建一个本地 Swift Package 或 framework target,并在其中进行一些 log: ```swift // In Framework A public func hello() { let logger = Logger(subsystem: "com.onevcat.frameworkA", category: "main") logger.info("A message from framework") } // In main app bundle import FrameworkA hello() ``` 虽然 "A message from framework" 能被打印出来,但是 Call Site 的符号信息和文件行数等都会丢失,你将无法直接定位到 Framework A 中的相关代码。这大大降低了 OSLog 在大型项目中的实用性。 #### OSLogStore 的选项和定位功能缺失 在 iOS 中使用 [`OSLogStore.getEntries(with:at:matching:)`](https://developer.apple.com/documentation/oslog/oslogstore/getentries(with:at:matching:)) 时,获取 log 时本来可以指定一些选项,比如用 [`.reverse`](https://developer.apple.com/documentation/oslog/oslogenumerator/options/reverse) 来让 log 以逆序被检索,或者使用 [`OSLogPosition`](https://developer.apple.com/documentation/oslog/oslogposition) 来指定获取 log 的起始范围。但是这些选项在相当都不起作用。甚至在 macOS 时,如果指定了 `.reverse`,则可能什么 log 都获取不到。 这些问题在 radar://87622922 和 radar://87416514 中进行了追踪,但是至今还没有看到修复的迹象。 #### "捕获" self 的问题 `Logger` 的输出日志的各个方法 (`Logger.info(_:)`, `Logger.error(_:)` 等),接受的都是一个 `OSLogMessage` 类型的值。文档指出,我们不应该手动创建一个 `OSLogMessage` 值,而是应该尽量使用插值的方式,把生成工作交给 Logging 框架。 > You don’t create instances of OSLogMessage directly. Instead, the system creates them for you when writing messages to the unified logging system using a Logger. `OSLogMessage` 在插值时实现了 `OSLogInterpolation`,后者负责处理日志输出时和普通字符串插值不同的独有特性,比如隐私 mask、数字或日期格式等。在最终调用插值时,它会将参数转换到[一个 `@escaping` closure](https://github.com/apple/swift/blob/4b440a1d80a0900b6121b6e4a15fff2a96263bc5/stdlib/private/OSLog/OSLogStringTypes.swift#L129): ```swift internal mutating func append(_ value: @escaping () -> String) { // ... } ``` 这意味着,虽然在 `Logger` 使用时表面上并没有 closure,但实际上我们需要显式地将 `self` 写出来: ```swift class A { var value = 100 func hello() { let logger = Logger( subsystem: "logger.onevcat.com", category: "main" ) // 如果没有 self,报错。 // Reference to property 'value' in closure requires explicit use of 'self' to make capture semantics explicit. logger.info("Test value: \(self.value)") } } ``` 这在大部分时候不构成问题,因为 logger 方法会马上结束,并且将 `self` 的引用计数减一。但是如果我们有一些异步代码时,这些 logger 语句就可能会在不经意间影响生命周期。比如: ```swift-diff class A { var value = 100 func hello() { let logger = Logger( subsystem: "logger.onevcat.com", category: "main" ) + longLastingWorkShouldNotRetainSelf { logger.info("Test value: \(self.value)") + } } } ``` 上例中,简单地把 logger 移动到一个异步方法中时,由于 `self` 已经存在于原来的代码里了,因此这样的移动不会导致编译器再次报错,我们也就少了一次注意到 `self` 存在持有问题的机会。如果疏忽,将直接导致外界调用 `A.hello()` 时改变原来预想的生命周期。在处理涉及到 `self` 的 `Logger`,特别是在闭包里时,需要对是否应该持有 `self` 进行额外的思考。如果没有必要,可以用 `[weak self]` 的方式避免持有。 URL: https://onevcat.com/2023/08/observation-framework/index.html.md Published At: 2023-08-07 09:15:00 +0900 # 深入理解 Observation - 原理,back porting 和性能 SwiftUI 遵循 Single Source of Truth 的原则,只有修改 View 所订阅的状态,才能改变 view tree 并触发对 body 的重新求值,进而刷新 UI。最初发布时,SwiftUI 提供了 `@State`、`@ObservedObject` 和 `@EnvironmentObject` 等属性包装器进行状态管理。在 iOS 14 中,Apple 添加了 `@StateObject`,它补全了 `View` 中持有引用类型实例的情况,使得 SwiftUI 的状态管理更加完善。 在订阅引用类型时,`ObservableObject` 扮演着 Model 类型的角色,但它存在一个严重的问题,即无法提供属性粒度的订阅。在 SwiftUI 的 View 中,对 `ObservableObject` 的订阅是基于整个实例的。只要 `ObservableObject` 上的任何一个 `@Published` 属性发生改变,都会触发整个实例的 `objectWillChange` 发布者发出变化,进而导致所有订阅了这个对象的 View 进行重新求值。在复杂的 SwiftUI 应用中,这可能会导致严重的性能问题,并且阻碍程序的可扩展性。因此,使用者需要精心设计数据模型,以避免大规模的性能退化。 在 WWDC 23 中,Apple 推出了全新的 Observation 框架,旨在解决 SwiftUI 上的状态管理混乱和性能问题。这个框架的工作方式看似非常神奇,甚至无需特别声明,就能在 View 中实现属性粒度的订阅,从而避免不必要的刷新。本篇文章将深入探讨背后的原理,帮助您: - 理解 Observation 框架的实质和实现机制 - 比较其与之前解决方案的优势所在 - 介绍一种把 Observation 前向兼容到 iOS 14 的方式 - 探讨在处理 SwiftUI 状态管理时的一些权衡与考虑 通过阅读本文,您将对 SwiftUI 中的新 Observation 框架有更清晰的认识,了解它为开发者带来的好处,并掌握在实际应用中做出明智选择的能力。 我们先来看看 Observation 做了些什么吧。 ## Observation 框架的工作方式 Observation 的使用非常简单,您只需要在模型类的声明前加上 `@Observable` 标记,就可以轻松地在 View 中使用了:一旦模型类实例的存储属性或计算属性发生变化,`View` 的 `body` 就会自动重新求值,并刷新 UI。 ```swift import Observation @Observable final class Chat { var message: String var alreadyRead: Bool init(message: String, alreadyRead: Bool) { self.message = message self.alreadyRead = alreadyRead } } var chat = Chat(message: "Sample Message", alreadyRead: false) struct ContentView: View { var body: some View { let _ = Self._printChanges() Label("Message", systemImage: chat.alreadyRead ? "envelope.open" : "envelope" ) Button("Read") { chat.alreadyRead = true } } } ``` > 虽然大多数情况下我们更倾向于使用 struct 来表示数据模型,但是 @Observable 只能用在 class 类型上。这是因为对于可变的内部状态,我们只能在引用类型的稳定实例上进行状态监测才有意义。 初次接触时,`@Observable` 的确有点像魔法:我们无需声明 chat 和 ContentView 之间的任何关系,只需在 `View.body` 中访问 `alreadyRead` 属性,就自动完成了订阅。关于 `@Observable` 在 SwiftUI 中的具体使用以及从 `ObservableObject` 迁移到 `@Observable` 的内容,WWDC 23 的 [Discover Observation in SwiftUI session](https://developer.apple.com/videos/play/wwdc2023/10149/) 提供了详细解释。我们建议您观看相关视频,深入了解这一新特性的使用方法和优势。 ### Observable 宏,宏的展开 `@Observable` 虽然看起来和其他属性包装器有些相似,但是它实际上是 Swift 5.9 引入的宏。想要理解它背后做了什么,我们可以展开这个宏: ![](/assets/images/2023/expand-macro.png) ```swift @Observable final class Chat { @ObservationTracked var message: String @ObservationTracked var alreadyRead: Bool @ObservationIgnored private var _message: String @ObservationIgnored private var _alreadyRead: Bool @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar() internal nonisolated func access( keyPath: KeyPath ) { _$observationRegistrar.access(self, keyPath: keyPath) } internal nonisolated func withMutation( keyPath: KeyPath, _ mutation: () throws -> T ) rethrows -> T { try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation) } } extension Chat: Observation.Observable { } ``` `@Observable` 宏主要完成以下三件事情: 1. 为所有的存储属性添加 `@ObservationTracked`,`@ObservationTracked` 也是一个宏,它会进一步展开,并将原来的存储属性转换为计算属性。同时,对于每个被转换的存储属性,`@Observable` 宏会为其添加一个带有下划线的新的存储属性。 2. 添加与 `ObservationRegistrar` 相关的内容,包括一个 `_$observationRegistrar` 实例,以及 `access` 和 `withMutation` 两个辅助方法。这两个方法接受 `Chat` 的 `KeyPath`,并将这些信息转发给 registrar 的相关方法。 3. 使 `Chat` 遵循 `Observation.Observable` 协议。该协议现在没有任何要求的方法,它只作为编译辅助。 `@ObservationTracked` 宏还可以进一步展开。以 `message` 为例,它的展开结果如下: ```swift var message: String { init(initialValue) initializes (_message) { _message = initialValue } get { access(keyPath: \.message) return _message } set { withMutation(keyPath: \.message) { _message = newValue } } } ``` 1. `init(initialValue)` 是 Swift 5.9 中专门添加的新特性,称为 [Init Accessors](https://github.com/apple/swift-evolution/blob/main/proposals/0400-init-accessors.md),它为计算属性添加 getter 和 setter 以外的第三种访问方式,`init`。由于宏无法改写已有的 `Chat` 初始化方法的实现,因此它为 `Chat.init` 提供了一种访问计算属性的途径,允许我们在初始化方法中调用计算属性的这个 init 声明,来为新生成的背后的存储属性 `_message` 进行初始化。 2. `@ObservationTracked` 将 `message` 转换为计算属性,并为其添加了 getter 和 setter。通过调用前面提到的 `access` 和 `withMutation` 方法,`@ObservationTracked` 将属性的读取和写入与 registrar 关联在一起,实现了对属性的监测和追踪。 由此,关于 Observation 框架在 SwiftUI 中的运作机制,我们可以得到如下大致图景:在 `View` 的 `body` 中,通过 getter 访问实例上的属性时,Observation Registrar 会记录这次访问,并为当前 `View` 注册一个能够刷新自身的方法;而当通过 setter 修改属性的值时,Registrar 会从记录中找到对应的刷新方法并执行,进而触发 View 的重新求值和刷新。 这种机制使得 SwiftUI 能够精确地追踪每个属性的变化,避免不必要的刷新,从而提高应用程序的性能和响应性。 ### ObservationRegistrar 和 withObservationTracking ![](/assets/images/2023/access-tracking-keypath.png) 可能你已经注意到了,`ObservationRegistrar` 中的 `access` 方法具有如下签名: ```swift func access( _ subject: Subject, keyPath: KeyPath ) where Subject : Observable ``` 在这个方法里,我们可以获取到 model 类型的实例本身以及访问所涉及的 `KeyPath`。但是,仅凭这些信息,我们无法获取到关于调用者 (也就是 `View`) 的信息,也就不可能在属性变更时完成刷新。中间一定还缺少了一些东西。 Observation 框架中存在一个全局函数,`withObservationTracking`: ```swift func withObservationTracking( _ apply: () -> T, onChange: @autoclosure () -> () -> Void ) -> T ``` 它接受两个闭包:在第一个 `apply` 闭包中所访问的 `Observable` 实例的变量将被观察;对于这些属性的任何变化,都将**触发一次且仅一次** `onChange` 闭包的调用。举例来说: ```swift let chat = Chat(message: "Sample message", alreadyRead: false) withObservationTracking { let _ = chat.alreadyRead } onChange: { print("On Changed: \(chat.message) | \(chat.alreadyRead)") } chat.message = "Some text" // 没有输出 chat.alreadyRead = true // 打印: On Changed: Some text | false chat.alreadyRead = false // 没有输出 ``` 上面的示例中,有几点值得注意: 1. 由于在 `apply` 中,我们只访问了 `alreadyRead` 属性,因此在设置 `chat.message` 时,`onChange` 并没有被触发。这个属性并没有被添加到访问追踪里。 2. 当我们设置 `chat.alreadyRead = true` 时,`onChange` 被调用。不过这时所获取的 `alreadyRead` 依然是 `false`。`onChange` 将在属性的 `willSet` 时发生。也就是说,在这个闭包中,我们无法获取到新值。 3. 再次改变 `alreadyRead` 的值,不会再次触发 `onChange`。相关的观察在第一次触发时都被移除了。 `withObservationTracking` 扮演了重要的桥梁角色,在 SwiftUI 的 `View.body` 对 model 属性的观察中,它把两者联系了起来。 ![](/assets/images/2023/withObservationTracking.png) 注意到观察只触发一次的事实,假设 SwiftUI 中有个 `renderUI` 的方法来重新对 `body` 求值,则我们可以把整个流程简化地看作是递归调用: ```swift var chat: Chat //... func renderUI() -> some View { withObservationTracking { VStack { Label("Message", systemImage: chat.alreadyRead ? "envelope.open" : "envelope") Button("Read") { chat.alreadyRead = true } } } onChange: { DispatchQueue.main.async { self.renderUI() } } } ``` > 当然,实际上在 `onChange` 中,SwiftUI 仅只是把涉及到的 view 标记为 dirty,并统一在下一个 main runloop 进行重新绘制。在这里我们简化了这个过程。 ## 实现细节 除去 SwiftUI 的相关部分,好消息是我们并不需要对 Observation 框架的实现进行任何猜测,因为它作为 Swift 项目的一部分开源了,你可以在这里找到[该框架的所有源码](https://github.com/apple/swift/tree/main/stdlib/public/Observation)。框架的实现非常简洁直接,也很巧妙。虽然整体和我们的假设十分类似,但在具体实现中,还是有一些值得注意的细节。 ### 访问追踪 `withObservationTracking` 是一个全局函数,它提供了一个通用的 `apply` 闭包。全局函数本身没有对特定 registrar 的引用,因此要将 `onChange` 与 registrar 关联起来,必然需要利用一个全局变量来暂时保存 registrar (或者说其中所保存的 keypath) 和 `onChange` 闭包之间的关联。 在 Observation 框架的实现中,这是通过一个自定义的 `_ThreadLocal` 结构体来将 access list 保存在线程中的一个本地值来实现的。多个不同的 `withObservationTracking` 调用可以同时追踪多个不同的 `Observable` 对象上的属性,每个追踪对应一个 registrar。然而,所有的追踪都共享同一个 access list。 你可以将 access list 想象成一个字典,其中以对象的 `ObjectIdentifier` 为 key,而 value 则包含了这个对象上的 registrar 和访问到的 KeyPath。通过这些信息,我们最终能够找到 `onChange`,并执行我们想要的代码。 ```swift struct _AccessList { internal var entries = [ObjectIdentifier : Entry]() // ... } struct Entry { let context: ObservationRegistrar.Context var properties: Set // ... } struct ObservationRegistrar { internal struct Context { var lookups = [AnyKeyPath : Set]() var observations = [Int : () -> () /* content of onChange */ ]() // ... } } ``` > 上面的代码只是示意,为了方便理解,进行了简化和部分修改。 ### 线程安全 通过 `Observable` 属性中的 setter 进行的赋值,会通过 registrar 的 `withMutation` 方法,在全局 access list 和 registrar 中获取到观察了该对象上对应属性 keypath 的 `onChange` 方法。在建立观察关系 (也就是调用 `withObservationTracking`) 时,Observation 框架的内部实现使用了一个互斥锁来确保线程安全。因此,我们可以在任意线程安全地使用 `withObservationTracking`,而不必担心数据竞争的问题。 在观察触发时,对 observations 的调用没有进行额外的线程处理。`onChange` 将会在首个被观察的属性设置所发生的线程上进行调用。因此,如果我们希望在 `onChange` 中进行一些与线程安全有关的处理,需要注意调用发生的线程。在 SwiftUI 中,这大概率不是问题,因为对于 `View.body` 的重新求值会被“汇总”到主线程中进行。但是如果我们在 SwiftUI 之外的环境中单独使用 `withObservationTracking`,并且希望在 `onChange` 中刷新 UI,那么最好对当前线程进行一些判断,以确保安全性。 ### 观察时机 Observation 框架当前的实现选择了在值 `willSet` 的时候对所有被观察的变更以“仅调用一次”的方式调用 `onChange`。这让我们产生联想,Observation 是否可以做到以下事情: 1. 在 `didSet` 时,而非 `willSet` 时进行调用。 2. 保持观察者的状态,在每次 `Observable` 属性发生变化时都进行调用。 在当前实现中,追踪观察所使用的 `Id` 具有如下定义: ```swift enum Id { case willSet(Int) case didSet(Int) case full(Int, Int) } ``` 当前的实现已经考虑了 `didSet` 的情况,并且也有相应的实现,但是为 `didSet` 添加观察的接口没有暴露出来。目前,Observation 主要是与 SwiftUI 协作,因此 `willSet` 是首先被考虑的。未来,如果有需要,相信 `didSet` 以及设置属性前后都进行通知的 `.full` 模式也可以很容易地实现。 对于第二点,Observation 框架没有提供相关选项,也没有对应代码。不过,因为每个注册观察闭包都使用各自的 Id 进行管理,因此提供选项让用户可以进行长期观察,应该也是可以实现的。 ## 利弊权衡 ### 后向兼容和技术债 Observation 要求的 deploy target 为 iOS 17,在短期内对于大多数 app 来说这是难以达到的。于是开发者们面临巨大的困境:明明有更好更高效的方式,但却要在两三年后才能使用,而这期间所写的每一行传统方式的代码,都将在未来成为需要偿还的技术债,这是很令人沮丧的。 在技术层面来说,想要把 Observation 框架的内容进行前向兼容 (back-porting),让它能跑在以前的系统版本,并没有什么难度。笔者也在[这个 repo](https://github.com/onevcat/ObservationBP) 进行了尝试和概念验证及[官方实现的同款测试](https://github.com/onevcat/ObservationBP/blob/master/ObservationBPTests/ObservationBPTests.swift),把 Observation 的所有 API back port 到了 iOS 14。利用这个仓库的内容,只要导入 `ObservationBP`,我们可以以完全相同的方式使用这个框架,来缓解技术债的问题: ```swift import ObservationBP @Observable fileprivate class Person { init(name: String, age: Int) { self.name = name self.age = age } var name: String = "" var age: Int = 0 } let p = Person(name: "Tom", age: 12) withObservationTracking { _ = p.name } onChange: { print("Changed!") } ``` 在将来有机会把最低版本升级到 iOS 17 后,可以简单地把 `import ObservationBP` 替换为 `import Observation`,就能无缝切换到 Apple 的官方版本。 事实上我们并没有太多单独使用 Observation 框架的理由,它总是和 SwiftUI 搭配使用的。确实,我们可以[提供一层包装](https://github.com/onevcat/ObservationBP/blob/master/ObservationBP/SwiftUI/ObservationView.swift),来让我们的 SwiftUI 代码也能利用这个 back-porting 的实现: ```swift import ObservationBP public struct ObservationView: View { @State private var token: Int = 0 private let content: () -> Content public init(@ViewBuilder _ content: @escaping () -> Content) { self.content = content } public var body: some View { _ = token return withObservationTracking { content() } onChange: { token += 1 } } } ``` 如我们在上面说到的那样,在 `withObservationTracking` 的 `onChange` 中,我们需要一种方式,来重建 access list。这里我们在 `body` 里访问了 `token`,来让 `onChange` 再次触发 `body`,它会重新调用 `content()` 进行求值,建立新的观察关系。 使用上,只需要把带有观察需求的 `View` 包裹到 `ObservationView`: ```swift var body: some View { ObservationView { VStack { Text(person.name) Text("\(person.age)") HStack { Button("+") { person.age += 1 } Button("-") { person.age -= 1 } } } .padding() } } ``` 在当前条件下,我们不可能做到 SwiftUI 5.0 那样透明和无缝利用 Observation。这大概也是 Apple 选择把 Observation 框架作为 Swift 5.9 标准库的一部分而非单独的 package 的原因:和新系统绑定的新版本的 SwiftUI 依赖这个框架,因此选择让框架也和新版本系统进行绑定。 ### 不同的观察方式 到目前为止,在 iOS 开发中我们已经有不少观察的手段了。Observation 是不是可以替代掉它们呢? #### 对比 KVO KVO 是很常见的观察手段,在不少 UIKit 代码中,都存在着使用 KVO 进行观察的模式。KVO 要求被观察的属性具有 `dynamic` 标记,对于 UIKit 中基于 Objective-C 的属性来说这很容易满足,但是对于驱动 view 的模型类型来说,为每一个属性都添加 `dynamic` 则相对困难,也会带来额外的开销。 Observation 框架可以解决这部分问题,为一个属性添加 setter 和 getter,要比将整个属性转换为 `dynamic` 更轻量,特别是在 Swift 宏的帮助下,开发者们肯定更乐意使用 Observation。但是,当前 Observation 只支持单次订阅和 `willSet` 回调,在需要长期观察的场合,这种方法显然很难完全替代 KVO。 我们期待看到 Observation 支持更多选项,届时就可以进一步评估使用它来替代 KVO 的可能性。 #### 对比 Combine 在使用 Observation 框架后,我们已经找不到理由继续使用 Combine 中的 `ObservableObject`,因此在 SwiftUI 中对应的 `@ObservedObject`,`@StateObject` 和 `@EnvironmentObject` 理论上也不再被需要了。随着 SwiftUI 彻底摆脱 Combine,在 iOS 17 之后 Observation 框架可以完全取代 Combine 在绑定状态和 view 方面的工作。 但是 Combine 有着其他很多方面的使用案例,它的强项在于合并多个事件流并对它们进行变形。这和 Observation 框架要做的事情并不在同一个赛道。在决定要使用哪个框架时,我们还是应该根据需求,选取合适的工具。 ### 性能 相比传统的基于实例整体进行观察的 `ObservableObject` 的 model 类型,使用 `@Observable` 进行属性粒度的观察,天然地能减少 `View.body` 重新求值的次数,这是因为对实例上属性的访问始终都会是对实例本身访问的子集。由于在 `@Observable` 中,单纯的对实例的访问不会触发重新求值,因此一些曾经的性能“优化方式”,比如尽量将 View 的 model 进行细粒度拆分,可能不再是最优方案。 举个例子,在使用 `ObservableObject` 时,如果我们的 Model 类型是: ```swift final class Person: ObservableObject { @Published var name: String @Published var age: Int init(name: String, age: Int) { self.name = name self.age = age } } ``` 我们曾经会更倾向于这样做: ```swift struct ContentView: View { @StateObject private var person = Person(name: "Tom", age: 12) var body: some View { NameView(name: person.name) AgeView(age: person.age) } } struct NameView: View { let name: String var body: some View { Text(name) } } struct AgeView: View { let age: Int var body: some View { Text("\(age)") } } ``` 这样,在 `person.age` 变动时,只需要刷新 `ContentView` 和 `AgeView`。 但是,在使用 `@Observable` 后: ```swift @Observable final class Person { var name: String var age: Int init(name: String, age: Int) { self.name = name self.age = age } } ``` 则是直接把 `person` 向下传递会更高效: ```swift struct ContentView: View { private var person = Person(name: "Tom", age: 12) var body: some View { PersonNameView(person: person) PersonAgeView(person: person) } } struct PersonNameView: View { let person: Person var body: some View { Text(person.name) } } struct PersonAgeView: View { let person: Person var body: some View { Text("\(person.age)") } } ``` 在这个小例子中,在 `person.age` 变动时,只有 `PersonAgeView` 需要刷新。当这类优化积少成多时,在大规模 app 中所能带来的性能提升将是可观的。 不过相对于原来的方式,`@Observable` 驱动的 `View` 在每次重新求值后,都要重新建立 access list 和观察关系。如果某一个属性被太多的 `View` 观察,那么这个重建时间也将会随之大幅提升。这具体会带来多少影响,还需要进一步的评估和听取社区反馈意见。 ## 总结 1. 从 iOS 17 开始,使用 Observation 框架和 `@Observable` 宏将会是 SwiftUI 进行状态管理的最佳方式。它们不仅提供了简洁的语法,也带来了性能的提升。 2. Observation 框架可以单独使用,通过宏来改写属性的 setter 和 getter,并使用一个 access tracking list 完成单次的 `willSet` 观察。不过,由于目前 Observation 框架所暴露的选项有限,它的使用场景主要集中在 SwiftUI 内部,在 SwiftUI 之外的使用场景相对较少。 3. 虽然当前只支持 `willSet`,但 `didSet` 和 `full` 的支持已经实现,仅仅只是没有将接口暴露出来。所以未来某一天 Observation 支持其他属性设置时机的观察,并不足为奇。 4. 将 Observation 框架 back port 到早期版本并不存在技术上的困难,但是由于开发者难以提供透明的 SwiftUI wrapper,这使得将其应用于旧版本的 SwiftUI 有一定挑战。同时,考虑到 SwiftUI 是该框架的主要用户,并且与系统版本绑定,因此 Observation 框架也被设计为与系统版本绑定的特性。 5. 使用新的框架写法会带来新的性能优化实践,深入理解 Observation 的原理将有助于我们编写性能更加优秀的 SwiftUI app。 ### 参考链接 - [WWDC 23 - Discover Observation in SwiftUI](https://developer.apple.com/videos/play/wwdc2023/10149/) - [Observation 官方文档](https://developer.apple.com/documentation/observation) - [Swift 标准库中的 Observation 源码](https://github.com/apple/swift/tree/main/stdlib/public/Observation) - [Observation 前向兼容,概念验证](https://github.com/onevcat/ObservationBP) URL: https://onevcat.com/2023/04/dev-talk-testing/index.html.md Published At: 2023-04-06 22:15:00 +0900 # 一些关于开发的杂谈话题 - 测试 最近接手了一些陈旧项目的维护工作,需要把一部分质量很烂的代码进行重构甚至重写。在这个过程期间,我也有机会对一些开发中比较重要的而且通用的知识进行了一点重新的思考和整理,在这里想把它们用个两三篇文章,以杂谈的方式记录一下。这些内容在我刚入门程序开发的时候困扰过我一段时间,所以虽然可能对于已经有多年经验的大佬们用处不大,但是希望新入行的同学们能通过这些话题得到一些启发,如果能减少走弯路的时间,那就更好了。 今天的第一个话题是有关测试的。在以前,我也写过一些[关于测试的文章](https://onevcat.com/tags/测试/),不过更多的还是对某个特定框架的使用。我自己本身也在很长一段时间内保持了给包括框架和 app 写测试的习惯,并来回倒腾过不少不同风格的测试。在这篇短文里,我想对一些基本的问题和想法的变化进行解释。 ## 为什么要写测试?你会给项目和代码写测试吗? 这是一个每次我去参加各种技术分享会,在结束后的自由交流环节经常会被问到的问题。 我很理解由于工期紧张、需求变动频繁等原因,导致的对测试有意无意的忽视。但在这里,我还是想给出一个关于写测试的理由的答案。如果整篇文章只有一句话值得被记住,那就是: {: .alert .alert-info} 合理的测试保证了开发者的生活品质不受工作带来的负面影响。 乍一听似乎有点无厘头,测试所额外耗费的时间难道没有对生活带来影响吗?但是这确实是一句实话:测试给我带来的最大收益,就是对于自己代码质量的信心。保证测试的通过,也就是保证了代码的最低限正确行为。时间流逝,在代码不断变化的过程中,曾经正确且明显的部分,会随着项目复杂度的上升,与各部件逐渐耦合;甚至由于外部依赖的变化,而变得无法理解和修改。在每次提交时都能保证持续运行的测试,则是对抗这种“磨损”的最佳手段。 我会给我接手的代码编写测试,但是要强调,测试的目标绝不是尽可能高的覆盖率,而是**在测试能带来的代码信心和测试所需要消耗的时间精力之间寻找平衡**。 ### 测试些什么 具体来说,我会对这些内容进行测试: - 关于 Model 的测试,特别是涉及到算法或者逻辑的部分。比如各类排序,日期时间的解析,数据模型的编解码,状态发生器的运算等。 - 可能随着外部条件变化而改变,但不加以特定场景就难以重现或发现的部分。比如大量线程同时操作某个对象,特殊的输入导致的 edge case 等。 - 能够将运行时才能确认的代码提前到测试期间确定的部分。比如对于网络请求的处理和流程,或者一些需要预先设置复杂条件才能到达的代码路径。 ### 不测试什么 作为打工人,虽然心酸,但是公司一般不太会因为我们写了漂亮的测试代码而多付工资。我们的收入还是来源于实际的产品代码,所以凡是维护成本过高,需求变化过快的代码,一般情况下我不太会去书写测试。包括: - 出了问题可以甩锅给 Apple 的代码:几乎所有的 View 代码,像是布局啊,view 的属性设置啊,控件的点击拖动啊这类。这类代码绝大部分时候依赖 UI 框架的实现,测试在很多时候意义不大。如果想要确保 view 的正确,更多的时候我们可以转而去测试 view model。 - 出了问题可以甩锅给 QA 的代码:几乎所有的胶水代码,也就是传统意义上 view controller 层级的代码。因为一旦有需求变更,这部分代码非常容易发生剧烈变动,而且也难以进行自动化测试。更多时候更倾向于交给 QA 团队。 - 出了问题可以甩锅给同事的代码:其他人的框架和代码。如果没有特殊的理由,我选择信任别人的代码,这包括同事写的项目里的代码和各种第三方依赖。对于同事的代码,如果同时满足:非常关键+没有测试+质量稀烂,那么我可能会考虑适当补一些测试,甚至重构一下让自己稍微安心;对于第三方依赖,如果质量不佳或者没有测试,那我可能会选择建议换一个类似的,或者自己造一个更好的。 - 出了问题可以甩锅给三体人的代码:那些几乎不可能出错的代码,比如只是进行了赋值的某个类的初始化方法,简单的 getter/setter 等。 ## 测试的风格和测试框架 ### BDD, TDD 或者随心所欲? 在 Swift 社区中,BDD 的风格并不像 Ruby 或者 JS 社区中那样流行。个人感觉这一方面可能是由于作为生态的控制者的 Apple 并没有提供第一方的 BDD 风格的测试框架支持,如果你想要尝试 BDD 的测试风格,可能需要依赖一些第三方的测试框架,比如 [Quick/Nimble](https://github.com/Quick/Nimble);另一方面,则是 Apple 开发社区相对前端来说还是更偏向保守,相比起 XCTest 中传统的断言方式,BDD 并没有什么统治地位的优势或者所谓的 killer feature。我个人不是很喜欢 BDD 的语法方式,但如果以学习为目的折腾折腾增长见识的话,作为尝试也还不错。 最为严格 TDD 倡导我们先写测试,然后再进行实际编码实现。我在一些开发任务中实际尝试了这种做法,得到过几点结论: 1. 严格地按照先编写测试,然后再编写实际代码的方式,**确实可以帮助梳理代码结构,达到更合理的架构**。 2. 但是由于先写测试,在不具备实际实现代码的情况下,测试显然不可能编译通过。这需要我来回在测试代码和实现代码中进行切换。对于开发熟练工来说,这是一种不必要的折磨。 3. 所以我一般选择执行“不那么严格”的 TDD:**先写一部分最基础的类型和方法**,但一旦某个类型初具规模 (比如拥有了三四个方法),或者遇到类型间的交互,就**立刻停下来开始编写测试**。编写的测试应该不仅仅只涵盖已有的实现,还应该尽力去定义其他还没有进行实现的部分,回归到严格 TDD 的路径。这样可以通过在测试中的“实际用例”,来确定所需要的接口定义。 实践中,我觉得这种非严格的 TDD 更符合人性和直觉,它的关键在于需要在测试代码和实现代码之间找到开发效率的平衡。如果你以前没有使用 TDD 的开发方式,那么其实一开始的时候还是建议使用最严格的方式,之后在逐步过渡到平衡阶段,会更容易对全局有所掌控。 > 如果你对 TDD 完全没有了解,那么其实可以先读读看 Kent Beck 的[关于测试驱动开发的书](https://www.oreilly.com/library/view/test-driven-development/0321146530/) (Test Driven Development: By Example),这本书可以说是 TDD 的“圣经”了。 ### 关于 AI 在测试中的应用 从 [Kite](https://github.com/kiteco) 和 [Tabnine](https://www.tabnine.com/),到如今的 [GitHub Copilot](https://github.com/features/copilot),甚至更加通用的 ChatGPT,使用 AI 辅助编程逐渐流行,并且已经在很多方面展现了非凡的潜力。比如用 GitHub Copilot 为某个方法写测试,对于一些简单的方法,已经能够达到不错的效果了: ![](/assets/images/2023/ai-gen-tests.png) 生成测试代码,其实是 AI 辅助编程的一个非常有用的场景:它们一般足够简单 (如果不够简单,往往则表示代码设计可能出现了问题),拥有非常好的可预期性,而且存在大量重复。这些都是 AI 所擅长的领域,也能节省开发者的大量时间。 而反过来,也许也会有新的发现:我们能不能就做一些简单的事情,比如编写测试,然后让 AI 根据测试去完成实际的实现 (也就是相对困难的部分)。一来,这非常符合 TDD 的精神和工作流程;二来,这能更好地解放开发者。当然,现在版本的 AI 虽然大部分时候能给出解决方案,但看起来还并不能非常完美地完成这项任务:因为像 ChatGPT 这类 LLM 模型,只具有文本生成能力而非实际思考的能力,你至少必须花上很多时间去 review AI 写出的代码,去真正理解并维护这些代码。在当下,这要花费大量的时间,可能还不如自己去编写相关代码,所以更明智的做法是让 AI 安份扮演副驾驶 (copilot) 的角色,仅在小片段上提供意见。但是可能随着 AI 不断的学习和训练,这种情况会被彻底改变,也许在未来,AI 才是主驾,而人类开发者则变成副驾。 {: .alert .alert-warning} 如果那一天到来,那么编程就不再是程序员所需要掌握的核心技能了。 ### Apple 平台的测试,XCTest 和其他框架 Apple 在自家平台提供的 XCTest 是非常成熟的测试框架了,应该也是 iOS 相关开发中使用最广泛的框架。要注意,使用什么测试框架,和你使用哪种测试风格,并没有直接关系。在尝试一整圈以后,我个人现在在 Apple 平台进行测试时,几乎只使用 XCTest 了。Apple 官方支持、逐年更新以适应语言和框架新特性 (比如 async 的支持)、以及稳定的性能和表现,是做出这个选择的重要原因。 其他的测试框架个人涉足不深,就不过多介绍了。如果是在 Apple 平台的话,个人建议优先从 XCTest 入手:这是最简单也是和 IDE 结合最直观的测试框架。等积累一定经验后,再尝试对比其他框架。 ### Unit Test、Feature Test 和 UI Test 个人情况: - 优先保证 Unit Test - 酌情适当 Feature Test - 几乎不写 UI Test 这其实和前面“测试什么”,“不测试什么”是对应的。虽然 Feature Test 和 UI Test 有它们自己的适用范围,并能进一步保证正确性,但是相应的代价也相对较大。完备且正确的 Unit Test,加上一些关键路径上的 Feature Test,在绝大多数情况下已经能满足**不让工作影响正常生活**这一测试的目标。更完备的 Feature Test,甚至如果有信心维护的话,力所能及添加一些合理的 UI Test 会更好。但是过度的测试往往很容易达到边界效应:它们不会大幅改善软件的可靠性,反而容易成为严重的维护负担和开发成本。 ## 改善测试质量的方式 ### 注入、Mock 和 Stub 好的测试应该是顺理成章的,符合明确、简洁、稳定这些要素。代码耦合和互相依赖一直是书写测试的大敌: - 在针对某个方法的测试中,内部的耦合代码实际上并不属于该方法应该被测试的部分。 - 这些耦合部分大概率需要合适的配置或者先决条件才能正常工作,而这些代码深藏于被测方法和类型里,在测试期间难以控制。 - 部分耦合代码可能给程序状态带来强烈的副作用 (side effect),导致测试不稳定或者依赖特定顺序。 在实践中,我们有很多手段可以解耦合,其中最常用的是依赖注入:不在实现代码中直接持有依赖,而是通过一些方式,从外部将给入这个依赖。举个最简单的例子,下面这样的代码是难以测试的: ```swift class Person { var path: String { /* */ } var data: Data { /* */ } // 持有 FileManager.default func saveIfNotExiting() { let shouldSave = !FileManager.default.fileExists(atPath: path) if shouldSave { FileManager.default.createFile(atPath: path, contents: data, attributes: nil) } } } ``` `saveIfNotExiting` 中使用了 `FileManager` 实例,这使得 `Person` 和整个 `FileManager` 耦合在了一起。这时,对 `saveIfNotExiting` 的测试,不可避免地需要依赖外部状态,也即 `path` 上是否已经存在文件;要验证调用结果,也只能再次检查 `path` 上是否存在文件。这些都让测试变得不稳定。 通过结合使用 Swift protocol 和依赖注入,可以改善这个问题: ```swift protocol SaveContext { func fileExists(atPath path: String) -> Bool @discardableResult func createFile( atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey : Any]? ) -> Bool } extension FileManager: SaveContext { } func saveIfNotExiting(saveContext: SaveContext) { let shouldSave = !saveContext.fileExists(atPath: path) if shouldSave { saveContext.createFile(atPath: path, contents: data, attributes: nil) } } ``` 这样,在测试时,我们只需要构建测试专用的类型,来满足 `SaveContext`,并将它传递给 `saveIfNotExiting`,就可以非常简单地设置各种测试条件并验证结果了。 {: .alert .alert-info} [pointfreeco/swift-dependencies](https://github.com/pointfreeco/swift-dependencies) 更进一步,对 Swift 中的依赖注入进行了非常漂亮的封装,并内置了一些常见的需要注入的类型。如果不想使用添加参数这种太过于“破坏性”的注入方式,这里提供的方案也许会更加舒适。 如果由于某种原因,不能使用基于 Protocol 的注入,那么传统的 Mock 或者 Stub 也会是不错的选择。由于 Objective-C 的动态特性,基于 Mock 来验证行为,甚至是通过 Stub 来“修改”一些预设值,曾经是相当流行的做法。在 Swift 的时代,Mock 和 Stub 的使用难度有所提升,在大部分情况下,两者都只是在测试层面解决依赖的问题,而并没有真正改善代码设计。如果有条件的话,个人更建议合理使用依赖注入,来实际地减少代码耦合。 ### 使用纯函数 减少测试难度的另一个要点,是尽可能地书写[纯函数](https://zh.wikipedia.org/wiki/纯函数),也就是那些和状态无关的函数。 如果确定的输入一定能够导致确定的输出,那么想要验证测试的结果,就会变得容易很多。在以 SwiftUI 为代表的 [基于单向](https://onevcat.com/2021/12/tca-1/) [数据流](https://onevcat.com/2017/07/state-based-viewcontroller/) 的编程模型中,状态变更部分的代码往往被设计为纯函数,状态变更的结果再去驱动 UI 框架完成呈现和渲染。这让这些状态变更代码 (它们往往是你所编写的程序的核心部分) 可以很容易地进行测试。 这样的设计被工业界广泛验证。在设备性能相对过剩的今天,大规模的程序设计中,在非关键节点上,项目的可维护性要比牺牲可读性的性能优化更加重要。我们在设计代码时,其实也可以尽量使用纯函数,尽量简化可变状态,尽量编写覆盖率达标的测试,以期让代码和我们自己都能活得更长更久。 URL: https://onevcat.com/2022/12/2022-final/index.html.md Published At: 2022-12-31 12:10:00 +0900 # 不知所谓的 2022 年终总结 其实随着年龄增长,总感觉最近每年都很平淡,也几乎没有什么肉眼可见的进步。再加上疫情到了第三年,自己又长期在宅工作,无形中少了许多和这个世界接触的机会,更让自己的思想越来越僵化死板。不知道是不是因为长年在日本这种国度的关系,从感觉上来说似乎这个世界固化住了。一种即视感萦绕在周围,自己却没有什么新思路,也找不到突破的方式。如何才能在这种情况下继续前进,想来应该会成为今后重要的课题。 这篇年终总结一下笔,居然发现自己整一年都脑袋空空,实在是很不应该。既然没有什么特别想要写的,那就还是先按照每个月挑选一张照片配上说明,来简单回顾一下这一整年吧。一是抒发一下心绪,二来也算是一种见证。最后阶段会依照惯例补充一些今年的书籍、动漫和游戏。 如果硬要说自己对比去年有什么不同的话,大概两鬓新增的白发在寒风中所诉说的故事就是一切了。 ## 图说 #### 一月 ![](/assets/images/2022/2022-final-1.jpg) 姐妹两人操作香菱,帮助爸爸在璃月大地上做任务打工升级。 电子游戏早已是成熟的第九艺术,顶级的游戏必然有着顶级的图像、音乐以及故事。游戏早已是我人生中无法抹掉的印记。相比于用一些冰冷的法律法规,设置重重阻碍来限制她们的游戏时间,还不如多多引导,主动让她们接触顶级视听和游玩体验,树立合理的审美情趣。 于是今年带着两姊妹玩了不少有意思的电子游戏,并且有意识地把一些有趣而不乏深度的剧情与她们一起分享。优秀的游戏曾经陪伴我长大,游戏的世界也让我有机会接触到很多知识,甚至另一种世界观,成为了我的一部分。在这疫情肆虐的世界里,人与人的接触必然变少,这对于小朋友的成长必然产生深远而不可忽视的影响。在正确的引导下,希望电子游戏能在一定程度上吸引她们的兴趣,并补充她们对这个世界的认知。 #### 二月 ![](/assets/images/2022/2022-final-2.jpg) 在家招待自己的小客人们,七七帮忙制作杯子蛋糕。 相比于爸爸妈妈的社交恐惧症带来了“举目无友”,小朋友们之间的友谊则要简单得多。沾她们的光,我们也经常有机会邀请别的父母到家里做客。因为长期远程工作的关系,这种面对面的交流这几年着实是缺乏得厉害。日益恶化的网络环境,也让自己对人的判断难免出现偏差。这种邀请客人来家里畅谈的机会,可以说是难能可贵了。 更何况还有好吃的杯子蛋糕! #### 三月 ![](/assets/images/2022/2022-final-3.jpg) 满满穿上小礼服,参加幼儿园的毕业典礼。 时间真的飞快,一转眼满满同学已经是小学一年级的小大人了。最近一年,在妹妹的各种“映衬”下,姐姐俨然已经成为了家里的“模范生”。总体而言,满满的小学生活至今为止还是十分顺利的。能在学校里感受到乐趣,能在课上课后有所进步,能和我们分享她的各种经历,每一天的成长肉眼可见,这就是最让人高兴和激动的事儿了。 #### 四月 ![](/assets/images/2022/2022-final-4.jpg) 准备出发前往大阪,目标 USJ。停机坪上的“从世界来,到世界去”,在疫情逐渐平复的日子里,也算寄托了一种希望。 大概是疫情两三年以来第一次来到机场。两人原本几个月大开始就满世界乱飞,但结果由于疫情,她们的记忆已经被“扭曲”成了“从来没做坐过飞机”,以至于驻足在候机厅窗口张望。这几年经历了很多大大小小的事件,越发感受到,身处历史的进程中的我们,想要敏锐地感知它的发展,并不是一件易事。可以说这是“只缘身在此山中”,也可以说这是“缺月隐云雾,嘉禾秀稂莠”。很多改变,在不经意之间确实发生并影响着所有人。但是在当下,它们却并不十分明显。若干年后我们回顾时,才会发现也许就是当年一个不起眼的事件,让整个世界线都发生了偏移。 #### 五月 ![](/assets/images/2022/2022-final-5.jpg) 公司已经把远程办公当作长期政策了,所以把书房当作办公室的日子还不知道会持续多久。在家中添置了足够强力的打印机,现在这个角落可越来越像样了。 原来用的喷墨打印机隔三差五就需要换墨盒,打印速度也十分堪忧,对于资料的扫描难以归档和整理。想到今后几年大概率还是会在家办公,加上小朋友们逐渐开始上学,也必然会有更多的打印需求,干脆一步到位,弄了个小事务所级别的激光打印机。各种扫描的文件直接发送到 NAS 进行存档,总体感受相当舒适。 #### 六月 ![](/assets/images/2022/2022-final-6.jpg) 妈妈亲手为满满打造的七岁生日蛋糕,虽然卖相不比店里的专业和精致,但是味道却一点儿不输。 本来按照惯例,生日的时候小朋友们都更倾向于吃冰激凌蛋糕。不过偶尔回归传统的奶油蛋糕似乎也很不错,特别是如果这个蛋糕是妈妈亲手做的。在日本这边,小朋友的七岁五岁三岁都被认为是比较重要的生日节点,似乎也有着各种复杂的礼仪,甚至隆重一点的话需要很正规地去附近的神社参拜。可能这些活动的仪式感,能够让小朋友们更真切地认识到自己的成长吧。不过在这边作为凑热闹的外国人,吃个蛋糕拍个纪念照也已经足够开心了。 #### 七月 ![](/assets/images/2022/2022-final-7.jpg) 自家门前小院里种的番茄,今晚可以加菜了! 原本只是抱着试一试玩一玩的心态,买了一盆番茄苗来养。虽然中途夭折了一棵,但想不到最后居然还真能结出像样的果实。虽然成本要比去超市直接买两个番茄高得多,但是每天出门都能望它们两眼,看着小苗一点点长大、开花、结果,最终成熟,这番体验可是在超市无论如何都买不到的。虽然最后吃起来味道实在不怎么样,但是仔细想来,似乎也算是第一次亲手种出了能吃的东西。可喜可贺,可喜可贺! #### 八月 ![](/assets/images/2022/2022-final-8.jpg) 熊本县,阿苏草原,七七在这里第一次体验骑马。 成长本身就是一个不断尝试新鲜事物的过程。妹妹其实在性格上来说,依然会天然地抗拒一些新的事物:包括并不限于新的食物,新的活动等。不过得益于经常的旅行,她对于新的地方还是充满了期待。希望她能够早一点懂得“要经常尝试新东西,才会有新发现”的道理。而作为大人,还是要多多思考如何守护这份与生俱来的好奇心。 #### 九月 ![](/assets/images/2022/2022-final-9.jpg) 某酒店的手工制作工房,七七正在参加玻璃时钟的制作体验,电烙铁拿得有模有样。 作为一个工科男,其实每次看到烙铁,都会引发那段焊电路板时被烫的不好回忆。所谓一朝被蛇咬,十年怕井绳。我自认属于动手能力极强的那一档,但自那之后就很少再拿起烙铁了。虽然失败不可避免,但是人肯定还是需要不断的成功经验,才能树立信心的,这对未来面对挑战时的心态非常重要。 想要做到从失败中学习和汲取经验,第一步就是勇敢承认失败,认真面对错误。只有这样,我们才能确立努力的方向,拾取前进的信心。 #### 十月 ![](/assets/images/2022/2022-final-10.jpg) 东京晴空塔,天望展望台。从这里可以看到东京市中心街景全貌,算是一个整理心情和放空自我的好地方。 说来惭愧,来日本已经将近十年,而晴空塔这种地标性建筑,以前却从没来过。跂高而望,不如登高博见。虽说自从民用无人机普及后,像是展望台这种三四百米高度的俯瞰视角变得不再珍贵,但人类这种生物对于能够亲自登高望远所带来的视觉震撼和诱惑,大抵还是难以拒绝的。 如果有机会,选一个好天气,配一壶咖啡,在台子上静静待上半饷,不也是一件人生乐事? #### 十一月 ![](/assets/images/2022/2022-final-11.jpg) 某车站边上的加湿器广告。虽然演员[要润](https://zh.wikipedia.org/wiki/要潤)的名字和“大事”这个词在中日文环境中的意义不同,但大环境下这个广告在中文互联网上一度成了热点。 关于[润学](https://zh.wikipedia.org/wiki/润学)相关事情的兴起和讨论,我没有任何的发言权。每个人的判断无疑都受限于个人的人生经历,当下的时代背景,甚至周围的环境。而这些判断所引出的决定,以及这些决定在时间维度上发展后所得到的结果,都是难以预料的。周遭的一些朋友倒是经常会有人询问有关的事情,但是我自己也就是一个随遇而安的人,当初的决定也没有什么“深思熟虑”。 无论是谁,也无论在哪儿,每个人其实都在负重而行。 #### 十二月 ![](/assets/images/2022/2022-final-12.jpg) 北海道,雪中步履维艰,却挡不住大家一起前行迈向前方的步伐。 套用一句官媒的话,2022 绝对是“极不寻常、极不平凡”的一年。就连我这样迟钝如木鱼的人,都可以本能地体会到这个世界的发展和撕裂。可能我们难免正在一场危机的前方,但是这同样也会是孕育机遇的地方。至少需要思考如何才能平安度过危机,并把这些思考付诸实践,才是接下来一年的重点。 “高筑墙,广积粮,缓称王”,新的时代付与了这句话新的意义,但大抵上应该是不会错的。 ## 书评 今年读书偏少,摘一些有印象的。 - [《艺术的故事》](https://book.douban.com/subject/3162991/) - 解释了本质上艺术到底是什么,它们是怎么产生,如何发展,并传承至今的。艺术发展史也许没有人类战争或者政治史那样跌宕起伏、惊心动魄,但是在欣赏中镌刻的历史,往往散发出更耀眼的光芒。 - [《无穷的开始》](https://book.douban.com/subject/26184242/) - 副标题叫做《世界进步的本源》,算是一本有趣的科普读物,也涉及了部分人文讨论。在宇宙维度下,人类几乎已经丧失了进行实际的科学实验的条件,于是理论的推导比以往更加重要。有趣的是,科学道路上的前方越来越远离现实,但是科学的目的确是让我们拥有更好的现实。 - [《圆圈正义》](https://book.douban.com/subject/34815132/) - 罗翔教授的一些随笔收录。虽然对于业余吃瓜群众来说,在 B 站看看罗老师的视频已经能达到“被普法”的目的了,不过实际的文字能够仔细检阅,相比拖拉进度条,有时候还是翻看书页更能让人反复回味。 - [《繁荣与衰退》](https://book.douban.com/subject/33478543/) - 对美国经济史的脉络进行梳理。不管愿不愿意承认,美国在过去一两百年的崛起和取得的成就,是璀璨夺目的。而在现今当代,也依然是最强的存在。从名为“历史”的捷径中,我们往往可以寻找到很多想要的答案。 - [《置身事内》](https://book.douban.com/subject/35546622/) - 相对于上面的美国经济史,中国的道路和崛起方式截然不同。地方政府进行经济治理的基本方式,以及这种方式背后的逻辑,并非无迹可寻。而对应的挑战和风险,本书也有涉及。 ## 游戏 今年算是个游戏大年了,但是大作其实没有怎么玩。一来是进入了 3A 审美疲劳期,二来是实在没有大块时间允许沉浸下来。列举一下今年主要玩下去的游戏们吧,按照自己的评分进行排序: - `5/5` 原神 - 年初跟着《神女劈观》的热度一起入坑的原神,现在已经成为每天都会去打理一下的游戏了。我个人比较愿意把这款游戏叫做新时代的《魔兽世界》,也算是一种现象了。抛开各种节奏不谈,原神的游戏制作和质量无疑非常优秀的,综合素质,特别是角色、地图设计,音乐等,完成了对竞争对手的全方面碾压。甚至刚到璃月望舒客栈时,那种潜藏在一个我这样的资深玩家身体里多年的“没错,我想要的就是这样一个中国风格的游戏”的感慨,也油然而生。如果你对二次元不算反感,或对中国公司制作的游戏仍有偏见的话,那么强烈推荐试试原神。 - `4.5/5` 巫师 3 次世代版 - 免费的更新,高清的建模,还带光追。重新体验一波猎魔人的机会不可放过。也不知道巫师系列还会不会继续出新作,不过次世代版里早就习惯和各种水鬼妖灵搏命白狼,这次终于不会从小山坡跳下来动不动就摔死,可太好了。 - `4.5/5` 笼中窥梦 - 好像是今年唯一一个认真玩了的独立游戏。视觉错位的解密游戏现在感觉都能自成一类了。所以在前面几年大量的类似思路游戏的铺垫下,笼中窥梦就显得没那么惊艳了。不过游戏设计和转场还是值得称赞。 - `4/5` 星之卡比:探索发现 - 不需要动什么脑筋,可以和小朋友们一起玩的合家欢作品。Switch 的机能注定任天堂家的游戏只能靠游戏性取胜,比起精美的贴图建模,和小朋友们一起解锁各种新武器和吸入各式道具,才是欢乐的源泉。 - `4/5` 帝国时代4 - 应该是去年的游戏了?没想到微软真的把续作甘出来了。每段战役的剧情视频和结束后的各种小百科,让我一度以为这是一部披着游戏外皮的历史普及记录片。不过这些精良的视频确实给游戏带来了某种意义上的历史厚重感。 - `3.5/5` 斯普拉遁 3 - 买回来剧情通了一半,就放着了。打对战几乎没有赢过,日本小学生实在是太厉害了,打不过打不过...游戏素质是没话说,但是不太对我的胃口吧。 - `3/5` 异度之刃 3 - 个人认为综合素质远不如二代,不管从叙事节奏和主旨剧情来说,都没有“噼咔”的感觉。唯一的亮点是中期的一波演出,但是缺少悬疑和转折的故事,都让这款作品在与前作2代每章一个高潮的对比下黯然失色。 老头环和新战神实在没时间,也许 2023 等波打折? ## 动漫 今年追过的番剧,只列出评分排名和名字了(只写一句话简评的原因才不是因为要去做饭了呢!) - `5/5` 莉可丽丝 - 谁不喜欢美少女贴贴呢? - `5/5` 异世界舅舅 - 谁不喜欢看戏吃瓜呢? - `5/5` 间谍过家家 - 谁不喜欢间谍杀手呢? - `4.5/5` 派对浪客诸葛孔明 - 谁不喜欢丞相热舞呢? - `4.5/5` 后宫之乌 - 谁不喜欢冰霜美人豆腐心呢? - `4/5` 辉夜大小姐想让我告白 超级浪漫 - 谁不喜欢看聪明人互相算计呢? - `4/5` 4个人各自有着自己的秘密 - 谁不喜欢自己藏点小秘密呢? - `3.5/5` 契约之吻 - 谁不喜欢恶魔小女友呢? - `3/5` 在地下城寻求邂逅是否搞错了什么第四季 - 谁不喜欢人鱼小妖怪呢? - `2/5` 书虫公主 - 谁不喜欢甘雨小姐姐的配音呢? --- 好耶!今年居然在跨年之前就写完了,真棒。奖励晚上可以多吃一条蟹腿。 URL: https://onevcat.com/2022/11/swift-regex/index.html.md Published At: 2022-11-15 23:00:00 +0900 # Swift 正则速查手册 Swift 5.7 中引入了正则表达式的语法支持,整理一下相关的一些话题、方法和示例,以备今后自己能够速查。 ## 总览 Swift 正则由标准库中的 `Regex` 类型驱动,需要 iOS 16.0 或 macOS 13.0,早期的 deploy 版本无法使用。 构建一个正则表达式的方式,分为传统的正则字面量构建,以及通过 Regex Builder DSL 的更加易读的方式。后者可以内嵌使用前者,以及其他一些已有的 parser,在可读性和功能上要强力很多。实践中,推荐**结合使用字面量和 Builder API 在简洁和易读之间获取平衡**。 ## 常见字面量 和其他各语言正则表达式的字面量没有显著不同。 直接将字面量包裹在 `/.../` 中使用,Swift 将把类似的声明转换为 `Regex` 类型的实例: ```swift let bitcoinAddress_v1 = /([13][a-km-zA-HJ-NP-Z0-9]{26,33})/ ``` 一些常用的字面量表达以及示例。更多非常用的例子,可以参考[这里的 Cheat Sheet](https://github.com/niklongstone/regular-expression-cheat-sheet)。 ### 字符集 | 表达式 | 说明 | 示例 | | --------- | ---------- | --------------------------------- | | `[aeiou]` | 匹配指定字符集 | On**e**V's␣D**e**n␣**i**s␣**a**␣bl**o**g. | | `[^aeiou]` | 排除字符集 | **On**e**V's␣D**e**n␣**i**s␣**a**␣bl**o**g.** | | `[A-Z]` | 匹配字符范围 | **O**ne**V**'s␣**D**en␣is␣a␣blog. | | `.` | 除换行符以外的任意字符。等效于 `[^\n\r]` | **OneV's␣Den␣is␣a␣blog.** | | `\s` | 匹配空格字符 (包括 tab 和换行) | OneV's**␣**Den**␣**is**␣**a**␣**blog. | | `\S` | 匹配非空格字符 | **OneV's**␣**Den**␣**is**␣**a**␣**blog.** | | `[\s\S]` | 匹配空格和非空格,也即任意字符。等效于 `[^]` | **OneV's␣Den␣is␣a␣blog.** | | `\w` | 匹配字母数字下划线等低位 ASCII。等效于 `[A-Za-z0-9_]` | **OneV**'**s**␣**Den**␣**is**␣**a**␣**blog**. | | `\W` | 等效于 `[^A-Za-z0-9_]` | | | `\d` | 匹配数字,等效于 `[0-9]` | +(**81**)**021**-**1234**-**5678** | | `\D` | 非数字,等效于 `[^0-9]` | **+(**81**)**021**-**1234**-**5678 | ### 数量 | 表达式 | 说明 | 示例 | 结果 | | ---------- | --------------------------------- | ----------- | --------------------------------------- | | `+` | 匹配一个或多个 | `b\w+` | b **be** **bee** **beer** **beers** | | `*` | 匹配零个或多个 | `b\w*` | **b** **be** **bee** **beer** **beers** | | `{2,3}` | 匹配若干个 | `b\w{2,3}` | b be **bee** **beer** **beer**s | | `?` | 匹配零个或一个 | `colou?r` | **color** **colour** | | 数量 + `?` | 使前置数量进行惰性匹配 (尽可能少) | `b\w+?` | b **be** **be**e **be**er **be**ers | | `|` | 逻辑或,择一匹配 | `b(a|e|i)d` | **bad** bud bod **bed** **bid** | ### 锚点 | 表达式 | 说明 | 示例 | 结果 | | ------ | ------------------------------ | ------ | ----------------------------------- | | `^` | 匹配字符串开头 | `^\w+` | **she** sells seashells | | `$` | 匹配字符串结尾 | `\w+$` | she sells **seashells** | | `\b` | 匹配 `\w` 和非 `\w` 的边缘位置 | `s\b` | she sell**s** seashell**s** | | `\B` | 匹配非边缘位置 | `s\B` | **s**he **s**ells **s**ea**s**hells | ### 捕获组 | 表达式 | 说明 | 示例 | | ---------------- | -------------------------------------------------- | ------------------------- | | `(OneV)+` | 捕获括号内的匹配,使其成组并出现在匹配结果中 | **OneV**'s Den is a blog. | | `(?OneV)+` | 命名捕获匹配,在结果中可使用名字对匹配结果进行引用 | | | `(?:OneV)+` | 成组但不进行捕获,允许使用数量但不关心和捕获结果 | | ### Lookahead | 表达式 | 说明 | 示例 | | ---------- | --------------------------------------------------------- | ----------------------- | | `\d(?=px)` | `?=` - Positive lookahead。预先检查,符合时再进行主体匹配 | 1pt **2**px 3em **4**px | | `\d(?!px)` | `?!` - Negative lookahead。预先检查,不符合时进行主体匹配 | **1**pt 2px **3**em 4px | ## Builder DSL 字面量表达式虽然简洁,但是对应复杂情境会难以理解,也不便于修改。使用 [`RegexBuilder` 框架](https://developer.apple.com/documentation/regexbuilder)提供的 DSL 来描述正则表达式是更具有表达性的方法。 比如, ```swift let bitcoinAddress_v1 = /([13][a-km-zA-HJ-NP-Z0-9]{26,33})/ ``` 等效于: ```swift import RegexBuilder let bitcoinAddress_v1 = Regex { Capture { One(.anyOf("13")) Repeat(26...33) { CharacterClass( ("a"..."k"), ("m"..."z"), ("A"..."H"), ("J"..."N"), ("P"..."Z"), ("0"..."9") ) } } } ``` `Regex.init(_:)` 接受一个 result builder 形式的闭包,你可以往闭包中塞入多个 `RegexComponent` 来构建完整的正则表达式。注意 `Regex` 类型本身也满足 `RegexComponent` 协议,所以你也可以直接把字面量传递给 `Regex` 初始化方法。 字面量所提供的特性,在 Regex Builder 中都有对应。除此之外,Swift Regex Builder 框架还提供了更易读的强类型描述。一些常见的对应 `RegexComponent` 如下: ### 字符集 字符集相关的 `RegexComponent` 基本被定义在 [`CharacterClass`](https://developer.apple.com/documentation/regexbuilder/characterclass) 中。 | 字面量表达式 | 等效的 `RegexComponent` | | ------------ | ------------------------------------------------------------ | | `[aeiou]` | `.anyOf("aeiou")`。为了可读性,可以考虑加上量词 `One(.anyOf("aeiou"))` | | `[^aeiou]` | `CharacterClass.anyOf("aeiou").inverted` | | `[A-Z]` | `("A"..."Z")` | | `.` | `.any` | | `\s` | `.whitespace` | | `\S` | `.whitespace.inverted` | | `[\s\S]` | `CharacterClass(.whitespace, .whitespace.inverted)` | | `\w` | `.word` | | `\W` | `.word.inverted` | | `\d` | `.digit` | | `\D` | `.digit.inverted` | ### 数量 | 字面量表达式 (例) | 等效的 `RegexComponent` | | -------------------- | ------------------------------ | | `+` (`b\w+`) | `OneOrMore(.word)` | | `*` (`b\w*`) | `ZeroOrMore(.word)` | | `{2,3}` (`b\w{2,3}`) | `Repeat(2...3) { .word }` | | `?` (`colou?r`) | `Optionally { "u" }` | | 数量 + `?` (`b\w+?`) | `OneOrMore(.word, .reluctant)` | | `|` (`b(a|e|i)d`) | `ChoiceOf { "a" ↵ "e" ↵ "i" }` | ### 锚点 | 字面量表达式 (例) | 等效的 `RegexComponent` | | ----------------- | ---------------------------------------------------- | | `^` (`^\w+`) | `Regex { Anchor.startOfSubject ↵ OneOrMore(.word) }` | | `$` (`\w+$`) | `Regex { OneOrMore(.word) ↵ Anchor.endOfSubject }` | | `\b` (`s\b`) | `Regex { "s" ↵ Anchor.wordBoundary }` | | `\B` (`s\B`) | `Regex { "s" ↵ Anchor.wordBoundary.inverted }` | 此外: - 对于多行匹配模式的情况 (如带有 `m` 的 `/^abc/m`),此时 `^` 和 `$` 等效为 `.startOfLine`,`.endOfLine` 等。 - 对于 Unicode 支持,常用的还有 `.textSegmentBoundary (\y)` 等。 ### 捕获 | 字面量表达式 | 等效的 `RegexComponent` | | ---------------- | ------------------------------------------------------------ | | `(OneV)+` | `OneOrMore { Capture { "OneV" } }` | | `(?OneV)+` | `let name = Reference(Substring.self)`
`OneOrMore { Capture(as: name) { "OneV" } }` | | `(?:OneV)+` | `OneOrMore { "OneV" }` | Regex Builder 支持在 `Capture` 的过程中同时进行 mapping,把结果转换为其他形式的字符串甚至是其他类型的强类型值: ```swift Regex { TryCapture(as: kind) { OneOrMore(.word) } transform: { Transaction.Kind($0) } // 得到一个强类型 `Kind` 值 } ``` 如果转换可能会失败并返回 `nil`,使用 `TryCapture`:失败时跳过匹配;如果转换一定会成功,使用普通的 `Capture`。 ### Lookahead | 字面量表达式 | 等效的 `RegexComponent` | | ------------ | ----------------------------------------------- | | `\d(?=px)` | `Regex { .digit ↵ Lookahead { "px" } }` | | `\d(?!px)` | `Regex { .digit ↵ NegativeLookahead { "px" } }` | ## 常用 Parser 相对于字面量,使用 Regex Builder 的最大优势,在于可以嵌套使用已经存在的 Parser 进行匹配。凡是满足 `RegexComponent` 的值,都可以放到 `Regex` 表达式中。Foundation 中,部分 `ParseStrategy` 满足 `RegexComponent` 并提供相应方法来创建 `Regex` 中可用的 parser。iOS 16 中,默认可用 Parser 有: | 所属 Parser 类型 | 方法签名 | 可解析示例 | | ------------------------------------------ | ---------------------------------------------------------- | --------------------------------- | | `Date.ParseStrategy` | `date(_:locale:timeZone:calendar:)` | Oct 21, 2015, 10/21/2015, etc | | `Date.ParseStrategy` | `date(format:locale:timeZone:calendar:twoDigitStartDate:)` | 05_04_22 | | `Date.ParseStrategy` | `dateTime(date:time:locale:timeZone:calendar:)` | 10/17/2020, 9:54:29 PM | | `Date.ISO8601FormatStyle` | `iso8601(timeZone:...)` | 2021-06-21T211015 | | `Date.ISO8601FormatStyle` | `iso8601Date(timeZone:dateSeparator:)` | 2015-11-14 | | `Date.ISO8601FormatStyle` | `iso8601WithTimeZone(...)` | 2021-06-21T21:10:15+0800 | | `Decimal.FormatStyle.Currency` | `localizedCurrency(code:locale:)` | $52,249.98 -> `Decimal` | | `Decimal.FormatStyle` | `localizedDecimal(locale:)` | 1.234, 1E5 -> `Decimal` | | `FloatingPointFormatStyle` | `localizedDouble(locale:)` | 1.234, 1E5 -> -> `Double` | | `FlatingPointFormatStyle.Percent` | `localizedDoublePercentage(locale:)` | 15.4%, `-200%` -> `Double` | | `IntegerFormatStyle` | `localizedInteger(locale:)` | 199, 1.234 -> `Int` | | `IntegerFormatStyle.Currency` | `localizedIntegerCurrency(code:locale:)` | $52,249.98 -> `Int` | | `IntegerFormatStyle.Percent` | `localizedIntegerPercentage(locale:)` | 15.4%, -200% -> `Int` | > 关于 Foundation 中 `ParseStrategy` 的相关内容,可以参看肘子兄的[这篇博客](https://www.fatbobman.com/posts/newFormatter/),以及 WWDC 21 中[相关的视频](https://developer.apple.com/videos/play/wwdc2021/10109/)。 ### 自定义 Parser 和 `CustomConsumingRegexComponent` 对于自己实现的或是第三方提供的 Parser,可以通过满足 `CustomConsumingRegexComponent` 来让它进而满足 `RegexComponent` 并用在 Regex 构造中。 ```swift func consuming( _ input: String, startingAt index: String.Index, in bounds: Range ) throws -> (upperBound: String.Index, output: Self.RegexOutput)? ``` 返回匹配停止时的上界,以及比配得到的结果本身即可。对于这一点,WWDC 22 的 [Swift Regex: Beyond the basics](https://developer.apple.com/videos/play/wwdc2022/110358) 给了一个非常好的例子: ```swift import Darwin struct CDoubleParser: CustomConsumingRegexComponent { typealias RegexOutput = Double func consuming( _ input: String, startingAt index: String.Index, in bounds: Range ) throws -> (upperBound: String.Index, output: Double)? { input[index...].withCString { startAddress in var endAddress: UnsafeMutablePointer! let output = strtod(startAddress, &endAddress) guard endAddress > startAddress else { return nil } let parsedLength = startAddress.distance(to: endAddress) let upperBound = input.utf8.index(index, offsetBy: parsedLength) return (upperBound, output) } } } ``` 在很多情况下,我们可能会进一步地使用 [protocol 中泛型上下文静态查找](https://github.com/apple/swift-evolution/blob/main/proposals/0299-extend-generic-static-member-lookup.md)的特性,为 `RegexComponent` 添加类型成员,以便在 `Regex` 中直接使用: ```swift extension RegexComponent where Self == CDoubleParser { static var cDouble: Self { CDoubleParser() } } ``` Foundation 中的各种 parser 基本都遵循了类似的实现方式。 ## 匹配方式 ### 常见的匹配方法 ```swift // 匹配所有可能项,并将全部结果返回 input.matches(of: regex) // [Regex.Match] // 匹配时返回第一个结果 input.firstMatch(of: regex) // Regex.Match? // 整个字符串能完整匹配时才返回结果 input.wholeMatch(of: regex) // Regex.Match? // 字符串的开始部分匹配的话返回结果 // 如果只需要判断是否匹配,使用 `start(with:)` input.prefixMatch(of: regex) // Regex.Match? ``` 匹配后得到的结果中,`.0` 返回匹配到的整个字符串,从 `.1` 开始是捕获的组: ```swift let regex = /Welcome to (.+?), a person blog from (\d+)/ let text = "Welcome to OneV's Den, a person blog from 2011" if let result = text.wholeMatch(of: regex) { print("Title: \(result.1)") // OneV's Den print("Year: \(result.2)") // 2011 } ``` `Regex.Match` 实现了 dynamic lookup,可以使用 `Reference` 直接获取命名的捕获: ```swift let regex = /Welcome to (?.+?), a person blog from (?\d+)/ let text = "Welcome to OneV's Den, a person blog from 2011" if let result = text.wholeMatch(of: regex) { print("Title: \(result.name)") // OneV's Den print("Year: \(result.year)") // 2011 } ``` ### 基于 Regex 的字符串算法/操作 - `input.ranges(of: regex)` - `input.replacing(regex, with: "string")` - `input.trimmingPrefix(regex)` 等..原来在 `Collection` 中可以针对字符串的操作,可以找到对应的 `Regex` 版本。 ### Regex 变换/Flags 在创建 `Regex` 后,可以使用其上的实例方法来对 `Regex` 进行部分修改。最常用的大概有: - `ignoresCase(_:)` - 匹配是否忽略大小写。等效于 `/[aeiou]/i` 中的 `i` flag。 - `anchorsMatchLineEndings(_:)` - `^` 和 `$` 是否也匹配每行。等效于 `/^[aeiou]$/m` 中的 `m` flag。 - `dotMatchesNewlines(_:)` - 字面量 `.` 是否应该匹配包括换行符在内的任意字符。等效于 `s` flag。 ## 小结 Swift Regex 是符合 Swift 美学的正则写法,可以在标准库层面替代掉 Apple 平台上原有的被诟病已久的 `NSRegularExpression`。随着时代车轮的前行,`NSRegularExpression` 肯定将被逐渐扫进垃圾堆。 当前 Swift Regex 已经相对很完善了,它的优点非常明确: - 使用 DSL 的方式构建易于理解和维护的正则 - 可以与 Parser 结合使用,提供高质量的匹配 当然,在本文写作时也还存在一些不足。 - 文档不足,实际用例和社区支持也相对匮乏 - 需求的系统版本较高,近几年内可能难以完全迁移 - 不论字面量还是 DSL,暂时还不支持 `if` 等条件控制 - Foundation 的 Parser 数量和种类不多 不过这些毒点相对都是容易改善的,个人还是十分看好 Swift Regex 的前景。特别是用来做一些简单的文本处理和本地工具的话,会非常方便。 和 Swift `String` 一样,`Regex` 从设计初期就考虑了 Unicode 安全。不过本文暂时没有涉及 Unicode 的处理,日后如果用到再继续补充。 ### 参考 #### WWDC 22 - [Meet Swift Regex](https://developer.apple.com/videos/play/wwdc2022/110357/) - [Swift Regex: Beyond the basics](https://developer.apple.com/videos/play/wwdc2022/110358/) #### Swift Evolution - [SE-0350: Regex type and overview](https://github.com/apple/swift-evolution/blob/main/proposals/0350-regex-type-overview.md) - [SE-0351: Regex builder DSL](https://github.com/apple/swift-evolution/blob/main/proposals/0351-regex-builder.md) - [SE-0354: Regex literals](https://github.com/apple/swift-evolution/blob/main/proposals/0354-regex-literals.md) - [SE-0355: Regex syntax](https://github.com/apple/swift-evolution/blob/main/proposals/0355-regex-syntax-run-time-construction.md) - [SE-0357: Regex-powered algorithms](https://github.com/apple/swift-evolution/blob/main/proposals/0357-regex-string-processing-algorithms.md) - [SE-0363: Unicode for String Processing](https://github.com/apple/swift-evolution/blob/main/proposals/0363-unicode-for-string-processing.md) #### 其他资源 - [Regex Playground](https://regexr.com) - [Cheat Sheet](https://github.com/niklongstone/regular-expression-cheat-sheet) URL: https://onevcat.com/2022/10/spm-in-xcode/index.html.md Published At: 2022-10-14 11:00:00 +0900 # Xcode 中使用 SPM 和 Build Configuration 的一些坑 ## TL;DR 当前,在 Xcode 中使用 Swift Package Manager 的包时,SPM 在编译 package 时将参照 Build Configuration 的**名字**,**自动选择**使用 debug 还是 release 来编译,这决定了像是 `DEBUG` 这样的编译 flag 以及最终的二进制产品的架构。在 Xcode 中使用默认的 "Debug" 和 "Release" 之外的自定义的 Build Configuration 时,这个自动选择可能会造成问题。 现在 (2022 年 10 月) 还并没有特别好的方式将 Xcode 中 Build Configuration 映射到 SPM 的编译环境中去。希望未来版本的 Xcode 和 SPM 能有所改善。 {: .alert .alert-info} 关于文中的一些例子,可以[在这里找到源码](https://github.com/onevcat/SPMConfigDemo)。 ## Xcode 和 SPM 中的编译条件 ### 默认的 DEBUG 编译条件 在 Xcode 中,创建项目时我们会自动得到两个 Build Configuration:Debug 和 Release。 ![](/assets/images/2022/xcode-configurations.png) 在 `SWIFT_ACTIVE_COMPILATION_CONDITIONS` 中,Debug Configuration 预定义了 `DEBUG` 条件: ![](/assets/images/2022/xcode-configurations-debug.png) 这允许我们用类似这样的代码来在 Debug 和 Release 时编译不同的内容: ```swift // In app #if DEBUG public let appContainsDebugFlag = true #else public let appContainsDebugFlag = false #endif ``` 从 Xcode 11 开始,我们可以直接在 Xcode 里[使用 SPM 来添加框架](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app)。在 package 中,我们也可以使用同样的代码方式来进行区分: ```swift // In package public struct MyLibrary { #if DEBUG public static let libContainsDebugFlag = true #else public static let libContainsDebugFlag = false #endif } ``` 为了观察,可以把这些结果放到 UI 上: ```swift Form { Section("DEBUG flag") { Text("App: \(appContainsDebugFlag ? "YES" : "NO")") Text("Package: \(MyLibrary.libContainsDebugFlag ? "YES" : "NO")") } }.monospaced() ``` 使用 Xcode 默认的 Debug Configuration 运行,得到如下结果,一些都很美好: ![](/assets/images/2022/xcode-run-debug.png) ### 自定义编译条件 但是,Package 里的这个 `DEBUG` 条件,**并不是**通过把 Xcode 项目里的 `SWIFT_ACTIVE_COMPILATION_CONDITIONS` 传递到 SPM 来实现的。想要验证这一点,我们可以在 Xcode 中添加一个新的 condition,比如 `CUSTOM`: ![](/assets/images/2022/xcode-custom-flag.png) 类似 `#if DEBUG` 那样,为 `CUSTOM` 也添加一个属性: ```swift // In package #if CUSTOM public static let libContainsCustomFlag = true #else public static let libContainsCustomFlag = false #endif // In app #if CUSTOM public let appContainsCustomFlag = true #else public let appContainsCustomFlag = false #endif ``` 很不幸,这个 `CUSTOM` 条件在 package 中并不生效: ![](/assets/images/2022/xcode-run-custom-flag.png) 如果对 build log 进行一些确认,可以看到,对于 app target,`DEBUG` 和 `CUSTOM` 都被正确传递给了编译命令。但是在编译 package 时,给入的条件为: ``` SwiftCompile normal arm64 Compiling\ MyLibrary.swift ... builtin-swiftTaskExecution .. -D SWIFT_PACKAGE -D DEBUG -D Xcode ... ``` 在 Xcode 14.0,传入的条件有 `SWIFT_PACKAGE`,`DEBUG` 和 `Xcode`;`CUSTOM` 不在此列。 在本文写作时,SPM 只提供两个 [Build Configuration](https://developer.apple.com/documentation/packagedescription/buildconfiguration),`.debug` 和 `.release`: ```swift public struct BuildConfiguration : Encodable { /// The debug build configuration. public static let debug: PackageDescription.BuildConfiguration /// The release build configuration. public static let release: PackageDescription.BuildConfiguration } ``` SPM [本身支持为某个 Configuration 自定义条件](https://developer.apple.com/documentation/packagedescription/swiftsetting/define(_:_:)),对于自己拥有控制权的 package,我们可以通过在 Package.swift 中添加 `swiftSettings` 来传递这个 condition: ```swift-diff ... targets: [ .target( name: "MyLibrary", dependencies: [], + swiftSettings: [.define("CUSTOM", .when(configuration: .debug))] ), ... ``` 对于那些直接从 git 仓库添加的外部包,默认情况下其内容是锁定的。如果只是需要暂时传入一个编译 condition 的话,可以通过[将它转换为本地包](https://developer.apple.com/documentation/xcode/editing-a-package-dependency-as-a-local-package),然后进行和上面类似的操作为其添加 `swiftSettings`。如果需要长期的解决方案,可以考虑自己再对需要的外部包进行一次封装:创建一个新的依赖这些外部包的 Swift package,然后在将它们暴露出来的时候添加上合适的 `swiftSettings`。 > 作为包的维护者,如果我们在包里使用了除 `DEBUG` 外的编译条件,最好也相应地在 Package.swift 中进行添加。用户在使用 Xcode 编译你的包时,Xcode 会尊重这些设置。 ## 基于 Build Configuration 的判定 当 Xcode 选择使用 `.debug` 去编译 SPM 包时,它按照 Xcode 通用的编译条件,“自动地”传入 `DEBUG`。但是什么时候 Xcode 会去选择使用 `.debug`,什么时候它选择用 `.release` 呢? 答案可能让人大跌眼镜。在 Xcode 环境下,Xcode 会基于 Build Configuration 的名字,来选择 SPM 包的所使用的编译配置。具体来说,暂时发现的规则有: - 如果名字里包含有 `Debug` 或者 `Development` (不区分大小写),那么 Xcode 会使用 `.debug` 来编译 SPM 包。比如默认的 `Debug`,以及 `Development`,`Debug_Testing`,`_development_`,`Not_DEBUG`,`hello development` 都在此列。 - 否则,使用 `.release` 进行编译。比如默认的 `Release`,以及像是 `Dev`,`Testing`,`Staging`,`Prod`,`Beta`,`QA`,`CI` 等等,都会使用 `.release` 作为编译配置。 ![](/assets/images/2022/xcode-configurations-rename.png) Xcode 在这里选取了“经验主义”和自以为是的做法,当 SPM 被使用在 Xcode 中时,自定义 Build Configuration 的名字就变成了一个笑话。当你辛辛苦苦为项目配置了一个 `Testing` 的编译配置,打算用来专门跑测试时,你会发现这个配置下编译出来的 Swift package 都经过了优化并被去掉了 testable 支持。想要让 SPM 能按照预想工作,你必须将 Xcode 中的 Build Configuration 命名改回去,比如把它叫做 `Debug_Testing`。 这些规则写在了 Xcode 的编译工具链中,它们并非开源代码,现在也并没有任何文档对这件事进行说明和规定,所以它们是有可能在未来被随意改变的。比较安全的做法,是老老实实就只使用默认的 `Debug` 和 `Release` 两个 Build Configuration。当需要更多环境 (比如用来为不同环境设置不同的 bundle id 或者 app 名字) 时,也许可以选择使用多个 scheme 并为它们配置合适的环境变量来进行区分。 ## 编译架构和 Apple Silicon 除了 `DEBUG` flag 之外,Xcode 在为 SPM 包选取编译配置后,还会根据 `.debug` 和 `.release` 为包自动选取需要编译的架构。对于 `.release` 配置,情况比较简单:`ONLY_ACTIVE_ARCH` 被设置为 false,按照当前 Xcode 版本定义的 Standard Architecture 编译多个架构的二进制文件;对于 `.debug` 的话,则会将 `ONLY_ACTIVE_ARCH` 置为 true,根据 mac 设备和目标设备 (模拟器或者真机) 来决定一个编译架构。 ### 在模拟器上排除 arm64 导致的问题 在 Apple Silicon 的时代,默认情况下 Xcode 会使用 arm64 架构运行。这时候,自带的 iOS 模拟器也会跑在 arm64 下。如果你在项目里使用了一些老旧的以二进制发布的库,比如 fat binary 做的 framework,或者是不包含模拟器 arm64 的 .a 的文件,那么很可能在 Apple Silicon 的 mac 上,以模拟器为目标进行链接时,看到类似这样的错误: {: .alert .alert-danger} building for iOS Simulator, but linking in object file built for iOS, for architecture arm64 这是因为虽然库中包含了 arm64,但是其中标明了它是用在实际设备而非模拟器上的。[网络上常见的办法](https://stackoverflow.com/a/63955114),会教你在 `EXCLUDED_ARCHS` 里为 simulator 添加 `arm64`,用来把这个架构排除出去。 ``` EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64 ``` 这是一个治标不治本的“快速疗法”,加入这个设定可以让你编译通过并运行,但是你需要清楚了解到这么做的弊端:因为 arm64 被排除了,所以在 iOS 模拟器上,只有 x86_64 这一个架构选择。这意味着你的整个 app 都会以 x86_64 进行编译,然后跑在 x86_64 的模拟器上。而在 Apple Silicon 的 mac 上,这个模拟器其实是使用 Rosetta 2 跑起来的,这意味着性能的大幅下降。 而更为致命的是,这个方法在和 SPM 一起使用时,会更加麻烦。 因为 Xcode 不会将你设定的 `EXCLUDED_ARCHS` 传递给 SPM,所以在针对模拟器编译时,你会遇到这样的问题: {: .alert .alert-danger} Could not find module 'MyLibrary' for target 'x86_64-apple-ios-simulator'; found: arm64-apple-ios-simulator 对于 `.debug`,`ONLY_ACTIVE_ARCH` 为 true,编译目标为 arm64 的 iOS 模拟器,因此 SPM 只会给出 arm64-apple-ios-simulator 版本的编译结果。但是项目本身设定了 `EXCLUDED_ARCHS` arm64,它在链接包时,需要的其实是 x86_64 模拟器版本的包。砰! > 对于老旧二进制的依赖,最正确的做法是催促维护者赶快适配 xcframework。另一种可行的方案,是 hack 一下二进制,修改 arm64 slice 的目标字段,“欺骗” Xcode 让它认为这个二进制的 arm64 就是为模拟器编译的。这种方法[在这里有详细解释](https://bogo.wtf/arm64-to-sim.html),作者也发布了[相关的 arm64-to-sim 工具](https://github.com/bogo/arm64-to-sim),如有需要,可以暂时酌情使用。 ### 意外和意外的叠加 理解了 Xcode 中 SPM 选取 Build Configuration 的原理,以及编译架构的关系,我们就可以用“以毒攻毒”的方式“解决”上面的问题。 最简单的方法就是修改 Xcode 中 Build Configuration 的名字,比如把 `Debug` 改成 `Dev`。这样一来,SPM 会选取 `.release` 来编译 Swift 包,此时它会把所有支持的架构都进行编译。在 app target 中即使我们排除了 arm64,链接时因为 x86_64 的 Swift 包的编译结果也存在,因此可以正常找到所需的架构进行链接。 这种用一个“意外”来修正另一个“意外”的做法虽然很愚蠢,但是也还算有效。 带来的最大的副作用有两个: 1. 因为要使用 `.release` 进行包的编译,这不仅会需要编译不必要的架构,也需要进行额外的编译优化,将导致包的编译速度降低。 2. 因为包被 release 优化了,所以 debug 会变得困难:比如在包中设置的断点可能无法工作,`po` 的输出可能出现问题等。 ## 小结 想要从根本上解决这些问题,需要 Xcode 中的 SPM 提供一些手段,让我们可以将 Xcode 的 Build Configuration (包括各种编译 flag 的设定) 映射到 SPM 的 Build Configuration 上。社区设想的 [Package Flavors](https://github.com/swift-embedded/swift-package-manager/blob/embedded-5.1/Documentation/Internals/PackageManagerCommunityProposal.md#package-flavors) 可以解决这个问题,但是这个课题需要涉及到 Xcode 的实现,所以需要 Apple 官方进行修改。但不幸的是,现在我们还没有看到 Apple 对此做出公开和积极的响应。 在成熟解决方案问世之前,我们能做的事情是相当有限的,总结一下: - 尽量不去自定义 Build Configuration 的名字。如果确实需要修改,理解编译配置的名字对 SPM 编译可能产生的影响。 - 如果需要用到二进制库,尽量使用包含所有架构的 xcframework 格式。如果没有提供,可以考虑使用 arm64-to-sim 把为设备编译的 arm64 转换为模拟器的 arm64。 - 常规方式绕不过的话,可以创建自己的 wrapper package,在 Package.swift 中传递需要的编译参数。 - 在 Apple Silicon 上如果实在没办法的话,可以尝试使用 Rosetta 运行 Xcode 作为临时解决方案。 URL: https://onevcat.com/2022/05/tca-4/index.html.md Published At: 2022-05-18 12:00:00 +0900 # TCA - SwiftUI 的救星?(四) 这是一系列关于 TCA 文章的最后一篇。在系列中前面的几篇里,我们简述了 [TCA 的最小 Feature 核心思想](/2021/12/tca-1/),并研究了[绑定和环境值的处理](/2021/12/tca-2/),以及 [Effect 角色和 Feature 组合的方式](/2022/03/tca-3/)等话题。作为贯穿整个系列的示例 app,现在应该已经拥有一个可用的猜数字游戏了。这篇文章会综合运用之前的内容,来看看和 UI 以及日常操作更贴近的一些话题,比如如何用 TCA 的方式展示 `List` 并让结果可以被删除,如何处理导航以及 alert 弹窗等。 > 如果你想要跟做,可以直接使用上一篇文章完成练习后最后的状态,或者从[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-3-finish)获取到起始代码。 ## 展示结果 List 在前一篇文章[最后的练习中](/2022/03/tca-3/#记录结果并显示数据),我们使用了 `var results: [GameResult]` 来存放结果并显示已完成的状态数字。现在我们的 app 还只有一个单一页面,我们打算为 app 添加一个展示所有已猜测结果,并且可以对结果进行删除的新页面。 ### 使用 `IdentifiedArray` 进行改造 在实际开始之前,来对 `results` 数组进行一些改造:使用 TCA 中定义的 `IdentifiedArray` 来代替简单的 Swift `Array`: ```swift-diff struct GameState: Equatable { var counter: Counter = .init() var timer: TimerState = .init() - var results: [GameResult] = [] + var results = IdentifiedArrayOf() var lastTimestamp = 0.0 } ``` 这会导致编译无法通过,我们先把错误放一放,来看看相比起 `Array` 来说,`IdentifiedArray` 的优势: - 和普通 `Array` 一样,`IdentifiedArray` 也尊重元素顺序,并支持基于 index 的 O(1) 随机存取。而且它提供了和 `Array` 兼容的 API。 - 但是和 `Array` 不同,`IdentifiedArray` 要求其中元素遵守 `Identifiable` 协议:也就是只有包含 `id` 属性的实例能被放入其中,而且需要确保唯一,不能有同样 `id` 的元素被放入。 - 有了 `Identifiable` 和唯一性的保证,`IdentifiedArray` 就可以利用类似字典的方式通过 `id` 快速地查找元素。 使用 `Array` 对一组数据建模,是最容易和最简单的想法。但当 app 更复杂时,处理 `Array` 很容易造成性能退化或者出错: - 要根据相等 (也就是 `Array.firstIndex(of:)`) 来查找其中的某个元素会需要 O(n) 的复杂度。 - 使用 index 来获取元素虽然是 O(1),但是如果处理异步的情况,异步操作开始时的 index 有可能和之后的 index 不一致,导致错误 (试想在异步期间,以同步的方式删除了某些元素的情况:异步操作之前保存的 index 将会失效,访问这个 index 可能获取到不同的元素,甚至引起崩溃)。 `IdentifiedArray` 通过提供基于 `Identifiable` 的访问方式,可以同时解决上面两个问题。虽然在我们的这个简单例子中使用 `Array` 也无伤大雅,但是在 TCA 的世界,甚至在普通的其他 Swift 开发时,如果被上面的问题困扰,我们都可以用 `IdentifiedArray` 来处理。 回到 app,为了让 `IdentifiedArrayOf` 能成立 (它是 `IdentifiedArray` 的类型别名),我们需要 `GameResult` 满足 `Identifiable`。因为 `Counter` 已经满足 `Identifiable` 了,所以一个简单的方法就是重构一下 `GameResult`,让它直接包含 `Counter`: ```swift-diff - struct GameResult: Equatable { + struct GameResult: Equatable, Identifiable { - let secret: Int - let guess: Int + let counter: Counter let timeSpent: TimeInterval - var correct: Bool { secret == guess } + var correct: Bool { counter.secret == counter.count } + var id: UUID { counter.id } } ``` 然后,更新 `reducer` 和 `body` 的部分,让编译通过: ```swift-diff let gameReducer = Reducer.combine( .init { state, action, environment in switch action { case .counter(.playNext): let result = GameResult( - secret: state.counter.secret, - guess: state.counter.count, + counter: state.counter, timeSpent: state.timer.duration - state.lastTimestamp ) // ... }, // ... ) struct GameView: View { var body: some View { // ... - resultLabel(viewStore.state) + resultLabel(viewStore.state.elements) } // ... } ``` 编译并运行,app 的行为应该没有改变,不过在底层我们已经转为使用 `IdentifiedArray` 来存储结果数据了。 > 这个重构在我们的 app 中不是必要的,但是 TCA 里大量使用了 `IdentifiedArray`。有意识地从一开始就使用 `IdentifiedArray` 很多时候可以节省不必要的麻烦。 ### 使用独立 feature 的方式进行构建 和之前的各个 feature 一样,result list 的画面也是由 state,reducer,environment 和 action 等要素组成的。需要再次强调,这就是 TCA 最优秀的一点:**我们只需要着眼创建简单的小组件,然后通过组合的方式把它们添加到大组件中**。 对于 State 角色,暂时只需要一个数组,我们可以简单地用 `IdentifiedArrayOf` 来表示。创建一个新的 `GameResultListView.swift` 文件,添加如下内容: ```swift import ComposableArchitecture typealias GameResultListState = IdentifiedArrayOf ``` 除了展示外,我们还希望能够删除结果,所以 action 需要能反应这个操作。 ```swift enum GameResultListAction { case remove(offset: IndexSet) } ``` `GameResultListView` 不需要特殊的 Environment,reducer 也非常简单: ```swift struct GameResultListEnvironment {} let gameResultListReducer = Reducer { state, action, environment in switch action { case .remove(let offset): state.remove(atOffsets: offset) return .none } } ``` 相信你已经对这些部分非常熟悉了。最后,创建 `GameResultListView` 并把这些东西组合起来就好了: ```swift struct GameResultListView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in List { ForEach(viewStore.state) { result in HStack { Image(systemName: result.correct ? "checkmark.circle" : "x.circle") Text("Secret: \(result.counter.secret)") Text("Answer: \(result.counter.count)") }.foregroundColor(result.correct ? .green : .red) } } } } } ``` 我们还没有把 `GameResultListView` 添加到 app 里,想要先验证它,可以添加 preview: ```swift struct GameResultListView_Previews: PreviewProvider { static var previews: some View { GameResultListView( store: .init( initialState: .init(rows: [ GameResult( counter: .init( count: 20, secret: 20, id: .init() ), timeSpent: 100), GameResult( counter: .init(), timeSpent: 100) ]), reducer: gameResultListReducer, environment: .init() ) ) } } ``` ![](/assets/images/2022/tca-resultlist-preview.png) ### 支持删除 在 SwiftUI 中添加默认的删除操作非常简单,只需要为 cell 添加 `onDelete` 就行了。作为通用 UI,我们也添加一个 `EditButton`: ```swift-diff struct GameResultListView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in List { ForEach(viewStore.rows) { result in // ... } + .onDelete { viewStore.send(.remove(offset: $0)) } } + .toolbar { + EditButton() + } } } } ``` 在 `onDelete` 中,向 `viewStore` 发送 `.remove` action,从而触发 reducer 并更新状态即可。如果在 Preview 中选择运行,我们就可以在预览画布中直接删除显示的项目了。 ## Navigation 导航 ### 基本导航 接下来通过导航的方式显示这个新创建的 `GameResultListView`。在 app 主页面中,我们已经看到过如何将小组件使用 `pullback` 的方式进行组合了。将 list feature 和 app 其他部分的 feature 进行组合的方式并没有什么不同:也就是把子组件的 state,action,reducer 和 view 都集成到父组件去。 在这里,我们计划在导航栏上添加一个 "Detail" 按钮,通过 `NavigationLink` 的方式显示结果列表。首先,在 `CounterDemoApp.swift` 中添加一个 `NavigationView`,作为整个 app 的容器: ```swift-diff struct CounterDemoApp: App { var body: some Scene { WindowGroup { + NavigationView { GameView( store: Store( initialState: GameState(), reducer: gameReducer, environment: .live) ) + } } } } ``` ##### State 在 `GameState` 中,已经存在 `var results: IdentifiedArrayOf` 数据源了,我们可以直接将它作为列表画面的数据源。 ##### Action 在 `GameResultListView` 操作结果数组的同时,我们希望把结果拉回到 `GameState.results` 里,为此,我们需要一个能处理 `GameResultListAction` 的 action。在 `GameAction` 中新加入一个成员: ```swift-diff enum GameAction { case counter(CounterAction) case timer(TimerAction) + case listResult(GameResultListAction) } ``` ##### Reducer 更新 `gameReducer`,让 `gameResultListReducer` 根据 `.listResult` 的行为把操作的结果拉回到 `results`。在 `gameReducer` 中 `combine` 的最后,添加: ```swift-diff let gameReducer = Reducer.combine( .init { state, action, environment in // ... }, // ... timerReducer.pullback( state: \.timer, action: /GameAction.timer, environment: { .init(date: $0.date, mainQueue: $0.mainQueue) } + ), + gameResultListReducer.pullback( + state: \.results, + action: /GameAction.listResult, + environment: { _ in .init() } ) ) ``` 这样,接收到 `.listResult` action 时 `gameResultListReducer` 造成的结果 (新的 result list state,也就是 `IdentifiedArrayOf`) 将通过 `\.results` 这个 `WritableKeyPath` 写回到 `GameState.results` 属性中,以完成 state 的更新。 ##### View 最后,在 `body` 中创建 `NavigationLink`,用 `scope` 把 `results` 切割出来,把新的 store 传递给 `GameResultListView` 作为目标 view,导航就完成了: ```swift-diff struct GameView: View { let store: Store var body: some View { WithViewStore(store.scope(state: \.results)) { viewStore in VStack { // ... }.onAppear { viewStore.send(.timer(.start)) } + }.toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + NavigationLink("Detail") { + GameResultListView(store: store.scope(state: \.results, action: GameAction.listResult)) + } + } } } ``` ##### 结果 运行 app,现在主页面 `GameView` 处于 `NavigationView` 环境中。当进行几个猜数字后,点击 "Detail" 按钮,app 可以导航到 `GameResultListView` 中,你也可以在详细页面里删除几个结果,然后返回到主页面:注意主页面上的计数将随着详细页面的修改而变动,它们共享单一的数据源,这避免了数据在两个页面中的不同步,一般来说是非常好的实践: 如果你对 pullback 的行为还不清楚,推荐对照前一篇文章中的这张流程图再次确认: ![](/assets/images/2022/tca-pullback-flow.png) #### 存在的问题 TCA 这类类似 Elm 的架构形式,一大特点是 State 完全决定 UI,这也是在进行 UI 测试时很重要的手段:只要我们能构建出合适的 State (model 层),我们就能期待固定的 UI,这让整个 app 的界面成为一个“纯函数”:`UI = F(State)`。 但不幸的是,上面这种简单的导航形式破坏了这个公式:显示主页面时的 State 和显示列表页面时的 State 是无法区分的,同一种状态可能会对应不同的 UI。这是因为管理导航的状态存在于 SwiftUI 内部,它在我们的 State 中没有体现出来。 如果不是很计较 app 的严肃性,那么这种简单的导航关系也不是不能接受。不过为了满足纯函数的要求,我们来看看 SwiftUI 提供的另一种导航方式,也就是基于 Binding 值控制的导航,要如何与 TCA 协同工作。 ### 基于 Binding 的导航 除了上面用到的最简单的 `init(_:destination:)` 以外,`NavigationLink` 还有一些带有 `Binding` 的变种版本,比如: ```swift init( _ titleKey: LocalizedStringKey, isActive: Binding, @ViewBuilder destination: () -> Destination ) init( _ titleKey: LocalizedStringKey, tag: V, selection: Binding, @ViewBuilder destination: () -> Destination ) where V : Hashable ``` 前者接受 `Binding`,这个 `Binding` 可以通过两种方式控制导航状态: - 当用户通过 UI 触发导航时,SwiftUI 负责将这个值设为 `true`。在使用回退按钮返回时,SwiftUI 负责将这个值设为 `false`。 - 我们也可以通过代码把这个 `Binding` 值设置为 `true` 或 `false` 来触发相应的导航和回退行为。 相比起前者的 `Bool`,后者接受 `V?` 的绑定值和一个代表当前 `NavigationLink` 的 `tag` 值:当 `selection` 的 `V` 和 `tag` 的 `V` 相同时,导航生效并展示 `destination` 的内容。为了判断这个相同,SwiftUI 要求 `V` 满足 `Hashable`。 这两个变体为 TCA 提供了机会,可以通过 State 来控制导航状态:只要我们在 `GameState` 中添加一个代表的导航状态的变量,就可以通过把这个变量转换为 Binding 并设置它,来让状态和 UI 一一对应:即 state 为 `true` 或者 non-nil 值时,显示详细页面;否则为 `false` 或 `nil` 时,显示主页面。 #### Identified 在这个例子中,我们选用 `Binding` 的方法来控制。在 `GameState` 中添加一个属性: ```swift-diff struct GameState: Equatable { // ... + var resultListState: Identified? } ``` `Binding` 中需要 `V` 满足 `Hashable`,这里我们原本的目标是让 `GameResultListState` (也就是 `IdentifiedArrayOf`) 满足 `Hashable`。这是一个相对困难的任务:我们可以为 `IdentifiedArray` 添加 `Hashable` 实现,但是这并不是一个好选择:这两个类型定义都不属于我们,我们无法控制将来 TCA 是否会为 `IdentifiedArray` 引入 `Hashable` 实现。TCA 中将一个任意值转为 `Hashable` 更简单的方式就是用 `Identified` 包装它,手动为它赋予一个 id 值,用它作为 `V` 的类型。在我们的例子中,导航只有一个单一的状态,所以我们完全可以定义一个通用的 `UUID` 作为 `NavigationLink` 的 `tag`,在 `GameView.swift` 的顶层 scope 添加下面的定义: ```swift let resultListStateTag = UUID() ``` > 使用 `Binding` 和 `tag` 的版本,更多是为了区分多个可能的导航情况 (比如一个列表中的各个选项都可能导航至下一个页面)。 > > 实际上,对于我们这里的例子,因为只有一个可能的触发导航的情况它,所以并没有必要使用 `tag` 的方式控制,只需要使用 `Binding` 就可以了。不过我们还是选择 `Binding` 的版本作为例子,因为它更具一般性。 #### Binding 和导航 Action 处理 如果你还记得 [TCA 中绑定值的处理方式](/2021/12/tca-2/#在-tca-中实现单个绑定),通过 `viewStore.binding` 操作绑定值时,可以在这个值发生变化时让 TCA 发送一个 action。我们需要在 reducer 中捕获这个 action 并为 `resultListState` 设置合适的值。在 `GameAction` 里添加控制导航的 action 成员: ```swift-diff enum GameAction { case counter(CounterAction) case listResult(GameResultListAction) case timer(TimerAction) + case setNavigation(UUID?) } ``` 然后将 `body` 中 `NavigationLink` 的部分替换为基于 `Binding` 的方式: ```swift-diff struct GameView: View { let store: Store var body: some View { WithViewStore(store.scope(state: \.results)) { viewStore in // ... }.toolbar { ToolbarItem(placement: .navigationBarTrailing) { - NavigationLink("Detail") { - GameResultListView(store: store.scope(state: \.results, action: GameAction.listResult)) - } + WithViewStore(store) { viewStore in + NavigationLink( + "Detail", + tag: resultListStateTag, + selection: viewStore.binding(get: \.resultListState?.id, send: GameAction.setNavigation), + destination: { + Text("Sample") + } + ) + } } } } ``` 当 `NavigationLink` 的 selection 被触发时,`.setNavigation(resultListStateTag)` 被发送,在 `gameReducer` 中,捕获这个 action 并进行处理: ```swift-diff let gameReducer = Reducer.combine( .init { state, action, environment in switch action { // ... + case .setNavigation(.some(let id)): + state.resultListState = .init(state.results, id: id) + return .none + case .setNavigation(.none): + state.results = state.resultListState?.value ?? [] + state.resultListState = nil + return .none } }, // ... ) ``` 接收到带有 `id` 的 `.setNavigation` action 时,我们手动设置 `resultListState`,这会触发导航。在用户退出导航时,接收到 `.setNavigation(.none)`,这时我们把 `resultListState.value` 设置回 `result`,然后把整个 `resultListState` 置为 `nil`,从导航中返回。 现在,`gameReducer` 对 `gameResultListReducer` 进行 `pullback` 时,将结果拉回 `results`。但是现在我们想要传递给 `GameResultListView` 的值已经是 `resultListState.value`,而非原来的 `results`。我们需要修改 `gameResultListReducer.pullback` 的部分: ```swift-diff let gameReducer = // ... gameResultListReducer - .pullback( - state: \.results, - action: /GameAction.listResult, - environment: { _ in .init() } - ) + .pullback( + state: \Identified.value, + action: .self, + environment: { $0 } + ) + .optional() + .pullback( + state: \.resultListState, + action: /GameAction.listResult, + environment: { _ in .init() } + ) ) ``` 如果你还记得 `pullback` 的初衷,你应该记得,它的目的是把原本作用在本地域上的 reducer 转换为能够作用在全局域的 reducer。在这里,我们想要做的是把 `gameResultListReducer` 对 `GameResultListState` 造成的变更,拉回到 `GameState.resultListState.value` 中。因为 `resultListState` 是一个可选值,因此原本在 pullback 中我们应该把 `state` 写为 `\.resultListState?.value`。不过这种写法只能给我们不可写的 `KeyPath`,而非 `pullback` 要求的 `WritableKeyPath`。为了处理可选值,TCA 提供了 `optional()` 操作,来处理可选值的 `WritableKeyPath`。这里我们可以理解为,先把 `GameResultListState` 的结果写到某个 `Identified` 的 `value` 里,然后把这个 `Identified` 包裹在一个可选值里,最后再通过 `\.resultListState` 写到 `GameState` 里。 #### IfLetStore 整个过程的最后一步,是在 `NavigationLink` 的 `destination` 里创建正确的 `GameResultListView`。和上面 pullback 的情况类似,我们不再选择使用 `results`,而是使用 `\.resultListState?.value` 来切分 store: ```swift // 注意,无法编译 store.scope( state: \.resultListState?.value, action: GameAction.listResult ) ``` 但这样做得到的是一个可选值 state 的类型 `Store`,它并不能满足 `GameResultListView` 所需要的 `Store`。TCA 在处理 store 中可选值属性的切割时,使用 `IfLetStore` 来进行包装,它会根据其中状态可选值是否为 `nil` 来构建不同的 view: ```swift-diff var body: some View { // ... NavigationLink( "Detail", tag: resultListStateTag, selection: viewStore.binding(get: \.resultListState?.id, send: GameAction.setNavigation), destination: { - Text("Sample") + IfLetStore( + store.scope(state: \.resultListState?.value, action: GameAction.listResult), + then: { GameResultListView(store: $0) } + ) } ) } ``` 至此,我们完成了最完整的使用 `Binding` 进行导航的方式。运行 app,你会发现看起来整个 app 的行为和简单导航时并没有什么区别。但是我们现在可以通过构建合适的 `GameState`,来直接显示结果详细页面。这在追踪和调试 app 中带来巨大便利,也正是 TCA 的强大之处。比如,在 `CounterDemoApp` 中,我们可以添加一些 sample: ```swift let sample: GameResultListState = [ .init(counter: .init(count: 10, secret: 10, id: .init()), timeSpent: 100), .init(counter: .init(), timeSpent: 100), ] let testState = GameState( counter: .init(), timer: .init(), resultListState: .init(sample, id: resultListStateTag), results: sample, lastTimestamp: 100 ) ``` 然后将它直接设置给 app: ```swift-diff struct CounterDemoApp: App { var body: some Scene { WindowGroup { NavigationView { GameView( store: Store( - initialState: GameState(), + initialState: testState reducer: gameReducer, environment: .live) ) } } } } ``` 现在运行 app,我们会被直接导航到结果页面。确保唯一的 state 对应的唯一 UI,可以让开发快速定位问题:只需要提供 app 出现问题时的 state,理论上就可以稳定重现和立即开始调试。 ### 更多讨论 #### SwiftUI 导航最佳实践 虽然 Apple 在 SwiftUI 导航上做了不少努力,但是传统的几种导航方式有一定缺失:不论是 navigation 还是 sheet,对于基于 Binding 的导航,控制导航状态的 Binding 值并不会被传递到 [`NavigationLink`](https://developer.apple.com/documentation/swiftui/navigationlink) 的 `destination` 或者 [`View.sheet`](https://developer.apple.com/documentation/swiftui/view/sheet(item:ondismiss:content:)) 的 `content` 中,这导致后续页面无法有效修改前置页面的数据,从而造成事实上的数据源不统一。 在 TCA 中因为不能直接修改 state,我们选择通过在 Binding 变化时发送 action 的方式更新 state。这种方法在 TCA 里非常合适,但在普通的 SwiftUI app 里虽然也可行,却显得有点儿格格不入。TCA 的维护者对此专门[开源了一套工具](https://github.com/pointfreeco/swiftui-navigation),来补充原生 SwiftUI 架构在导航上的不足,其中也包含了对于这个话题的更深入的讨论。 #### ViewStore 的各种形式 在上面的例子中,我们看到了在 `View` 中使用 `IfLetStore` 来切分 state 中的可选值的方法;对于可选值,在组合 reducer 时,我们在 pullback 之前相应地使用了 `optional()` 方法将非可选的本地状态转换为可选值的全局状态,从而完成状态回拉。 另一种特殊的 Store 形式是 `ForEachStore`,它针对 State 中的 `IdentifiedArray`,将其中每一个元素切为一个新的 Store。如果 `List` 中的每个 cell 自成一套 feature 的话 (比如示例的猜数字 app 中,允许结果列表页面的每个结果 cell 再点击进去,并显示一个 `CounterView` 来修改内容的话),这种方式将让我们很容易把 `List` 和 TCA 进行结合。与 `IfLetStore` 和 `optional()` 的关系类似,在组合 reducer 时,TCA 也为 `IdentifiedArray` 的属性准备了 [`forEach` 方法](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducer/foreach(state:action:environment:file:line:)-gvte)来把数组中的各个元素变更拉回到全局状态的对应元素中。我们将把关于数组切分和拉回的课题作为练习留给读者。 另外,对于 enum 形式的 State,TCA 也准备了相应的 `SwitchStore` 和 `CaseLet`,可以让我们以相似的语法根据不同 State 属性创建 view。关于这些内容,在理解了 TCA 的工作原理后,就都是一些类似语法糖的存在,可以在实际用到时再加以确认。 ## Alert 和结果存储 可能有细心的同学会问,在上面 `Binding` 导航的时候,为什么不直接选择在 `.setNavigation(.some(let id))` 的时候单独只设置一个 `UUID`,而保持将结果直接 pullback 到 `results` 呢?`resultListState` 存在的意义是什么?或者甚至,为什么不直接使用 `Binding` 的 `NavigationLink` 版本呢? 对于很多情况,在 list view 里直接操作 `results` 是完全可行的,不过如果我们有需要暂时保留原来数据的场景的话,在 `.setNavigation(.some(let id))` 中复制一份 `results` (在例子中我们通过创建新的 `Identified` 值进行复制),在编辑过程中保持原来 `results` 的稳定,并在完全结束后再把更改后的 `resultListState` 重新赋给 `results` 就是必要的了。 我们通过一个例子来说明,比如现在我们希望在从列表界面返回后多加一次 alert 弹窗确认,当用户确认更改后通过网络请求向服务端“汇报”这次更改,然后在成功后再刷新 UI。如果用户选择放弃修改的话,则维持原来的结果不变。 ### AlertState 显示一个 alert 在 app 开发中是非常常见的,TCA 为此内置了一个专门用来管理 alert 的类型:`AlertState`。为了让 alert 能够工作,我们可以为它添加一组 action,描述 alert 的按钮点击行为。在 `GameView.swift` 中添加: ```swift enum GameAlertAction: Equatable { case alertSaveButtonTapped case alertCancelButtonTapped case alertDismiss } ``` 然后在 `GameState` 里新增 `alert` 属性: ```swift-diff struct GameState: Equatable { // ... + var alert: AlertState? } ``` 和处理导航关系时一样,通过在 reducer 里设置 `alert` 可选值,就可以控制 alert 的显示和隐藏。我们计划在从结果列表页面返回时展示这个 alert,修改 `gameReducer` 的 `setNavigation(.none)` 分支: ```swift-diff let gameReducer = Reducer.combine( .init { state, action, environment in switch action { // ... case .setNavigation(.none): - state.results = state.resultListState?.value ?? [] - state.resultListState = nil + if state.resultListState?.value != state.results { + state.alert = .init( + title: .init("Save Changes?"), + primaryButton: .default(.init("OK"), action: .send(.alertSaveButtonTapped)), + secondaryButton: .cancel(.init("Cancel"), action: .send(.alertCancelButtonTapped)) + ) + } else { + state.resultListState = nil + } return .none } // ... ) ``` 最后,在 `GameView` 中合适的地方加上这个 `alert` 就行了: ```swift-diff struct GameView: View { // ... var body: some View { WithViewStore(store.scope(state: \.results)) { viewStore in // ... }.toolbar { // ... + }.alert( + store.scope(state: \.alert, action: GameAction.alertAction), + dismiss: .alertDismiss + ) } // ... } ``` ### 处理 dismiss 和按钮事件 不过现在代码还无法编译,因为在 reducer 里我们还没有处理 alert 相关的 action。 `.alertDismiss` action 将会在 alert 被 dismiss 的时候触发,之后 TCA 再根据具体是哪个按钮被点击,向 reducer 发送对应按钮绑定的 action。因此常规做法是在 `.alertDismiss` 中将 `state.alert` 置回 `nil`,然后在 `.alertSaveButtonTapped` 和 `.alertCancelButtonTapped` 进行相应的逻辑。在 `gameReducer` 的 `.setNavigation(.none)` 之后追加: ```swift-diff let gameReducer = Reducer.combine( .init { state, action, environment in switch action { // ... case .setNavigation(.none): // ... + case .alertAction(.alertDismiss): + state.alert = nil + return .none + case .alertAction(.alertSaveButtonTapped): + // Todo: 暂时直接设置 results,实际应该发送请求。 + state.results = state.resultListState?.value ?? [] + state.resultListState = nil + return .none + case .alertAction(.alertCancelButtonTapped): + state.resultListState = nil + return .none ``` 一切准备就绪,现在运行 app,尝试在结果列表中删除几个项目,并返回主页面。现在结果并不会直接更新了,而是先弹出确认框,并在用户点击保存时才进行更新。 ![](/assets/images/2022/tca-alert.png) ### Effect 和 Loading UI 最后我们来处理上面代码中 "Todo" 的部分:发送实际请求,并在完成时再进行 `results` 的更新。为了简单起见,这里就只用一个 `delay` 的 `Effect` 来模拟这个请求了。实际的网络请求的实现 (以及错误处理),就留作练习了。 在 `GameAction` 中添加一个 case 代表请求结果: ```swift-diff enum GameAction { // ... + case saveResult(Result) } ``` 为了显示网络请求正在进行,我们可以在 state 里添加一个属性,表示加载正在进行: ```swift-diff struct GameState: Equatable { // ... + var savingResults: Bool = false } ``` 然后将 `gameReducer` 里 `.alertSaveButtonTapped` case 中的处理替换,并添加对 `.saveResult` 的处理。 ```swift-diff case .alertAction(.alertSaveButtonTapped): - // Todo: 暂时直接设置 results,实际应该发送请求。 - state.results = state.resultListState?.value ?? [] - state.resultListState = nil - return .none + state.savingResults = true + return Effect(value: .saveResult(.success(()))) + .delay(for: 2, scheduler: environment.mainQueue) + .eraseToEffect() // ... + case .saveResult(let result): + state.savingResults = false + state.results = state.resultListState?.value ?? [] + state.resultListState = nil + return .none ``` 最后,稍微修改 `GameView` 中 `NavigationLink` 的部分,在请求途中显示一个 `ProgressView`: ```swift-diff NavigationLink( // ... label: { + if viewStore.savingResults { + ProgressView() + } else { Text("Detail") + } } ) ``` ## 总结 到这里,我想应该可以为这一个系列的 TCA 教程画上句号了。我们看到了 TCA 的各个组件以及它们的组织方式,常见的一些用法和模式,并且对背后的思想进行了探索。虽然我们没有涉及到 TCA 框架的所有部分 (毕竟这系列文章并不是使用手册,篇幅上也不允许),但是一旦我们理解和弄清了架构的思想,那么使用顶层 API 就只是手到擒来了。 对于更大和更复杂的 app 架构,TCA 框架会面临其他一些问题,比如数据在多个 feature 间共享的方式,state 过于庞大后可能带来的性能问题,以及跨越多个层级传递数据的方式等。本文写作时,这些问题都没有特别完美和通用的解决方式。不过,TCA 并没有到达 1.0 版本,它本身也在快速发展和演进中,几乎每个月都会有全新的特性甚至破坏性的变化被引入。如果你遇到了棘手的问题,或者对最佳实践有所疑问,不妨到 TCA 的[项目和 issue 页面中](https://github.com/pointfreeco/swift-composable-architecture)寻求答案或者帮助。将你的心得和体会总结,并通过某种方式回馈给社区,也将会对这个项目的建设带来好处。 想要进一步学习 TCA 的话,除了它本身带有的[几个 demo](https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples) 以外,Point-Free 实际上还开源了一个相当完整的项目:[isowords](https://github.com/pointfreeco/isowords)。另外,他们主持的[每周教学节目](https://www.pointfree.co),也对包括 TCA 在内的很多 Swift 话题进行了非常深刻的讨论,如果学有余力,我个人十分推荐。 ## 练习 如果你没有跟随本文更新代码,你可以在[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-4-start)找到下面练习的起始代码。 ### 使用 modal 进行展示 在本文中,我们使用了 `NavigationLink` 来展示结果页面。iOS app 里另一种常见的迁移方式是 modal present。尝试使用 [`sheet(item:onDismiss:content:)`](https://developer.apple.com/documentation/swiftui/view/sheet(item:ondismiss:content:)) 来呈现结果列表页面。 ### 实际的网络请求 在用户点击保存按钮时,我们使用了下面的 `Effect` 来模拟网络请求: ```swift Effect(value: .saveResult(.success(()))) .delay(for: 2, scheduler: environment.mainQueue) ``` 请你尝试把这个用来模拟的 `Effect` 替换成实际的网络请求吧!不需要真的进行数据传递,只需要随意构建一个 `dataTask` 就好,比如: ```swift let sampleRequest = URLSession.shared .dataTaskPublisher(for: URL(string: "https://example.com")!) .map { element -> String in return String(data: element.data, encoding: .utf8) ?? "" } ``` 然后把结果用 `catchToEffect` 转换为我们需要的类型。需要注意,在 reducer 中应该要合理处理错误的情况;另外,为了能够测试,这个请求应该放在环境值中,而不是直接写在 reducer 里。如果你已经忘了如何使用 TCA 处理网络请求和进行测试,可以参考前一篇文章中关于[网络请求 Effect](/2022/03/tca-3/#网络请求-effect) 的部分。 ### Loading UI 的问题 在进行保存请求时,`savingResults` 为 `true`。这种情况下,我们在 `GameView` 里把 "Detail" 按钮替换为了 `ProgressView`。但是主界面中的 "Next" 按钮依然可以点击,请求期间我们仍可把新的结果添加到 `results` 里。在网络请求结束后,`results` 里虽然可能存在新的结果,但它还是会被 `resultListState` 覆盖,导致请求期间的结果丢失。参见下面的重现步骤: 要解决这个问题,可以选择在请求期间禁用 "Next" 按钮 (比较简单的实现,但是很差很粗暴的用户体验),或者引入一种机制来合并结果 (比较好的体验,但需要更多代码)。或者你可以自行考虑其他的解决方案。 ### 尝试 ForEachStore 文中没有用到 `ForEachStore`。请参考 TCA 的相关文档,学习 `ForEachStore` 和 `Reducer.forEach` 的用法,在结果列表页面中添加一层导航,来增加对每个结果的“编辑”功能,让用户可以利用 `CounterView` 修改他们之前的猜测结果。 URL: https://onevcat.com/2022/03/tca-3/index.html.md Published At: 2022-03-17 13:50:00 +0900 # TCA - SwiftUI 的救星?(三) 在[上一篇关于 TCA 的文章](https://onevcat.com/2021/12/tca-2/)中,我们看到了绑定的工作方式以及 Environment 在管理依赖和提供易测试性时发挥的作用。在这篇文章中,我们会继续深入,来看看 TCA 中的两个重要话题:`Effect` 角色到底是什么,以及如何通过组合的方式来把多个小 Feature 组合在一起,形成更加复杂的 UI 结构。 > 如果你想要跟做,可以直接使用上一篇文章完成练习后最后的状态,或者从[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-2-finish)获取到起始代码。 ## Effect ### 什么是 Effect Elm-like 的状态管理之所以能够保持可测试及可扩展,**核心要求是 Reducer 的纯函数特性**。Environment 通过提供依赖解决了 reducer **输入阶段**的副作用 (比如 reducer 需要获取某个 `Date` 等),而 Effect 解决的则是 reducer **输出阶段**的副作用:如果在 Reducer 接收到某个行为之后,需要作出非状态变化的反应,比如发送一个网络请求、向硬盘写一些数据、或者甚至是监听某个通知等,都需要通过返回 `Effect` 进行。`Effect` 定义了需要在纯函数外执行的代码,以及处理结果的方式:一般来说这个执行过程会是一个耗时行为,行为的结果通过 `Action` 的方式在未来某个时间再次触发 reducer 并更新最终状态。TCA 在运行 reducer 的代码,并获取到返回的 `Effect` 后,**负责执行它所定义的代码,然后按照需要发送新的 `Action`**。 Counter app 当前的实现里,在 `counterReducer` 的所有 case 中我们返回的都是 `.none` 这个 Effect,也就是说,一个什么都不做的 Effect。在这一节里,我们先试着来实现一个简单的带有计时 `Effect` 的 View。 ### Timer Effect 上一篇文章结束时,我们已经有一个猜数字的游戏了。但是单纯的猜数字似乎有点无聊,要作为一个游戏,我们来添加一些“竞争”的要素,比如为谜题计时。我们希望获得一个这样的 UI: ![](/assets/images/2022/tca-timer-label-view.png) 当谜题开始时,计时器获取当前日期并显示在第一行,并在第二行对所花费的时间用秒表进行计时。我们当然可以把它作为 `CounterView` 和 `Counter` State 的一部分,但是功能上其实两者应该是互相独立的,TCA 的核心优势就是将小部件进行组合,所以遵循最小化的原则,我们会为这个 Timer 创建它自己的 View,State 和 Action。关于这部分的内容,我们在前面已经做过一遍了,因此我会加快一些速度。 首先定义 State,我们需要记录开始时间和已经经过的时间,因此 Model 层很简单: ```swift struct TimerState: Equatable { var started: Date? = nil var duration: TimeInterval = 0 } ``` 然后来到 `Action` 的定义:**开始计时**和**结束计时**这两个 action 是很明确的,问题在于我们要如何更新 `TimerState.duration` 呢?按照 TCA 的架构方式,reducer 是唯一能够设置 State 的地方,而 reducer 又需要接受某个 action 进行驱动。因此,我们显然也还是需要一个 action,来表示**每次 timer duration 的更新**,在这里我们把它叫做 `timeUpdated`: ```swift enum TimerAction { case start case stop case timeUpdated } ``` 有了 State 和 Action,接下来自然而然就是 Reducer 了。`.timeUpdated` 是最简单的,假如我们希望每次 `.timeUpdated` 的时候让 `state.duration` 增加 0.01s: ```swift struct TimerEnvironment { } let timerReducer = Reducer { state, action, environment in switch action { case .start: fatalError("Not implemented") case .timeUpdated: state.duration += 0.01 return .none case .stop: fatalError("Not implemented") } } ``` 现在,我们只需要想办法在 `.start` 的 case 里进行一些奇妙的“设定”,让 TCA 运行时每隔 10ms 发送一次 `.timeUpdated` action 就可以了。把这类行为进行一些抽象:在处理 Action 时,进行一些 TCA 系统之外的操作,并把结果转换为新的 Action 反馈到 TCA 系统里,这类行为就是一个 Effect。在 reducer 中,我们通过返回一个 `Effect` 类型的值来描述这件事情。 对于 Timer,TCA 框架直接定义了 `Effect.timer`。在 `timerReducer` 中,我们直接使用它来返回一个按时间触发的 effect: ```swift-diff struct TimerEnvironment { + // 1 + var date: () -> Date + var mainQueue: AnySchedulerOf + static var live: TimerEnvironment { + .init( + date: Date.init, + mainQueue: .main + ) + } } let timerReducer = Reducer { state, action, environment in + // 2 + struct TimerId: Hashable {} switch action { case .start: - fatalError("Not implemented") + if state.started == nil { + state.started = environment.date() + } + // 3 + return Effect.timer( + id: TimerId(), + every: .milliseconds(10), + tolerance: .zero, + on: environment.mainQueue + ).map { time -> TimerAction in + // 4 + return TimerAction.timeUpdated + } case .timeUpdated: state.duration += 0.01 return .none case .stop: fatalError("Not implemented") } } ``` 1. 类似上一篇文中,对于外部输入,我们使用环境值来进行注入。 2. 为了能够实现 Effect 的取消,我们需要为创建的 Effect 指定一个 id。这里 `TimerId` 是一个最简单的满足了 `Hashable` 的类型。 3. TCA 中直接提供了创建一个 timer 的方法,我们创建一个 `TimerId` 的实例作为这个 Effect 的 id。 4. `Effect.timer` 返回类型是 `Effect`。而在 `timerReducer` 中,我们要求返回值为 `Effect`。TCA 为 `Effect` 的 output 转换提供了人见人爱的 `map` 方法。用它就可以把返回结果转换为我们需要的类型了。 遇到 `.start` 后,reducer 返回一个 timer Effect,开启一个“副作用”。之后,每隔 10 毫秒,`.timeUpdated` 就将被发送一次,reducer 获取到这个 action,并用它来更新 `duration`。 #### Effect 的取消 在 `.stop` 中我们需要让这个 timer 停止,我们通过返回一个特殊的 `Effect.cancel` 来实现取消操作: ```swift-diff let timerReducer = Reducer { state, action, environment in struct TimerId: Hashable {} switch action { // ... case .stop: - fatalError("Not implemented") + return .cancel(id: TimerId()) // ... } ``` 通过把哈希值相同的 `TimerId` 内部类型实例传递个 `.cancel`,TCA 就会帮我们寻找到之前开始的 timer,并将它停下来了。 最困难的 reducer 部分已经搞定了,接下来创建 `TimerLabelView`,并按要求画 UI,就很简单了。和前面文章的做法完全一样,使用 `WithViewStore` 将 store 进行转换: ```swift struct TimerLabelView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in VStack(alignment: .leading) { Label( viewStore.started == nil ? "-" : "\(viewStore.started!.formatted(date: .omitted, time: .standard))", systemImage: "clock" ) Label( "\(viewStore.duration, format: .number)s", systemImage: "timer" ) } } } } ``` 想要进行一些直观上的控制的话,在 Preview 中为它再加上合适的按钮: ```swift struct TimerLabelView_Previews: PreviewProvider { static let store = Store(initialState: .init(), reducer: timerReducer, environment: .live) static var previews: some View { VStack { WithViewStore(store) { viewStore in VStack { TimerLabelView(store: store) HStack { Button("Start") { viewStore.send(.start) } Button("Stop") { viewStore.send(.stop) } }.padding() } } } } } ``` ![](/assets/images/2022/tca-timer-label-preview.gif) > 在上面的例子中,多次点击 "Start" 按钮也不会造成什么问题,这是因为在通过 `Effect.timer` 创建新的计时 Effect 时,它的内部已经使用传入的 id 先进行了一次 `.cancel` 处理。 ### 测试 Effect 在把 `TimerLabelView` 组合到我们的 app 之前,先来看看怎么测试。经常写测试的小伙伴们肯定都遇到过这样的难题:如何写好一个异步操作的测试。这类异步操作不仅仅涉及到像是本例中 timer 这种类型,也可能有像是网络请求或者等待用户输入等更具普遍意义的情形。在传统作法中,我们往往会依靠测试桩 (test stub) 和模拟 (mock) 对象加上一定的注入,或者干脆直接等待固定的时间,然后再验证结果。这些手段是有效的,但是 stub 和 mock 不仅为测试带来了更多的外部依赖和复杂度,也许要我们对实际代码进行修改,让它可以被注入;而强行等待的方法,不仅会拉长测试所需要的时间,而且随着环境不同,这些测试失效也面临着失效的可能性。 在 TCA 中,由于存在 Environment 类型,我们“天然”拥有了一个系统外部的注入点。在这一部分,我们会来看看如何通过使用注入的 scheduler 完成 `timerReducer` 的测试。 在定义 `TimerEnvironment` 时,我们将 State 系统外部的部分都囊括了进来,包括 `date` 和 `mainQueue`。在实际的 app 代码里,我们把 `AnySchedulerOf.main` (它其实就是 `DispatchQueue.main`) 赋给了 `mainQueue`,来让 timer 的事件运行在主队列上。`.main` 是和 app 以及真实世界绑定的队列,对 State 体系来说,这是一个巨大的“副作用”。在测试中,我们需要一个能被我们精确控制和操作的队列,来保证测试不被外界影响。TCA 中为我们定义了一个简单好用的类型,`TestScheduler`。 为 `TimerLabel` 添加测试: ```swift // TimerLabelTests.swift import XCTest import ComposableArchitecture @testable import CounterDemo class TimerLabelTests: XCTestCase { let scheduler = DispatchQueue.test // ... } ``` `DispatchQueue.test` 是 TCA 专门为测试定义的 ,它的类型为 `TestSchedulerOf`。`TestSchedulerOf` 不像 `.main` 这样的队列,会随着 app 和真实时间向前运行,它上面定义了一系列操作方法,让我们可以手动控制时刻。我们会在稍后看到具体的用法。 接下来添加对 timer 的实际测试: ```swift class TimerLabelTests: XCTestCase { let scheduler = DispatchQueue.test func testTimerUpdate() throws { let store = TestStore( initialState: TimerState(), reducer: timerReducer, environment: TimerEnvironment( date: { Date(timeIntervalSince1970: 100) }, mainQueue: scheduler.eraseToAnyScheduler() ) ) // ... } } ``` 正如上面提到的,我们使用 `TimerEnvironment` 进行环境注入,除了为 `date` 设定固定值外,还将 test scheduler 赋值给了 `mainQueue`。如果你已经忘了为什么需要 `Environment` 注入,可以复习一下这个系列的[上一篇文章](https://onevcat.com/2021/12/tca-2/#环境值)。 最后就是操作 `scheduler`,然后判断状态的部分了: ```swift func testTimerUpdate() throws { // ... store.send(.start) { $0.started = Date(timeIntervalSince1970: 100) } // 1 scheduler.advance(by: .milliseconds(35)) // 2 store.receive(.timeUpdated) { $0.duration = 0.01 } store.receive(.timeUpdated) { $0.duration = 0.02 } store.receive(.timeUpdated) { $0.duration = 0.03 } // 3 store.send(.stop) } ``` 1. `advance(by:)` 将这个 `scheduler` 的“时针”前进给定的时间,也就是说,让时间流逝。我们不再依赖于不精确的现实世界,也不依赖于运行这个测试的具体设备和环境,而可以准确地将计时器调到 35 毫秒的位置。 2. 使用 `.receive` 来断言接收到了某个事件,并且在闭包中验证 State 的改变。这里由于 1 中 `scheduler.advance` 的原因,我们会期望收到三次 `.timeUpdated` (因为在 `timerReducer` 的实现中我们指定了 10 毫秒触发一次 timer)。 3. 最后,向 `store` 发送 `.stop` action 来取消 timer,让它停下。 在上面的断言中,删除 2 中的任意一个 `receive` 调用或者是移除掉 3 中的 `send(.stop)`,都会导致测试的失败。 TCA 在对应 Effect 测试时,会对还未被 `receive` 的 action 以及还在运行的 Effect 进行断言,这个特性非常优秀,保证了涉及的异步操作处理“万无一失”。 ### 其他 Effect 和测试 除了 Timer 之外,我们在实际开发中还会遇到各种各样的异步操作,其中最常见的大概就是网络请求了。TCA 提供了一系列方法,来把基于闭包或者 `Publisher` 的异步操作封装成一个可供 reducer 返回的 `Effect`。 #### 网络请求 Effect 我们来看一个网络请求的例子,这个举例和正在做的猜数字 app 无关,但是却是 app 开发最常见的任务,而且它是很典型的把 `Publisher` 包装成 `Effect` 的例子,所以我希望单独来说说。 假设我们有这样的 Request,它以 `Publisher` 的形式从网络加载一些数据: ```swift import Combine let sampleRequest = URLSession.shared .dataTaskPublisher(for: URL(string: "https://example.com")!) .map { element -> String in return String(data: element.data, encoding: .utf8) ?? "" } ``` 在 TCA 中,我们已经看到了很多将外部作用放在 `Environment` 中的例子了,网络请求是一个非常大的副作用,它也不例外: ```swift struct SampleTextEnvironment { var loadText: () -> Effect var mainQueue: AnySchedulerOf static let live = SampleTextEnvironment( loadText: { sampleRequest.eraseToEffect() }, mainQueue: .main ) } ``` `eraseToEffect` 是 TCA 中定义在 `Publisher` 上的辅助方法,它把这个 `Publisher` 包装成 TCA 可用的 `Effect`。 剩下的部分就是定义相关的 State 和 Reducer 了: ```swift enum SampleTextAction: Equatable { case load case loaded(Result) } struct SampleTextState: Equatable { var loading: Bool var text: String } let sampleTextReducer = Reducer { state, action, environment in switch action { case .load: state.loading = true // 1 return environment.loadText() .receive(on: environment.mainQueue) .catchToEffect(SampleTextAction.loaded) case .loaded(let result): // 2 state.loading = false do { state.text = try result.get() } catch { state.text = "Error: \(error)" } return .none } } ``` 1. 在接受到 `.load` 后,我们返回一个 Effect 来加载数据。`environment.loadText` 的结果会在 `mainQueue` 上处理。最后,我们需要把这个 `Effect` 的结果 (在这里是一个可能失败的类型:`Effect`,它对应的结果可能是 `String`,也可能是 `URLError` 值) 转换为 reducer 初始化方法所要求的 `Effect`。这个转换通过 `catchToEffect` 来实现,它的函数签名是: ```swift func catchToEffect( _ transform: @escaping (Result) -> T ) -> Effect ``` 参数 `transform` 是一个接受 `(Result) -> T` 的函数,因此,我们只需要提供一个 `(Result -> SampleTextAction)` 的转换,就能用这个方法把 `Effect` 从 `Effect` 转换到 `Effect`,用来提供给 reducer 做返回。这也是为什么我们将 `.loaded` 定义为 `loaded(Result)` 的原因:这样一来,我们就可以使用 Swift 中 enum case 的名字可以当作函数的方法,简单地通过 `.catchToEffect(SampleTextAction.loaded)` 来完成转换了。如果你觉得难以理解,也可以把这部分代码写全,它相当于: ```swift return environment.loadText() .receive(on: environment.mainQueue) .catchToEffect({ result in return SampleTextAction.loaded(result) }) ``` 这种做法在 TCA 中处理 Effect 时很常见,对于一个接收 Effect 结果的 Action,把它的关联值定义为 `Result` 的形式,可以让 reducer 的部分的代码简化很多。 2. 在 `.load` 中返回的 `Effect` 执行完成,并经过转换后,`.loaded` action 被发送。这给了 Reducer 一个处理 Effect 结果和更新状态的机会。在 TCA 中,对于异步操作我们会大量看到这种模式。 最后,管理这个请求的 View 的部分就非常简单了,仅供参考: ```swift struct SampleTextView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in ZStack { VStack { Button("Load") { viewStore.send(.load) } Text(viewStore.text) } if viewStore.loading { ProgressView().progressViewStyle(.circular) } } } } } ``` #### 测试网络请求 你可能已经猜到了,对于网络请求 Effect 的测试,和之前的 Timer 测试应该是相似的:我们通过 Environment 注入的方式,提供合适的 `loadText` 和 `mainQueue`,就能精确控制 Effect 的行为了: ```swift class SampleTextTests: XCTestCase { let scheduler = DispatchQueue.test func testSampleTextRequest() throws { let store = TestStore( initialState: SampleTextState(loading: false, text: ""), reducer: sampleTextReducer, environment: SampleTextEnvironment( // 1 loadText: { Effect(value: "Hello World") }, mainQueue: scheduler.eraseToAnyScheduler() ) ) store.send(.load) { state in state.loading = true } // 2 scheduler.advance() store.receive(.loaded(.success("Hello World"))) { state in state.loading = false state.text = "Hello World" } } } ``` 1. 相对于提供一个实际的 `dataTask` publisher,这里直接返回了一个 "Hello World" 作为完成值的 `Effect`。它代表了一个“即将发生”的外部“返回值”。 2. 和上面 timer 的例子相似,使用 `.test` 和 `advance` 让测试向前运行。不添加参数时,`.zero` 会被使用,这代表 `scheduler` 不会发生时间流逝,但会把所有当前“堆积”的 Effect 事件都发送出去。TCA 也为我们准备了一个特殊的 `.immediate` 来简化这个过程: ```swift-diff class SampleTextTests: XCTestCase { - let scheduler = DispatchQueue.test func testSampleTextRequest() throws { let store = TestStore( initialState: SampleTextState(loading: false, text: ""), reducer: sampleTextReducer, environment: SampleTextEnvironment( loadText: { Effect(value: "Hello World") }, - mainQueue: scheduler.eraseToAnyScheduler() + mainQueue: .immediate ) ) store.send(.load) { state in state.loading = true } - scheduler.advance() store.receive(.loaded(.success("Hello World"))) { state in state.loading = false state.text = "Hello World" } } } ``` `.immediate` 会无视掉 Effect (或者说 Publisher) 中的有关时间的部分,而立即让这些 Effect 完成,因此我们从上例中把 `scheduler` 都移除掉,让代码更简化。 > 不过相应地,`.immediate` 无法对应和测试像是 `Debounce`、`Throttle` 或者 `Timer` 这类行为。对于这种需要验证时间的行为,还是应该使用 `TestScheduler`。 #### 更多类型的 Effect 以及 Effect 操作 除了 Timer 和 Publisher 外,像是传统的基于闭包回调的异步方法,或者是基于全新的 Swift Concurrency 的操作,TCA 都在 `Effect` 类型中为它们提供了相应的封装方式。 另外,对于需要进行多个异步操作的情况,TCA 也提供了诸如 `concatenate` (顺次执行多个 `Effect`) 和 `merge` (同时执行多个 `Effect`) 这样的手段。对于只需要执行,不关心返回也不需要在完成时触发新 action 的操作,使用 `fireAndForget` 就能简易地执行它们。 这篇文章并不打算对这些部分再进行详细介绍,您可以参考 TCA 的示例或者文档,找到关于它们的更多说明。 ## Composable 现在让我们回到 Demo 中来。我们有一个可以用来猜数字的 `CounterView`,还有一个用来表示时间的 `TimerLabelView` 了。现在我们来看看怎么把两者结合起来,完成一个带有计时的猜数字小游戏: ![](/assets/images/2022/tca-timer-label-view.png) 这涉及到 TCA 的核心概念,如何把不同的组件进行组合。在前面的几个例子中,我们已经看到一个小型的组件特性是怎么工作的了:每一个特性都是一组 State,Action,Reducer 和 Environment 的结合。在把小的组件进行组合时,所生成的较大组件也遵循着完全一样的方式:它也是 State,Action,Reducer 和 Environment 的结合,不过其中每个角色,也都是更小特性中对应角色的组合。 ### Game State 先从 `State` 开始,创建 `GameState`,它代表了一对 `Counter` 和 `TimerState` 的模型: ```swift struct GameState: Equatable { var counter: Counter = .init() var timer: TimerState = .init() } ``` ### Game Action 接下来是 `Action`,和 `State` 类似,我们可以简单地组合 `CounterAction` 和 `TimerAction`: ```swift enum GameAction { case counter(CounterAction) case timer(TimerAction) } ``` ### Game Environment 我们已经定义了 `CounterEnvironment` 和 `TimerEnvironment`,对于 `GameEnvironment` 来说,我们暂时定义一个空的环境类型就好: ```swift struct GameEnvironment { } ``` 在实际的 app 开发中,你可能会发现有很多时候我们会重复定义一些相同的环境值,比如 `date` 或者 `mainQueue` 等。这类相同的环境其实我们可以添加包装,让它们的复用更容易一些,我们会在本文练习部分稍微提及。在那之前,由于 `GameState` 的状态转变并不涉及更多的外部副作用,所以为了说明简便,暂时留空。 {: .alert .alert-warning} 实际上“不涉及副作用”这个论断是错误的,更准确来说,`GameState` 内部的 `Counter` 和 `TimerState` 都是有副作用的。这些副作用,在 `Game` 的层级上不应该由 `CounterEnvironment.live` 或者 `TimerEnvironment.live` 来定义,而应该从 `GameEnvironment` 中转换过去。这部分内容也被当作了练习,还请确认一下。 ### Game Reducer 最后,是最艰难的部分 `Reducer` 了。这里的核心思想有下面三条: 1. 组件的行为都是由 reducer 定义的。子组件的行为,也应该由子组件的 reducer 自己决定。因此我们需要使用已有的 `counterReducer` 和 `timerReducer`,并**把 `GameAction` 转换为子组件所需要的 `CounterAction` 或 `TimerAction` 并传递给它们**。 2. 子组件对各自 State 进行修改的结果,需要反应到父组件中,这样才能完成父组件 `View` 的刷新。在这个例子中,`counterReducer` 和 `timerReducer` 会更改各自的 `Counter` 和 `TimerState`,但是 `GameState` 中的 `counter` 和 `timer` 并不会被子组件的 reducer 更改 (因为 `GameState` 是一个 struct),因此我们需要一种方式**让子组件 reducer 能够设置父组件对应的 state**。 3. 多个组件需要联合起来工作,因此各个组件的 reducer **需要进行合并**。 TCA 中,在将多个子组件的 Reducer 组合成父组件 Reducer 时,通常结合使用 `combine` 和 `pullback`。TCA 为我们提供了一些特殊的写法,让整个过程看起来非常简洁: ```swift let gameReducer = Reducer.combine( // 3 counterReducer.pullback( state: \.counter, // 2 action: /GameAction.counter, // 1 environment: { _ in .live } ), timerReducer.pullback( state: \.timer, action: /GameAction.timer, environment: { _ in .live } ) ) ``` 在子组件 reducer 上调用 `pullback` 函数是整个过程的关键:`pullback` 负责将子组件的 reducer “拉回”成为父组件 reducer 的一部分,它首先把父组件 Action 进行转换并发送给子组件,然后把子组件的 State 变化设置回到父组件中。具体来说,上面的代码中对应的编号: 1. `/GameAction.counter` 来自一个为 TCA 开发的[工具库 CasePaths](https://github.com/pointfreeco/swift-case-paths),它通过在 enum case 之前添加斜杠,来把这个 case 转换为一个具有更丰富特性的 `CasePath` struct。在这里,`CasePath` 主要承担从接收到的父组件 Action 中将对应的子组件的 Action 提取出来的工作,这样在子组件的 reducer 中,就可以使用它们了。 2. 对于 `state`,使用 Key path 的语法创建一个 `WritableKeyPath`。子组件 reducer 中对子组件 state 的变更,最终会通过这个 `WritableKeyPath` 写回到 `GameState` 相关的属性里,最后触发 View 的刷新。 3. `pullback` 是一个转换器,它把子组件的 reducer 类型转换为父组件的 reducer 类型。最后,我们使用 `Reducer.combine` 把 `counterReducer` 和 `timerReducer` 转换后的结果合并起来,这样它们就可以同时工作了。 理解 `pullback` 在 TCA 里非常重要,作为参考,我把这个函数的签名写在下面,你可以对照各个参数再梳理一遍: ```swift struct Reducer { func pullback( state toLocalState: WritableKeyPath, action toLocalAction: CasePath, environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment ) -> Reducer // ... } ``` ### Game View 最后,`View` 的部分就很简单了: ```swift struct GameView: View { let store: Store var body: some View { WithViewStore(store.stateless) { viewStore in VStack { TimerLabelView(store: store.scope(state: \.timer, action: GameAction.timer)) CounterView(store: store.scope(state: \.counter, action: GameAction.counter)) }.onAppear { viewStore.send(.timer(.start)) } } } } ``` 在系列的[第一篇文章](https://onevcat.com/2021/12/tca-1/)中,我们就已经看到过使用 `scope` 切分 Store 的例子了。被切分后的 Store 类型上满足子组件 View 的需求,子组件 View 向这个被切分后的 Store 发送的 action (比如点击 `CounterView` 中的加号所发送的 `CounterAction.increment`),将被嵌入 (embed) 成为父组件的 action `GameAction.counter(.increment)`,并交给 `gameReducer` 处理。`gameReducer` 用我们上面提到的手段,通过 `CasePath` 把子组件的 action 进行提取 (extract),再交给回 `counterReducer` 处理。 唯一要注意的是,在创建 `WithViewStore` 时,我们给了 `store.stateless`。这是因为我们在 `GameView` 中其实没有用到其中的任何 State,这个 store 并不需要驱动 view,我们也就不需要订阅这个 Store 的内容变更。如果不添加 `stateless`,那么 `GameState` 中的任何变化,都将会触发 `WithViewStore` 中 View 内容的更新,这会带来不必要的刷新工作,降低 app 效率。 > `stateless` 相当于用 Void 对原来的 store 进行切分:`store.scope(state: { _ in () })`。 现在,把 App 的初始的 View 换成 `GameView`,运行 app,就能看到计时器和猜数字游戏一同工作了。 ```swift-diff WindowGroup { - CounterView( + GameView( store: Store( - initialState: Counter(), - reducer: counterReducer, - environment: .live + initialState: GameState(), + reducer: gameReducer, + environment: GameEnvironment() ) } ``` 用序列图可以更直观地显示 `pullback` 和 `scope` 在组件组合的时候到底做了什么。对于更大的组件,我们也可以用类似的方式一点点从小组件搭建: ![](/assets/images/2022/tca-pullback-flow.png) ## 练习 如果你没有跟随本文更新代码,你可以在[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-3-start)找到下面练习的起始代码。参考实现可以在[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-3-finish)找到。 ### 验证各个 View 的刷新 #### 最坏情况 在 `GameView` 中我们使用了 `stateless` 来切分出一个无状态的 Store。请试试看把这个 `stateless` 去掉,然后在 `TimerLabelView` 和 `CounterView` 的 `body` 中添加一些打印语句 (比如 `Self._printChanges()`),验证一下在最差状态下 `View` 的刷新机理。 #### 占位 View 在 `GameView` 中,我们现在选择了在 `VStack` 的 `onAppear` 中发送 `.timer(.start)` 来使计时器开始工作。`ViewStore` 仅仅只是用来做这件事,其实它并没有直接驱动这个 View 的显示。因此,我们也许可以尝试这样的写法: ```swift VStack { TimerLabelView(store: store.scope(state: \.timer, action: GameAction.timer)) CounterView(store: store.scope(state: \.counter, action: GameAction.counter)) WithViewStore(store) { viewStore in Color.clear .frame(width: 0, height: 0) .onAppear { viewStore.send(.timer(.start)) } } } ``` > 你可以会想用 `EmptyView` 进行占位,来作为发送 `.start` 的“载体”,但不幸的是,`EmptyView` 不会对包括 `onAppear` 在内的各种 modifier 作出任何响应和改变。所以我们这里用了 `Color.clear`,在 SwiftUI 中,这也算是一种常见的占位方式。 请验证一下这种方式和“最坏情况”的区别。 #### 重新考虑计时器的开始行为 如果我们选择把开始计时的操作移动到 `TimerLabelView` 的 body 中去: ```swift-diff struct TimerLabelView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in VStack(alignment: .leading) { //... } + .onAppear { viewStore.send(.start) } } } } ``` 那么显然,在 `GameView` 中我们就完全不需要 `WithViewStore` 了。这是一种权衡:我们失去了从外界控制计时器行为的能力,但是 `GameView` 的代码会更加简单,而且 `TimerLabelView` 也更加“自包容”了。根据情景的不同,也许我们会有不一样的选择。 ### 改造 GameEnvironment 以及测试 在引入 `GameEnvironment` 时,为了简化问题,我们将它留空,并且在 `gameReducer` 时直接使用 `.live` 来作为子组件的环境: ```swift struct GameEnvironment { } let gameReducer = Reducer.combine( counterReducer.pullback( // ... environment: { _ in .live /* CounterEnvironment */ } ), timerReducer.pullback( // ... environment: { _ in .live /* TimerEnvironment */ } ) ) ``` 这是有缺陷的:我们在测试 `gameReducer` 时,将无法通过测试环境中对 `GameEnvironment` 进行注入,来控制这两个子组件的环境,这导致 Game feature 无法测试。 `pullback` 的 `environment` 参数其实是一个函数:它负责将 `GameEnvironment` 转变为子组件所需要的环境。所以这里的解决方案是,在 `GameEnvironment` 中定义所有我们需要的环境值,然后在 `pullback` 时用这些环境值创建新的子组件环境,层层注入。请你试试看! > 在实际 app 中你可能会发现,许多组件都会共享部分状态,比如 `date`,`mainQueue` 等。 TCA 在示例 app 中给出了一种[更加通用的方式](https://github.com/pointfreeco/swift-composable-architecture/blob/ce142f2e17da621eb17321e32f1655d948af7042/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift#L159-L197),使用 `@dynamicMemberLookup` 来巧妙地把子组件的环境包装到一个 `SystemEnvironment` 中。像是 `date` 和 `mainQueue` 这类通用的环境值,就不需要每次在子组件环境中定义了。 ### 记录结果并显示数据 在 `CounterView` 的 "Next" 按钮被按下后,我开启新的题目。我们想要把每一次猜数字的结果 (无论对错) 在按下 "Next" 的时候记录下来,这样我们之后就可以查询我们猜测了哪些数据。 每次猜测的结果用下面的 `GameResult` 类型表示: ```swift struct GameResult: Equatable { let secret: Int let guess: Int let timeSpent: TimeInterval } ``` 举例来说,如果第一个数字是 10,我们在按下 "Next" 之前已经让 counter 变成了 10,且耗时 5 秒,那么我们需要记录 `GameResult(secret: 10, guess: 10, timeSpent: 5.0)`;对于没有猜对就继续的情况,我们也用同样的类型记录下来。记录的结果保存在 `GameState` 的一个数组中: ```swift-diff struct GameState: Equatable { var counter: Counter = .init() var timer: TimerState = .init() + var results: [GameResult] = [] } ``` 现在,请你在 GameView 上显示猜测过的总数,以及其中正确的个数:类似这样的 UI: ![](/assets/images/2022/tca-result-lable.png) > 提示:可以在 `gameReducer` 的 `combine` 中创建并添加一个新的 reducer,让它截取 `.counter(.playNext)` 事件并更新 `GameState.results`。 URL: https://onevcat.com/2021/12/2021-final/index.html.md Published At: 2021-12-23 10:30:00 +0900 # 2021 年终总结 ![](/assets/images/2021/final-2021.jpg) 早晨拉开窗帘,被挡住的阳光终于像是冲开了壁障一般,肆意铺在桌面上。但是窗外被风吹得摇摇晃晃的树枝,俨然是在诉说着刚刚过去的这个夜晚是何等难熬。对它们,对我们,这个冬天似乎比以往都要寒冷,大家都盼望着春天快点到来。 习惯性地打开电脑,习惯性地泡上咖啡,突然想起已经实在是到年底了。[去年的年终总结](https://onevcat.com/2021/01/2020-final/)被我硬生生拖到了今年,而今年我也不想再如故蹉跎。在屋子里久了,脑子显然会不清醒,于是我决定披上外套,出门到院子里,呼吸一点新鲜空气。顺便顶着这冬日的寒风的清醒,来捋一捋今年的思绪。当然,在最后我也会整理一下今年看过的书单、动漫和玩过的游戏,算是历来年终总结的传统节目。 ## 关于生活 疫情虽然不像去年那么热了,但也逃不出生活的主旋律之一。 金毛大统领曾经曰过“病毒会奇迹般地消失”,他在说这句话的时候,应该没有想到这个预言居然会在地球另一边的一个小岛上实现。前段时间日本疫情[突然好转](https://news.cctv.com/2021/11/04/ARTIcAOgQntPcN5jbnkETIns211104.shtml){:target="_blank"},据说研究团队给出的原因是 nsp14 变异导致病毒自我灭绝。不过我自己左看右看,其实应该也还是疫苗打得够集中,民众戴口罩够自觉,从而瞬间形成的免疫屏障所带来的结果。最近几天的感染数字其实又在抬头,随着疫苗“有效性半年”的大限将至,并且参考接种开始更早的欧洲的情况,我十分看好日本再次回到日增破万的阶段。全世界经过了这么两年的折腾,显然已经疲惫不堪了。现在[回望 2019 年的生活](https://onevcat.com/2019/12/2019-final/){:target="_blank"},就算没有踏遍东亚,至少也算是丰富多彩。 从中美贸易战开打,“世界村”就开始坍塌了,加上半路杀出新冠疫情这个程咬金,逆全球化的趋势更是无比明显。对我这种远在他乡的市井小民最深刻的影响,莫过于~~国内买的辣条都寄不过来~~两年半了都回不了一次国。各种变异株“你方唱罢我登场”,但看起来都是在往高传染低致命的方向演变。不过无论是奥密克戎的毒性多么不堪,但肉眼预计这种全球闭锁的情况,至少还要持续很长一段时间。只能说希望一切安好,希望能尽早回国看看。 其他方面就乏善可陈了。我似乎回到了以前教室食堂宿舍三点一线的生活中,每天接送小朋友们到保育园,偶尔去超市屯下货,然后就回家宅起来。也许这就叫平平凡凡才是真?我自认是耐得住寂寞的人,但偶尔也会希望路边不远处的野猫能来院子里晒晒太阳,隔壁的邻居能来一起吐槽一下时事。但现代社会就是这样残酷,别说人了,就算是猫,都要忙着去挨家挨户巡查地盘,完全没有意愿停下它那忙碌的脚步,也根本懒得看你一眼。于是,我也只好收起那期盼的眼神,退回到自己温暖的巢里,然后用厚厚的茧把自己包裹起来:可能这就是性格使然,大概本性也很难改变了吧。 ## 关于工作 一切顺利。今年虽然负责事情的总量也没变多,但升职加薪似乎倒也没少。相对于实际的代码工作来说,由于团队缺人,今年有更多的机会面试了一些人,于是对日本的 IT 和职场的情况有了更多的理解 (技术是真的弱也更有实感了)。在线面试和传统的 onsite 确实有很大的区别,面对屏幕的时候,由于音像延迟以及摄像头一般只能拍到面部,所以很难察觉到一些细节:我愿意把这些细节叫做面试中空气的变化 (你懂的)。这种细节的缺失,对于双方来说都有一点损失:本来光靠一两次面试,候选者和公司就已经很难完成对彼此全面良好的判断了,online 面试更是雪上加霜。幸好对于程序员来说,更多时候彼此面对的都是代码而非人,所以只要笔试代码干净漂亮,总还是愿意多给一些机会。降低了实际面试的要求和期望,转而增加笔试时候的比重 (或者说选择在笔试关更加严格),大概是最近面试时候的一个重要转变吧。 其他工作都有条不紊地进行着,日复一日的版本迭代,在空闲时间找机会重构烂掉的部分,偶尔进行一些技术评估和方向上的把控,保证项目能够长久持续做下去,不拖团队后腿,同时也能让同事们事半功倍,大家开开心心:这些就是我现在工作的重心。 日本的互联网市场显然不像国内千变万化。国内真是魄力无穷,前脚双减教培大整改,后脚连续约谈各种大厂反垄断,几个大锤下来业界形态就完全改变。我都能够非常明显地感受到,国内正处于一个极端重要的转型期。最近几年的政策方向,肯定会给后续十年甚至二十年的社会结构带来想象不到的影响。类似的事情大概在日本这个一潭死水的地方是很难发生的。对于我这样混日子的底层白领来说,这失去了很多机会;不过另一方面,这种缺乏可能性的安定,也让生活相对平稳,焉知非福吧。 ## 关于学习 每年充电还是要充的。 在程序设计方面,今年主要用 Rust 实际写了一点工具类的东西,大概也就两三千行的玩具,来改善开发流程。实际上选择更拿手的语言,比如 Swift,来做这件事情会更好一些,但是既然去年学了些 Rust,那有明显的钉子,自然是用新锤子比较开心。不过就像[这个知乎回答](https://www.zhihu.com/question/385243209/answer/1309383186){:target="_blank"}里说的: > 写 Rust 的感觉就像一个炼金术士在一整墙奇形怪状的玻璃仪器前小心翼翼的炼制内存, 又叫 : > > - 它现在去哪里了? > - 装东西的一百种方法 > - 猜猜它是什么类型? > - 配平 & 的艺术 > - std::mem: 置换与交换反应 实际使用时大概有一大半的时间都花在了研究 move 和 borrow,配平 `&` 以及 lifetime 上。应该是我很不熟练,所以会有这样的困惑。虽然能直观感受到内存使用上的小心谨慎,但是实际的开发体验确实有些痛苦。除非有对性能和内存安全非常敏感的需求,否则 Rust 的牛刀用来杀鸡,个人感觉是不太合适的。 Swift 也提出了关于 ARC 改进和 ownership 的[路线图](https://forums.swift.org/t/a-roadmap-for-improving-swift-performance-predictability-arc-improvements-and-ownership-control/54206){:target="_blank"}。第一感受就是,虽然表面上写的是 Swift,但骨子里真全是 Rust。暂时现在很难对这样的改动发表什么看法,只希望 Swift 团队能在易用性和安全性之间找到平衡吧。 今年闲暇时间学的语言是 [Crystal](https://crystal-lang.org){:target="_blank"},这是一门很像 Ruby 的语言,可以认为是为 Ruby 添加了强类型。其实 iOS 社区和 Ruby 有着不少天然连接,所以我对 Crystal 的关注也从很早就开始了。今年这门语言终于来到了 1.0,学起来要少很多坑。如果你有一些 Ruby 经验的话,大概半小时就能上手开始写东西了,非常方便。业界已经有不少为动态类型语言添加静态类型支持的先例了,比如 TypeScript 就取得了巨大成功。Crystal 在语法上向 Ruby 靠拢,添加编译期间的类型支持,大概也是想效仿 TS 对 JS 的成功。虽然“编译”比“转译”要讨人喜欢一些 (我估摸着大家大概都不会太喜欢 Babel 这样的东西),但是 Crystal 最后能不能被业界认可,可能还是取决于有没有像 Rails 那样的[明星框架](https://github.com/isaced/crystal-web-framework-stars){:target="_blank"}出现了。 在编程之外,我也开始了一些 Blender 的学习。趁着黑五用优惠价买了 [polygon runway 的视频教程](https://polygonrunway.com),并没有打算回到游戏行业,只是希望能够在需要的时候至少能在自己力所能及的范围内,做一些 logo 或者 3D 视觉渲染图。我自己有十年前的一些 Unity 经验,对于大部分 3D 和图形渲染的概念也都有所了解,所以上手速度还不错。视频教程虽然是全英文,也没有字幕什么的,不过就算当作工作之余转换脑筋,也还是很不错的。 ## 关于阅读 今年人慵懒了,读的书不多,需要反省。技术类的书籍都很无聊,大多都是教程级别的就不写了;科普类的杂志订阅了一些 (牛顿科学世界和国家地理什么的),内容也都零零散散。还是多介绍一些今年看过的人文社科的书吧。 ### [中国经济 2021:开启复式时代](https://book.douban.com/subject/35253106//) 这个算是年货,每年都有一本的年度经济预测。其实也不算是预测,因为很多道理都是明摆在台面上的,很多事情也都是阳谋,但是即便如此,时机和趋势的判断还是非常重要。我个人是在年初看的,经过一整年后,在 2021 即将结束的时候,又有机会再翻看一下,会有一些不同的体验。最近的中央经济工作会议也对来年进行了定调,相信关注经济的同学肯定能比我看出更多门道。 ### [2052:未来四十年的中国与世界](https://book.douban.com/subject/25704047//) 这本是很早以前的书了,作者曾经参与了上世纪 70 年代的一本非常著名的书籍《增长的极限》的写作和修订。作者团队使用模型仿真的方式,预测未来世界的情况。这些预测并非精准,但五十年前的预测确实为指引社会的发展有着积极的意义。这本书继续预测了未来 40 年的情况:成书是在 2012 年,现在十年过去,我们已经可以来验证部分预测了:书中关于中国的判断,关于新能源的展望,关于碳中和的见解,俨然已经部分成真。可以说虽然世界经历了一些波折,但显然在按照这个预测运行着,这是一本趋势判断的好书。 ### [隐秘帝国:美国工业经济和企业权力的兴衰](https://book.douban.com/subject/35469478/) 关于科氏工业的发展历程和这个商业帝国背后的一些隐秘的故事。如果你根本不知道科氏工业,那就对了:低调的掌舵者牢牢把企业控制权掌握在手,并在几个“合适”的时机出手干涉政治和法律,让自己立于不败之地。不管是前期企业杀伐,劳资纠纷还是后面政治游说的手段,都能让人对“资本主义到底是怎么回事儿”有更深的理解。 但是说实话似乎有了更深的理解后,也没太大其他作用 XD...见仁见智了。 ### [文明、现代化、价值投资与中国](https://book.douban.com/subject/34997975//) 作者李录是芒格的资产管理人。不是很清楚芒格在最近这波阿里的暴跌和中概股风暴里亏了多少。不过如果相信价值投资,或者说想要不那么心惊肉跳地靠投资赚钱的话,这本书还是很值得一看的,毕竟人家是股神弟子是吧。 ### [我真的坐不住了:骨科医生让你上班更轻松](https://book.douban.com/subject/35197174/) 年纪渐长和在家办公的双重暴击下,最近腰已经到极限了。虽然买了“钱所能及”的最好的椅子和最好的床,也在实践各种站立办公和保持运动,但是浑身不舒服的情况还是在持续。这本书分成颈腰膝三个部分,对常见的疼法和原因做了解释,并用图示科普了一些对应方法。 专业医生的谆谆教诲,不敢不铭刻于心啊。 ### [伯罗奔尼撒战争史](https://book.douban.com/subject/6794362//) 在当前的环境下,最近大概经常会听到[修昔底德陷阱](https://zh.wikipedia.org/wiki/修昔底德陷阱)这个词吧。这本书正是修昔底德的毕生巨著 (虽然和红楼梦一样没写完人就没了)。斯巴达和雅典的这场战争波及整个希腊世界,也彻底结束了古典的民主时代,是一系列巨变的开始。 实际上读起来可能会比较乏味,但是如果结合 ~~UbiBug~~ Ubisoft 的[希腊旅游模拟器](https://store.steampowered.com/app/812140/Assassins_Creed_Odyssey/?l=schinese),就还挺有意思:可以对照书中的地名和人物,让整本书变得“真实和立体”起来,进而更好地理解和思考历史上这些人物所面临的环境以及他们所作出的决策的原因。 ### [宋代物价研究](https://book.douban.com/subject/3365365/) 史料和引用非常翔实,作者花了很多精力分门别类整理和记录了宋代的上到土地房屋,下至禽蛋鼠蛤 ~~(认真的!真的有专门的一节写蛤蛤!)~~ 的各类物价情况。没有什么过多主观评价,但是如果一边阅读一边笔记的话,可以很清晰地看到从北宋到南宋的货币变化情况,并横向地推测出当时的一般民众的生活状态。对比一下今天的物价也会很有意思。 当然了,用当代的经济理论和各种货币政策去套物价情况,肯定是不准确的,但是也有一番“开了天眼”去用超越时代的理论解读历史变迁的乐趣。 ### [地球上最孤单的动物](https://book.douban.com/subject/34897038/) 和小朋友一起看的插图书,制作精美,手感和书的味道都很赞。许多动物我之前都并不认识,不过同时也有很多像是长颈鹿这样的例子,让人很震惊。像是黑知更鸟和魔鳉之类的故事 (或者说知识) 我是第一次知道。 ## 关于动漫和游戏 ### 番組 和去年的情况有些类似,作为一个“被迫宅”,今年也追了一些番。不过大部分都没什么新意,纯粹~~打发时间~~休闲放松,每个也都写一句话点评吧。如果有同学有十分推荐的番,也欢迎在评论补充。(排名不分先后,推荐指数仅代表个人意见) | 标题 | 短评 | 推荐指数 | | ------------------------------------------------ | ------------------------------------------------------------ | -------- | | 急战5秒殊死斗 | 男主分配到的“能使用别人认为你的能力”的能力,本来是很出彩的设定,我抱着去看“人性中尔虞我诈的黑暗面”的期望追的番,结果叙事完全没能发挥出来。差评。 | 1/5 | | 86-不存在的战区 | 带有一些种族主义的批判和思考。第一季很优秀,对男女主的刻画都很细腻,引人共鸣。但第二季就很一般了。单给第一季分数吧,第二季不太推荐。 | 4/5 | | Beastars 第二季 | 故事接着第一季,继续讲述狼同学的成长历程。制作和第一季一样精良,不过和大部分第一季优秀的动画一样,后续想要超越前面的都会很困难。 | 3/5 | | 女朋友and 女朋友 | 如何脚踩三条船的教程,但是不具有任何~~可操作性~~逻辑基础。只能不带脑子看个热闹,然而我看到中间就受不了弃坑了。 | 1/5 | | 转生成为了只有乙女游戏破灭Flag的邪恶大小姐第二季 | 既然去年已经看了第一季,那今年顺便把第二季也看了算了。总体和第一季差不多,继续种田种到逆后宫开满。不是很有意思。 | 2/5 | | 剃须。然后捡到女高中生。 | 本来以为是一个比较色气的番,结果反而一本正经地讨论起社会责任来,最后还发波糖...不真实,但不讨厌。 | 3/5 | | 咒术回战 | 算是今 (去) 年的现象级番了,确实制作精良。少年漫嘛,热血就行了;何况还能看五条老师开挂。 | 5/5 | | Love Live! Superstar!! | Love Live 系列的新 project,这回人数上终于收敛了一些,所以每个人的故事也能丰富一点。说白了就是去看唐可可的。唔哇..太好听了吧!(既然大家都是去听歌的,剧情的话也就那样了) | 3/5 | | 约定的梦幻岛 第二季 | 第一季很优秀,那追第二季就是理所当然了。但是抱歉,我到中期实在看不下去了,这季真的崩成一匹野马了。 | 0/5 | | 我们的重置人生 | 一般都是异世界穿越,但是这个是穿到同世界的大学入学,重新选择一条非社畜的人生。题材很有意思,也对开未来挂“帮助他人”和自我成长、价值实现等做了一些探索,还行。 | 3/5 | | 圣女魔力无所不能 | 开挂的异世界穿越番。女主穿越回去开逆向后宫的故事,之前已经有“乙女游戏破灭flag”撞档了,所以这次来了个非后宫的纯爱类。嘛,甜度够就行了,我自己一个人的话可能不太会看。 | 3/5 | | 关于我转生变成史莱姆这档事 第二季 | 萌王虽然小说长年霸榜,但是也顶不住这第二季的超慢节奏啊。其实中第一季最后就已经初见端倪了,但是第二季的表现却更慢。看睡着的时候都有。 | 2/5 | | 打了300年的史莱姆,不知不觉就练到了满级 | 轻松日常,反正无脑,剧情也没什么不妥的(认真你就输了)。合格的用来减压放松的治愈番。 | 3/5 | | 无职转生~到了异世界就拿出真本事~ | 传说中的穿越鼻祖,制作精良,名副其实。大概因为经费充足,每集OP都是正片,算是很少见了。人物刻画很细腻,角色有血有肉,世界观也很考究。推荐。 | 4.5/5 | | Vivy - 萤石眼之歌 | 霸权社制作是真的强。一般来说讲人和 AI 关系的题材一旦铺开了都很难收得住,但是这部居然能在加入了时间悖论的情况下都整个圆得不错。几个小故事都让人很感动,歌也好听,可以说是今年的一个惊喜之作。 | 5/5 | | 小林家的龙女仆S | 少有的第一季优秀第二季继续优秀的动画,火烧事件后京阿尼的第一部作品,一如既往的精彩。京阿尼并没有倒下! | 4.5/5 | | 侦探,已经死了 | 故事是好故事,但大家都是冲着白毛侦探老婆来的。前两集把白毛的人设立起来了,但之后就盒饭下线,一直到制作方发现人气不足再搬出来。只能说,讲故事的方式问题很大。不太推荐。 | 2/5 | | 佐贺偶像是传奇 第二季 | 第二季的开场真是莫名其妙,完全是为了重来而强行卖一波惨。不过整季的剧情也还可圈可点,也把花魁和 0 号的故事进行了补全。反正僵尸都能当偶像了,其他也就不要再追究了对吧,偶像番歌好听就行。 | 3.5/5 | | 世界顶尖的暗杀者转生为异世界贵族 | 穿越后宫番,没什么营养。不过这种题材就不要带那么多批判眼光了,无脑爽就行了。 | 3/5 | | 古见同学有交流障碍症 | 我不确定是否真的有“交流障碍症”这么一个病征,但是不喜欢说话,害怕和人交流的情况肯定是存在的。古见同学很幸运,周围有那么多友善的人,但现实中可能就不那么美好了。作为轻松校园喜剧可算优秀,同时多少也能让人有些思考。 | 4/5 | | 国王排名 | 画风儿童向,内容小黑暗的优秀成长番。铺设悬疑、让人感动的剧情、主角配角各种人物的成长、慢慢展开的多条暗线,以及它们和主线剧情的交织,这些都引起了观众共鸣。虽然还没完结,但从编剧到演出都展现了很高水平的动画。 | 4.5/5 | ### 游戏 去年年底天真地想着买 PS5,于是就早早把 PS4 Pro 拿去二手店卖掉了。哪里想到直到一年后的今天,PS5 都还一机难求。于是干脆彻底躺平放弃,连带着换新的索尼大法电视的计划也无限期推延了。感觉索尼从我这儿少赚了一个亿。 所以今年主要的游戏平台就只有 NS 和 PC 了,PS 再见。 #### Switch | 标题 | 游戏状态 | 短评 | 推荐指数 | | ------------------------------ | ------------ | ------------------------------------------------------------ | -------- | | 塞尔达传说 御天之剑 HD | 40小时,通关 | 虽然是冷饭重置版,但是解密部分的设计在今天看来也依然出色。如果没有玩过原版的话,还是非常推荐。 | 4.5/5 | | 妖怪学园Y ~自由欢乐的学园生活~ | 35小时,通关 | 妖怪 Watch 的衍生游戏,日本小学生里似乎很火。基本玩给小朋友看的,让她们能有一些社交话题。 | 3.5/5 | | New 宝可梦随乐拍 | 10小时,通关 | 抓拍宝可梦的游戏...比较没意思,但是小朋友们很兴奋,比较适合她们。NS 合家欢机子的定位还是很明确。 | 2/5 | | 超级马力欧3D世界+狂怒世界 | 10小时,搁置 | 3D 其实没怎么玩,狂怒世界是打通了。如果马力欧奥德赛找月亮还没过瘾的话,可以把狂怒世界看做一个 DLC,还挺有意思的。 | 4/5 | #### PC | 标题 | 游戏状态 | 短评 | 推荐指数 | | ----------------- | -------------- | ------------------------------------------------------------ | -------- | | 极限竞速:地平线4 | 10小时,搁浅 | 车枪球基本都要打到骨折我才会入,不是特别的爱好者,所以也就是玩一会儿。感觉还可以,喜欢赛车游戏的人肯定连5代都玩很久了,我也不班门弄斧了。 | 4/5 | | 双人成行 | 20小时,继续中 | 在和小朋友一起玩,不愧是年度最佳游戏,完美无瑕,无懈可击。如果你能找到基友女友老婆老妈儿子女儿甚至一条狗陪你玩的话,这肯定是培养感情的最佳选择,绝对不会让你后悔。 | 5/5 | | 凯娜:精神之桥 | 15小时,继续中 | 一个有点东亚风的冒险解密类,前期节奏有点慢,但是拿到弓以后可以biubiu射,还是很爽快。制作团队和塞尔达有些“渊源”,所以有不少致敬塞尔达的解密,资深粉丝大概会觉得很亲切。 | 4/5 | | 小白兔电商 | 20小时,通关 | 一个有点特殊的带有很弱的经营性质的 Galgame。紧跟时事,做了很多暗喻和关联,算是一个特点。因为时事性比较强,所以玩起来也会有些感触。今年比较意外和让人惊喜的作品,如果想要玩的话需要趁早,时事性不足的话大概可玩性也会降低。 | 4/5 | | 展翅翱翔 | 5小时,搁置 | 制作精美的卡牌游戏,牌都是各种鸟类,据说可以帮助玩家认识鸟类特性。初期印象感觉需要计算的不多,作为卡牌的话不是太吸引人,不过插画还是相当精美的。烦躁了打开虐虐电脑,顺便听个鸟叫放松一下。 | 3/5 | | 仙剑奇侠传7 | 27小时,通关 | 反正就是情怀,还是会买。画面进步很大,但整体的游戏素质让人沮丧。国产 RPG 一向是以剧情取胜,但是堕落到本作,连故事都讲不好,实在难堪。 | 1.5/5 | | 刺客信条 奥德赛 | 40小时,继续中 | 偶尔心血来潮会想起这个游戏还没打完,每次上去都要重新熟悉键位和操作。不过反正就是希腊旅游模拟器,慢慢玩就行了。游戏是好游戏,但是莫名得让人不想推主线。 | 3.5/5 | | 破晓传奇 | 25小时,继续中 | JRPG 的王道展开的剧情,属于那种按部就班的作品。没有太多惊喜,但是也没有什么太大缺陷的作品。探讨了一些平等啊解放啊之类的主题,反正主角团队都是一眼假的圣母就对了。 | 3.5/5 | | 神笔狗良 | 2小时,继续中 | 好吧,正式名称 Chicory:A Colorful Tale。很有趣的涂色解密游戏,刚开始玩,不过看起来很治愈。缺点是暂时没有中文版 (毕竟独立游戏),不过英文也就是初中生水平。如果有 iPad 版会更赞... | 4/5 | 写完这个,今年后面也就只剩假期了。我们明年再见! URL: https://onevcat.com/2021/12/tca-2/index.html.md Published At: 2021-12-16 09:50:00 +0900 # TCA - SwiftUI 的救星?(二) 在[上一篇关于 TCA 的文章](https://onevcat.com/2021/12/tca-1/)中,我们通过总览的方式看到了 TCA 中一个 Feature 的运作方式,并尝试实现了一个最小的 Feature 和它的测试。在这篇文章中,我们会继续深入,看看 TCA 中对 Binding 的处理,以及使用 Environment 来把依赖从 reducer 中解耦的方法。 > 如果你想要跟做,可以直接使用上一篇文章完成练习后最后的状态,或者从[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-1-finish)获取到起始代码。 ## 关于绑定 ### 绑定和普通状态的区别 在上一篇文章中,我们实现了“点击按钮” -> “发送 Action” -> “更新 State” -> “触发 UI 更新” 的流程,这解决了“状态驱动 UI”这一课题。不过,除了单纯的“通过状态来更新 UI” 以外,SwiftUI 同时也支持在反方向使用 `@Binding` 的方式把某个 State 绑定给控件,让 UI 能够不经由我们的代码,来更改某个状态。在 SwiftUI 中,我们几乎可以在所有既表示状态,又能接受输入的控件上找到这种模式,比如 `TextField` 接受 `String` 的绑定 `Binding`,`Toggle` 接受 `Bool` 的绑定 `Binding` 等。 当我们把某个状态通过 `Binding` 交给其他 view 时,这个 view 就有能力改变去直接改变状态了,实际上这是违反了 TCA 中关于只能在 reducer 中更改状态的规定的。对于绑定,TCA 中为 View Store 添加了将状态转换为一种“特殊绑定关系”的方法。我们来试试看把 Counter 例子中的显示数字的 `Text` 改成可以接受直接输入的 `TextField`。 ### 在 TCA 中实现单个绑定 首先,为 `CounterAction` 和 `counterReducer` 添加对应的接受一个字符串值来设定 `count` 的能力: ```swift-diff enum CounterAction { case increment case decrement + case setCount(String) case reset } let counterReducer = Reducer { state, action, _ in switch action { // ... + case .setCount(let text): + if let value = Int(text) { + state.count = value + } + return .none // ... }.debug() ``` 接下来,把 `body` 中原来的 `Text` 替换为下面的 `TextField`: ```swift-diff var body: some View { WithViewStore(store) { viewStore in // ... - Text("\(viewStore.count)") + TextField( + String(viewStore.count), + text: viewStore.binding( + get: { String($0.count) }, + send: { CounterAction.setCount($0) } + ) + ) + .frame(width: 40) + .multilineTextAlignment(.center) .foregroundColor(colorOfCount(viewStore.count)) } } ``` `viewStore.binding` 方法接受 `get` 和 `send` 两个参数,它们都是和当前 View Store 及绑定 view 类型相关的泛型函数。在特化 (将泛型在这个上下文中转换为具体类型) 后: - `get: (Counter) -> String` 负责为对象 View (这里的 `TextField`) 提供数据。 - `send: (String) -> CounterAction` 负责将 View 新发送的值转换为 View Store 可以理解的 action,并发送它来触发 `counterReducer`。 在 `counterReducer` 接到 `binding` 给出的 `setCount` 事件后,我们就回到使用 reducer 进行状态更新,并驱动 UI 的标准 TCA 循环中了。 > 传统的 SwiftUI 中,我们在通过 `$` 符号获取一个状态的 Binding 时,实际上是调用了它的 `projectedValue`。而 `viewStore.binding` 在内部通过将 View Store 自己包装到一个 `ObservedObject` 里,然后通过自定义的 `projectedValue` 来把输入的 `get` 和 `send` 设置给 `Binding` 使用中。对内,它通过内部存储维持了状态,并把这个细节隐藏起来;对外,它通过 action 来把状态的改变发送出去。捕获这个改变,并对应地更新它,最后再把新的状态再次通过 `get` 设置给 binding,是开发者需要保证的事情。 ### 简化代码 做一点重构:现在 `binding` 的 `get` 是从 `$0.count` 生成的 `String`,reducer 中对 `state.count` 的设定也需要先从 `String` 转换为 `Int`。我们把这部分 Mode 和 View 表现形式相关的部分抽取出来,放到 `Counter` 的一个 extension 中,作为 View Model 使用: ```swift extension Counter { var countString: String { get { String(count) } set { count = Int(newValue) ?? count } } } ``` 把 reducer 中转换 `String` 的部分替换成 `countString`: ```swift-diff let counterReducer = Reducer { state, action, _ in switch action { // ... case .setCount(let text): - if let value = Int(text) { - state.count = value - } + state.countString = text return .none // ... }.debug() ``` 在 Swift 5.2 中,`KeyPath` 已经可以被当作函数使用了,因此我们可以把 `\Counter.countString` 的类型看作 `(Counter) -> String`。同时,Swift 5.3 中 [enum case 也可以当作函数](https://github.com/apple/swift-evolution/blob/main/proposals/0280-enum-cases-as-protocol-witnesses.md),可以认为 `CounterAction.setCount` 具有类型 `(String) -> CounterAction`。两者恰好满足 `binding` 的两个参数的要求,所以可以进一步将创建绑定的部分简化: ```swift-diff // ... TextField( String(viewStore.count), text: viewStore.binding( - get: { String($0.count) }, + get: \.countString, - send: { CounterAction.setCount($0) } + send: CounterAction.setCount ) ) // ... ``` 最后,别忘了为 `.setCount` 添加测试! ### 多个绑定值 如果在一个 Feature 中,有多个绑定值的话,使用例子中这样的方式,每次我们都会需要添加一个 action,然后在 `binding` 中 `send` 它。这是千篇一律的模板代码,TCA 中设计了 `@BindableState` 和 `BindableAction`,让多个绑定的写法简单一些。具体来说,分三步: 1. 为 `State` 中的需要和 UI 绑定的变量添加 `@BindableState`。 2. 将 `Action` 声明为 `BindableAction`,然后添加一个“特殊”的 case `binding(BindingAction)` 。 3. 在 Reducer 中处理这个 `.binding`,并添加 `.binding()` 调用。 直接用代码说明会更快: ```swift-diff // 1 struct MyState: Equatable { + @BindableState var foo: Bool = false + @BindableState var bar: String = "" } // 2 - enum MyAction { + enum MyAction: BindableAction { + case binding(BindingAction) } // 3 let myReducer = //... // ... + case .binding: + return .none } + .binding() ``` 这样一番操作后,我们就可以在 View 里用类似标准 SwiftUI 的做法,使用 `$` 取 projected value 来进行 Binding 了: ```swift-diff struct MyView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in + Toggle("Toggle!", isOn: viewStore.binding(\.$foo)) + TextField("Text Field!", text: viewStore.binding(\.$bar)) } } } ``` 这样一来,即使有多个 binding 值,我们也只需要用一个 `.binding` action 就能对应了。这段代码能够工作,是因为 `BindableAction` 要求一个签名为 `BindingAction -> Self` 且名为 `binding` 的函数: ```swift public protocol BindableAction { static func binding(_ action: BindingAction) -> Self } ``` 再一次,利用了将 enum case 作为函数使用的 Swift 新特性,代码可以变得非常简单优雅。 ## 环境值 ### 猜数字游戏 回到 Counter 的例子来。既然已经有输入数字的方式了,那不如来做一个猜数字的小游戏吧! {: .alert .alert-info} 猜数字:程序随机选择 -100 到 100 之间的数字,用户输入一个数字,程序判断这个数字是否就是随机选择的数字。如果不是,返回“太大”或者“太小”作为反馈,并要求用户继续尝试输入下一个数字进行猜测。 最简单的方法,是在 `Counter` 中添加一个属性,用来持有这个随机数: ```swift-diff struct Counter: Equatable { var count: Int = 0 + let secret = Int.random(in: -100 ... 100) } ``` 检查 `count` 和 `secret` 的关系,返回答案: ```swift extension Counter { enum CheckResult { case lower, equal, higher } var checkResult: CheckResult { if count < secret { return .lower } if count > secret { return .higher } return .equal } } ``` 有了这个模型,我们就可以通过使用 `checkResult` 来在 view 中显示一个代表结果的 `Label` 了: ```swift-diff struct CounterView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in VStack { + checkLabel(with: viewStore.checkResult) HStack { Button("-") { viewStore.send(.decrement) } // ... } func checkLabel(with checkResult: Counter.CheckResult) -> some View { switch checkResult { case .lower: return Label("Lower", systemImage: "lessthan.circle") .foregroundColor(.red) case .higher: return Label("Higher", systemImage: "greaterthan.circle") .foregroundColor(.red) case .equal: return Label("Correct", systemImage: "checkmark.circle") .foregroundColor(.green) } } } ``` 最终,我们可以得到这样的 UI: ![](/assets/images/2021/tca-check-result.png) ### 外部依赖 当我们用这个 UI “蒙对”答案后,Reset 按钮虽然可以把猜测归零,但它并不能为我们重开一局,这当然有点无聊。我们来试试看把 Reset 按钮改成 New Game 按钮。 在 UI 和 `CounterAction` 里我们已经定义了 `.reset` 行为了,进行一些重命名的工作: ```swift-diff enum CounterAction { // ... - case reset + case playNext } struct CounterView: View { // ... var body: some View { // ... - Button("Reset") { viewStore.send(.reset) } + Button("Next") { viewStore.send(.playNext) } } } ``` 然后在 `counterReducer` 里处理这个情况, ```swift-diff struct Counter: Equatable { var count: Int = 0 - let secret = Int.random(in: -100 ... 100) + var secret = Int.random(in: -100 ... 100) } let counterReducer = Reducer { // ... - case .reset: + case .playNext: state.count = 0 + state.secret = Int.random(in: -100 ... 100) return .none // ... }.debug() ``` 运行 app,观察 reducer `debug()` 的输出,可以看到一切正常!太好了。 随时 Cmd + U 运行测试是大家都应该养成的习惯,这时候我们可以发现测试编译失败了。最后的任务就是修正原来的 `.reset` 测试,这也很简单: ```swift func testReset() throws { - store.send(.reset) { state in + store.send(.playNext) { state in state.count = 0 } } ``` 但是,测试的运行结果大概率会失败! ![](/assets/images/2021/tca-environment-test-failure.png) 这是因为 `.playNext` 现在不仅重置 `count`,也会随机生成新的 `secret`。而 `TestStore` 会把 `send` 闭包结束时的 `state` 和真正的由 reducer 操作的 state 进行比较并断言:前者没有设置合适的 `secret`,导致它们并不相等,所以测试失败了。 我们需要一种稳定的方式,来保证测试成功。 ### 使用环境值解决依赖 在 TCA 中,为了保证可测试性,reducer **必须**是纯函数:也就是说,相同的输入 (state, action 和 environment) 的组合,必须能给出相同的输入 (在这里输出是 state 和 effect,我们会在后面的文章再接触 effect 角色)。 ```swift let counterReducer = // ... { state, action, _ in // ... case .playNext: state.count = 0 state.secret = Int.random(in: -100 ... 100) return .none //... }.debug() ``` 在处理 `.playNext` 时,`Int.random` 显然无法保证每次调用都给出同样结果,它也是导致 reducer 变得无法测试的原因。TCA 中环境 (Environment) 的概念,就是为了对应这类外部依赖的情况。如果在 reducer 内部出现了依赖外部状态的情况 (比如说这里的 `Int.random`,使用的是自动选择随机种子的 `SystemRandomNumberGenerator`),我们可以把这个状态通过 `Environment` 进行注入,让实际 app 和单元测试能使用不同的环境。 首先,更新 `CounterEnvironment`,加入一个属性,用它来持有随机生成 `Int` 的方法。 ```swift-diff struct CounterEnvironment { + var generateRandom: (ClosedRange) -> Int } ``` 现在编译器需要我们为原来 `CounterEnvironment()` 的地方加上 `generateRandom` 的设定。我们可以直接在生成时用 `Int.random` 来创建一个 `CounterEnvironment`: ```swift-diff CounterView( store: Store( initialState: Counter(), reducer: counterReducer, - environment: CounterEnvironment() + environment: CounterEnvironment( + generateRandom: { Int.random(in: $0) } + ) ) ) ``` 一种更加常见和简洁的做法,是为 `CounterEnvironment` 定义一组环境,然后把它们传到相应的地方: ```swift-diff struct CounterEnvironment { var generateRandom: (ClosedRange) -> Int + static let live = CounterEnvironment( + generateRandom: Int.random + ) } CounterView( store: Store( initialState: Counter(), reducer: counterReducer, - environment: CounterEnvironment() + environment: .live ) ) ``` 现在,在 reducer 中,就可以使用注入的环境值来达到和原来等效的结果了: ```swift-diff let counterReducer = // ... { - state, action, _ in + state, action, environment in // ... case .playNext: state.count = 0 - state.secret = Int.random(in: -100 ... 100) + state.secret = environment.generateRandom(-100 ... 100) return .none // ... }.debug() ``` 万事俱备,回到最开始的目的 - 保证测试能顺利通过。在 test target 中,用类似的方法创建一个 `.test` 环境: ```swift extension CounterEnvironment { static let test = CounterEnvironment(generateRandom: { _ in 5 }) } ``` 现在,在生成 `TestStore` 的时候,使用 `.test`,然后在断言时生成合适的 `Counter` 作为新的 state,测试就能顺利通过了: ```swift-diff store = TestStore( initialState: Counter(count: Int.random(in: -100...100)), reducer: counterReducer, - environment: CounterEnvironment() + environment: .test ) store.send(.playNext) { state in - state.count = 0 + state = Counter(count: 0, secret: 5) } ``` > 在 `store.send` 的闭包里,我们现在直接为 `state` 设置了一个新的 `Counter`,并明确了所有期望的属性。这里也可以分开两行,写成 `state.count = 0` 以及 `state.secret = 5`,测试也可以通过。选择哪种方式都可以,但在涉及到复杂的情况下,会倾向于选择完整的赋值:在测试中,我们希望的是通过断言来比较期望 state 和实际 state 的差别,而不是重新去实现一次 reducer 中的逻辑。这可能引入混乱,因为在测试失败时你需要去排查到底是 reducer 本身的问题,还是测试代码中操作状态造成的问题。 ### 其他常见依赖 除了像是 random 系列以外,凡是会随着调用环境的变化 (包括时间,地点,各种外部状态等等) 而打破 reducer 纯函数特性的外部依赖,都应该被纳入 Environment 的范畴。常见的像是 `UUID` 的生成,当前 `Date` 的获取,获取某个运行队列 (比如 main queue),使用 Core Location 获取现在的位置信息,负责发送网络请求的网络框架等等。 它们之中有一些是可以同步完成的,比如例子中的 `Int.random`;有一些则是需要一定时间才能得到结果,比如获取位置信息和发送网络请求。对于后者,我们往往会把它转换为一个 `Effect`。我们会在下一篇文章中再讨论 `Effect`。 ## 练习 如果你没有跟随本文更新代码,你可以在[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-2-start)找到下面练习的起始代码。参考实现可以在[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-2-finish)找到。 #### 添加一个 Slider 用键盘和加减号来控制 Counter 已经不错了,但是添加一个 Slider 会更有趣。请为 CounterView 添加一个 `Slider`,用来来和 `TextField` 以及 "+" "-" `Button` 一起,控制我们的猜数字游戏。 期望的 UI 大概是这样: ![](/assets/images/2021/tca-slider-binding.png) 别忘了写测试! #### 完善 Counter,记录更多信息 为了后面功能的开发,我们需要更新一下 Counter 模型。首先,每个谜题添加一些元信息,比如谜题 ID: 在 Counter 中加上下面的属性,然后让它满足 `Identifiable`: ```swift-diff - struct Counter: Equatable { + struct Counter: Equatable, Identifiable { var count: Int = 0 var secret = Int.random(in: -100 ... 100) + var id: UUID = UUID() } ``` 在开始新一轮游戏的时候,记得更新 `id`。还有,别忘了写测试! URL: https://onevcat.com/2021/12/tca-1/index.html.md Published At: 2021-12-09 16:50:00 +0900 # TCA - SwiftUI 的救星?(一) 打算用几篇文章介绍一下 [TCA (The Composable Architecture)](https://github.com/pointfreeco/swift-composable-architecture),这是一种看起来非常契合 SwiftUI 的架构方式。 四年多前我写过一篇关于[使用单向数据流来架构 View Controller](https://onevcat.com/2017/07/state-based-viewcontroller/) 的文章,因为 UIKit 中并没有强制的 view 刷新流程,所以包括绑定数据在内的很多事情都需要自己动手,这为大规模使用造成了不小的障碍。而自那时过了两年后, SwiftUI 的发布才让这套机制有了更加合适的舞台。在 SwiftUI 发布初期,我也写过一本[相关的书籍](https://objccn.io/products/swift-ui),里面使用了一些类似的想法,但是很不完善。现在,我想要回头再看看这样的架构方式,来看看最近一段时间在社区帮助下的进化,以及它是否能成为现下更好的选择。 对于以前很少接触声明式或者类似架构的朋友来说,其中有一些概念和选择可能不太容易理解,比如为什么 Side Effect 需要额外对应,如何在不同 View 之间共享状态,页面迁移的时候如何优雅处理等等。在这一系列文章里,我会尽量按照自己的理解,尝试阐明一些常见的问题,希望能帮助读者有一个更加平滑的入门体验。 作为开篇,我们先来简单看一看现在 SwfitUI 在架构上存在的一些不足。然后使用 TCA 实现一个最简单的 View。 ## SwiftUI 很赞,但是... iOS 15 一声炮响,给开发们送来了全新版本的 SwiftUI。它不仅有更加合理的异步方法和全新特性,更是修正了诸多顽疾。可以说,从 iOS 14 开始,SwiftUI 才算逐渐进入了可用的状态。而最近随着公司的项目彻底抛弃 iOS 13,我也终于可以更多地正式在工作中用上 SwiftUI 了。 Apple 并没有像在 UIKit 中贯彻 MVC 那样,为 SwiftUI “钦定” 一个架构。虽然 SwiftUI 中提供了诸多状态管理的关键字或属性包装 (property wrapper),比如 `@State`、`@ObservedObject` 等,但是你很难说官方 SwiftUI 教程里关于[数据传递](https://developer.apple.com/tutorials/app-dev-training/passing-data-with-bindings)和[状态管理](https://developer.apple.com/tutorials/app-dev-training/managing-state-and-life-cycle)的部分,足够指导开发者构建出稳定和可扩展的 app。SwiftUI 最基础的状态管理模式,做到了 single source of truth:所有的 view 都是由状态导出的,但是它同时也存在了很多不足。简单就可以列举一些: - 复杂的状态修饰,想要“正常”使用,你至少必须要记住 `@State`,`@ObservedObject`,`@StateObject`,`@Binding`,`@EnvironmentObject` 各自的特点和区别。 - 很多修改状态的代码内嵌在 `View.body` 中,甚至只能在 `body` 中和其他 view 代码混杂在一起。同一个状态可能被多个不相关的 View 直接修改 (比如通过 `Binding`),这些修改难以被追踪和定位,在 app 更复杂的情况下会是噩梦。 - 测试困难:这可能和直觉相反,因为 SwiftUI 框架的 view 完全是由状态决定的,所以理论上来说我们只需要测试状态 (也就是 model 层) 就行,这本应是很容易的。但是如果严格按照 Apple 官方教程的基本做法,app 中会存在大量私有状态,这些状态难以 mock,而且就算可以,如何测试对这些状态的修改也是问题。 当然,这些不足都可以克服,比如死记硬背下五种属性包装的写法、尽可能减少共享可变状态来避免被意外修改、以及按照 Apple 的[推荐](https://developer.apple.com/videos/play/wwdc2019/233/)准备一组 preview 的数据然后打开 View 文件去挨个检查 Preview 的结果 (虽然有一些[自动化工具](https://www.raywenderlich.com/24426963-snapshot-testing-tutorial-for-swiftui-getting-started)帮我们解放双眼,但严肃点儿,别笑,Apple 在这个 session 里原本的意思就是让我们去查渲染结果!)。 我们真的需要一种架构,来让 SwiftUI 的使用更加轻松一些。 ## 从 Elm 获得的启示 我估摸着前端开发的圈子一年能大约能[诞生 500 多种架构](https://www.zhihu.com/question/314536318)。如果我们需要一种新架构,那去前端那边抄一下大抵是不会错的。结合 SwiftUI 的特点,[Elm](https://elm-lang.org) 就是非常优秀的“抄袭”对象。 说实话,要是你现在正好想要学习一门语言,那我想推荐的就是 Elm。不过虽然 Elm 是一门[通用编程语言](https://zh.wikipedia.org/wiki/通用编程语言),但可以说这门语言实际上只为一件事服务,那就是 Elm 架构 ( The Elm Architecture, TEA)。一个最简单的 counter 在 Elm 中长成这个样子: ```elm type Msg = Increment | Decrement update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of Increment -> ( model + 1, Cmd.none ) Decrement -> ( model - 1, Cmd.none ) view model = div [] [ button [ onClick Decrement ] [ text "-" ] , div [] [ text (String.fromInt model) ] , button [ onClick Increment ] [ text "+" ] ] ``` 如果有机会,我再写一些 Elm 或者 Haskell 的东西。在这里,我决定直接把上面这段代码“翻译”成伪 SwiftUI: ```swift enum Msg { case increment case decrement } typealias Model = Int func update(msg: Msg, model: Model) -> (Model, Cmd) { switch msg { case .increment: return (model + 1, .none) case .decrement: return (model - 1, .none) } } func view(model: Model) -> some View { HStack { Button("-") { sendMsg(.decrement) } Text("\(model)") Button("+") { sendMsg(.increment) } } } ``` #### TEA 架构组成部件 整个过程如图所示 (为了简洁,先省去了 `Cmd` 的部分,我们会在系列后面的文章再谈到这个内容): ![](/assets/images/2021/tca-simple-elm.png) 1. 用户在 view 上的操作 (比如按下某个按钮),将会以消息的方式进行发送。Elm 中的某种机制将捕获到这个消息。 2. 在检测到新消息到来时,它会和当前的 `Model` 一并,作为输入传递给 `update` 函数。这个函数通常是 app 开发者所需要花费时间最长的部分,它控制了整个 app 状态的变化。作为 Elm 架构的核心,它需要根据输入的消息和状态,演算出新的 `Model`。 3. 这个新的 model 将替换掉原有的 model,并准备在下一个 `msg` 到来时,再次重复上面的过程,去获取新的状态。 4. Elm 运行时负责在得到新 Model 后调用 `view` 函数,渲染出结果 (在 Elm 的语境下,就是一个前端 HTML 页面)。用户可以通过它再次发送新的消息,重复上面的循环。 现在,你已经对 TEA 有了基本的了解了。我们类比一下这些步骤在 SwiftUI 中的实现,可以发现步骤 4 其实已经包含在 SwiftUI 中了:当 `@State` 或 `@ObservedObject` 的 `@Published` 发生变化时,SwiftUI 会自动调用 `View.body` 为我们渲染新的界面。因此,想要在 SwiftUI 中实现 TEA,我们需要做的是实现 1 至 3。或者换句话说,我们需要的是一套规则,来把零散的 SwiftUI 状态管理的方式进行规范。TCA 正是在这方面做出了非常多的努力。 ## 第一个 TCA app 来实际做一点东西吧,比如上面的这个 Counter。新建一个 SwiftUI 项目。因为我们会涉及到大量测试的话题,所以记得把 "Include Tests" 勾选上。然后在项目的 Package Dependencies 里把 TCA 加入到依赖中: ![](/assets/images/2021/tca-add-dependency.png) > 在本文写作的 TCA 版本 (0.29.0) 中,使用 Xcode 13.2 的话将无法编译 TCA 框架。暂时可以使用 Xcode 13.1,或者等待 workaround 修正。 把 ContentView.swift 的内容替换为 ```swift struct Counter: Equatable { var count: Int = 0 } enum CounterAction { case increment case decrement } struct CounterEnvironment { } // 2 let counterReducer = Reducer { state, action, _ in switch action { case .increment: // 3 state.count += 1 return .none case .decrement: // 3 state.count -= 1 return .none } } struct CounterView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in HStack { // 1 Button("-") { viewStore.send(.decrement) } Text("\(viewStore.count)") Button("+") { viewStore.send(.increment) } } } } } ``` 基本上就是对上面 Elm 翻译的伪 SwiftUI 代码进行了一些替换:`Model` -> `Counter`,`Msg` -> `CounterAction`,`update(msg:model:)` -> `counterReducer`,`view(model:)` -> `ContentView.body`。 > `Reducer`,`Store` 和 `WithViewStore` 是 TCA 中的类型: > > - `Reducer` 是函数式编程中的常见概念,顾名思意,它将多项内容进行合并,最后返回单个结果。 > - ContentView 中,我们不直接操作 `Counter`,而是将它放在一个 `Store` 中。这个 Store 负责把 `Counter` (State) 和 Action 连接起来。 > - `CounterEnvironment` 让我们有机会为 reducer 提供自定义的运行环境,用来注入一些依赖。我们会把相关内容放到后面再解释。 上面的代码中 1 至 3,恰好就对应了 TEA 组成部件中对应的部分: #### 1. 发送消息,而非直接改变状态 任何用户操作,我们都通过向 `viewStore` 发送一个 `Action` 来表达。在这里,当用户按下 “-” 或 “+” 按钮时,我们发送对应的 `CounterAction`。选择将 Action 定义为 enum,可以带来更清晰地表达意图。但不仅如此,它还能在合并 reducer 时带来很多便利的特性,在后续文章中我们会涉及相关话题。虽然并不是强制,但是如果没有特殊理由,我们最好跟随这一实践,用 enum 来表达 Action。 #### 2. 只在 Reducer 中改变状态 我们已经说过,`Reducer` 是逻辑的核心部分。它同时也是 TCA 中最为灵活的部分,我们的大部分工作应该都是围绕打造合适的 `Reducer` 来展开的。对于状态的改变,应且仅应在 `Reducer` 中完成:它的初始化方法接受一个函数,其类型为: ```swift (inout State, Action, Environment) -> Effect ``` `inout` 的 `State` 让我们可以“原地”对 `state` 进行变更,而不需要明确地返回它。这个函数的返回值是一个 `Effect`,它代表不应该在 reducer 中进行的副作用,比如 API 请求,获取当前时间等。我们会在下一篇文章中看到这部分内容。 #### 3. 更新状态并触发渲染 在 Reducer 闭包中改变状态是合法的,新的状态将被 TCA 用来触发 view 的渲染,并保存下来等待下一次 Action 到来。在 SwiftUI 中,TCA 使用 `ViewStore` (它本身是一个 `ObservableObject`) 来通过 `@ObservedObject` 触发 UI 刷新。 有了这些内容,整个模块的运行就闭合了。在 `Preview` 的部分传入初始的 model 实例和 reducer 来创建 Store: ```swift struct ContentView_Previews: PreviewProvider { static var previews: some View { CounterView( store: Store( initialState: Counter(), reducer: counterReducer, environment: CounterEnvironment() ) } } ``` 最后,在 App 的入口将 `@main` 的内容也替换成带有 store 的 `CounterView`,整个程序就可以运行了: ```swift @main struct CounterDemoApp: App { var body: some Scene { WindowGroup { CounterView( store: Store( initialState: Counter(), reducer: counterReducer, environment: CounterEnvironment()) ) } } } ``` ## Debug 和 Test 这一套机制能正常运行的一个重要前提,是通过 model 对 view 进行渲染的部分是正确的。也就是说,我们需要相信 SwiftUI 中 `State` -> `View` 的过程是正确的 (实际上就算不正确,作为 SwiftUI 这个框架的使用者来说,我们能做的事情其实有限)。在这个前提下,我们只需要检查 Action 的发送是否正确,以及 Reducer 中对 State 的变更是否正确就行了。 TCA 中 `Reducer` 上有一个非常方便的 `debug()` 方法,它会为这个 `Reducer` 开启控制台的调试输出,打印出接收到的 Action 以及其中 State 的变化。为 `counterReducer` 加上这个调用: ```swift let counterReducer = Reducer { // ... }.debug() ``` 这时,点击按钮会给我们这样的输出,State 的变化被以 diff 的方式打印出来: ![](/assets/images/2021/tca-reducer-debug.png) `.debug()` 只会在 `#if DEBUG` 的编译条件下打印,也就是说在 Release 时其实并不产生影响。另外,当我们有更多更复杂的 `Reducer` 时,我们也可以选择只在某个或某几个 `Reducer` 上调用 `.debug()` 来帮助调试。在 TCA 中,一组关联的 State/Reducer/Action (以及 Environment) 统合起来称为一个 Feature。我们总是可以通过把小部件的 Feature 整体一起,组合形成更大的 Feature 或是添加到其他 Feature 上去,形成一组更大的功能。这种依靠组合的开发方式,可以让我们保持小 Feature 的可测试和可用性。而这种组合,也正是 The Composable Architecture 中 Composable 所代表的意涵。 现在我们还只有 Counter 这一个 Feature。随着 app 越来越复杂,在后面我们会看到更多的 Feature,以及如何通过 TCA 提供的工具,将它们组合到一起。 使用 `.debug()` 可以让我们在控制台实际看到状态变化的方式,但如果能用单元测试确保这些变化,会更加高效和有意义。在 Unit Test 里,我们添加一个测试,来验证发送 `.increment` 时的情况: ```swift func testCounterIncrement() throws { let store = TestStore( initialState: Counter(count: Int.random(in: -100...100)), reducer: counterReducer, environment: CounterEnvironment() ) store.send(.increment) { state in state.count += 1 } } ``` `TestStore` 是 TCA 中专门用来处理测试的一种 `Store`。它在接受通过 `send` 发送的 Action 的同时,还在内部带有断言。如果接收到 Action 后产生的新的 model 状态和提供的 model 状态不符,那么测试失败。上例中,`store.send(.increment)` 所对应的 State 变更,应该是 `count` 增加一,因此在 `send` 方法提供的闭包部分,我们正确更新了 state 作为最终状态。 在初始化 `Counter` 提供 `initialState` 时,我们传递了一个随机值。通过使用 Xcode 13 提供的“重复测试”功能 (右键点击对应测试左侧的图标),我们可以重复这个测试,这可以让我们通过提供不同的初始状态,来覆盖更多的情况。在这个简单的例子中可能显得“小题大作”,但是在更加复杂的场景里,这有助于我们发现一些潜藏的问题。 ![](/assets/images/2021/tca-repeatly-test.png) 如果测试失败,TCA 也会通过 dump 打印出非常漂亮的 diff 结果,让错误一目了然: ![](/assets/images/2021/tca-test-failing.png) 除了自带断言,`TestStore` 还有其他一些用法,比如用来对应时序敏感的测试。另外,通过配置合适的 `Environment`,我们可以提供稳定的 `Effect` 作为 mock。这些课题其实在我们使用其他架构时,也都会遇到,在有些情况下会很难处理。这种时候,开发者们的选择往往是“如果写测试太麻烦,那要不就算了吧”。在 TCA 这一套易用的测试套件的帮助下,我们大概很难再用这个借口逃避测试。大多数时候,书写测试反而变成一种乐趣,这对项目质量的提升和保障可谓厥功至伟。 ## Store 和 ViewStore ### 切分 Store 避免不必要的 view 更新 在这个简单的例子中,有一个很重要的部分,我决定放到本文最后进行强调,那就是 `Store` 和 `ViewStore` 的设计。`Store` 扮演的是状态持有者,同时也负责在运行的时候连接 State 和 Action。Single source of truth 是状态驱动 UI 的最基本原则之一,由于这个要求,我们希望持有状态的角色只有一个。因此很常见的选择是,整个 app 只有一个 Store。UI 对这个 Store 进行观察 (比如通过将它设置为 `@ObservedObject`),攫取它们所需要的状态,并对状态的变化作出响应。 ![](/assets/images/2021/tca-subscribe.png) 通常情况下,一个这样的 Store 中会存在非常多的状态。但是具体的 view 一般只需要一来其中一个很小的子集。比如上图中 View 1 只需要依赖 State 1,而完全不关心 State 2。 如果让 View 直接观察整个 Store,在其中某个状态发生变化时,SwiftUI 将会要求所有对 Store 进行观察的 UI 更新,这会造成所有的 view 都对 `body` 进行重新求值,是非常大的浪费。比如下图中,State 2 发生了变化,但是并不依赖 State 2 的 View 1 和 View 1-1 只是因为观察了 Store,也会由于 `@ObservedObject` 的特性,重新对 `body` 进行求值: ![](/assets/images/2021/tca-full-change.png) TCA 中为了避免这个问题,把传统意义的 Store 的功能进行了拆分,发明了 `ViewStore` 的概念: `Store` 依然是状态的实际管理者和持有者,它代表了 app 状态的**纯数据层**的表示。在 TCA 的使用者来看,`Store` 最重要的功能,是对状态进行切分,比如对于图示中的 `State` 和 `Store`: ```swift struct State1 { struct State1_1 { var foo: Int } var childState: State1_1 var bar: Int } struct State2 { var baz: Int } struct AppState { var state1: State1 var state2: State2 } let store = Store( initialState: AppState( /* */ ), reducer: appReducer, environment: () ) ``` 在将 Store 传递给不同页面时,可以使用 `.scope` 将其“切分”出来: ```swift let store: Store var body: some View { TabView { View1( store: store.scope( state: \.state1, action: AppAction.action1 ) ) View2( store: store.scope( state: \.state2, action: AppAction.action2 ) ) } } ``` 这样可以限制每个页面所能够访问到的状态,保持清晰。 ![](/assets/images/2021/tca-scope-view-store.png) 最后,再来看这一段最简单的 TCA 架构下的代码: ```swift struct CounterView: View { let store: Store var body: some View { WithViewStore(store) { viewStore in HStack { Button("-") { viewStore.send(.decrement) } Text("\(viewStore.count)") Button("+") { viewStore.send(.increment) } } } } } ``` TCA 通过 `WithViewStore` 来把一个**代表纯数据**的 `Store` 转换为 SwiftUI 可观测的数据。不出意外,当 `WithViewStore` 接受的闭包满足 `View` 协议时,它本身也将满足 `View`,这也是为什么我们能在 `CounterView` 的 `body` 直接用它来构建一个 View 的原因。`WithViewStore` 这个 view,在内部持有一个 `ViewStore` 类型,它进一步保持了对于 `store` 的引用。作为 `View`,它通过 `@ObservedObject` 对这个 `ViewStore` 进行观察,并响应它的变更。因此,如果我们的 View 持有的只是切分后的 `Store`,那么原始 Store 其他部分的变更,就不会影响到当前这个 Store 的切片,从而保证那些和当前 UI 不相关的状态改变,不会导致当前 UI 的刷新。 ![](/assets/images/2021/tca-scope-view-store-change.png) 当我们在 View 之间自上向下传递数据时,尽量保证把 Store 进行细分,就能保证模块之间互不干扰。但是,实际上在使用 TCA 做项目时,更多的情景时我们从更小的模块进行构建 (它会包含自己的一套 Feature),然后再把这些本地内容“添加”到它的上级。所以 Store 的切分将会变得自然而然。现在你可能对这部分内容还有怀疑,但是在后面的几篇文章中,会逐步深入 feature 划分和组织,在那里你可以看到更多的例子。 ### 跨 UI 框架的使用 另一方面,`Store` 和 `ViewStore` 的分离,让 TCA 可以摆脱对 UI 框架的依赖。在 SwiftUI 中,body 的刷新是 SwiftUI 运行时通过 `@ObservedObject` 属性包装所提供的特性。现在这部分内容被包含在了 `WithViewStore` 中。但是 `Store` 和 `ViewStore` 本身并不依赖于任何特定的 UI 框架。也就是说,我们也可以在 UIKit 或者 AppKit 的 app 中用同一套 API 来使用 TCA。虽然这需要我们自己去将 View 和 Model 绑定起来,会有些麻烦,但是如果你想要尽快尝试 TCA,却又不能使用 SwiftUI,也可以在 UIKit 中进行学习。你得到的经验可以很容易迁移到其他的 UI 平台 (甚至 web app) 中去。 ## 练习 为了巩固,我也准备了一些练习。完成后的项目将会作为下一篇文章的起始代码使用。不过如果你实在不想进行这些练习,或者不确定是否正确完成,每一篇文章也提供了初始代码以供参考,所以不必担心。如果你没有跟随代码部分完成这个示例,你可以在[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-1-start)找到这次练习的初始代码。参考实现可以在[这里](https://github.com/onevcat/CounterDemo/releases/tag/part-1-finish)找到。 #### 为数据文本添加颜色 为了更好地看清数字的正负,请为数字[加上颜色](https://developer.apple.com/documentation/swiftui/view/foregroundcolor(_:)):正数时用绿色显示,负数时用红色显示。 #### 添加一个 Reset 按钮 除了加和减以外,添加一个重置按钮,按下后将数字复原为 0。 #### 为 Counter 补全所有测试 现在测试中只包含了 `.increment` 的情况。请添加减号和重置按钮的相关测试。 URL: https://onevcat.com/2021/09/structured-concurrency/index.html.md Published At: 2021-09-29 09:50:00 +0900 # Swift 结构化并发 > 本文是我的新书[《Swift 异步和并发》](https://objccn.io/products/async-swift)中的部分内容,介绍了关于 Swift 中结构化并发的相关概念。如果你对学习 Swift 并发的其他话题有兴趣,也许这本书可以作为参考读物。 `async/await` 所引入的异步函数的简单写法,可以在暂停点时放弃线程,这是构建高并发系统所不可或缺的。但是异步函数本身,其实并没有解决并发编程的问题。结构化并发 (structured concurrency) 将用一个高效可预测的模型,来实现优雅的异步代码的并发。 ## 什么是结构化 “结构化” (structured) 这个词天生充满了美好的寓意:一切有条不紊、充满合理的逻辑和准则。但是结构化并不是天然的:在计算机编程的发展早期,所使用的汇编语言,甚至到 Fortran 和 Cobol 中,为了更加契合计算机运行的实际方式,只有“顺序执行”和“跳转”这两种基本控制流。使用无条件的跳转 (goto 语句) 可能会让代码运行杂乱无状。在戴克斯特拉的《GOTO 语句有害论》之后,关于是否应该使用结构化编程的争论持续了一段时间。在今天这个时间点上,我们已经可以看到,结构化编程取得了全面胜利:大部分的现代编程语言已经不再支持 `goto` 语句,或者是将它限制在了极其严苛的条件之下。而基于条件判断 (`if`),循环 (`for`/`while`) 和方法调用的结构化编程控制流已经是绝对的主流。 不过当话题来到并发编程时,我们似乎看到了当年非结构化编程的影子。也许我们正处在与当年 `goto` 语句式微的同样的历史时期,也许我们马上会见证一种更为先进的编程范式成为主流。在深入到具体的 Swift 结构化并发模型之前,我们先来看看更一般的结构化编程和结构化并发之间的关系。 ### goto 语句 goto 语句是非结构化的,它允许控制流无条件地跳转到某个标签。虽然现在看来 goto 语句已经彻底失败,完全不得人心,但是受限于编程语言的发展,goto 语句在当时是有其生存土壤的。在还没有发明代码块的概念 (也就是 `{ ... }`) 之前,基于顺序执行和跳转的控制流,不仅是最简单的天然选择,也完美契合 CPU 执行指令的方式。顺序执行的语句非常简单,它总可以找到明确的执行入口和出口,但是跳转语句就不一定了: ![](/assets/images/2021/goto.png) 程序开发的初期,控制流的设计更多地选择了贴近实际执行的方式,这也是 goto 语句被大量使用的主要原因。不过 goto 的缺点也是相当明显的:不加限制的跳转,会导致代码的可读性急剧下降。如果程序中存在 `goto`,那么就可能在**任何时候跳转到任何部分**,这样一来,程序就并不是黑匣子了:程序的抽象被破坏,你所调用的方法并不一定会把控制权还给你。另外,多次来回跳转,往往最后会变成[面条代码](https://zh.wikipedia.org/wiki/面条式代码),在调试程序时,这会是每个程序员的噩梦。 #### 结构化编程 在代码块的概念出现后,一些基本的封装带来了新的控制流方式,包括我们今天最常使用的条件语句、循环语句以及函数调用。由它们所构成的编程范式,即是我们所熟悉的结构化编程: ![](/assets/images/2021/control-flow.png) 实际上,这些控制流也可以使用 `goto` 语句来实现,而且一开始人们也认为这些新控制流仅只是 `goto` 的语法糖。不过相比于 `goto`,新控制流们拥有一个非常显著的特点:控制流从顶部入口开始,然后某些事情发生,最后控制流都在底部结束。除非死循环,否则从入口进入的代码最终一定会执行达到出口。 这不仅让代码的思维模型变得更简单,也为编译器在低层级进行优化提供了可能。如果代码作用域里没有 `goto`,那么在出口处,我们就可以确定在代码块中申请的本地资源肯定不会再被需要。这一点对于回收资源 (比如在 `defer` 中关闭文件、切断网络,甚至是自动释放内存等) 是至关重要的。 完全禁止使用 `goto` 语句已经成为了大部分现代编程语言的选择。即使有少部分语言还支持 `goto`,它们也大都遵循高德纳 (Donald Ervin Knuth) 所提出的前进分支和后退分支不得交叉的[理论](https://pic.plover.com/knuth-GOTO.pdf)。像是 `break`,`continue` 和提前 `return` 这样的控制流,依然遵循着结构化的基本原则:代码拥有单一的入口和出口。事实上我们今天用现代编程语言所写的程序,绝大部分都是结构化的了。当今,结构化编程的习惯已经深入人心,对程序员们来说,使用结构化编程来组织代码,早已如同呼吸一般自然。 ### 非结构化的并发 不过,程序的结构化并不意味着并发也是结构化的。相反,Swift 现存的并发模型面临的问题,恰恰和当年 `goto` 的情况类似。Swift 当前的并发手段,最常见的要属使用 `Dispatch` 库将任务派发,并通过回调函数获取结果: ```swift func foo() -> Bool { bar(completion: { print($0) }) baz(completion: { print($0) }) return true } func bar(completion: @escaping (Int) -> Void) { DispatchQueue.global().async { // ... completion(1) } } func baz(completion: @escaping (Int) -> Void) { DispatchQueue.global().async { // ... completion(2) } } ``` `bar` 和 `baz` 通过派发,以非阻塞的方式运行任务,并通过 `completion` 汇报结果。对于调用者的 `foo` 来说,它作为一段程序,本身是结构化的:在调用 `bar` 和 `baz` 后,程序的控制权,至少是当前线程的控制权,会回到 `foo` 中。最终控制流将到达 `foo` 的函数块的出口位置。但是,如果我们将视野扩展一些,就会发现在并发角度来看,这个控制流存在很大隐患:在 `bar` 和 `baz` 中的派发和回调,事实就是一种函数间无条件的“跳转”行为。`bar` 和 `baz` 虽然会立即将控制流交还给 `foo`,但是并发执行的行为会同时发生。这些被派发的并发操作在运行时中,并不知道自己是从哪里来的,这些调用不存在于,也不能存在于当前的调用栈上。它们在自己的线程中拥有调用栈,生命周期也和 `foo` 函数的作用域无关: ![](/assets/images/2021/unstructured-concurrency.png) 在 `foo` 到达出口时,由 `foo` 初始化的派发任务可能并没有完成。在派发后,实际上从入口开始的单个控制流将被一分为二:其中一个正常地到达程序出口,而另一个则通过派发跳转,最终“不知所踪”。即使在一段时间后,派发出去的操作通过回调函数回到闭包中,但是它并没有关于原来调用者的信息 (比如调用栈等),这只不过是一次孤独的跳转。 ![](/assets/images/2021/callback-goto.png) 除了使代码的控制流变得非常复杂以外,这样的非结构化并发还带来了另一个致命的后果:由于和调用者拥有不同的调用栈,因此它们并不知道调用者是谁,所以无法以抛出的方式向上传递错误。在基于回调的 API 中,一般将 `Error` 作为回调函数的参数传递。慵懒的开发者们总会有意无意忽视掉这种错误,Swift 5.0 中加入的 `Result` 缓解了这一现象。但是在未来某个未知的上下文中处理“突如其来”的错误,即便对于顶级开发者来说,也不是一件轻而易举的事情。 结构化并发理论认为,这种通过派发所进行的并行,藉由时间或者线程上的错位,实际上实现了任意的跳转。它只是 goto 语句的“高级”一些的形式,在本质上并没有不同,回调和闭包语法只是让它丑陋的面貌得到了一定程度遮掩。 > 除了回调和闭包,我们也有另外的一些传统并发手段,比如协议和代理模式或者 `Future` 和 `Promise` 等,但是它们实际上和回调并没有什么区别,在并发模型上带来的“随意跳转”是等价的。 ### 结构化并发 并发程序是很难写好的,想正确地设计一个复杂并发更是难上加难。不过,你有没有怀疑过,这可能并不是我们智商上有什么问题,而是我们所使用的工具并不那么趁手如意?并发难写的原因,也许只是和当年 `goto` 一样,是我们没有发明合适的理论。 `goto` 最大的问题,在于它破坏了抽象层:当我们封装一个方法并进行调用时,我们所做的事情是相信这个方法会为我们完成它所声称的事情,把它看作一个黑盒。但是如果存在 `goto`,这个抽象假设就不再有效。你必须仔细深入到黑盒里面,去研究它的跳转方式:因为黑盒并不一定会乖乖把控制权还给你,而是会把调用控制流引到其他任意地方去。 非结构化的并发面临类似的问题:一旦我们的并发框架中允许使用派发回调模式,那么我们在调用任意一个函数时,我们都会存在这样的担忧: - 这个函数会不会产生一个后台任务? - 这个函数虽然返回了,但是它所产生的后台任务可能还在运行,它什么时候会结束,它结束后会产生怎么样的行为? - 作为调用者,我应该在哪里、以怎样的方式处理回调? - 我需要保持这个函数用到的资源吗?后台任务会自动去持有这些资源吗?我需要自己去释放它们吗? - 后台任务是否可以被管理,比如想要取消的话应该怎么做? - 派发出去的任务会不会再去派发别的任务?别的这些任务会被正确管理吗?如果取消了这个派发出去的任务,那些被二次派发的任务也会被正确取消吗? 这些答案并没有通用的约定,也没有编译器或运行时的保证。你很可能需要深入到每个函数的实现去寻找答案,或者只能依赖于那些脆弱且容易过时的文档 (前提还得有人写文档!) 然后不断自行猜测。和 `goto` 一样,派发回调破坏了并发的黑盒。它让我们所希冀和依赖的抽象大厦轰然坍塌,让我们原本可以用来在并发程序的天空中自由翱翔的双翼霎时折断。 结构化并发并没有很长的历史,它的基本概念由 Martin Sústrik 在 2016 年[首次提出](https://250bpm.com/blog:71/index.html),之后 Nathaniel Smith 用一篇[《Go 语句有害论》](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/)笔记“致敬”了当年对 `goto` 的批评,并从更高层阐明了结构化并发的做法,同时给出了一个 Python 库来证明和实践这些概念。我相信 Swift 团队在设计并发模型时,或多或少也参考了这些讨论,并吸收了相关经验。就算不是唯一,Swift 现在也是少数几个在原生层面上将结构化并发加入到标准库的语言之一。 那么,到底什么是结构化并发? 如果要用一句话概括,那就是即使进行并发操作,也要保证控制流路径的单一入口和单一出口。程序可以产生多个控制流来实现并发,但是所有的并发路径在出口时都应该处于完成 (或取消) 状态,并合并到一起。 ![](/assets/images/2021/structured-concurrency.png) 这种将并发路径统合的做法,带来的一个非常明显的好处:它让抽象层重新有效。`foo` 现在是严格“自包含”的:在 `foo` 中产生的额外控制流路径,都将在 `foo` 中收束。这个方法现在回到了黑盒状态,在结构化并发的语境下,我们可以确信代码不会跳转到结构外,控制流最终会回到掌握之中。 为了将并发路径合并,程序需要具有暂停等待其他部分的能力。异步函数恰恰满足了这个条件:使用异步函数来获取暂停主控制流的能力,函数可以执行其他的异步并发操作并等待它们完成,最后主控制流和并发控制流统合后,从单一出口返回给调用者。这也是我们在之前就将异步函数称为结构化并发基础的原因。 ## 基于 Task 的结构化并发模型 在 Swift 并发编程中,结构化并发需要依赖异步函数,而异步函数又必须运行在某个任务上下文中,因此可以说,想要进行结构化并发,必须具有任务上下文。实际上,Swift 结构化并发就是以任务为基本要素进行组织的。 ### 当前任务状态 Swift 并发编程把异步操作抽象为任务,在任意的异步函数中,我们总可是使用 `withUnsafeCurrentTask` 来获取和检查当前任务: ```swift override func viewDidLoad() { super.viewDidLoad() withUnsafeCurrentTask { task in // 1 print(task as Any) // => nil } Task { // 2 await foo() } } func foo() async { withUnsafeCurrentTask { task in // 3 if let task = task { // 4 print("Cancelled: \(task.isCancelled)") // => Cancelled: false print(task.priority) // TaskPriority(rawValue: 33) } else { print("No task") } } } ``` 1. `withUnsafeCurrentTask` 本身不是异步函数,你也可以在普通的同步函数中使用它。如果当前的函数并没有运行在任何任务上下文环境中,也就是说,到 `withUnsafeCurrentTask` 为止的调用链中如果没有异步函数的话,这里得到的 `task` 会是 `nil`。 2. 使用 `Task` 的初始化方法,可以得到一个新的任务环境。在上一章中我们已经看到过几种开始任务的方式了。 3. 对于 `foo` 的调用,发生在上一步的 `Task` 闭包作用范围中,它的运行环境就是这个新创建的 `Task`。 4. 对于获取到的 `task`,可以访问它的 `isCancelled` 和 `priority` 属性检查它是否已经被取消以及当前的优先级。我们甚至可以调用 `cancel()` 来取消这个任务。 要注意任务的存在与否和函数本身是不是异步函数并没有必然关系,这是显然的:同步函数也可以在任务上下文中被调用。比如下面的 `syncFunc` 中,`withUnsafeCurrentTask` 也会给回一个有效任务: ```swift func foo() async { withUnsafeCurrentTask { task in // ... } syncFunc() } func syncFunc() { withUnsafeCurrentTask { task in print(task as Any) // => Optional( // UnsafeCurrentTask(_task: (Opaque Value)) // ) } } ``` 使用 `withUnsafeCurrentTask` 获取到的任务实际上是一个 `UnsafeCurrentTask` 值。和 Swift 中其他的 `Unsafe` 系 API 类似,Swift 仅保证它在 `withUnsafeCurrentTask` 的闭包中有效。你不能存储这个值,也不能在闭包之外调用或访问它的属性和方法,那会导致未定义的行为。 因为检查当前任务的状态相对是比较常用的操作,Swift 为此准备了一个“简便方法”:使用 `Task` 的静态属性来获取当前状态,比如: ```swift extension Task where Success == Never, Failure == Never { static var isCancelled: Bool { get } static var currentPriority: TaskPriority { get } } ``` 虽然被定义为 `static var`,但是它们**并不表示**针对所有 `Task` 类型通用的某个全局属性,而是表示当前任务的情况。因为一个异步函数的运行环境必须有且仅会有一个任务上下文,所以使用 `static` 变量来表示这唯一一个任务的特性,是可以理解的。相比于每次去获取 `UnsafeCurrentTask`,这种写法更加简单。比如,我们可以在不同的任务上下文中使用 `Task.isCancelled` 检查任务的取消情况: ```swift Task { let t1 = Task { print("t1: \(Task.isCancelled)") } let t2 = Task { print("t2: \(Task.isCancelled)") } t1.cancel() print("t: \(Task.isCancelled)") } // 输出: // t: false // t1: true // t2: false ``` ### 任务层级 上例中虽然 `t1` 和 `t2` 是在外层 `Task` 中再新生成并进行并发的,但是它们之间没有从属关系,并不是结构化的。这一点从 `t: false` 先于其他输出就可以看出,`t1` 和 `t2` 的执行都是在外层 `Task` 闭包结束后才进行的,它们**逃逸**出去了,这和结构化并发的收束规定不符。 想要创建结构化的并发任务,就需要让内层的 `t1` 和 `t2` 与外层 `Task` 具有某种从属关系。你可以已经猜到了,外层任务作为根节点,内层任务作为叶子节点,就可以使用树的数据结构,来描述各个任务的从属关系,并进而构建结构化的并发了。这个层级关系,和 UI 开发时的 View 层级关系十分相似。 通过用树的方式组织任务层级,我们可以获取下面这些有用特性: - 一个任务具有它自己的优先级和取消标识,它可以拥有若干个子任务 (叶子节点) 并在其中执行异步函数。 - 当一个父任务被取消时,这个父任务的取消标识将被设置,并向下传递到所有的子任务中去。 - 无论是正常完成还是抛出错误,子任务会将结果向上报告给父任务,在所有子任务正常完成或者抛出之前,父任务是不会被完成的。 当任务的根节点退出时,我们通过等待所有的子节点,来保证并发任务都已经退出。树形结构允许我们在某个子节点扩展出更多的二层子节点,来组织更复杂的任务。这个子节点也许要遵守同样的规则,等待它的二层子节点们完成后,它自身才能完成。这样一来,在这棵树上的所有任务就都结构化了。 在 Swift 并发中,在任务树上创建一个叶子节点,有两种方法:通过任务组 (task group) 或是通过 `async let` 的异步绑定语法。我们来看看两者的一些异同。 ### 任务组 #### 典型应用 在任务运行上下文中,或者更具体来说,在某个异步函数中,我们可以通过 `withTaskGroup` 为当前的任务添加一组结构化的并发子任务: ```swift struct TaskGroupSample { func start() async { print("Start") // 1 await withTaskGroup(of: Int.self) { group in for i in 0 ..< 3 { // 2 group.addTask { await work(i) } } print("Task added") // 4 for await result in group { print("Get result: \(result)") } // 5 print("Task ended") } print("End") } private func work(_ value: Int) async -> Int { // 3 print("Start work \(value)") await Task.sleep(UInt64(value) * NSEC_PER_SEC) print("Work \(value) done") return value } } ``` 解释一下上面注释中的数字标注。使用 `withTaskGroup` 可以开启一个新的任务组,它的完整的函数签名是: ```swift func withTaskGroup( of childTaskResultType: ChildTaskResult.Type, returning returnType: GroupResult.Type = GroupResult.self, body: (inout TaskGroup) async -> GroupResult ) async -> GroupResult ``` 1. 这个签名看起来十分复杂,有点吓人,我们来解释一下。`childTaskResultType` 正如其名,我们需要指定子任务们的返回类型。同一个任务组中的子任务只能拥有同样的返回类型,这是为了让 `TaskGroup` 的 API 更加易用,让它可以满足带有强类型的 `AsyncSequence` 协议所需要的假设。`returning` 定义了整个任务组的返回值类型,它拥有默认值,通过推断就可以得到,我们一般不需要理会。在 `body` 的参数中能得到一个 `inout` 修饰的 `TaskGroup`,我们可以通过使用它来向当前任务上下文添加结构化并发子任务。 2. `addTask` API 把新的任务添加到当前任务中。被添加的任务会在调度器获取到可用资源后立即开始执行。在这里的例子里,`for...in` 循环中的三个任务会被立即添加到任务组里,并开始执行。 3. 在实际工作开始时,我们进行了一次 `print` 输出,这让我们可以更容易地观测到事件的顺序。 4. `group` 满足 `AsyncSequence`,因此我们可以使用 `for await` 的语法来获取子任务的执行结果。`group` 中的某个任务完成时,它的结果将被放到异步序列的缓冲区中。每当 `group` 的 `next` 会被调用时,如果缓冲区里有值,异步序列就将它作为下一个值给出;如果缓冲区为空,那么就等待下一个任务完成,这是异步序列的标准行为。 5. `for await` 的结束意味着异步序列的 `next` 方法返回了 `nil`,此时`group` 中的子任务已经全部执行完毕了,`withTaskGroup` 的闭包也来到最后。接下来,外层的 "End" 也会被输出。整个结构化并发结束执行。 调用上面的代码,输出结果为: ```swift Task { await TaskGroupSample().start() } // 输出: // Start // Task added // Start work 0 // Start work 1 // Start work 2 // Work 0 done // Get result: 0 // Work 1 done // Get result: 1 // Work 2 done // Get result: 2 // Task ended // End ``` 由 `work` 定义的三个异步操作并发执行,它们各自运行在独自的子任务空间中。这些子任务在被添加后即刻开始执行,并最终在离开 `group` 作用域时再汇集到一起。用一个图表,我们可以看出这个结构化并发的运行方式: ![](/assets/images/2021/taskground-concurrency.png) #### 隐式等待 为了获取子任务的结果,我们在上例中使用 `for await` 明确地等待 `group` 完成。这从语义上明确地满足结构化并发的要求:子任务会在控制流到达底部前结束。不过一个常见的疑问是,其实编译器并没有强制我们书写 `for await` 代码。如果我们因为某种原因,比如由于用不到这些结果,而导致忘了等待 `group`,会发生什么呢?任务组会不会因为没有等待,而导致原来的控制流不会暂停,就这样继续运行并结束?这样是不是违反了结构化并发的需要? 好消息是,即使我们没有明确 `await` 任务组,编译器在检测到结构化并发作用域结束时,会为我们自动添加上 `await` 并在等待所有任务结束后再继续控制流。比如,在上面的代码中,如果我们将 `for await` 部分删去: ```swift await withTaskGroup(of: Int.self) { group in for i in 0 ..< 3 { group.addTask { await work(i) } } print("Task added") // for await... print("Task ended") } print("End") ``` 输出将变为: ``` // Start // Task added // Task ended // Start work 0 // ... // Work 2 done // End ``` 虽然 "Task ended" 的输出似乎提早了,但代表整个任务组完成的 "End" 的输出依然处于最后,它一定会在子任务全部完成之后才发生。对于结构化的任务组,编译器会为在离开作用域时我们自动生成 `await group` 的代码,上面的代码其实相当于: ```swift await withTaskGroup(of: Int.self) { group in for i in 0 ..< 3 { group.addTask { await work(i) } } print("Task added") print("Task ended") // 编译器自动生成的代码 for await _ in group { } } print("End") ``` 它满足结构化并发控制流的单入单出,将子任务的生命周期控制在任务组的作用域内,这也是结构化并发的最主要目的。即使我们手动 `await` 了 `group` 中的部分结果,然后退出了这个异步序列,结构化并发依然会保证在整个闭包退出前,让所有的子任务得以完成: ```swift await withTaskGroup(of: Int.self) { group in for i in 0 ..< 3 { group.addTask { await work(i) } } print("Task added") for await result in group { print("Get result: \(result)") // 在首个子任务完成后就跳出 break } print("Task ended") // 编译器自动生成的代码 await group.waitForAll() } ``` #### 任务组的值捕获 任务组中的每个子任务都拥有返回值,上面例子中 `work` 返回的 `Int` 就是子任务的返回值。当 `for await` 一个任务组时,就可以获取到每个子任务的返回值。任务组必须在所有子任务完成后才能完成,因此我们有机会“整理”所有子任务的返回结果,并为整个任务组设定一个返回值。比如把所有的 `work` 结果加起来: ```swift let v: Int = await withTaskGroup(of: Int.self) { group in var value = 0 for i in 0 ..< 3 { group.addTask { return await work(i) } } for await result in group { value += result } return value } print("End. Result: \(v)") ``` 每次 `work` 子任务完成后,结果的 `result` 都会和 `value` 累加,运行这段代码将输出结果 `3`。 一种很**常见的错误**,是把 `value += result` 的逻辑写到 `addTask` 中: ```swift let v: Int = await withTaskGroup(of: Int.self) { group in var value = 0 for i in 0 ..< 3 { group.addTask { let result = await work(i) value += result return result } } // 等待所有子任务完成 await group.waitForAll() return value } ``` 这样的做法会带来一个编译错误: > Mutation of captured var 'value' in concurrently-executing code 在将代码通过 `addTask` 添加到任务组时,我们必须有清醒的认识:这些代码有可能以并发方式同时运行。编译器可以检测到这里我们在一个明显的并发上下文中改变了某个共享状态。不加限制地从并发环境中访问是危险操作,可能造成崩溃。得益于结构化并发,现在编译器可以理解任务上下文的区别,在静态检查时就发现这一点,从而从根本上避免了这里的内存风险。 更严格一些,即使只是读取这个 `var value` 值,也是不被允许的: ```swift await withTaskGroup(of: Int.self) { group in var value = 0 for i in 0 ..< 3 { group.addTask { print("Value: \(value)") return await work(i) } } } ``` 将给出错误: > Reference to captured var 'value' in concurrently-executing code 和上面修改 `value` 的道理一样,由于 `value` 可能在并发操作执行的同时被外界改变,这样的访问也是不安全的。如果我们能保证 `value` 的值不会被更改的话,可以把 `var value` 的声明改为 `let value` 来避免这个错误: ```swift await withTaskGroup(of: Int.self) { group in // var value = 0 let value = 0 // ... } ``` 或者使用 `[value]` 的语法,来捕获当前的 `value` 值。由于 `value` 是值类型的值,因此它将会遵循值语义,被复制到 `addTask` 闭包内使用。子任务闭包内的访问将不再使用闭包外的内存,从而保证安全: ```swift await withTaskGroup(of: Int.self) { group in var value = 0 for i in 0 ..< 3 { // 用 [value] 捕获当前的 value 值 0 group.addTask { [value] in let result = await work(i) print("Value: \(value)") // Value: 0 return result } } // 将 value 改为 100 value = 100 // ... } ``` 不过,如果我们把 `value` 再向上提到类的成员一级的话,这个静态检查将失去作用: ```swift // 错误的代码,不要这样做 class TaskGroupSample { var value = 0 func start() async { await withTaskGroup(of: Int.self) { group in for i in 0 ..< 3 { group.addTask { // 可以访问 value print("Value: \(self.value)") // 可以操作 value let result = await self.work(i) self.value += result return result } } } // ... } } ``` 在 Swift 5.5 中,虽然它可以编译 (而且使用起来,特别是在本地调试时也几乎不会有问题),但这样的行为是**错误**的。和 Rust 不同,Swift 的堆内存所有权模型还无法完全区分内存的借用 (borrow) 和移动 (move),因此这种数据竞争和内存错误,还需要开发者自行注意。 Swift 编译器并非无法检出上述错误,它只是暂时“容忍”了这种情况。包括静态检测上述错误在内的完全的编译器级别并发数据安全,是未来 Swift 版本中的目标。现在,在并发上下文中访问共享数据时,Swift 设计了 actor 类型来确保数据安全。我们在介绍后面关于 actor 的章节,以及并发底层模型和内存安全的部分后,你会对这种情况背后的原因有更深入的了解。 #### 任务组逃逸 和 `withUnsafeCurrentTask` 中的 `task` 类似,`withTaskGroup` 闭包中的 `group` 也不应该被外部持有并在作用范围之外使用。虽然 Swift 编译器现在没有阻止我们这样做,但是在 `withTaskGroup` 闭包外使用 `group` 的话,将完全破坏结构化并发的假设: ```swift // 错误的代码,不要这样做 func start() async { var g: TaskGroup? = nil await withTaskGroup(of: Int.self) { group in g = group //... } g?.addTask { await work(1) } print("End") } ``` 通过 `g?.addTask` 添加的任务有可能在 `start` 完成后继续运行,这回到了非结构并发的老路;但它也可能让整个任务组进入到难以预测的状态,这将摧毁程序的执行假设。`TaskGroup` 实际上**并不是**用来存储 `Task` 的容器,它也不提供组织任务时需要的树形数据结构,这个类型仅仅只是作为对底层接口的包装,提供了创建任务节点的方法。要注意,在闭包作用范围外添加任务的行为是未定义的,随着 Swift 的升级,今后有可能直接产生运行时的崩溃。虽然现在并没有提供任何语言特性来确保 `group` 不被复制出去,但是我们绝对应该避免这种反模式的做法。 ### async let 异步绑定 除了任务组以外,`async let` 是另一种创建结构化并发子任务的方式。`withTaskGroup` 提供了一种非常“正规”的创建结构化并发的方式:它明确地描绘了结构化任务的作用返回,确保在闭包内部生成的每个子任务都在 `group` 结束时被 `await`。通过对 `group` 这个异步序列进行迭代,我们可以按照异步任务完成的顺序对结果进行处理。只要遵守一定的使用约定,就可以保证并发结构化的正确工作并从中受益。 但是,这些优点有时候也正是 `withTaskGroup` 不足:每次我们想要使用 `withTaskGroup` 时,往往都需要遵循同样的模板,包括创建任务组、定义和添加子任务、使用 `await` 等待完成等,这些都是模板代码。而且对于所有子任务的返回值必须是同样类型的要求,也让灵活性下降或者要求更多的额外实现 (比如将各个任务的返回值用新类型封装等)。`withTaskGroup` 的核心在于,生成子任务并将它的返回值 (或者错误) 向上汇报给父任务,然后父任务将各个子任务的结果汇总起来,最终结束当前的结构化并发作用域。这种数据流模式十分常见,如果能让它简单一些,会大幅简化我们使用结构化并发的难度。`async let` 的语法正是为了简化结构化并发的使用而诞生的。 在 `withTaskGroup` 的例子中的代码,使用 `async let` 可以改写为下面的形式: ```swift func start() async { print("Start") async let v0 = work(0) async let v1 = work(1) async let v2 = work(2) print("Task added") let result = await v0 + v1 + v2 print("Task ended") print("End. Result: \(result)") } ``` `async let` 和 `let` 类似,它定义一个本地常量,并通过等号右侧的表达式来初始化这个常量。区别在于,这个初始化表达式必须是一个异步函数的调用,通过将这个异步函数“绑定”到常量值上,Swift 会创建一个并发执行的子任务,并在其中执行该异步函数。`async let` 赋值后,子任务会立即开始执行。如果想要获取执行的结果 (也就是子任务的返回值),可以对赋值的常量使用 `await` 等待它的完成。 在上例中,我们使用了单一 `await` 来等待 `v0`、`v1` 和 `v2` 完成。和 `try` 一样,对于有多个表达式都需要暂停等待的情况,我们只需要使用一个 `await` 就可以了。当然,如果我们愿意,也可以把三个表达式分开来写: ```swift let result0 = await v0 let result1 = await v1 let result2 = await v2 let result = result0 + result1 + result2 ``` 需要特别强调,虽然这里我们顺次进行了 `await`,看起来好像是在等 `v0` 求值完毕后,再开始 `v1` 的暂停;然后在 `v1` 求值后再开始 `v2`。但是实际上,在 `async let` 时,这些子任务就一同开始以并发的方式进行了。在例子中,完成 `work(n)` 的耗时为 `n` 秒,所以上面的写法将在第 0 秒,第 1 秒和第 2 秒分别得出 `v0`,`v1` 和 `v2` 的值,**而不是**在第 0 秒,第 1 秒和第 3 秒 (1 秒 + 2 秒) 后才得到对应值。 由此衍生的另一个疑问是,如果我们修改 `await` 的顺序,会发生什么呢?比如下面的代码是否会带来不同的时序: ```swift let result1 = await v1 let result2 = await v2 let result0 = await v0 let result = result0 + result1 + result2 ``` 如果是考察每个子任务实际完成的时序,那么答案是没有变化:在 `async let` 创建子任务时,这个任务就开始执行了,因此 `v0`、`v1` 和 `v2` 真正执行的耗时,依旧是 0 秒,1 秒和 2 秒。但是,使用 `await` 最终获取 `v0` 值的时刻,是严格排在获取 `v2` 值之后的:当 `v0` 任务完成后,它的结果将被暂存在它自身的续体栈上,等待执行上下文通过 `await` 切换到自己时,才会把结果返回。也就是说在上例中,通过 `async let` 把任务绑定并开始执行后,`await v1` 会在 1 秒后完成;再经过 1 秒时间,`await v2` 完成;然后紧接着,`await v0` 会把 2 秒之前就已经完成的结果立即返回给 `result0`: ![](/assets/images/2021/async-let-concurrency.png) 这个例子中虽然最终的时序上会和之前有细微不同,但是这并没有违反结构化并发的规定。而且在绝大多数场景下,这也不会影响并发的结果和逻辑。不论是前面提到的任务组,还是 `async let`,它们所生成的子任务都是结构化的。不过,它们还有些许差别,我们马上就会谈到这个话题。 #### 隐式取消 在使用 `async let` 时,编译器也没有强制我们书写类似 `await v0` 这样的等待语句。有了 `TaskGroup` 中的经验以及 Swift 里“默认安全”的行为规范,我们不难猜测出,对于没有 `await` 的异步绑定,编译器也帮我们做了某些“手脚”,以保证单进单出的结构化并发依然成立。 如果没有 `await`,那么 Swift 并发会在被绑定的常量离开作用域时,隐式地将绑定的子任务取消掉,然后进行 `await`。也就是说,对于这样的代码: ```swift func start() async { async let v0 = work(0) print("End") } ``` 它等效于: ```swift func start() async { async let v0 = work(0) print("End") // 下面是编译器自动生成的伪代码 // 注意和 Task group 的不同 // v0 绑定的任务被取消 // 伪代码,实际上绑定中并没有 `task` 这个属性 v0.task.cancel() // 隐式 await,满足结构化并发 _ = await v0 } ``` 和 `TaskGroup` API 的不同之处在于,被绑定的任务将先被取消,然后才进行 `await`。这给了我们额外的机会去清理或者中止那些没有被使用的任务。不过,这种“隐藏行为”在异步函数可以抛出的时候,可能会造成很多的困惑。我们现在还没有涉及到任务的取消行为,以及如何正确处理取消。这是一个相对复杂且单独的话题,我们会在下一章中集中解释这里的细节。现在,你只需要记住,和 `TaskGroup` 一样,就算没有 `await`,`async let` **依然满足结构化并发要求**这一结论就可以了。 #### 对比任务组 既然同样是为了书写结构化并发的程序,`async let` 经常会用来和任务组作比较。在语义上,两者所表达的范式是很类似的,因此也会有人认为 `async let` 只是任务组 API 的语法糖:因为任务组 API 的使用太过于繁琐了,而异步绑定毕竟在语法上要简洁很多。 但实际上它们之间是有差异的。`async let` 不能动态地表达任务的数量,能够生成的子任务数量在编译时必须是已经确定好的。比如,对于一个输入的数组,我们可以通过 `TaskGroup` 开始对应数量的子任务,但是我们却无法用 `async let` 改写这段代码: ```swift func startAll(_ items: [Int]) async { await withTaskGroup(of: Int.self) { group in for item in items { group.addTask { await work(item) } } for await value in group { print("Value: \(value)") } } } ``` 除了上面那些只能使用某一种方式创建的结构化并发任务外,对于可以互换的情况,任务组 API 和异步绑定 API 的区别在于提供了两种不同风格的编程方式。一个大致的使用原则是,如果我们需要比较“严肃”地界定结构化并发的起始,那么用任务组的闭包将它限制起来,并发的结构会显得更加清晰;而如果我们只是想要快速地并发开始少数几个任务,并减少其他模板代码的干扰,那么使用 `async let` 进行异步绑定,会让代码更简洁易读。 ### 结构化并发的组合 在只使用一次 `withTaskGroup` 或者一组 `async let` 的单一层级的维度上,我们可能很难看出结构化并发的优势,因为这时对于任务的调度还处于可控状态:我们完全可以使用传统的技术,通过添加一些信号量,来“手动”控制保证并发任务最终可以合并到一起。但是,随着系统逐渐复杂,可能会面临在一些并发的子任务中再次进行任务并发的需求。也就是,形成多个层级的子任务系统。在这种情况下,想依靠原始的信号量来进行任务管理会变得异常复杂。这也是结构化并发这一抽象真正能发挥全部功效的情况。 通过嵌套使用 `withTaskGroup` 或者 `async let`,可以在一般人能够轻易理解的范围内,灵活地构建出这种多层级的并发任务。最简单的方式,是在 `withTaskGroup` 中为 `group` 添加 task 时再开启一个 `withTaskGroup`: ```swift func start() async { // 第一层任务组 await withTaskGroup(of: Int.self) { group in group.addTask { // 第二层任务组 await withTaskGroup(of: Int.self) { innerGroup in innerGroup.addTask { await work(0) } innerGroup.addTask { await work(2) } return await innerGroup.reduce(0) { result, value in result + value } } } group.addTask { await work(1) } } print("End") } ``` ![](/assets/images/2021/nested-taskgroup.png) 对于上面使用 `work` 函数的例子来说,多加的一层 `innerGroup` 在执行时并不会造成太大区别:三个任务依然是按照结构化并发执行。不过,这种层级的划分,给了我们更精确控制并发行为的机会。在结构化并发的任务模型中,子任务会从其父任务中继承**任务优先级**以及**任务的本地值 (task local value)**;在处理任务取消时,除了父任务会将取消传递给子任务外,在子任务中的抛出也会将取消向上传递。不论是当我们需要精确地在某一组任务中设置这些行为,或者只是单纯地为了更好的可读性,这种通过嵌套得到更加细分的任务层级的方法,都会对我们的目标有所帮助。 > 任务本地值指的是那些仅存在于当前任务上下文中的,由外界注入的值。我们会在后面的章节中针对这个话题展开讨论。 相对于 `withTaskGroup` 的嵌套,使用 `async let` 会更有技巧性一些。`async let` 赋值等号右边,接受的是一个对异步函数的调用。这个异步函数可以是像 `work` 这样的具体具名的函数,也可以是一个匿名函数。比如,上面的 `withTaskGroup` 嵌套的例子,使用 `async let`,可以简单地写为: ```swift func start() async { async let v02: Int = { async let v0 = work(0) async let v2 = work(2) return await v0 + v2 }() async let v1 = work(1) _ = await v02 + v1 print("End") } ``` 这里在 `v02` 等号右侧的是一个匿名的异步函数闭包调用,其中通过两个新的 `async let` 开始了嵌套的子任务。特别注意,上例中的写法和下面这样的 `await` 有本质不同: ```swift func start() async { async let v02: Int = { return await work(0) + work(2) }() // ... } ``` `await work(0) + work(2)` 将会**顺次**执行 `work(0)` 和 `work(2)`,并把它们的结果相加。这时两个操作不是并发执行的,也不涉及新的子任务。 当然,我们也可以把两个嵌套的 `async let` 提取到一个署名的函数中,这样调用就会回到我们所熟悉的方式: ```swift func start() async { async let v02 = work02() //... } func work02() async -> Int { async let v0 = work(0) async let v2 = work(2) return await v0 + v2 } ``` 大部分时候,把子任务的部分提取成具名的函数会更好。不过对于这个简单的例子,直接使用匿名函数,让 `work(0)`、`work(2)` 与另一个子任务中的 `work(1)` 并列起来,可能结构会更清楚。 因为 `withTaskGroup` 和 `async let` 都产生结构性并发任务,因此有时候我们也可以将它们混合起来使用。比如在 `async let` 的右侧写一个 `withTaskGroup`;或者在 `group.addTask` 中用 `async let` 绑定新的任务。不过不论如何,这种“静态”的任务生成方式,理解起来都是相对容易的:只要我们能将生成的任务层级和我们想要的任务层级对应起来,两者混用也不会有什么问题。 ## 非结构化任务 `TaskGroup.addTask` 和 `async let` 是 Swift 并发中“唯二”的创建结构化并发任务的 API。它们从当前的任务运行环境中继承任务优先级等属性,为即将开始的异步操作创建新的任务环境,然后将新的任务作为子任务添加到当前任务环境中。 除此之外,我们也看到过使用 `Task.init` 和 `Task.detached` 来创建新任务,并在其中执行异步函数的方式: ```swift func start() async { Task { await work(1) } Task.detached { await work(2) } print("End") } ``` 这类任务具有最高的灵活性,它们可以在任何地方被创建。它们生成一棵新的任务树,并位于顶层,不属于任何其他任务的子任务,生命周期不和其他作用域绑定,当然也没有结构化并发的特性。对比三者,可以看出它们之间明显的不同: - `TaskGroup.addTask` 和 `async let` - 创建结构化的子任务,继承优先级和本地值。 - `Task.init` - 创建非结构化的任务根节点,从当前任务中继承运行环境:比如 actor 隔离域,优先级和本地值等。 - `Task.detached` - 创建非结构化的任务根节点,不从当前任务中继承优先级和本地值等运行环境,完全新的游离任务环境。 有一种迷思认为,我们在新建根节点任务时,应该尽量使用 `Task.init` 而避免选用生成一个完全“游离任务”的 `Task.detached`。其实这并不全然正确,有时候我们希望从当前任务环境中继承一些事实,但也有时候我们确实想要一个“干净”的任务环境。比如 `@main` 标记的异步程序入口和 SwiftUI `task` 修饰符,都使用的是 `Task.detached`。具体是不是有可能从当前任务环境中继承属性,或者应不应该继承这些属性,需要具体问题具体分析。 创建非结构化任务时,我们可以得到一个具体的 `Task` 值,它充当了这个新建任务的标识。从 `Task.init` 或 `Task.detached` 的闭包中返回的值,将作为整个 `Task` 运行结束后的值。使用 `Task.value` 这个异步只读属性,我们可以获取到整个 `Task` 的返回值: ```swift extension Task { var value: Success { get async throws } } // 或者当 Task 不会失败时,value 也不会 throw: extension Task where Failure == Never { var value: Success { get async } } ``` 想要访问这个值,和其他任意异步属性一样,需要使用 `await`: ```swift func start() async { let t1 = Task { await work(1) } let t2 = Task.detached { await work(2) } let v1 = await t1.value let v2 = await t2.value } ``` 一旦创建任务,其中的异步任务就会被马上提交并执行。所以上面的代码依然是并发的:`t1` 和 `t2` 之间没有暂停,将同时执行,`t1` 任务在 1 秒后完成,而 `t2` 在两秒后完成。`await t1.value` 和 `await t2.value` 的顺序并不影响最终的执行耗时,即使是我们先 `await` 了 `t2`,`t1` 的预先计算的结果也会被暂存起来,并在它被 `await` 的时候给出。 用 `Task.init` 或 `Task.detached` 明确创建的 `Task`,是没有结构化并发特性的。`Task` 值超过作用域并不会导致自动取消或是 `await` 行为。想要取消一个这样的 `Task`,必须持有返回的 `Task` 值并明确调用 `cancel`: ```swift let t1 = Task { await work(1) } // 稍后 t1.cancel() ``` 这种非结构化并发中,外层的 `Task` 的取消,并不会传递到内层 `Task`。或者,更准确来说,这样的两个 `Task` 并没有任何从属关系,它们都是顶层任务: ```swift let outer = Task { let innner = Task { await work(1) } await work(2) } outer.cancel() outer.isCancelled // true inner.isCancelled // false ``` 单是这样的多个 `Task`,看起来还很简单。但是考虑到 `Task.value` 其实也是一种异步函数,如果我们将结构化并发和非结构化的任务组合起来使用的话,事情马上就会变得复杂起来。比如下面这个“简单”的例子,它在 `async let` 右侧开启新的 `Task`: ```swift func start() async { async let t1 = Task { await work(1) print("Cancelled: \(Task.isCancelled)") }.value async let t2 = Task.detached { await work(2) print("Cancelled: \(Task.isCancelled)") }.value } ``` `t1` 和 `t2` 确实是结构化的,但是它们开启的新任务,却并非如此:虽然 `t1` 和 `t2` 在超出 `start` 作用域时,由于没有 `await`,这两个绑定都将被取消,但这个取消并不能传递到非结构化的 `Task` 中,所以两个 `isCancelled` 都将输出 `false`。 除非有特别的理由,我们希望某个任务独立于结构化并发的生命周期,否则我们应该尽量避免在结构化并发的上下文中使用非结构化任务。这可以让结构化的任务树保持简单,而不是随意地产生不受管理的新树。 > 不过确实也有一些情况我们会倾向于选择非结构化的并发,比如一些并不影响异步系统中其他部分的非关键操作。像是下载文件后将它写入缓存就是一个好例子:在下载完成后我们就可以马上结束“下载”这个核心的异步行为,并在开始缓存的同时,就将文件返回给调用者了。写入缓存作为“顺带”操作,不应该作为结构化任务的一员。此时使用独立任务会更合适。 ## 小结 历史已经证明了,完全放弃 `goto` 语句,使用结构化编程,有利于我们理解和写出正确控制流的程序。而随着计算机的发展和程序设计的演进,现在我们来到了另一个重要的时间节点:我们是否应该完全使用结构化并发,而舍弃掉原有的非结构化并发模型呢?现在有这个趋势,但是大家也都还保留了原来的并发模型。即使要完全转变,可能也还需要一些时间。 Swift 是当前少数几个在语言和标准库层面对结构化并发进行支持的语言之一。得益于 Swift 语言默认安全的特性,只要我们遵循一些简单的规定 (比如不在闭包外传递和持有 task group 等),就可以写出正确、安全和非常易于理解的结构化并发代码。这为简化并发复杂度提供了有效的工具。`withTaskGroup` 和 `async let` 在创建结构化并发上是等效的,但是它们并非可以完全互相代替。两者有各自最适用的情景,在超出作用域的隐式行为细节上也略有不同。切实理解这些不同,可以帮助我们在面对任务时选取最合适的工具。 本章中我们只讨论了结构化并发的完成特性:父任务在子任务全部完成之前,是不会完成的。对于结构化并发来说,这只是其中一部分内容,对于另一个大的话题,任务取消,本章中鲜有涉及。在下一章里,我们会仔细探讨任务取消的相关话题,这会让我们对结构化并发在简化并发编程模型中所带来的优势,有更加深刻的理解。 URL: https://onevcat.com/2021/07/swift-concurrency/index.html.md Published At: 2021-07-01 11:00:00 +0900 # Swift 并发初步 > 本文是我的新书[《Swift 异步和并发》](https://objccn.io/products/async-swift)中第一章内容,主要从概览的方向上介绍了 Swift 5.5 中引入的 Swift 并发特性的使用方法。如果你对学习 Swift 并发有兴趣,也许可以作为参考读物。 > 你可以在这里找到本文中的[参考代码](/assets/samples/async-swift-chapter02.zip)。在本文写作的 Xcode 13 beta 2 环境下,你需要额外安装[最新的 Swift 5.5 toolchain](https://swift.org/download/#swift-55-development) 来运行这些代码。 虽然可能你已经跃跃欲试,想要创建第一个 Swift 的并发程序,但是“名不正则言不顺”。在实际进入代码之前,作为全书开头,我还是想先对几个重要的相关概念进行说明。这样在今后本书中,当我们提起 Swift 异步和并发时,对具体它指代了什么内容,能够取得统一的认识。本章后半部分,我们会实际着手写一些 Swift 并发代码,来描述整套体系的基本构成和工作流程。 ## 一些基本概念 ### 同步和异步 在我们说到线程的执行方式时,同步 (synchronous) 和异步 (asynchronous) 是这个话题中最基本的一组概念。**同步操作**意味着在操作完成之前,运行这个操作的线程都将被占用,直到函数最终被抛出或者返回。Swift 5.5 之前,所有的函数都是同步函数,我们简单地使用 `func` 关键字来声明这样一个同步函数: ```swift var results: [String] = [] func addAppending(_ value: String, to string: String) { results.append(value.appending(string)) } ``` `addAppending` 是一个同步函数,在它返回之前,运行它的线程将无法执行其他操作,或者说它不能被用来运行其他函数,必须等待当前函数执行完成后这个线程才能做其他事情。 ![](/assets/images/2021/sync-func.png) 在 iOS 开发中,我们使用的 UI 开发框架,也就是 UIKit 或者 SwiftUI,不是线程安全的:对用户输入的处理和 UI 的绘制,必须在与主线程绑定的 main runloop 中进行。假设我们希望用户界面以每秒 60 帧的速率运行,那么主线程中每两次绘制之间,所能允许的处理时间最多只有 16 毫秒 (1 / 60s)。当主线程中要同步处理的其他操作耗时很少时 (比如我们的 `addAppending`,可能耗时只有几十纳秒),这不会造成什么问题。但是,如果这个同步操作耗时过长的话,主线程将被阻塞。它不能接受用户输入,也无法向 GPU 提交请求去绘制新的 UI,这将导致用户界面掉帧甚至卡死。这种“长耗时”的操作,其实是很常见的:比如从网络请求中获取数据,从磁盘加载一个大文件,或者进行某些非常复杂的加解密运算等。 下面的 `loadSignature` 从某个网络 URL 读取字符串:如果这个操作发生在主线程,且耗时超过 16ms (这是很可能发生的,因为通过握手协议建立网络连接,以及接收数据,都是一系列复杂操作),那么主线程将无法处理其他任何操作,UI 将不会刷新。 ```swift // 从网络读取一个字符串 func loadSignature() throws -> String? { // someURL 是远程 URL,比如 https://example.com let data = try Data(contentsOf: someURL) return String(data: data, encoding: .utf8) } ``` ![](/assets/images/2021/sync-func-block-ui.png) `loadSignature` 最终的耗时超过 16 ms,对 UI 的刷新或操作的处理不得不被延后。在用户观感上,将表现为掉帧或者整个界面卡住。这是客户端开发中绝对需要避免的问题之一。 Swift 5.5 之前,要解决这个问题,最常见的做法是将耗时的同步操作转换为**异步操作**:把实际长时间执行的任务放到另外的线程 (或者叫做后台线程) 运行,然后在操作结束时提供运行在主线程的回调,以供 UI 操作之用: ```swift func loadSignature( _ completion: @escaping (String?, Error?) -> Void ) { DispatchQueue.global().async { do { let d = try Data(contentsOf: someURL) DispatchQueue.main.async { completion(String(data: d, encoding: .utf8), nil) } } catch { DispatchQueue.main.async { completion(nil, error) } } } } ``` ![](/assets/images/2021/sync-func-dispatch.png) `DispatchQueue.global` 负责将任务添加到全局后台派发队列。在底层,[GCD 库](https://en.wikipedia.org/wiki/Grand_Central_Dispatch) (Grand Central Dispatch) 会进行线程调度,为实际耗时繁重的 `Data.init(contentsOf:)` 分配合适的线程。耗时任务在主线程外进行处理,完成后再由 `DispatchQueue.main` 派发回主线程,并按照结果调用 `completion` 回调方法。这样一来,主线程不再承担耗时任务,UI 刷新和用户事件处理可以得到保障。 异步操作虽然可以避免卡顿,但是使用起来存在不少问题,最主要包括: - 错误处理隐藏在回调函数的参数中,无法用 `throw` 的方式明确地告知并强制调用侧去进行错误处理。 - 对回调函数的调用没有编译器保证,开发者可能会忘记调用 `completion`,或者多次调用 `completion`。 - 通过 `DispatchQueue` 进行线程调度很快会使代码复杂化。特别是如果线程调度的操作被隐藏在被调用的方法中的时候,不查看源码的话,在 (调用侧的) 回调函数中,几乎无法确定代码当前运行的线程状态。 - 对于正在执行的任务,没有很好的取消机制。 除此之外,还有其他一些没有列举的问题。它们都可能成为我们程序中潜在 bug 的温床,在之后关于异步函数的章节里,我们会再回顾这个例子,并仔细探讨这些问题的细节。 需要进行说明的是,虽然我们将运行在后台线程加载数据的行为称为**异步操作**,但是接受回调函数作为参数的 `loadSignature(_:)` 方法,其本身依然是一个**同步函数**。这个方法在返回前仍旧会占据主线程,只不过它现在的执行时间非常短,UI 相关的操作不再受影响。 Swift 5.5 之前,Swift 语言中并没有真正异步函数的概念,我们稍后会看到使用 `async` 修饰的异步函数是如何简化上面的代码的。 ### 串行和并行 另外一组重要的概念是串行和并行。对于通过同步方法执行的同步操作来说,这些操作一定是以串行方式在同一线程中发生的。“做完一件事,然后再进行下一件事”,是最常见的、也是我们人类最容易理解的代码执行方式: ```swift if let signature = try loadSignature() { addAppending(signature, to: "some data") } print(results) ``` `loadSignature`,`addAppending` 和 `print` 被顺次调用,它们在同一线程中按严格的先后顺序发生。这种执行方式,我们将它称为**串行 (serial)**。 ![](/assets/images/2021/serial-sync.png) **同步方法执行的同步操作**,是串行的充分但非必要条件。异步操作也可能会以串行方式执行。假设除了 `loadSignature(_:)` 以外,我们还有一个从数据库里读取一系列数据的函数,它使用类似的方法,把具体工作放到其他线程异步执行: ```swift func loadFromDatabase( _ completion: @escaping ([String]?, Error?) -> Void ) { // ... } ``` 如果我们先从数据库中读取数据,在完成后再使用 `loadSignature` 从网络获取签名,最后将签名附加到每一条数据库中取出的字符串上,可以这么写: ```swift loadFromDatabase { (strings, error) in if let strings = strings { loadSignature { signature, error in if let signature = signature { strings.forEach { strings.forEach { strings.forEach { addAppending(signature, to: $0) } } else { print("Error") } } } else { print("Error.") } } ``` 虽然这些操作是**异步**的,但是它们 (从数据库读取 `[String]`,从网络下载签名,最后将签名添加到每条数据中) 依然是**串行**的,加载签名必定发生在读取数据库完成之后,而最后的 `addAppending` 也必然发生在 `loadSignature` 之后: ![](/assets/images/2021/serial-async.png) > 虽然图中把 `loadFromDatabase` 和 `loadSignature` 画在了同一个线程里,但事实上它们有可能是在不同线程执行的。不过在上面代码的情况下,它们的先后次序依然是严格不变的。 事实上,虽然最后的 `addAppending` 任务同时需要原始数据和签名才能进行,但 `loadFromDatabase` 和 `loadSignature` 之间其实并没有依赖关系。如果它们能够一起执行的话,我们的程序有很大机率能变得更快。这时候,我们会需要更多的线程,来同时执行两个操作: ```swift // loadFromDatabase { (strings, error) in // ... // loadSignature { signature, error in { // ... // 可以将串行调用替换为: loadFromDatabase { (strings, error) in //... } loadSignature { signature, error in //... } ``` > 为了确保在 `addAppending` 执行时,从数据库加载的内容和从网络下载的签名都已经准备好,我们需要某种手段来确保这些数据的可用性。在 GCD 中,通常可以使用 `DispatchGroup` 或者 `DispatchSemaphore` 来实现这一点。但是我们并不是一本探讨 GCD 的书籍,所以这部分内容就略过了。 两个 `load` 方法同时开始工作,理论上资源充足的话 (足够的 CPU,网络带宽等),现在它们所消耗的时间会小于串行时的两者之和: ![](/assets/images/2021/parallel-async.png) 这时候,`loadFromDatabase` 和 `loadSignature` 这两个异步操作,在不同的线程中同时执行。对于这种拥有多套资源同时执行的方式,我们就将它称为**并行 (parallel)**。 ### Swift 并发是什么 在有了这些基本概念后,最后可以谈谈关于并发 (concurrency) 这个名词了。在计算机科学中,并发指的是多个计算同时执行的特性。并发计算中涉及的**同时执行**,主要是若干个操作的开始和结束时间之间存在重叠。它并不关心具体的执行方式:我们可以把同一个线程中的多个操作交替运行 (这需要这类操作能够暂时被置于暂停状态) 叫做并发,这几个操作将会是分时运行的;我们也可以把在不同处理器核心中运行的任务叫做并发,此时这些任务必定是并行的。 而当 Apple 在定义“Swift 并发”是什么的时候,和上面这个经典的计算机科学中的定义实质上没有太多不同。Swift 官方文档给出了这样的解释: > Swift 提供内建的支持,让开发者能以结构化的方式书写异步和并行的代码,... 并发这个术语,指的是异步和并行这一常见组合。 所以在提到 Swift 并发时,它指的就是**异步和并行代码的组合**。这在语义上,其实是传统并发的一个子集:它限制了实现并发的手段就是异步代码,这个限定降低了我们理解并发的难度。在本书中,如果没有特别说明,我们在提到 Swift 并发时,指的都是“异步和并行代码的组合”这个简化版的意义,或者专指 Swift 5.5 中引入的这一套处理并发的语法和框架。 除了定义方式稍有不同之外,Swift 并发和其他编程语言在处理同样问题时所面临的挑战几乎一样。从戴克斯特拉 (Edsger W. Dijkstra) 提出信号量 (semaphore) 的概念起,到东尼・霍尔爵士 (Tony Hoare) 使用 [CSP](https://zh.wikipedia.org/wiki/交談循序程式) 描述和尝试解决[哲学家就餐问题](https://zh.wikipedia.org/wiki/哲学家就餐问题),再到 actor 模型或者通道模型 (channel model) 的提出,并发编程最大的困难,以及这些工具所要解决的问题大致上只有两个: 1. 如何确保不同运算运行步骤之间的交互或通信可以按照正确的顺序执行 2. 如何确保运算资源在不同运算之间被安全地共享、访问和传递 第一个问题负责并发的逻辑正确,第二个问题负责并发的内存安全。在以前,开发者在使用 GCD 编写并发代码时往往需要很多经验,否则难以正确处理上述问题。Swift 5.5 设计了**异步函数**的书写方法,在此基础上,利用**结构化并发**确保运算步骤的交互和通信正确,利用 **actor 模型**确保共享的计算资源能在隔离的情况下被正确访问和操作。它们组合在一起,提供了一系列工具让开发者能简单地编写出稳定高效的并发代码。我们接下来,会浅显地对这几部分内容进行瞥视,并在后面对各个话题展开探究。 > 戴克斯特拉还发表了著名的《GOTO 语句有害论》(Go To Statement Considered Harmful),并和霍尔爵士一同推动了结构化编程的发展。霍尔爵士在稍后也提出了对 null 的反对,最终促成了现代语言中普遍采用的 `Optional` (或者叫别的名称,比如 `Maybe` 或 null safety 等) 设计。如果没有他们,也许我们今天在编写代码时还在处理无尽的 goto 和 null 检查,会要辛苦很多。 ## 异步函数 为了更容易和优雅地解决上面两个问题,Swift 需要在语言层面引入新的工具:第一步就是添加异步函数的概念。在函数声明的返回箭头前面,加上 `async` 关键字,就可以把一个函数声明为异步函数: ```swift func loadSignature() async throws -> String { fatalError("暂未实现") } ``` 异步函数的 `async` 关键字会帮助编译器确保两件事情: 1. 它允许我们在函数体内部使用 `await` 关键字; 2. 它要求其他人在调用这个函数时,使用 `await` 关键字。 这和与它处于类似位置的 `throws` 关键字有点相似。在使用 `throws` 时,它允许我们在函数内部使用 `throw` 抛出错误,并要求调用者使用 `try` 来处理可能的抛出。`async` 也扮演了这样一个角色,它要求在特定情况下对当前函数进行标记,这是对于开发者的一种明确的提示,表明这个函数有一些特别的性质:`try/throw` 代表了函数可以被抛出,而 `await` 则代表了函数在此处可能会**放弃当前线程**,它是程序的**潜在暂停点**。 放弃线程的能力,意味着异步方法可以被“暂停”,这个线程可以被用来执行其他代码。如果这个线程是主线程的话,那么界面将不会卡顿。被 `await` 的语句将被底层机制分配到其他合适的线程,在执行完成后,之前的“暂停”将结束,异步方法从刚才的 `await` 语句后开始,继续向下执行。 关于异步函数的设计和更多深入内容,我们会在随后的相关章节展开。在这里,我们先来看看一个简单的异步函数的使用。Foundation 框架中已经为我们提供了很多异步函数,比如使用 `URLSession` 从某个 `URL` 加载数据,现在也有异步版本了。在由 `async` 标记的异步函数中,我们可以调用其他异步函数: ```swift func loadSignature() async throws -> String? { let (data, _) = try await URLSession.shared.data(from: someURL) return String(data: data, encoding: .utf8) } ``` > 这些 Foundation,或者 AppKit 或 UIKit 中的异步函数,有一部分是重写和新添加的,但更多的情况是由相应的 Objective-C 接口转换而来。满足一定条件的 Objective-C 函数,可以直接转换为 Swift 的异步函数,非常方便。在后一章我们也会具体谈到。 如果我们把 `loadFromDatabase` 也写成异步函数的形式。那么,在上面串行部分,原本的嵌套式的异步操作代码: ```swift loadFromDatabase { (strings, error) in if let strings = strings { loadSignature { signature, error in if let signature = signature { strings.forEach { strings.forEach { strings.forEach { addAppending(signature, to: $0) } } else { print("Error") } } } else { print("Error.") } } ``` 就可以非常简单地写成这样的形式: ```swift let strings = try await loadFromDatabase() if let signature = try await loadSignature() { strings.forEach { addAppending(signature, to: $0) } } else { throw NoSignatureError() } ``` 不用多说,单从代码行数就可以一眼看清优劣了。异步函数极大简化了异步操作的写法,它避免了内嵌的回调,将异步操作按照顺序写成了类似“同步执行”的方法。另外,这种写法允许我们使用 try/throw 的组合对错误进行处理,编译器会对所有的返回路径给出保证,而不必像回调那样时刻检查是不是所有的路径都进行了处理。 ## 结构化并发 对于同步函数来说,线程决定了它的执行环境。而对于异步函数,则由任务 (Task) 决定执行环境。Swift 提供了一系列 `Task` 相关 API 来让开发者创建、组织、检查和取消任务。这些 API 围绕着 `Task` 这一核心类型,为每一组并发任务构建出一棵结构化的任务树: - 一个任务具有它自己的优先级和取消标识,它可以拥有若干个子任务并在其中执行异步函数。 - 当一个父任务被取消时,这个父任务的取消标识将被设置,并向下传递到所有的子任务中去。 - 无论是正常完成还是抛出错误,子任务会将结果向上报告给父任务,在所有子任务完成之前 (不论是正常结束还是抛出),父任务是不会完成的。 这些特性看上去和 [`Operation` 类](https://developer.apple.com/documentation/foundation/operation) 有一些相似,不过 `Task` 直接利用异步函数的语法,可以用更简洁的方式进行表达。而 `Operation` 则需要依靠子类或者闭包。 在调用异步函数时,需要在它前面添加 `await` 关键字;而另一方面,只有在异步函数中,我们才能使用 `await` 关键字。那么问题在于,第一个异步函数执行的上下文,或者说任务树的根节点,是怎么来的? 简单地使用 `Task.init` 就可以让我们获取一个任务执行的上下文环境,它接受一个 `async` 标记的闭包: ```swift struct Task where Failure : Error { init( priority: TaskPriority? = nil, priority: TaskPriority? = nil, priority: TaskPriority? = nil, operation: @escaping @Sendable () async throws -> Success ) } ``` 它继承当前任务上下文的优先级等特性,创建一个新的任务树根节点,我们可以在其中使用异步函数: ```swift var results: [String] = [] func someSyncMethod() { Task { try await processFromScratch() print("Done: \(results)") } } func processFromScratch() async throws { let strings = try await loadFromDatabase() if let signature = try await loadSignature() { strings.forEach { results.append($0.appending(signature)) } } else { throw NoSignatureError() } } ``` 注意,在 `processFromScratch` 中的处理依然是串行的:对 `loadFromDatabase` 的 `await` 将使这个异步函数在此暂停,直到实际操作结束,接下来才会执行 `loadSignature`: ![](/assets/images/2021/task-serial.png) 我们当然会希望这两个操作可以同时进行。在两者都准备好后,再调用 `appending` 来实际将签名附加到数据上。这需要任务以结构化的方式进行组织。使用 `async let` 绑定可以做到这一点: ```swift func processFromScratch() async throws { async let loadStrings = loadFromDatabase() async let loadSignature = loadSignature() results = [] let strings = try await loadStrings if let signature = try await loadSignature { strings.forEach { addAppending(signature, to: $0) } } else { throw NoSignatureError() } } ``` `async let` 被称为**异步绑定**,它在当前 Task 上下文中创建新的子任务,并将它用作被绑定的异步函数 (也就是 `async let` 右侧的表达式) 的运行环境。和 `Task.init` 新建一个任务根节点不同,`async let` 所创建的子任务是任务树上的叶子节点。被异步绑定的操作会立即开始执行,即使在 `await` 之前执行就已经完成,其结果依然可以等到 `await` 语句时再进行求值。在上面的例子中,`loadFromDatabase` 和 `loadSignature` 将被并发执行。 ![](/assets/images/2021/task-parallel.png) 相对于 GCD 调度的并发,基于任务的结构化并发在控制并发行为上具有得天独厚的优势。为了展示这一优势,我们可以尝试把事情再弄复杂一点。上面的 `processFromScratch` 完成了从本地加载数据,从网络获取签名,最后再将签名附加到每一条数据上这一系列操作。假设我们以前可能就做过类似的事情,并且在服务器上已经存储了所有结果,于是我们有机会在进行本地运算的同时,去尝试直接加载这些结果作为“优化路径”,避免重复的本地计算。类似地,可以用一个异步函数来表示“从网络直接加载结果”的操作: ```swift func loadResultRemotely() async throws { // 模拟网络加载的耗时 await Task.sleep(2 * NSEC_PER_SEC) results = ["data1^sig", "data2^sig", "data3^sig"] } ``` 除了 `async let` 外,另一种创建结构化并发的方式,是使用任务组 (Task group)。比如,我们希望在执行 `loadResultRemotely` 的同时,让 `processFromScratch` 一起运行,可以用 `withThrowingTaskGroup` 将两个操作写在同一个 task group 中: ```swift func someSyncMethod() { Task { await withThrowingTaskGroup(of: Void.self) { group in group.addTask { try await self.loadResultRemotely() } group.addTask(priority: .low) { try await self.processFromScratch() } } print("Done: \(results)") } } ``` > 对于 `processFromScratch`,我们为它特别指定了 `.low` 的优先级,这会导致该任务在另一个低优先级线程中被调度。我们一会儿会看到这一点带来的影响。 `withThrowingTaskGroup` 和它的非抛出版本 `withTaskGroup` 提供了另一种创建结构化并发的组织方式。当在运行时才知道任务数量时,或是我们需要为不同的子任务设置不同优先级时,我们将只能选择使用 Task Group。在其他大部分情况下,`async let` 和 task group 可以混用甚至互相替代: ![](/assets/images/2021/task-parallel-group.png) 闭包中的 `group` 满足 `AsyncSequence` 协议,它让我们可以使用 `for await` 的方式用类似同步循环的写法来访问异步操作的结果。另外,通过调用 group 的 `cancelAll`,我们可以在适当的情况下将任务标记为取消。比如在 `loadResultRemotely` 很快返回时,我们可以取消掉正在进行的 `processFromScratch`,以节省计算资源。关于异步序列和任务取消这些话题,我们会在稍后专门的章节中继续探讨。 ## actor 模型和数据隔离 在 `processFromScratch` 里,我们先将 `results` 设置为 `[]`,然后再处理每条数据,并将结果添加到 `results` 里: ```swift func processFromScratch() async throws { // ... results = [] strings.forEach { addAppending(signature, to: $0) } // ... } ``` 在作为示例的 `loadResultRemotely` 里,我们现在则是直接把结果赋值给了 `results`: ```swift func loadResultRemotely() async throws { await Task.sleep(2 * NSEC_PER_SEC) results = ["data1^sig", "data2^sig", "data3^sig"] } ``` 因此,一般来说我们会认为,不论 `processFromScratch` 和 `loadResultRemotely` 执行的先后顺序如何,我们总是应该得到唯一确定的 `results`,也就是数据 `["data1^sig", "data2^sig", "data3^sig"]`。但事实上,如果我们对 `loadResultRemotely` 的 `Task.sleep` 时长进行一些调整,让它和 `processFromScratch` 所耗费的时间相仿,就可能会看到出乎意料的结果。在正确输出三个元素的情况外,有时候它会输出六个元素: ```swift // 有机率输出: Done: ["data1^sig", "data2^sig", "data3^sig", "data1^sig", "data2^sig", "data3^sig"] ``` 我们在 `addTask` 时为两个任务指定了不同的优先级,因此它们中的代码将运行在不同的调度线程上。两个异步操作在不同线程同时访问了 `results`,造成了数据竞争。在上面这个结果中,我们可以将它解释为 `processFromScratch` 先将 `results` 设为了空数列,紧接着 `loadResultRemotely` 完成,将它设为正确的结果,然后 `processFromScratch` 中的 `forEach` 把计算得出的三个签名再添加进去。 ![](/assets/images/2021/data-racing.png) 这大概率并不是我们想要的结果。不过幸运的是两个操作现在并没有真正“同时”地去更改 `results` 的内存,它们依然有先后顺序,因此只是最后的数据有些奇怪。 `processFromScratch` 和 `loadResultRemotely` 在不同的任务环境中对变量 `results` 进行了操作。由于这两个操作是并发执行的,所以也可能出现一种更糟糕的情况:它们对 `results` 的操作同时发生。如果 `results` 的底层存储被多个操作同时更改的话,我们会得到一个运行时错误。作为示例 (虽然没有太多实际意义),通过增加 `someSyncMethod` 的运行次数就可以很容易地让程序崩溃: ```swift for _ in 0 ..< 10000 { someSyncMethod() } // 运行时崩溃。一个典型的内存错误 // Thread 10: EXC_BAD_ACCESS (code=1, address=0x55a8fdbc060c) ``` 为了确保资源 (在这个例子里,是 `results` 指向的内存) 在不同运算之间被安全地共享和访问,以前通常的做法是将相关的代码放入一个串行的 dispatch queue 中,然后以同步的方式把对资源的访问派发到队列中去执行,这样我们可以避免多个线程同时对资源进行访问。按照这个思路可以进行一些重构,将 `results` 放到新的 `Holder` 类型中,并使用私有的 `DispatchQueue` 将它保护起来: ```swift class Holder { private let queue = DispatchQueue(label: "resultholder.queue") private var results: [String] = [] func getResults() -> [String] { queue.sync { results } } func setResults(_ results: [String]) { queue.sync { self.results = results } } func append(_ value: String) { queue.sync { self.results.append(value) } } } ``` 接下来,将原来代码中使用到 `results: [String]` 的地方替换为 `Holder`,并使用暴露出的方法将原来对 `results` 的直接操作进行替换,可以解决运行时崩溃的问题。 ```swift // var results: [String] = [] var holder = Holder() // ... // results = [] holder.setResults([]) // results.append(data.appending(signature)) holder.append(data.appending(signature)) // print("Done: \(results)") print("Done: \(holder.getResults())") ``` 在使用 GCD 进行并发操作时,这种模式非常常见。但是它存在一些难以忽视的问题: 1. 大量且易错的模板代码:凡是涉及 `results` 的操作,都需要使用 `queue.sync` 包围起来,但是编译器并没有给我们任何保证。在某些时候忘了使用队列,编译器也不会进行任何提示,这种情况下内存依然存在危险。当有更多资源需要保护时,代码复杂度也将爆炸式上升。 2. 小心死锁:在一个 `queue.sync` 中调用另一个 `queue.sync` 的方法,会造成线程死锁。在代码简单的时候,这很容易避免,但是随着复杂度增加,想要理解当前代码运行是由哪一个队列派发的,它又运行在哪一个线程上,往往会伴随着严重的困难。必须精心设计,避免重复派发。 在一定程度上,我们可以用 `async` 替代 `sync` 派发来缓解死锁的问题;或者放弃队列,转而使用锁 (比如 `NSLock` 或者 `NSRecursiveLock`)。不过不论如何做,都需要开发者对线程调度和这种基于共享内存的数据模型有深刻理解,否则非常容易写出很多坑。 Swift 并发引入了一种在业界已经被多次证明有效的新的数据共享模型,**actor 模型** (参与者模型),来解决这些问题。虽然有些偏失,但最简单的理解,可以认为 actor 就是一个“封装了私有队列”的 class。将上面 `Holder` 中 `class` 改为 `actor`,并把 `queue` 的相关部分去掉,我们就可以得到一个 actor 类型。这个类型的特性和 `class` 很相似,它拥有引用语义,在它上面定义属性和方法的方式和普通的 `class` 没有什么不同: ```swift actor Holder { var results: [String] = [] func setResults(_ results: [String]) { self.results = results } func append(_ value: String) { results.append(value) } } ``` 对比由私有队列保护的“手动挡”的 class,这个“自动档”的 actor 实现显然简洁得多。actor 内部会提供一个隔离域:在 actor 内部对自身存储属性或其他方法的访问,比如在 `append(_:)` 函数中使用 `results` 时,可以不加任何限制,这些代码都会被自动隔离在被封装的“私有队列”里。但是从外部对 actor 的成员进行访问时,编译器会要求切换到 actor 的隔离域,以确保数据安全。在这个要求发生时,当前执行的程序可能会发生暂停。编译器将自动把要跨隔离域的函数转换为异步函数,并要求我们使用 `await` 来进行调用。 > 虽然实际底层实现中,actor 并非持有一个私有队列,但是现在,你可以就这样简单理解。在本书后面的部分我们会做更深入的探索。 当我们把 `Holder` 从 `class` 转换为 `actor` 后,原来对 `holder` 的调用也需要更新。简单来说,在访问相关成员时,添加 `await` 即可: ```swift // holder.setResults([]) await holder.setResults([]) // holder.append(data.appending(signature)) await holder.append(data.appending(signature)) // print("Done: \(holder.getResults())") print("Done: \(await holder.results)") ``` 现在,在并发环境中访问 `holder` 不再会造成崩溃了。不过,即时使用 `Holder`,不论是基于 `DispatchQueue` 还是 `actor`,上面代码所得到的结果中依然可能会存在多于三个元素的情况。这是在预期内的:数据隔离只解决同时访问的造成的内存问题 (在 Swift 中,这种不安全行为大多数情况下表现为程序崩溃)。而这里的数据正确性关系到 actor 的**可重入** (reentrancy)。要正确理解可重入,我们必须先对异步函数的特性有更多了解,因此我们会在之后的章节里再谈到这个话题。 另外,`actor` 类型现在还并没有提供指定具体运行方式的手段。虽然我们可以使用 `@MainActor` 来确保 UI 线程的隔离,但是对于一般的 actor,我们还无法指定隔离代码应该以怎样的方式运行在哪一个线程。我们之后也还会看到包括全局 actor、非隔离标记 (nonisolated) 和 actor 的数据模型等内容。 ## 小结 我想本章应该已经有足够多的内容了。我们从最基本的概念开始,展示了使用 GCD 或者其他一些“原始”手段来处理并发程序时可能面临的困难,并在此基础上介绍了 Swift 并发中处理和解决这些问题的方式。 Swift 并发虽然涉及的概念很多,但是各种的模块边界是清晰的: - 异步函数:提供语法工具,使用更简洁和高效的方式,表达异步行为。 - 结构化并发:提供并发的运行环境,负责正确的函数调度、取消和执行顺序以及任务的生命周期。 - actor 模型:提供封装良好的数据隔离,确保并发代码的安全。 熟悉这些边界,有助于我们清晰地理解 Swift 并发各个部分的设计意图,从而让我们手中的工具可以被运用在正确的地方。作为概览,在本章中读者应该已经看到如何使用 Swift 并发的工具书写并发代码了。本书接下来的部分,将会对每个模块做更加深入的探讨,以求将更多隐藏在宏观概念下的细节暴露出来。 URL: https://onevcat.com/2021/04/magicmirror/index.html.md Published At: 2021-04-25 23:00:00 +0900 # 用树莓派打造一个超薄魔镜的简单教程 本来买了一个树莓派打算架个 [Nextcloud](https://nextcloud.com),实际弄好以后发现并不是很用得上。眼看新买的树莓派就要沦为吃灰大军中的一员,实在不甘心。正好家里有一面穿衣镜,趁机改造一下,做成了一个 Magic Mirror,最终效果如下。 ![](/assets/images/2021/mm-final-effect.jpg) 有一些朋友好奇是怎么做到的,也会想要自己动手做一个类似的。这篇文章里我简单把这个镜子用到的材料、一些基本步骤和自己遇到的一些坑记录下来,通过说明整个过程,为大家提供参考。 这篇文章不会是一个手把手一步步教你做的教程。如果你也想要制作一个你自己的魔镜,最好是根据自己的情况和现实需要选取材料,这样会更贴合你自己的需求,也能让整个过程更有乐趣。 ## 总体构成 总体上,一个魔镜由三个大部件构成:**单向玻璃**做成的镜子,放在镜子后面的**显示屏**,以及运行 [MagicMirror 软件](https://magicmirror.builders)并将内容显示在屏幕上的控制用电脑 (在本文里就指**树莓派**)。对于不同行业背景的人来说,可能难点会不太一样。我会按照顺序来介绍需要做的准备。 ### 单向玻璃 本来在装修房子的时候就买了一个普通穿衣镜挂在玄关墙上,具体来说的话是宜家的[这款 LINDBYN 镜子](https://www.ikea.cn/cn/zh/p/lindbyn-lin-de-bi-en-jing-zi-bai-se-90493700/),平平无奇。 ![](/assets/images/2021/mm-original-mirror.jpg) 想要实现魔镜效果,需要一块能从后方透过光的镜子,也就是[单向透视玻璃](https://zh.wikipedia.org/wiki/單向玻璃)。理想的普通镜子反射率 100%,而单向玻璃则是一种光线既能反射也能透射的玻璃,其反射率和透过率则根据当前房间的光线不同而有所区别。这种玻璃在审讯室里经常使用:从光线充足的一侧 (嫌疑人待的被监控房间) 来看,就是一面普通镜子;而从光线较暗的一侧 (监控房间) 则是正常的玻璃。 在实现魔镜时,使用的就是这样的原理:贴紧墙面的一侧无光,类似监控室;我们生活的空间光线较为充足,类似被监控房间;在镜子后方屏幕发出的光,相当于“改善”了镜子内侧的光线条件,这部分光透过镜子,被我们看到,从而形成“镜中屏”的效果。 回到我的情况,各种魔镜教程大佬们都是自己做木工打镜框的,但对“心灵手不巧”的我来说这有点困难。宜家的这个镜子镜框是可以很简单拆下来重复利用的,所以我只需要准备一块相同大小厚度和圆角的单面镜换上去就行了。国内网购发达,随便搜一下应该就可以找到很多“玻璃加工定制”之类的店铺,可以询问能不能做单向玻璃。如果找不到的话,做一块普通玻璃,然后买一卷单向透视的玻璃膜之类的,自己动手贴成本会更低。 因为做玻璃实在不是我的长项,更不要说给玻璃切圆角这种高级操作了,所以我选择了日本一家可以[定制镜子的网站](https://www.e-kagami.com/magic.html)直接委托做了一块合适的单向镜。测量高宽不是问题,测量圆角麻烦一些,我找到了一张[测量圆角的专用纸](/assets/images/2021/mm-round-corner.pdf),也提供给需要的同学。用 A4 无缩放打印出来,把玻璃放上去对齐一下,就能得到数值,十分方便。宜家这款镜子的圆角为 50mm,厚度 3mm,直接下单就行了 (钱飞走了...)。 对于最终魔镜的效果,单向玻璃还有一些重要参数,在这里也介绍一下。 #### 透过率 这代表了光线透过单向玻璃的效率。对于魔镜来说,用途类似电子看板,采用 8% ~ 10% 的透过率,会是比较好的选择。太高的透过率可能导致屏幕部分的镜面效果不好。 #### 反射率 由于存在透射,因此魔镜的反射率很难达到普通平面镜的水平。考虑到玻璃吸收的波长和反射膜的透射部分,一般来说 50% 的反射率就能达到不错的效果了。 #### 关于玻璃膜 不管是直接定制单向玻璃,还是买普通玻璃自己贴膜,其实都是原本一块透光良好的玻璃,配上能同时反射和透射的单向膜。如果是自己购买贴膜的话,需要特别注意不要买常规的那种办公室玻璃的热反射膜,一般来说热反射膜透过太高可能镜面效果不太好。购买时注意多询问光线透过率,不要太高就好。 #### 镜面正反 虽然单向玻璃的镜面两边都是存在反射和透射的,但是因为膜的位置会导致两面的性能不太一致:在有膜面我们能得到更多的反射率,这样会让成品有更好的镜面表现。简单来说,我们可以通过指甲接触镜面来判断是哪一面朝外:玻璃面的话,由于反射部分在玻璃后方,所以指甲尖是不能触碰到镜中虚像的;而膜面的话指尖可以直接贴到: ![](/assets/images/2021/mm-check-mirror.jpg) 相对于玻璃面,膜面的反光会更好,也就能得到更优秀的镜面。但缺点是更容易磨损,不过对于做魔镜来说,肯定还是要选膜面朝外,以获取更好的效果。 关于单向镜的一些更专业的说明,不妨参看[知乎上的相关科普](https://zhuanlan.zhihu.com/p/63316188);贴膜相关技巧也请多咨询店家。 ### 显示屏 显示屏没有太多要求,大小差不多,有合适的接口就行了。树莓派自身的视频输出是 mini HDMI 或者标准 HDMI,所以显示屏有一个 HDMI 的输入是最好的。如果想要使用显示屏给树莓派供电的话,最好是有稍微高一点的功率输入,比如 20W 甚至 30W 的 USB-C PD,这样才能保证树莓派的供电电压充足。 我这次用的是一个之前从朋友那里薅来的杂牌 15 寸显示器。朋友放那儿万年不用,于是我厚着脸皮去讨了过来;我拿到以后也万年不用,于是想起来能不能折腾一下。因为已经嵌到镜子里了,并没来得及准备照片,所以我就直接从网上找了个宣传图来用了...(侵删..) ![](/assets/images/2021/mm-display.jpg) 实际上只要厚度宽度合适,什么样的显示器都是可以的。如果预算有限的话,直接买一块裸的液晶面板会更便宜。在选择显示屏的时候,需要注意给接口的线缆留出足够空间。15 寸的显示器对于我用的镜子 (40 厘米宽) 来说,有一点点偏大了:普通的线缆,特别是 HDMI 的线,插口部位是比较长的,于是导致了显示屏无法放入镜子里。不过幸好只是差了一点点,重新购买了插口部分比较短的线缆后,有惊无险,勉强能够放入。 ### 树莓派和 Magic Mirror 最后的重头戏就是用树莓派让显示器显示 [MagicMirror](https://magicmirror.builders) 啦。MagicMirror 本身很简单,其实就是一个 Electron 包起来的 app,它提供了很多适合显示在镜子上的模块,我们可以用它来快速地配置并显示一个黑底白字,符合心意的全屏 UI。 如果只是要在树莓派上安装并运行的话,照着[官方的安装教程](https://docs.magicmirror.builders/getting-started/installation.html#manual-installation)几个命令就搞定了。本来我的如意算盘是,在已经买了的 8GB 版本性能爆炸的 Raspberry Pi 4 Model B 上多装一个 MagaicMirror,然后一并扔到镜子后面就行了。但是人算不如天算,当我想把树莓派扔到镜子后面的时候,我发现了一个问题:那就是我的镜子为了追求美观,它把自己弄得很薄... ![](/assets/images/2021/mm-mirror-depth.jpg) 整个镜子厚度其实只有 20 毫米出头,去掉镜子玻璃本身的厚度和一些凸出来的边角,其实镜子背后实际可用的厚度只有 15 毫米不到。而我回头望了望躺在角落的 Raspberry Pi 4 Model B,发现原本小巧玲珑的它,此刻的身材却显得如此“硕大”... | | Model B | Model A | Zero | | -------- | -------------------- | ----------------------- | ------------------- | | 长宽 | 85.60 mm × 56.5 mm | 65 mm × 56.5 mm | 65 mm × 30 mm | | 厚度 | 17 mm | 10 mm | 5mm | | 最大内存 | 8 GB (RPi 4 Model B) | 512 MB (RPi 3 Model A+) | 512 MB (RPi Zero W) | 已有的 17mm 的 Model B 显然是放不进去的,所以我只能选择更薄的型号。在 2021 年初这个时间点上,可供选择的只有 3 代的 Model A+ 或者 Model Zero W。但是两者都有致命的问题,那就是搭载的内存最多只有 512 MB。实测的结论是,512 MB 的内存不足以按照官方写明的方法完整运行 MagicMirror:虽然安装和初期运行可以完成 (速度很慢),但是实际运行后就算只使用默认的模块,也会由于内存不足而经常会出现卡死的状态。 不过好消息是,MagicMirror 支持服务器和客户端分开运行。也就是说,我可以用我原来的强力 4 代 Model B 运行 MagicMirror 的服务器部分,架设一个 MagicMirror 实例。然后新购入一枚薄型的树莓派,比如 Raspberry Pi 3 Model A+ 或者 Raspberry Pi Zero W,将它仅仅用来显示 MagicMirror 实例,这样就能将内存占用分成两个部分,让低端薄型树莓派也能正常工作。 我选择了 Raspberry Pi 3 Model A+,这主要是因为家里有一些多余的 HDMI 线,而缺少 Zero 需要的 mini HDMI。另外 RPi 3 Model A+ 相对性能和接口也稍微丰富一些,也许今后还有扩展的可能。不过 Zero W 尺寸更加紧凑一些,性能上应该也没什么问题。 > 注意在选择 RPi Zero W 的时候,不要误买成 RPi Zero。RPi Zero 中是没有 WiFi 支持的,而 Zero W 是 Zero 的升级版本,加入了无线连接的支持。 所以整个计划的重点就是: 1. 使用一台现有的 RPi 4 Model B 运行 MagicMirror 服务器。 2. 使用一台 RPi 3 Model A+ 作为客户端,去显示服务器提供的内容。 3. 通过家庭 WiFi 连接两台树莓派。 4. 把显示器和 RPi 3 Model A+ 放到单向玻璃的后方,并为它们接通电源。 5. 单向玻璃透过率控制在 10%,将有膜面朝外,以获得良好的反射效果。 一图胜千言。 ![](/assets/images/2021/mm-project.jpg) 在这里我们假设各设备在家庭网络中的 IP 地址如下: 1. 服务器 - 192.168.0.100 2. 客户端 - 192.168.0.110 3. 日常用的电脑 (非必要,用于验证) - 192.168.0.120 服务器和客户端的 IP 是需要固定的,通过路由器 DHCP 保留 IP 的功能,我们可以为对应的设备固定内网 IP。 ### MagicMirror 的服务器模式 利用一台长年在线的设备,安装 Node.js 和 MagicMirror 就行了。如果性能上没问题的话,普通的桌面版 RPi OS 也没问题。关于如何把 RPi OS 烧到 SD 卡以及像是网络连接这样的基本配置这里就不赘述了,可以参考[官方文档](https://www.raspberrypi.org/documentation/)进行。在准备好基本环境后,按照 [MagicMirror 的安装文档](https://docs.magicmirror.builders/getting-started/installation.html#manual-installation),在服务器设备上运行下面的命令: ```sh curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - ``` > 将 Node.js 10 的源添加到软件源列表。虽然现在 Node.js 的版本早已飞升,但是 MagicMirror 还是推荐 10.x。为了避免兼容性麻烦,而且我们也不会从新版本中得到太多收益,这里按照文档继续使用 10.x 版本。 ```sh sudo apt install -y nodejs git ``` > 安装 Node.js 和 git。Node.js 是 MagicMirror 所需要运行环境。git 是我们稍后获取 MagicMirror 时所需要的工具。 ```sh git clone https://github.com/MichMich/MagicMirror cd MagicMirror npm install ``` > 将 MagicMirror 的代码克隆至本地,进入到对应文件夹,然后用 Node.js 包管理器安装依赖。根据设备性能不同和网络环境的区别,这一步有可能会花费较长时间,需要耐心等待。 ```sh cp config/config.js.sample config/config.js ``` > 将配置的示例文件复制一份,作为初始的配置文件。之后我们会通过编辑 `config/config.js` 文件来对 MagicMirror 进行配置。 如果你的设备跑的是桌面版操作系统 (比如标准的 RPi OS 或者 Ubuntu Desktop),并且有外接屏幕的话,到这里你就应该可以运行 `npm run start` 来启动完整的 MagicMirror,并让它显示在屏幕上了。不过在本例里,我们需要的是让 MagicMirror 跑在服务器模式,然后用性能较弱的客户端连接访问。所以还需要一些额外配置。 用 nano (或者 vim) 打开配置文件 `nano config/config.js`,编辑内容。 - 将 `address` 修改为 `0.0.0.0` - 将 `port` 设为希望使用的端口号 (本例中使用 `5959`,没什么特别的意义,注意不要使用 0-1023 范围内的保留端口,也最好不要用一些常见服务的端口号,避免冲突。随机选一个 1024 以上的四位数字,大概率不会有问题)。 - `ipWhitelist` 定义了允许连接并访问服务器的 IP 地址。在我们的计划中,我们最少需要把客户端树莓派的 IP 地址填写进来;为了调试的时候方便一些,我们也可以把主要使用的桌面电脑设备 (比如 mac 或者 PC) 的 IP 也加进去,我们之前假设了客户端树莓派的 IP 是 192.168.0.110,电脑的 IP 是 192.168.0.120。 - 最后,本文面向的大概率是中文使用者,可以将 MagicMirror 的语言设为中文 `zh-cn`,区域设为 `zh`。这样的话像是日期或者天气等,就可以用我们熟悉的方式表现了。 修改后的 config.js 文件内容大致如下: ```js var config = { address: '0.0.0.0', port: 5959, ipWhitelist: [ '127.0.0.1', '192.168.0.110', '192.168.0.120' ], language: 'zh-cn', locale: 'zh' modules: [ ... // 更多内容 } ``` 进行这些修改后,就可以尝试用服务器模式运行 MagicMirror 了: ```sh npm run server # 输出 > node ./serveronly > ... > Ready to go! Please point your browser to: http://0.0.0.0:5959 ``` 尝试从电脑设备的浏览器访问服务器地址和对应端口,`http://192.168.0.100:5959`,如果一切正常的话,我们应该就能看到默认的 MagicMirror 界面了。 ![](/assets/images/2021/mm-default.png) 最后,我们可能想要 MagicMirror 的服务器在开机时就自动运行。因为 MagicMirror 其实是一个 Node.js 程序,最简单的方式是用 PM2 添加一个启动项。先停止运行中的 server,然后: ```sh sudo npm install pm2 -g pm2 start npm --name magicmirror -- run server pm2 startup ``` `pm2 startup` 将根据你的环境 (系统,用户名) 等,生成[合适的启动服务](https://pm2.keymetrics.io/docs/usage/startup/)。你只需要将输出的内容复制粘贴运行,比如: ```sh # 记得使用 pm2 startup 输出的内容 sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u ${your_username} --hp /home/${your_username} ``` 最后再次验证 MagicMirror 已经处于运行状态 (通过电脑设备的浏览器或者 `pm2 l` 命令),并将当前的 pm2 任务状态保存。这样,以后设备重启时,该服务也将随着重新启动了。 ```sh pm2 save ``` ### 客户端 这次使用的客户端有 512MB 的严重的内存限制:要进行浏览,客户端其实是需要一个图形界面和跑一个浏览器的。最方便的做法当然是直接装一个桌面系统,但是这会让原本就捉襟见肘的内存雪上加霜。为了更好的稳定性,我选择了安装纯命令行的操作系统,然后为它配置一个桌面服务和额外安装浏览器,以确保最小的内存占用。 > 因为内存限制带来的额外步骤肯定是大大增加了安装复杂程度的。有钱不急且需求超薄的同学,其实也可以等一波新的 Model A 或者 RPi Zero,应该会标配到 1GB 内存,就可以直接跑完整版 MaginMirror,也就没这么多事儿了。 我选择的是 [Raspberry Pi OS Lite](https://www.raspberrypi.org/software/operating-systems/#raspberry-pi-os-32-bit),其实 [Ubuntu Server](https://ubuntu.com/download/raspberry-pi) 应该也是没有问题的,大家都是 Debian 你好我好大家好而已。 #### 初期设定 同样,在用 [Raspberry Pi Imager](https://www.raspberrypi.org/software/) 把 RPi OS Lite 烧到客户端用的 SD 卡以后,连接显示器,开机使用默认的用户名 `pi` 和 `raspberry` 首次登入。一般来说首要做的事情是修改密码,在刚开机的 RPi OS Lite 里: ```sh passwd ``` 设定密码后,为客户端设定 WiFi。最简单的可以使用 `raspi-config` 工具: 1. 运行 `sudo raspi-config`。 2. 选择 **Localisation Options** 和 **Change wireless country** 设定一个合适的国家。(5G WiFi 在各个国家基本都有法律规制,如果不填写国家代码,可能导致无法连接 WiFi) 3. 设定 SSID 和密码,确保和验证树莓派已经连上网络 (比如 `ping apple.com`)。 > 使用 `ifconfig wlan0` 可以查看当前无线网络的连接状态,如果你还没有在路由上配置 DHCP 保留 IP 地址的话,可以在输出的信息 (`ether`) 中找到无线网卡的 MAC 地址,并在路由中进行设置。如上所述,本文中假设客户端的 IP 是 192.168.0.110。 #### 开启 SSH (可选) 为了今后能方便地连到客户端,可以继续使用 `raspi-config` 开启 SSH 访问: 1. 运行 `sudo raspi-config`。 2. 选择 **Interfacing Options**。 3. 找到并选择 **SSH**。 4. 选择 **Yes** **Ok** 和 **Finish** 来打开 SSH 支持和相关服务。 这样,以后就能从局域网内的设备通过 `ssh pi@192.168.0.110` 来随时连接到魔镜客户端进行调整或者重启了。 #### 安装桌面环境 为了能启动浏览器,并显示 MagicMirror 这样的图形,我们需要一个图形界面的环境。 ```sh sudo apt install xinit matchbox x11-xserver-utils ``` 解释一下,`xinit` 让我们可以手动启动一个 Xorg 的显示 server,也就是桌面环境;`matchbox` 是一个常见的轻量级 window manager。`x11-xserver-utils` (或者说在这个软件包中的 `xset` 命令) 提供了一些用来对显示进行设置的工具,比如关闭节能选项,关闭屏幕保护等等。 我们的计划是使用 `xinit` 来启动 `matchbox`,然后在它管理的 GUI 窗口中打开浏览器并显示 MagicMirror 的内容。 #### 安装浏览器 还需要一个可以浏览网页内容的浏览器,`chromium` 就很好: ```sh sudo apt install chromium-browser ``` 另外,在激活的窗口中,会有鼠标指针显示。对于 MagicMirror 的应用,我们肯定是希望把它隐藏掉的。可以使用 `unclutter` 来达成这个目的: ```sh sudo apt install unclutter ``` 万事俱备了,最后只需要启动浏览器,并且打开 MagicMirror 的服务器地址就行了。 #### 启动浏览器 在用户文件夹下创建一个脚本 `start_browser.sh`,然后填入以下内容: ##### start_browser.sh ```sh #!/bin/sh unclutter & # 隐藏鼠标指针 xset s off -dpms # 禁用 DPMS,禁用自动关屏节能 matchbox-window-manager & chromium-browser --check-for-update-interval=31104000 \ --start-fullscreen --kiosk --incognito \ --noerrdialogs --disable-translate --no-first-run \ --fast --fast-start --disable-infobars \ --disable-features=TranslateUI \ --disk-cache-dir=/dev/null \ http://192.168.0.100:5959 ``` 启动 chromium 时,使用 `--kiosk` 参数来隐藏掉所有 UI。注意,你需要把最后一行的地址换成你实际的服务器地址。 最后,为了能让浏览器显示中文,我们至少需要安装一个中文字体: ```sh sudo apt install fonts-wqy-microhei ``` 现在可以来实际试试看用 `xinit` 启动浏览器并在显示屏上查看效果了! ```sh chmod 755 /home/pi/start_browser.sh xinit /home/pi/start_browser.sh ``` 如果一切正常,连接在客户端的显示屏上应该就能够看到 MagicMirror 的内容了。 #### 开机自动运行 为了能在客户端树莓派开机 (或者重启后) 自动运行上面的命令并显示 MagicMirror,我们可以新创建一个脚本: ###### start.sh ```sh #!/bin/sh sleep 30 xinit /home/pi/start_browser.sh ``` 这个脚本会在开机用户登录后,等待 30 秒,然后用 xinit 运行启动浏览器的脚本。类似在服务器端的做法,我们可以用 PM2 来管理。但是其实客户端并不需要 Node.js 环境,单单为了这个目的去安装 Node.js 和 PM2 感觉有点太重了。RPi OS Lite 也是使用 `systemd` 服务来管理启动的,所以只需要添加一个服务就可以了。 ```sh sudo nano /etc/systemd/system/start_mirror.service ``` 编辑内容为: ``` [Unit] Description=Start Mirror on Startup After=default.target [Service] ExecStart=/home/pi/start.sh [Install] WantedBy=default.target ``` 这为系统添加了一个服务,我们用 `systemctl` 启用这个服务,将它设置为自动运行: ```sh chmod 755 /home/pi/start.sh sudo systemctl daemon-reload sudo systemctl enable start_mirror.service ``` 最后,我们希望作为客户端的树莓派能够在重启后自动登录并运行这些内容,所以需要开启免密的自动登录: 1. 运行 `sudo raspi-config`。 2. 选择 **System Options**,**Boot / Auto Login**。 3. 选择 **Console Autologin**,让树莓派在重启后自动登录。 应该一切都准备就绪了。现在你可以尝试重启客户端的树莓派,静待 30 秒,来看看它是否能自动打开魔镜了: ```sh sudo reboot ``` 一切准备完毕后,就可以把各种部件扔到镜子里,然后挂起来了。完工撒花~ ### 几个推荐的模块 默认情况下的 MagicMirror 已经自带了一些模块,比如天气、日历和时钟等。我们可以通过编辑**服务器端**的 MagicMirror/config/config.js 文件,来配置它们。关于这个话题,参看官方文档的 [Modules 部分](https://docs.magicmirror.builders/modules/introduction.html)的内容会更好,在这里只介绍几个我觉得很有用的第三方模块。 > 注意,在客户端一侧,我们只是单纯地跑一个浏览器。对于 Magic Mirror 的任何配置,都是在服务器端完成的。在更新服务器配置后,客户端可以使用刷新快捷键 (Ctrl+R) 来快速查看新内容。 第三方模块的安装都很简单,到 MagicMirror/modules 文件夹下,把想要使用的第三方模块 clone 下来,然后到 config.js 的 modules 里添加配置就可以了。 #### Jast [MMM-Jast](https://github.com/jalibu/MMM-Jast) 是一个非常好用的查看实时财经信息的模块。使用的是 Yahoo 的 API (不需要 API Key 或者任何帐号配置),从美股美元比特币到国债黄金大A股,只要 [Yahoo Finance](https://finance.yahoo.com) 里存在的标的,都是可添加进来。 #### Remote Control [MMM-Remote-Control](https://github.com/Jopyth/MMM-Remote-Control) 可以在服务器上添加一个网页前端,用 UI 来管理你的魔镜。你可以通过将管理页面收藏到手机或者电脑里,这样不用开终端也能完成像是个别模块的显示隐藏 (比如来客人的时候隐藏某些隐私内容),或者是整个魔镜的升级重启等操作。另外,这个模块还提供了一组 RESTful API,这样你就可以很容易地把魔镜集成到你现有的智能家居环境里,让智能音箱之类的设备去控制镜子的功能。 #### Burn In [MMM-BurnIn](https://github.com/werthdavid/MMM-BurnIn) 每隔一段时间将魔镜屏幕反色几秒,用来缓解长时间显示同样内容可能导致的烧屏 (虽然我使用的屏幕不是 OLED,可能相对较好,但是预防一下也并没有损失)。 #### Trello [MMM-Trello](https://github.com/Jopyth/MMM-Trello) 将 Trello 的卡片显示在镜子上。不止是事项提醒,有时候用来做留言或者自我激励的每日名言也不错。 #### AQI [MMM-AQI](https://github.com/ryck/MMM-AQI) 显示空气质量数据,最近几个蒙古沙尘还是很猛,看北京的小伙伴们的照片,那真是遮天蔽日。在日本这边其实还好,不是太用得上。 #### 其他模块 在 MagicMirror 的 [wiki 上列举](https://github.com/MichMich/MagicMirror/wiki/3rd-party-modules)了一些第三方模块,数量十分惊人。其实也有很多其他模块没有写在这个列表里,通过 GitHub 全站搜索 "MMM-" 也能找个八九不离十。可以选择自己喜欢的模块添加。 当然,如果没有能满足你的模块,那基本上能确定你是高端玩家了。这种情况下可以尝试自己开发模块,官方网站给了很不错的[模板和文档](https://docs.magicmirror.builders/development/introduction.html#general-advice),熟悉 Node.js 的朋友应该可以快速上手。想要将各种智能家电和魔镜做结合的同学,可能也许要读一读这里的文档,了解魔镜模块的生命周期和各种事件交互。 ## 一些值得一提的注意事项 ### 调整屏幕 如果需要在客户端竖屏显示,需要编辑 `/boot/config.txt` 文件: ```sh sudo nano /boot/config.txt ``` 把里面的 `display_rotate` 修改为需要的屏幕方向。默认为 `0` 表示不旋转。 ``` display_rotate=0 display_rotate=1 # 90 度 display_rotate=2 # 180 度 display_rotate=3 # 270 度 ``` 如果客户端部分的树莓派输出不能占满整个显示屏,可以考虑尝试将 `/boot/config.txt` 中的 `disable_overscan` 设为 `1`: ``` disable_overscan=1 ``` 在同样文件中,你还可以强制指定需要的分辨率帧率等等。一般来说保持默认即可,不再赘述。如有调整需要,可以参看[相关文档](https://www.raspberrypi.org/documentation/configuration/config-txt/video.md)。 ### 功耗和电压 在镜子的显示器和树莓派组合那边,标准的供电模型,应该是两条 DC 分别连到树莓派和显示器。为了让走线简单,我想要一根 DC 完成供电。 最早我尝试的是电源供给树莓派,然后通过树莓派的 USB 去带动显示器,实测下来树莓派 Model A+ 的拉胯的功率输出,经常会在屏幕上给出 Undervoltage warning 的[小闪电警告](https://www.raspberrypi.org/documentation/configuration/warning-icons.md)。这种情况下,运行上其实没有什么问题,但是界面上总有个闪电也很心烦。虽然通过[修改配置](https://www.raspberrypi.org/documentation/configuration/config-txt/misc.md)可以屏蔽掉这个闪电图标,但心里总还是不爽。 最后采用的是电源给显示器供电,然后显示器给树莓派 (反向) 供电的方式 (当然前提是显示器支持这种方式)。这需要电源测有稍微高一些的输出功率,实测下来 30W 是比较充裕的。当然,根据使用的显示器不同,这种方法可能会不能使用,或者这个数字可能会有不同。 ### 定时任务 为了省电(?)以及半夜下楼的时候不要被吓到,为客户端树莓派设定了一个 `cron` 任务,让镜子能自动在每天两点关闭显示器,然后在早上八点再点亮。使用 `crontab -e`,添加下面两条命令就可以了: ``` 0 2 * * * xset -display :0.0 dpms force off 0 8 * * * xset -display :0.0 dpms force on && xset -display :0.0 s off -dpms ``` 说到底还是日本电费太贵...如果能便宜一点,也就懒得这么开开关关了。 ### 增强镜面反光效果 单向镜的镜面效果无论如何还是比不过普通镜子的,所以或多或少离得近了还是会感觉镜面有点偏暗 (透过看到了镜后的黑暗空间)。作为代偿,我在镜子后面贴了一些纸,以减少透过率带来的影响。这样即使凑近,也不会看到透过的情况,可以让镜子看起来更完美一些。 ## 总结 对于一个软件行业的从业者来说,组装一个魔镜最大的挑战来自于找到合适的单向玻璃。我的话是比较偷懒直接花银子找专业人士订制解决了。如果不容易直接找到能加工单向镜的地方的话,就需要买玻璃然后自己贴单向膜,难度会陡然上升。 另外如果镜子背后的空间比较大,能够上至少 1GB 内存的 Model B 级别的设备的话,其实就不需要采用服务端和客户端分离的做法,软件安装上就会简单许多。但是厚的镜框也会带来反射率不足的问题,也许效果会有所折扣。 除了文中的内容外,接下来的玩法大概就是为镜子添加音频输入输出和语音识别 (每天对着镜子喊“魔镜魔镜告诉我”来唤醒镜子之类..),加入摄像头和人脸识别等等。也许以后换强力设备,直接对着镜子上 Zoom 开会也不是不可能。 本文是组装以后,因为有不少朋友发信息来问教程,所以凭借印象写成的。难免会有一些不足和错误,如果你发现哪里有问题,还请不吝指出,我争取多多更新补充。本来应该把客户端那边打一个镜像出来分享的 (这样就省了各种安装桌面和配置浏览器的时间),但是还请原谅我实在是太懒了,并不想再把镜子拆下来特地导出一遍镜像。而且自己动手才有乐趣,最后的成就感也会比较不同。 那就这样,加了个油! URL: https://onevcat.com/2021/03/swiftui-text-2/index.html.md Published At: 2021-03-26 12:00:00 +0900 # SwiftUI 中的 Text 插值和本地化 (下) 在[上篇中][previous],我们已经看到为什么 `Text`,或者更准确地说,`LocalizedStringKey`,可以接受 `Image` 和 `Date`,而不能接受 `Bool` 或者自定义的 `Person` 类型了。在这下篇中,让我们具体看看有哪些方法能让 `Text` 支持其他类型。 ## 为 LocalizedStringKey 自定义插值 如果我们只是想让 `Text` 可以直接接受 `true` 或者 `false`,我们可以简单地为加上 `appendInterpolation` 的 `Bool` 重载。 ```swift extension LocalizedStringKey.StringInterpolation { mutating func appendInterpolation(_ value: Bool) { appendLiteral(value.description) } } ``` 这样的话,我们就能避免编译错误了: ```swift Text("3 == 3 is \(true)") ``` 对于 `Person`,我们可以同样地添加 `appendInterpolation`,来直接为 `LocalizedStringKey` 增加 `Person` 版本的插值方法: ```swift extension LocalizedStringKey.StringInterpolation { mutating func appendInterpolation(_ person: Person, isFriend: Bool) { appendLiteral(person.title(isFriend: isFriend)) } } ``` 上面的代码为 **LocalizedStringKey.StringInterpolation** 添加了 **Bool** 和 **Person** 的支持,但是这样的做法其实破坏了本地化的支持。这可能并不是你想要的效果,甚至造成预料之外的行为。在完全理解前,请谨慎使用。在本文稍后关于本地化的部分,会对这个话题进行更多讨论。 {: .alert .alert-warning} ## LocalizedStringKey 的真面目 ### 通过 key 查找本地化值 我们花了大量篇幅,一直都在 `LocalizedStringKey` 和它的插值里转悠。回头想一想,我们似乎还完全没有关注过 `LocalizedStringKey` 本身到底是什么。正如其名,`LocalizedStringKey` 是 SwiftUI 用来在 Localization.strings 中查找 key 的类型。试着打印一下最简单的 `LocalizedStringKey` 值: ```swift let key1: LocalizedStringKey = "Hello World" print(key1) // LocalizedStringKey( // key: "Hello World", // hasFormatting: false, // arguments: [] // ) ``` 它会查找 "Hello World" key 对应的字符串。比如在本地化字符串文件中有这样的定义: ``` // Localization.strings "Hello World"="你好,世界"; ``` 那是使用时,SwiftUI 将根据 `LocalizedStringKey.key` 的值选取结果: ```swift Text("Hello World") Text("Hello World") .environment(\.locale, Locale(identifier: "zh-Hans")) ``` ![](/assets/images/2021/swiftui-text-4.png) ### 插值 LocalizedStringKey 的 key 那么有意思的部分来了,下面这个 `LocalizedStringKey` 的 key 会是什么呢? ```swift let name = "onevcat" let key2: LocalizedStringKey = "I am \(name)" ``` 是 `"I am onevcat"` 吗?如果是的话,那这个字符串要如何本地化?如果不是的话,那 key 会是什么? 打印一下看看就知道了: ```swift print(key2) // LocalizedStringKey( // key: "I am %@", // hasFormatting: true, // arguments: [ // SwiftUI.LocalizedStringKey.FormatArgument( // ...storage: Storage.value("onevcat", nil) // ) // ] // ) ``` key 并不是固定的 "I am onevcat",而是一个 String formatter:"I am %@"。熟悉 [String format](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Strings/Articles/formatSpecifiers.html) 的读者肯定对此不会陌生:`name` 被作为变量,会被传递到 String format 中,并替换掉 `%@` 这个表示对象的占位符。所以,在本地化这个字符串的时候,我们需要指定的 key 是 "I am %@"。当然,这个 `LocalizedStringKey` 也可以对应其他任意的输入: ```swift // Localization.strings "I am %@"="我是%@"; // ContentView.swift Text("I am \("onevcat")") // 我是onevcat Text("I am \("张三")") // 我是张三 ``` 对于 `Image` 插值来说,情况很相似:`Image` 插值的部分会被转换为 `%@`,以满足本地化 key 的需求: ```swift let key3: LocalizedStringKey = "Hello \(Image(systemName: "globe"))" print(key3) // LocalizedStringKey( // key: "Hello %@", // ... // ) // Localization.strings // "Hello %@"="你好,%@"; Text("Hello \(Image(systemName: "globe"))") Text("Hello \(Image(systemName: "globe"))") .environment(\.locale, Locale(identifier: "zh-Hans")) ``` ![](/assets/images/2021/swiftui-text-5.png) 值得注意的一点是,`Image` 的插值对应的格式化符号是 `%@`,这和 `String` 的插值或者其他一切对象插值所对应的符号是一致的。也就是说,下面的两种插值方式所找到的本地化字符串是相同的: ```swift Text("Hello \("onevcat")") .environment(\.locale, Locale(identifier: "zh-Hans")) Text("Hello \(Image(systemName: "globe"))") .environment(\.locale, Locale(identifier: "zh-Hans")) ``` ![](/assets/images/2021/swiftui-text-6.png) ### 其他类型的插值格式化 可能你已经猜到了,除了 `%@` 外,`LocalizedStringKey` 还支持其他类型的格式化,比如在插值 `Int` 时,会把 key 中的参数转换为 `%lld`;对 `Double` 则转换为 `%lf` 等: ```swift let key4: LocalizedStringKey = "Hello \(1))" // LocalizedStringKey(key: "Hello %lld) let key5: LocalizedStringKey = "Hello \(1.0))" // LocalizedStringKey(key: "Hello %lf) ``` 使用 `Hello %lld` 或者 `Hello %lf`,是不能在本地化文件中匹配到之前的 `Hello %@` 的。 ## 更合理的 appendInterpolation 实现 ### 避免 appendLiteral 现在让我们回到 `Bool` 和 `Person` 的插值这个话题。在本篇一开始,我们添加了两个插值方法,来让 `LocalizedStringKey` 接受 `Bool` 和 `Person` 的插值: ```swift mutating func appendInterpolation(_ value: Bool) { appendLiteral(value.description) } mutating func appendInterpolation(_ person: Person, isFriend: Bool) { appendLiteral(person.title(isFriend: isFriend)) } ``` 在两个方法中,我们都使用了 `appendLiteral` 来将 `String` 直接添加到 key 里,这样做我们得到的会是一个完整的,不含参数的 `LocalizedStringKey`,在大多数情况下,这不会是我们想要的结果: ```swift let key6: LocalizedStringKey = "3 == 3 is \(true)" // LocalizedStringKey(key: "3 == 3 is true", ...) let person = Person(name: "Geralt", place: "Rivia", nickName: "White Wolf") let key7: LocalizedStringKey = "Hi, \(person, isFriend: false)" // LocalizedStringKey(key: "Hi, Geralt of Rivia", ...) ``` 在实现新的 `appendInterpolation` 时,尊重插入的参数,将实际的插入动作转发给已有的 `appendInterpolation` 实现,让 `LocalizedStringKey` 类型去处理 key 的合成及格式化字符,应该是更合理和具有一般性的做法: ```swift mutating func appendInterpolation(_ value: Bool) { appendInterpolation(value.description) } mutating func appendInterpolation(_ person: Person, isFriend: Bool) { appendInterpolation(person.title(isFriend: isFriend)) } let key6: LocalizedStringKey = "3 == 3 is \(true)" // LocalizedStringKey(key: "3 == 3 is %@", ...) let key7: LocalizedStringKey = "Hi, \(person, isFriend: false)" // LocalizedStringKey(key: "Hi, %@", ...) ``` ### 为 Text 添加样式 结合利用 `LocalizedStringKey` 参数插值和已有的 `appendInterpolation`,可以写出一些简便方法。比如可以添加一组字符串格式化的方法,来让 `Text` 的样式设置更简单一些: ```swift extension LocalizedStringKey.StringInterpolation { mutating func appendInterpolation(bold value: LocalizedStringKey){ appendInterpolation(Text(value).bold()) } mutating func appendInterpolation(underline value: LocalizedStringKey){ appendInterpolation(Text(value).underline()) } mutating func appendInterpolation(italic value: LocalizedStringKey) { appendInterpolation(Text(value).italic()) } mutating func appendInterpolation(_ value: LocalizedStringKey, color: Color?) { appendInterpolation(Text(value).foregroundColor(color)) } } ``` ```swift Text("A \(bold: "wonderful") serenity \(italic: "has taken") \("possession", color: .red) of my \(underline: "entire soul").") ``` 可以得到如下的效果: ![](/assets/images/2021/swiftui-text-7.png) > 对应的 key 是 "A %@ serenity %@ %@ of my %@."。插值的地方都会被认为是需要参数的占位符。在一些情况下可能这不是你想要的结果,不过 attributed string 的本地化在 UIKit 中也是很恼人的存在。相对于 UIKit 来说,SwiftUI 在这方面的进步还是显而易见的。 ## 关于 `_FormatSpecifiable` 最后我们来看看关于 `_FormatSpecifiable` 的问题。可能你已经注意到了,在内建的 `LocalizedStringKey.StringInterpolation` 有两个方法涉及到了 `_FormatSpecifiable`: ```swift mutating func appendInterpolation(_ value: T) where T : _FormatSpecifiable mutating func appendInterpolation(_ value: T, specifier: String) where T : _FormatSpecifiable ``` ### 指定占位格式 Swift 中的部分基本类型,是满足 `_FormatSpecifiable` 这个私有协议的。**该协议帮助 `LocalizedStringKey` 在拼接 key 时选取合适的占位符表示**,比如对 `Int` 选取 `%lld`,对 `Double` 选取 `%lf` 等。当我们使用 `Int` 或 `Double` 做插值时,上面的重载方法将被使用: ```swift Text("1.5 + 1.5 = \(1.5 + 1.5)") // let key: LocalizedStringKey = "1.5 + 1.5 = \(1.5 + 1.5)" // print(key) // 1.5 + 1.5 = %lf ``` 上面的 `Text` 等号右边将按照 `%lf` 渲染: ![](/assets/images/2021/swiftui-text-8.png) 如果只想要保留到小数点后一位,可以直接用带有 `specifier` 参数的版本。在生成 key 时,会用传入的 `specifier` 取代原本应该使用的格式: ```swift Text("1.5 + 1.5 = \(1.5 + 1.5, specifier: "%.1lf")") // key: 1.5 + 1.5 = %.1lf ``` ![](/assets/images/2021/swiftui-text-9.png) ### 为自定义类型实现 `_FormatSpecifiable` 虽然是私有协议,但是 `_FormatSpecifiable` 相对还是比较简单的: ```swift protocol _FormatSpecifiable: Equatable { associatedtype _Arg var _arg: _Arg { get } var _specifier: String { get } } ``` 让 `_arg` 返回需要被插值的实际值,让 `_specifier` 返回占位符的格式,就可以了。比如可以猜测 `Int: _FormatSpecifiable` 的实现是: ```swift extension Int: _FormatSpecifiable { var _arg: Int { self } var _specifier: String { "%lld" } } ``` 对于我们在例子中多次用到的 `Person`,也可以用类似地手法让它满足 `_FormatSpecifiable`: ```swift extension Person: _FormatSpecifiable { var _arg: String { "\(name) of \(place)" } var _specifier: String { "%@" } } ``` 这样一来,即使我们不去为 `LocalizedStringKey` 添加 `Person` 插值的方法,编译器也会为我们选择 `_FormatSpecifiable` 的插值方式,将 `Person` 的描述添加到最终的 key 中了。 ## 总结 在[上篇][previous]的基础上,在本文中: - 我们尝试扩展了 `LocalizedStringKey` 插值的方法,让它支持了 `Bool` 和 `Person`。 - `LocalizedStringKey` 插值的主要任务是自动生成合适的,带有参数的本地化 key。 - 在扩展 `LocalizedStringKey` 插值时,应该是尽可能使用 `appendInterpolation`,避免参数“被吞”。 - 插值的格式是由 `_FormatSpecifiable` 确定的。我们也可以通过让自定义类型实现这个协议的方式,来进行插值。 至此,为什么 `Text` 中可以插值 `Image`,以及它背后发生的所有事情,我们应该都弄清楚了。 [previous]: https://onevcat.com/2021/03/swiftui-text-1/ URL: https://onevcat.com/2021/03/swiftui-text-1/index.html.md Published At: 2021-03-25 19:00:00 +0900 # SwiftUI 中的 Text 插值和本地化 (上) ## Text 中的插值 `Text` 是 SwiftUI 中最简单和最常见的 View 了,最基本的用法,我们可以直接把一个字符串字面量传入,来创建一个 `Text`: ```swift Text("Hello World") ``` ![](/assets/images/2021/swiftui-text-1.png) 在 iOS 14 (SwiftUI 2.0) 中,Apple 对 `Text` 插值进行了很多强化。除了简单的文本之外,我们还可以向 `Text` 中直接插入 `Image`: ```swift Text("Hello \(Image(systemName: "globe"))") ``` ![](/assets/images/2021/swiftui-text-2.png) 这是一个非常强大的特性,极大简化了图文混排的代码。除了普通的字符串和 `Image` 以外,`Text` 中的字符串插值还可以接受其他一些“奇奇怪怪”的类型,部分类型甚至还接受传入特性的 formatter,这给我们带来不少便利: ```swift Text("Date: \(Date(), style: .date)") Text("Time: \(Date(), style: .time)") Text("Meeting: \(DateInterval(start: Date(), end: Date(timeIntervalSinceNow: 3600)))") let fomatter: NumberFormatter = { let f = NumberFormatter() f.numberStyle = .currency return f }() Text("Pay: \(123 as NSNumber, formatter: fomatter)") ``` ![](/assets/images/2021/swiftui-text-3.png) 但是同时,一些平时可能很常见的字符串插值用法,在 `Text` 中并不支持,最典型的,我们可能遇到下面两种关于 `appendInterpolation` 的错误: ```swift Text("3 == 3 is \(true)") // 编译错误: // No exact matches in call to instance method 'appendInterpolation' struct Person { let name: String let place: String } Text("Hi, \(Person(name: "Geralt", place: "Rivia"))") // 编译错误: // Instance method 'appendInterpolation' requires that 'Person' conform to '_FormatSpecifiable' ``` 一开始遇到这些错误很可能会有点懵,`appendInterpolation` 是什么,`_FormatSpecifiable` 又是什么?要怎么才能让这些类型和 `Text` 一同工作? 我打算花两篇文章对这个话题和相关的 API 设计进行一些探索。 在本文中,我们会先来看看到底为什么 `Text` 可以接受 `Image` 或者 `Date` 作为插值,而不能接受 `Bool` 或 `Person` 这样的类型,这涉及到 Swift 5 中引入的自定义字符串插值的特性。在[稍后的下篇][next]里,我们会探索这个话题背后引出的更深层次的内容,来看看 SwiftUI 中的本地化到底是如何工作的。我们也会讨论应该如何解决对应的问题,并利用这些的特性写出更正确和漂亮的 SwiftUI 代码。 ## 幕后英雄:LocalizedStringKey SwiftUI 把多语言本地化的支持放到了首位,在直接使用字符串字面量去初始化一个 `Text` 的时候,所调用到的方法其实是 `init(_:tableName:bundle:comment:)`: ```swift extension Text { init( _ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, comment: StaticString? = nil ) } ``` `Text` 使用输入的 `key` 去 bundle 中寻找本地化的字符串文件,并且把满足设备语言的结果渲染出来。 因为 `LocalizedStringKey` 满足 `ExpressibleByStringInterpolation` (以及其父协议 `ExpressibleByStringLiteral`),它可以直接由字符串的字面量转换而来。也就是说,在上面例子中,不论被插值的是 `Image` 还是 `Date`,最后得到的,作为 `Text` 初始化方法的输入的,其实都是 `LocalizedStringKey` 实例。 > 对于字符串字面量来说,`Text` 会使用上面这个 `LocalizedStringKey` 重载。如果先把字符串存储在一个 `String` 里,比如 `let s = "hello"`,那么 `Text(s)` 将会选取另一个,接受 `StringProtocol` 的初始化方法:`init(_ content: S) where S : StringProtocol`。 > > `Text` 的另一个重要的初始化方法是 `init(verbatim:)`。如果你完全不需要本地化对应,那么使用这个方法将让你直接使用输入的字符串,从而完全跳过 `LocalizedStringKey`。 我们可以证明一下这一点:当按照普通字符串插值的方法,尝试简单地打印上面的插值字符串时,得到的结果如下: ```swift print("Hello \(Image(systemName: "globe"))") // Hello Image(provider: SwiftUI.ImageProviderBox) print("Date: \(Date(), style: .date)") // 编译错误: // Cannot infer contextual base in reference to member 'date' ``` `Image` 插值直接使用了 struct 的标准描述,给回了一个普通字符串;而 `Date` 的插值则直接不接受额外参数,给出了编译错误。无论哪个,都不可能作为简单字符串传给 `Text` 并得到最后的渲染结果。 实际上,在 `Text` 初始化方法里,这类插值使用的是 `LocalizedStringKey` 的相关插值方法。这也是在 Swift 5 中新加入的特性,它可以让我们进行对任意类型的输入进行插值 (比如 `Image`),甚至在插值时设定一些参数 (比如 `Date` 以及它的 `.date` style 参数)。 ## StringInterpolation 普通的字符串插值是 Swift 刚出现时就拥有的特性了。可以使用 `\(variable)` 的方式,将一个可以表示为 `String` 的值加到字符串字面量里: ```swift print("3 == 3 is \(true)") // 3 == 3 is true let luckyNumber = 7 print("My lucky number is \(luckNumber).") // My lucky number is 7. let name = "onevcat" print("I am \(name).") // I am onevcat. ``` 在 Swift 5 中,字面量插值得到了强化。我们可以通过让一个类型遵守 `ExpressibleByStringInterpolation` 来自定义插值行为。这个特性其实已经被讨论过不少了,但是为了让你更快熟悉和回忆起来,我们还是再来看看它的基本用法。 Swift 标准库中的 `String` 是满足该协议的,想要扩展 `String` 所支持的插值的类型,我们可以扩展 `String.StringInterpolation` 类型的实现,为它添加所需要的适当类型。用上面出现过的 `Person` 作为例子。不加修改的话,`print` 会按照 Swift struct 的默认格式打印 `Person` 值: ```swift struct Person { let name: String let place: String } print("Hi, \(Person(name: "Geralt", place: "Rivia"))") // Hi, Person(name: "Geralt", place: "Rivia") ``` 如果我们想要一个[更 role play 一点的名字](https://witcher.fandom.com/wiki/Geralt_of_Rivia)的话,可以考虑扩展 `String.StringInterpolation`,添加一个 `appendInterpolation(_ person: Person)` 方法,来自定义字符串字面量接收到 `Person` 时的行为: ```swift extension String.StringInterpolation { mutating func appendInterpolation(_ person: Person) { // 调用的 `appendLiteral(_ literal: String)` 接受 `String` 参数 appendLiteral("\(person.name) of \(person.place)") } } ``` 现在,`String` 中 `Person` 插值的情况会有所变化: ```swift print("Hi, \(Person(name: "Geralt", place: "Rivia"))") // Hi, Geralt of Rivia ``` 对于多个参数的情况,我们可以在 `String.StringInterpolation` 添加新的参数,并在插值时用类似“方法调用”写法,将参数传递进去: ```swift struct Person { let name: String let place: String // 好朋友的话一般叫昵称就行了 var nickName: String? } extension Person { var formalTitle: String { "\(name) of \(place)" } // 根据朋友关系,返回称呼 func title(isFriend: Bool) -> String { isFriend ? (nickName ?? formalTitle) : formalTitle } } extension String.StringInterpolation { mutating func appendInterpolation(_ person: Person, isFriend: Bool) { appendLiteral(person.title(isFriend: isFriend)) } } ``` 调用时,加上 `isFriend`: ```swift let person = Person( name: "Geralt", place: "Rivia", nickName: "White Wolf" ) print("Hi, \(person, isFriend: true)") // Hi, White Wolf ``` ## LocalizedStringKey 的字符串插值 ### Image 和 Date 了解了 `StringInterpolation` 后,我们可以来看看在 `Text` 语境下的 `LocalizedStringKey` 是如何处理插值的了。和普通的 `String` 类似,`LocalizedStringKey` 也遵守了 `ExpressibleByStringInterpolation`,而且 SwiftUI 中已经为它的 `StringInterpolation` 提供了一些常用的扩展实现。在当前 (iOS 14) 的 SwiftUI 实现中,它们包含了: ```swift extension LocalizedStringKey.StringInterpolation { mutating func appendInterpolation(_ string: String) mutating func appendInterpolation(_ subject: Subject, formatter: Formatter? = nil) where Subject : ReferenceConvertible mutating func appendInterpolation(_ subject: Subject, formatter: Formatter? = nil) where Subject : NSObject mutating func appendInterpolation(_ value: T) where T : _FormatSpecifiable mutating func appendInterpolation(_ value: T, specifier: String) where T : _FormatSpecifiable mutating func appendInterpolation(_ text: Text) mutating func appendInterpolation(_ image: Image) mutating func appendInterpolation(_ date: Date, style: Text.DateStyle) mutating func appendInterpolation(_ dates: ClosedRange) mutating func appendInterpolation(_ interval: DateInterval) } ``` 在本文第一部分的例子中,所涉及到的 `Image` 和 `Date` style 的插值,使用的正是上面所声明了的方法。在接受到正确的参数类型后,通过创建合适的 `Text` 进而得到最终的 `LocalizedStringKey`。我们很容易可以写出例子中的两个 `appendInterpolation` 的具体实现: ```swift mutating func appendInterpolation(_ image: Image) { appendInterpolation(Text(image)) } mutating func appendInterpolation(_ date: Date, style: Text.DateStyle) { appendInterpolation(Text(date, style: style)) } ``` ### Bool 和 Person 那么现在,我们就很容易理解为什么在最上面的例子中,`Bool` 和 `Person` 不能直接用在 `Text` 里的原因了。 对于 `Bool`: ```swift Text("3 == 3 is \(true)") // 编译错误: // No exact matches in call to instance method 'appendInterpolation' ``` `LocalizedStringKey` 没有针对 `Bool` 扩展 `appendInterpolation` 方法,于是没有办法使用插值的方式生成 `LocalizedStringKey` 实例。 对于 `Person`,最初的错误相对难以理解: ```swift Text("Hi, \(Person(name: "Geralt", place: "Rivia"))") // 编译错误: // Instance method 'appendInterpolation' requires that 'Person' conform to '_FormatSpecifiable' ``` 对照 SwiftUI 中已有的 `appendInterpolation` 实现,不难发现,其实它使用的是 : ```swift mutating func appendInterpolation(_ value: T) where T : _FormatSpecifiable ``` 这个最接近的重载方法,不过由于 `Person` 并没有实现 `_FormatSpecifiable` 这个私有协议,所以实质上还是找不到合适的插值方法。想要修正这个错误,我们可以选择为 `Person` 添加 `appendInterpolation`,或者是让它满足 `_FormatSpecifiable` 这个私有协议。不过两种方式其实本质上是**完全不同**的,而且根据实际的使用场景不同,有时候可能会带来意想不到的结果。我们会在这个[系列博文的下篇][next]中对这个话题做详细介绍。 ## 小结 - SwiftUI 2.0 中可以向 `Text` 中插值 `Image` 和 `Date` 这样的非 `String` 值,这让图文混排或者格式化文字非常方便。 - 灵活的插值得益于 Swift 5.0 引入的 `ExpressibleByStringInterpolation`。你可以为 `String` 自定义插值方式,甚至可以为自定义的任意类型设定字符串插值。 - 用字符串字面量初始化 `Text` 的时候,参数的类型是 `LocalizedStringKey`。 - `LocalizedStringKey` 实现了接受 `Image` 或者 `Date` 的插值方法,所以我们可以在创建 `Text` 时直接插入 `Image` 或者格式化的 `Date`。 - `LocalizedStringKey` 不接受 `Bool` 或者自定义类型的插值参数。我们可以添加相关方法,不过这会带来副作用。 [next]: https://onevcat.com/2021/03/swiftui-text-2/ URL: https://onevcat.com/2021/01/swiftui-state/index.html.md Published At: 2021-01-22 15:00:00 +0900 # 关于 SwiftUI State 的一些细节 > ### 2021 年 9 月更新 > > 在评论区里,[@CrystDragon 指出](https://github.com/onevcat/OneV-s-Den-Comments/issues/7#issuecomment-906893521)原文章的部分 > 内容已经在新版本 SwiftUI 中发生了变化。不过这也带来了另一方面更加让人迷惑的问题。因此我对部分内容进行了更新和额外说明,更新部分会作为评注内 > 容写在相关原文的后面。 ## @State 基础 在 SwiftUI 中,我们使用 `@State` 进行私有状态管理,并驱动 `View` 的显示,这是基础中的基础。比如,下面的 `ContentView` 将在点击加号按钮时将显示的数字 +1: ```swift struct ContentView: View { @State private var value = 99 var body: some View { VStack(alignment: .leading) { Text("Number: \(value)") Button("+") { value += 1 } } } } ``` 当我们想要将这个状态值传递给下层子 View 的时候,直接在子 View 中声明一个变量就可以了。下面的 View 在表现上来说完全一致: ```swift struct DetailView: View { let number: Int var body: some View { Text("Number: \(number)") } } struct ContentView: View { @State private var value = 99 var body: some View { VStack(alignment: .leading) { DetailView(number: value) Button("+") { value += 1 } } } } ``` 在 `ContentView` 中的 `@State value` 发生改变时,`ContentView.body` 被重新求值,`DetailView` 将被重新创建,包含新数字的 `Text` 被重新渲染。一切都很顺利。 ## 子 View 中自己的 @State 如果我们希望的不完全是这种被动的传递,而是希望 `DetailView` 也拥有这个传入的状态值,并且可以自己对这个值进行管理的话,一种方法是在让 `DetailView` 持有自己的 `@State`,然后通过初始化方法把值传递进去: ```swift struct DetailView0: View { @State var number: Int var body: some View { HStack { Text("0: \(number)") Button("+") { number += 1 } } } } // ContentView @State private var value = 99 var body: some View { // ... DetailView0(number: value) } ``` 这种方法能够奏效,但是违背了 `@State` 文档中关于这个属性标签的说明: > ... declare your state properties as private, to prevent clients of your view from accessing them. **如果一个 `@State` 无法被标记为 private 的话,一定是哪里出了问题**。一种很朴素的想法是,将 `@State` 声明为 `private`,然后使用合适的 `init` 方法来设置它。更多的时候,我们可能需要初始化方法来解决另一个更“现实”的问题:那就是使用合适的初始化方法,来对传递进来的 `value` 进行一些处理。比如,如果我们想要实现一个可以对任何传进来的数据在显示前就进行 +1 处理的 View: ```swift struct DetailView1: View { @State private var number: Int init(number: Int) { self.number = number + 1 } // } ``` 但这会给出一个编译错误! > Variable 'self.number' used before being initialized > ### 2021 年 9 月更新 > > 在最新的 Xcode 中,上面的方法已经不会报错了:对于初始化方法中类型匹配的情况,Swift 编译时会将其映射到内部底层存储的值,并完成设置。 > 不过,对于类型不匹配的情况,这个映射依然暂时不成立。比如下面的 `var number: Int?` 和输入参数的 `number: Int` 就是一个例子。因此,我决定 > 还是把下面的讨论再保留一段时间。 一开始你可能对这个错误一头雾水。我们会在本文后面的部分再来看这个错误的原因。现在先把它放在一边,想办法让编译通过。最简单的方式就是把 `number` 声明为 `Int?`: ```swift struct DetailView1: View { @State private var number: Int? init(number: Int) { self.number = number + 1 } var body: some View { HStack { Text("1: \(number ?? 0)") Button("+") { number = (number ?? 0) + 1 } } } } // ContentView @State private var value = 99 var body: some View { // ... DetailView1(number: value) } ``` 问答时间,你觉得 `DetailView1` 中的 `Text` 显示的会是什么呢?是 0,还是 100? > 我有一个邪恶的想法,也许可以把这道题加到我的面试题列表里去问其他小朋友.... 如果你回答的是 100 的话,恭喜,你答错掉“坑”里了。比较“出人意料”,虽然我们在 `init` 中设置了 `self.number = 100`,但在 `body` 被第一次求值时,`number` 的值是 `nil`,因此 `0` 会被显示在屏幕上。 ## @State 内部 问题出在 `@State` 上:SwiftUI [通过 property wrapper](/2019/06/swift-ui-firstlook-2/#教程-3---handling-user-input) 简化并模拟了普通的变量读写,但是我们必须始终牢记,`@State Int` 并不等同于 `Int`,它根本就不是一个传统意义的存储属性。这个 property wrapper 做的事情大体上说有三件: 1. 为底层的存储变量 `State` 这个 struct 提供了一组 getter 和 setter,这个 `State` struct 中保存了 `Int` 的具体数字。 2. 在 body 首次求值前,将 `State` 关联到当前 `View` 上,为它在堆中对应当前 `View` 分配一个存储位置。 3. 为 `@State` 修饰的变量设置观察,当值改变时,触发新一次的 `body` 求值,并刷新屏幕。 我们可以看到的 `State` 的 public 的部分只有几个初始化方法和 property wrapper 的标准的 value: ```swift struct State : DynamicProperty { init(wrappedValue value: Value) init(initialValue value: Value) var wrappedValue: Value { get nonmutating set } var projectedValue: Binding { get } } ``` 不过,通过打印和 dump `State` 的值,很容易知道它的几个私有变量。进一步地,可以大致猜测相对更完整和“私密”的 `State` 结构如下: ```swift struct State : DynamicProperty { var _value: Value var _location: StoredLocation? var _graph: ViewGraph? var wrappedValue: Value { get { _value } set { updateValue(newValue) } } // 发生在 init 后,body 求值前。 func _linkToGraph(graph: ViewGraph) { if _location == nil { _location = graph.getLocation(self) } if _location == nil { _location = graph.createAndStore(self) } _graph = graph } func _renderView(_ value: Value) { if let graph = _graph { // 有效的 State 值 _value = value graph.triggerRender(self) } } } ``` > SwiftUI 使用 meta data 来在 View 中寻找 `State` 变量,并将用来渲染的 `ViewGraph` 注入到 State 中。当 State 发生改变时,调用这个 Graph 来刷新界面。关于 `State` 渲染部分的原理,超出了本文的讨论范围。有机会在后面的博客再进一步探索。 对于 `@State` 的声明,会在当前 View 中带来一个自动生成的私有存储属性,来存储真实的 `State struct` 值。比如上面的 `DetailView1`,由于 `@State number` 的存在,实际上相当于: ```swift struct DetailView1: View { @State private var number: Int? private var _number: State // 自动生成 // ... } ``` 这为我们解释了为什么刚才直接声明 `@State var number: Int` 无法编译: ```swift struct DetailView1: View { @State private var number: Int init(number: Int) { self.number = number + 1 } // } ``` `Int?` 的声明在初始化时会默认赋值为 `nil`,让 `_number` 完成初始化 (它的值为 `State>(_value: nil, _location: nil)`);而非 Optional 的 `number` 则需要明确的初始化值,否则在调用 `self.number` 的时候,底层 `_number` 是没有完成初始化的。 于是“为什么 init 中的设置无效”的问题也迎刃而解了。对于 `@State` 的设置,只有在 View 被添加到 graph 中以后 (也就是首次 body 被求值前) 才有效。 > 当前 SwiftUI 的版本中,自动生成的存储变量使用的是在 State 变量名前加下划线的方式。这也是一个代码风格的提示:我们在自己选择变量名时,虽然部分语言使用下划线来表示类型中的私有变量,但在 SwiftUI 中,最好是避免使用 `_name` 这样的名字,因为它有可能会被系统生成的代码占用 (类似的情况也发生在其他一些 property wrapper 中,比如 Binding 等)。 ## 几种可选方案 在知道了 `State` struct 的工作原理后,为了达到最初的“在 init 中对传入数据进行一些操作”这个目的,会有几种选择。 首先是直接操作 `_number`: ```swift struct DetailView2: View { @State private var number: Int init(number: Int) { _number = State(wrappedValue: number + 1) } var body: some View { return HStack { Text("2: \(number)") Button("+") { number += 1 } } } } ``` 因为现在我们直接插手介入了 `_number` 的初始化,所以它在被添加到 View 之前,就有了正确的初始值 100。不过,因为 `_number` 显然并不存在于任何文档中,这么做带来的风险是这个行为今后随时可能失效。 另一种可行方案是,将 init 中获取的 `number` 值先暂存,然后在 `@State number` 可用时 (也就是在 body ) 中,再进行赋值: ```swift struct DetailView3: View { @State private var number: Int? private var tempNumber: Int init(number: Int) { self.tempNumber = number + 1 } var body: some View { DispatchQueue.main.async { if (number == nil) { number = tempNumber } } return HStack { Text("3: \(number ?? 0)") Button("+") { number = (number ?? 0) + 1 } } } } ``` 不过,这样的做法也并不是很合理。`State` 文档中明确指出: > You should only access a state property from inside the view’s body, or from methods called by it. 虽然 `DetailView3` 可以按照预期工作,但通过 `DispatchQueue.main.async` 中来访问和更改 state,是不是推荐的做法,还是存疑的。另外,由于实际上 `body` 有可能被多次求值,所以这部分代码会多次运行,你必须考虑它在 body 被重新求值时的正确性 (比如我们需要加入 `number == nil` 判断,才能避免重复设值)。在造成浪费的同时,这也增加了维护的难度。 对于这种方法,一个更好的设置初值的地方是在 `onAppear` 中: ```swift struct DetailView4: View { @State private var number: Int = 0 private var tempNumber: Int init(number: Int) { self.tempNumber = number + 1 } var body: some View { HStack { Text("4: \(number)") Button("+") { number += 1 } }.onAppear { number = tempNumber } } } ``` 虽然 `ContentView`中每次 `body` 被求值时,`DetailView4.init` 都会将 `tempNumber` 设置为最新的传入值,但是 `DetailView4.body` 中的 `onAppear` 只在最初出现在屏幕上时被调用一次。在拥有一定初始化逻辑的同时,避免了多次设置。 > ### 2021 年 9 月更新 > > 如果一定要从外部给 `@State` 一个初始值,这种方式是笔者比较推荐的方式:从外部在 initializer 中直接对 `@State` 直接进行初始化, > 是反模式的做法:一方面它事实上违背了 `@State` 应该是纯私有状态这一假设,另一方面由于 SwiftUI 中 View 只是一个“虚拟”的结构,而非真实的渲染 > 对象,即使表现为同一个视图,它在别的 view 的 `body` 中是可能被重复多次创建的。在初始化方法中做 `@State` 赋值,很可能导致已经改变的现有状态 > 被意外覆盖,这往往不是我们想要的结果。 ## State, Binding, StateObject, ObservedObject `@StateObject` 的情况和 `@State` 很类似:View 都拥有对这个状态的所有权,它们不会随着新的 View init 而重新初始化。这个行为和 `Binding` 以及 `ObservedObject` 是正好相反的:使用 `Binding` 和 `ObservedObject` 的话,意味着 View 不会负责底层的存储,开发者需要自行决定和维护“非所有”状态的声明周期。 当然,如果 `DetailView` 不需要自己拥有且独立管理的状态,而是想要直接使用 `ContentView` 中的值,且将这个值的更改反馈回去的话,使用标准的 `@Bining` 是毫无疑问的: ```swift struct DetailView5: View { @Binding var number: Int var body: some View { HStack { Text("5: \(number)") Button("+") { number += 1 } } } } ``` 在[之前的一篇文章](/2020/06/stateobject/) 中,我们已经详细探讨了这方面的内容。如果有兴趣的话,不妨花时间读读看。 ## 状态重设 对于文中的情景,想要对本地的 `State` (或者 `StateObject`) 在初始化时进行操作,最合适的方式还是通过在 `.onAppear` 里赋值来完成。如果想要在初次设置后,再次将父 view 的值“同步”到子 view 中去,可以选择使用 `id` modifier 来将子 view 上的已有状态清除掉。在一些场景下,这也会非常有用: ```swift struct ContentView: View { @State private var value = 99 var identifier: String { value < 105 ? "id1" : "id2" } var body: some View { VStack(alignment: .leading) { DetailView(number: value) Button("+") { value += 1 } Divider() DetailView4(number: value) .id(identifier) } } ``` 被 `id` modifier 修饰后,每次 `body` 求值时,`DetailView4` 将会检查是否具有相同的 `identifier`。如果出现不一致,在 graph 中的原来的 `DetailView4` 将被废弃,所有状态将被清除,并被重新创建。这样一来,最新的 `value` 值将被重新通过初始化方法设置到 `DetailView4.tempNumber`。而这个新 `View` 的 `onAppear` 也会被触发,最终把处理后的输入值再次显示出来。 ## 总结 对于 `@State` 来说,严格遵循文档所预想的使用方式,避免在 body 以外的地方获取和设置它的值,会避免不少麻烦。正确理解 `@State` 的工作方式和各个变化发生的时机,能让我们在迷茫时找到正确的分析方向,并最终对这些行为给出合理的解释和预测。 URL: https://onevcat.com/2021/01/2020-final/index.html.md Published At: 2021-01-04 18:50:00 +0900 # 迟到的 2020 年终总结 岁月如梭,白驹过隙。年前就打算写的这篇 2020 年终总结,硬生生被拖成了 2021 的“去年回顾”。主要还是因为思前想后,觉得 2020 年实在太过特殊:在 2020 经历的事情,也许今后很长一段时间都不会再有;于是在 2020 总结的经验,似乎也很难运用于未来。不过就算如此,还是勉强写点儿什么,权当留个纪念吧。 ### 关于疫情 疫情当然是关键词,从一个长期驻日的人眼中看来,就是“君病我未病*,*我病君已好”。当国内的小伙伴们都说说笑笑,手舞足蹈,穿上了自己最体面的衣裳,纷纷走出家门的时候,我还只能天天宅家里看着日日新高,瑟瑟发抖。幸运的是三月份的时候趁着疫情还没完全爆开,掐着点儿搬到了大一点的房子里,有了一个稍好的宅家环境,小朋友们也能活动活动,算是今年最明智的选择。 这波病毒其实对软件行业来说,影响有限,我们的项目决策和进度反而比以前要快了不少。远程会议说开就开,不需要排时间排地点;每天在家工作,上下班不用花两小时在电车上;不会有人动不动就跑过来打断思路,可以保持长时间的专注。这些都极大提高了效率并带来了一些“幸福感”。但是肉眼可见,对于需要实体运营的行业,比如餐饮、旅游来说,疫情的打击还是相当严重。观察各个国家政治体制的区别,所造成的抗疫行动和措施的差距,已经不属于我这个“工程师网红”有资格拿出来讨论的东西了。但是单就日本来说,很明显可以看得出,在“保经济”和“抗新冠”之间各种横跳:时而鼓励出游,时而紧急封锁。既不能下决心彻底阻绝疫情,亦不敢直接放开等着全民免疫。各类“左右互搏”的迷惑操作,带来了严重的政策空转和恶劣影响,也成功让日本成了东亚地区表现最差的国家,没有之一。现状打个比喻,大概就是日光倾城而下,整个社会被逼入了死角,步履维艰。 ### 关于工作 扯远了扯远了,还是来讲点代码相关的吧。好吧,其实没什么特别大的进展... 这一年一共发了 16 个 [Kingfisher 版本](https://github.com/onevcat/Kingfisher),除了那些年轻时候写的垃圾代码之外,基本算是在功能层面上把这个框架完善到比较满意的状态了。[UniWebView](https://uniwebview.com) 发了 14 个版本,其中包括一次 major 的升级,在工作之余也算带来了稳定的现金流,能减轻一些日常的开销压力。 开源以外的本职工作上的话,由于疫情的原因,我们的直播服务似乎迎来了一次真正的增长。不过利润都是资本家的,生活不易,打工人只期盼下个月奖金能多发一点就好... ![](/assets/images/2021/2020-final-1.jpg) ### 关于学习 每年学习新的编程语言的目标还在继续,今年挑了前端的 [Elm](https://elm-lang.org) 和全端的 [Rust](https://www.rust-lang.org)。 Elm 在 [objc.io 的一些书里](https://objccn.io/products/),特别是关于架构和函数式编程的书里,已经提到过很多次了。Elm 其实并不单纯只是一个语言,它同时也是一个典型的[单向数据流](https://onevcat.com/2017/07/state-based-viewcontroller/)的前端框架,算是 Redux 或者 Flutter 数据流和鼻祖。相对于使用 JavaScript 或者 Dart 语言来说,Elm 本身是和 Haskell 很接近的纯函数式的语言,因此在使用这类函数式的数据流架构时,会显得更加契合。对于自己来说,Elm 也让我在学了 Haskell 后第一次能将这些东西应用到实际,做出一点东西:为公司项目的 QA 团队写了一点小工具来作为练习。整个流程和开发体验还是不错的,想要的几个依赖也能在社区中轻松找到。不过如果要在更大规模,或者实际的产品中使用 Elm,可能还需要一些观察和更多经验。 Rust 今年已经很火了,接近甚至[超过 C](https://benchmarksgame-team.pages.debian.net/benchmarksgame/which-programs-are-fastest.html) 的性能,严谨绝不出错的所有权内存管理模型,以及由此带来的天生安全的多线程,每一个都切中要害,解决的是工程师每天都会头疼的问题。相对于 Haskell 或者 Elm,Rust 的学习曲线虽然要平缓得多,不过可以看出这门语言在设计时针对的就是有一定经验的开发者。如果没有在其他语言的实践中踩过一些坑,可能很难体会得到某些设计上的精妙之处。 现在对于 Rust 的理解还处于写一些简单的命令行工具,不过之后应该至少会尝试做一些更多的事情,比如写一点 web app 或者放到嵌入式设备里看看情况。如果有新的惊喜或者体会,再和大家汇报。 ### 关于阅读 大概因为在家时间比较多,所以相比往年来说,有机会多读了几本书。其中电子书和实体书都有一些,技术书和杂谈书都有一些,中文书和英文书都有一些。稍微对印象深刻的几本写点儿吧。 #### [人类简史:从动物到上帝](https://book.douban.com/subject/25985021/) 很有意思的一本人文类科普读物,其中有些观点很有意思。比如作者认为: > 智人之所以区别于别的动物,在于智人拥有“讲故事”的能力,并且这样的故事可以大范围流传,并被接受的相信。 > > 社会制度,价值取向等等,无一不是故事。 我们自小受到的马克思唯物主义认为,物质独立于意识,意识是物质的反应。辩证唯物论承认,事物的发展,当然也包括所谓的社会制度、价值取向等等,都是以矛盾驱动的。这些意识方面的东西,显然是一个个智人编造的“故事”。那我们是不是可以认为,有什么样的物质,就能编造出什么样的故事,也就能满足当时人们对于这些故事的需求。那么,“生产力决定生产关系”这样一个故事,是不是也是特定的物质条件下才能被编出来的,其实也并不是什么“真理”呢?再进一步,可能就上升到,唯物论本身是不是唯物的,要如何证明这个关系? 作者是历史学的专攻,并没有按照中国人的思维,明确给出的一个在马克思哲学框架下的答案。但是作者在书中用了一个很经典的手法,把《汉摩拉比法典》和《独立宣言》做了一番对比解读,来讲解故事到底是如何包装这些价值取向概念的,让人在忍俊不禁的同时拍案叫绝。 如果对于人类的意识和社会状态感兴趣,这本书当真有趣。 #### [复杂生命的起源](https://book.douban.com/subject/35221093/) 这本书有一本“姊妹篇”,[《生命进化的跃升》](https://book.douban.com/subject/35094222/)。两本书内容有部分相似,所以除非对这个话题感兴趣,否则基本只需要读一本就够了。 这两本书都是非常硬核的科普书籍,如果对生物学不是很感兴趣,或者没有跟上作者思路的话,可能会读不下去。通过对生物按照[三域系统](https://zh.wikipedia.org/wiki/三域系統)进行分类,作者认为复杂生命的起源发生且仅发生了一次。关于人类的“我是谁”这个终极问题,作者在更大的尺度上给出了一种诠释,并且由此推论和解释了包括人类在内的复杂生命,是如何选择了这一条路径演化至今的。 谁知道呢,也许这就是终极问题的答案。 #### [The Choice: Embrace the Possible](https://book.douban.com/subject/27609751//) 作者是奥斯维辛集中营的幸存者,战后帮助很多心理创伤患者走出阴霾,重新找到快乐。书中讨论了“伤害”和“受难”的区别:“伤害”更多地来自于外界,横行霸道的邻居,无能狂怒的上司,家庭暴力拳脚相向,甜言蜜语连哄带骗;而“受难”完全不同,它是内在的,只有你自己能够伤害到自己,并让自己的内心受难。伤害大多是一时的,但是如果在阴霾中走不出来,受难将会长期持续。 在奥斯威辛集中营的待遇,想来会比我们大多数人所受到的最大的苦难都更加残酷。作者在集中营里失去了至亲,受尽了折磨。但她仍然告诫我们,将自己的痛苦和别人的 (所谓更强烈) 的痛苦相比,然后得出结论:我所承受的痛苦不过如此,并以次期望能够减轻痛苦,是不切实际的。想要“生存”下来,要做的事情是接受:接受从前,也接受当下的情况。如果我们选择的是惩罚自己,让自己感觉到孤单和隔离,去臆想别人的“更大的”痛苦,那其实这样的选择不过是再一次从自己的内心伤害自己。在试图抚慰一个这样的人时,我们不希望对方得到的结论是“你比我惨多了,我的痛苦不算什么”,我们希望的结论是“你可以做到与自己和解,那么我也能!” 所以,不要去比较,而要与自己和解。与其坐地悲伤,不如即时行动。这是我从这本书里学到的东西。 #### [论中国](https://book.douban.com/subject/26607419/) [中国人民的老朋友](https://zh.wikipedia.org/wiki/中国人民的老朋友)基辛格同志的一本中国相关的书籍。从书中很容易看出,基辛格是真正的“知华派”,而且是真正的站在美国立场和美国利益的知华派。幸好在他任内中美关系不像 2020 这样糟糕,幸好现在美国政坛上再看不到这种对华认知准确的人物。从这个意义上来说,真是天佑中华。 他对中国的认知,特别是对建国后三代领导人的观察,是建立在大量的交往和实践中的。这和近年来某些令人啼笑皆非的对中国的臆测和揣摩,形成了鲜明的对比。关于使用“民族性”来预测中国的行动,以及以数十年为单位来衡量中国对利益决策的考虑这些技能,大概已经被当代只看短期利益的无知政客们彻底无视了。 总之,作为那段波澜壮阔的历史的亲历者和推动者,基辛格所讲述的“中国故事”,至少为中国人提供另一个审视自我的角度。 #### [重构 - 改善既有代码的设计](https://book.douban.com/subject/33400354/) 这本书很经典,以前是用 Java 举例,这次重读了一遍“与时俱进”用 JavaScript 写的第二版。 重构这个话题不管说起来还是做起来,都十分重要。但是现实里往往会被有意无意忽视,直到陷入泥沼寸步难行之后,才会为当初后悔莫及。大道理讲太多没用,一点一点在实际中尝试去做,让重构逐渐变成一种习惯,而不是刻意为之,才是正道。 第二版除了更换语言之外,也针对最近的风气 (或者换个词,最佳实践) 调整了一部分重构手法。去掉了一些过于老旧,不合时宜,甚至是被时间检验后不靠谱的重构方式,当然也添加了一些新的手法。快速过一遍,留下印象,然后在实践中慢慢掌握,应该是本书的正确食用方式。 毕竟开卷有益。 ### 关于动漫和游戏 因为宅家的缘故,今年在追番和玩游戏上都有所长进。对每个都写个一句话评论吧.. (排名不分先后) #### 番組 | 标题 | 短评 | 推荐指数 | | ------------------------------------------ | ------------------------------------------------------------ | -------- | | 动物新世代 BNA | 又名“狸猫成神记”或者“我和白狼不得不说的故事”。以人兽的视角探讨人与动物如何和谐共处的故事(误)。蛮有趣的。 | 4/5 | | 大理寺日志 | 又名“陈十进城记”或者“我和白猫不得不说的故事”。以人兽的视角探讨社会正义和政治黑暗的故事(大误)。蛮有趣的。 | 4/5 | | 在下求搞第三季 | 又名“飞龙上天记”或者“我和白兔不得不说的故事”。以人妖的视角探讨了种族平等,生存与毁灭的故事。不是很有意思。 | 3/5 | | 水果篮子第二季 | 和十二生肖打情骂俏。第一季比较有意思,第二季节奏有点慢。不是很有意思。 | 2/5 | | 试证明理科生已坠入情网 | 用逻辑,公式推导和数据,来量化证明喜欢这件事情。这事儿本身就比较扯,在理工科学生看来,就这? |3/5| | 转生成为了只有乙女游戏破灭Flag的邪恶大小姐 | 转生类,如何在逆境(?)中收逆向后宫的故事。不是很有意思。 | 2/5 | | 辉夜大小姐想让我告白第二季 | 延续了第一季的氛围和制作,虽然每集主题都差不多,但是还是可以很轻松看下来。蛮有趣的。 | 4/5 | | 烟草 | 讲道理这个是2019的番,但是2020才补上。初看觉得是兽娘动物园续作?但是世界观设定很吸引人。最大的问题是眼盲症分不清角色。很有意思。 | 4.5/5 | | 魔法科高校的劣等生 来访者篇 | 因为惯性看了这一季。比较无趣...下一季(如果还有)应该不会再追了。 | 1/5 | | 魔女之旅 | “虽然能力叼炸天但是我想当路人”的旁观者视角讲了一堆小故事。有一些故事很有深度,也能体会到角色的纠结。总体看着很舒服。 | 4/5 | | 魔王学院的不适任者 | 魔王转生成高中生虐菜的故事,每年都需要一些爽番不是么。从这个意义上来说,这部是成功的。爽就完了,偶尔也要放空脑袋不需要思考对吧。 |3/5| | 无能力者娜娜 | 一个普通人怎么在超能力者的环伺下生存,还要想着杀了他们的故事。虽然题材有点黑暗,但是对于心理描写很有趣。柊娜娜人设也深入人心,有点意思。 |4/5| | 约定的梦幻岛 | 从带领小伙伴们从食人农场逃跑的故事。除了主角群之外,对于配角们的描写也让人印象深刻。让人相信每个人都有自己的故事。蛮有意思。 |4/5| | Re:从零开始的异世界生活第二季 | 惯性追番。动画来说没有第一季精彩,可能是因为习惯了死亡轮回这个叙事套路了。没什么大问题,但是也没什么出彩点。 |3/5| | 富豪刑事 | “皇帝用的一定是金锄头吧”...用钱一路砸过去的故事,爽就完了。不过故事还是讲完整了,有点意思。 |3.5/5| | 总之就是非常可爱 | 月神(?)和凡人先扯证再恋爱的轻松故事,放松用的休闲番,顺便可以看看别人怎么撒糖的,保持学习嘛。有点意思。 |3/5| | 租借女友 | 牺牲了其他所有角色,来塑造一个完美的水原千鹤。反正每季都会有新老婆,再多一个也无所谓了。 |3/5| 其他还有一些番,比如《异种族风俗娘评鉴指南》啊《彼得·格里尔的贤者时间》啊我才没有去看呢。比较遗憾的是,今年没有哪个作品能给人眼前突然一亮,能让人打出满分的效果,不过说实话,那些 4 分以上的番,就已经很不错了。之后也许还要去补一下《咒术回战》,也许会有惊喜。 #### 游戏 只列出今年买了且玩了的.. #### Switch | 标题 | 游戏状态 | 短评 | 推荐指数 | | ----------------------------- | --------------- | ------------------------------------------------------------ | -------- | | 宝可梦不可思议迷宫救助队DX | 3小时,搁置 | 只有日语版,宝可梦死忠也许可以考虑?不是我的菜。 | 1/5 | | 集合吧!动物森友会 | 255小时,继续中 | 应该不用再介绍了。花鱼虫全满,很适合宅起来玩,有朋友一起就更好了。 | 5/5 | | 勇者斗恶龙XI S 寻觅逝去的时光 | 55小时,通关 | 日本现象级游戏,标准 RPG,打死一周目魔王以后的叙事方式很有意思。 | 3.5/5 | | Wenjia | 3小时,搁置 | 在现实与过去之间穿梭向前的平台游戏,视听上很舒服。没有深入,很难评分。有时间一定通关。 | -/5 | | 异度之刃决定版 | 50小时,通关 | 高清重置版本,玩的时候体会已经和当年不一样了。个人觉得没有异度2好玩。 | 3/5 | | 世界游戏大全51 | 2小时,搁置 | 本来希望能和小朋友一起玩,结果果然还是我太天真了。聚会的话比马里奥派对或者赛车差多了。 | 2/5 | | 皮克敏3豪华版 | 15小时,通关 | 两个小朋友出奇地喜欢,算是今年游戏的惊喜。挺可爱的,不过想要满分也不容易,很有意思。 | 4/5 | | 塞尔达无双 灾厄启示录 | 20小时,通关 | 标准的无双类游戏,一般,能玩。割草爱好者可以买,塞尔达爱好者的话建议云通关就够了。 | 3/5 | | 超级马里奥创作家 2 | 5小时,继续中 | 任亏券快到期了随便换的..还在努力中,不过感觉不太适合我。创造力不太够.. | 3/5 | | Carto | 3小时,继续中 |很有趣的游戏,按照提示,使用拼图的方式扩展地图解锁故事。让人耳目一新。|4/5| #### PlayStation PS 4 Pro 已经被我卖掉了,PS 5 迟迟买不到,所以基本没有玩什么 PS 游戏...希望能快一点买到新主机。 | 标题 | 游戏状态 | 短评 | 推荐指数 | | ----------------- | ------------ | ------------------------------------------------------------ | -------- | | 十三机兵防卫圈 | 25小时,通关 | 叙事风格很特别的游戏,剧情互相解锁,层层推进,很有意思。但是战斗部分比较无聊。总体表现很好。 | 4.5/5 | | 最终幻想 7 重置版 | 40小时,通关 | 童年记忆,可堪完美的重置。埋了很多完全不同于原作的伏笔,让重置版更有意思,也让人很在意后续发展。爱丽丝真香就对了。 | 4/5 | | 尼尔:机械纪元 | 35小时,通关 | 很特别的作品,但是不结合《龙背》的话,是很难抓住剧情重点的,需要大量补充背景知识。另外第二段重复实在有点拖沓。 | 4/5 | #### PC 今年换了 3070,所以搁置了很久的 PC 也成了大型游戏机。Steam 国区价格实在太香了.. | 标题 | 游戏状态 | 短评 | 推荐指数 | | ------------ | -------------- | ------------------------------------------------------------ | -------- | | 文明 6 | 75小时,?? | 这游戏也没有通关啥的..可以配合上面推荐的基本人文类的科普书一起。挺好玩的,再玩一个回合就睡! | 4.5/5 | | 杀戮尖塔 | 15小时,搁置 | 算是带领 Roguelike 走入大众视野的第一款游戏?对于常年打炉石的我来说,很快就能领悟要点 | 4/5 | | 神界:原罪2 | 4小时,放弃 | 欧美系 RPG,我习惯了日式 RPG,所以这个不是太玩得下去。喜欢的人大概会很喜欢?我一般. | 3/5 | | 赛博朋克2077 | 37小时,继续中 | 据说主机版很惨,PC 版还好,也没有特别多 bug。肯定没达到当初的预期,但慢慢做支线,多逛街,还是有点意思的。 | 4/5 | | 精灵与萤火意志 | 3小时,继续中 |画风很好,中规中矩的平台游戏。如果喜欢空洞骑士的话,应该也会喜欢这款作品。|4/5| | 欧洲卡车模拟器2 | 15小时,继续中 |如果觉得累了,可以打开音乐播放器,然后开始玩这个。少有的能让人放松,让人放空,让人慢下来的游戏。很喜欢。|4.5/5| | 魔兽世界 9.0 | ?? 小时,继续中 |WOW 玩了十多年了,从最开始的疯狂,到现在依靠惯性每次资料片开了去看个剧情升个级。把网游彻底玩成了单机。|4/5| | 黑帝斯 | 5小时,继续中 |Roguelike 的关卡动作类游戏,画风很好,故事也很抓人。还没有逃出来,努力中。推荐。|5/5| | Townscaper | 2小时,继续中 | 和欧卡2一样,让人放空的游戏。也可以给小朋友们当作鼠标练习,电脑要从娃娃抓起嘛。 |4/5| --- 明天就要正式开始新的一年了,现在该去煮饭了。明年的年终总结再见! URL: https://onevcat.com/2020/11/codable-default/index.html.md Published At: 2020-11-10 14:00:00 +0900 # 使用 Property Wrapper 为 Codable 解码设定默认值 本文介绍了一个使用 Swift Codable 解码时难以设置默认值问题,并利用 Property Wrapper 给出了一种相对优雅的解决方式,来在 key 不存在时或者解码失败时,为某个属性设置默认值。这为编解码系统提供了更好的稳定性和可扩展性。最后,对 enum 类型在某些情况下是否胜任进行了简单讨论。 > [示例代码](https://gist.github.com/onevcat/0f055ece50bd0c07e882890129dfcfb8) ## Codable 类型中可选值的窘 (囧?) 境 ### 基础类型可选值 Codable 的引入极大简化了 JSON 和 Swift 中的类型之间相互转换的难度。当我们将 Swift 类型中的一个值设定为可选值 (Optional) 时,意味着即使 JSON 中这个值缺失了,我们也可以将 JSON 成功解码。比如 `Video` 类型代表了一段视频直播,其中 up 主可以设定是否接受评论: ```swift struct Video: Decodable { let id: Int let title: String let commentEnabled: Bool? } ``` 下面的情况: ```js {"id": 12345, "title": "My First Video"} ``` 将解码得到: > Video(id: 12345, title: "My First Video", commentEnabled: nil) 引入可选的 `commentEnabled`,会导致使用起来相当麻烦。很可能我们不得不在 view controller 层级上去写这样的代码: ```swift if video.commentEnabled ?? false { // 在这里显示 comment UI } ``` 这让代码变得很丑,而且会散落在使用到 `commentEnabled` 的各个地方。如果我们想要的是,当 `"commentEnabled"` key 不存在时,将对应的属性设为 `false`,应该要怎么做呢? Swift 的 `Decodable` 并不支持在声明存储属性时为它指定默认值。如果强制进行赋值,你将会收获一个警告,JSON 值也无法被正确解析: ```swift // 错误的代码 struct Video: Decodable { let id: Int let title: String let commentEnabled: Bool = false // Warning: Immutable property will not be // decoded because it is declared with an // initial value which cannot be overwritten } // {"id": 12345, "title": "My First Video", "commentEnabled": true} // => Video(id: 12345, title: "My First Video", commentEnabled: false) ``` 显然这是不对的。 一个稍微好一些的做法是,为 `commentEnabled: Bool?` 设定一个特殊的 getter,来统一在一个地方返回不存在时的默认值: ```swift struct Video: Decodable { // ... private let commentEnabled: Bool? var resolvedCommentEnabled: Bool { commentEnabled ?? false } } ``` 相信你和我一样,会非常头疼 `resolvedCommentEnabled` 的名字到底应该怎么决定,这也带来了某种意义上的重复。 最不偷懒的解决方式,当然是为整个 `Video` 重写解码所需要的 `init(from:)` 方法,来在 `commentEnabled` key 不存在时直接设定默认值: ```swift struct Video: Decodable { let id: Int let title: String let commentEnabled: Bool enum CodingKeys: String, CodingKey { case id, title, commentEnabled } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(Int.self, forKey: .id) title = try container.decode(String.self, forKey: .title) commentEnabled = try container.decodeIfPresent(Bool.self, forKey: .commentEnabled) ?? false } } // {"id": 12345, "title": "My First Video"} // Video(id: 12345, title: "My First Video", commentEnabled: false) ``` 问题在于,可能会有其他类型也有类似的需求。就算预先设置了模板,但去为每个类型添加这么一坨 `CodingKeys` 和 `init(from:)`,想象一下就觉得是很恶心的事情。就算在这里我们为 `commentEnabled` 这个 `Bool` 值添加了默认的解析,对于其他类型中类似需求的 `Bool` 属性,还是需要再来一次。我们有没有更好的方法来对应**“给 Decodable 的属性添加默认值”**这件事情呢? ### 更大的陷阱:自定义类型的可选值 在开始尝试解决问题之前,先来看一个更致命的情况。对于上面的 `Bool?`,最多只是让我们的代码麻烦一些,还不至于出现大问题。但是如果我们希望的是对一个更复杂的类型进行解码,情况就会迅速恶化。考虑下面的代码: ```swift struct Video: Decodable { enum State: String, Decodable { case streaming // 正在直播 case archived // 已完成 } // ... let state: State } ``` 这里添加了 `Video.State`,我们将它声明为 `String` enum,且满足 `Decodable`。对于这样的 enum 类型,我们不需要额外进行实现,编译器就会帮助我们补完解码代码,这会把 `"streaming"` 和 `"archived"` 分别解码到对应的 case 中去: ```js {"id": 12345, "title": "My First Video", "state": "archived"} // Video( // id: 12345, // title: "My First Video", // commentEnabled: nil, // state: Video.State.archived // ) ``` 看起来很美好,但是考虑一下,如果将来服务器新增了一个状态,比如 up 主“提前预约”了一次直播时,服务器将返回 `"reserved"`。毫无疑问,我们上面的代码无法将 `"reserved"` 解析为 `State` 中的任何一个值,于是整个 `Video` 类型的解析就都挂掉了: ```js {"id": 12345, "title": "My First Video", "state": "reserved"} // error: Swift.DecodingError.dataCorrupted // Cannot initialize State from invalid String value reserved ``` 根据你想要实现的效果,在 client app 中,这可能是一个非常严重的问题。更麻烦的是,即使你将 `state` 声明为可选值的 `State?`,依然还是解决不了这个情况:可选值的解码所表达的是“如果不存在,则置为 nil”,而**不是**“如果解码失败,则置为 nil”。所以下面的改变不会对问题有任何帮助: ```swift struct Video: Decodable { // ... let state: State? } // {"id": 12345, "title": "My First Video", "state": "reserved"} // error: Swift.DecodingError.dataCorrupted // Cannot initialize State from invalid String value reserved ``` 只要 "state" key 在 JSON 中存在,那么解码就会发生;只要等待解码的数据无法初始化一个 `State`,那么整个值的解码就会失败。 当然,我们可以参照上面处理 `Bool` 的方法,在 `Video` 中把 `state` 声明为 `String`, 然后用一个 getter 设定默认值,比如: ```swift enum State: String, Decodable { case streaming case archived case unknown } private let state: String var resolvedState: State { State(rawValue: state) ?? .unknown } ``` 或者直接为 `Video` 重写 `init(from:)`。不过无论怎么做,都不是很理想。 有没有更好的方式来处理上面这两个问题呢?答案是使用 property wrapper。 ## Default property wrapper 的设计 最正确且通用的解决方式当然是为每个需要默认值的类型重写 `init(from:)`。不过 Codable 系统最便利的地方就在于可以自动为满足 Codable 的类型和每个属性生成代码。我们例子中的矛盾在于,编译器为某个类型 (比如 `Bool` 或者 `Video.State`) 生成的解码代码不能满足要求。想要对某个 property “做手脚”,property wrapper 当然是首选。 > 如果你对 property wrapper 还不熟悉,可以先把它理解成一组特别的 getter 和 setter:它提供一个特殊的盒子,把原来值的类型包装进去。被 property wrapper 声明的属性,实际上在存储时的类型是 property wrapper 这个“盒子”的类型,只不过编译器施了一些魔法,让它对外暴露的类型依然是被包装的原来的类型。 > > 已经有很多关于 property wrapper 使用的详细解释了:[官方文档](https://docs.swift.org/swift-book/LanguageGuide/Properties.html#ID617) 或者 [NSHipster](https://nshipster.com/propertywrapper/) 上都有很优秀的阅读资料。 ### 首次尝试 对 `Bool` 或者 `Video.State` 来说,设置 Default property wrapper 最理想的情况,是类似下面这样的声明方式: ```swift @Default(value: true) var commentEnabled: Bool @Default(value: .unknown) var state: State ``` 这需要 `Default` 这个 property wrapper 具有这样的声明: ```swift @propertyWrapper struct Default { let value: T var wrappedValue: T { get { fatalError("未实现") } } } ``` `wrappedValue` 的 getter 我们还没想好要怎么写,所以先 `fatalError` 留空。为了能让 `Default` 被直接解码,让它满足 `Decodable`: ```swift extension Default: Decodable { } ``` 很“幸运”,因为泛型类型 `T` 也满足了 `Decodable`,所以我们不需要任何实现就可以让 `Default` 满足 `Decodable` 了。但这真的是我们想要的东西吗? 实际上,`Default` property wrapper 修饰的变量的类型,就是一个具体的 `Default` 类型: ```swift @Default(value: true) var commentEnabled: Bool ``` `commentEnabled` 真正的类型并不是 `Bool`,而是 `Default`。而 `Default` 中只有 `let value: Bool` 这一个存储属性。所以它所规定的默认解码方式是寻找 `"value"` 这个 key 对应的布尔值。也就是说,在这个情况下,我们所期望的 JSON 形式其实是: ```swift { "id": 12345, "title": "My First Video", "commentEnabled": { "value": true } } ``` 很显然,这不是我们想要的东西。我们需要从一个 `singleValueContainer` 中去解码单个值,而不是将它作为 object 的一部分。所以需要实现自定义的用来解码的 `init`: ```swift // 错误代码 extension Default: Decodable { init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let v = (try? container.decode(T.self)) ?? value // 在 init 方法中,value 还不可用,怎么办?? self.wrappedValue = v // 如何把解码后的值交给 wrappedValue?? } } ``` 在这种情况下,产生了矛盾:我们不能用 `Default` 的 `init(value:)` 来为 property wrapper 指定一个默认值。这么做将导致我们无法从 decoder 中利用这个默认值进行解码。 此路不通,需要另寻他法:我们需要一种不涉及具体的值,而是通过类型系统来传递值的方式。 ### 使用类型约束传值 SwiftUI 中有很多使用类型来传递值的例子,在我的[前一篇文章](https://onevcat.com/2020/10/use-options-pattern/)中,也介绍了这种方式的另外一个用例。既然不能使用实例属性,那么我们不妨通过定义和类型绑定的 static 属性来设置默认值。 首先添加一个 protocol,用来规定默认值: ```swift protocol DefaultValue { associatedtype Value: Decodable static var defaultValue: Value { get } } ``` 然后让 `Bool` 满足这个默认值: ```swift extension Bool: DefaultValue { static let defaultValue = false } ``` 在这里,`DefaultValue.Value` 的类型会根据 `defaultValue` 的类型被自动推断为 `Bool`。 接下来,重新定义 `Default` property wrapper,以及用于解码的初始化方法: ```swift @propertyWrapper struct Default { var wrappedValue: T.Value } extension Default: Decodable { init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() wrappedValue = (try? container.decode(T.Value.self)) ?? T.defaultValue } } ``` 这样一来,我们就可以用这个新的 `Default` 修饰 `commentEnabled`,并对应解码失败的情况了: ```swift struct Video: Decodable { let id: Int let title: String @Default var commentEnabled: Bool } // {"id": 12345, "title": "My First Video", "commentEnabled": 123} // Video( // id: 12345, // title: "My First Video", // _commentEnabled: Default(wrappedValue: false) // ) ``` 虽然我们解码得到的是一个 `Default` 的值,但是在使用时,property wrapper 是完全透明的。 ```swift if video.commentEnabled { // 在这里显示 comment UI } ``` > 可能你已经注意到了,在这样的 `Video` 类型中,我们所使用的 `commentEnabled` 只是一个 `Bool` 类型的计算属性。在背后,编译器为我们生成了 `_commentEnabled` 这个存储属性。也就是说,如果我们手动为 `Video` 加一个 `_commentEnabled` 的话,会导致编译错误。 > > 虽然很多其他语言有这样的习惯,但在 Swift 中,并不建议使用下杠 `_` 作为变量的首字母。这可以帮助我们避免与编译器自动生成的代码产生冲突。 我们已经可以解码 `"commentEnabled": 123` 这类的意外输入了,但是现在,当 JSON 中 `"commentEnabled"` key 缺失时,解码依然会发生错误。这是因为我们所使用的解码器默认生成的代码是要求 key 存在的。想要改变这一行为,我们可以为 container 重写对于 `Default` 类型解码的实现: ```swift extension KeyedDecodingContainer { func decode( _ type: Default.Type, forKey key: Key ) throws -> Default where T: DefaultValue { try decodeIfPresent(type, forKey: key) ?? Default(wrappedValue: T.defaultValue) } } ``` 在键值编码的 container 中遇到要解码为 `Default` 的情况时,如果 key 不存在,则返回 `Default(wrappedValue: T.defaultValue)` 这个默认值。 有了这个,对于 JSON 中 `commentEnabled` 缺失的情况,也可以正确解码了: ```js {"id": 12345, "title": "My First Video"} // Video(id: 12345, title: "My First Video", commentEnabled: false) ``` 相比对于每个类型编写单独的默认值解码代码,这套方式具有很好的扩展性。比如,如果想要为 `Video.State` 也添加默认行为,只需要让它满足 `DefaultValue` 即可: ```swift extension Video.State: DefaultValue { static let defaultValue = Video.State.unknown } struct Video: Decodable { // ... @Default var state: State } // {"id": 12345, "title": "My First Video", "state": "reserved"} // Video( // id: 12345, // title: "My First Video", // _commentEnabled: Default(wrappedValue: false), // _state: Default(wrappedValue: Video.State.unknown) // ) ``` ### 整理 Default 类型 上面的方法还存在一个问题:像 `Default` 这样的修饰,只能将默认值解码到 `false`。但有时候针对不同情况,我们需要设置不同的默认值。 `DefaultValue` 协议其实并没有对类型作出太多规定:只要所提供的默认值 `defaultValue` 满足 `Decodable` 协议就行。因此,我们可以让别的类型,甚至是新创建的类型,满足 `DefaultValue`: ```swift extension Bool { enum False: DefaultValue { static let defaultValue = false } enum True: DefaultValue { static let defaultValue = true } } ``` 这样,我们就可以用这样的类型来定义不同的默认解码值了: ```swift @Default var commentEnabled: Bool @Default var publicVideo: Bool ``` 或者为了可读性,更进一步,使用 typealias 给它们一些更好的名字: ```swift extension Default { typealias True = Default typealias False = Default } @Default.False var commentEnabled: Bool @Default.True var publicVideo: Bool ``` 针对 `Video.State`,也可以做同样的整理,就留作给各位读者的练习啦!本文完整的示例代码可以在[这里](https://gist.github.com/onevcat/0f055ece50bd0c07e882890129dfcfb8)找到。 ## 关于 API 设计的一点补充说明 虽然本文着重于 Codable 的小技巧,而非整体的 API 设计,但在例子中我们使用了 `Video.State` 这个 enum 来表示视频的状态,这其实是不太妥当的。 处理类似这种状态时,很多 server 会返回特定的字符串,比如 `"streaming"`、`"archived"`。看起来这很像一个“状态枚举”的行为,而且 Swift 中的 enum 实在是很好用,所以大家可能会偏向于直接用 enum 来表征。但如果客户端和服务器之间没有协定未来的情况的话,十分有可能出现像例子中 `"reserved"` 这样的新值追加,进而导致问题 相比于 enum,其实这里用一个带有 raw value 的 struct 来表示会更好: ```swift struct Video: Decodable { struct State: RawRepresentable, Decodable { static let streaming = State(rawValue: "streaming") static let archived = State(rawValue: "archived") let rawValue: String } // ... let state: State } // {"id": 12345, "title": "My First Video", "state": "archived"} // Video( // id: 12345, // title: "My First Video", // state: Video.State(rawValue: "archived")) print(value.state == .archived) // true print(value.state == .streaming) // false ``` 这样一来,就算今后为 `state` 添加了新的字符串,现有的实现也不会被破坏。相比起原来的 enum `Video.State`,这个设计更加稳定。 和它很类似的,而且经常被用错的例子还有 HTTP method。不少地方把它设计成了类似这样的枚举: ```swift enum HTTPMethod: String { case get = "GET" case post = "POST" case put = "PUT" case delete = "DELETE" } ``` 确实,这几个 method 几乎能覆盖所有 (“增删查改”) 的情况,但是 HTTP 标准中还定义了[很多其他 method](https://tools.ietf.org/html/rfc2616#page-36),比如 `HEAD` 或者 `OPTIONS` 也不算鲜见。而且只要服务端和客户端协商好,method 甚至是[可以随意扩展](https://tools.ietf.org/html/rfc2616#section-9)的。在这种情况下,其实 enum 是不太理想的。类似上面的例子,使用 `RawRepresentable` 的 struct,则可以提供更好的扩展性。 URL: https://onevcat.com/2020/10/use-options-pattern/index.html.md Published At: 2020-10-21 14:00:00 +0900 # Swift 中使用 Option Pattern 改善可选项的 API 设计 SwiftUI 中提供了很多“新颖”的 API 设计思路和 Swift 的使用方式,我们可以进行借鉴,并反过来使用到普通的 Swift 代码中。[`PreferenceKey`](https://developer.apple.com/documentation/swiftui/preferencekey) 的处理方式就是其中之一:它通过 protocol 的方式,为子 view 们提供了一套模式,让它们能将自定义值以类型安全的方式,向上传到父 view 去。如果有机会,我会再专门介绍 `PreferenceKey`,但这种设计的模式其实和 UI 无关,在一般的 Swift 里,我们也能使用这种方法来改善 API 设计。 在这篇文章里,我们就来看看要如何做。文中相关的代码可以[在这里找到](https://gist.github.com/onevcat/40f21b41a6b1ffa06ceb9f3ee0470bf3)。你可以将这些代码复制到 Playground 中执行并查看结果。 ## 红绿灯 用一个交通信号灯作为例子。 ![](/assets/images/2020/light-1.png) 作为 Model 类型的 `TrafficLight` 类型定义了 `.stop`、`.proceed` 和 `.caution` 三种 `State`,它们分别代表停止、通行和注意三种状态 (当然,通俗来说就是“红绿黄”,但是 Model 不应该和颜色,也就是 View 层级相关)。它还持有一个 `state` 来表示当前的状态,并在设置时将这个状态通过 `onStateChanged` 发送出去: ```swift public class TrafficLight { public enum State { case stop case proceed case caution } public private(set) var state: State = .stop { didSet { onStateChanged?(state) } } public var onStateChanged: ((State) -> Void)? } ``` 其余部分的逻辑和本次主题无关,不过它们也比较简单。如果你有兴趣的话,可以点开下面的详情查看。但这不影响本文的理解。
TrafficLight 的其他部分

为了能让信号灯进行状态转换,我们可以在 TrafficLight 里定义各个阶段的时间:

1
2
3
public var stopDuration = 4.0
public var proceedDuration = 6.0
public var cautionDuration = 1.5

然后用一个 Timer 计时,并进行控制状态的转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private var timer: Timer?

private func turnState(_ state: State) {
    switch state {
    case .proceed:
        timer = Timer.scheduledTimer(withTimeInterval: proceedDuration, repeats: false) { _ in
            self.turnState(.caution)
        }
    case .caution:
        timer = Timer.scheduledTimer(withTimeInterval: cautionDuration, repeats: false) { _ in
            self.turnState(.stop)
        }
    case .stop:
        timer = Timer.scheduledTimer(withTimeInterval: stopDuration, repeats: false) { _ in
            self.turnState(.proceed)
        }
    }
    self.state = state
}

最后,向外提供开启和结束的方法就可以了:

1
2
3
4
5
6
7
8
9
public func start() {
    guard timer == nil else { return }
    turnState(.stop)
}

public func stop() {
    timer?.invalidate()
    timer = nil
}
在 (ViewController 中) 使用这个红绿灯也很简单。我们按照红绿黄的颜色,在 `onStateChanged` 中设定 `view` 的颜色: ```swift light = TrafficLight() light.onStateChanged = { [weak self] state in guard let self = self else { return } let color: UIColor switch state { case .proceed: color = .green case .caution: color = .yellow case .stop: color = .red } UIView.animate(withDuration: 0.25) { self.view.backgroundColor = color } } light.start() ``` 这样,View 的颜色就可以随着 `TrafficLight` 的变化而变更了: ![](/assets/images/2020/light-running.gif) ## 青色信号 世界很大,有些地方 (比如日本) 会使用倾向于青色,或者实际上应该是[绿松色 (turquoise)](https://www.color-hex.com/color/40e0d0),来表示“可以通行”。有时候这也是技术的[限制或者进步](http://www.reuk.co.uk/wordpress/news/uk-traffic-lights-57000-tonnes-of-co2/)所带来的结果。 > The green light was traditionally green in colour (hence its name) though modern LED green lights are turquoise. > > -- Wikipedia 中关于 Traffic light 的记述 ![](/assets/images/2020/light-2.png) 假设我们想要让 `TrafficLight` 支持青色的绿灯,一个能想到的最简单的方式,就是在 `TrafficLight` 里为“绿灯颜色”提供一个选项: ```swift public class TrafficLight { public enum GreenLightColor { case green case turquoise } public var preferredGreenLightColor: GreenLightColor = .green //... } ``` 然后在 `ViewController` 中使用对应的颜色: ```swift extension TrafficLight.GreenLightColor { var color: UIColor { switch self { case .green: return .green case .turquoise: return UIColor(red: 0.25, green: 0.88, blue: 0.82, alpha: 1.00) } } } light.preferredGreenLightColor = .turquoise light.onStateChanged = { [weak self, weak light] state in guard let self = self, let light = light else { return } // ... // case .proceed: color = .green case .proceed: color = light.preferredGreenLightColor.color } ``` 这样做当然能够解决问题,但是也会带来一些隐患。首先,需要在 `TrafficLight` 中添加一个额外的存储属性 `preferredGreenLightColor`,这使得 `TrafficLight` 示例所使用的内存开销增加了。在上例中,额外的 `GreenLightColor` 属性将会为每个实例带来 8 byte 的开销。 如果我们需要同时处理很多 `TrafficLight` 实例,而其中只有很少数需要 `.turquoise` 的话,这个开销就非常可惜了。 > 严格来说,上例的 TrafficLight.GreenLightColor 枚举其实只需要占用 1 byte。但是 64-bit 系统中在内存分配中的最小单位是 8 bytes。 > > 如果想要添加的属性不是像例子中这样简单的 enum,而是更加复杂的带有多个属性的类型的话,这一开销会更大。 另外,如果我们还要添加其他属性,很容易想到的方法是继续在 `TrafficLight` 上加入更多的存储属性。这其实是很没有扩展性的方法,我们并不能在 extension 中添加存储属性: ```swift // 无法编译 extension TrafficLight { enum A { case a } var myOption: A = .a // Extensions must not contain stored properties } ``` 需要修改 `TrafficLight` 的源码,才能添加这个选项,而且还需要为添加的属性设置合适的初始值,或者提供额外的 init 方法。如果我们不能直接修改 `TrafficLight` 的源码 (比如这个类型是别人的代码,或者是被封装到 framework 里的),那么像这样的添加选项的方式其实是无法实现的。 ## Option Pattern 可以用 Option Pattern 来解决这个问题。在 `TrafficLight` 中,我们不去提供专用的 `preferredGreenLightColor`,而是定义一个泛用的 `options` 字典,来将需要的选项值放到里面。为了限定能放进字典中的值,新建一个 `TrafficLightOption` 协议: ```swift public protocol TrafficLightOption { associatedtype Value /// 默认的选项值 static var defaultValue: Value { get } } ``` 在 `TrafficLight` 中,加入下面的 `options` 属性和下标方法: ```swift public class TrafficLight { // ... // 1 private var options = [ObjectIdentifier: Any]() public subscript(option type: T.Type) -> T.Value { get { // 2 options[ObjectIdentifier(type)] as? T.Value ?? type.defaultValue } set { options[ObjectIdentifier(type)] = newValue } } // ... } ``` 1. 只有满足 `Hashable` 的类型,才能作为 `options` 字典的 key。[`ObjectIdentifier`](https://developer.apple.com/documentation/swift/objectidentifier) 通过给定的类型或者是 class 实例,可以生成一个唯一代表该类型和实例的值。它非常适合用来当作 `options` 的 key。 2. 通过 key 在 `options` 中寻找设置的值。如果没有找到的话,返回默认值 `type.defaultValue`。 现在,对 `TrafficLight.GreenLightColor` 进行扩展,让它满足 `TrafficLightOption`。如果 `TrafficLight` 已经被打包成 framework,我们甚至可以把这部分代码从 `TrafficLight` 所在的 target 中拿出来: ```swift extension TrafficLight { public enum GreenLightColor: TrafficLightOption { case green case turquoise public static let defaultValue: GreenLightColor = .green } } ``` 我们将 `defaultValue` 声明为了 `GreenLightColor` 类型,这样`TrafficLightOption.Value` 的类型也将被编译器推断为 `GreenLightColor`。 最后,为这个选项提供 setter 和 getter: ```swift extension TrafficLight { public var preferredGreenLightColor: TrafficLight.GreenLightColor { get { self[option: GreenLightColor.self] } set { self[option: GreenLightColor.self] = newValue } } } ``` 现在,你可以像之前那样,通过直接在 `light` 上设置 `preferredGreenLightColor` 来使用这个选项,而且它已经不是 `TrafficLight` 的存储属性了。只要不进行设置,它便不会带来额外的开销。 ```swift light.preferredGreenLightColor = .turquoise ``` 有了 `TrafficLightOption`,现在想要为 `TrafficLight` 添加选项时,就不需要对类型本身的代码进行改动了,我们只需要声明一个满足 `TrafficLightOption` 的新类型,然后为它实现合适的计算属性就可以了。这大幅增加了原来类型的可扩展性。 ## 总结 Option Pattern 是一种受到 SwiftUI 的启发的模式,它帮助我们在不添加存储属性的前提下,提供了一种向已有类型中以类型安全的方式添加“存储”的手段。 这种模式非常适合从外界对已有的类型进行功能上的添加,或者是自下而上地对类型的使用方式进行改造。这项技术可以对 Swift 开发和 API 设计的更新产生一定有益的影响。反过来,了解这种模式,相信对于理解 SwiftUI 中的很多概念,比如 `PreferenceKey` 和 `alignmentGuide` 等,也会有所助益。 URL: https://onevcat.com/2020/09/swift-package-version/index.html.md Published At: 2020-09-05 12:00:00 +0900 # Package.swift toolchain 版本的选择 WWDC 2020 上 Swift Package Manager (SPM) 开始支持 [Resource bundle](https://developer.apple.com/videos/play/wwdc2020/10169/) 和 [Binary Framework](https://developer.apple.com/videos/play/wwdc2020/10147/)。对于 Package 的维护者来说,如果有需求,当然是应该尽快适配这些内容。首先要做的,就是将 Package.swift 中的 Swift Toolchain 版本改到最新的 5.3:只有最新的 tool chain 才具备这些功能。 ``` // swift-tools-version:5.3 ``` 但是,如果之前你就支持了 SPM 的话,直接在 Package.swift 文件上进行修改,会破坏旧版本的兼容性。比如 5.3 的 toolchain 是集成在 Xcode 12 中的,如果这样改动以后,现有的使用 Xcode 11 的用户由于 toolchain 版本过低,就无法再 build 这个 package,造成问题。 > package at 'xxx' is using Swift tools version 5.3.0 but the installed version is 5.2.0 你不能假设用户永远都使用最新的版本,不顾兼容性的更新也会带来严重的后果。那要如何让一个 package 支持多个版本的 toolchain 呢? ### Package.swift 文件后缀 你可能已经看到过,有的项目中 (比如 [PromiseKit](https://github.com/mxcl/PromiseKit)) 会有多个 Package.swift 的声明:它们带有不同的后缀,比如 `Package.swift`,`Package@swift-4.2.swift` 或者 `Package@swift-5.3.swift`。SPM 在选取声明文件时,会按照当前 toolchain 版本从新到旧,去选取最近的一个兼容版本的文件。举个几个例子,上面三个文件存在的情况下: - 如果安装了带有 5.3 的 Xcode 12,则选取使用 `Package@swift-5.3.swift`; - 如果运行环境是 5.1 的 Xcode 11,则跳过 `Package@swift-5.3.swift` (由于不符合最低版本)。同时,由于不存在 `Package@swift-5.1.swift` 这一恰好兼容的版本,SPM 会向下寻找最近的一个兼容版本,即 转而使用 `Package@swift-4.2.swift`。 - 如果 toolchain 版本甚至低于 4.2,那么所有带有后缀的声明文件都会被跳过,而去使用 `Package.swift`。 ### swift-tools-version 在选取了合适的 Package.swift 文件后,第一行的 `swift-tools-version` 注释将会最终决定实际使用的 toolchain 版本。虽然没有强制要求这个注释指定的版本号必须和 `Package@swift-x.y.swift` 文件名中的版本号一致,但是选取不同的数字显然会引起不必要的误解。 ## 那我该怎么做呢 因此,在添加 SPM 新版本支持的时候,正确的做法是: 1. 创建 package 时,在 `Package.swift` 的首行中,声明你的 package 所能支持的最低的 toolchain 版本。 2. 保持现有的所有 `Package.swift` 和 `Package@swift-a.b.swift` 不变:这可以让旧版本的 toolchain 继续使用已有的 package 描述。 3. 为新版本 `x.y` 添加 `Package@swift-x.y.swift` 文件,并在文件中首行将 toolchain 版本设置为同样的版本,即 `// swift-tools-version:x.y`。然后为新版编写合适的 package 声明。 你可以通过切换 Xcode 中的 Command Line Tools 设定,并使用下面的命令来检查当前设定下所被选用的 toolchain version。 ``` $ swift package tools-version ``` 确保每个组合都按照预想工作,就这么简单。 URL: https://onevcat.com/2020/06/stateobject/index.html.md Published At: 2020-06-25 12:00:00 +0900 # @StateObject 和 @ObservedObject 的区别和使用 > WWDC 2020 中,SwiftUI 迎来了非常多的变更。相比于 2019 年的初版,可以说 SwiftUI 达到了一个相对可用的状态。从这篇文章开始,我打算写几篇文章来介绍一些重要的变化和新追加的内容。如果你需要 SwiftUI 的入门和基本概念的材料,我参与的两本书籍[《SwiftUI 与 Combine 编程》](https://objccn.io/products/swift-ui)和[《SwiftUI 编程思想》](https://objccn.io/products/thinking-in-swiftui)依然会是很好的选择。 ### 字太多,不想看,长求总 `@ObservedObject` 不管存储,会随着 `View` 的创建被多次创建。而 `@StateObject` 保证对象只会被创建一次。因此,如果是在 `View` 里自行创建的 `ObservableObject` model 对象,大概率来说使用 `@StateObject` 会是更正确的选择。`@StateObject` 基本上来说就是一个针对 class 的 `@State` 升级版。 如果你对详细内容感兴趣,想知道整个故事的始末,可以继续阅读。 ### 初版 SwiftUI 的状态管理 在 2019 年 SwiftUI 刚问世时,除去专门用来管理手势的 `@GestureState` 以外,有三个常用的和状态管理相关的 property wrapper,它们分别是 `@State`,`@ObservedObject` 和 `@EnvironmentObject`。根据职责和作用范围不同,它们各自的适用场景也有区别。一般来说: - `@State` 用于 `View` 中的私有状态值,一般来说它所修饰的都应该是 struct 值,并且不应该被其他的 view 看到。它代表了 SwiftUI 中作用范围最小,本身也最简单的状态,比如一个 `Bool`,一个 `Int` 或者一个 `String`。简单说,如果一个状态能够被标记为 `private` 并且它是值类型,那么 `@State` 是适合的。 - 对于更复杂的一组状态,我们可以将它组织在一个 class 中,并让其实现 `ObservableObject` 协议。对于这样的 class 类型,其中被标记为 `@Published` 的属性,将会在变更时自动发出事件,通知对它有依赖的 `View` 进行更新。`View` 中如果需要依赖这样的 `ObservableObject` 对象,在声明时则使用 `@ObservedObject` 来订阅。 - `@EnvironmentObject` 针对那些需要传递到深层次的子 `View` 中的 `ObservableObject` 对象,我们可以在父层级的 `View` 上用 `.environmentObject` 修饰器来将它注入到环境中,这样任意子 `View` 都可以通过 `@EnvironmentObject` 来获取对应的对象。 这基本就是初版 SwiftUI 状态管理的全部了。 ![](/assets/images/2020/48b8f3b0ed887f90b8d420b137fb3689.jpg) 看起来对于状态管理,SwiftUI 的覆盖已经很全面了,那为什么要新加一个 `@StateObject` property wrapper 呢?为了弄清这个问题,我们先要来看看 `@ObservedObject` 存在的问题。 ### @ObservedObject 有什么问题 我们来考虑实现下面这样的界面: ![](/assets/images/2020/stateobject_app.png) 点击“Toggle Name”时,Current User 在真实名字和昵称之间转换。点击 “+1” 时,无条件为这个 `View` ~~续一秒~~ 显示的 Score 增加 1。 来看看下面的代码,算上空行也就五十行不到: ```swift struct ContentView: View { @State private var showRealName = false var body: some View { VStack { Button("Toggle Name") { showRealName.toggle() } Text("Current User: \(showRealName ? "Wei Wang" : "onevcat")") ScorePlate().padding(.top, 20) } } } class Model: ObservableObject { init() { print("Model Created") } @Published var score: Int = 0 } struct ScorePlate: View { @ObservedObject var model = Model() @State private var niceScore = false var body: some View { VStack { Button("+1") { if model.score > 3 { niceScore = true } model.score += 1 } Text("Score: \(model.score)") Text("Nice? \(niceScore ? "YES" : "NO")") ScoreText(model: model).padding(.top, 20) } } } struct ScoreText: View { @ObservedObject var model: Model var body: some View { if model.score > 10 { return Text("Fantastic") } else if model.score > 3 { return Text("Good") } else { return Text("Ummmm...") } } } ``` 简单解释一下行为: 对于 Toggle Name 按钮和 Current User 标签,直接写在了 `ContentView` 中。+1 按钮和显示分数以及分数状态的部分,则被封装到一个叫 `ScorePlate` 的 `View` 里。它需要一个模型来记录分数,也就是 `Model`。在 `ScorePlate` 中,我们将它声明为了一个 `@ObservedObject` 变量: ```swift struct ScorePlate: View { @ObservedObject var model = Model() //... } ``` 除了 `Model` 外,我们还在 `ScorePlate` 里添加了另一个私有的布尔状态 `@State niceScore`。每次 +1 时,除了让 `model.score` 增加外,还检查了它是否大于三,并且依此设置 `niceScore`。我们可以用它来考察 `@State` 和 `@ObservedObject` 行为上的不同。 最后,最下面一行是另外一个 `View`:`ScoreText`。它也含有一个 `@ObservedObject` 的 `Model`,并根据 score 值来决定要显示的文本内容。这个 `model` 会在初始化时传入: ```swift struct ScorePlate: View { var body: some View { // ... ScoreText(model: model).padding(.top, 20) } } ``` > 当然,在这个例子中,其实使用一个简单的 `@State` 的 `Int` 值就够了,但是为了说明问题,还是生造了一个 `Model` 这把牛刀来杀鸡。实际项目中 `Model` 肯定是会比一个 `Int` 要来得更复杂。 当我们尝试运行的时候,“+1” 按钮可以完美工作,“Nice” 和 “Ummmm...” 文本也能够按照预期改变,一切都很完美...直到我们想要用 “Toggle Name” 改变一下名字: ![](/assets/images/2020/stateobject_reset.gif) 除了 (被 `@State` 驱动的) Nice 标签,`ScorePlate` 的其他文本都被一个看似不相关的操作重置了!这显然不是我们想要的行为。 > (为节约流量和尊重 BLM,此处请自行脑补非洲裔问号图) 这是因为,和 `@State` 这种底层存储被 SwiftUI “全面接管” 的状态不同,`@ObservedObject` 只是在 `View` 和 `Model` 之间添加订阅关系,而不影响存储。因此,当 `ContentView` 中的状态发生变化,`ContentView.body` 被重新求值时,`ScorePlate` 就会被重新生成,其中的 `model` 也一同重新生成,导致了状态的“丢失”。运行代码,在 Xcode console 中可以看到每次点击 Toggle 按钮时都伴随着 `Model.init` 的输出。 > Nice 标签则不同,它是由 `@State` 驱动的:由于 `View` 是不可变的 struct,它的状态改变需要底层存储的支持。SwiftUI 将为 `@State` 创建额外的存储空间,来保证在 `View` 刷新 (也就是重新创建时),状态能够保持。但这对 `@ObservedObject` 并不适用。 ### 保证单次创建的 @StateObject 只要理解了 `@ObservedObject` 存在的问题,`@StateObject` 的意义也就很明显了。`@StateObject` 就是 `@State` 的升级版:`@State` 是针对 struct 状态所创建的存储,`@StateObject` 则是针对 `ObservableObject` class 的存储。它保证这个 class 实例不会随着 `View` 被重新创建。从而解决问题。 在上面这个具体的例子中,只要把 `ScorePlate` 中的 `@ObservedObject` 改成 `@StateObject`,就万事大吉了: ```swift struct ScorePlate: View { // @ObservedObject var model = Model() @StateObject var model = Model() } ``` 现在,`ScorePlate` 和 `ScoreText` 里的状态不会被重置了。 那么,一个自然而然引申出的问题是,我们是不是应该把所有的 `@ObservedObject` 都换成 `@StateObject`?比如上面例子中需要把 `ScoreText` 里的声明也进行替换吗?这看实际上你的 `View` 到底期望怎样的行为:如果不希望 model 状态在 View 刷新时丢失,那确实可以进行替换,这 (虽然可能会对性能有一些影响,但) 不会影响整体的行为。但是,如果 `View` 本身就期望每次刷新时获得一个全新的状态,那么对于那些不是自己创建的,而是从外界接受的 `ObservableObject` 来说,`@StateObject` 反而是不合适的。 ### 更多的讨论 #### 使用 `@EnvironmentObject` 保持状态 除了 `@StateObject` 外,另一种让状态 object 保持住的方式,是在更外层使用 `.environmentObject`: ```swift struct SwiftUINewApp: App { var body: some Scene { WindowGroup { ContentView().environmentObject(Model()) } } } ``` 这样,model 对象将被注入到环境中,不再随着 `ContentView` 的刷新而变更。在使用时,只需要遵循普通的 environment 方式,把 `Model` 声明为 `@EnvironmentObject` 就行了: ```swift struct ScorePlate: View { @EnvironmentObject var model: Model // ... // ScoreText(model: model).padding(.top, 20) ScoreText().padding(.top, 20) } struct ScoreText: View { @EnvironmentObject var model: Model // ... } ``` #### 和 `@State` 保持同样的生命周期 除了确保单次创建外,`@StateObject` 的另一个重要特性是和 `@State` 的“生命周期”保持统一,让 SwiftUI 全面接管背后的存储,也可以避免一些不必要的 bug。 在 `ContentView` 上稍作修改,把 `ScorePlate()` 放到一个 `NavigationLink` 中,就能看到结果: ```swift var body: some View { NavigationView { VStack { Button("Toggle Name") { showRealName.toggle() } Text("Current User: \(showRealName ? "Wei Wang" : "onevcat")") NavigationLink("Next", destination: ScorePlate().padding(.top, 20)) } } } ``` 当点击 “Next” 时,会导航到 `ScorePlate` 页面,可以在那里进行 +1 操作。当点击 Back button 回到 `ContentView`,并再次点击 “Next” 时,一般情况下我们会希望 `ScorePlate` 的状态被重置,得到一个全新的,从 0 开始的状态。此时使用 `@StateObject` 可以工作良好,因为 SwiftUI 帮助我们重建了 `@State` 和 `@StateObject`。而如果我们将 `ScorePlate` 里的声明从 `@StateObject` 改回 `@ObservedObject` 的话,SwiftUI 将不再能够帮助我们进行状态管理,除非通过 “Toggle” 按钮刷新整个 `ContentView`,否则 `ScorePlate` 在再次展示时将保留原来的状态。 > 当然,如果你有意想要在 `ScorePlate` 保留这些状态的话,使用 `@ObservedObject` 或者上面的 `@EnvironmentObject` 的方式才是正确的选择。 ### 总结 简单说,对于 `View` 自己创建的 `ObservableObject` 状态对象来说,极大概率你可能需要使用新的 `@StateObject` 来让它的存储和生命周期更合理: ```swift struct MyView: View { @StateObject var model = Model() } ``` 而对于那些从外界接受 `ObservableObject` 的 `View`,究竟是使用 `@ObservedObject` 还是 `@StateObject`,则需要根据情况和需要确定。像是那些存在于 `NavigationLink` 的 `destination` 中的 `View`,由于 SwiftUI 对它们的构建时机并没有做 lazy 处理,在处理它们时,需要格外小心。 不论哪种情况,彻底弄清楚两者的区别和背后的逻辑,可以帮助我们更好地理解一个 SwiftUI app 的行为模式。 URL: https://onevcat.com/2020/06/first-look-app-clips/index.html.md Published At: 2020-06-23 12:00:00 +0900 # 一些关于 App Clips 的笔记 App clips 是今天 WWDC 上 iOS 14 的一个重要“卖点”,它提供了一种“即时使用”的方式,让用户可以在特定时间、特定场景,在不下载完整 app 的前提下,体验到你的 app 的核心功能。 装好 Xcode 12 以后第一时间体验了一下如何为 app 添加 app clip。它的创建和使用都很简单,也没有什么新的 API,所以要为 app 开发一个 clip 的话,难点更多地在于配置、代码的复用以及尺寸优化等。在阅读文档和实际体验的同时,顺便整理了一些要点,作为备忘。 ### App clips 的一些基本事实和添加步骤 在写作本文时 (2020.06.23),通过文档和实践能获知的关于 app clips 的几点情况: - 一个 app 能且只能拥有一个 app clip。 - 通过在一个 app project 中添加 app clip target 就能很简单地创建一个 app clip 了。 ![](/assets/images/2020/app_clip_target_add.png) - App clip 的**结构和普通的 app 毫无二致**。你可以使用绝大多数的框架,包括 SwiftUI 和 UIKit ([不能使用的](https://developer.apple.com/documentation/app_clips/developing_a_great_app_clip#3625585)都是一些冷门框架和隐私相关的框架,比如 CallKit 和 HomeKit 等 )。所以 app clip 的开发非常简单,你可以使用所有你已经熟知的技术:创建 `UIViewController`,组织 `UIView`,用 `URLSession` 发送请求等等。和小程序这种 H5 技术不同,app clip 就是一个 native 的几乎“什么都能做”的“简化版”的 app。 - App clip 所包含的功能必须是 main app 的子集。App clip 的 bundle ID 必须是 main app 的 bundle ID 后缀加上 `.Clip` (在 Xcode 中创建 app clip target 时会自动帮你搞定)。 - 域名和 server 配置方面,和支持 Universal Link 以及 Web Credentials 的时候要做的事情非常相似:你需要为 app clip 的 target 添加 Associated Domain,格式为 `appclips:yourdomain.com`;然后在 server 的 App Site Association (通常是在网站 `.well-known` 下的 `apple-app-site-association` 文件) 中添加这个域名对应的 `appclips` 条目: ```javascript { "appclips": { "apps": ["ABCED12345.com.example.MyApp.Clip"] } } ``` - 默认最简单的情况下,app clip **通过 Safari App Banner 或者 iMessage app 中的符合 domain 要求的 URL 下载和启动**。这种启动方式叫做 Default App Clip Experience。 - 一个能够启动 app clip 的 App Banner 形式如下: ```xml { Publishers.ZipAll(self) } } let zipped = [p1, p2, p3].zipAll ``` ## 不足之处和改进空间 虽然 `ZipAll` 应该已经可以正常工作了,但是还有一些值得优化的地方。 ### 性能改进 首先是 `firstRowOutputItems` 中的数组操作的效率。`buffer` 的类型是 `[[Output]]`,它其中的元素也只是普通的 `Array`。因此 `firstRowOutputItems` 里的移除首个元素 `column.remove(at: 0)` 的操作,其实时间复杂度是 `O(n)`,而它又处于一个 `buffer.count` 的循环中,所以这里会带来一个 n^2 的复杂度,是难以接收的。我们可以自己创建一个队列的数据结构,把 `remove(at: 0)` 的操作简化为 `O(1)` 来避免这个问题。 其次,还是在 `firstRowOutputItems` 里,我们每次都对“是否 `buffer` 中所有的列都至少有一个元素”进行了判断:`buffer.allSatisfy({ !$0.isEmpty })`,这也是一个 `O(n)`。一种更简单的方式,是维护一个变量来记录当前已经收到的可合并值的个数:在每次收到值时,判断 `buffer` 对应的位置上是否已经有值,来确定需不需要更改这个变量。如果发现已经收到的可合并值的个数与 publishers 的数量相等的话,就说明所有数据都已经准备就绪,可以将它们 `zip` 并发送。通过这样一个变量,我们可以把这里的 `O(n)` 也简化为 `O(1)`。甚至更进一步,可以自然而然地做到去掉上面提到的 `buffer.count` 循环,把整个发送流程优化到 `O(1)`。 ### 有限 Demand 除了速度优化外,`ZipAll` 现在的行为逻辑也有值得商榷的地方。在 `startSubscribing` 里,我们简单地使用了 `sink` 来对输入的 `publishers` 进行订阅。`Sink` subscriber 在通过 `receive(subscription:)` 接收到订阅后,会立即 `request(_:)` `.unlimited` 的 `Subscribers.Demand`。这其实没有尊重 Combine 事件的拉取模型原则:在我们的 `ZipAll` 实现中,下游订阅者可以通过控制 `Demand` 来控制收到的值的数量,但是内部的 `publishers` 的订阅却可以接受无限多的值。这么一来,一旦在 `ZipAll` 内部产生 back pressure,比如外部所需要的值的频率小于内部 publishers 产生值的频率的话,`buffer` 将可以大量积压,导致内存问题。实际上,我们可以根据下游订阅者需要的值的数量,来决定我们所需要的 publishers 给我们的值的数量。这样,我们就能将 back pressure 的处理也应用到被 zip 的 publishers 中去,从而避免溢出问题。 相对于使用 `Sink`,我们可以用 `AnySubscriber` 来在更细的力度上进行一些控制。比如在收到订阅后只请求有限个事件,在收到新值时尊重下游订阅的 `Demand` 等: ```swift AnySubscriber( receiveSubscription: { subscription in }, receiveValue: { value -> Subscribers.Demand in }, receiveCompletion: { completion in } ) ``` ## 总结 可以看出,文中给出的实现有不少缺点,这个参考实现更多地是为了以最简单的方式说明自定义 `Publisher` 的一般方法,还远远没有达到可以用在产品代码中的质量。不过,通过这种直接的例子,我们可以总结出一些实现自定义 `Publisher` 时的一般经验: 1. `Publisher` 的接口和它需要完成的任务是相对固定的,遵循 Combine 的工作流程图,来实现其中各个职责类型的必要方法即可。 2. 如果没有特殊的需求,一般我们会将 `Publisher` 定义为 `struct` 而非 `class`,这可以让内存管理和多次订阅的行为更加容易预测。但是,如果一个 `Publisher` 有需要共享的话,应该将它定义为引用语义,比如 [`Publishers.Share`](https://developer.apple.com/documentation/combine/publishers/share)。 3. 相对于 `Publisher`,大部分有关时序的操作都被封装到了 `Subscription` 里。作为 `Publisher` 和 `Subscriber` 之间通讯的桥梁,`Subscription` 负责大部分逻辑,并维护 Combine 流程的正确性。一般来说,这也是在自定义 `Publisher` 时我们花费最多时间的地方。 4. 想要确保你的自定义 `Publisher` 能在 Combine 的世界中运行良好,需要遵守基本的规则。比如尊重下游的 demand,考虑性能因素等。 ## 练习 为了保持和[《SwiftUI 和 Combine 编程》]((https://objccn.io/products/swift-ui))这本书的形式上的类似,我也准备了一些小练习,希望能帮助读者通过实际动手练习掌握本文的内容。 ### 1. 优化 `ZipAll` 上面提出了关于优化 `ZipAll` 的一些想法,包括运行性能的优化和防止 `buffer` 堆积等。请你在力所能及的范围内对 `ZipAll` 进行修改和优化,并架设一些性能测试来验证你的修改确实发挥了作用。 > 提示,一般来说,在测试中我们可以使用 `PassthroughSubject` 作为数据源,并通过 [`measure(_:)`](https://developer.apple.com/documentation/xctest/xctestcase/1496290-measure) 来设立性能测试。如果在尝试后你还是对如何优化没有概念的话,不妨可以参考 RxSwift 中关于 ZipAll 的这个[高效实现](https://github.com/ReactiveX/RxSwift/blob/master/RxSwift/Observables/Zip%2BCollection.swift)。 ### 2. 实现 `CombineLatestAll` 和 `Zip` 相对应的操作是 `CombineLatest`,我们对它应该已经非常熟悉了:和 `Zip` 等待**所有** `Publisher` 都发出值不同,它会在**任意** `Publisher` 发出值后即把各个 `Publisher` 的最新值合并且向外发送。Combine 中也只实现了 `CombineLatest`,`CombineLatest3` 和 `CombineLatest4`,但是没有更一般的接受任意多个 `Publisher` 的 `CombineLatestAll`。请你仿照 `ZipAll` 的方式,实现自定义的 `CombineLatestAll`。 URL: https://onevcat.com/2019/12/2019-final/index.html.md Published At: 2019-12-31 14:22:00 +0900 # 2019 年终总结 ![](/assets/images/2019/2019-final.jpg) 距离[上一次写年终总结](/2015/12/2015-final/)已经过去四年时间了。在人生中带上两个小朋友以后,远游这种事情的难度就高企不下了。一年里除了工作以外,活动的轨迹多半也都落在了以家为圆心两公里为半径的圆周里。看着小朋友们一天天长大,在被她们的想象力和好奇心折服的同时,也不可避免地感觉到了自己的“成熟”...嗯,或者直白些,不可避免地感觉到了自己在变老。 对我来说,2019 年是很有意思的一年,它是充满“矛盾”的一年。我能认知的世界在变大,但我实际生活的圈子却在变小。世界的变动非常剧烈,在中美争霸背景下,被时代洪流的裹挟向前的我们,其实很难对抗宏观层面的规律。而对这个世界的认知范围,往往决定了我们在这一变局下会去往何方。但同时,偏安一隅的日本在经济上的孤岛效应,在这个大时代中却偶然地变成了一个避风港,把这些影响缩小了。庆幸的同时,有一些不甘;但不甘的同时,却又有些许惧怕。这样的矛盾的心情,大概还会持续一段时间。 矫情完毕,回到年终总结。因为心绪比较散乱,所以我挑选并加注了一些今年的“有意思”的自摄照片,希望能够作为今年思考的记录。最后,我也会整理一下今年的书单和好物目录,算是传统节目。 ## 照片 ### 2019 年 1 月 1 日 @ 冲绳・那覇・那覇客运码头 ![](/assets/images/2019/2019-final-2019-01-01.jpg) > 新年的第一个早晨,在那霸码头等船。朝阳虽然被云遮住,但它们正被吹走。云后透出的金色阳光,均匀地铺在略显陈旧,但整齐码放的集装箱上,呈现出颇具冲击力的色彩,给人“烈士暮年,壮心不已”的感觉。 彼时彼刻:孟晚舟女士刚被捕一月;中美贸易战箭在弦上,蓄势待发;习川互致贺信庆祝新年和40年建交。大多数人在 2018 经历的寒冬之后,都期盼能迎来春天,但是后续的展开却不遂人愿。此时此刻:孟的引渡听证将要开始,但事件结束还遥遥无期;贸易战已打得如火如荼,一地鸡毛;中美的世界领导权争夺已经摆上台面,闷声发展的阶段似乎已经远去。世界正在经历着剧烈的变更,也许我们不自觉,但这个时代里的每件事情的影响,在历史上也许都会远超我们的想象。到底我们会去向何方,到底我们能去向何方? --- ### 2019 年 3 月 31 日 @ 千叶・鸭川・鸭川海洋世界 ![](/assets/images/2019/2019-final-2019-03-31.jpg) > 妹妹拿到了好吃的冰激凌,主动与姐姐分食。 作为独生子女的一代,以前对于这种兄弟姐妹之间的情愫只能依靠想象来做猜测。不过现在有机会看到两个小朋友之间的各种“相爱相杀”,也算是很有趣的一件事情。四岁半的姐姐和两岁半的妹妹今后种种,还希望你们互相多一点照应包容,少一些争执纷扰,共同去品尝和体会这段奇妙的人生。 --- ### 2019 年 4 月 3 日 @ 神奈川・川崎・小川町樱花道 ![](/assets/images/2019/2019-final-2019-04-03.jpg) > 四月初是神奈川樱花满开之际,家门前的寻常街道亦堪绝景。满满在拿到了她的第一个相机后,兴奋地跑到街上随意乱按,便有了这张《川崎夜樱图》。 当代科技和大规模的工业,让曾经高不可及的物件寻常不已。至今我仍会唏嘘,小时候一个废旧的不能使用的相机,作为玩具陪伴了我多年。而如今,只需要花一点小钱,就可以买到质量不错的,专门为小朋友准备的数码相机。当一代人开始提到“相片”时,第一反应是找到手机上那个带花儿的 app;看到显示屏时,上来就用手指划来划去。她们才是数码时代的原生代,而她们的生活,注定将于我们不同。这是一种值得尊重的生活方式,而她们也注定是值得尊重的一代新人类。 --- ### 2019 年 4 月 13 日 @ 东京迪士尼乐园・卡通城 ![](/assets/images/2019/2019-final-2019-04-13.jpg) > 妹妹看到姐姐的小相机后羡慕不已,吵着也要了一台,还带去了迪士尼乐园。在排队时,她从自己的视角拍下了这张照片。 兴许这只是妹妹随手一拍,而后她的注意力便会立即转移到路边的小草,漂亮的栏杆。但又有谁在这一时一刻注意到了她的视角呢? 立场不同,看到的事物自然也会不同。但是就是这般简单的道理,很多时候我们这些大人都有迷惘。你看到的是人头攒动、熙熙攘攘、森罗万象,但有人看到的是孤苦伶仃、杂乱无章,甚至重重压迫。 无数鸡汤告诉我们要换位思考,但这又何其容易!人生的高度,有传承,有天分,有努力,有运气。高度不够的芸芸大众,要活好这一辈子已是不易,时刻为至亲至爱之人加以考虑,都是捉襟见肘,更遑论博爱天下之善为;高高在上之人想要苦天下之所苦,乐天下之所乐,要付出的努力恐怕不输上这高位所需。我们所在追寻的这种风度,在当今社会,不过只是水中花镜中月罢了。 --- ### 2019 年 4 月 27 日 @ 北京・清华大学・某教授办公室 ![](/assets/images/2019/2019-final-2019-04-27.jpg) > 跟着爸爸妈妈参加完一系列“无聊的”毕业十周年校庆活动后,满满在教授爷爷的办公室里躺倒在了两把椅子拼凑的临时行军床上。胆敢在教授老师们的办公室里公然睡觉,还不被责罚的,大概只有这种初生牛犊了。 转眼本科毕业已经十年。当初风华正茂,意气风发的青年,现在已经大腹便便,油腻不堪。大学的光阴是美好的,但是当我真正意识到这一点时,毕业已经过去太久。如果要问,如果给我一架哆啦 A 梦的时光机,你想去哪里?我大概会回答送我到背着行李告别父母的入学仪式那天。那是新的人生的开始,也是新的诗篇的序章。我大概会有机会谱出更好的旋律和节奏,成为一个更有用的人。 这个世界并没有时光机,很多事情只能留下回忆。 不过,要是问我关于这段回忆的感受,那我的答案是: 有过遗憾,但无后悔。这是因为,新的篇章,每天都可以开始书写。 --- ### 2019 年 5 月 7 日 @ 东京・新宿未来塔・LINE 办公室 ![](/assets/images/2019/2019-final-2019-05-07.jpg) > 普通的一天上午,空空如也的 LINE 办公室,我的工位就在最深处的角落。如果不说,很难想象这已经是标准上班时间过后接近一小时的场面。裁量劳动制下的弹性工作时间,意味着员工可以选择自由的上下班时间,而在 LINE 里,加班可以说是比较罕见的。 996.ICU 是今年一个热点话题。我从唯物辨证法里获益良多,所以也习惯了用唯物辨证法来分析问题。当然,我不想去当一个卫道士,为 996 寻找千百理由;同样,我也不想再极口项斯,天天夸耀 LINE 的弹性工作。 一个社会中有无数个例,万千的个例会交汇,会集合,会碰撞。当这些个例的棱角被磨平后,它们互相挤压作用,呈现出一种社会学上的共性。身处变局中的我们,不可能去决定这个共性。如果不想被磨平棱角和相互挤压,大概就只有去做那个闪闪发光的个例。 --- ### 2019 年 5 月 27 日 @ 杭州・阿里巴巴西溪园区・员工食堂 ![](/assets/images/2019/2019-final-2019-05-27.jpg) > 中午十二点,食堂正在准备营业。已经有零零星星的人开始吃饭,但真正的人潮还要等到半小时后。偌大的园区,依靠自行车穿梭在楼宇之间,食堂体育馆,这一切仿佛把人带回了校园生活。不,也许这里的很多人,就没有离开过校园生活。 在东京的话,是很难想象能有这么一块地方,让一个公司建立起这样一套完善的配套机制的。阿里巴巴创造的商业奇迹大概很难再复制,因为这个世界上找不出第二个有着这样统一语言,统一市场,统一文化的地方了。我只能心怀敬畏去仰视这个商业帝国,并没有立场,也没有能力去对它进行什么评价。 希望在这里工作的人,能追寻到自己的梦想;希望在这里发生的事,能带领我们的时代前进。 --- ### 2019 年 5 月 29 日 @ 北京・清华大学・职业发展指导中心 ![](/assets/images/2019/2019-final-2019-05-29.jpg) > 因为 LINE 2020 校园招聘回到学校,向学弟学妹们介绍 LINE 的技术背景和企业文化,期望能够帮助工作寻找到合适的跨国人才。 能站在清华大学的校徽后面讲上个十来二十分钟,可以算是我的夙愿了。虽然最后是以招聘会这种形式实现的,但我也心存感激。不管是校庆,还是宣讲,每次回到学校,总是觉得自己想要找回一些什么东西。 可以感受到学校逐年都在进步,95 后甚至是 00 后的学弟学妹们的眼界,比我们那时候不知道高到哪里去了。他们所思考的,他们所担忧的,正是这个时代所思考和担忧的。这是这个时代的幸事,一批年轻有为的人,正在思索着自己和国家的未来。这些我对他们的了解,远超我的想象。 --- ### 2019 年 6 月 4 日 @ 神奈川・川崎・车站人行天桥 ![](/assets/images/2019/2019-final-2019-06-04.jpg) > 姐妹俩赖床导致来不及在家吃早餐。上学路上,一人[叼一片面包](https://zh.moegirl.org/zh-hans/叼面包)相视而笑。不过直到吃完也没有在街角撞到奇怪的东西或者变成魔法少女,这让两人非常失望。 日本的保育制度十分完善,小朋友们都有稳定和专业的保育园可以托付,这让我们得以用双职工的家庭养育两个小孩,也不至于太过狼狈,十月开始保育园的[收费负担也有所下降](https://www.youhomushouka.go.jp)。可以说,在各个方面上,都能体会到日本政府对于生育率和人口问题发自内心的担忧。 --- ### 2019 年 6 月 23 日 @ 神奈川・新 川崎住宅公園 ![](/assets/images/2019/2019-final-2019-06-23.jpg) > 新家外观的效果图初稿。 现在的住处无法保证两个小朋友今后有各自的房间,也确实略显狭小了,因此今年都在寻找更合适的房子。一户建在日本是普通的居民住宅,很多时候反而高层公寓是更高档的存在。不过对于中国人来说,拥有一栋自己的小楼的诱惑,还是难以抵挡的。 一开始是想寻找合适的造好的买,但是不论从土地大小和建筑格局,总是没有找到称心如意的。所以最后决定从购入土地开始,走注文住宅的路子,让建筑公司帮忙设计和新建。期间完整地经历了一次在日本买地盖房的全过程,算是很有趣的体验。在来回十几次的调整设计之后,总算是能按照心愿确定细节,不出意外的话,大概明年就能入住。 --- ### 2019 年 7 月 1 日 @ 神奈川・川崎・家中 ![](/assets/images/2019/2019-final-2019-07-01.jpg) > 在家中关注香港局势。香港反送中运动高潮之一,示威者冲入立法会并进行喷涂,这些活动和行为通过视频直播传遍全球。 得益于直播行业的兴起和普及,世界上任何地方发生的事情,几乎都能被迅速传播。对于香港的问题,在不同立场上往往会选择性地只看到事实的某个侧面,进而很容易得到不同的结论。这种事件,当下是难以说清的,它所带来的历史成本与公允价值判断是无法估量的。 如果真要说“时代革命”,那一定会是新的科技和生产方式带来的,也许是 AI 和机器人的全面发力,也许是某个不可想象的医疗突破,也许是信息整合调度方式的变更。基于 4G 的直播技术的可以说已经完全成熟。它确实给人们的生活带来了变化,但算不上变革。近几年回国开会,大家张口闭口谈的都是 5G。在当下所谓“后移动互联网”时代,大家都在思考 5G 能做什么,会不会带来下一个风口。我的判断是,5G 当然也会带来变化,但却不会是变革。 --- ### 2019 年 7 月 18 日 @ 东京・新宿未来塔・LINE 办公室 ![](/assets/images/2019/2019-final-2019-07-18.jpg) > FaceApp 中的特效,将一张照片中的人“老龄化”处理,效果相当惊人。 AI 这几年真的热到沸腾了,图像风格化和这类图片处理的 app 常常能成为热点。我个人倾向于把 AI 和机器学习叫做“新时代的算法”:这年头熟练红黑树大概率并不能帮你创造一些什么,但是如果能熟练使用几种机器学习的方法,很可能可以帮助这个世界变得更好。 而且老年版里居然为我保留了头发,没有被处理成秃子,真的很感谢! --- ### 2019 年 9 月 20 日 @ 台北・松山机场捷运站 ![](/assets/images/2019/2019-final-2019-09-20.jpg) > 在便利店付钱时,误将 100 人民币当成了 100 新台币交给店员,被好心退回。从颜色到布局到款式,两种货币的 100 元都过于相似了吧 XD。 到台北参加了 iPlayground 的会议。和 @Swift 类似,这也是一场由开发者和志愿者自行举办的 Swift 技术会议。从大陆到台湾的自由行已经被暂停了,不过从日本过去还并没有什么阻碍。这是我第二次到台湾,会方给安排了台大对面的夜市中心。我只想说,楼下陈三鼎的黑糖青蛙撞奶真香! --- ### 2019 年 10 月 11 日 @ 神奈川・川崎・LeFRONT 购物广场 ![](/assets/images/2019/2019-final-2019-10-11.jpg) > “史上最强台风”,“云系覆盖日本全境”,“预计东京 8000 人伤亡”,[台风 19 号](https://zh.wikipedia.org/wiki/颱風海貝思_(2019年))来临前,商场停业,电车停运,整个东日本如临大敌。 55 条河流 79 处决堤,93 人死亡 468 人受伤。自从 311 大地震造成了惨痛的教训后,日本对于自然灾害的重视已经到了新的高度。但即使这样,即使发展出了高度的文明,我们人类,在大自然面前依然是渺小的存在。 --- ### 2019 年 12 月 24 日 @ 神奈川・川崎・家中 ![](/assets/images/2019/2019-final-2019-12-24.jpg) > 看完《冰雪奇缘 2》的妹妹总是以 Anna 自居,而姐姐也乐得当 Elsa。今年妹妹收到了 Anna 公主的紫红色披肩和宝石蓝裙子。自己终于能和娃娃穿成一样了,非常满意。 小朋友们现在是从心底相信圣诞老人存在的,今年满满还专门给圣诞老人寄了愿望卡片,并且承诺要做一个好孩子。不过,孩子们终究会慢慢长大,她们会知道圣诞老人也许从不曾存在:一直都是爸爸妈妈在挑选礼物,并鬼鬼祟祟地把它们藏在袜子和衣柜里;她们寄给圣诞老人的卡片也从没有离开过这个家,而是被收藏在了满是稚嫩作品的盒子里。 但那又有什么关系呢?“圣诞老人总会驾驶着麋鹿拉的雪橇,在这一天晚上把礼物派发给每一个小朋友”,对“圣诞老人”来说,这是一种无条件的信任,对小朋友们来说,这是一种无终止的承诺。 有诗歌,有幻想,有浪漫,有希望,有爱。圣诞老人,其实真的存在。 --- ## 好书 今年读过并且觉得比较有意思的一些课外书,以及一句话评语。技术类书籍几乎都很无聊,就不列举了。 - [日本国紀 by 百田尚樹](https://book.douban.com/subject/30375911/) - 日语能力见长所带来的显而易见的好处是,我几乎已经可以阅读第一手的日语书籍和博客了。想要了解身处的国家以及这个国家国民的思维,一个最好的方式就是读史。这本《日本国紀》算是对日本人 DNA 的由来,做了一次剖析,也让我看到日本右翼政治一些“有趣”的地方。 - [大国的崩溃 by 沙希利·浦洛基](https://book.douban.com/subject/27021578/) - 一本关于苏联解体过程的纪实和解读。在当下中美格局里,或多或少能从中看到一些影子。对于判断当下的局势也许会有帮助。 - [中国经济 2020 by 王德培](https://book.douban.com/subject/34888158/) - 经济类的图书一般都很难看,因为学派出身和官方口径往往会左右笔者的视角。不过这本书相对轻松,让“经济外行人”也能凑个热闹。 - [非暴力沟通 by 马歇尔·卢森堡](https://book.douban.com/subject/3533221/) - 本来是为了减少和小朋友们吵架的机率开始读的,但是最后发现其实运用到一般生活里也很合适。“暴力沟通”并不只有吵架,也有“不合作”或者“自我发泄”这样的“冷暴力沟通”,它们甚至很多时候比争吵发泄更加严重。这是一本情绪管理的好书,而且非常实用。 --- ## 好物 今年买的一些觉得比较值得的东西,以及一句话评语。 - [住友不动产 J・URBAN 注文住宅](https://www.j-urban.jp) - 有生以来买的最贵的商品。不过从先期设计到来回交流,看着最初的想法逐渐成型,还是很有成就感。 - [御Mavic Mini 航拍小飞机](https://www.dji.com/mavic-mini) - 随带随飞的迷你款无人机,以前买过 Phantom,但是那个实在是太大太显眼,想要带出门需要做很多心理斗争。但是 Mini 完全没有这方面的顾虑,就一个手机大小,非常适合轻度用户。 - [VisionKids HappiCAMU 儿童相机](https://www.visionkids.com/happicamu-kids-camera) - 画风可爱,受到小朋友们的欢迎。不过如果太暴力的话,会有点容易坏。 - [罗技 R500 激光笔无线演示器](https://www.logitech.com.cn/zh-cn/product/r500-laser-presentation-remote) - 今年做各种 presentation 比较多,科普类的演讲还好,但是如果涉及到一些代码的话,有时很难说清楚。另外,每次翻页的时候要碰键盘,也让演讲流畅性大打折扣。如果有演讲场合,一个遥控器还是很必要。泛用性和性价比看来的话,我对现在用的 R500 很满意。 - [BULL3T 便携式微型电筒](https://www.kickstarter.com/projects/bullet/bull3t-worlds-most-powerful-micro-flashlight-ever/description) - 挂在钥匙链上使用。晚上小朋友睡觉后不方便开灯,手机也不在身边的时候就需要它帮忙。另外,走夜路的时候担心手机电池不足时,小电筒也能提供足够和安心的照明。 - [Keychron K2 机械键盘](https://www.kickstarter.com/projects/keytron/keychron-k2-a-sleek-compact-wireless-mechanical-ke/description) - 作为机械键盘玩家,看到有众筹的键盘,而且还无缝支持 Mac,自然是要下手的。买之前已经做好了这是一款炫酷键盘的心理准备,但是实际上手后发现比想象得还要炫酷...只能用来打游戏,用来写代码的话实在太中二了。 2019,以上,再见。2020,你好,未来。 URL: https://onevcat.com/2019/12/backpressure-in-combine/index.html.md Published At: 2019-12-01 12:32:00 +0900 # 关于 Backpressure 和 Combine 中的处理 > 本文是对我的《SwiftUI 和 Combine 编程》书籍的补充,对一些虽然很重要,但和书中上下文内容相去略远,或者一些不太适合以书本的篇幅详细展开解释的内容进行了追加说明。如果你对 SwiftUI 和 Combine 的更多话题有兴趣的话,可以考虑[参阅原书](https://objccn.io/products/swift-ui)。 Combine 在 API 设计上很多地方都参考了 Rx 系,特别是 [RxSwift](https://github.com/ReactiveX/RxSwift) 的做法。如果你已经对响应式编程很了解的话,从 RxSwift 迁移到 Combine 应该是轻而易举的。如果要说起 RxSwift 和 Combine 的最显著的不同,那就是 RxSwift 在可预期的未来[没有支持 backpressure 的计划](https://github.com/ReactiveX/RxSwift/issues/1666?source=post_page-----64780a150d89----------------------#issuecomment-395546338);但是 Combine 中原生对这个特性进行了支持:在 Combine 中你可以在 `Subscriber` 中返回追加接收的事件数量,来定义 Backpressure 的响应行为。在这篇文章里,我们会解释这个行为。 ## 什么是 Backpressure,为什么需要处理它 虽然在 iOS 客户端中,backpressure 也许不是那么常见,但是这在软件开发里可能是一个开发者或多或少都会遇到的话题。Backpressure 这个词来源于流体力学,一般被叫做**背压**或者**回压**,指的是**流体在管道中流动时,(由于高度差或者压力所产生的阻滞) 在逆流动方向上的阻力**。在响应式的编程世界中,我们经常会把由 Publisher,Operator 和 Subscriber 组成的事件处理方式比喻为“管道”,把对应的不断发生的事件叫做“事件流”。类比而言,在这个上下文中,backpressure 指的是**在事件流动中,下游 (Operator 或者 Subscriber) 对上游 Publisher 的阻力**。 为什么会产生这样的“阻力”呢?一个最常见的原因就是下游的 Subscriber 的处理速度无法跟上上游 Publisher 产生事件的速度。在理想世界中,如果我们的处理速度无穷,那么不管 Publisher 以多快的速度产生事件,Subscriber 都可以消化并处理这些事件。但是实际情况显然不会如此,有时候 Publisher 的事件生成速度可以远超 Subscriber 的处理速度,这种情况下就会产生一些问题。 举例来说,比如我们的 Publisher 从一个快速的 web socket 接受数据,经过一系列类似 [`Publishers.Map`](https://developer.apple.com/documentation/combine/publishers/map) 的变形操作,将接收到的数据转换为 app 中的 Model,最终的订阅者在接收到数据后执行 UI 渲染的工作,把数据添加到 Table View 里并绘制 UI。很显然,相对于 UI 渲染来说,接收数据和数据变形是非常快的。在一帧 (60 Hz 下的话,16ms) 中,我们可以接收和处理成千上万条数据,但是可能只能创建和渲染十多个 cell。如果我们想要处理这些数据,朴素来说,可能的方式有四种: 1. 阻塞主线程,在这一帧中处理完这成千上万的 cell。 2. 把接受到的数据暂存在一个 buffer 里,取出合适的量进行处理。剩余部分则等待下一帧或者稍后空闲时再进行渲染。 3. 在接收到数据后,使用采样方法丢弃掉一部分数据,只去处理部分数据,以满足满帧渲染。 4. 控制事件产生的速度,让事件的发生速度减慢,来“适应”订阅者处理的速度。 在客户端开发中,方案 1 是最不可取的,很显然它会把 UI 整个卡死,甚至让我们可爱的 [watchdog 0x8badf00d (ate bad food)](https://stackoverflow.com/a/36644249/1468886) 从而造成 app 崩溃。方案 2 在某些情况下可能会有用,但是如果数据一直堆积,buffer 迟早会发生溢出。对于方案 3,在“将大量数据渲染到 UI 上”这一情景中,UI 刷新的速率将远远超过人能看到和处理的信息量,所以它是可行的,丢弃掉部分数据并不会造成使用体验上的影响。方案 4 如果可以实现的话,则是相对理想的 backpressure 处理方式:让发送端去适配接收端,在保证体验的情况下同时也保障了数据完整性,并且 (至少对客户端来说) 不会存在 buffer 溢出的情况。 另外一个常见的例子是大型文件转存,例如从磁盘的某个位置通过一个 stream 读取数据,然后将它写入到另一个地方。磁盘的读写速度往往是存在差别的,通常来说读速要比写速快很多。假设磁盘读取速度为 100 MB/s,写入速度为 50 MB/s,如果两端都全速的话,每秒将会堆积 50 MB 的数据到 buffer 中,很多场景下这是难以接受的。我们可以通过限制读取速度,来完美解决这个速度差,而这就是上面的方案 4 中的思想。 简单来说,backpressure 提供了一种方案,来解决在异步操作中发送端和接收端速率无法匹配的问题 (通常是发送端快于接收端)。在一个 (像是 Combine 这样的) 异步处理框架中,是否能够支持控制上游速度来处理 backpressure,关键取决于一点:事件的发送到底是基于**拉取模型**还是**推送模型**。如果是拉取模型,那么所定义的 Publisher 会根据要求**按需发送**,那么我们就可以控制事件发送的频率,进而处理前述的上下游速度不匹配的问题。 ## 自定义 Subscriber ### Combine 框架基于拉取的事件模型 好消息是,Combine 的事件发送确实是基于拉取模型的。我们回顾一下典型的 Combine 订阅和事件发送的流程: ![](/assets/images/2019/publisher-subscriber-flow.svg) 图中共有三种主要角色,除了两端的 `Publisher` 和 `Subscriber` 以外,还有一个负责作为“桥梁”连接两者的 `Subscription`。 步骤 3,4 和 5 中分别涉及到 `Subscription` 和 `Subscriber` 的下面两个方法: ```swift protocol Subscription { func request(_ demand: Subscribers.Demand) } protocol Subscriber { func receive(_ input: Self.Input) -> Subscribers.Demand } ``` 它们都和 `Subscribers.Demand` 相关:这个值表示了 `Subscriber` 希望接收的事件数量。Combine 框架中有这样的约定:`Subscriber` 对应着的订阅所发出的事件总数,不应该超过 `Subscription.request(_:)` 所传入的 `Demand` 和接下来每次 `Subscriber.receive(_:)` 被调用时返回的 `Demand` 的值的累加。基于这个规则,`Subscriber` 可以根据自身情况通过使用合适的 `Demand` 来控制上游。 这么说会有些抽象。在这篇文章里,我们会把注意力集中在 `Subscriber` 上,首先来看看如何实现自定义的 `Subscriber`,由此理解 Combine 的拉取模型的意义。然后再尝试实现一个能够控制 `Publisher` 发送事件的特殊 `Subscriber`。 关于图中另外两种角色 `Publisher` 和 `Subscription`,我可能会在另外的文章里再进行更多说明。 ### 重写 Sink 在订阅某个 `Publisher` 时,大概最常用的莫过于 `sink` 了: ```swift [1,2,3,4,5].publisher.sink( receiveCompletion: { completion in print("Completion: \(completion)") }, receiveValue: { value in print("Receive value: \(value)") } ) ``` 定义在 `Publisher` 上的扩展方法 `sink(receiveCompletion:receiveValue:)` 只不过是标准的订阅流程的简写方式。按照“正规的”方式,我们可以明确地创建 `Subscriber` 并让它订阅 `Publisher`,上面的代码等效于: ```swift let publisher = [1,2,3,4,5].publisher let subscriber = Subscribers.Sink( receiveCompletion: { completion in print("Completion: \(completion)") }, receiveValue: { value in print("Receive value: \(value)") } ) publisher.subscribe(subscriber) ``` `Sink` 做的事情非常简单,它在订阅时直接申请接受 `Subscribers.Demand.unlimited` 个元素。在每次收到事件时,调用预先设定的 block。现在,作为起始,我们来创建一个自定义的 `MySink`: ```swift // 1 extension Subscribers { // 2 class MySink: Subscriber, Cancellable { let receiveCompletion: (Subscribers.Completion) -> Void let receiveValue: (Input) -> Void // 3 var subscription: Subscription? // ... } } ``` 1. Combine 中的 `Publisher` 和 `Subscriber` 大都作为内嵌类型,定义在 `Publishers` 和 `Subscribers` 中。在这里,我们也遵循这个规则,把 `MySink` 写在 `Subscribers` 里。 2. 我们想让 `MySink` 满足 `Cancellable`,因此需要持有 `subscription`,才能在未来取消这个订阅。在语义上来说,我们也不希望发生复制,所以使用 `class` 来声明 `MySink`。这也是实现自定义 `Subscriber` 的一般做法。 3. 在 `Subscriber` 中持有 `subscription` 是很常见的操作,除了用来对应取消以外,这还可以让我们灵活处理额外的值的请求,稍后我们会看到这方面的内容。 接下来,创建一个初始化方法,它接受 `receiveCompletion` 和 `receiveValue`: ```swift init( receiveCompletion: @escaping (Subscribers.Completion) -> Void, receiveValue: @escaping (Input) -> Void ) { self.receiveCompletion = receiveCompletion self.receiveValue = receiveValue } ``` 想要实现 `Subscriber` 协议,我们需要实现协议中定义的所有三个方法: ```swift public protocol Subscriber { func receive(subscription: Subscription) func receive(_ input: Self.Input) -> Subscribers.Demand func receive(completion: Subscribers.Completion) } ``` 在 `MySink` 里,我们可以完全遵循 `Sink` 的做法:在一开始收到订阅时,就请求无限多的事件;而在后续收到值时,则不再做 (也不需要做) 更多的请求: ```swift func receive(subscription: Subscription) { self.subscription = subscription subscription.request(.unlimited) } func receive(_ input: Input) -> Subscribers.Demand { receiveValue(input) return .none } func receive(completion: Subscribers.Completion) { receiveCompletion(completion) subscription = nil } ``` 最后,为了实现 `Cancellable`,我们需要将 `cancel()` 的调用“转发”给 `subscription`: ```swift func cancel() { subscription?.cancel() subscription = nil } ``` 为了避免意外的循环引用 (因为 `Subscription` 很多情况下也会持有 `Subscriber`),所以在收到完成事件或者收到取消请求时,不再继续需要订阅的情况下,要记得将 `subscription` 置回为 `nil`。 最后的最后,为了方便使用,不妨为 `Publisher` 提供一个扩展方法,来帮助我们用 `MySink` 做订阅: ```swift extension Publisher { func mySink( receiveCompletion: @escaping (Subscribers.Completion) -> Void, receiveValue: @escaping (Output) -> Void ) -> Cancellable { let sink = Subscribers.MySink( receiveCompletion: receiveCompletion, receiveValue: receiveValue ) self.subscribe(sink) return sink } } [1,2,3,4,5].publisher.mySink( receiveCompletion: { completion in print("Completion: \(completion)") }, receiveValue: { value in print("Receive value: \(value)") } ) // 输出: // Receive value: 1 // Receive value: 2 // Receive value: 3 // Receive value: 4 // Receive value: 5 // Completion: finished ``` `mySink` 的行为和原始的 `sink` 应该是完全一致的。现在我们就可以开始着手修改 `MySink` 的代码,让事情变得更有趣一些了。 ### 按照 Demand 的需求来发送事件 我们可以对 `MySink` 做一点手脚,来控制它的拉取行为。比如将 `receive(subscription:)` 里初始的请求数量调整为 `.max(1)`: ```swift func receive(subscription: Subscription) { self.subscription = subscription // subscription.request(.unlimited) subscription.request(.max(1)) } ``` 这样一来,输出就停留在只有一行了: ``` Receive value: 1 ``` 这是因为现在我们只在订阅发生时去请求了一个值,而在 `receive(_:)` 里,我们返回的 `.none` 代表不再需要 `Publisher` 给出新值了。在这个方法中,我们有机会决定下一次的事件请求数量:可以将请求数从 `.none` 调整为 `.max(1)`: ```swift func receive(_ input: Input) -> Subscribers.Demand { receiveValue(input) // return .none return .max(1) } ``` 输出将恢复原来的情况:每当 `MySink` 收到一个值时,它会再去**拉取**下一个值,直到最后结束。我们可以通过为 `Publisher` 添加 `print()` 来从控制台输出确定这个行为: ```swift // [1,2,3,4,5].publisher [1,2,3,4,5].publisher.print() .mySink( receiveCompletion: { completion in print("Completion: \(completion)") }, receiveValue: { value in // print("Receive value: \(value)") } ) // 输出: // receive subscription: ([1, 2, 3, 4, 5]) // request max: (1) // receive value: (1) // request max: (1) (synchronous) // receive value: (2) // request max: (1) (synchronous) // receive value: (3) // ... ``` 通过在 `Subscriber` 里的 `receive(subscription:)` 和 `receive(_:)` 来控制 `Subscribers.Demand`,我们做到了控制 `Publisher` 的事件发送。那要如何使用这个特性处理 backpressure 的情况呢? ## 能够处理 backpressure 的 Subscriber ### 让已停止的事件流继续 按照 Combine 约定,当 `Publisher` 发送的值满足 `Subscriber` 所要求的数量后,便不再发送新的值。在上面的 `MySink` 实现里,只要将 `receive(_:)` 的返回值设为 `.none`,那么就只会有第一个值被发出。这时候我们便遇到了一个问题,因为后续的值不会再被发送,`receive(_:)` 也不会再被调用,因此我们不再有机会在 `receive(_:)` 中返回新的 `Demand`,来让 `Publisher` 重新开始工作。 我们需要一种方式来“重新启动”这个流程,那就是 `Subscription` 上的 `request(_ demand: Subscribers.Demand)` 方法。在订阅刚开始时,我们已经使用过它来开始第一次发送。现在,当 `Publisher` “暂停”后,我也也可以从外部用它来重启发送流程,这也是我们要暂存 `subscription` 的另一个重要理由。 在 `MySink` 里添加 `resume` 方法: ```swift func resume() { subscription?.request(.max(1)) } ``` 创建一个 `Resumable` 协议,并让 `MySink` 遵守这个协议: ```swift protocol Resumable { func resume() } extension Subscribers { class MySink : Subscriber, Cancellable, Resumable { //... // MySink 已经实现了 resume() } } ``` 最后,把 `Publisher.mySink` 的返回类型从 `Cancellable` 修改为 `Cancellable & Resumable` 的联合: ```swift extension Publisher { func mySink( receiveCompletion: @escaping (Subscribers.Completion) -> Void, receiveValue: @escaping (Output) -> Void ) -> Cancellable & Resumable { // ... } } ``` 现在,即使我们把 `MySink` 里 `receive(_:)` 的返回值改回 `.none`,让 `Publisher` 在被订阅后只发出一次值,我们也可以再通过反复调用 `resume(_:)` 来“分批次”拉取所有值了: ```swift extension Subscribers { class MySink //... { func receive(_ input: Input) -> Subscribers.Demand { receiveValue(input) return .none } } } let subscriber = [1,2,3,4,5].publisher.print() .mySink( receiveCompletion: { completion in print("Completion: \(completion)") }, receiveValue: { value in print("Receive value: \(value)") } ) subscriber.resume() subscriber.resume() subscriber.resume() subscriber.resume() ``` ### 注入控制逻辑,暂停 `Publisher` 发送 现在我们只差最后一块拼图了,那就是到底由谁来负责暂停 `Publisher` 的逻辑。当前的 `MySink` 中,由于开始订阅时只接受了 `.max(1)`,同时, `receive(_:)` 返回的是 `.none`,所以在接到第一个值后,`Publisher` 是无条件暂停的。实际上,和 `resume` 逻辑类似,我们会更希望将暂停的逻辑也“委托”出去,由调用者来决定合适的暂停时机:`receiveValue` 回调是一个不错的地方。将 `MySink` 中的 `receiveValue` 签名进行修改,让它返回一个 `Bool` 来表示是否应该继续下一次请求,并为 `MySink` 添加一个属性持有它: ```swift class MySink // ... { // let receiveValue: (Input) -> Void let receiveValue: (Input) -> Bool var shouldPullNewValue: Bool = false init( receiveCompletion: @escaping (Subscribers.Completion) -> Void, // receiveValue: @escaping (Input) -> Void receiveValue: @escaping (Input) -> Bool ) // ... } extension Publisher { func mySink( receiveCompletion: @escaping (Subscribers.Completion) -> Void, // receiveValue: @escaping (Output) -> Void receiveValue: @escaping (Output) -> Bool ) -> Cancellable & Resumable ``` 当 `shouldPullNewValue` 为 `true` 时,在收到新值后,应当继续请求下一个值;否则,便不再继续请求,将事件流关闭,等待外界调用 `resume` 再重启。 对 `MySink` 的相关方法进行修改: ```swift func receive(subscription: Subscription) { self.subscription = subscription resume() } func receive(_ input: Input) -> Subscribers.Demand { shouldPullNewValue = receiveValue(input) return shouldPullNewValue ? .max(1) : .none } func resume() { guard !shouldPullNewValue else { return } shouldPullNewValue = true subscription?.request(.max(1)) } ``` 这样,就可以在使用的时候通过 `receiveValue` 闭包返回 `false` 来暂停;在暂停后,通过调用 `resume` 来继续了。 假设我们有一个巨大 (甚至无限!) 的数据集,在使用 `sink` 的情况下由于处理速度无法跟上事件的发送速度,我们将会被直接卡死: ```swift (1...).publisher.sink( receiveCompletion: { completion in print("Completion: \(completion)") }, receiveValue: { value in print("Receive value: \(value)") } ) ``` 但是如果我们通过使用 `mySink` 并设定一定条件,就可以很优雅地处理这个 backpressure。比如每秒只需要 `Publisher` 发送五个事件,并进行处理: ```swift var buffer = [Int]() let subscriber = (1...).publisher.print().mySink( receiveCompletion: { completion in print("Completion: \(completion)") }, receiveValue: { value in print("Receive value: \(value)") buffer.append(value) return buffer.count < 5 } ) let cancellable = Timer.publish(every: 1, on: .main, in: .default) .autoconnect() .sink { _ in buffer.removeAll() subscriber.resume() } ``` 通过这种方式,我们自定义的 `MySink` 成为了一个可以用于处理 backpressure 的通用方案。 > 相关的代码可以[在这里找到](https://gist.github.com/onevcat/baecc584e3cbfa2cc161290b2dfd300a)。 ## 练习 为了保持和[《SwiftUI 和 Combine 编程》]((https://objccn.io/products/swift-ui))这本书的形式上的类似,我也准备了一些小练习,希望能帮助读者通过实际动手练习掌握本文的内容。 ### 1. 自定义实现 `Subscribers.Assign` 文中自定义了 `MySink`,来复现 `Sink` 的功能。现在请你依照类似的方式创建一个你自己的 `MyAssign` 类型,让它和 `Subscribers.Assign` 的行为一致。作为提示,下面是 Combine 框架中 `Subscribers.Assign` 的 (简化版的) public 声明: ```swift class Assign : Subscriber, Cancellable { typealias Failure = Never var object: Root? { get } let keyPath: ReferenceWritableKeyPath // ... } ``` ### 2. 一次 request 超过 `.max(1)` 个数的事件 在引入 `resume` 时,我们将 `.max(1)` 硬编码在了方法内部: ```swift func resume() { subscription?.request(.max(1)) } ``` 我们能不能修改这个方法的签名,让它更灵活一些,接受一个 `Demand` 参数,让它可以向 `subscription` 请求多个值?比如: ```swift func resume(_ demand: Subscribers.Demand) { subscription?.request(demand) } ``` 这么做会对 `Resumable` 产生影响吗?会对之后我们想要实现的暂停逻辑有什么影响?我们还能够使用这样的 `resume` 写出可靠的暂停和重启逻辑么? ### 3. 通用的 Subscriber 和专用的 Subscriber 本文最后我们实现的是一个相对通用的 Subscriber,但是如果逻辑更复杂,或者需要大规模重复使用时,把逻辑放在 `receiveValue` 闭包中会有些麻烦。 请尝试把原文中 「`buffer` 元素数到达 5 时,暂停一秒」这个逻辑封装起来,用一个新的专用的 `Subscriber` 替代。你可以尝试两个方向: 1. 使用一个新的类型,包装现有的 `MySink`,将判断逻辑放到新类型中;`Subscriber` 协调所需要定义的方法,通过转发的方式交给 `MySink` 处理。 2. 完全重新实现一个和 `MySink` 无关的 `Subscriber`,专门用来处理这类定时开关的事件流。 URL: https://onevcat.com/2019/06/swift-ui-firstlook-2/index.html.md Published At: 2019-06-11 12:32:00 +0900 # SwiftUI 的一些初步探索 (二) ![](/assets/images/2019/swift-ui.png) > 我已经计划写一本关于 SwiftUI 和 Combine 编程的书籍,希望能通过一些实践案例帮助您快速上手 SwiftUI 及 Combine 响应式编程框架,掌握下一代客户端 UI 开发技术。现在这本书已经开始预售,预计能在 10 月左右完成。如果您对此有兴趣,可以查看 [ObjC 中国的产品页面](https://objccn.io/products/)了解详情及购买。十分感谢! 接[上一篇](https://onevcat.com/2019/06/swift-ui-firstlook/)继续对 SwiftUI 的教程进行一些解读。 ### [教程 2 - Building Lists and Navigation](https://developer.apple.com/tutorials/swiftui/building-lists-and-navigation) #### [Section 4 - Step 2: 静态 `List`](https://developer.apple.com/tutorials/swiftui/building-lists-and-navigation#create-the-list-of-landmarks) ```swift var body: some View { List { LandmarkRow(landmark: landmarkData[0]) LandmarkRow(landmark: landmarkData[1]) } } ``` 这里的 `List` 和 `HStack` 或者 `VStack` 之类的容器很相似,接受一个 view builder 并采用 View DSL 的方式列举了两个 `LandmarkRow`。这种方式构建了对应着 `UITableView` 的静态 cell 的组织方式。 ```swift public init(content: () -> Content) ``` 我们可以运行 app,并使用 Xcode 的 View Hierarchy 工具来观察 UI,结果可能会让你觉得很眼熟: ![](https://images.xiaozhuanlan.com/photo/2019/a900c8d2687dab13ba438602da826552.png) 实际上在屏幕上绘制的 `UpdateCoalesingTableView` 是一个 `UITableView` 的子类,而两个 cell `ListCoreCellHost` 也是 `UITableViewCell` 的子类。对于 `List` 来说,SwiftUI 底层直接使用了成熟的 `UITableView` 的一套实现逻辑,而并非重新进行绘制。相比起来,像是 `Text` 或者 `Image` 这样的单一 `View` 在 `UIKit` 层则全部统一由 `DisplayList.ViewUpdater.Platform.CGDrawingView` 这个 `UIView` 的子类进行绘制。 不过在使用 SwiftUI 时,我们首先需要做的就是跳出 UIKit 的思维方式,不应该去关心背后的绘制和实现。使用 `UITableView` 来表达 `List` 也许只是权宜之计,也许在未来也会被另外更高效的绘制方式取代。由于 SwiftUI 层只是 `View` 描述的数据抽象,因此和 React 的 Virtual DOM 以及 Flutter 的 Widget 一样,背后的具体绘制方式是完全解耦合,并且可以进行替换的。这为今后 SwiftUI 更进一步留出了足够的可能性。 #### [Section 5 - Step 2: 动态 `List` 和 `Identifiable`](https://developer.apple.com/tutorials/swiftui/building-lists-and-navigation#make-the-list-dynamic) ```swift List(landmarkData.identified(by: \.id)) { landmark in LandmarkRow(landmark: landmark) } ``` 除了静态方式以外,`List` 当然也可以接受动态方式的输入,这时使用的初始化方法和上面静态的情况不一样: ```swift public struct List where Selection : SelectionManager, Content : View { public init( _ data: Data, action: @escaping (Data.Element.IdentifiedValue) -> Void, rowContent: @escaping (Data.Element.IdentifiedValue) -> RowContent) where Content == ForEach>>, Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable //... } ``` 这个初始化方法的约束比较多,我们一行行来看: - `Content == ForEach>>` 因为这个函数签名中并没有出现 `Content`,`Content` 仅只 `List` 的类型声明中有定义,所以在这与其说是一个约束,不如说是一个用来反向确定 `List` 实际类型的描述。现在让我们先将注意力放在更重要的地方,稍后会再多讲一些这个。 - `Data : RandomAccessCollection` 这基本上等同于要求第一个输入参数是 `Array`。 - `RowContent : View` 对于构建每一行的 `rowContent` 来说,需要返回是 `View` 是很正常的事情。注意 `rowContent` 其实也是被 `@ViewBuilder` 标记的,因此你也可以把 `LandmarkRow` 的内容展开写进去。不过一般我们会更希望尽可能拆小 UI 部件,而不是把东西堆在一起。 - `Data.Element : Identifiable` 要求 `Data.Element` (也就是数组元素的类型) 上存在一个可以辨别出某个实例的[满足 `Hashable` 的 id](https://developer.apple.com/documentation/swiftui/identifiable/3285392-id)。这个要求将在数据变更时快速定位到变化的数据所对应的 cell,并进行 UI 刷新。 关于 `List` 以及其他一些常见的基础 `View`,有一个比较有趣的事实。在下面的代码中,我们期望 `List` 的初始化方法生成的是某个类型的 `View`: ```swift var body: some View { List { //... } } ``` 但是你看遍 [List 的文档](https://developer.apple.com/documentation/swiftui/list),甚至是 Cmd + Click 到 SwiftUI 的 interface 中查找 `View` 相关的内容,都找不到 `List : View` 之类的声明。 难道是因为 SwiftUI 做了什么手脚,让本来没有满足 `View` 的类型都可以“充当”一个 `View` 吗?当然不是这样...如果你在运行时暂定 app 并用 lldb 打印一下 `List` 的类型信息,可以看到下面的下面的信息: ``` (lldb) type lookup List ... struct List : SwiftUI._UnaryView where ... ``` 进一步,`_UnaryView` 的声明是: ```swift protocol _UnaryView : View where Self.Body : _UnaryView { } ``` SwiftUI 内部的一元视图 `_UnaryView` 协议虽然是满足 `View` 的,但它被隐藏起来了,而满足它的 `List` 虽然是 public 的,但是却可以把这个协议链的信息也作为内部信息隐藏起来。这是 Swift 内部框架的特权,第三方的开发者无法这样在在两个 public 的声明之间插入一个私有声明。 最后,SwiftUI 中当前 (Xcode 11 beta 1) 只有对应 `UITableView` 的 `List`,而没有 `UICollectionView` 对应的像是 `Grid` 这样的类型。现在想要实现类似效果的话,只能嵌套使用 `VStack` 和 `HStack`。这是比较奇怪的,因为技术层面上应该和 table view 没有太多区别,大概是因为工期不太够?相信今后应该会补充上 `Grid`。 ### [教程 3 - Handling User Input](https://developer.apple.com/tutorials/swiftui/handling-user-input) #### [Section 3 - Step 2: `@State` 和 `Binding`](https://developer.apple.com/tutorials/swiftui/handling-user-input#add-a-control-to-toggle-the-state) ```swift @State var showFavoritesOnly = true var body: some View { NavigationView { List { Toggle(isOn: $showFavoritesOnly) { Text("Favorites only") } //... if !self.showFavoritesOnly || landmark.isFavorite { ``` 这里出现了两个以前在 Swift 里没有的特性:`@State` 和 `$showFavoritesOnly`。 如果你 Cmd + Click 点到 `State` 的定义里面,可以看到它其实是一个特殊的 `struct`: ```swift @propertyWrapper public struct State : DynamicViewProperty, BindingConvertible { /// Initialize with the provided initial value. public init(initialValue value: Value) /// The current state value. public var value: Value { get nonmutating set } /// Returns a binding referencing the state value. public var binding: Binding { get } /// Produces the binding referencing this state value public var delegateValue: Binding { get } } ``` `@propertyWrapper` 标注和[上一篇中提到](https://xiaozhuanlan.com/topic/7652341890#sectionsection3step5viewbuilderhttpsdeveloperapplecomtutorialsswiftuicreatingandcombiningviewscombineviewsusingstacks)的 `@_functionBuilder` 类似,它修饰的 `struct` 可以变成一个新的修饰符并作用在其他代码上,来改变这些代码默认的行为。这里 `@propertyWrapper` 修饰的 `State` 被用做了 `@State` 修饰符,并用来修饰 `View` 中的 `showFavoritesOnly` 变量。 和 `@_functionBuilder` 负责按照规矩“重新构造”函数的作用不同,`@propertyWrapper` 的修饰符最终会作用在属性上,将属性“包裹”起来,以达到控制某个属性的读写行为的目的。如果将这部分代码“展开”,它实际上是这个样子的: ```swift // @State var showFavoritesOnly = true var showFavoritesOnly = State(initialValue: true) var body: some View { NavigationView { List { // Toggle(isOn: $showFavoritesOnly) { Toggle(isOn: showFavoritesOnly.binding) { Text("Favorites only") } //... // if !self.showFavoritesOnly || landmark.isFavorite { if !self.showFavoritesOnly.value || landmark.isFavorite { ``` 我把变化之前的部分注释了一下,并且在后面一行写上了展开后的结果。可以看到 `@State` 只是声明 `State` struct 的一种简写方式而已。`State` 里对具体要如何读写属性的规则进行了定义。对于读取,非常简单,使用 `showFavoritesOnly.value` 就能拿到 `State` 中存储的实际值。而原代码中 `$showFavoritesOnly` 的写法也只不过是 `showFavoritesOnly.binding` 的简化。`binding` 将创建一个 `showFavoritesOnly` 的引用,并将它传递给 `Toggle`。再次强调,这个 `binding` 是一个**引用**类型,所以 `Toggle` 中对它的修改,会直接反应到当前 View 的 `showFavoritesOnly` 去设置它的 `value`。而 `State` 的 value didSet 将触发 `body` 的刷新,从而完成 State -> View 的绑定。 > 在 Xcode 11 beta 1 中,Swift 中使用的修饰符名字是 `@propertyDelegate`,不过在 WWDC 上 Apple 提到这个特性时把它叫做了 `@propertyWrapper`。根据[可靠消息](https://twitter.com/josefdolezal/status/1137619597002248192?s=21),在未来正式版中应该也会叫做 `@propertyWrapper`,所以大家在看各种资料的时候最好也建议一个简单的映射关系。 > > 如果你想要了解更多关于 `@propertyWrapper` 的细节,可以看看[相关的提案](https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-delegates.md)和[论坛讨论](https://forums.swift.org/t/se-0258-property-delegates/23139)。比较有意思的细节是 Apple 在将相应的 PR merge 进了 master 以后又把这个提案的打回了“修改”的状态,而非直接接受。除了 `@propertyWrapper` 的名称修正以外,应该还会有一些其他的细节修改,但是已经公开的行为模式上应该不会太大变化了。 SwiftUI 中还有几个常见的 `@` 开头的修饰,比如 `@Binding`,`@Environment`,`@EnvironmentObject` 等,原理上和 `@State` 都一样,只不过它们所对应的 struct 中定义读写方式有区别。它们共同构成了 SwiftUI 数据流的最基本的单元。对于 SwiftUI 的数据流,如果展开的话足够一整篇文章了。在这里还是十分建议看一看 [Session 226 - Data Flow Through SwiftUI](https://developer.apple.com/videos/play/wwdc2019/226/) 的相关内容。 ### [教程 5 - Animating Views and Transitions](https://developer.apple.com/tutorials/swiftui/animating-views-and-transitions) #### [Section 2 - Step 4: 两种动画的方式](https://developer.apple.com/tutorials/swiftui/animating-views-and-transitions#customize-view-transitions) 在 SwiftUI 中,做动画变的十分简单。Apple 的教程里提供了两种动画的方式: 1. 直接在 `View` 上使用 `.animation` modifier 2. 使用 `withAnimation { }` 来控制某个 `State`,进而触发动画。 对于只需要对单个 `View` 做动画的时候,`animation(_:)` 要更方便一些,它和其他各类 modifier 并没有太大不同,返回的是一个包装了对象 `View` 和对应的动画类型的新的 `View`。`animation(_:)` 接受的参数 `Animation` 并不是直接定义 `View` 上的动画的数值内容的,它是描述的是动画所使用的时间曲线,动画的延迟等这些和 `View` 无关的东西。具体和 `View` 有关的,想要进行动画的数值方面的变更,由其他的诸如 `rotationEffect` 和 `scaleEffect` 这样的 modifier 来描述。 在上面的 [教程 5 - Section 1 - Step 5](https://developer.apple.com/tutorials/swiftui/animating-views-and-transitions#add-animations-to-individual-views) 里有这样一段代码: ```swift Button(action: { self.showDetail.toggle() }) { Image(systemName: "chevron.right.circle") .imageScale(.large) .rotationEffect(.degrees(showDetail ? 90 : 0)) .animation(nil) .scaleEffect(showDetail ? 1.5 : 1) .padding() .animation(.spring()) } ``` 要注意,SwiftUI 的 modifier 是有顺序的。在我们调用 `animation(_:)` 时,SwiftUI 做的事情等效于是把之前的所有 modifier 检查一遍,然后找出所有满足 [`Animatable`](https://developer.apple.com/documentation/swiftui/animatable) 协议的 view 上的数值变化,比如角度、位置、尺寸等,然后将这些变化打个包,创建一个事物 (`Transaction`) 并提交给底层渲染去做动画。在上面的代码中,`.rotationEffect` 后的 `.animation(nil)` 将 rotation 的动画提交,因为指定了 `nil` 所以这里没有实际的动画。在最后,`.rotationEffect` 已经被处理了,所以末行的 `.animation(.spring())` 提交的只有 `.scaleEffect`。 `withAnimation { }` 是一个顶层函数,在闭包内部,我们一般会触发某个 State 的变化,并让 `View.body` 进行重新计算: ```swift Button(action: { withAnimation { self.showDetail.toggle() } }) { //... } ``` 如果需要,你也可以为它指定一个具体的 `Animation`: ```swift withAnimation(.basic()) { self.showDetail.toggle() } ``` 这个方法相当于把一个 `animation` 设置到 `View` 数值变化的 `Transaction` 上,并提交给底层渲染去做动画。从原理上来说,`withAnimation` 是统一控制单个的 `Transaction`,而针对不同 `View` 的 `animation(_:)` 调用则可能对应多个不同的 `Transaction`。 ### [教程 7 - Working with UI Controls](https://developer.apple.com/tutorials/swiftui/working-with-ui-controls) #### [Section 4 - Step 2: 关于 `View` 的生命周期](https://developer.apple.com/tutorials/swiftui/working-with-ui-controls#delay-edit-propagation) ```swift ProfileEditor(profile: $draftProfile) .onDisappear { self.draftProfile = self.profile } ``` 在 UIKit 开发时,我们经常会接触一些像是 `viewDidLoad`,`viewWillAppear` 这样的生命周期的方法,并在里面进行一些配置。SwiftUI 里也有一部分这类生命周期的方法,比如 `.onAppear` 和 `.onDisappear`,它们也被“统一”在了 modifier 这面大旗下。 但是相对于 UIKit 来说,SwiftUI 中能 hook 的生命周期方法比较少,而且相对要通用一些。本身在生命周期中做操作这种方式就和声明式的编程理念有些相悖,看上去就像是加上了一些命令式的 hack。我个人比较期待 `View` 和 `Combine` 能再深度结合一些,把像是 `self.draftProfile = self.profile` 这类依赖生命周期的操作也用绑定的方式搞定。 相比于 `.onAppear` 和 `.onDisappear`,更通用的事件响应 hook 是 `.onReceive(_:perform:)`,它定义了一个可以响应目标 `Publisher` 的任意的 `View`,一旦订阅的 `Publisher` 发出新的事件时,`onReceive` 就将被调用。因为我们可以自行定义这些 publisher,所以它是完备的,这在把现有的 UIKit View 转换到 SwiftUI View 时会十分有用。 URL: https://onevcat.com/2019/06/swift-ui-firstlook/index.html.md Published At: 2019-06-04 15:32:00 +0900 # SwiftUI 的一些初步探索 (一) ![](/assets/images/2019/swift-ui.png) > 我已经计划写一本关于 SwiftUI 和 Combine 编程的书籍,希望能通过一些实践案例帮助您快速上手 SwiftUI 及 Combine 响应式编程框架,掌握下一代客户端 UI 开发技术。现在这本书已经开始预售,预计能在 10 月左右完成。如果您对此有兴趣,可以查看 [ObjC 中国的产品页面](https://objccn.io/products/)了解详情及购买。十分感谢! ## 总览 如果你想要入门 SwiftUI 的使用,那 Apple 这次给出的[官方教程](https://developer.apple.com/tutorials/swiftui)绝对给力。这个教程提供了非常详尽的步骤和说明,网页的交互也是一流,是觉得值得看和动手学习的参考。 不过,SwiftUI 中有一些值得注意的细节在教程里并没有太详细提及,也可能造成一些困惑。这篇文章以我的个人观点对教程的某些部分进行了补充说明,希望能在大家跟随教程学习 SwiftUI 的时候有点帮助。这篇文章的推荐阅读方式是,一边参照 SwiftUI 教程实际动手进行实现,一边在到达对应步骤时参照本文加深理解。在下面每段内容前我标注了对应的教程章节和链接,以供参考。 在开始学习 SwiftUI 之前,我们需要大致了解一个问题:为什么我们会需要一个新的 UI 框架。 ## 为什么需要 SwiftUI ### UIKit 面临的挑战 对于 Swift 开发者来说,昨天的 WWDC 19 首日 Keynote 和 Platforms State of the Union 上最引人注目的内容自然是 SwiftUI 的公布了。从 iOS SDK 2.0 开始,UIKit 已经伴随广大 iOS 开发者经历了接近十年的风风雨雨。UIKit 的思想继承了成熟的 AppKit 和 MVC,在初出时,为 iOS 开发者提供了良好的学习曲线。 UIKit 提供的是一套符合直觉的,基于控制流的命令式的编程方式。最主要的思想是在确保 View 或者 View Controller 生命周期以及用户交互时,相应的方法 (比如 `viewDidLoad` 或者某个 target-action 等) 能够被正确调用,从而构建用户界面和逻辑。不过,不管是从使用的便利性还是稳定性来说,UIKit 都面临着巨大的挑战。我个人勉强也能算是 iOS 开发的“老司机”了,但是「掉到 UIKit 的坑里」这件事,也几乎还是我每天的日常。UIKit 的基本思想要求 View Controller 承担绝大部分职责,它需要协调 model,view 以及用户交互。这带来了巨大的 side effect 以及大量的状态,如果没有妥善安置,它们将在 View Controller 中混杂在一起,同时作用于 view 或者逻辑,从而使状态管理愈发复杂,最后甚至不可维护而导致项目失败。不仅是作为开发者我们自己写的代码,UIKit 本身内部其实也经常受困于可变状态,各种奇怪的 bug 也频频出现。 ### 声明式的界面开发方式 近年来,随着编程技术和思想的进步,使用声明式或者函数式的方式来进行界面开发,已经越来越被接受并逐渐成为主流。最早的思想大概是来源于 [Elm](https://elm-lang.org),之后这套方式被 [React](https://reactjs.org) 和 [Flutter](https://flutter.dev) 采用,这一点上 SwiftUI 也几乎与它们一致。总结起来,这些 UI 框架都遵循以下步骤和原则: 1. 使用各自的 DSL 来描述「UI 应该是什么样子」,而不是用一句句的代码来指导「要怎样构建 UI」。 比如传统的 UIKit,我们会使用这样的代码来添加一个 “Hello World” 的标签,它负责“创建 label”,“设置文字”,“将其添加到 view 上”: ```swift func viewDidLoad() { super.viewDidLoad() let label = UILabel() label.text = "Hello World" view.addSubview(label) // 省略了布局的代码 } ``` 而相对起来,使用 SwiftUI 我们只需要告诉 SDK 我们需要一个文字标签: ```swift var body: some View { Text("Hello World") } ``` 2. 接下来,框架内部读取这些 view 的声明,负责将它们以合适的方式绘制渲染。 注意,这些 view 的声明只是纯数据结构的描述,而不是实际显示出来的视图,因此这些结构的创建和差分对比并不会带来太多性能损耗。相对来说,将描述性的语言进行渲染绘制的部分是最慢的,这部分工作将交由框架以黑盒的方式为我们完成。 1. 如果 `View` 需要根据某个状态 (state) 进行改变,那我们将这个状态存储在变量中,并在声明 view 时使用它: ```swift @State var name: String = "Tom" var body: some View { Text("Hello \(name)") } ``` > 关于代码细节可以先忽略,我们稍后会更多地解释这方面的内容。 1. 状态发生改变时,框架重新调用声明部分的代码,计算出新的 view 声明,并和原来的 view 进行差分,之后框架负责对变更的部分进行高效的重新绘制。 SwiftUI 的思想也完全一样,而且实际处理也不外乎这几个步骤。使用描述方式开发,大幅减少了在 app 开发者层面上出现问题的机率。 ## 一些细节解读 [官方教程](https://developer.apple.com/tutorials/swiftui)中对声明式 UI 的编程思想有深刻的体现。另外,SwiftUI 中也采用了非常多 Swift 5.1 的新特性,会让习惯了 Swift 4 或者 5 的开发者“耳目一新”。接下来,我会分几个话题,对官方教程的一些地方进行解释和探索。 ### [教程 1 - Creating and Combining Views](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views) #### [Section 1 - Step 3: SwiftUI app 的启动](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views#create-a-new-project-and-explore-the-canvas) 创建 app 之后第一件好奇的事情是,SwiftUI app 是怎么启动的。 教程示例 app 在 AppDelegate 中通过 `application(_:configurationForConnecting:options)` 返回了一个名为 "Default Configuration" 的 `UISceneConfiguration` 实例: ```swift func application( _ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } ``` 这个名字的 Configuration 在 Info.plist 的 “UIApplicationSceneManifest -> UISceneConfigurations” 中进行了定义,指定了 Scene Session Delegate 类为 `$(PRODUCT_MODULE_NAME).SceneDelegate`。这部分内容是 iOS 13 中新加入的通过 Scene 管理 app 生命周期的方式,以及多窗口支持部分所需要的代码。这部分不是我们今天的话题。在 app 完成启动后,控制权被交接给 `SceneDelegate`,它的 `scene(_:willConnectTo:options:)` 将会被调用,进行 UI 的配置: ```swift func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { let window = UIWindow(frame: UIScreen.main.bounds) window.rootViewController = UIHostingController(rootView: ContentView()) self.window = window window.makeKeyAndVisible() } ``` 这部分内容就是标准的 iOS app 启动流程了。`UIHostingController` 是一个 `UIViewController` 子类,它将负责接受一个 SwiftUI 的 View 描述并将其用 UIKit 进行渲染 (在 iOS 下的情况)。`UIHostingController` 就是一个普通的 `UIViewController`,因此完全可以做到将 SwiftUI 创建的界面一点点集成到已有的 UIKit app 中,而并不需要从头开始就是基于 SwiftUI 的构建。 由于 Swift ABI 已经稳定,SwiftUI 是一个搭载在用户 iOS 系统上的 Swift 框架。因此它的最低支持的版本是 iOS 13,可能想要在实际项目中使用,还需要等待一两年时间。 #### [Section 1 - Step 4: 关于 some View](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views#create-a-new-project-and-explore-the-canvas) ```swift struct ContentView: View { var body: some View { Text("Hello World") } } ``` 一眼看上去可能会对 `some` 比较陌生,为了讲明白这件事,我们先从 `View` 说起。 `View` 是 SwiftUI 的一个最核心的协议,代表了一个屏幕上元素的描述。这个协议中含有一个 associatedtype: ```swift public protocol View : _View { associatedtype Body : View var body: Self.Body { get } } ``` 这种带有 associatedtype 的协议不能作为**类型**来使用,而只能作为**类型约束**使用: ```swift // Error func createView() -> View { } // OK func createView() -> T { } ``` 这样一来,其实我们是不能写类似这种代码的: ```swift // Error,含有 associatedtype 的 protocol View 只能作为类型约束使用 struct ContentView: View { var body: View { Text("Hello World") } } ``` 想要 Swift 帮助自动推断出 `View.Body` 的类型的话,我们需要明确地指出 `body` 的真正的类型。在这里,`body` 的实际类型是 `Text`: ```swift struct ContentView: View { var body: Text { Text("Hello World") } } ``` 当然我们可以明确指定出 `body` 的类型,但是这带来一些麻烦: 1. 每次修改 `body` 的返回时我们都需要手动去更改相应的类型。 2. 新建一个 `View` 的时候,我们都需要去考虑会是什么类型。 3. 其实我们只关心返回的是不是一个 `View`,而对实际上它是什么类型并不感兴趣。 `some View` 这种写法使用了 Swift 5.1 的 [Opaque return types 特性](https://github.com/apple/swift-evolution/blob/master/proposals/0244-opaque-result-types.md)。它向编译器作出保证,每次 `body` 得到的一定是某一个确定的,遵守 `View` 协议的类型,但是请编译器“网开一面”,不要再细究具体的类型。返回类型**确定单一**这个条件十分重要,比如,下面的代码也是无法通过的: ```swift let someCondition: Bool // Error: Function declares an opaque return type, // but the return statements in its body do not have // matching underlying types. var body: some View { if someCondition { // 这个分支返回 Text return Text("Hello World") } else { // 这个分支返回 Button,和 if 分支的类型不统一 return Button(action: {}) { Text("Tap me") } } } ``` 这是一个编译期间的特性,在保证 associatedtype protocol 的功能的前提下,使用 `some` 可以抹消具体的类型。这个特性用在 SwiftUI 上简化了书写难度,让不同 `View` 声明的语法上更加统一。 #### [Section 2 - Step 1: 预览 SwiftUI](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views#customize-the-text-view) SwiftUI 的 Preview 是 Apple 用来对标 RN 或者 Flutter 的 Hot Reloading 的开发工具。由于 IBDesignable 的性能上的惨痛教训,而且得益于 SwiftUI 经由 UIKit 的跨 Apple 平台的特性,Apple 这次选择了直接在 macOS 上进行渲染。因此,你需要使用搭载有 SwiftUI.framework 的 macOS 10.15 才能够看到 Xcode Previews 界面。 Xcode 将对代码进行静态分析 (得益于 [SwiftSyntax 框架](https://github.com/apple/swift-syntax)),找到所有遵守 `PreviewProvider` 协议的类型进行预览渲染。另外,你可以为这些预览提供合适的数据,这甚至可以让整个界面开发流程不需要实际运行 app 就能进行。 笔者自己尝试下来,这套开发方式带来的效率提升相比 Hot Reloading 要更大。Hot Reloading 需要你有一个大致界面和准备相应数据,然后运行 app,停在要开发的界面,再进行调整。如果数据状态发生变化,你还需要 restart app 才能反应。SwiftUI 的 Preview 相比起来,不需要运行 app 并且可以提供任何的 dummy 数据,在开发效率上更胜一筹。 经过短短一天的使用,Option + Command + P 这个刷新 preview 的快捷键已经深入到我的肌肉记忆中了。 #### [Section 3 - Step 5: 关于 ViewBuilder](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views#combine-views-using-stacks) 创建 Stack 的语法很有趣: ```swift VStack(alignment: .leading) { Text("Turtle Rock") .font(.title) Text("Joshua Tree National Park") .font(.subheadline) } ``` 一开始看起来好像我们给出了两个 `Text`,似乎是构成的是一个类似数组形式的 `[View]`,但实际上并不是这么一回事。这里调用了 `VStack` 类型的初始化方法: ```swift public struct VStack where Content : View { init( alignment: HorizontalAlignment = .center, spacing: Length? = nil, content: () -> Content) } ``` 前面的 `alignment` 和 `spacing` 没啥好说,最后一个 `content` 比较有意思。看签名的话,它是一个 `() -> Content` 类型,但是我们在创建这个 `VStack` 时所提供的代码只是简单列举了两个 `Text`,而并没有实际返回一个可用的 `Content`。 这里使用了 Swift 5.1 的另一个新特性:[Funtion builders](https://github.com/apple/swift-evolution/blob/9992cf3c11c2d5e0ea20bee98657d93902d5b174/proposals/XXXX-function-builders.md)。如果你实际观察 `VStack` 的[这个初始化方法的签名](https://developer.apple.com/documentation/swiftui/vstack/3278367-init),会发现 `content` 前面其实有一个 `@ViewBuilder` 标记: ```swift init( alignment: HorizontalAlignment = .center, spacing: Length? = nil, @ViewBuilder content: () -> Content) ``` 而 `ViewBuilder` 则是一个由 `@_functionBuilder` 进行标记的 struct: ```swift @_functionBuilder public struct ViewBuilder { /* */ } ``` 使用 `@_functionBuilder` 进行标记的类型 (这里的 `ViewBuilder`),可以被用来对其他内容进行标记 (这里用 `@ViewBuilder` 对 `content` 进行标记)。被用 function builder 标记过的 `ViewBuilder` 标记以后,`content` 这个输入的 function 在被使用前,会按照 `ViewBuilder` 中合适的 `buildBlock` [进行 build](https://github.com/apple/swift-evolution/blob/9992cf3c11c2d5e0ea20bee98657d93902d5b174/proposals/XXXX-function-builders.md#function-building-methods) 后再使用。如果你阅读 `ViewBuilder` 的[文档](https://developer.apple.com/documentation/swiftui/viewbuilder),会发现有很多接受不同个数参数的 `buildBlock` 方法,它们将负责把闭包中一一列举的 `Text` 和其他可能的 `View` 转换为一个 `TupleView`,并返回。由此,`content` 的签名 `() -> Content` 可以得到满足。 实际上构建这个 `VStack` 的代码会被转换为类似下面这样: ```swift // 等效伪代码,不能实际编译。 VStack(alignment: .leading) { viewBuilder -> Content in let text1 = Text("Turtle Rock").font(.title) let text2 = Text("Joshua Tree National Park").font(.subheadline) return viewBuilder.buildBlock(text1, text2) } ``` 当然这种基于 funtion builder 的方式是有一定限制的。比如 `ViewBuilder` 就只实现了最多[十个参数](https://developer.apple.com/documentation/swiftui/viewbuilder/3278693-buildblock)的 `buildBlock`,因此如果你在一个 `VStack` 中放超过十个 `View` 的话,编译器就会不太高兴。不过对于正常的 UI 构建,十个参数应该足够了。如果还不行的话,你也可以考虑直接使用 `TupleView` 来用多元组的方式合并 `View`: ```swift TupleView<(Text, Text)>( (Text("Hello"), Text("Hello")) ) ``` 除了按顺序接受和构建 `View` 的 `buildBlock` 以外,`ViewBuilder` 还实现了两个特殊的方法:`buildEither` 和 `buildIf`。它们分别对应 block 中的 `if...else` 的语法和 `if` 的语法。也就是说,你可以在 `VStack` 里写这样的代码: ```swift var someCondition: Bool VStack(alignment: .leading) { Text("Turtle Rock") .font(.title) Text("Joshua Tree National Park") .font(.subheadline) if someCondition { Text("Condition") } else { Text("Not Condition") } } ``` 其他的命令式的代码在 `VStack` 的 `content` 闭包里是不被接受的,下面这样也不行: ```swift VStack(alignment: .leading) { // let 语句无法通过 function builder 创建合适的输出 let someCondition = model.condition if someCondition { Text("Condition") } else { Text("Not Condition") } } ``` 到目前为止,只有以下三种写法能被接受 (有可能随着 SwiftUI 的发展出现别的可接受写法): * 结果为 `View` 的语句 * `if` 语句 * `if...else...` 语句 #### [Section 4 - Step 7: 链式调用修改 View 的属性](https://developer.apple.com/tutorials/swiftui/creating-and-combining-views#create-a-custom-image-view) 教程到这一步的话,相信大家已经对 SwiftUI 的超强表达能力有所感悟了。 ```swift var body: some View { Image("turtlerock") .clipShape(Circle()) .overlay( Circle().stroke(Color.white, lineWidth: 4)) .shadow(radius: 10) } ``` 可以试想一下,在 UIKit 中要动手撸一个这个效果的困难程度。我大概可以保证,99% 的开发者很难在不借助文档或者 copy paste 的前提下完成这些事情,但是在 SwiftUI 中简直信手拈来。在创建 `View` 之后,用链式调用的方式,可以将 `View` 转换为一个含有变更后内容的对象。这么说比较抽象,我们可以来看一个具体的例子。比如简化一下上面的代码: ```swift let image: Image = Image("turtlerock") let modified: _ModifiedContent = image.shadow(radius: 10) ``` `image` 通过一个 `.shadow` 的 modifier,`modified` 变量的类型将转变为 `_ModifiedContent`。如果你查看 `View` 上的 `shadow` 的定义,它是这样的: ```swift extension View { func shadow( color: Color = Color(.sRGBLinear, white: 0, opacity: 0.33), radius: Length, x: Length = 0, y: Length = 0) -> Self.Modified<_ShadowEffect> } ``` `Modified` 是 `View` 上的一个 typealias,在 `struct Image: View` 的实现里,我们有: ```swift public typealias Modified = _ModifiedContent ``` `_ModifiedContent` 是一个 SwiftUI 的私有类型,它存储了待变更的内容,以及用来实施变更的 `Modifier`: ```swift struct _ModifiedContent { var content: Content var modifier: Modifier } ``` 在 `Content` 遵守 `View`,`Modifier` 遵守 `ViewModifier` 的情况下,`_ModifiedContent` 也将遵守 `View`,这是我们能够通过 `View` 的各个 modifier extension 进行链式调用的基础: ```swift extension _ModifiedContent : _View where Content : View, Modifier : ViewModifier { } ``` 在 `shadow` 的例子中,SwiftUI 内部会使用 `_ShadowEffect` 这个 `ViewModifier`,并把 `image` 自身和 `_ShadowEffect` 实例存放到 `_ModifiedContent` 里。不论是 image 还是 modifier,都只是对未来实际视图的描述,而不是直接对渲染进行的操作。在最终渲染前,`ViewModifier` 的 `body(content: Self.Content) -> Self.Body` 将被调用,以给出最终渲染层所需要的各个属性。 > 更具体来说,`_ShadowEffect` 是一个满足 [`EnvironmentalModifier` 协议](https://developer.apple.com/documentation/swiftui/environmentalmodifier)的类型,这个协议要求在使用前根据使用环境将自身解析为具体的 modifier。 其他的几个修改 View 属性的链式调用与 `shadow` 的原理几乎一致。 ## 小结 上面是对 SwiftUI 教程的第一部分进行的一些说明,在之后的一篇文章里,我会对剩余的几个教程中有意思的部分再做些解释。 虽然公开还只有一天,但是 SwiftUI 已经经常被用来和 Flutter 等框架进行比较。试用下来,在 view 的描述表现力上和与 app 的结合方面,SwiftUI 要胜过 Flutter 和 Dart 的组合很多。Swift 虽然开源了,但是 Apple 对它的掌控并没有减弱。Swift 5.1 的很多特性几乎可以说都是为了 SwiftUI 量身定制的,我们已经在本文中看到了一些例子,比如 Opaque return types 和 Function builder 等。在接下来对后面几个教程的解读中,我们还会看到更多这方面的内容。 另外,Apple 在背后使用 Combine.framework 这个响应式编程框架来对 SwiftUI.framework 进行驱动和数据绑定,相比于现有的 RxSwift/RxCocoa 或者是 ReactiveSwift 的方案来说,得到了语言和编译器层级的大力支持。如果有机会,我想我也会对这方面的内容进行一些探索和介绍。 URL: https://onevcat.com/2019/02/swift-abi/index.html.md Published At: 2019-02-21 10:28:00 +0900 # Swift ABI 稳定对我们到底意味着什么 Swift 社区最近最重大的新闻应该就是 ABI 稳定了。这个话题虽然已经讨论了有一阵子了,但随着 Xcode 10.2 beta 的迭代和 Swift 5 的 release 被提上日程,最终 Swift ABI 稳定能做到什么程度,我们开发者能做些什么,需要做些什么,就变成了一个重要的话题。Apple 在这个月接连发布了 [ABI Stability and More](https://swift.org/blog/abi-stability-and-more/) 和 [Evolving Swift On Apple Platforms After ABI Stability](https://swift.org/blog/abi-stability-and-apple/) 两篇文章来阐述 Swift 5 发布以后 ABI 相关的内容所带来的改变。虽然原文不是很长,但是有些地方上下文没有说太清楚,可能不太容易理解。本文希望对这个话题以问答的形式进行一些总结,让大家能更明白将要发生的事情。 ### 我是一个 app 开发者,Swift 5 发布以后会怎么样? 简单说,安装 Xcode 10.2,然后**正常迁移**就可以了,和以往 Swift 3 到 Swift 4 需要做的事情差不多。单论 Swift 5 这个版本,不会对你的开发造成什么影响,直到下一个版本 (比如 Swift 5.1) 之前,你几乎不需要关心 ABI 稳定这件事。关于下个 Swift 版本,我们稍后会提到这件事情。 ### 我还是想知道什么是 ABI 稳定? 就是 binary 接口稳定,也就是在运行的时候只要是用 Swift 5 (或以上) 的编译器编译出来的 binary,就可以跑在任意的 Swift 5 (或以上) 的 runtime 上。这样,我们就不需要像以往那样在 app 里放一个 Swift runtime 了,Apple 会把它弄到 iOS 和 macOS 系统里。 ### 所以说 app 尺寸会变小? **是的**,但是这是 Apple 通过 App Thinning 帮我们完成的,不需要你操心。在提交 app 时,Apple 将会按照 iOS 系统创建不同的下载包。对于 iOS 12.2 的系统,因为它们预装了 Swift 5 的 runtime,所以不再需要 Swift 的库,它们会被从 app bundle 中删掉。对于 iOS 12.2 以下的系统,外甥打灯笼,照旧。 一个新创建的空 app,针对 **iOS 12.2 打包出来压缩后的下载大小是 26KB**,**而对 iOS 12.0 则是 2.4MB**。如果你使用了很多标准库里的东西,那这个差距会更大 (因为没有用到的标准库的符号会被 strip 掉),对于一个比较有规模的 app 来说,一般可以减小 10M 左右的体积。 ### 还有什么其他好处么? 因为系统集成了 Swift,所以大家都用同一个 Swift 了,app 启动的时候也就不需要额外加载 Swift,所以在新系统上会更快更省内存。当然啦,只是针对新系统。 另外,对于 Apple 的工程师来说,他们终于能在系统的框架里使用 Swift 了。这样一来,很多东西就不必通过 Objective-C wrap 一遍,这会让代码运行效率提高很多。虽然在 iOS 12.2 中应该还没有 Swift 编写的框架,但是我们也许能在不久的将来看到 Swift 被 Apple 自己所使用。等今年 WWDC 的消息吧。 ### 我还想用一段时间的 Xcode 10.1,不太想这么快升级 Xcode 10.1 里的是 Swift 4.2 的编译器,出来的 binary 不是 ABI 稳定的,而且必定打包了 Swift runtime。新的系统发现 app 包中有 Swift runtime 后,就会选择不去使用系统本身的 Swift runtime。这种情况下一切保持和现在不变。旧版本的 Xcode 只有旧版本的 iOS SDK,所以自然你也没有办法用到新系统的 Swift 写的框架,系统肯定不需要在同一个进程中跑两个 Swift runtime。 简单说,你还可以一直使用 Xcode 10.1 直到 Apple 不再接受它打包的 app。不过这样的话,你不能使用新版本 Swift 的任何特性,也不能从 ABI 稳定中获得任何好处。 ### 我升级了 Xcode 10.2,但是还想用 Swift 4 的兼容模式,会怎么样? 首先你需要弄清楚 Swift 的**编译器版本**和**语言兼容版本**的区别: | 编译器版本 | 语言兼容版本 | 对应的 Xcode 版本 | | -------------- | ------------------- | ---------------------- | | Swift 5.0 | Swift 5.0, 4.2, 4.0 | Xcode 10.2 | | Swift 4.2 | Swift 4.2, 4.0, 3.0 | Xcode 10.0, Xcode 10.1 | | 更多历史版本 … | | | 同一个 Xcode 版本默认使用的编译器版本只有一个 (在你不更换 toolchain 的前提下),当我们在说到“使用 Xcode10.2 的 Swift 4 兼容模式”时,我们其实指的是,使用 Xcode 10.2 搭载的 Swift 5.0 版本的编译器,它提供了 4.2 的语法兼容,可以让我们不加修改地编译 Swift 4.2 的代码。即使你在 Xcode 10.2 中选择语言为 Swift 4,你所得到的二进制依然是 ABI 稳定的。ABI 和你的语言是 Swift 4 还是 Swift 5 无关,只和你的编译器版本,或者说 Xcode 版本有关。 > 多提一句,即使你选择了 Swift 4 的语言兼容,只要编译器版本 (当然,以及对应的标准库版本) 是 5.0 以上,你依然可以使用 Swift 5 的语法特性 (比如新增加的类型等)。 ### 看起来 ABI 稳定很美好,那么代价呢? Good question! 我们在第一个问题里就提到过,一切都会很美好,直到下一个版本。因为 Swift runtime 现在被放到 iOS 系统里了,所以想要升级就没那么容易了。 在 ABI 稳定之前,Swift runtime 是作为开发工具的一部分,被作为库打包到 app 中的。这样一来,在开发时,我们可以随意使用新版本 Swift 的类型或特性,因为它们的版本是开发者自己决定的。不过,当 ABI 稳定后,Swift runtime 变为了用户系统的一部分,它从开发工具,变为了运行的环境,不再由我们开发者唯一决定。比如说,对应 iOS 13 的 Swift 6 的标准库中添加了某个类型 `A`,但是在 iOS 12.2 这个只搭载了 Swift 5 的系统中,并没有这个类型。这意味着我们需要在使用 Swift 的时候考虑设备兼容的问题:如果你需要兼容那些搭载了旧版本 Swift 的系统,那你将无法在代码里使用新版本的 Swift runtime 特性。 这和我们一直以来适配新系统的 API 时候的情况差不多,在 Swift 5 以后,我们需要等到 deploy target 升级到对应的版本,才能开始使用对应的 Swift 特性。这意味着,我们可能会需要写一些这样的兼容代码: ```swift // 假如 Swift 6.0 是 iOS 13.0 的 Swift 版本 if #available(iOS 13.0, *) { // Swift 6.0 标准库中存在 A let a = A() } else { // 不存在 A 时的处理 } ``` 对于“新添加的某个类型”这种程度的兼容,我们可以用上面的方式处理。但是对于更靠近语言层面的一些东西 (比如现在已有的 `Codable` 这样的特性),恐怕适配起来就没有那么简单了。在未来,Deployment target 可能会和 Swift 语言版本挂钩,新的语言特性出现后,我们可能需要等待一段时间才能实际用上。而除了那些纯编译期间的内容外,任何与 Swift runtime 和标准库有关的特性,都会要遵守这个规则。 ### 可以像现在一样打包新版本的 Swift runtime 到 app 里,然后指定用打包的 Swift 版本么 不能,对于包含有 Swift runtime 和标准库的系统,如果运行的 binary 是 ABI 稳定的,那么就必须使用系统提供的 Swift。这里的主要原因是,Apple 想要保留使用 Swift 来实现系统框架的可能性: 1. 如果允许两个 Swift runtime (系统自带,以及 app 打包的),那么这两个运行时将无法互相访问,app 也无法与系统的 Swift 框架或者第三方的 ABI 稳定的框架进行交互。 2. 如果允许完全替换 Swift runtime,系统的 Swift 框架将执行用户提供的 Swift 标准库中的代码,这将造成重大的安全隐患。 ### 有任何可能性让我能无视系统版本,去使用 Swift 的新特性么 有,但是相对麻烦,很大程度上也依赖 Apple 是否愿意支持。如果你还记得 iOS 5.0 引入 ARC 时,Apple 为了让 iOS 4.3 和之前的系统也能使用 ARC 的代码,在 deployment target 选到 iOS 4.3 或之前时,会用 static link 的方式打包一个叫做 `libarclite` 的库,其中包含了 ARC 所需要的一些 runtime 方法。对于 ABI 稳定后的 Swift,也许可以采用类似做法,来提供兼容。 > 这种做法在感觉上和 Android 的 [Support Library Packages](https://developer.android.com/topic/libraries/support-library/packages) 的方式类似,但是 Apple 似乎不是很倾向于提供这样的官方支持。所以之后要看有没有机会依靠社区力量来提供 Swift 的兼容支持了。 不能第一时间用上新的语言特性,必然会打击大家进行适配和使用新特性的积极性,也势必会影响到语言的发展和快速迭代,可以说这一限制是相当不利的。 所以,对于一般的 app 开发者来说,ABI 稳定其实就是一场博弈:你现在有更小的 app 尺寸,但是却被限制了无法使用最新的语言特性,除非你提升 app 的 depolyment target。 ### 我是框架开发者,ABI 稳定后我可以用 binary 形式来发布了么? > #### 2019-12-17 更新: > > 从 Swift 5.1 (Xcode 11) 开始,Apple 提供了 XCFramework 支持,来让开发者以二进制的形式发布框架。现在 Swift 已经达到了 module stability。 还不能。ABI 稳定是使用 binary 发布框架的必要非充分条件。框架的 binary 在不同的 runtime 是兼容了,但是作为框架,现在是依靠一个 `.swiftmodule` 的二进制文件来描述 API Interface 的,这个二进制文件中包含了序列化后的 AST (更准确说,是 interface 的 SIL),以及编译这个 module 时的平台环境 (Swift 编译器版本等)。 ABI 稳定并不意味着编译工具链的稳定,对于框架来说,想要用 binary 的方式提供框架,除了 binary 本身稳定以外,还需要描述 binary 的方式 (也就是现在的 swiftmodule) 也稳定,而这正在开发中。将来,Swift 将为 module 提供文本形式的 `.swiftinterface` 作为框架 API 描述,然后让未来的编译器根据这个描述去“编译”出对应的 `.swiftmodule` 作为缓存并使用。 这一目标被称为 module stability,当达到 module stability 后,你就可以使用 binary 来发布框架了。 ### 能总结一下 ABI 稳定,或者展望一下未来么? ABI 稳定最大的受益者应该是 Apple,这让 Apple 在自己的生态系统中,特别是系统框架中,可以使用 Swift 来进行实现。在我看来,Swift ABI 稳定为 Apple 开发平台的一场革命奠定了基础。在接下来的几年里,如果你还想要关注 Apple 平台,可能下面几件事情会特别重要: 1. Apple 什么时候发布第一个 Swift 写的系统框架 2. Apple 什么时候开始提供第一个 Swift only 的 API 3. Apple 什么时候开始“锁定” Objective-C 的 SDK,不再为它增加新的 API 4. Apple 什么时候开始用 Swift 特性更新现有的 Objective-C SDK 这些事情也许会在未来几年陆续发生。面对微软从 Win32 API 向 .Net 一路迁移,到今天的 UWP (Universal Windows Platform),Google 来势汹汹的 Fuchsia 和 Dart,Swift 是 Apple 唯一能与它们抗衡的答案。相比于微软提供的泛型和并发编程模型,Google 的 Flutter 的跨平台的先天优势,Apple 平台基于 Objective-C 的 API 的易用性已然被抛开很远。虽然 Apple 在 2014 年承诺过依然维护 Objective-C,但是经过 Swift 这五年的发展,随着 Swift ABI 的稳定,什么时候如果 Objective-C 成为了继续发展的阻碍,相信 Apple 已经有足够的理由将它抛弃。 作为 Apple 平台的从业者,我们也许正处在另一个时代变革的开端。 > #### 2019-12-17 更新: > > 上面几件事情中,1 和 2 已经完全实现:Apple 在 WWDC 2019 上高调宣布了一系列 Swift written 和 Swift only 的框架,包括 Combine 和 SwiftUI 等。iOS Swift 开发的新纪元已经开始了。 > #### 2021-06-10 更新: > > WWDC 2021 公布了 Swift 并发编程的相关特性。很不幸的是,由于 ABI 的决策,这些重要的,可以说是革命性的特性只能在 iOS 15 或之后的版本中使用。 > 开发社区已经为这个问题吵起来,大家纷纷开始羡慕隔壁 Kotlin 的小伙伴能把 async/await 支持到 Android 5。 > 现在论坛开始[删帖+锁帖](https://forums.swift.org/t/will-swift-concurrency-deploy-back-to-older-oss/49370/58)了。 > 让我们看看开发者们有没有可能上演一波年度大戏,倒逼 Apple 改变之前关于 ABI 的决策。不过就算想要向前兼容,大概也会非常麻烦并且有很大风险。个人不是很看好。 URL: https://onevcat.com/2018/12/jose-3/index.html.md Published At: 2018-12-07 10:28:00 +0900 # 与 JOSE 战斗的日子 - 写给 iOS 开发者的密码学入门手册 (实践) ![](/assets/images/2018/matrix.jpg) ## 概述 这是关于 JOSE 和密码学的三篇系列文章中的最后一篇,你可以在下面的链接中找到其他部分: 1. [基础 - 什么是 JWT 以及 JOSE](/2018/12/jose-1/) 2. [理论 - JOSE 中的签名和验证流程](/2018/12/jose-2/) 3. 实践 - 如何使用 Security.framework 处理 JOSE 中的验证 (本文) 这一篇中,我们会在 JOSE 基础篇和理论篇的知识架构上,使用 iOS (或者说 Cocoa) 的相关框架来完成对 JWT 的解析,并利用 JWK 对它的签名进行验证。在最后,我会给出一些我自己在实现和学习这些内容时的思考,并把一些相关工具和标准列举一下。 ## 解码 JWT JWT,或者更精确一点,JWS 中的 Header 和 Payload 都是 Base64Url 编码的。为了获取原文内容,先需要对 Header 和 Payload 解码。 ### Base64Url Base64 相信大家都已经很熟悉了,随着网络普及,这套编码有一个很大的“缺点”,就是使用了 `+`,`/` 和 `=`。这些字符在 URL 里是很不友好的,在作为传输时需要额外做 escaping。Base64Url 就是针对这个问题的改进,具体来说就是: 1. 将 `+` 替换为 `-`; 2. 将 `/` 替换为 `_`; 3. 将末尾的 `=` 干掉。 相关代码的话非常简单,为 `Data` 和 `String` 分别添加 extension 来相互转换就好: ```swift extension Data { // Encode `self` with URL escaping considered. var base64URLEncoded: String { let base64Encoded = base64EncodedString() return base64Encoded .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } } extension String { // Returns the data of `self` (which is a base64 string), with URL related characters decoded. var base64URLDecoded: Data? { let paddingLength = 4 - count % 4 // Filling = for %4 padding. let padding = (paddingLength < 4) ? String(repeating: "=", count: paddingLength) : "" let base64EncodedString = self .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") + padding return Data(base64Encoded: base64EncodedString) } } ``` ### 结合使用 `JSONDecoder` 和 Base64Url 来处理 JWT 因为 JWT 的 Header 和 Payload 部分实际上是有效的 JSON,为了简单,我们可以利用 Swift 的 Codable 来解析 JWT。为了简化处理,可以封装一个针对以 Base64Url 表示的 JSON 的 decoder: ```swift class Base64URLJSONDecoder: JSONDecoder { override func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable { guard let string = String(data: data, encoding: .ascii) else { // 错误处理 } return try decode(type, from: string) } func decode(_ type: T.Type, from string: String) throws -> T where T : Decodable { guard let decodedData = string.base64URLDecoded else { // 错误处理 } return try super.decode(type, from: decodedData) } } ``` `Base64URLJSONDecoder` 将 Base64Url 的转换封装到解码过程中,这样一来,我们只需要获取 JWT,将它用 `.` 分割开,然后使用 `Base64URLJSONDecoder` 就能把 Header 和 Payload 轻易转换了,比如: ```swift struct Header: Codable { let algorithm: String let tokenType: String? let keyID: String? enum CodingKeys: String, CodingKey { case algorithm = "alg" case tokenType = "typ" case keyID = "kid" } } let jwtRaw = "eyJhbGciOiJSUzI1NiI..." // JWT 字符串,后面部分省略了 let rawComponents = text.components(separatedBy: ".") let decoder = Base64JSONDecoder() let header = try decoder.decode(Header.self, from: rawComponents[0]) guard let keyID = header.keyID else { /* 验证失败 */ } ``` 在 Header 中,我们应该可以找到指定了验证签名所需要使用的公钥的 `keyID`。如果没有的话,验证失败,登录过程终止。 对于签名,我们将解码后的原始的 `Data` 保存下来,稍后使用。同样地,我们最好也保存一下 `{Header}.{Payload}` 的部分,它在验证中也会被使用到: ```swift let signature = rawComponents[2].base64URLDecoded! let plainText = "\(rawComponents[0]).\(rawComponents[1])" ``` > 这里的代码基本都没有考虑错误处理,大部分是直接让程序崩溃。实际的产品中验证签名过程中的错误应该被恰当处理,而不是粗暴挂掉。 ## 在 Security.framework 中处理签名 我们已经准备好签名的数据和原文了,万事俱备,只欠密钥。 ### 处理密钥 通过 `keyID`,在预先设定的 JWT Host 中我们应该可以找到以 JWK 形式表示的密钥。我们计划使用 Security.framework 来处理密钥和签名验证,首先要做的就是遵守框架和 JWA 的规范,通过 JWK 的密钥生成 Security 框架喜欢的 `SecKey` 值。 在其他大部分情况下,我们可能会从一个证书 (certificate,不管是从网络下载的 PEM 还是存储在本地的证书文件) 里获取公钥。像是处理 HTTPS challenge 或者 SSL Pinning 的时候,大部分情况下我们拿到的是完整的证书数据,通过 `SecCertificateCreateWithData` 使用 DER 编码的数据创建证书并获取公钥: ```swift guard let cert = SecCertificateCreateWithData(nil, data as CFData) else { // 错误处理 return } let policy = SecPolicyCreateBasicX509() var trust: SecTrust? = nil SecTrustCreateWithCertificates(cert, policy, &trust) guard let t = trust, let key: SecKey = SecTrustCopyPublicKey(t) else { // 错误处理 return } print(key) ``` 但是,在 JWK 的场合,我们是没有 X.509 证书的。JWK 直接将密钥类型和参数编码在 JSON 中,我们当然可以按照 DER 编码规则将这些信息编码回一个符合 X.509 要求的证书,然后使用上面的方法再从中获取证书。不过这显然是画蛇添足,我们完全可以直接通过这些参数,使用特定格式的数据来直接生成 `SecKey`。 > 有可能有同学会迷惑于“公钥”和“证书”这两个概念。一个证书,除了包含有公钥以外,还包含有像是证书发行者,证书目的,以及其他一些元数据的信息。因此,我们可以从一个证书中,提取它所存储的公钥。 > > 另外,证书本身一般会由另外一个私钥进行签名,并由颁发机构或者受信任的机构进行验证保证其真实性。 使用 [`SecKeyCreateWithData`](https://developer.apple.com/documentation/security/1643701-seckeycreatewithdata) 就可以直接通过公钥参数来生成了: ```swift func SecKeyCreateWithData(_ keyData: CFData, _ attributes: CFDictionary, _ error: UnsafeMutablePointer?>?) -> SecKey? ``` 第二个参数 `attributes` 需要的是密钥种类 (RSA 还是 EC),密钥类型 (公钥还是私钥),密钥尺寸 (数据 bit 数) 等信息,比较简单。 关于所需要的数据格式,根据密钥种类不同,而有所区别。在[这个风马牛不相及的页面](https://developer.apple.com/documentation/security/1643698-seckeycopyexternalrepresentation) 以及 [SecKey 源码](https://opensource.apple.com/source/Security/Security-58286.41.2/keychain/SecKey.h) 的注释中有所提及: > The method returns data in the PKCS #1 format for an RSA key. For an elliptic curve public key, > the format follows the ANSI X9.63 standard using a byte string of 04 || X || Y. ... All of > these representations use constant size integers, including leading zeros as needed. > The requested data format depend on the type of key (kSecAttrKeyType) being created: > > ``` > kSecAttrKeyTypeRSA PKCS#1 format, public key can be also in x509 public key format > kSecAttrKeyTypeECSECPrimeRandom ANSI X9.63 format (04 || X || Y [ || K]) > ``` #### JWA - RSA 简单说,RSA 的公钥需要遵守 PKCS#1,使用 X.509 编码即可。所以对于 RSA 的 JWK 里的 `n` 和 `e`,我们用 DER 按照 X.509 编码成序列后,就可以扔给 Security 框架了: ```swift extension JWK { struct RSA { let modulus: String let exponent: String } } let jwk: JWK.RSA = ... guard let n = jwk.modulus.base64URLDecoded else { ... } guard let e = jwk.exponent.base64URLDecoded else { ... } var modulusBytes = [UInt8](n) if let firstByte = modulusBytes.first, firstByte >= 0x80 { modulusBytes.insert(0x00, at: 0) } let exponentBytes = [UInt8](e) let modulusEncoded = modulusBytes.encode(as: .integer) let exponentEncoded = exponentBytes.encode(as: .integer) let sequenceEncoded = (modulusEncoded + exponentEncoded).encode(as: .sequence) let data = Data(bytes: sequenceEncoded) ``` > 关于 DER 编码部分的代码,可以在[这里](https://github.com/line/line-sdk-ios-swift/blob/8c2476d9d00225cf4b33c0e245e9bd580c59f4d8/LineSDK/LineSDK/Crypto/JWK/JWA.swift#L185-L240)找到。对于 `modulusBytes`,首位大于等于 `0x80` 时需要追加 `0x00` 的原因,也已经在[第一篇](/2018/jose-1/)中提及。如果你不知道我在说什么,建议回头仔细再看一下前两篇的内容。 使用上面的 `data` 就可以获取 RSA 的公钥了: ```swift let sizeInBits = data.count * MemoryLayout.size let attributes: [CFString: Any] = [ kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeyClass: kSecAttrKeyClassPublic, kSecAttrKeySizeInBits: NSNumber(value: sizeInBits) ] var error: Unmanaged? guard let key = SecKeyCreateWithData(data as CFData, attributes as CFDictionary, &error) else { // 错误处理 } print(key) // 一切正常的话,打印类似这样: // ``` #### JWA - ECSDA 按照说明,对于 EC 公钥,期望的数据是符合 X9.63 中未压缩的椭圆曲线点座标:`04 || X || Y`。不过,虽然在文档说明里提及: > All of these representations use constant size integers, including leading zeros as needed. 但事实是 `SecKeyCreateWithData` 并不喜欢在首位追加 `0x00` 的做法。这里的 `X` 和 `Y` **必须**是满足椭圆曲线对应要求的密钥位数的整数值,如果在首位大于等于 `0x80` 的值前面追加 `0x00`,反而会导致无法创建 `SecKey`。所以,在组织数据时,不仅不需要添加 `0x00`,我们反而最好检查一下获取的 JWK,如果首位有不必要的 `0x00` 的话,应该将其去除: ```swift extension JWK { struct RSA { let x: String let y: String } } let jwk: JWK.RSA = ... guard let decodedXData = jwk.x.base64URLDecoded else { ... } guard let decodedYData = jwk.y.base64URLDecoded else { ... } let xBytes: [UInt8] if decodedXData.count == curve.coordinateOctetLength { xBytes = [UInt8](decodedXData) } else { xBytes = [UInt8](decodedXData).dropFirst { $0 == 0x00 } } let yBytes: [UInt8] if decodedYData.count == curve.coordinateOctetLength { yBytes = [UInt8](decodedYData) } else { yBytes = [UInt8](decodedYData).dropFirst { $0 == 0x00 } } let uncompressedIndicator: [UInt8] = [0x04] let data = Data(bytes: uncompressedIndicator + xBytes + yBytes) ``` 创建公钥时和 RSA 类似: ```swift let sizeInBits = data.count * MemoryLayout.size let attributes: [CFString: Any] = [ kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, kSecAttrKeyClass: kSecAttrKeyClassPublic, kSecAttrKeySizeInBits: NSNumber(value: sizeInBits) ] var error: Unmanaged? guard let key = SecKeyCreateWithData(data as CFData, attributes as CFDictionary, &error) else { // 错误处理 } print(key) // 一切正常的话,打印类似这样: // ``` ### 验证签名 Security 框架中为使用公钥进行签名验证准备了一个方法:[`SecKeyVerifySignature`](https://developer.apple.com/documentation/security/1643715-seckeyverifysignature): ```swift func SecKeyVerifySignature(_ key: SecKey, _ algorithm: SecKeyAlgorithm, _ signedData: CFData, _ signature: CFData, _ error: UnsafeMutablePointer?>?) -> Bool ``` `key` 我们已经拿到了,`signedData` 就是之前我们准备的 `{Header}.{Payload}` 的字符串的数据表示 (也就是 `plainText.data(using: .ascii)`。注意,这里的 `plainText` 不是一个 Base64Url 字符串,JWS 签名所针对的就是这个拼凑后的字符串的散列值)。我们需要为不同的签名算法指定合适的 `SecKeyAlgorithm`,通过访问 `SecKeyAlgorithm` 的静态成员,就可以获取 Security 框架预先定义的算法了。比如常用的: ``` let ecdsa256 = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256 let rsa256 = SecKeyAlgorithm.rsaSignatureDigestPKCS1v15SHA256 ``` 你可以在 Apple 的[文档里](https://developer.apple.com/documentation/security/seckeyalgorithm)找到所有支持的算法的定义,但是不幸的是,这些算法都只有名字,没有具体说明,也没有使用范例。想要具体知道某个算法的用法,可能需要在[源码级别](https://opensource.apple.com/source/Security/Security-57740.51.3/keychain/SecKey.h)去参考注释。为了方便,对于签名验证相关的一些常用算法,我列了一个表说明对应关系: | 算法 | 输入数据 (signedData) | 签名 (signature) | 对应 JWT 算法 | | ------------------------------------ | ------------- | ----------- | ------------------------------------ | | rsaSignatureDigestPKCS1v15SHA{x} | 原数据的 SHA-x 摘要 | PKCS#1 v1.5 padding 的签名 | RS{x} | | rsaSignatureMessagePKCS1v15SHA{x} | 原数据本身,框架负责计算 SHA-x 摘要 | PKCS#1 v1.5 padding 的签名 | RS{x} | | rsaSignatureDigestPSSSHA{x} | 原数据的 SHA-x 摘要 | 使用 PSS 的 PKCS#1 v2.1 签名 | PS{x} | | rsaSignatureMessagePSSSHA{x} | 原数据本身,框架负责计算 SHA-x 摘要 | 使用 PSS 的 PKCS#1 v2.1 签名 | PS{x} | | ecdsaSignatureDigestX962SHA{x} | 原数据的 SHA-x 摘要 | DER x9.62 编码的 r 和 s | ES{x} | | ecdsaSignatureMessageX962SHA{x} | 原数据本身,框架负责计算 SHA-x 摘要 | DER x9.62 编码的 r 和 s | ES{x} | 不难看出,这些签名算法基本就是 `{算法类型} + {数据处理方式} + {签名格式}` 的组合。另外还有一些更为泛用的签名算法,像是 `.ecdsaSignatureRFC4754` 或者 `.rsaSignatureRaw`,你需要按照源码注释给入合适的输入,不过一般来说还是直接使用预设的散列的 `__Message__SHA___` 这类算法最为方便。 > `SecKeyAlgorithm` 中除了签名算法,也包括了使用 RSA 和 EC 进行加密的相关算法。整体上和签名算法的命名方式类似,有兴趣和需要相关内容的同学可以自行研究。 对于 JWT 来说,RS 算法的签名已经是 PKCS#1 v1.5 padding 的了,所以直接将 `signedData` 和 `signature` 配合使用 `rsaSignatureMessagePKCS1v15SHA{x}` 就可以完成验证。 ```swift var error: Unmanaged? let result = SecKeyVerifySignature( key, .rsaSignatureMessagePKCS1v15SHA256, signedData as CFData, signature as CFData, &error) ``` 对于 ES 的 JWT 来说,事情要麻烦一些。我们收到的 JWT 里的签名只是 {r, s} 的简单连接,所以需要预先进行处理。按照 X9.62 中对 `signature` 的编码定义: ``` ECDSA-Sig-Value ::= SEQUENCE { r INTEGER, s INTEGER } ``` 因此,在调用 `SecKeyVerifySignature` 之前,先处理签名: ```swift let count = signature.count guard count != 0 && count % 2 == 0 else { // 错误,签名应该是两个等长的整数 } var rBytes = [UInt8](signature[..<(count / 2)]) var sBytes = [UInt8](signature[(count / 2)...]) // 处理首位,我们已经做过很多次了。 if rBytes.first! >= UInt8(0x80) { rBytes.insert(0x00, at: 0) } if sBytes.first! >= UInt8(0x80) { sBytes.insert(0x00, at: 0) } // 完成签名的 DER 编码 let processedSignature = Data(bytes: (rBytes.encode(as: .integer) + sBytes.encode(as: .integer)) .encode(as: .sequence)) var error: Unmanaged? let result = SecKeyVerifySignature( key, .ecdsaSignatureMessageX962SHA256, signedData as CFData, processedSignature as CFData, &error) ``` > 上面 RSA 和 ECDSA 的验证,都假设了使用 SHA-256 作为散列算法。如果你采用的是其他的散列算法,记得替换。 ### 验证 Payload 内容 签名正确完成验证之后,我们就可以对 JWT Payload 里的内容进行验证了:包括但不限于 "iss","sub","exp","iat" 这些保留值是否正确。当签名和内容都验证无误后,就可以安心使用这个 JWT 了。 ## 一些问题 至此,我们从最初的 JWT 定义开始,引伸出 JWA,JWK 等一系列 JOSE 概念。然后我们研究了互联网安全领域的通用编码方式和几种最常见的密钥的构成。最后,我们使用这些知识在 Security 框架的帮助下,完成了 JWT 的签名验证的整个流程。 事后看上去没有太大难度,但是由于涉及到的名词概念很多,相关标准错综复杂,因此初上手想要把全盘都弄明白,还是会有一定困难。希望这系列文章能够帮助你在起步阶段就建立相对清晰的知识体系,这样在阅读其他的相关信息时,可以对新的知识进行更好的分类整理。 最后,是一些我自己在学习和实践中的考虑。在此一并列出,以供参考。如果您有什么指正和补充,也欢迎留言评论。 #### 为什么不用已有的相关开源框架 现存的和这个主题相关的 iOS 或者 Swift 框架有一些,比如 [JOSESwift](https://github.com/airsidemobile/JOSESwift),[JSONWebToken.swift](https://github.com/kylef/JSONWebToken.swift),[Swift-JWT](https://github.com/IBM-Swift/Swift-JWT),[vaper/jwt](https://github.com/vapor/jwt) 等等。来回比较考察,它们现在 (2018 年 12 月) 或多或少存在下面的不足: * 没有一个从 JWK 开始到 JWT 的完整方案。JWT 相关的框架基本都是从本地证书获取公钥进行验证,而我需要从 JWK 获取证书 * 支持 JWK 的框架只实现了部分算法,比如只有 RSA,没有 ECDSA 支持。 * 一些框架依赖关系太复杂,而且大部分实现是面向 Swift Server Side,而非 iOS 的。 在 [LINE SDK](https://github.com/line/line-sdk-ios-swift) 中,我们需要,且只需要在 iOS 上利用 Security 框架完成验证。同时 Server 可能会变更配置,所以我们需要同时支持 RSA 和 ECDSA (当前默认使用 ECDSA)。另外,本身作为一个提供给第三方开发者的 SDK,我们不允许引入不可靠的复杂依赖关系 (最理想的情况是零依赖,也就是 LINE SDK 的现状)。基于这些原因,我没有使用现有的开源代码,而是自己从头进行实现。 #### 为什么不把你做的相关内容整理开源 在 LINE SDK 中的方案是不完备的,它是 JOSE 中满足我们的 JWT 解析和验证需求的最小子集,因此没有很高的泛用性,不适合作为单独项目开源。不过因为 LINE SDK 整个项目是开源的,JOSE 部分的代码其实也都是公开且相对独立的。如果你感兴趣,可以在 LINE SDK 的 [Crypto 文件夹](https://github.com/line/line-sdk-ios-swift/tree/master/LineSDK/LineSDK/Crypto)下找到所有相关代码。 #### 为什么要用非对称算法,各算法之间有什么优劣 不少 JWT 使用 HS 的算法 (HMAC)。和 RSA 或 ECDSA 不同,HMAC 是对称加密算法。对称算法加密和解密比较简单,因为密钥相同,所以比较适合用在 Server to Server 这种双方可信的场合。如果在客户端上使用对称算法,那就需要将这个密钥存放在客户端上,这显然是不可接受的。对于 Client - Server 的通讯,非对称算法应该是毋庸置疑的选择。 相比与 RSA,ECDSA 可以使用更短的密钥实现和数倍长于自己的 RSA 相同的安全性能。 > For example, at a security level of 80 bits (meaning an attacker requires a maximum of about 2^80 operations to find the private key) the size of an ECDSA public key would be 160 bits, whereas the size of a DSA public key is at least 1024 bits. 由于 ECDSA 是专用的 DSA 算法,只能用于签名,而不能用作加密和密钥交换,所以它比 RSA 要快很多。另外,更小的密钥也带来了更小的计算量。这些特性对于减少 Server 负担非常重要。关于 ECDSA 的优势和它相对于 RSA 的对比,可以参考 Cloudflare 的[这篇文章](https://blog.cloudflare.com/ecdsa-the-digital-signature-algorithm-of-a-better-internet/)。 #### 签名的安全性 JWT 签名的伪造一直是一个困扰人的问题。因为 JWT 的 Header 和 Payload 内容一旦确定的话,它的签名也就确定了 (虽然 ECDSA 会产生随机数使签名每次都不同,但是这些签名都可以通过验证)。这带来一个问题,攻击者可以通过截取以前的有效的 JWT,然后把它作为新的响应发给用户。这类 JWT 依然可以正确通过签名验证。 因此,我们必须每次生成不同的 JWT,来防止这种替换攻击。最简单的方式就是在内存中存储随机值,发送 JWT 请求时附带这个随机值,然后 Server 将这个随机值嵌入在返回的 JWT 的 Payload 中。Client 收到后,再与内存中保存的值进行比对。这样保证了每次返回的 JWT 都不相同,让签名验证更加安全。 #### OpenSSL 版本的问题 macOS 上自带的 OpenSSL 版本一般比较旧,而大部分 Linux 系统的 OpenSSL 更新一些。不同版本的 OpenSSL (或者其他的常用安全框架) 实现细节上会有差异,比如有些版本会在负数首位补 `0x00` 等。在测试时,最好让 Server 的小伙伴确认一下使用的 OpenSSL 版本,这样能在验证和使用密钥上避免一些不必要的麻烦。(请不要问我细节!都是泪) #### JWT 可以用来做什么,应该用来做什么 JWT 最常见的使用场景有两个: - **授权**:用户登录后,在后续的请求中带上一个有效的 JWT,其中包含该用户可以访问的路径或权限等。服务器验证 JWT 有效性后对访问进行授权。相比于传统像是 OAuth 的 token 来说,服务器并不需要存储这些 token,可以实现无状态的授权,因此它的开销较小,也更容易实现和理解。另外,由于 JWT 不需要依赖 Cookie 的特性,跨站或者跨服务依然可能使用,这让单点登录非常简单。 - **信息交换**:LINE SDK 中对用户信息进行签名和验证,就属于信息交换的范畴。依赖 JWT 的签名特性,接收方可以确保 JWT 中的内容没有被篡改,是一种安全的信息交换方式。 最近有非常多的关于反对使用 JWT 进行授权的声音,比如[这篇文章](http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/)和[这篇文章](https://paragonie.com/blog/2017/03/jwt-json-web-tokens-is-bad-standard-that-everyone-should-avoid)。JWT 作为授权 token 来使用,最大的问题在于无法过期或者作废,另外,一些严格遵守标准的实现,反而可能[引入严重的安全问题](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/)。 不过对于第二种用法,也就是信息交换来说,JWT 所提供的便捷和安全性是无人质疑的。 #### 我也想读读看相关标准 如你所愿,我整理了一下涉及到的标准。祝武运昌隆! ##### 关于编码和算法 - [X.680 - ASN.1 的标准和基本标注方式](https://www.itu.int/ITU-T/studygroups/com17/languages/X.680-0207.pdf):ASN.1 是这套方法的名字,而对应的标准号是 X.680。 - [X.690 - DER 编码规则](https://www.itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf):也包括了其他的,比如 BER 和 CER 的编码规则。 - [RFC 3279 - 关于 X.509 如何编码密钥和签名](https://tools.ietf.org/html/rfc3279):在 X.509 应用层面上密钥以及签名的构成。 - [SEC 2 - 关于椭圆曲线算法参数](http://www.secg.org/sec2-v2.pdf):ECDSA 的各种 OIDs 定义和椭圆曲线 G 值的表示方式。 - [X9.62 - 椭圆曲线的应用和相关编码方式](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.202.2977&rep=rep1&type=pdf):描述了 ECDSA 算法和密钥的表示方式。它在 SEC 2 的基础上添加了关于曲线点 (也就是实际的密钥本身) 的定义。 - [RFC 5480 - 椭圆曲线公钥的信息](https://tools.ietf.org/html/rfc5480):EC 公钥的定义,表示方式,使用曲线和对应的密钥位数及散列算法的关系。 - [RFC 8017 - RSA 算法相关的标准](https://tools.ietf.org/html/rfc8017):包括像是 RSA key 的 ASN.1 定义,所注册的 OIDs 。 ##### 关于 JOSE - [RFC 7515 - JSON Web Signature (JWS)](https://tools.ietf.org/html/rfc7515) - [RFC 7516 - JSON Web Encryption (JWE)](https://tools.ietf.org/html/rfc7516) - [RFC 7517 - JSON Web Key (JWK)](https://tools.ietf.org/html/rfc7517) - [RFC 7518 - JSON Web Algorithms (JWA)](https://tools.ietf.org/html/rfc7518) - [RFC 7519 - JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519) - [RFC 7165 - JOSE 的使用例子和要求](https://tools.ietf.org/html/rfc7165) ##### 杂项 - [RFC 4648 - 关于 Base64Url 的编码规则](https://tools.ietf.org/html/rfc4648):JOSE 中的数据都是使用 Base64Url 进行编码的。 - [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html):OpenID 相关的 profile 取得方式,以及其中键值对的定义。关于 Discovery Document 的更好的说明,可以参考 [Google 的这个指南](https://developers.google.com/identity/protocols/OpenIDConnect#discovery)。 #### 验证和速查工具汇总 - [ASN.1 解码器](https://holtstrom.com/michael/tools/asn1decoder.php):将一段 DER 数据解码为可读的 ASN.1 表示。 - [数据格式转换](https://cryptii.com/pipes/base64-to-hex):将数据在 Base64、文本和字节表示之间进行任意转换。 - [ASN.1 中的 OIDs 转换](https://www.alvestrand.no/objectid/top.html):帮助解码和编码 OBJECT IDENTIFIER 值。 - [JWK 和 PEM 相互转换](https://8gwifi.org/jwkconvertfunctions.jsp):将 JWK 或者 PEM 的密钥相互转换的工具。 #### 你的这篇文章或者代码好像有问题! 我是初学者,文章中的纰漏请不吝赐教指出! 关于代码方面的不足,[LINE SDK](https://github.com/line/line-sdk-ios-swift) 欢迎各种 PR。但是如果您发现的问题涉及安全漏洞,或者会导致比较严重后果的话,还请**先不要公开公布**。如果能按照[这里的说明](https://github.com/line/line-sdk-ios-swift/blob/master/.github/ISSUE_TEMPLATE.md)给我们发送邮件联系的话,实在感激不尽。 URL: https://onevcat.com/2018/12/jose-2/index.html.md Published At: 2018-12-05 09:38:00 +0900 # 与 JOSE 战斗的日子 - 写给 iOS 开发者的密码学入门手册 (理论) ![](/assets/images/2018/matrix.jpg) ## 概述 这是关于 JOSE 和密码学的三篇系列文章中的第二篇,你可以在下面的链接中找到其他部分: 1. [基础 - 什么是 JWT 以及 JOSE](/2018/12/jose-1/) 2. 理论 - JOSE 中的签名和验证流程 (本文) 3. [实践 - 如何使用 Security.framework 处理 JOSE 中的验证](/2018/12/jose-3/) 这一篇中,主要介绍网络传输的密钥的编码和处理方法,以及进行数字签名和验证的基本流程。我们在之后实践一篇里,会使用到这些知识。 ## 密钥的表现形式 显然 JWK 是一种密钥的表现形式,它使用 JSON 的方式,遵守 JWA 的参数,来定义密钥。不过这种表现形式在日常里使用得并不是那么普遍,我们在平时看到得更多的也许是这样的密钥: ``` -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAryQICCl6NZ5gDKrnSztO 3Hy8PEUcuyvg/ikC+VcIo2SFFSf18a3IMYldIugqqqZCs4/4uVW3sbdLs/6PfgdX 7O9D22ZiFWHPYA2k2N744MNiCD1UE+tJyllUhSblK48bn+v1oZHCM0nYQ2NqUkvS j+hwUU3RiWl7x3D2s9wSdNt7XUtW05a/FXehsPSiJfKvHJJnGOX0BgTvkLnkAOTd OrUZ/wK69Dzu4IvrN4vs9Nes8vbwPa/ddZEzGR0cQMt0JBkhk9kU/qwqUseP1QRJ 5I1jR4g8aYPL/ke9K35PxZWuDp3U0UPAZ3PjFAh+5T+fc7gzCs9dPzSHloruU+gl FQIDAQAB -----END PUBLIC KEY----- ``` 这是一个 RSA 公钥的 PEM (Privacy-Enhanced Mail) 表示方式。类似地,对于 ECDSA 密钥,也可以类似表示: ``` -----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9 q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg== -----END PUBLIC KEY----- ``` 在处理 JOSE 相关的验证时,我们其实是不会涉及这种格式的密钥的。但是我们会用到里面的相关的一些编码方式来处理 JWK 密钥和 Security 框架中 `SecKey` 的转换。所以这里把它作为一节单独介绍。 ### PEM,ASN.1,X.509 和 DER 编码 上面的 PEM 格式的密钥可以用任意的文本编辑器打开,它就是一个简单的纯 ASCII 字符的文件。由于容易读写复制,所以在交换密钥时这种格式非常流行。每个 PEM 密钥都由 "-----BEGIN #{label})-----" 标签开头,以 "-----END #{label}-----" 标签结尾。注意,PEM 并非专门为了传递 Key 而生,BEGIN 和 END 之后的 label 并不一定就是例子中的 "PUBLIC KEY",它只是一个让人能读懂的描述,来表示通过这个 PEM 传递的数据到底是什么。 在两个标签之间,就是密钥本身。PEM 中的换行字符需要被忽略掉,可以很清楚地看到,这其实就是一个 **Base64 编码**的字符串。用上面的 ECDSA 密钥为例,将这个 Base64 还原为字节数据的话,结果是: ``` 30 59 30 13 06 07 2a 86 48 ce 3d 02 01 06 08 2a 86 48 ce 3d 03 01 07 03 42 00 04 11 5b 3f a3 9f ae 41 b4 e3 2f 77 21 ca 72 f8 c1 78 14 83 64 7d ab d5 14 f0 8e 66 12 8b d4 7f ce 90 67 b9 0e 04 88 c9 c2 a9 f3 0f 5a 26 6a 07 84 1d 6c 07 74 13 ba 07 e7 45 69 b9 9d 4f d3 ce c6 ``` 很多同学到这里就退缩了,觉得这种二进制没有实际意义。但其实这一串字节是通过 [ASN.1 (Abstract Syntax Notation One) 定义](https://en.wikipedia.org/wiki/Abstract_Syntax_Notation_One)的数据。ASN.1 定义了一些表示信息的标准句法,用来对字符串,整数等等进行无歧义和精确地传输。ASN.1 里有很多具体的编码规则,来具体将一些数据按照 ASN.1 的方式进行编码,进行具体表达。在网络传输和密码学中,最简单和最常见的编码方式是 [DER (Distinguished Encoding Rules)](https://en.wikipedia.org/wiki/X.690#DER_encoding)。 > ASN.1 格式对应的标准是 X.680,DER 被定义在 X.690 中。 有了编码方式以后,为了能表达密钥,我们还需要定义一些元信息,比如一个密钥应该需要声明自己的身份 (在 ASN.1 中称为 "OBJECT IDENTIFIER"),是一个什么种类的密钥,采用的是什么样的曲线或者 padding,**X.509 标准**就是做这件事的。最后,对于 ECDSA 来说,还有一个 **X9.62** 的标准。它是 X.509 中规定的用来编码 ECDSA 密钥的方式。 光这样说会很抽象,具体来讲,可以简单对这几个概念和各自的作用进行总结: - ASN.1 - 一种数据或者信息表达时使用的句法,比如 “接下来是一串连续内容 (SEQUENCE),长度是...”;“现在开始一个整数”;“从这里开始是位串 (BITSTRING)” 等这样句法信息。 - DER - 是 ASN.1 的一种具体编码方式,比如使用 `0x30` 表示 SEQUENCE 的开始,然后下一个/若干个字节表示这段内容的长度;使用 `0x02` 表示现在开始是一个整数;使用 `0x03` 表示 BIT STRING 开始等。 - X.509 - 在网络证书和公钥传输时,所应该遵守的 ASN.1 形式。它定义了一个特定证书或者公钥应该由哪些部分构成,比如“一开始应该有一个 SEQUENCE,然后紧接着是两个整数来代表密钥值”等。这些构成的部分由 ASN.1 格式表达,一般由 DER 编码。 - X9.62 - 针对 ECDSA 相关算法的定义。X.509 是一个一般性的密钥编码规定,在 X.509 中指定了 ECDSA 的密钥和签名需要遵守 X9.62。(类似相应地,它也规定了 RSA 的密钥和签名要遵守 PKCS (Public Key Cryptography Standards))。 - PEM - 将证书或者密钥用 DER 编码后,可以得到一组字节数据。把这些数据转换为 Base64 编码的字符串,然后在前后加上 BEGIN 和 END 标签,就得到 PEM 的表现形式。 > 标准有点多?没错,这个世界上有很多标准化制定的组织,在这篇文章中,标准来源也不尽相同。从名字基本可以简单分类: > > - RFC (Request For Comments) 是 IETF 这个专门推动互联网标准的组织所发布的 > - "X." 开头的是 ITU-T 相关的标准,比如 ASN.1 (X.680) 是 ISO 和 ITU-T 的联合标准,X.509 是基于 ASN.1 的补充和扩展。 > - "X9." 开头的,比如 X9.62,是 ANSI (美国国家标准学会) 的产品 > - PKCS 是 RSA Security 所制定的标准 > > 我们会看到,在一些 RFC 标准中,会引用和规定需要使用 ANSI 的标准;而本来属于 RSA Security 的一些标准,也出现在了 RFC 中。另外,IETF 也会收录某些其他标准化组织的内容,比如 RFC 3280 其实就是 X.509。和专利市场的相互授权类似,各个标准组织之间也有竞争合作。不过这是另外一个关于爱恨情仇的故事了,其中八卦,我们有机会以后再说。 ### DER 编码规则 DER 编码的通用规则是,在一个代表类型的字节后面,一般都会接上这个类型的数据所占用的字节长度,然后是实际的数据。 #### 整数 举例说明,在 DER 中,使用 `0x02` 代表整数,所以如果我们想要编码十进制的 100 这个整数时,会得到: ``` 00000010 00000001 01100100 0x02 0x01 0x64 ``` `0x02` 代表之后是一个整数,这个整数占用的字节长度为 1 (`0x01`),值为 100 (`0x64`)。 我们在[系列的上一篇文章](/2018/12/jose-1/)中提到过,如果数值的首个字节超过 `0x80` 的话,就说明第一个 bit 是 1,在有符号域上这代表一个负数。这时候如果我们想要编码的是一个正数的话,就需要在前面添加一个 `0x00` 的字节。比如我们如果想要编码 `0xCE 29 10` 这个整数的话,就需要添加 `0x00`,因为首位的 `0xCE` 在二进制下为 `0b_1100_1110`: ``` 0x02 0x04 0x00 0xCE 0x29 0x10 ``` #### 序列 将两个整数前后排列,就可以形成一个序列 (SEQUENCE),序列的类型编码为 `0x30`,类似地,在类型编码后面也是字节长度值。比如两个整数 `0x64` 和 `0xCE2910` 编码成一个序列,得到的结果是: ``` 30 09 02 01 64 02 04 00 CE 29 10 ``` 为了看上去舒适一些,可以整理一下: ``` 30 09 -> SEQUENCE 9 bytes 02 01 -> Int 1 byte 64 -> Value 0x64 02 04 -> Int 4 byte 00 CE 29 10 -> Value 0xCE2910 ``` #### 其他类型及实例 列举几个我们在本文中用到的 DER 的类型编码: | 类型 | 编码 | | ---- | ---- | | INTEGER | 0x02 | | SEQUENCE | 0x30 | | BIT STRING | 0x03 | | OBJECT IDENTIFIER | 0x06 | > 如果你想对 DER 有更深入了解,最好的办法应该是看微软的的[这个文档](https://docs.microsoft.com/en-us/windows/desktop/seccertenroll/distinguished-encoding-rules),相比于冰冷的标准定义,这里面用人类能懂的语言详细描述了 DER 编码方法。 有了这些基础知识,我们可以来看看本文一开始例子中的 ECDSA 公钥的二进制里都是些什么内容了: ``` 30 59 30 13 06 07 2a 86 48 ce 3d 02 01 06 08 2a 86 48 ce 3d 03 01 07 03 42 00 04 11 5b 3f a3 9f ae 41 b4 e3 2f 77 21 ca 72 f8 c1 78 14 83 64 7d ab d5 14 f0 8e 66 12 8b d4 7f ce 90 67 b9 0e 04 88 c9 c2 a9 f3 0f 5a 26 6a 07 84 1d 6c 07 74 13 ba 07 e7 45 69 b9 9d 4f d3 ce c6 ``` 整理一下: ![](/assets/images/2018/ECDSA-pub-der.png) 需要简单说明的有两点: 1. 在 `30 13` 这个 SEQUENCE 里,我们能找到两个 OBJECT IDENTIFIER 的定义。关于 OBJECT IDENTIFIER 的编解码规则,可以参考[这里的说明](https://docs.microsoft.com/en-us/windows/desktop/seccertenroll/about-object-identifier)。这部分不是重点,所以就简单只说结论然后跳过了。这两个值分别代表: - `1.2.840.10045.2.1` - (ecPublicKey) - `1.2.840.10045.3.1.7` - (P-256) 可以看到,它定义了这个公钥的类型,以及使用的曲线。 > 关于解码后的 OBJECT IDENTIFIER 所代表的涵义,可以在[这里](https://www.alvestrand.no/objectid/top.html)进行查询。 2. BIT STRING 定义的是一个一串 BIT 数据 (注意这里的 STRING 并不是字符串的意思)。在一个 bit 为单位的数据里,可能存在想要传输的数据 bit 数不是 8 的倍数的情况。但是在 DER 编码长度时,我们指定的是 byte 数。比如在 `03 42` 这 BIT STRING 中,我们的 STRING 长度是 66 个字节 (528 bit)。但是如果我们想要传输的 bit 数只有 523 bit 时,最后一个 byte 中的后 5 bit 数据其实并不是我们想要的。这时候我们需要一种方式来指定应该“丢弃”掉最后若干 bit。`03 42` 之后的字节 `0x00` 负责指定数据末尾有多少 bit 不应该使用。当然,这里我们想要传输的数据 bit 数恰好是 8 的倍数,所以设为 0,表示所有 bit 我们都要使用。如果我们想要舍弃最后 5 bit 的话,这个 byte 就应该是 `0x05`。 > DER 中还有一个类型叫做 OCTET STRING,它定义的是一个 8 bit (OCTET,或者说字节) 组成的字节串流。而 BIT STRING 传输的单位是一个 bit,要注意区分。(我们在本文中不会用到 OCTET STRING,它通常用来传输一些 ACSII 字符串等) 接下来按照 SEC 和 X9.62 的规定,整个 BIT STRING 就应该是 ECDSA 公钥的 x 和 y 的值了。这里第一位的 `0x04` 表示这个密钥中的整数值是没有被压缩的。这个 byte 其他可选的值有 `0x00` (椭圆曲线取点在无穷),`0x02` (压缩,even y),`0x03` (压缩, odd y)。通常我们见到的 (以及 iOS 默认能接受的) 都是无压缩的整数值。之后 BIT STRING 还剩 64 位,它们分别就是 32 位的 x 和 32 位的 y 值了! 如果你回到[第一篇文章](/2018/12/jose-1/),我们曾经给过一个示例的 JWK: ```json { "kty":"EC", "alg":"ES256", "use":"sig", "kid":"3829b108279b26bcfcc8971e348d116", "crv":"P-256", "x":"EVs_o5-uQbTjL3chynL4wXgUg2R9q9UU8I5mEovUf84", "y":"kGe5DgSIycKp8w9aJmoHhB1sB3QTugfnRWm5nU_TzsY" } ``` 你可以尝试一下将这里的 x 和 y 的值转换为字节: ``` x -> 11 5b 3f a3 9f ae 41 b4 e3 2f 77 21 ca 72 f8 c1 78 14 83 64 7d ab d5 14 f0 8e 66 12 8b d4 7f ce y -> 90 67 b9 0e 04 88 c9 c2 a9 f3 0f 5a 26 6a 07 84 1d 6c 07 74 13 ba 07 e7 45 69 b9 9d 4f d3 ce c6 ``` 是不是感觉有点眼熟?没错,它们就是上面 DER 编码的 BITSTRING 里 `0x04` 后面的部分。嗯...要不你也可以试试看自己分析一下本文一开始给出的那个 PEM 格式的 RSA 公钥?它和系列第一篇文中的 JWK 格式的 RSA 公钥也是等价的。 > 如果你遇到困难,可以利用搜索引擎。你也可以在 [RFC 2315](https://tools.ietf.org/html/rfc2315) 或者 PKCS#7 中找到 RSA 公钥的编码方式。 > > 另外,顺带一提,作为 iOS 开发者经常从 Keychain 导出证书时使用的 .p12 文件,其实就是遵守 PKCS#12,来将一个 X.509 证书和私钥打包到一起。 ## 非对称密钥的签名和验证 至此,我们完整了解了密钥,至少是 RSA 和 ECDSA 公钥。使用 RSA 进行数据签名和验证的流程如下: ![](/assets/images/2018/crypto-sign.png) ![](/assets/images/2018/crypto-verify.png) 注意,上面的图是 RSA 算法的情况,对于 ECDSA 来说,流程稍有不同。ECDSA 在验证时**并不是**把收到的 Signature 还原成摘要数据,然后进行对比。ECDSA 的 Signature 是由两个大数组成 (通常写作 {r, s}),通过 Signature 中的整数 s,以及收到的原始数据所计算出的摘要,可以计算出另一个整数 v。如 r 和 v 相等的话,就认为验证通过,否则失败。 > RSA 签名时,实际上是对摘要进行稳定的变换运算 (当然通常你也可以把它叫做加密),所以对于给定的数据,散列算法和加密私钥,得到的签名是一致的。但是对于 ECDSA 来说,签名时需要一个随机选择的 k 值,因此每次进行 ECDSA 所得到的签名内容是不同的。不太了解这一点的话,可能会在看到每次签名变化时感到疑惑。 RSA 或者 ECDSA 具体的算法原理在这里就不展开了,都是一些基本的数学运算。几乎所有的平台对这些算法都会有现成的实现,如果没有特殊必要,一般不会需要自己去进行实现。比如在 iOS 平台上,Security.framework 的 `SecKeyCreateSignature` 和 `SecKeyVerifySignature` 就为我们实现了签名和验证的相关接口,我们只需要传入合适的参数即可。 ## OpenID Connect Discovery 作为这部分的结尾,让我们看一点轻松的话题吧。在理论的海洋里“畅游”了一番以后,应该回顾一下我们最初的目的,就是这张流程图: ![](/assets/images/2018/jose-flow.png) 第一步,从 Auth Server 获取 JWT,并完成解析,是很简单的事情。最后一步,将 JWK 转换成 Security 框架中的密钥,并且对 JWT 的数据进行验证,我们在详细了解了密钥和签名/验证的知识以后,大概也能解决。剩下的问题是中间的框图:我们应该去哪儿寻找 JWT Header 中定义的公钥? 把 JWK Host 的 URL 写死在客户端当然是最简单省事儿的方法,但是其实有业界更通用一些的做法,那就是用 [Discovery Document](https://developers.google.com/identity/protocols/OpenIDConnect#discovery)。最初的起源是 [OpenID Connect](https://openid.net/connect/),或者说 OAuth2 中需要一系列 API (发起验证请求,交换 token 等,都是不同的 API entry)。这一套内容都可以通过配置来改变,相比于把每个 API 都写死在客户端,我们可能更愿意选择只写死一个入口,而 Discovery Document 就是这个入口。 > 有时候有人会把 OpenID Connect 和 OAuth2 弄混淆,它们其实是不一样的东西:OpenID Connect 负责的是“验证”,也就是负责“你真的是你吗”的问题;而 OAuth2 负责“授权”,也就是“我可以访问你的数据吗”的问题。不过两者有时候会被一起执行,所以不需要分得那么清楚。比如 Google 的 OAuth 2.0 API 也负责了验证的工作。(LINE 的 Login API 亦是如此,[LINE SDK](https://github.com/line/line-sdk-ios-swift) 在授权时同时也完成了验证。) 你可以找到一些 Discovery Document 的例子,比如 [Google 的](https://accounts.google.com/.well-known/openid-configuration),或者 [LINE 的](https://access.line.me/.well-known/openid-configuration)。在里面可以找到 `jwks_uri` 这个 key,它就是我们的 JWK Host 的位置,这个位置会放置了若干个 JWK (它们合在一次称为 [JWK Set](https://auth0.com/docs/jwks))。里面应该包括之前在 JWT Header 中所指定的 `kid` 的密钥。 ## 小结 本文主要介绍了如何处理和编码的密钥,以及进行数字签名和验证的基本流程。有一部分内容虽然在处理 JOSE 时用不到 (比如 PEM 格式),但是作为密码学和网络交换中最常用的知识,了解后相信会在未来的某一天派上用场。 我们在之后[实践一篇](/2018/12/jose-3/)里,会先解析收到的 JWT。然后依靠本篇文章的这些知识,将 JWK 转换为 `SecKey`,并使用 Security.framework 提供的 API 和算法,来完成 JWT 的验证工作。同时,也会讨论一些工程中的经验和选择。 URL: https://onevcat.com/2018/12/jose-1/index.html.md Published At: 2018-12-03 10:38:00 +0900 # 与 JOSE 战斗的日子 - 写给 iOS 开发者的密码学入门手册 (基础) ![](/assets/images/2018/matrix.jpg) ## 概述 事情的缘由很简单,工作上在做 [LINE SDK](https://github.com/line/line-sdk-ios-swift) 的开发,在拿 token 的时候有一步额外的验证:从 Server 会发回一个 JWT (JSON Web Token),客户端需要对这个 JWT 进行签名和内容的验证,以确保信息没有被人篡改。Server 在签名中使用的算法类型会在 JWT 中写明,验证签名所需要的公钥 ID 也可以在 JWT 中找到。这个公钥是以 JWK (JSON Web Key) 的形式公开,客户端拿到 JWK 后即可在本地对收到的 JWT 进行验证。用一张图的话,大概是这样: ![](/assets/images/2018/jose-flow.png) ### 步骤 > 如果你现在对下面说步骤不理解的话 (这挺正常的,毕竟这篇文章都还没正式开始 😂),可以先跳过这部分,等我们有一些基础知识以后再回头看看就好。如果你很清楚这些步骤的话,那真是好棒棒,你应该能无压力阅读该系列剩余部分内容了。 LINE SDK 里使用 JWT 验证用户的逻辑如下: 1. 向登录服务器请求 access token,登录服务器返回 access token,同时返回一个 JWT。 2. JWT 中包含应该使用的算法和密钥的 ID。通过密钥 ID,去找预先定义好的 Host 拿到 JWK 形式的该 ID 的密钥。 3. 将 1 的 JWT 和 2 的密钥转换为 Security.framework 接受的形式,进行签名验证。 这个过程想法很简单,但会涉及到一系列比较基础的密码学知识和标准的阅读,难度不大,但是枯燥乏味。另外,由于 iOS 并没有直接将 JWK 转换为 native 的 `SecKey` 的方式,自己也没有任何密码学的基础,所以在处理密钥转换上也花了一些工夫。为了后来者能比较顺利地处理相关内容 (包括 JWT 解析验证,JWK 特别是 RSA 和 EC 算法的密钥转换等),也为了过一段时间自己还能有地方回忆这些内容,所以将一些关键的理论知识和步骤记录下来。 ### 系列文章的内容 整个系列会比较长,为了阅读压力小一些,我会分成三个部分: 1. 基础 - 什么是 JWT 以及 JOSE (本文) 2. [理论 - JOSE 中的签名和验证流程](/2018/12/jose-2/) 3. [实践 - 如何使用 Security.framework 处理 JOSE 中的验证](/2018/12/jose-3/) 全部读完的话应该能对网络相关的密码学有一个肤浅的了解,特别是常见的签名算法和密钥种类,编码规则,怎么处理拿到的密钥,怎么做签名验证等等。如果你在工作中有相关需求,但不知道如何下手的话,可以仔细阅读整个系列,并参看开源的 [LINE SDK Swift](https://github.com/line/line-sdk-ios-swift) 的相关实现,甚至直接 copy 部分代码 (如果可以的话,也请顺便点一下 star)。如果你只是感兴趣想要简单了解的话,可以只看 JOSE 和 JWT 的基础概念和理论流程部分的内容,作为知识面的扩展,等以后有实际需要了再回头看实践部分的内容。 在文章结尾,我还列举了一些常见的问题,包括笔者自己在学习时的思考和最后的选择。如果您有什么见解,也欢迎发表在评论里,我会继续总结和补充。 > 声明:笔者自身对密码学也是初学,而本文介绍的密码学知识也都是自己的一些理解,同时尽量不涉及过于原理性的内容,一切以普通工程师实用为目标原则。其中可以想象在很多地方会有理解的错误,还请多包涵。如您发现问题,也往不吝赐教指正,感激不尽。 ## JWT 以及 JOSE ### 什么是 JWT 估计大部分 Swift 的开发者对 JWT 会比较陌生,所以先简单介绍一下它是什么,以及可以用来做什么。JWT (JSON Web Token) 是一个编码后的字符串,比如: ``` eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ``` 一个典型的 JWT 由三部分组成,通过点号 `.` 进行分割。每个部分都是经过 **Base64Url** 编码的字符串。第一部分 (Header) 和第二部分 (Payload) 在解码后应该是有效的 JSON,最后一部分 (签名) 是通过一定算法作用在前两部分上所得到的签名数据。接收方可以通过这个签名数据来验证 token 的 Header 及 Payload 部分的数据是否可信。 > 为了视觉上看起来轻松一些,在上面的 JWT 例子中每个点号后加入了换行。实际的 JWT 中不应该存在任何换行的情况。 > 严格来说,JWT 有两种实现,分别是 JWS (JSON Web Signature) 和 JWE (JSON Web Encryption)。由于 JWS 的应用更为广泛,所以一般说起 JWT 大家默认会认为是 JWS。JWS 的 Payload 是 Base64Url 的明文,而 JWE 的数据则是经过加密的。相对地,相比于 JWS 的三个部分,JWE 有五个部分组成。本文中提到 JWT 的时候,所指的都是用于签名认证的 JWS 实现。 > 关于 Base64Url 编码和处理,在本文后面部分会再提到。 #### Header Header 包含了 JWT 的一些元信息。我们可以尝试将上面的 `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9` 这个 Header 解码,得到: ```json {"alg":"HS256","typ":"JWT"} ``` > 关于在数据的不同格式之间互相转换 (明文,Base64,Hex Bytes 等),我推荐[这个](https://cryptii.com/pipes/base64-to-hex)非常不错的 web app。 在 JWT Header 中,"alg" 是必须指定的值,它表示这个 JWT 的签名方式。上例中 JWT 使用的是 `HS256` 进行签名,也就是使用 SHA-256 作为摘要算法的 HMAC。常见的选择还有 `RS256`,`ES256` 等等。总结一下: - `HSXXX` 或者说 [HMAC](https://en.wikipedia.org/wiki/HMAC):一种对称算法 (symmetric algorithm),也就是加密密钥和解密密钥是同一个。类似于我们创建 zip 文件时设定的密码,验证方需要知道和签名方同样的密钥,才能得到正确的验证结果。 - `RSXXX`:使用 [RSA](https://en.wikipedia.org/wiki/RSA_(cryptosystem)) 进行签名。RSA 是一种基于极大整数做因数分解的非对称算法 (asymmetric algorithm)。相比于对称算法的 HMAC 只有一对密钥,RSA 使用成对的公钥 (public key) 和私钥 (private key) 来进行签名和验证。大多数 HTTPS 中验证证书和加密传输数据使用的是 RSA 算法。 - `ESXXX`:使用 [椭圆曲线数字签名算法 (ECDSA)](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm) 进行签名。和 RSA 类似,它也是一种非对称算法。不过它是基于椭圆曲线的。ECDSA 最著名的使用场景是比特币的数字签名。 - `PSXXX`: 和 `RSXXX` 类似使用 RSA 算法,但是使用 PSS 作为 padding 进行签名。作为对比,`RSXXX` 中使用的是 PKCS1-v1_5 的 padding。 > 如果你对这些介绍一头雾水,也不必担心。关于各个算法的一些更细节的内容,会在后面实践部分再详细说明。现在,你只需要知道 Header 中 "alg" key 为我们指明了签名所使用的签名算法和散列算法。我们之后需要依据这里的指示来验证签名。 除了 "alg" 外,在 Header 中发行方还可以放入其他有帮助的内容。JWS 的标准定义了一些[预留的 Header key](https://tools.ietf.org/html/rfc7515#section-4)。在本文中,除了 "alg" 以外,我们还会用到 "kid",它用来表示在验证时所需要的,从 JWK Host 中获取的公钥的 key ID。现在我们先集中于 JWT 的构造,之后在 JWK 的部分我们再对它的使用进行介绍。 #### Payload Payload 是想要进行交换的实际有意义的数据部分。上面例子解码后的 Payload 部分是: ```json {"sub":"1234567890","name":"John Doe","iat":1516239022} ``` 和 Header 类似,payload 中也有一些[预先定义和保留的 key](https://tools.ietf.org/html/rfc7519#section-4),我们称它们为 claim。常见的预定义的 key 包括有: - "iss" (Issuer):JWT 的签发者名字,一般是公司名或者项目名 - "sub" (Subject):JWT 的主题 - "exp" (Expiration Time):过期时间,在这个时间之后应当视为无效 - "iat" (Issued At):发行时间,在这个时间之前应当视为无效 当然,你还可以在 Payload 里添加任何你想要传递的信息。 我们在验证签名后,就可以检查 Payload 里的各个条目是否有效:比如发行者名字是否正确,这个 JWT 是否在有效期内等等。因为一旦签名检查通过,我们就可以保证 Payload 的东西是可靠的,所以这很适合用来进行消息验证。 > 注意,在 JWS 里,Header 和 Payload 是 Base64Url 编码的**明文**,所以你不应该用 JWS 来传输任何敏感信息。如果你需要加密,应该选择 JWE。 #### Signature 一个 JWT 的最后一部分是签名。首先对 Header 和 Payload 的原文进行 Base64Url 编码,然后用 `.` 将它们连接起来,最后扔给签名散列算法进行签名,把签名得到的数据再 Base64Url 编码,就能得到这个签名了。写成伪代码的话,是这样的: ``` // 比如使用 RS256 签名: let 签名数据: Data = RS256签名算法(Base64Url(string: Header).Base64Url(string: Payload), 私钥) let 签名: String = Base64Url(data: 签名数据) ``` 最后,把编码后的 Header,Payload 和 Signature 都用 `.` 连在一起,就是我们收发的 JWT 了。 ### 什么是 JOSE JWT 其实是 JOSE 这个更大的概念中的一个组成部分。JOSE (Javascript Object Signing and Encryption) 定义了一系列标准,用来规范在网络传输中使用 JSON 的方式。我们在上面介绍过了JWS 和 JWE,在这一系列概念中还有两个比较重要,而且相互关联的概念:JWK 和 JWA。它们一起组成了整个 JOSE 体系。 ![](/assets/images/2018/jose.png) #### JWK 不管签名验证还是加密解密,都离不开密钥。JWK (JSON Web Key) 解决的是如何使用 JSON 来表示一个密钥这件事。 RSA 的公钥由模数 (modulus) 和指数 (exponent) 组成,一个典型的代表 RSA 公钥的 JWK 如下: ```json { "alg": "RS256", "n": "ryQICCl6NZ5gDKrnSztO3Hy8PEUcuyvg_ikC-VcIo2SFFSf18a3IMYldIugqqqZCs4_4uVW3sbdLs_6PfgdX7O9D22ZiFWHPYA2k2N744MNiCD1UE-tJyllUhSblK48bn-v1oZHCM0nYQ2NqUkvSj-hwUU3RiWl7x3D2s9wSdNt7XUtW05a_FXehsPSiJfKvHJJnGOX0BgTvkLnkAOTdOrUZ_wK69Dzu4IvrN4vs9Nes8vbwPa_ddZEzGR0cQMt0JBkhk9kU_qwqUseP1QRJ5I1jR4g8aYPL_ke9K35PxZWuDp3U0UPAZ3PjFAh-5T-fc7gzCs9dPzSHloruU-glFQ", "use": "sig", "kid": "b863b534069bfc0207197bcf831320d1cdc2cee2", "e": "AQAB", "kty": "RSA" } ``` 模数 `n` 和指数 `e` 构成了密钥最关键的数据部分,这两部分都是 Base64Url 编码的大数字。 > 关于 RSA 的原理,不在本文范围内,你可以在其他很多地方找到相关信息。 > > 如果你接触过几个 RSA 密钥,可能会发现 "e" 的值基本都是 "AQAB"。这并不是巧合,这是数字 65537 (0x 01 00 01) 的 Base64Url 表示。选择 AQAB 作为指数已经是业界标准,它同时兼顾了运算效率和安全性能。同样,这部分内容也超出了本文范畴。 类似地,一个典型的 ECDSA 的 JWK 内容如下: ```json { "kty":"EC", "alg":"ES256", "use":"sig", "kid":"3829b108279b26bcfcc8971e348d116", "crv":"P-256", "x":"EVs_o5-uQbTjL3chynL4wXgUg2R9q9UU8I5mEovUf84", "y":"AJBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G" } ``` 决定一个 ECDSA 公钥的参数有三个: "crv" 定义使用的密钥所使用的加密曲线,一般可能值为 "P-256","P-384" 和 "P-521"。"x" 和 "y" 是选取的椭圆曲线点的座标值,根据曲线 "crv" 的不同,这个值的长度也会有区别;另外,推荐使用的散列算法也会随着 "crv" 的变化有所不同: | crv | x/y 的字节长度 | 散列算法 | | ----- | ------------- | ------- | | P-256 | 32 | SHA-256 | | P-384 | 48 | SHA-384 | | P-521 | 66 | SHA-512 | > 注意 `P-521` 对应的是 `SHA-512`,不是 `SHA-521` (不存在 521 位的散列算法 😂) 同样,使用的曲线也决定了签名的长度。在使用 ECDSA 对数据签名时,通过椭圆曲线计算得到 r 和 s 两个值。这两个值的字节长度也应该符合上表。 > 细心的同学可能会发现上面的 ECDSA 密钥中 "y" 的值转换为 hex 表示后是 33 个字节: > > ``` > 00 90 67 b9 0e 04 88 c9 c2 a9 f3 0f 5a 26 6a 07 84 > 1d 6c 07 74 13 ba 07 e7 45 69 b9 9d 4f d3 ce c6 > ``` > > 我们知道,在密钥中 "x" 和 "y" 都是大的整数,但是在某些安全框架的实现 (比如一些版本的 OpenSSL) 中,使用的会是普通的整数类型 (Int),而非无符号整数 (UInt)。而如果一个数字首 bit 为 1 的话,在有符号的整数系统中会被认为是负数。在这里,"y" 原本第一个 byte 其实是 `0x90` (bit 表示是 0b_1001_0000),首 bit 为 1,为了避免被误认为负数,有的实现会在前面添加 `0x00`。但是实际上把这样一个 33 byte 的值作为 "y" 放在 JWK 中,是不符合标准的。如果你遇到了这种情况,可以和负责服务器的小伙伴商量一下让他先处理一下,给你正确的 key。当然,你也可以自己在客户端检查和处理长度不符合预期的问题,以增强本地代码的健壮性。 > > 在这个例子中,如果服务器在生成 JWK 时就帮我们处理了 `0x00` 的问题的话,那么 "y" 的值应该是 > > ```kGe5DgSIycKp8w9aJmoHhB1sB3QTugfnRWm5nU_TzsY``` > > 我们还会在后面看到更多的处理 `0x00` 添加或删除的情况,对于首字节是 `0x80` (`0b_1000_0000`) 或者以上的值,我们可能都需要考虑具体实现是接受 Int 还是 UInt 的问题。 #### JWA JWA (JSON Web Algorithms) 定义的就是在 JWT 和 JWK 中涉及的算法了,它为每种算法定义了具体可能存在哪些参数,和参数的表示规则。比如上面 JWK 例子中的 "n","e","x","y","crv" 都是在 JWA 标准中定义的。它为如何使用 JWK,如何验证 JWT 提供支持和指导。 除了 RSA 和 ECDSA 以外,JWA 里还定义了 AES 相关的加密算法,不过这部分内容和 JWS 没什么关系。另外,在签名算法定义的后面,也附带了如果使用签名和如何进行验证的简单说明。我们在之后会对 JOSE 中的签名和验证过程进行更详细的解释。 ## 小结 本文简述了 JWT 和 JOSE 的相关基础概念。您现在对 JWT 是什么,JOSE 有哪些组成部分,以及它们大概长什么样有一定了解。 你可以访问 [JWT.io](https://jwt.io) 来实际试试看创建和验证一个 JWT 的过程。如果你想要更深入了解 JWT 的内容和定义的话,JWT.io 还提供了免费的 JWT Handbook,里面有更详细的介绍。我们在系列文章的最后还会对 JWT 的应用场景,适用范围和存在的风险进行补充说明。 系列文章后面两篇,会分别针对 [JOSE 中的签名和验证过程](/2018/12/jose-2/))以及作为 iOS 开发者如何[使用 Security.frame 来处理 JOSE 相关的概念实践](/2018/12/jose-3/)进行更详细的说明。 URL: https://onevcat.com/2018/11/tools-i-am-using/index.html.md Published At: 2018-11-16 11:32:54 +0900 # 我所使用的工具们 工欲善其事,必先利其器。作为创造者,合手的工具可以以倍速提高效率。对于程序员来说,特别是对于在 macOS 上做开发的程序员来说,我们有非常多的 app 选择。 同时,也有很多朋友会好奇我日常做开发时都使用一些什么样的 app。趁这个机会整理一下自己所偏好使用的一些工具。 [数码荔枝](https://www.lizhi.io/)作为国内有名的软件经销商,为我们争取到了很多中国区特供的优惠价格,文中部分工具也提供了优惠合作的购买链接,您可以先 [拿到优惠券](https://partner.lizhi.io/onevcat/cp),然后以中国地区的专供价格进行购买,通常会比到官网购买便宜很多。通过这样购买的软件我可以拿到一定的 分成,可能可以 cover 这个站点的运营成本。如果您正好需要购买某个 app 的话,还请不妨考虑这种方式。先谢谢啦! > 「优惠链接」中写的价格是本文写作时在数码荔枝商店中的售价,并不包含优惠券的折扣。这个价格可能会有变动,还请留意。 ## 开发工具 Xcode,JetBrains 全家桶之类的这种大家都需要用的就跳过不说了。下面是几个平时用起来很顺手的和软件开发有关的 macOS app。 ### [Code Runner](https://coderunnerapp.com) ![](/assets/images/2018/code-runner.jpg) [Code Runner](https://coderunnerapp.com) 的作用很类似于 Xcode 的 Playground,它能提供一个快速验证和实验想法的地方,你可以输入各种语言的代码,然后去执行它们,迅速得到结果。我一般用它来验证一些小段的程序,看看结果是否正确。如果没问题的话,再把这些代码复制到实际项目里使用。相比于 Xcode 的 Playground,Code Runner 支持各种杂七杂八的语言 (你可以在官网看到详细的支持列表),并且为部分语言提供开箱即用的媲美 IDE 的补全和调试支持。 另外,在闲暇时做算法题或者写一些有意思的小东西小脚本的时候,Code Runner 也能帮助我快速开始。值得一提的是,Code Runner 的开发者相当良心,从 version 1 到现在 version 3 的升级,都没有再额外收费。这也是我想把这个 app 放到第一个来介绍的原因。 > VS Code 或者其他一些编辑器也有[插件的方式](https://marketplace.visualstudio.com/items?itemName=formulahendry.code-runner)提供类似 Code Runner 的功能,但是很多时候需要额外的配置,功能上也相较羸弱一些。我个人更愿意选择一个即开即用,节省时间精力,而且确实很优秀的方案。 > [优惠链接 ($14.99 -> ¥99)](https://partner.lizhi.io/onevcat/coderunner) --- ### [Reveal](https://revealapp.com) ![](/assets/images/2018/reveal.jpg) 自从 Xcode 加入了 View Debugging 以来,很多朋友会问是不是可以替代 [Reveal](https://revealapp.com)。我个人的经验来说,不能。也许简单一些的界面可以用 Xcode 自带的凑合,但是如果遇到 view 比较多的复杂界面,或者需要在更深层的地方 (比如 layer 或者某些特定属性) 中查找问题的话,Reveal 带来的便利性远超 Xcode。 几乎如果你有 iOS UI 开发的需求的话,这个工具会为你节省好多小时,换算下来,是一款性价比极高的可以无脑入手的重要工具。 > [优惠链接 ($59 -> ¥329)](https://partner.lizhi.io/onevcat/reveal) --- ### [Flawless](https://flawlessapp.io) ![](/assets/images/2018/flawless.jpg) 这是一个比较小众的工具,它可以把一张图片注入到 iOS 模拟器里,然后以覆盖层或者左右对比的方式,来检查 UI 的位置尺寸颜色等等一系列属性有没有符合要求。对于有设计师出图和对 UI 还原追求比较极致的同学,是很好的工具,可以帮助你真正做到“一个像素都不差”的精致效果。 我之前有一段时间写了很多 UI 的东西,加上日本这边 QA 和设计师都蛮挑剔的,真是会追着一个像素这种问题和你纠缠。这款 app 也帮我省下不少时间来纠结这类问题。但是最近 UI 相对做的比较少,价值就没有那么突出了。 另外,我记得我买的时候一个 license 是 $15,但是好像在写作本文的时候价格变成了 $49,而且只有一年的更新。这相对来说就比较贵了...有兴趣但是嫌贵的同学也可以观望一下。这款 app 还没有国内的经销商。 --- ### [Charles](https://www.charlesproxy.com) ![](/assets/images/2018/charles.png) [Charles](https://www.charlesproxy.com) 这个应该不用再多介绍了,老牌的 HTTP proxy 和代理抓包工具,功能十分强大。不管用来检测网络请求和响应,还是中途拦截和修改请求,或者是检测 socket 数据,都可以自由应对。现在开发几乎不可能不和网络打交道,而 Charles 则让网络部分的开发和调试过程变得轻松不少。 最近 Charles 也推出了 iOS 版本,可以直接在设备上运行,免去了来回在手机中设置代理的麻烦,也可以让 QA 或者测试的小伙伴直接记录请求。 > [优惠链接 ($50 -> ¥199)](https://partner.lizhi.io/onevcat/charles) --- ### [Fork](https://git-fork.com) ![](/assets/images/2018/fork.jpg) 我自己是喜欢使用 GUI 来操作 git 仓库的。几乎 99% 的日常 git 操作相对并不复杂,使用 GUI 会更直接一些,也更快一些。特别在遇到冲突,或者想要查找 log 历史的时候,GUI 的优势就相当明显了。我以前的偏好是 [Tower](https://www.git-tower.com/mac),但是最近 Tower 把收费模式从一次买断改为了按年订阅,而且订阅期满后则不能再继续使用。我认为这不是一个工具 app 应该有的收费模式,也很不喜欢它们把一些卖点功能单独放在更高价的订阅等级里的做法,所以我并没有升级到 Tower 的订阅。作为替代,我尝试了很多其他的 Git GUI,最终选择了 [Fork](https://git-fork.com) 作为替代。 除了在拖拽支持上还有一点欠缺以外,它能够很好地满足我对一个优秀 Git GUI 的一切幻想。特别它还内置了解决冲突的界面和对比工具,很好地简化了 merge 的流程。界面和交互上也经过了精心打磨,作为一款个人开发者的作品,能有这样的高度和完成度非常不易。 Fork 现在还在 beta 中,但是软件质量可以说远远超出了 beta 的名字,而且作者也承诺今后不会使用订阅制收费。应该是正式 release 我会第一时间购买的 app。 --- ### [Paw](https://paw.cloud) ![](/assets/images/2018/paw.jpg) 在写网络代码的时候,我比较倾向于先动手把网络部分的请求都发一遍,先调通,确认服务器端的请求返回都没问题后,再开始开始着手在 app 里实现相关内容。这时候,一个能帮助保存 API 请求和相关参数的工具就很有用。[Paw](https://paw.cloud) 就是这样的一个工具:记录保存 token,按照不同配置参数来生成网络请求,将请求的内容和返回结果共享给 server 端的小伙伴,甚至最后按照网络请求的配置直接生成代码 (虽然这些代码不太可能直接用在项目了...)。 Paw 给了我一个“一站式”的不用自己动脑筋去实现的 HTTP Client 和 API 管理的方式。如果记录保持完整的话,有时候甚至可以作为 server 的状态和返回的测试来运行,在遇到网络方面的疑难杂症时可以帮助快速定位问题所在。 > [优惠链接 ($49.99 -> ¥249)](https://partner.lizhi.io/onevcat/paw) --- ### [CodeKit](https://codekitapp.com) ![](/assets/images/2018/codekit.jpg) 这是一个前端开发的工具,我主要用它来快速将一些像 Sass 或者 TypeScript 的东西编译成相应的 CSS 和 JavaScript 等。通常在一个项目里,这部分内容都应该由类似 Gulp 或者 Webpack 或者 Babel 之类的工具来做。但是我经常会发现,因为我并不是一个专业的 Web 前端开发,很多时候只是在现有的东西上修修改改。通常写对应的任务和配置,以及从头开始架设开发环境所花的时间,会比实际做事的时间还长。CodeKit 解决了这个问题,它提供了一套不太需要配置的工作流,把前端语言编译,asset 压缩等工作自动化,然后提供了 Hot Reload 的 server 来监视这些变化。 基本上把之前需要自行配置的一系列所谓 modern Web 开发的方式,进行了简化和封装,让不那么正规的项目也可以从正规的工作流中受益的一个工具。 > [优惠链接 ($34 -> ¥209)](https://partner.lizhi.io/onevcat/codekit) --- ### [TablePlus](https://tableplus.io/) ![](/assets/images/2018/tableplus.png) 一个数据库可视化的 GUI 工具,可以方便地对 MySQL,PostgreSQL,Redis 和其他各种数据库进行操作和数据查看。写 SQL 或者各类查询语句是一件挺无趣的事情,使用命令行去对数据库更改之类的工作也很不方便。这个 GUI 在同一个环境下为不同的数据库提供 driver,让我们用更人性化的方式去访问和修改数据库。如果是 server 开发,可能会经常有需要查找和操作数据库的话,这个工具应该能加速不少。 > [购买链接 ($49 -> ¥339)](https://partner.lizhi.io/onevcat/tableplus) --- ## 个人工具 然后是一些个人的管理工具和日常使用的 app。 ### [Things](https://culturedcode.com/things/) ![](/assets/images/2018/things.jpg) 最近各种事情变多以后,生活经常会没有条理,往往有那种明明记得应该有什么事儿没做,但是就是想不起来的时候,所以需要一个类似 ToDo List 的管理类 app。Things 严格来说是一个简化版的 GTD 类 app,相比最简单的 ToDo List,它在项目分类和时间节点上做得更好。同时,对比 OmniFocus 这样的“硬派”任务管理类 app,它足够简单容易上手。macOS 版本和 iOS 版本的同步,第三方 app 的支持 (比如从邮件客户端 Spark 发送项目给 Things),和不俗的交互及颜值,都是我选择这个 app 来作为日程管理的理由。毕竟上面记录了每天都要面对的烦心事儿,要是 app 本身再让人心烦的话,这生活就没法过了... --- ### [Agenda](https://agenda.com) ![](/assets/images/2018/agenda.png) 作为一个和日历绑定的笔记本在使用。Things 主要是记录任务和日程,而 Agenda 主要用来记录更长一些的想法,比如会议上要做的发言,读某篇博客或者某本书的心得体会,这样的东西。对于任何不太合适扔到 Things 的内容,我都会选择放到这里备查。一开始我还担心按照日期和日历来组织笔记会有会很奇怪,但是实际用上以后发现其实也还是可以结合项目来整理,笔记的查找和复习也相当方便。像是会议准备和发言这些内容,更是可以及时归档,保持整洁。 Agenda 一个比较有意思的地方,是它的收费模式。它们采用自称做[「现金奶牛」](https://medium.com/@drewmccormack/a-cash-cow-is-on-the-agenda-138a11995595)的收费方式,每次付费,你可以得到迄今为止的所有附加功能,以及未来一年的更新。即使到期以后,你也可以继续拥有已有特性以及对新系统的支持,直到下一次出现你想要续费购买的新特性时,你才需要另行付费。这种模式相当新颖,也同时激发了用户的购买欲和给了开发者持续努力的动力,很有意思。 这个 app 的 iOS 和 macOS 多端同步也非常好,总体质量不愧于 WWDC 2018 的 Design Award。如果还没有尝试过的同学,不妨一试。 --- ### [PDF Expert](https://pdfexpert.com) ![](/assets/images/2018/pdf-expert-sample.png) Readdle 家的 app 质量都相当有保证,除了这款老牌的 PDF 阅读器,我同时也在使用他们的[邮件 app Spark](https://sparkmailapp.com) 和[日历 app Calendars 5](https://readdle.com/calendars5)。即使以最严厉的眼光来看,他们的这些 app 几乎都挑不出什么毛病。PDF Expert 提供了优良的浏览性能和相当丰富的笔记特性,对于 PDF 效果的还原以及各种辅助阅读的功能都相当完善。我在 macOS 和 iPad 上都使用它来阅读和管理各类技术电子书籍。 > [优惠链接 ($79.99 -> ¥199)](https://partner.lizhi.io/onevcat/tableplus) --- ### [Bartender](https://www.macbartender.com) macOS 的右上状态栏一直是“兵家必争之地”。有些 app 确实利用状态栏图标做了合适的事情,让使用 app 变得更加方便。但是也难免有一些“毒瘤”要突出自己的存在感,强制性地把自己的图标放上去,还不给用户留出选项。在 app 逐渐变多后,状态栏经常过度膨胀,杂乱无章。Bartender 正是为了解决这个问题而出现的。你可以指定折叠某些不常用的状态栏图标,或者干脆永久隐藏它们。对于 MacBook 笔记本来说,屏幕宽度本来就不像 iMac 那样可供“挥霍”,所以基本在我的笔记本上这也是保持清爽的必备 app 了。 > [优惠链接 ($15 -> ¥89)](https://partner.lizhi.io/onevcat/bartender) --- ## 后记 对于文中没有介绍到的很多工具,可能在数码荔枝也有特价出售,您可以[拿到优惠券](https://partner.lizhi.io/onevcat/cp),然后去逛一逛网店看看有没有需要。 另外,如果你还有什么值得分享的工具类 app,不论是可以帮助提高开发效率的,还是帮助更好地使用 macOS 的,都欢迎留言提出~也许通过努力,我们也可以为大家争取到国内的分销商特价,以造福国内开发者。 URL: https://onevcat.com/2018/11/defer/index.html.md Published At: 2018-11-16 10:38:00 +0900 # 关于 Swift defer 的正确使用 其实这篇文章的缘起是由于在对 [Kingfisher](https://github.com/onevcat/Kingfisher/) 做重构的时候,因为自己对 `defer` 的理解不够准确,导致了一个 bug。所以想藉由这篇文章探索一下 `defer` 这个关键字的一些 edge case。 ### 典型用法 Swift 里的 `defer` 大家应该都很熟悉了,`defer` 所声明的 block 会在当前代码执行退出后被调用。正因为它提供了一种延时调用的方式,所以一般会被用来做资源释放或者销毁,这在某个函数有多个返回出口的时候特别有用。比如下面的通过 `FileHandle` 打开文件进行操作的方法: ```swift func operateOnFile(descriptor: Int32) { let fileHandle = FileHandle(fileDescriptor: descriptor) let data = fileHandle.readDataToEndOfFile() if /* onlyRead */ { fileHandle.closeFile() return } let shouldWrite = /* 是否需要写文件 */ guard shouldWrite else { fileHandle.closeFile() return } fileHandle.seekToEndOfFile() fileHandle.write(someData) fileHandle.closeFile() } ``` 我们在不同的地方都需要调用 `fileHandle.closeFile()` 来关闭文件,这里更好的做法是用 `defer` 来统一处理。这不仅可以让我们就近在资源申请的地方就声明释放,也减少了未来添加代码时忘记释放资源的可能性: ```swift func operateOnFile(descriptor: Int32) { let fileHandle = FileHandle(fileDescriptor: descriptor) defer { fileHandle.closeFile() } let data = fileHandle.readDataToEndOfFile() if /* onlyRead */ { return } let shouldWrite = /* 是否需要写文件 */ guard shouldWrite else { return } fileHandle.seekToEndOfFile() fileHandle.write(someData) } ``` ### `defer` 的作用域 在做 Kingfisher 重构时,对线程安全的保证我选择使用了 `NSLock` 来完成。简单说,会有一些类似这样的方法: ```swift let lock = NSLock() let tasks: [ID: Task] = [:] func remove(_ id: ID) { lock.lock() defer { lock.unlock() } tasks[id] = nil } ``` 对于 `tasks` 的操作可能发生在不同线程中,用 `lock()` 来获取锁,并保证当前线程独占,然后在操作完成后使用 `unlock()` 释放资源。这是很典型的 `defer` 的使用方式。 但是后来出现了一种情况,即调用 `remove` 方法之前,我们在同一线程的 caller 中获取过这个锁了,比如: ```swift func doSomethingThenRemove() { lock.lock() defer { lock.unlock() } // 操作 `tasks` // ... // 最后,移除 `task` remove(123) } ``` 这样做显然在 `remove` 中造成了死锁 (deadlock):`remove` 里的 `lock()` 在等待 `doSomethingThenRemove` 中做 `unlock()` 操作,而这个 `unlock` 被 `remove` 阻塞了,永远不可能达到。 解决的方法大概有三种: 1. 换用 `NSRecursiveLock`:[`NSRecursiveLock`](https://developer.apple.com/documentation/foundation/nsrecursivelock) 可以在同一个线程获取多次,而不造成死锁的问题。 2. 在调用 `remove` 之前先 `unlock`。 3. 为 `remove` 传入按照条件,避免在其中加锁。 1 和 2 都会造成额外的性能损失,虽然在一般情况下这样的加锁性能微乎其微,但是使用方案 3 似乎也并不很麻烦。于是我很开心地把 `remove` 改成了这样: ```swift func remove(_ id: ID, acquireLock: Bool) { if acquireLock { lock.lock() defer { lock.unlock() } } tasks[id] = nil } ``` 很好,现在调用 `remove(123, acquireLock: false)` 不再会死锁了。但是很快我发现,在 `acquireLock` 为 `true` 的时候锁也失效了。再仔细阅读 Swift Programming Language 关于 `defer` 的描述: > A `defer` statement is used for executing code just before transferring program control outside of **the scope that the defer statement appears in**. 所以,上面的代码其实相当于: ```swift func remove(_ id: ID, acquireLock: Bool) { if acquireLock { lock.lock() lock.unlock() } tasks[id] = nil } ``` GG 斯密达... 以前很单纯地认为 `defer` 是在函数退出的时候调用,并没有注意其实是**当前 scope 退出的时候**调用这个事实,造成了这个错误。在 `if`,`guard`,`for`,`try` 这些语句中使用 `defer` 时,应该要特别注意这一点。 ### `defer` 和闭包 另一个比较有意思的事实是,虽然 `defer` 后面跟了一个闭包,但是它更多地像是一个语法糖,和我们所熟知的闭包特性不一样,并不会持有里面的值。比如: ```swift func foo() { var number = 1 defer { print("Statement 2: \(number)") } number = 100 print("Statement 1: \(number)") } ``` 将会输出: ``` Statement 1: 100 Statement 2: 100 ``` 在 `defer` 中如果要依赖某个变量值时,需要自行进行复制: ```swift func foo() { var number = 1 var closureNumber = number defer { print("Statement 2: \(closureNumber)") } number = 100 print("Statement 1: \(number)") } // Statement 1: 100 // Statement 2: 1 ``` ### `defer` 的执行时机 `defer` 的执行时机紧接在离开作用域之后,但是是在其他语句之前。这个特性为 `defer` 带来了一些很“微妙”的使用方式。比如从 `0` 开始的自增: ```swift class Foo { var num = 0 func foo() -> Int { defer { num += 1 } return num } // 没有 `defer` 的话我们可能要这么写 // func foo() -> Int { // num += 1 // return num - 1 // } } let f = Foo() f.foo() // 0 f.foo() // 1 f.num // 2 ``` 输出结果 `foo()` 返回了 `+1` 之前的 `num`,而 `f.num` 则是 `defer` 中经过 `+1` 之后的结果。不使用 `defer` 的话,我们其实很难达到这种“在返回后进行操作”的效果。 虽然很特殊,**但是强烈不建议在 `defer` 中执行这类 side effect**。 > This means that a `defer` statement can be used, for example, to perform manual resource management such as closing file descriptors, and to perform actions that need to happen even if an error is thrown. 从语言设计上来说,`defer` 的目的就是进行资源清理和避免重复的返回前需要执行的代码,而不是用来以取巧地实现某些功能。这样做只会让代码可读性降低。 URL: https://onevcat.com/2018/10/swift-result-error/index.html.md Published At: 2018-10-31 11:38:00 +0900 # Result<T> 还是 Result<T, E: Error> > 我之前在[专栏文章](https://xiaozhuanlan.com/topic/4718350296)里曾经发布这篇文章,由于这个话题其实还是挺重要的,可以说代表了 Swift 今后发展的方向流派,所以即使和专栏文章内容有些重复,我还是想把它再贴到博客来。经过半年以后,自己对于这个问题也有了更多的实践和想法,所以同时也更新了一下。我没有直接改动原文,而是把新的想法和需要补充的说明,用类似这段话的引用的方式写在合适的上下文里。 ### 开始先打个广告 我个人经常会在[数码荔枝](https://www.lizhi.io)用优惠价格购买面向中国用户的一些软件,相比于花美金直接购买,价格非常实惠。近年来国内的正版风气和对知识知识产权的尊重的进步,是有目共睹的,这很大程度上也要归功于像数码荔枝这样的分销商可以和开发商讨论出更适合国内的定价和销售策略。让我,或者让像我一样的开发者,能节省出一些奶粉钱和尿布钱... 最近我和数码荔枝有一些接触,有一些长期合作的推广。如果大家对某款软件有兴趣,不妨先到数码荔枝的店面找找看,也许能为你省下不少银子。另外也可以访问我的[推广页面领取通用的优惠券](https://partner.lizhi.io/onevcat/cp),然后再使用优惠券购买任意你中意的软件。优惠券也可以多次重复领取,任意使用。 最后,他们定期也会推出一些力度很大的半价促销,比如 ¥94 就能买到 $79.99 的 PDF Expert 这种不可思议的事情..这个促销到年底为止,如果有在 macOS 上看 PDF 又不满足于系统预览的羸弱功能和性能的小伙伴们可[千万不要错过](https://partner.lizhi.io/onevcat/pdf_expert_for_mac)。 [![](/assets/images/2018/pdf-expert.png)](https://partner.lizhi.io/onevcat/pdf_expert_for_mac) ### 背景知识 Cocoa API 中有很多接受回调的异步方法,比如 `URLSession` 的 `dataTask(with:completionHandler:)`。 ```swift URLSession.shared.dataTask(with: request) { data, response, error in if error != nil { handle(error: error!) } else { handle(data: data!) } } ``` 有些情况下,回调方法接受的参数比较复杂,比如这里有三个参数:`(Data?, URLResponse?, Error?)`,它们都是可选值。当 session 请求成功时,`Data` 参数包含 response 中的数据,`Error` 为 `nil`;当发生错误时,则正好相反,`Error` 指明具体的错误 (由于历史原因,它会是一个 `NSError` 对象),`Data` 为 `nil`。 > 关于这个事实,`dataTask(with:completionHandler:)` 的[文档的 Discussion 部分](https://developer.apple.com/documentation/foundation/urlsession/1407613-datatask)有十分详细的说明。另外,`response: URLResponse?` 相对复杂一些:不论是请求成功还是失败,只要从 server 收到了 `response`,它就会被包含在这个变量里。 这么做虽然看上去无害,但其实存在改善的余地。显然 `data` 和 `error` 是互斥的:事实上是不可能存在 `data` 和 `error` 同时为 `nil` 或者同时非 `nil` 的情况的,但是编译器却无法静态地确认这个事实。编译器没有制止我们在错误的 `if` 语句中对 `nil` 值进行解包,而这种行为将导致运行时的意外崩溃。 我们可以通过一个简单的封装来改进这个设计:如果你实际写过 Swift,可能已经对 `Result` 很熟悉了。它的思想非常简单,用泛型将可能的返回值包装起来,因为结果是成功或者失败二选一,所以我们可以藉此去除不必要的可选值。 ```swift enum Result { case success(T) case failure(E) } ``` 把它运用到 `URLSession` 中的话,包装一下 `URLSession` 方法,上面调用可以变为: ```swift // 如果 Result 存在于标准库的话, // 这部分代码应该由标准库的 Foundataion 扩展进行实现 extension URLSession { func dataTask(with request: URLRequest, completionHandler: @escaping (Result<(Data, URLResponse), NSError>) -> Void) -> URLSessionDataTask { return dataTask(with: request) { data, response, error in if error != nil { completionHandler(.failure(error! as NSError)) } else { completionHandler(.success((data!, response!))) } } } } URLSession.shared.dataTask(with: request) { result in switch result { case .success(let (data, _)): handle(data: data) case .failure(let error): handle(error: error) } } ``` > 这里原文代码中 `completionHandler` 里 `(Result<(Data, URLResponse), NSError>) -> Void)` 这个类型是错误的。`Data` 存在时 `URLResponse` 一定存在,但是我们上面讨论过,当 `NSError` 不为 `nil` 时,`URLResponse` 也可能存在。原文代码忽略了这个事实,将导致 error 状况时无法获取到可能的 `URLResponse`。正确的类型应该是 `(Result<(Data), NSError>, URLResponse?) -> Void` > > 当然,在回调中对 `result` 的处理也需要对应进行修改。 调用的时候看起来很棒,我们可以避免检查可选值的情况,让编译器保证在对应的 `case` 分支中有确定的非可选值。这个设计在很多存在异步代码的框架中被广泛使用,比如 [Swift Package Manager](https://github.com/apple/swift-package-manager/blob/master/Sources/Basic/Result.swift),[Alamofire](https://github.com/Alamofire/Alamofire/blob/master/Source/Result.swift) 等中都可觅其踪。 > 上面代码注释中提到,「如果 Result 存在于标准库的话,这部分代码应该由标准库的 Foundataion 扩展进行实现」。但是考虑到原有的可选值参数 (`(Data?, URLResponse?, Error?)`) 作为回调的 API 将会共享同样的函数名,所以上面的函数命名是不可取的,否则将导致冲突。在这类 public API 发布后,如何改善和迭代确实是个难题。一个可行的方法是把 Foundation 的 `URLSession` deprecate 掉,提取出相关方法放到诸如 Network.framework 里,并让它跨平台。另一种可行方案是通过自动转换工具,强制 Swift 使用 `Result` 的回调,并保持 OC 中的多参数回调。如果你正在打算使用 `Result` 改善现有设计,并且需要考虑保持 API 的兼容性时,这会是一个不小的挑战。 ### 错误类型泛型参数 如此常用的一个可以改善设计的定义,为什么没有存在于标准库中呢?关于 `Result`,其实已经有[相关的提案](https://github.com/apple/swift-evolution/pull/757): ![](/assets/images/2018/result-type.png) 这个提案中值得注意的地方在于,`Result` 的泛型类型只对成功时的值进行了类型约束,而忽略了错误类型。给出的 `Result` 定义类似这样: ```swift enum Result { case success(T) case failure(Error) } ``` 很快,在 1 楼就有人质疑,问这样做的意义何在,因为毕竟很多已存在的 `Result` 实现都是包含了 `Error` 类型约束的。确定的 `Error` 类型也让人在使用时多了一份“安全感”。 不过,其实我们实际类比一下 Swift 中已经存在的错误处理的设计。Swift 中的 `Error` 只是一个协议,在 throw 的时候,我们也并不会指明需要抛出的错误的类型: ```swift func methodCanThrow() throws { if somethingGoesWrong { // 在这里可以 throw 任意类型的 Error } } do { try methodCanThrow() } catch { if error is SomeErrorType { // ... } else if error is AnotherErrorType { // ... } } ``` 但是,在带有错误类型约束的 `Result` 中,我们需要为 `E` 指定一个确定的错误类型 (或者说,Swift 并不支持在特化时使用协议,`Result` 这样的类型是非法的)。这与现有的 Swift 错误处理机制是背道而驰的。 > 关于 Swift 是否应该抛出带有类型的错误,曾经存在过一段时间的争论。最终问题归结于,如果一个函数可以抛出多种错误 (不论是该函数自身产生的错误,还是在函数中 try 其他函数时它们所带来的更底层的错误),那么 `throws` 语法将会变得非常复杂且不可控 (试想极端情况下某个函数可能会抛出数十种错误)。现在大家一致的看法是已有的用 `protocol Error` 来定义错误的做法是可取的,而且这也编码在了语言层级,我们对「依赖编译器来确定 `try catch` 会得到具体哪种错误」这件事,几乎无能为力。 > > 另外,半开玩笑地说,要是 Swift 能类似这样 `extension Swift.Error: Swift.Error {}`,支持协议遵守自身协议的话,一切就很完美了,XD。 ### 选择哪个比较好? 两种方式各有优缺点,特别在如果需要考虑 Cocoa 兼容的情况下,更并说不上哪一个就是完胜。这里将两种写法的优缺点简单比较一下,在实践中最好是根据项目情况进行选择。 #### Result ##### 优点 1. 可以由编译器帮助进行确定错误类型 当通过使用某个具体的错误类型扩展 `Error` 并将它设定为 `Result` 的错误类型约束后,在判断错误时我们就可以比较容易地检查错误处理的完备情况了: ```swift enum UserRegisterError: Error { case duplicatedUsername case unsafePassword } userService.register("user", "password") { result: Result in switch result { case .success(let user): print("User registered: \(user)") case .failure(let error): if error == .duplicatedUsername { // ... } else if error == .unsafePassword { // ... } } } ``` 上例中,由于 `Error` 的类型已经可以被确定是 `UserRegisterError`,因此在 `failure` 分支中的检查变得相对容易。 > 这种编译器的类型保证给了 API 使用者相当强的信心,来从容进行错误处理。如果只是一个单纯的 `Error` 类型,API 的用户将面临相当大的压力,因为不翻阅文档的话,就无从知晓需要处理怎样的错误,而更多的情况会是文档和事实不匹配... > > 但是带有类型的错误就相当容易了,查看该类型的 public member 就能知道会面临的情况了。在制作和发布框架,以及提供给他人使用的 API 的时候,这一点非常重要。 2. 按条件的协议扩展 使用泛型约束的另一个好处是可以方便地对某些情况的 `Result` 进行扩展。 举例来说,某些异步操作可能永远不会失败,对于这些操作,我们没有必要再使用 switch 去检查分支情况。一个很好的例子就是 `Timer`,我们设定一个在一段时间后执行的 Timer 后,如果不考虑人为取消,这个 Timer 总是可以正确执行完毕,而不会发生任何错误的。我们可能会选择使用一个特定的类型来代表这种情况: ```swift enum NoError: Error {} func run(after: TimeInterval, done: @escaping (Result) -> Void ) { Timer.scheduledTimer(withTimeInterval: after, repeats: false) { timer in done(.success(timer)) } } ``` 在使用的时候,本来我们需要这样的代码: ```swift run(after: 2) { result in switch result { case .success(let timer): print(timer) case .failure: fatalError("Never happen") } } ``` 但是,通过对 `E` 为 `NoError` 的情况添加扩展,可以让事情简单不少: ```swift extension Result where E == NoError { var value: T { if case .success(let v) = self { return v } fatalError("Never happen") } } run(after: 2) { // $0.value is the timer object print($0.value) } ``` > 这个 `Timer` 的例子虽然很简单,但是可能实际上意义不大,因为我们可以直接使用 `Timer.scheduledTimer` 并使用简单的 block 完成。但是当回调 block 有多个参数时,或者需要链式调用 (比如为 `Result` 添加 `map`,`filter` 之类的支持时),类似 `NoError` 这样的扩展方式就会很有用。 > 在 NSHipster 里有一篇[关于 `Never` 的文章](https://nshipster.com/never/),提到使用 `Never` 来代表无值的方式。其中就给出了一个和 `Result` 一起使用的例子。我们只需要使 `extension Never: Error {}` 就可以将它指定为 `Result` 的第二个类型参数,从而去除掉代码中对 `.failure` case 的判断。这是比 `NoError` 更好的一种方式。 > > 当然,如果你需要一个只会失败不会成功的 `Result` 的话,也可以将 `Never` 放到第一个类型参数的位置:`Result`。 ##### 缺点 1. 与 Cocoa 兼容不良 由于历史原因,Cocoa API 中表达的错误都是”无类型“的 `NSError` 的。如果你跳出 Swift 标准库,要去使用 Cocoa 的方法 (对于在 Apple 平台开发来说,这简直是一定的),就不得不面临这个问题。很多时候,你可能会被迫写成 `Result` 的形式,这样我们上面提到的优点几乎就丧失殆尽了。 2. 可能需要多层嵌套或者封装 即使对于限定在 Swift 标准库的情况来说,也有可能存在某个 API 产生若干种不同的错误的情况。如果想要完整地按照类型处理这些情况,我们可能会需要将错误嵌套起来: ```swift // 用户注册可能产生的错误 // 当用户注册的请求完成且返回有效数据,但数据表明注册失败时触发 enum UserRegisterError: Error { case duplicatedUsername case unsafePassword } // Server API 整体可能产生的错误 // 当请求成功但 response status code 不是 200 时触发 enum APIResponseError: Error { case permissionDenied // 403 case entryNotFound // 404 case serverDied // 500 } // 所有的 API Client 可能发生的错误 enum APIClientError: Error { // 没有得到响应 case requestTimeout // 得到了响应,但是 HTTP Status Code 非 200 case apiFailed(APIResponseError) // 得到了响应且为 200,但数据无法解析为期望数据 case invalidResponse(Data) // 请求和响应一切正常,但 API 的结果是失败 (比如注册不成功) case apiResultFailed(Error) } ``` > 上面的错误嵌套比较幼稚。更好的类型结构是将 `UserRegisterError` 和 `APIResponseError` 定义到 `APIClientError` 里,另外,因为不会直接抛出,因此没有必要让 `UserRegisterError` 和 `APIResponseError` 遵守 `Error` 协议,它们只需要承担说明错误原因的任务即可。 > > 对这几个类型加以整理,并重新命名,现在我认为比较合理的错误定义如下 (为了简短一些,我去除了注释): > > ```swift > enum APIClientError: Error { > > enum ResponseErrorReason { > case permissionDenied > case entryNotFound > case serverDied > } > > enum ResultErrorReason { > enum UserRegisterError { > case duplicatedUsername > case unsafePassword > } > > case userRegisterError(UserRegisterError) > } > > case requestTimeout > case apiFailed(ResponseErrorReason) > case invalidResponse(Data) > case apiResultFailed(ResultErrorReason) > } > ``` > > 当然,如果随着嵌套过深而缩进变多时,你也可以把内嵌的 `Reason` enum 放到 `APIClientError` 的 extension 里去。 上面的 `APIClientError` 涵盖了进行一次 API 请求时所有可能的错误,但是这套方式在使用时会很痛苦: ```swift API.send(request) { result in switch result { case .success(let response): //... case .failure(let error): switch error { case .requestTimeout: print("Timeout!") case .apiFailed(let apiFailedError): switch apiFailedError: { case .permissionDenied: print("403") case .entryNotFound: print("404") case .serverDied: print("500") } case .invalidResponse(let data): print("Invalid response body data: \(data)") case .apiResultFailed(let apiResultError): if let apiResultError = apiResultError as? UserRegisterError { switch apiResultError { case .duplicatedUsername: print("User already exists.") case .unsafePassword: print("Password too simple.") } } } } } ``` 相信我,你不会想要写这种代码的。 > 经过半年的实践,事实是我发现这样的代码并没有想象中的麻烦,而它带来的好处远远超过所造成的不便。 > > 这里代码中有唯一一个 `as?` 对 `UserRegisterError` 的转换,如果采用更上面引用中定义的 `ResultErrorReason`,则可以去除这个类型转换,而使类型系统覆盖到整个错误处理中。 > > 相较于对每个 API 都写这样一堆错误处理的代码,我们显然更倾向于集中在一个地方处理这些错误,这在某种程度上“强迫”我们思考如何将错误处理的代码抽象化和一般化,对于减少冗余和改善设计是有好处的。另外,在设计 API 时,我们可以提供一系列的便捷方法,来让 API 的用户能很快定位到某几个特定的感兴趣的错误,并作出处理。比如: > > ```swift > extension APIClientError { > var isLoginRequired: Bool { > if case .apiFailed(.permissionDenied) = self { > return true > } > return false > } > } > ``` > > 用 `error.isLoginRequired` 即可迅速确定是否是由于用户权限不足,需要登录,产生的错误。这部分内容可以由 API 的提供者主动定义 (这样做也起到一种指导作用,来告诉 API 用户到底哪些错误是特别值得关心的),也可以由使用者在之后自行进行扩展。 另一种”方便“的做法是使用像是 `AnyError` 的类型来对 `Error` 提供封装: ```swift struct AnyError: Error { let error: Error } ``` 这可以把任意 `Error` 封装并作为 `Result` 的 `.failure` 成员进行使用。但是这时 `Result` 中的 `E` 几乎就没有意义了。 > Swift 中存在不少 `Any` 开头的类型,比如 `AnyIterator`,`AnyCollection`,`AnyIndex` 等等。这些类型起到的作用是类型抹消,有它们存在的历史原因,但是随着 Swift 的发展,特别是加入了 Conditional Conformance 以后,这一系列 `Any` 类型存在的意义就变小了。 > > 使用 `AnyError` 来进行封装 (或者说对具体 Error 类型进行抹消),可以让我们抛出任意类型的错误。这更多的是一种对现有 Cocoa API 的妥协。对于纯 Swift 环境来说,`AnyError` 并不是理想中应该存在的类型。因此如果你选择了 `Result` 的话,我们就应该尽可能避免抛出这种无类型的错误。 > > 那问题就回到了,对于 Cocoa API 抛出的错误 (也就是以前的 `NSError`),我们应该怎样处理?一种方式是按照文档进行封装,比如将所有 `NSURLSessionError` 归类到一个 `URLSessionErrorReason`,然后把从 Cocoa 得到的 `NSError` 作为关联值传递给使用者;另一种方式是在抛出给 API 使用者之前,在内部就对这个 Cocoa 错误进行“消化”,将它转换为有意义的特定的某个已经存在的 Error Reason。后者虽然减轻了 API 使用者的压力,但是势必会丢失一些信息,所以如果没有特别理由的话,第一种的做法可能更加合适。 3. 错误处理的 API 兼容存在风险 现在来说,为 enum 添加一个 case 的操作是无法做到 API 兼容的。使用侧如果枚举了所有的 case 进行处理的话,在 case 增加时,原来的代码将无法编译。(不过对于错误处理来说,这倒可能对强制开发者对应错误情况是一种督促 233..) 如果一个框架或者一套 API 严格遵守 [semantic version](https://semver.org) 的话,这意味着一个大版本的更新。但是其实我们都心知肚明,增加一个之前可能忽略了的错误情况,却带来一个大版本更新,带来的麻烦显然得不偿失。 Swift 社区现在对于增加 enum case 时如何保持 API compatibility 也有一个[成熟而且已经被接受了的提案](https://github.com/apple/swift-evolution/blob/af284b519443d3d985f77cc366005ea908e2af59/proposals/0192-non-exhaustive-enums.md)。将 enum 定义为 `frozen` 和 `nonFrozen`,并对 `nonFrozen` 的 enum 使用 `unknown` 关键字来保证源码兼容。我们在下个版本的 Swift 中应该就可以使用这个特性了。 #### Result 不带 `Error` 类型的优缺点正好和上面相反。 相对于 `Result`,`Result` 不在外部对错误类型提出任何限制,API 的创建者可以摆脱 `AnyError`,直接将任意的 `Error` 作为 `.failure` 值使用。 但同时很明显,相对的,一个最重要的特性缺失就是我们无法针对错误类型的特点为 `Result` 进行扩展了。 ### 结论 因为 Swift 并没有提供使用协议类型作为泛型中特化的具体类型的支持,这导致在 API 的强类型严谨性和灵活性上无法取得两端都完美的做法。硬要对比的话,可能 `Result` 对使用者更加友好一些,因为它提供了一个定义错误类型的机会。但是相对地,如果创建者没有掌握好错误类型的程度,而将多层嵌套的错误传递时,反而会增加使用者的负担。同时,由于错误类型被限定,导致 API 的变更要比只定义了结果类型的 `Result` 困难得多。 不过 `Result` 暂时看起来不太可能被添加到标准库中,因为它背后存在一个更大的协程和整个语言的异步模型该如何处理错误的话题。在有更多的实践和讨论之前,如果没有革命性和语言创新的话,对如何进行处理的话题,恐怕很难达成完美的共识。 结论:错误处理真的是一件相当艰难的事情。 > 最近这半年,在不同项目里,我对 `Result` 和 `Result` 两种方式都进行了一些尝试。现在看来,我会更多地选择带有错误类型的 `Result` 的形式,特别是在开发框架或者需要严谨的错误处理的时候。将框架中可能抛出的错误进行统一封装,可以很大程度上减轻使用者的压力,让错误处理的代码更加健壮。如果设计得当,它也能提供更好的扩展性。 URL: https://onevcat.com/2018/10/diary/index.html.md Published At: 2018-10-23 10:11:26 +0900 # 十年前的日记们 ## 假如我有时光机 最近把工作上的事情忙完了,也把主机从美国换到了日本的机房,解决了国内的访问问题,所以准备开始好好重新拾掇一下,恢复定期更新 blog。 其实我从大学时就有开始写 blog 的习惯了。不过不像最近的独立博客,那时候更多地是用新浪或者搜狐这样的平台,所以也就在那些地方也留下了不少“足迹”。既然是自己“存在过的证明”,我想可能还是把它们汇总一下,留个存档为好。于是就有了这篇和“技术”没什么关系的文章。 这里面是我从 2006 年底到 2010 年三月期间的一些碎碎念,时间跨越从大二上半学期开始,到研究生二年级为止,大概三年半的时间。在此之前的文章大约已经佚失,不再可寻了,此后的文章在本站的最后几页应该可以看到。所以这一系列日记的集合,把这个站点的生命向前拓展了四年,也算是对自己青涩时光的一些记录和回顾。 大部分其实都是碎碎念,抱怨学习生活的枯燥和繁重,各种姿势求安慰求抚摸。还有一些是少年得志的优越感的展现,以及一些年轻气盛的骄傲。不过其中还是夹杂了一些观点,里面有一些,现在看来可能很幼稚,但是这确实是我当时的想法。而更多的思考,则坚实地成为了自己这一路走来的基盘,至今依然指导着我的行为和前进方向。 所谓“人不中二枉少年”,能在十年后的今天,翻出这些真实记载了自己是怎样一个人的日记,幸甚幸甚。所以将它们加以整理,一并发在这里,一方面可以杜绝原有平台消失的风险,一方面也可以作为留念,提醒自己的过去。 当然,同时也可以满足读者的好奇心...(如果大家对这个平淡无奇的我的过去,有所好奇的话。) ## 十年前的日记们 ### 2006-12-21 今天13点,一个伟大的时刻,历时三周后,我终于完成了长达33页的数字电路大作业报告。欣喜若狂; 今天13点01分,一个无语的时刻,我可爱的报告被舍友强行“征用”,不知道应该高兴还是沮丧。万般无奈。 不过在一个小时后,我又投入了另一项伟大的工作——烧开水中...好吧,烧开水的学名叫做“用传感器测空气相对压力系数”。其实觉得物理的实验挺无聊的,照章办事而已,上至仪器原理,下到步骤数据,手把手教给你,这真的能够达到那传说中的“培养学生独立实验能力”的美好目的么?我不知道...我只知道要是把这本实验指导书弄丢了,我的实验课绝对得挂。但是同样是实验,电子信息技术的就要好很多。虽说插面包板实在是郁闷,但是没有了教条一般的实验指导,感觉确实要自由许多……至少我可以很大气的和老师说:“弄出来了就行,你管我怎么做DI~” 而不用埋头对着一个不是温度表的表去烧开水...读不够数据还要重烧...浪费资源,没记错的话北京好像是缺水城市.. --- 说实话,我还从来没像现在这样忙过。期末选修课一共有三篇论文,还没动笔;这周末有英语考试;下周需要交两个实验报告;还要准备期末的主课考试了。数学物理方程完全没有概念,也许等我能把勒让德方程和贝塞尔函数以及各种积分变换法烂熟于胸,就能拿到个很高的成绩吧。但是现实是,我关注的是去年这门课的挂课人数是15人。还好,15人,应该还轮不到我...但是要是万一轮到了呢...可恶的正态分布..还有更可恶的麦克斯韦分布... --- 赶论文吧... > Ye, 全过了!棒,去吃好吃的... --- ### 2006-12-22 昨天给一些朋友发短信,把这个blog公布出去。没想到的是我刚一发完,就同时收到了五六条回复。看来大家都是把手机带身边的,回头看看自己,经常会有连续充电一星期的事情发生...恩恩,我比较宠爱电池啦~一定要把它喂得饱饱的 😂 今天很郁闷,起了个大早,五点多的样子吧。别以为我伟大,我是被吓醒的...做了恶梦..梦到厄~梦到一个D触发器坏了,然后实验出不来结果...再然后,就吓醒了...很没志气,居然这都会被吓醒。更要命的是,越想重新睡着,就越来越清醒....索性起床。心里还是很不平衡,在冬至这样一年中黑夜最长的日子里,我却...那么早就... 不过后来就平衡了。在我睡了整节的复变函数课和后半节的数字电路课,回到宿舍之后,发现了睡眼惺忪的舍友们问我有没有吃午饭。恩,我没撬任何课,而且和他们享受睡眠的时间相差无几。赚了... 明天英语考试,祝大家好运~也祝自己好运... ![](/assets/images/2006/locker.jpg) 数字电路大作业第三题仿真电路图,这个电路实现了一个密码锁的功能~~嘀嘀嘟嘟嘟~~ 咔哒~ (薄薄一张电路图,引无数英雄尽折腰。偷看隔壁宿舍的孩子们埋头苦画,窃笑...今天是Deadline,不知道他们在12点前能不能搞定) 看来一切还是提前做完比较好,嘿嘿 ^_^ --- ### 2007-02-09 呀呀呀...果然是没有定时更新的习惯。回家以后睡太爽,直接把Blog忘了...罪过罪过 今天和杨扬李宁去打了两个小时的羽毛球,长久不动,稍微出点汗,好舒服的。宁宁上次去看老班就见过了,杨扬也没有多大变化,就是开始留长头发了``呵呵 中午一起去车车吃了饭,路上说道主人还曾经在MAMAFU打过工`在门口迎宾,好厉害啊..有空的话,我也想去尝试一下,找份事情做做..不过估计不会要我迎宾吧.难说去算帐也不错哦。 今天2月9号了,不知不觉这个假期就过了整整一半了。还是很颓废,本来说假期里多看看下学期的书的,但是总是睡太多..不管了,好好休息也是应该的嘛。有个广告怎么说来着:一个月夏威夷的海滩度假,是为了一年纳斯达克的XXXX...人家都夏威夷了,我只不过是在家多睡了几小时,,不为过哉。可是..还是..很不..恩..因为现在的做法和最初的想法还是有了差距,心中有些不安。 这个假期可能组织不起大规模的同学聚会了,忙着过年的啊什么的,好多人都不在昆明。不过最想见的人都已经见到了的,所以也没太大关系了。另外,李宁计划5月长假去南京,恩,我犹豫中。到时候再看吧.. 呀.该喝午茶了~~~ --- ### 2007-02-11 阳光依然灿烂,提前体会到了夏天的感觉。 昆明的天气,就是很怪。全国未冷昆明先寒,全国未热昆明先燥。在好多地方经历了一个暖冬,最近开始有降温趋势的时候,昆明却从几年不遇的严寒中摇身一变,以火热的胸怀迎接了远方的来客。 **骄阳。** 西大堤的红嘴鸥飞翔漫天,二十年了吧,这些小生灵年年不殆,飞越几千里,来到这高山上的明珠度过一个惬意的冬季。开春之时,一片白色之间已然看不到跋涉的辛苦,有的只是丰满的羽毛和亮丽的身姿。昆明人给了它们安定的环境,这些精灵们用自己完美的表演给了人们欢笑,这也算是最好的回报。 **和风。** 北京不似昆明。京城的冬天,肃杀和萧索是主旋律,深秋毫不留情的带走了一切绿色,严冬便肆意妄为,人们只好躲到大衣里,藏到房间里。昆明一年四季都有绿色的,即使是在如今年这样的冬天。也大概是如此,才让本来应当狂虐嚣张的风有了一些的收敛。正是这一些的收敛,风的性质便全然不同了。寒风凛冽变成了微风拂面,心情自然也就不同。昆明果然是一座闲适的城市。 --- ### 2007-03-16 #### 题目 求证:一维束缚态的波函数相位必是常数 #### 证明 ``` 由共轭定理, ψ*(x)也是方程的解 由于是一维束缚态,对于给定的E,该能级是非简并的 设ψ*(x)=Cψ(x) ① 同时取共轭 有ψ(x)=|C|^2ψ(x) |C|=1,即C=exp(iα) 代入①式 得 ρ(x)exp(-iθ(x))=ρ(x)exp(iα+iθ(x)) 所以 θ=kπ-α/2 是与x无关的常数 ``` 证毕。 > 几行的证明,用了一天时间……今天体会到,量子力学量力学了………… --- ### 2007-03-24 好累... 才第三周,作业快做不完了 编一个星期的C++,憔悴中... 休息... 不知道还能坚持多久; 大概是,真的老了吧..呵呵...或许是未老先衰? 已经开始期待五一了。 --- ### 2007-03-28 手球果然是危险的运动 昨天打手球,估计是冲动了一点点,把手给拧了。 当时没觉得怎么,后来越来越不对,到了晚上直接小严重,左手完全不能动了...痛得一晚上没有合眼。啊啊好困...又睡不着,真惨哦 今早的课...算了吧,去了也是睡。 而且现在刚刚有点好转,要是在去的路上又弄到的话,估计这手是真的废了。 恩,开始学习吧... 大家做运动的时候一定要小心啊 (单手打字还真是难受,看来是只适合看书这种不需要左手的工作了) --- ### 2007-04-05 紫荆是清华的代表,不过这次却被玉兰抢了先机。去六教的路上偶然发现已然满树玉兰,才知道春天真的到了。不知道为什么,感觉玉兰是一种挺忧伤的花,洁白无暇,却长在这污浊之世。据说玉兰花开过之后,花瓣就会渐渐变黑,似是沾染了污秽。想来也很可惜,原本好好的花,却最终是要不复洁白的——恰如人之在世吧。真正能出淤泥而不染,濯清涟而不妖的,又有几人? 玉兰花还有一大特点是没有叶衬,远望去就如满树白鸽,自守高洁。春风起时,又是落英缤纷的洒脱。莫约一周之后,待得满地花儿落尽,才开始不紧不慢的长叶,就好像那些花儿从来不存在过一样。也许真的简单才是美,安静才是美.... “朝饮木兰之坠露兮,夕餐菊之落英” 是《离骚》中说玉兰的句子。不知道屈原如何做到的早春暮秋,跨越时空的振颤我自然是体会不到了,惟能做到的,也就是将这一树玉兰凝为永恒了。 --- ### 2007-04-08 自从上周手受伤以后就一直颓废ing 改变,,,,要努力,,,,不能放弃!! 下周开始每天都去自习,就这样,嗯! --- ### 2007-04-10 仔细看了《逆光》的歌词,蛮喜欢。一种给人希望的感觉.... 往往是在刺眼的逆光中,才能看到一些真正的感情,那应当是别人无意之间的流露吧。 也许我需要一点光芒? 摇头,笑。 呵呵,其实谁又不需要呢? > 我一直害怕有答案 > > 也许爱情轻轻在风里打转 > > 离开释怀很短暂又重来 > > 于是乎自问自答 > > > 我不要困难把我们击散 > > 我自卑自己那么不勇敢 > > 遗憾没有到达 拥抱过还是害怕 > > 用力推开你我依然留下 > > > 有一束光,那瞬间,是什么痛的刺眼! > > 你的视线是谅解为什么舍不得熄灭 > > 我逆着光,却看见 > > 那是泪光,那力量,我不想再去抵挡! > > 面对希望,逆着光,感觉爱存在的地方 > > 一直就在,我身旁! > > > 我以为我不后退反复证明这份爱有多不对 > > 背对着你如此漆黑 > > 忍住疲惫 睁开眼 打开窗 才发现你就是光芒 --- ### 2007-04-18 记得以前自己是很爱收拾的。书桌一向很干净,容不下一点灰尘...看看现在自己杂乱的桌子,发现变化真是可怕。是太忙,或是太懒 =_= Hilbert空间,Guassian波包、强反型层和栅氧电荷、Markov链、各种运放....迷茫....真不知道这些东西除了说出来吓人以外还能有啥太大用途..(好像有点矛盾,最近貌似是天天在用凌乱思绪)... 今天去PSpice实验居然把水杯丢了....不爽 不爽啊不爽!~呜喵...爪子,抓! --- ### 2007-04-22 这几天挺郁闷的。(不过貌似每天都很郁闷,郁闷综合症,笑~~) 前天卡巴斯基的报告,有量子力学冲突,听不到,错过。 昨天比尔盖茨的演讲,有模电实验冲突,去不了,错过。 今天火箭爵士的比赛,有英语考试冲突,看不成,错过。 好像今天还有Ayumi的演唱会吧...就只能遥望上海方向发半分钟呆了,继续错过。 王菲唱了,错也是一种美...虽然那个是犯错而非错过.. ​ 下周期中考试,又是检验半学期成果的时候了。这半学期自己有很努力的学习,希望能有个不错的结果吧。 昨天和班上某牛聊天,才发现自己和人家差得不是一个档次。聊天说起做作业,当我还在对某一道题目研究的时候,人家说的是,“作业嘛~做完了以后我还是会去翻翻答案,看看答案有没有写错的”...为啥我先想到的是看看自己有没有错捏,差距啊差距~~ 牛大概是不需要理由的吧....有幸和连续两年的年段第一一个班,从来没见他看过课本上过课做过作业,一到考试,7科5满,最低98...自己就只能在一边望天兴叹了..=_= 貌似牛是天生的... 不过每天和各种大牛在一起也好,,,,幻想某天自己也变成牛.... 厄...不对,我是猫,怎么能够错变成牛呢? --- ### 2007-04-25 所谓旧馆,乃老图书馆,新馆东侧是也。那可是代代THU学生的一块自习圣地。 今日在旧馆奋斗于信号与系统之中,忽见一ppmm从身边飘过。此mm黑色上衣,短裙,且绝对可算得上是天使面容,魔鬼身材。偶不禁感慨:“THU能见到这景象也是不容易啊。”何况在旧馆这自习之圣地。mm坐的位置离偶不远,走之前,偶又恋恋不舍地瞟此mm一眼。发现其看书的姿势也颇为淑女。但总觉得这一瞟给偶留下了一定的心理阴影,并之后百思不得其解为什么。到宿舍了,突然意识到了问题的所在: 那mm正在看的书是: 《电路原理》 不禁感慨:“大概也就THU能见到这景象了……” --- ### 2007-05-01 又一个五一。 该走的都走了。每每幻想自己能独自“霸占”这间30平米的屋子,尽情做想做的事情,这次五一居然成了真。做完程序设计的大作业,刚才到园子里逛了逛,回来还觉着不够,搬了椅子,拿了茶点到阳台赏起月来,真没想到自己还有这般如此的兴致~^O^ 每次五一十一开始之时,都觉着7天漫漫假期,总可以做出一些事情。但是往往倏忽之间,便已到假期之末,回首一周,时光皆为虚度。不知道自己现在是否还可以称作是青春年少,不知道自己现在是否还有资格说什么天长地久,知道的,只是北坡之上的那轮狡黠的月亮,只是钟表之中那嘀嗒流过的声音。记忆中上次赏月已是高二了,晚自习后,往草坪上那么一躺,忘掉整天的烦恼,呼吸绿色的芬芳,伴着花儿虫儿,陪着鸟儿鱼儿,一起看一轮月亮。幻想着自己成为云彩,拥有那份变化莫测的飘逸;或者是成为星星,享受永不磨灭的光辉。不过现在赏月的心境与那时的浪漫天真却有不同了。没了那随意一躺得洒脱,没了那无忧无虑的幻想。看一轮圆月,不自然地就想起家人和朋友;估计看到残月的话,会更悲哀吧。大概还是受传统文化影响深了……只能是但愿人长久,千里共婵娟了。 晚上的清华园确实很静,朱老先生的荷花塘便更加得静了——那本就是人所罕至之处。可惜的是荷二楼一缕灯光,却使得塘子辛苦倒映的月黯淡无光,丢掉了本来的意境。时候还没到,花都没有开,不过好像已经含苞待放了,再有机会闲逛的荷塘的话,兴许能够体验一次朱老的月下荷塘吧。 这几天园子里的游人明显多了,不说那些在二校门前搔首弄姿的散客,单说团体大规模出动,我就碰上好几次。先是什么什么高中的来参观一次,中午的时候在观畴园门口停了两大排自行车;然后换什么什么小学来,所有孩子戴着小黄帽,在大礼堂门口拍照,也颇算一套新风景;更令人叫绝得是,什么什么幼儿园居然也全员出动,开了3辆大巴直杀主楼而去,孩子们一个一个手拉手往主楼一过,弄得差点实验迟到...sigh...都想着进清华...其实,清华真的是个要人折寿的地方。没有下决心拼命的,没有想清楚即使你拼了命也不一定有好结果的,也没有必要来了。每次看上课名单上什么02年入学的还在重修的,就会为他们悲哀——在一个不属于自己的舞台上舞蹈,又怎么能够精彩?其实要是换一个学校,换一个不是那么能让人疯狂的学校,结果会更好。 今天去还了考信号前借的参考书,那图书馆得阿姨用一种很奇怪的眼神看我,想了好久不明白为什么。后来在翻准备五一借回来看的书的时候才反应过来,原来是奇怪我怎么才借3天就还...因为清华的习惯向来是满一个月还得拖3天才还书的...恩,我知道了...下次记得,图书馆不催的书,坚决不还。嗯,就这么决定了。 --- ### 2007-05-12 考完收工。 还不错,心情和天气一样,晴晴的。半个学期的努力似乎是没有白费```` 让考试占去太多时间了,现在终于能做些自己的事情了...吼下先。开始玩! --- ### 2007-05-15 额滴神啊...终于找到时间写写blog了 本来以为期中考以后会闲一阵子,都准备好疯玩几天了,但现在看来不仅没得闲,反而被因为考试所落下的很多事情一涌而来给冲的找不着南北了。甲级团支部的评比、电子技术实验的仿真和CAD作业、与期中以前完全不同的学习内容....还好传说中的程序大作业在五一就做好了,要不真不知道应该怎么对付了..莫非叫我去抄?哼哼....不是我的风格呐!坚决自己做! 最近浮躁得很,大概和天气热了也有关系....做不出题的时候越来越多了,设计失败的时候越来越多了,生气的时候也越来越多了,直接后果就是开始长痘...烦得很。有时候会胡思乱想一些东西,结果也没想出个结果..浪费挺多时间的,划不来阿划不来。心果然还是要静才行,很多时候甘于寂寞才能成大气候,这句话是不错的... 专业分流的事情越来越近了,说实话我还没想好到底去哪里。电信每天就分析波传输啊各种变换啊,微电弄半导体啊集成电路啊什么的....随大流的话,就去电信咯,但是据说电信数学用得挺多...怕...但是去微电的话,估计身边会少很多大牛吧..也是一种损失。踌躇中....偏向微电一些吧..喜欢搞搞设计...去电信的话,也许要面对的都是什么“两三百年前的手算技巧”..会蛮无趣吧- -呵呵.只能再看咯。 不知道大家都过得怎么样...好想你们。感觉好多人假期里都要去上GRE啊或者做一些实践,估计回家的时间会比较少了吧。还是希望假期能好好聚一聚...虽然我时间也不是很多 =_= 唔...假期实践,3周FPGA,2周MatLab,应该能学不少东西的,期待,,,, 不过好像还早..那是8月份的事情了呵呵。 只能到这里了....要去做实验了....可恨又可爱的电路板.. --- ### 2007-05-19 I am lost. Who can save me... --- ### 2007-05-20 昨天和前天情绪都不是很好,今早起来的时候终于发现原因了——睡眠不足啊。昨天睡了将近10个小时,早上起来以后又去小跑了一圈,很舒服,好多了。吼吼...没有什么是美美睡一觉解决不了的,如果有,那就睡两觉。 可能是退化了,现在好像不能像以前那样很随意的控制自己的情绪了,经常会做出一些很感性的事情来,不知道是好还是不好。或者我本来就缺乏感性,这样正好弥补了一点缺点?也许吧..不知道=_= 前些天去校内注了个册报了个到,但是我不喜欢那里的感觉,太公众吧,觉得乱乱的...还是蜷回自己的小窝比较好~已经有一种很亲切的感觉了。校内那边估计不太会去的了,不过好像我看高中的同学基本也都不怎么弄校内的,大学的同学还多一些...恩,不管怎么样,就先这样吧。 今天晚上甲级团支部评比了,团工作一年,其实成败就在此定论。觉着挺假的...一年组织团活动,绞尽脑汁安排内容,想方设法抽出时间,结果感觉基本是没有达到什么预想中的活动目的的,最后就仅只是有几张照片几份资料去参加所谓的评选而已。而最后的评比结果很大程度取决于报告做得怎么样,却不关心活动的效果怎么样。面子工程...实际上这样的评比流于形式了。但是还是得参加...去年我们评上了,今年的班团架子压力也不小。全班同学翘首期待的....厄,说实际一点,数目不少的奖金....还是得去拿。不过嘛..今晚就作陈述了,着急也没用的啦~~~ 评比一完,今年的团工作也就算是彻底over啦。可以卸任,专心做其他的事情了~OYOY 翻了翻以前的那篇《人淡如菊》,决定把它打印出来贴在衣柜上....好多事情现在自己处理的很是不好,反省..反省... --- ### 2007-05-21 天气,晴。 到昨天晚上为止,本学期的团工作算是基本结束了。年级评选的时候,我真的体会到了什么叫做王道...九班一出,谁与争锋...香香的粉PP的PPT,昭时超级严谨的展示,班上所有同学的不懈努力,让这个第一来得如此容易....这一届的团支部任务确实圆满达成了吧^O^ 不过已经决定选微电了,大概现在开始是我在九班的最后两个月了。心存不舍,但我的目标却依然明确。继续努力.... --- ### 2007-05-24 难得,北京居然会下一整天的雨,在北京待两年了,这还是第一次。 早上上课以后就一直下,到中午看到雨还没停,又熬不住午饭的诱惑,只好冒雨去吃饭。还好雨不大,天气又比较暖和,淋淋雨也还是不错....但是一想到北京的酸雨...天,快去洗澡先。 恩...回来了,继续写。 很奇怪自己现在对光线很敏感,昨天晚上没有拉上窗帘,导致了今早4点半就醒了过来...要知道我昨天1点半才睡着。迷迷糊糊继续睡去,但是很不幸的是,昨晚忘了关灯...于是6点的时候眼前哗的一闪,再次醒来。还好,今天也没觉着困...我一直睡得很少,偶尔来这样一下,问题应该也不是很大.... 睡眠确实很差,5点多必然会醒来...可恶的夏天....为什么会亮这么早,呜...注定不让我多睡一会儿么。不知道能有什么办法改善一下啦...希望明天能睡到7点..否则真的会是慢性自杀吧 呵呵 =_=... 到此,不能再写了...做模电去了。 --- ### 2007-05-26 突然,想喝一点酒。 仙三紫萱结局,重楼只能默默注视紫萱,一向只以和景天切磋为乐趣的他有句台词:“今天,我只想喝酒....”。重楼使用了自己的力量,可以说是将紫萱从宿命之中解救了出来,可紫萱却只晓得执着甚至盲目地守护着一份早该结束的感情。重楼的郁闷,我想我现在是能知道一点了..自己守护之人却对自己似若惘闻,多年的无私换来的只是心爱之人为他人的垂泪。也不得不说重楼是可悲的,他被这段莫名而来的缘份羁绊太久吧,到最终一届堂堂魔尊也只能借酒消愁。 何以解忧,惟有杜康....千年前古人早已道出酒的妙用。从孔老夫子庙礼之上的一樽清酌,到东坡兄问青天时手中的一瓮醍醐,再到今人愁思时那月光下的清澈明亮,飘飘零一杯一杯,却怎么也解不尽这世间的苦愁。或许,把酒当作一种艺术,更能以一份豁然面对吧。 学校超市貌似只有红酒啤酒,除了它们..应该就只有威士忌了..虽然我是有专门的广口威士忌酒杯,但实在没兴趣喝那样乏味的酒...其实很想试试精心调制的鸡尾..琴酒底的,配上一点白兰地,再按自己的情绪,随意弄些莱姆椰奶之类的,选择一种无拘无束的自由,摇出一份自由自在的快乐,找一个偏僻安静的角落,享受自己内心的一份佳酿,将那些平时的憋闷彻底释放,何其乐哉... 很多事情,我不能说。有些事情本身我无法控制,所以我只能控制自己。也许偶尔是可以靠酒发泄一下的吧(笑).. 可惜现在没酒...恩,就连啤酒都没有...月光下的清澈明亮...好,我想到了,可以以水代酒。 就以水代酒,在这个夜,忘掉烦恼。天乐地乐,山乐水乐,我为何不也乐在其中?!同乐! --- ### 2007-05-31 忙着写程序去了,就忘了写blog..哼哼...越来越糊涂了,都分不清哪一个更重要了。 这几天过得还好啦...也许会有一点点low mood,但是总体来说,并没有想象的那么差,呵呵。硬要说的话~都是程序惹的祸.... 好多时候在想,要是世界上没那么多发现或者发明,我今天的生活会不会轻松很多?至少要是Shockley没有发明晶体管,我就不用学微电;要是Kilby没有弄出IC,模电就会和蔼很多;要是Shordinger没有那该死的假设,那么量子一直会是感性认识而不是数学推导吧...不过要是没有这些东西的话..我也就没办法在这里写blog了=_=.. 真是恐怖....我在念大学前..唔..从幼儿园大班开始吧,到高二左右的样子,十来年才把古典的一套东西学完。大学了..两年时间,我学到的东西将近是以前十多年总合有余吧...回首看这两年,四个字,苦不堪言...但是,我想这是值得的吧。清华教给我最多的,其实不是知识,也不是什么品行或者是做学问的精神,而是在逆境中如何保持自我,如何让自己内心充满勇气——不论受到怎样的打击.... 大一的时候就把《自杀论》好好地研究过一遍了。不过那时候看那本书,更多的是为了别人。现在又拿出来开始看第二遍,发现一年前的理解确实很浅,一年了...生活教给了我很多,于是现在我开始为自己看这本书^O^ 两年的时光,呵呵,两年在清华的时光,相信能把大部分人当年那霸气式的锐气磨平了...我不幸成为那大部分人中的一员。很多时候,看身边的人,看身边的事,惟有苦笑。终于体会了什么叫做无奈,什么叫做心有余而力不足,什么叫做人上有人天外有天....其实来到这里,我是做过心理准备的,但是貌似准备不足,让我又白白花了两年时间来真正的做“准备”吧。但是也还是不知道现在自己,是不是已经准备好了(笑...) 哎呀呀,发现自己现在笑得都很涩了....也许只有到假期才会好一些了吧。现在明白“如释重负”是什么意思了...呵呵 不用担心我...我。很。坚。强。~~~!! 牢骚发过,一切都会没事的~~~~哎呀呀 --- ### 2007-06-05 今天好热啊!!! 36度...过几天估计40.... 好热好热好热好热好热......抓狂中..... 热... 还会更热.... 会不会一直热下去.....? 不会吧.... 但是,现在,真的,很热。 心静自然凉。狂心顿歇,歇即菩提。 --- ### 2007-06-05 它萎缩在角落,漠然的看着这周遭。周围只有冷漠,鄙夷的眼光,寒意包围着它,这种寒由心而生。逃避、低头躲避着人们的目光。它拼命的逃跑。她走到它的面前,蹲下身出手。它警戒的退了退,她抚摩着它,给它吃的。然后站起身走了。它惊呆了,等它回过神来,她走远了。 它沿路追赶,但只有两旁表情僵硬的路人。她已不见踪影。它每天走着这条路,它期盼能遇见她。等她... ... 它怀疑了,不该等了。或许这就是猫的本性吧。不能像狗一样忠诚吧。这段时间她们来看它。它以为会因为她们忘掉她。但最近她们也不来了。在它身边的人越来越少了。它不想要求什么,只要她们很好就够了。 --- ### 2007-06-07 今天高考。 两年了,回首看看高考的一段历程,淡笑。也无风雨也无晴吧.... 高考的日子,自然是很难忘的,特别是考前一个月,日子很充实的感觉吧...觉着终于有了一个明确的目标,为之努力,为之拼搏。很幸运,最后老天看得起我,给了我个不错的结果.... 昨晚闲,于是把各省的模拟题稍微看了一下,令人庆幸的是,很多题目虽然没法很快完成,但总还是有个思路。于是心里踏实了一些——就算被退学了,再读一年还是有实力可以考考的(真实打算是要是被退学了就在清华卖煎饼好了..)。 --- ### 2007-06-08 唰,一张纸一交,我就离开了电子工程系,到了微纳电子学系。 兴趣所在,按自己兴趣办事,没什么不好,虽然也许会有一点逃避的因素,但这绝对是良性的。 其实我觉着微纳电子才是真正的电子工程的内容,信号那边的还是应该叫无线电的好。 班里班外也有大牛被调剂到微电的,选了电信然后又写了服从调剂,大抵都是会被调剂的,虽然也有人是为了“形式”上好看一点...比起他们我要幸福多了,至少命运掌握在自己手里,呵呵... 今天选课,进了选课系统,发现跳出的欢迎框已经赫然写着“微纳电子学系”了,说实话,有一点点伤感。毕竟是自己待了两年的地方,恋恋不舍是会有的,就像当初离开初中,离开高中那样.... 忽然想到了毕业。一生中能有多少同学...一生中能有多少相处得很好的同学?很多的人,毕业之后,也许一生中就再也见不到一面...“珍惜”二字,写来容易,品来心酸.... 看自己的课表,挺空的...一点都不像传说中的炼狱般的大三应有的课表。于是疯狂地找各种选修课,甚至是别的系的课或者是本系在高年级的课程,来充实自己的课表...德语,VHDL,文献检索,魏晋风度...终于选到一个差不多的分数,然后突然发现自己的空虚....空虚的可怕。 总结了下现在的自己。强迫症,偏执狂,双重人格,精神分裂,也许就差个自闭症了...要是什么时候我的blog很久没更新的话,就基本可以断言我“五毒俱全,病入膏肓”了。呵,我也知道不好..但是,就暂时这样吧,应该,没事的... 当一个人习惯了一切....他就变得麻木了。不知道这样说,对还是不对。 --- ### 2007-06-10 寂寞的空气,我累的浑身疲惫。看着窗外星儿闪烁的夜空,我无法言语。 始终思念着的人,会常常忘了自己是在想念。就象人总在呼吸一样,常常忘记空气的存在。 > 我选择忘记, > > 我选择纪念。 > > 我总在逃避, > > 我总在淡漠。 我们选择了不同的方式,在过去某个时间里。我们微笑想着彼此,早已失去话语。眼神的注视,神情的冷漠。 透过阴霾,穿过人群,挨过无数个寂静的黑夜,我在角落里等待。我梦见那个微笑着的温暖。可是却隔得远,无法越过去。我站在这边看着对岸的你,我无能为力,消失离去。 永恒是什么,我的字典里早已不存在的字眼。没有想象,没有期待,默默看着一切慢慢发生。原来只是因为懦弱,因为怕伤害,怕伤痕累累地无法愈合。 季节、天气。变幻蔓延。 看浮躁的人群,孤独在想念你。 这个城市,我可以沉没。 --- ### 2007-06-11 有点微醺的感觉。 同学聚会,有点醉了。但是很高兴。 “那种微酸的滋味,有点微醺的感觉,梦做一半比较美...不想睡,我要陪你一整夜我要幸福的催眠,天旋地转的晕眩。” 说不上天旋地转,倒也是昏昏沉沉。 不错,这样的状态,肩上的压力顿时小了许多。 还不够醉,很多事情,还不能说。也许会成为我一生的秘密吧,呵呵.... 恩,据说醉的时候,做题效率最高,我去试试看。 期末考了,也许会不经常更新了,但绝对不是自闭症能够哦。还请大家放心。 桌面换了张很可爱很可爱的猫咪,光看着都会很开心,呼呼 --- ### 2007-06-19 习惯了每周二被面包板煎熬,似乎已经开始逐渐习惯了,不知道是好兆头还是坏兆头... 今天终于最后一次了,虽然只是暂时的最后一次,但是一想到马上就可以从万恶的实验中解脱1个月,说实话心里还是很爽的。 最后一节量子课和体育课已经过去了,看着课程一科科结束,才真的感到时光的易逝。假期时候的信誓旦旦仿佛就在昨日,而这个学期我对自己诺言的履行,还算差强人意吧.... 体育课下得早,就顺便到书店看看书去了。发现原来数字集成电路并非像想象中的那样简单哦,当然,电动力学依旧还是很BT,郭硕鸿那本书讲得倒确实通俗易懂,但是如果只看那本书的话,估计是没啥前途的了... 最后买了本VHDL,为了下学期的课程。貌似数集也需要熟悉一点HDL的。也许假期有事情做了。呵呵...呵呵... 总之,先祈祷今天插板顺利吧。 --- ### 2007-06-22 今早翘课了...一节信号,一节微电,都是我喜欢的课...翘了。 说来很悲哀..我翘课去了图书馆,看了一整天的随机数学。才打定主意随机只求一过,但今天发现以现在的水平,或许过都会有困难。平时上随机课确实蛮认真听了的,作业也很好的做了的,但是回头一看,还是很多东西不懂。或许这就是所谓的天份吧。想想下周就开考了,复习工作还缺很多..不安。 考试要持续两周..6门,虽然不少,但也还好。比较一下修双学位的同志们从上周开始基本算一直考一个月来说,是要轻松很多了。不过三个星期以后就能回家咯,想想还是不错的... 假期的安排,也许会出去玩一下吧。多陪陪父母,多会会同学....每天都和书打交道,觉着自己都有点脱离社会了....以前从没感到的“两耳不闻窗外事,一心只读圣贤书”的境界,似乎离我比较近了...莫非真的成了书呆子=_=哎 晚上和在清华的高中同学聚了一聚,走出南门的一瞬间,才发现自己真的好像很久没出过清华了。复习很紧张,抽出一点时间来放松一下,挺好的。 懒懒的和小刘艳艳丹丹聊着各自的生活,晒着5点半钟半沉的太阳。熟悉的南门,不熟悉的心境;熟悉的地点,不熟悉的时间。疲惫的一个学期的最后一个周末,休息一晚上吧,当给自己放假... 两年前的我们,面前是同样的起跑线;两年后的今天,早已有人分道扬镳。看着06级的孩子们谈笑风生,陡然发现自己的时代好像已经过去,心不由一凉...或许是未老先衰,也或许真的老了。 夏季学期...连续一个星期的实验...!@$!@#!@#@%#@ 好吧...说实话,我还是有点期待的....不过就是不知道到时候是什么心情了。也许真的会做到吐出来... 再说吧~~~ 胡言乱语...睡觉了。明天的课,不能再翘了~~ 期末~fighting!! --- ### 2007-06-24 今天整理内务。把大一到现在的书整理了下...看起来还是很多的,而且很杂。书多,但是真正学好的貌似却没有几本...本科过了一半,书柜已然摆满,我又是向来舍不得卖书的人,真不知道以后这些书该怎么办了...现在只能希望妹妹也考上清华,而且还“不幸”地也跑到电子系..这样这些书就能有个着落了吧..呵呵 在外人看来,能在THU这样的学校念书,是一种幸福吧...但是同学们经常讨论的却是“是不是后悔来这里”,或者是“是不是后悔当初选择了电子”。诚然,THU的变态是全国有名的,电子系的变态在THU里也是有名的,但是真的就那么惨么?不见得啦...昨天和艳艳的聊天,今天和宁宁的聊天,发现其实每个人都很辛苦的。大家有自己的生活,有自己的烦恼,也必须在这人生青春的几年内,为自己写下一谱绚丽的乐章。我一直想相信,其实没有谁比谁更累,只要心甘情愿,一切都会变得十分简单。 这个假期似乎开始有很多人不回家了吧...大抵都是GRE班弄的...奇怪的是,好像自己身边顶尖人才荟萃的电子系里,出国气氛不是那么的浓重,倒是走出电子就发现G和T满天飞...也有可能是电子大牛们都喜欢背地里阴着干吧....恩...我应该算是打定主意不出国了...(其实是条件差出不了,哈哈...真酸...) 假期,能回去还是回去...至少半年没见的父母了。树欲静而风不止,子欲养而亲不待...以后的时间,肯定是会越来越少,一定不要把遗憾留到最后,那样的话,绝对是一种悲哀...不论对亲人,还是对自己。这个假期应该会和家人一起出去旅游了..想来也至少有两年没有陪他们好好地出去走走了,期待中.... --- ### 2007-06-26 明天开考了。一个学期的最后的精彩吧。 和以前不太一样,完全没有紧迫感。今天很懒散的讨论了一些比较难的题目,回顾了下模电的基础中的基础...恩,上学期学的那些内容...不知道为啥就突然想看,虽然知道和这次考试无关。不知道这样的感觉是好是坏...总之今晚就没看书了,打开PPLive,打开Blog,给自己一点其他事情做做。被人家说这学期学成神了..其实我很冤枉,我一直只是在做自己喜欢的事情嘛... 这个学期确实挺累,但也还算充实饱满。没有什么目的,一味向前冲就是了...自我感觉良好吧...或许这就只是自我感觉而已,呵呵。趁着学期快结束的时候,感谢下这段时间帮过我的所有人..班里的所有兄弟姐妹们,同寝的哥们,远方的以前的同学,家乡的亲人..要是没有了你们的鼓励和支持,我是坚持不到现在的。 天气很热,要是明天能够凉快一点,那就完美了~~呵呵^^p --- ### 2007-07-01 莫名其妙地,突然想听Canon。 怀念它的一分清新明快吧,飞舞的节奏,时而欢乐,时而深沉,在善变中蕴着对生活追求不变的坚持。第一次听到这歌不知道是什么时候了,但是印象最深的大概还是野蛮女友里那Canon背景音下礼堂中一朵鲜红的玫瑰了。还有就是拉威尔的波莱罗舞曲...算是陪伴了整个学年的东西了吧...恩,浪漫的钢琴,可惜我不会....有点后悔小时候没有学上一两样乐器,没办法让自己重复这些旋律...不过转念再一想...以我家三代乐盲的资质,我想要学会什么乐器,大概会比随机数学考试拿满分更难吧...呵呵 一下子就考完一半了,真快。 一直听到的是,当一个人全心投入一件事情中以后,便会觉得时间过得特别快。现今看来,没有全身心投入,时间也过得挺快耶。果然青春易逝,得抓紧才行...这个假期,一定拿出行动来~!等我。 吼吼~~~ --- ### 2007-07-03 VODKA...真好喝 但是到后来,有一点苦……虽然已经兑了果汁,但是清冽仍在...全身发热,果然很厉害.... 我决定 睡一下去,呼呼 睡个好觉,补偿自己。 --- ### 2007-07-07 ....连考六门十二天,人都考糊了。 考完收工。好好玩两天,然后回家.... 这个假期注定会很忙吧,一个小学期紧接在后。昨天拿到了小学期里数模混合的一些材料,看着头还是很大...三天时间要做出一个像模像样的系统,对于一个还基本没太接触专业的本科学生来说,算是有点难吧。不过既然有人能做出来,那我也不要比他差!哼哼~~ 但数模混合还只是整个小学期的三分之一...MATLAB和FPGA还都是未知数...下个学期的电动和固物也还得照顾...惨哦...下学期的话,看课表是蛮松的,但是现在我貌似有被抓到总支工作的危险,到时候是忙是松就说不定鸟~~先祈祷吧。 不对不对,刚考完,不能想这些东西..我还不自虐。所以说,今晚去唱歌,明天疯一天~还约了人去吃麻辣诱惑...恩恩。期待呢,好久没有吃辣了,我恨北京这充斥着小粉和甜面酱的世界..呼`` 陆陆续续好多高中同学到北京GRE了,各自都怀有一份梦想吧。挺可惜的是,不是住得远就是时间不对,见一面都难。只好回昆明聚了。 还没考完的同志们,坚持住,胜利就在不远处~~! --- ### 2007-07-10 11点30的飞机 听了ZYC的建议,起了个大早..提早了很多到机场。无聊地等待了将近两个小时,到了应该登机的时候,被告知飞机刚刚到达本站,还要准备...于是等待又继续了下去。 想不到这还只是噩梦的开始。 其实没让我们等太久,就能登机了。上飞机,继续等待...为什么总是有一两个乘客会不顾其他大多数人的时间,迟迟不肯上机呢....又半小时后,终于离开地面了~~ 噩梦还在继续....预计中的3个小时,到达昆明上空了。无尽的盘旋。据说大雨。这我是知道的,云南的天气,说变就变..明明刚才还在说晴25度,一下子就直接不能降落了。在机场上面转了半个小时,然后直接被拉去成都了=_= 一直在天上的感觉真的很不爽..但是值得庆幸的是还能在成都降落...飞机加油,然后再次起飞... 早上8点半出门,晚上6点半到昆明...呼 其实要是飞机上坐我旁边的是个PLMM的话,我也就不说什么了..结果却是一个一直在挖脚的大叔...唔..我真不知道该说什么了。 不过想想还好,比起第一次寒假回家时从早上9点一直等到凌晨3点的经历,这不算什么了。 不过为什么这种事情都让我碰上了呢...四次回家两次不顺...百分之五十的几率,未免太高了吧=,= sigh... --- ### 2007-07-12 雨一直下,三天了。 北京可不会有这样的雨,恩,这就是故乡的味道吧~ 成绩陆续出来了,已经知道的几科,比自己预想的要差一点。但是关系不大吧...很多事情尽力就好。只是这学期90+的目标算是彻底破灭了呵呵~~嘛,也好,下学期有继续努力的动力了! 今天猛地意识到,自己居然也会大半天的守在电脑前面一遍一遍的登陆info去关心自己的成绩...和以前的我比,真的变了..变得对这些事情关心了...活得潇洒确实很难耶。开始佩服三年前的自己了,有洒脱的实力,真的是一种幸福,怀旧ing 窗外雨不知道还得下多久。高中同学能回来的大都回来了。唔~该见见面叙叙旧了... 稍微看了下电动力学,很晕...教材都很难看,更别说题了。说实话,我已经没有信心学这门课了..不知道该怎么办。 sigh.. --- ### 2007-07-21 刚回到家... 好像没太多想说的..感触的话,是有一点,但是都不值一提吧。始终是跟着别人去的,所以很多事情都没我什么说话的权利,恩,情况就是这样的.. 分数是全部出来了的,和理想的有不小差距,但毕竟是进步了,这就行了...在考试分数很低的时候,我往往就安慰自己说,其实课程的内容都掌握的啦,就是不会考试而已,偶然偶然。但是每每这样想过,心中便会觉着不安..莫名的不安,总是欠一点什么,自己又说不出来~... 开始庆幸自己选择微电了,因为这几天继续看电动力学的时候,发现这门课绝对需要花费我海量的时间,否则下场很可能就是挂掉(当然如果老师比较水像吕嵘那样的话,还是很有希望过的)。电信的那么多课程..什么DSP什么通电..当然还有电动力学...唔,好吧我弃了。想都怕了.. 有时候想,一个学期学那么多东西,真正掌握到手的有多少,真正以后能用到的又有多少。好吧..就算它是基础课,我觉得也就学到以后真正需要用的程度就好了...比如工科微积分,会算积分会解偏微分再会个Taylor展开,也就是了..至少到现在真正用到的就这些。至于什么一致收敛啊,实数完备性啊,学了干嘛?我一辈子也用不到的吧估计..但是不学还不行,考试要的,danm it。感觉确实是浪费了不少的时间呢。 牢骚先到这...开始干活.. --- ### 2007-07-23 今天很不爽。他们一个二个都去上新东方了... 我就在家颓废的看看魔兽比赛... 鲜明对比... 该死的雨,下这么得意,据说好多天了。猫儿们都过不来了..可以隐约听到它们叫,但是不想召唤,怕淋了它们弄生病..也许是多虑了,没有那么脆弱吧..苦了它们了。特别是新生的小猫..再坚持几天吧... 移位乘法的实现,想了很久了,还是没有结果。本来思路是清楚的,但是一坐到电脑前面真正开始写的时候,脑袋又浆糊了。转头一看,假期过了一半了吧。还好并非一事无成..比较欣慰的。 现在计划彻底乱了,烦躁.. --- ### 2007-07-26 星期四,天气晴。 终于不下雨了... 久违的阳光,茵茵的草地,这才是假期嘛```下了不知多久的雨呢,人都发霉了..恩,要好好的晒一晒! 看日历的时候突然发现都26号了..记得回家还没几天呢啊..为啥总感觉假期过得特别快而学期过得特别慢呢~~真是的。不过转念一想,以后要是干起活来,估计是没有什么所谓假期的吧,那就是说我大学的这几个假期,很可能成为一生中最后的比较传统的假期了..好可怕..赶快享受假期才是王道.... 天又阴了....刚才还能见到阳光阿...老天爷,别吓我 --- ### 2007-07-27 击鼓传花的问答游戏?有意思.. 1. 你会选择过去还是未来?理由 > 当然是未来。总活在过去是懦弱的表现,而且我渴望更多不同的生活体验。 2. 如果你的爱人背叛了你怎么办?是分手还是给她一次机会? > 爱她,希望她幸福。离我而去,自然是因为有人能给她更大的幸福吧.精神上彼此的相互独立是我所追求的。一切随缘。 3. 如果以前的你是不快乐的,有什么方式可以让自己快乐呢? > 饱饱的吃一顿,轻轻的吹吹风,懒懒的睡一觉,再用另一种眼光来看世界,自然就会快乐起来 4. 当你的幸福消失了你怎么办? > 大叫着谁动了我的奶酪,然后穿上跑鞋寻找新的奶酪^O^。还有什么办法呢?总不能原地打转坐哭到天明吧呵呵 5. 希望以后的你是什么样子的? > 随遇而安,追求独立自由,秉持猫的心态,冷眼看尽繁华。 --- ### 2007-08-05 我回来了... 真是受不了,雨啊雨啊雨。快疯了.....一直下个不停 莫非我真的是雨神..?! 下周就要回北京了。新的生活等着我 回北京再慢慢写,恩 大家假期过得都好吧,要一直愉快哦 --- ### 2007-08-14 这几天忙着玩仙剑四了,一直没写博,从今天开始恢复更新频率。 从95年的仙一就一直陪着仙剑走到现在,对仙剑的感情自是不凡,或许也有一点点依赖。仙四结局CG和制作人员名单结束的那一刻,我知道,这个充满悲欢离合的舞台,再一次拉下了帷幕——又一部仙剑作品离去了。 真的是很不错的游戏,至少近两年来我已经不曾有过这样的感动了。天河、菱纱、梦璃,甚至紫英、玄霄都陪伴了度过了几天不错的时光。恩,其实小学期开始都已经两天了,虽说假期里有所准备,但是还是不能太过放纵自己。收心,收心~~ 回想假期还是做了不少事情的,但是一想到很多人现在才陆续回昆明..而且又要组织聚会..我却在北京整天对着电脑写程序...呜。怎么会这样.... 写MatLab去了....恩,写程序的效率,其实是和键盘是否顺手密切相关的... 事实就是这样的。从今晚开始好好学习了..吼吼~~~ --- ### 2007-08-16 入学两周年祭。 说“祭”有点夸张了,不过确实想写点东西。 去上课的时候人家问今天是什么日子,还提示说是很有意义的一天。说来惭愧,我首先想到的却是日本投降纪念日后一天...最后提示两年前,我才恍然大悟..原来是入学两周年了。时光飞逝,晃眼两年,周围大家也都是一片嘘唏.. 两年前的今天,一样是个雨天。雨中我踏上这片土地,伴随的是泥土的芬馨,怀着很高的理想。如今我却身在一个关键的转折..两年来我所经历的一切,确实改变了我很多很多,其中有失落的沮丧,也有成功的欢欣。正是在这次次的得失中,我学会了用自己的方式看待这个世界。虽然以前也曾有些想法,但是真正清晰起来,靠得还是这两年。 人生是什么?有人说“人生如戏”,游戏人生,倘若真能做到快意畅达,固是好事,但是若仅仅只是游戏,又有多少人有快意畅达的资本?要认真做事,积累实力去追求精神的解放,这样的话,又哪里会有游戏人生的一份豪爽?有人说“人生若梦”,就算是梦,也需知生死浮沉,只不过是这梦的始末峰谷...而假若人生真如场梦,我们却又哪里知道自己是在梦中?或是在梦中的何处?梦醒之后,又会是怎样的一个活了一辈子确又十分陌生的世界?.... 一直很欣赏一句话,“平淡对待得失,冷眼看尽繁华”。大学的两年,可以说就是我对自我感情加以限制的两年吧...太多的得失,太多的权衡,真的让我很疲惫。每天在应接种种选择的利弊的同时,我早已感到力不从心....不如看淡吧,像菊那样,选一个无人问津的时节,再美丽的绽放自己...一时的功利名望,却是永远抵不过一生的清新淡雅的。不止一个人和我说过,觉着周围人的功利心太强,凡事想求一个捷径通途。大学里尚且如此,外面的社会更何其厉!不能说“众人皆醉唯我独醒”,但最少也应该争取“出泥不染,濯涟不妖”..自己的信念,我会一直坚持,就算会吃亏,就算会失败,至少我也能昂着头说道,我是一个有自己准则的人。我知道,很多时候,人活着为的,就仅只是自己的选择。 已然过了午夜整点,已然不是两周年正,但我的选择依然不会改变。 上善若水,人淡如菊.... --- ### 2007-08-20 ``` FPGAFPGAFPGAFPGAFPGAFPGAFPGA FPGAFPGAFPGAFPGAFPGAFPGAFPGA FPGAFPGAFPGAFPGAFPGAFPGAFPGA FPGAFPGAFPGAFPGAFPGAFPGAFPGA FPGAFPGAFPGAFPGAFPGAFPGAFPGA FPGAFPGAFPGAFPGAFPGAFPGAFPGA ``` 发泄完毕..继续回去写代码。 晚上再来更新.. --- 回来了.. VHDL写多了。 很乏味的生活呐,每天早上起来第一件事情就是写代码,然后到11点半,午饭。回寝室继续写代码,然后5点半吃晚饭。回寝室继续写代码,然后到12点左右休息一下...还好这种生活不会持续太久了..FPGA的必做和前边几个项目的选做都搞定了。最后的选做?让它见鬼去吧...对于出题者本身诚意就不足的题目,我向来不太愿意花时间去揣测的... FPGA完了以后还有更恶的MATLAB和数模混合...说小学期比正式上起课来要变态,那是一点都不为过。恩,估计以后两三周的日子,会继续乏味了吧。不过也许以后想起来,会是一段很充实的时光... 昨天是七夕,找惯例喝了摩卡...咖啡,牛奶,糖浆,巧克力...不得不说摩卡正是一个甜蜜的选择。不过,独峰的摩卡很一般就是了,让人从口中品不到浓郁的香甜...还好柚右漂亮的衣服弥补了咖啡的不足,嘿嘿~~^_^ 哎。...其实还是很伤感的...不符合我年龄的.. 长大了..其他人都是从感性趋向理性,为什么我恰恰相反.... FPGA,KO。YES!..~~ 终于可以暂时不写代码了不看波形了。我大概需要休息一下 --- ### 2007-08-23 可以休息一天了。 今天的FPGA检测还算顺利,虽然GW48-PK2和我闹了点别扭,但是它数码管本身存在的问题却还是被俺一眼洞穿....应蓓华小姐硬是不信我逻辑的正确性..结果换了旁边的板子才最后使她心服口服,哼哼。 所以可以稍微休息一下了。虽然还有最后的一个选做依然循环在无尽的错误中,但是我还是决定今晚不弄FPGA了,恶心中。据说本科毕业写两年FPGA以后,就可以达到一个很高的水平了,但是也仅限于语法的应用...算法的东西还是需要..恩,智慧加勤奋...一个写代码的前辈如是说... 刚才没事上网查了下,发现我们有几个选做实验直接是一些学校的毕业设计的题目了..很是寒。最后的选做实验的难度还超过了一些发表在灌水期刊上的paper..更寒...突然对thu严酷的训练有了一丝好感,小得意... 两年了,第一次体会到自己还是变强了些的。以前总是沉浸于横向对比,一定要和周围的人决出个高下,然后就被各种巨牛三百六十度碾压,现在想想还真是可笑的。地球60亿人,人上有人...每天去横向岂不累死?纵向一看,自己的进步确实不可否认的..欣慰中`` 明天开始,估计就要天天面对传说中的“东方红”了...so so la re,do do la re.... 想起头就痛,特别对我这种音乐盲,估计更是煎熬了`` 先不管它...恩,先放假一晚。就这样 --- ### 2007-08-25 刚看完了《好奇害死猫》,很好的片子...其实上个学期就看到宣传片了,因为片名的curiosity kills a cat本来是我很喜欢的一句英国谚语,并且cat这种生灵有确实与我有些缘分,就决定了一定要好好看看这部片子。 好奇害死猫,真实的惨淡。在欲望面前,原来人是如此的脆弱;在平静的表面下,却有那么多盘根错节的关系。也许郑重永远不会猜到,自己一直只是棋局中的棋子;也许晓霞至死也不明白,为何只是单纯的对爱追求,却换来的是如此凄惨的结局;也许千羽在孤零赎罪的后半生中,永远不会清楚自己到底喜欢的是红玫瑰还是白玫瑰;也许momo还会拿着手机看照片,但是她却也不会懂得这个世界的险恶与复杂。 偏执的对立和偏执的爱情,成就了一段悲剧。在这段故事里,没有猫,但却人人都是那只猫。对富贵阶层的好奇,让人堕入了欲望的深渊;对激情生活的好奇,让人失去了选择的余地;对真挚感情的好奇,让人丢掉了宝贵的生命;对绝对完美的好奇,让人丧失了心智上的宁静。每个人都过着自己的生活,每个人都猜不透别人的世界....一个一个连环的计算中,有着太多的诱惑,而最终的受害者,还只不过是那只好奇的拥有九条命但是一样会死掉的猫罢了。 其实,看完片子之后,觉得他们都很坏,自私占有,欲求不满...但是,仔细想想,他们全都挺可怜的。代表爱情的玫瑰,最终成全的不过是情的复仇。该迷茫的,依旧迷茫;能清醒的,却已经不在...社会造就了一切,社会变了,人心就变了,人心变了,就什么都变了... > 将约克的白色和兰开斯特的红色结合,与其它花不一样的地方,是这种蔷薇在一朵花里面要么就是全白,要么就是全红。 ....一本无字的《玫瑰圣经》,一段普通的花卉描述,却将人性整个剖出。为何要如此极端..要么全白,要么全红...守护与毁灭,就在一瞬间... 生活还要继续,也许好奇也还会继续.. --- ### 2007-09-01 首先向大家汇报这几天的生活学习和工作情况...和我奋战在thu2007年夏季小学期的每天被`5562|1162`和“电灯比油灯进步多了”折磨的战友们可以跳过这一段了..因为我不想再让你们重新回首那不堪的往事..鲁迅曾说过“痛苦才是人生的原貌”..什么..?哦..也许是泰戈尔说的..记不清了..好吧,鲁迅曾说过要“敢于面对惨淡的人生”..所以我将勇敢地独自地回忆这几天曾经发生的事情.... .... 还是算了吧..实在太残忍太血腥了..而且我也不算是“真的勇士”..那回忆还是略去的比较好..毕竟来这博也可能会有未成年人或者心脏病患者。在这里只告诫后来的孩子们..一定一定要做好充足的准备,否则这几天真的会死得很难看..厄?你说你有大作业的种子..好吧,那请无视我好了.. 明明在几年以前的,今天是开学的好日子了,但现在呢,怎么好像比正式开学了还惨很多... 唉呀呀,本来说是要写很多很多的,可是结果真正到写的时候又不知道该些啥...胡言乱语,也许这就叫空虚? 也许明天能想到要说什么.. --- ### 2007-09-01 风中之烛,戴妃十年.. 戴安娜永远那么美丽,不论是外貌还是内心。 否则不会有人能记住她这么久。 十年前,香消玉殒,十年后,扼腕叹息。精神上的时空错乱,却又把我带回十年之前.... 车祸?那是车祸么?那简直就是皇室的谋杀...天妒人嫉不可怕,可怕的是要一个风中之烛的女子来承担这一切。至今再追问重重幕后,已然失去了意义,但是回首审视,却还是被戴妃倾倒,为之惋惜。 灰姑娘般的出身,白天鹅般的高贵,公主殿下并不是浪得虚名。她的一切,都似童话,唯一缺的,就是童话般的结局了。她总是能把感召力赋予实际的行动,而不像某女王那样只是在深宫怨己怨民;她的出现终于使皇室和民众有了接触,却反被皇室恩将仇报。须知道,不是每个人都能那么真诚地去拥抱爱滋病人、麻风病人,也不是每个人都能在平等地和那些卑微苦难、被人遗弃的弱势群体促膝长谈,给他们安抚和支持;不是每个人都有勇气穿上防护衣戴上头盔走在危险的雷区,并去为那些被地雷误炸的小孩子包扎伤口,与双腿残疾的男子并肩交谈。可是,她能,那么真诚感人,又带动那么多人投入到慈善事业当中来。正如一本书中所写——她曾高高在上,但她也低头去抚慰那苍凉的大地。因为,她是天使,带给处于贫困、疾病及灾难中的人们以希望与信心,除了天使无人能及,那是一种与生俱来的亲和力与感染力。 但是天使,却折翼了。我只是希望,她当时不要凄凉地带着无助和绝望... 母亲是很喜欢戴安娜的,但是戴妃香殒之时,她却并不悲伤。她只摸着我的头告诉我,也许这才是最好的结局。十年前,我不懂;十年后,我有所感触。 戴妃的美,凝结到了一瞬间。 如果有机会,我会去她墓前,默默送上一朵白玫瑰。 --- ### 2007-09-02 上次摸面包板已经是两个月前了,为了明天开始的“最后的疯狂”,又拿起了板子.. 感慨板子问题..四排二十行六十列,加上四排四行五十列,1400个彼此交连而又不连的小孔,渗着的是整个大二的酸甜苦辣。跟着班长学委领板子的时候,一人五盒塞在车筐里,高高兴兴奔回宿舍;第一次做门延时的实验,死插活插出不了结果,最后莫名其妙得以解决;一插就出结果,叫助教检查,等了5分钟,示波器上就空空如也,不得不怀疑自己人品;考试时才发现自己当初设计的时候为了一点点便利,却造成了原理上无法修正的错误导致失败,体会到了“着急也没办法”的心境;整个模电实验从始至终非常顺利,对理论知识的沾沾自喜。01-234,一块超好记的编号的面包板,真的陪我走了重要的一年... 这次数模混合以后,板子们就要到它们的新主人的手里了。希望它们能一路走好,希望他们人品常在,希望他们能在这180平方厘米的板子上找到自己的人品~~ 嘿嘿嘿嘿... 祝自己明天好运~~ > 9月4日 > > 做不出来,抓狂啊.... > > 快死了,谁来...救救我.... > > 救救我... --- ### 2007-09-04 终于快完了。 可怕的小学期... 大部分都已经过去了,剩下的内容虽然包括一个考试,但是相比起以前的东西来说,应该是仁慈太多了。 好累呢,写不动字了...喝牛奶,睡觉。我需要休息一下。 祝自己好梦~ --- ### 2007-09-17 懒得数这是从小学一年级开始的第几个开学日了,总之,经过了不长的假期和比假期还长的小学期之后,又开学了。 今天的感觉就一个字:困。也许是前几天的放松还在继续,睡眠也不是很有规律,再加上本来就是阴天,黑黑的,况且老师为了让大家看清讲义,还特地关了几盏灯,更是营造出睡觉的气氛。早上的两节课都是在六教A区的中等大小的教室上的,说实话除了考水平一和物理实验前的自习,我还从没在那种教室看过书,也许还不习惯吧。以后慢慢来... 中午吃过饭以后到教材中心把书买了..发现已经快念不起书了,一本本得贼贵。不是很明白为什么都喜欢上原版教材,更不明白既然用了原版教材为什么并不按照那教材讲课...每次上课以后都会有种白买书的感觉。哎。只有自己看好了。上学期一本原版就看得半死不活了,这学期上来就先两本.要命哦。谁来救救我的英语... 唔..看样子居然快下雨了...原本说去洗澡的...残念=_= --- ### 2007-10-08 大雁飞过,菊花满头...很早的一首歌,也许叫做..中华民谣? 天气转凉了。 早晨出去洗漱的时候,才发现一件短袖已经撑不住了。北方的天,说变就变快得很,前天还是炎炎烈日热得不行,现今就已经需要秋装上阵。上课路上留意到旁边花圃里菊花正开得灿烂,不由感叹这次花开怎会如此迅猛强烈,毫无征兆。最后才想起..整个十一假期就没有留意过教学楼周围,自然也就不会有什么发现。 菊是见得不少了,自己也很喜欢这种花。总觉得那种菊的那种意境,确实正是我所追求的,但是又觉着它是那么的高高在上,仿佛就是纯粹理想中的..曳曳摇摆,却丝毫不可及的脱俗:不与百花争相艳,百花开遍我自芳.. 这里的秋天和家乡很不同。昆明常绿植物很多,一年四季整个城市就算不叫葱郁,至少也花花绿绿。北京的树叶都是要掉的,学校主干道的叶子,每年都全落一次,提醒着大家秋冬已至。特别遇到大风天的晚上,一觉起来,满地黄叶,若正好起得早不赶时间,路上人少的话,便可以悠悠的下车,一步一步踩在颇有厚度的落叶上,享受清晨的掺着芬芳的空气。绝对是一席美景,绝对是一番乐事...恩...今年还没落叶,还有机会... 感叹一下...电动力学好难... 不过计算机网络好像更难...不知道该笑还是该哭....得过且过?sigh... --- ### 2007-10-16 自己越来越可怕了。 早有人说过我有精神分裂症,我向来是嗤之以鼻的,不过今天我确实感受到了。 前一分钟,还在困扰,忧郁.... 后一分钟,却在享受阳光.... 然后,又转到阴天和别人吵架——要知道我向来是,不发脾气的 这不是分裂,还能是什么? 内心的痛苦?说不上...只是有点淡淡的伤感...还好我相信时间能够抚平一切 或许将要,面对新的生活 告别混沌态,是不是也意味着,无限的可能也就此终结?笑..量子力学中毒症... --- ### 2007-10-18 一起撒花。 这几天,挺好的,也挺忙的,终于有点时间来写写BLOG。 从何说起呢?我一直生活的准则是独立于世,两年来也早已习惯了每天在自己的世界中享受一份宁静和恬适,并且同时守护着一份并不真实的寂寞。直到前天,一个仿佛能永远带着微笑的孩子敲开了我的房门,礼貌又客气,小心而谨慎,说道,想搬到我的心里住.. 恩,就是这么突然,但又却在情理中——只不过我太粗心。我曾经错过了许多珍爱我和我珍爱的人,等到我真正反应过来的时候,常常是为时已晚。也许在七夕我就应该主动一些,但是最后却弄成这样..我真的好笨好笨...明明知道你不喜欢暧昧的。恩,这次,我不要再犯同样的错误了。(其实真的又差点错过你..但是幸好有你的耐心和勇敢..谢谢你) 好啦,不再多说什么..总之,这几天,很开心 但是最重要的是,你要每天开心~~因为只有你开心,我才会真正快乐。 ^_^ 爱你。 --- ### 2007-10-20 城市的空中,往往星光黯淡,可见的依稀就一颗金星,还得仔细找寻才有踪迹。 看星星,本来是要去数的,但经常的情形是漫天的找星星。能找到的时候,便很开心,欣赏它们在天空的飞舞,像精灵一般跳跃;找不到的时候,便安慰自己。虽然看不到星星,但是它们始终在那里:也许在某个不经意的晚上,我会不经意地抬头,伴着一些蒲公英,被那满天的星震撼~ 其实地上也是有星星的。好像在北京是没有见过萤火虫们了,也是,毕竟是现代化的大都市,就算是城边上的清华园,也不会再给它们合适的水源生存了。对萤火虫最早的印象要追溯到好小好小还在农家生活的时候了,盛夏的晚上,走到田埂上,就会看到“繁星”点点了。记得曾经捉过萤火虫的,但是每每一捉到手上,它们便不发光了,难道是怕人?所以弄得我现在一直很怀疑车胤囊萤夜读的故事了。 嗯...说了那么多,其实是想找机会和你伴着地上星,去数天上星~~ --- ### 2007-10-21 42.195,一个对我来说难以置信的数字 2:04:26,我相信这是一个用生命创造的奇迹 今天,又是每年的北京马拉松比赛了。和往年“事不关己,高高挂起”差不多,我始终没有去跑~~嗯...天生不喜欢跑步吧,加上老妈也不喜欢我去...俗话说百善孝为先...他们也是担心我这本来就“脆弱”的身体吧...不过,好像真的却是应该加强锻炼了。可是每每有这种想法,就会被窗外阵阵寒风打消...唉,没毅力的孩子... 班里好像有三个人去跑了吧,据说成绩都还不错,祝贺他们咯,至少是完成了一次对自己的挑战吧~~ 突然发现,有时候也该好好面对自己了。我从小生长的环境,都是很优越的。有着一贯传承算是书香门第的家庭氛围,有着衣食无忧开心快乐的富足生活,有着美满幸福互爱互敬的父亲母亲,从小受到良好教育,没遇到过什么挫折,可以说是活得蛮潇洒...然后就混到了这里,发现整个世界满不是自己当年想的样子。懵了,再然后就被生活的现实直接打到谷底...当初的锐气早就被消磨殆尽,但是心中还是会有不甘~~唔.. 这周最后一天了...下星期传说中的教学评估就开始了,超级大扰民的活动呐,真是的..全校上上下下都在拿这说事..但是不能随便迟到了是真的,万一一个不小心被抓住..厄厄,想太多了...两年大学,除了逃课,我还真一次都没迟到过呢,哼哼...下周会很忙,也会很充实和愉快吧呵呵 --- ### 2007-10-22 抽这团委例会前的一小段时间,写点东西。 今天篮球小组赛的最后一场,打5班。谁赢谁出线,蛮残酷的,在缺阵了小红和昭时以后,班上的篮球水平下的还真不是一点点多。居然要打到最后一场比赛来争夺出线权,不过还好过程有惊无险,顺利拿下了。希望能走得更远吧~~毕竟这次篮球赛,我参与的算挺多得了..恩,当然是指组织方面的事情。 比较悲惨的是,球赛的时候伤到腿了。捡球的时候不小心不小心磕到了台阶上,当时只觉得有点痛,心想最多擦破些皮就是了,到后来愈加厉害,拉上裤腿一看,竟然全是血..恩,伤口虽然面不大,可是不知道为什么却非常深,回来处理的时候才发现居然都快能看到里面的骨头了..轻轻碰一下就成这样,看来我真的是老了。幸运的是,正好穿了一条深色的裤子..比较好洗吧。要是白的,可能就麻烦了..对付血渍,我还不知道该怎么办。 买了些水果,在女生楼门口徘徊了一阵子,最后还是没有上去..恩,估计规定也不允许的吧。不知道。弄了一晚上的固物,挺难的。刚才柚右过来,顺便帮她带过去的时候,看到好多大一的孩子在练习跳绳,嚷嚷的,很开心的感觉。还没有期中考试吧..好像记得两年前这时候的我们,应该也和他们差不多,呵呵。恩..等考完试,楼下应该不会有那么吵了~~不知道以前的学长学姐们..是不是也这样评论过我们.唉唉。 10点有例会,不能再写了...就这样吧 --- ### 2007-10-24 乐器果然都是很高深的东西,特别是古琴。 现场听过古琴回来,确实是有些感触。以前高中的时候有一套正版的民乐CD合集,就很喜欢听,特别是筝和琵琶,当然古琴也有听,不过当时蛮受不了那沉闷的低音和缓慢的节奏,听了会很想睡,大概是不够成熟吧。今天正好有个古琴讲座,正好晚上没事,正好被拉去,于是就正好被熏陶一下... 讲课的老爷爷穿了身褂子,很白很素的那种,加上一头银发,端坐在琴前,真的有点仙风道骨,让人肃然起敬。先是谈琴,后是弹琴,都听得很有味道,很大程度改变了我对古琴的偏见。仔细想想,一把古琴流传了两千多年,一直是知识分子身份的象征。远到孔子、伯牙,再到公瑾、孔明,又至李白、王勃,史上凡有些名誉的士人,大多都会弹上那么两曲吧~~以琴会友,不仅是现世的交流,更是与古人的共鸣。 其实突然有种想学古琴的冲动...不过看看手边那么多事情和现在忙的样子..以及老爷爷的演奏手法...- -,好吧,还是算了,没有音乐天赋的我.. 听听就挺好。 --- ### 2007-10-27 香山行,天气不错。其实夜里下雨下得挺厉害,早上起来的时候还飞些毛毛细雨。但是自出发开始雨就停了,到了香山,居然还出了一点点太阳。山上叶子红的不少,加上水汽一蒸,掺在一起感觉蛮好的。要是不是人山人海的话,一切会很完美啦。 一口气爬到了山顶,说实话来北京两年,除了一开始的时候象征性的在香山脚下逛了一圈,这还是第一次真正爬香山。北京附近嘛,山也大不到哪里去,比起家乡动辄2XXX米的山,这个完全只能算个小高地。没什么困难就到顶了,恩,最大的阻碍就是人多..爬不快咯。 山顶的几张照片彻底废了...按快门的时机那叫一个差T_T...算了,说起来都是伤心。 下山的时候人更多,一步一个台阶慢慢踱下来的。 不想写了,不知道能不能凑合算一篇游记...Over... --- ### 2007-10-31 临近期中了,虽然注意提前做了好多的事情,可是还是觉得很忙很忙。 三门期中考试都很硬...固物数集电动,其实哪一门学得都不是很扎实;两门期中就完的课,文献大部分算弄好了但是还想做个PPT展示,VHDL在和奶奶长谈许久之后还是前途未卜;DIC作业一周一周压过来,还掺些Project来为难人;电动作业攒了三周,想来也马上会有一个爆发;两篇古代经典的读书笔记也拦到了面前;还要抽出时间忙团委那摊子事,去组织GG李的讲座...唯一值得欣慰的是量子作业写完了。唔,30分的体力活呐。 不能写了,要不今天的数集又看不完了 恩,忙归忙,过得还是很开心的——因为有你~~^_^ --- ### 2007-11-16 其实也已经习惯了北京天气的说变就变,前天还是好好的,昨天一下雨,地上结一点冰,突然就那么冷下来了。 不过今天倒是一个好天气。早晨起来的时候,发现一缕阳光射到屋子里(我们是朝南的房间,足见我起得多晚了),暖暖的很舒服。一两只鸟儿还跳到阳台上叽叽喳喳,冬日里也还是一副活泼的模样。 吃了几片面包,然后去买花。一个月的时间倏忽而过,彼此也有了更多的交集。一束并不鲜艳的玫瑰,给你,也给我自己。 每天都要快乐~ :) --- ### 2007-11-18 这一周很忙也很累,好多门的期中考试,除了一开始的固体物理,其他好像都不是很理想——或许固物也只不过是自我感觉良好罢了,希望明天出成绩的时候不要哭出来才好。(开玩笑...好像至今为止我还没为考试伤过心 :P)。今天的电动回来的时候本来已经基本万念俱灰了,但是很happy的发现长征gg在网络学堂上贴了公告说最后一题题目出错,所有同学那个题目按满分记...乍一看是很好,不过仔细想想,为了分数分布,估计会在别的地方更加克扣吧,结果估计都差不多了。果然是不可能让你那么容易蒙混过关的,哎哎,下一周新的生活就将开始。希望冬日阳光依旧~~ 傍晚的时候和亲爱的一起去看了色戒,还是不错的片子的,但并没有宣传的那么出彩,也不如外界声势那样宏大。演员的演技还是值得肯定的,那种人在江湖身不由己的感觉还是体现出来的了,在绝对冷漠和残忍下的一点点真情,不得不说还是能让人心头为之一颤,不过也就不过如此了。 写博快一年了,翻了翻以前的东西,又大致的整理了一下,发现自己写的大抵都是空发点牢骚,或者就是到处转发别人写的东西,真正属于自己的并不很多。不过转念一想,我本来就是那种属于“情感冷漠,知觉麻木”的人,所以想法很少也尚属正常吧。只是希望这里能够记录下真实的自己,和真实的感情——不论它们是什么,只要是可以分享的,就无私地拿出来就好~~ --- ### 2007-11-22 最近读庄子,有些感慨。 关于物化的本质的一些想法。 > 庄周梦蝴蝶, 蝴蝶为庄周。 > > 一体更变易, 万事良悠悠。 > > 乃知蓬莱水, 复作清浅流。 > > 青门种瓜人, 旧日东陵侯。 > > 富贵故如此, 营营何所求? > > ——李白 《古风》五十九首之九 “庄周梦蝶”的故事可谓是家喻户晓的。这则典故出自《庄子·齐物论》:“昔者庄周梦为胡蝶,栩栩然胡蝶也,自喻适志与,不知周也。俄然觉,则蘧蘧然周也。不知周之梦为胡蝶与,胡蝶之梦为周与?周与胡蝶,则必有分矣。此之谓物化。” 《齐物论》是《庄子》全书中非常重要的一篇。庄子提出了“齐生死,等万物”的妙论,在庄子看来,世上一切矛盾对立的双方皆无差别,不管是大与小,贵与贱,荣与辱,甚至是生与死,都可以“道通为一”,而这样万物化而为一的思想,便是所谓的“物化”。庄子用“梦蝶”这一寓言故事形象地说明了他的“物化”意境:因为它们都是由“道”变化出来的不同物象,所以在根本上是完全一致的。它们之间的生死差别、彼我区分没有必要去追究了,就像没有必要去搞清楚究竟是庄周梦为蝴蝶,还是蝴蝶梦为庄周一样。圣人就是根本取消了万物间的差别、对立,而任之自然、随物变化,从而进入“物化”的境界。 庄周所提出的这样的哲学观点,在当时看来可以说是大胆,甚至在颇为开放的世人看来也是有一些荒谬的。我们每天所面对的,确实是实在存在的现实,而相对的矛盾体也是客观存在的。初读庄子,总会觉得一个人怎么能把生与死这样明显差异巨大的事物都等量齐观呢?再读庄子,我终于能体会到庄子内心的一点想法。 让我们先来看看庄周给我们提出的寓言故事吧。庄子自称一天做梦时梦见自己变成了蝴蝶,而梦醒之后发现自己还是庄子,于是他不知道自己到底是梦到庄子的蝴蝶呢,还是梦到蝴蝶的庄子。在这里,庄子提出一个哲学问题,那就是人如何才能认识真实。假使梦足够真实,人是没有任何能力知道自己是在做梦的。既然我们很可能连自己到底是谁都分不清楚,又怎么可能去把世间万物逐一分而化之呢?于是,他想到了“齐物”之说。庄子认为,一切都是相对的,“物无非彼,物无非是”,“彼出于是,是亦因彼”,“是亦彼也,彼亦是也”,“彼亦一是非,此亦一是非”。庄子的这几句话,说明了在他眼中并不存在有彼此和是非的区别,世上的万物都是必然有差而不相同的,但是在它们之中,必然蕴含着能让人们认知的共同本质。既然本质相同,为何又要硬加区分呢?而事物的共同本质,便是所谓的“道”。超脱是非,便能掌握大道的枢要;掌握了道的枢要,便能进入事物的中心,就能顺应是非而无穷变化:这也正是庄子所追求的一种境界。 庄子提出的这样的哲学观点,可以说是超越了时空的惊天手笔。直到2000多年以后,西方哲学界才有类似的观点出现。1641年,笛卡尔在《形而上学的沉思》一文中阐述了类似的观点,他认为人通过意识来感知世界,世界万物都是间接被感知的,因此外部世界有可能是真实的,同样也有可能是虚假的。这一论点以后成为了怀疑论的重要前提。仔细品味,这和庄子的观点还是有一些区别的。庄子更强调“相对”概念,他并不怀疑相对事物的存在性,而却对它们相对的本质更感兴趣,将本来不同事物加以“物化”。这样的思想是充满了辩证观点的,也为人能观察自然和社会问题提供了很好的理论依据。事实证明,历史上和当今世界许多的自然现象和社会问题,都确实是遵循着这样的辩证规律发展的,而之后的哲学家们所新提出的可行的哲学理论,也被证明了是需要依照辩证观点来进行的。 庄子能从“物化”观点的提出,也是有一定历史背景的。在百家争鸣的时代,诸子各执一言,多少对其他学派怀有偏见。而诸子百家因此生是生非也令庄子有所不屑。总观《庄子》全书,在很多地方都能看到庄子类似的“妙论”,可以看出其旷豁的胸襟。但是后人在注读庄子之时,常会曲解了“物化”的本意,认为庄子的这种思想,只是片面夸大了事物差别的相对性,而从根本上取消了人类认识世界的必要。事实上“物化”是庄子“万物一齐”相对主义哲学的论点,其内涵和外延都不仅只是有人解释的那样,是事物之间的转化。扬雄作在《甘泉赋》中说:“事变物化,目骇耳回。”杜甫作《天育骠图歌》说:“年多物化空形影,呜呼健步无由骋。”清代宣颖作《南华经解》,以佛教唯心主义观点解释“物化”,他说:“物化则一片清虚,四大皆空虚,真淡之至也。”这些“物化”均已经不是庄子所谓的“物化”了。 最后用清人张潮写的《幽梦影》的一句妙语作结,它点出了庄子哲学的精髓:“庄周梦为蝴蝶,庄周之幸也;蝴蝶梦为庄周,蝴蝶之不幸也。”确实如此,庄周化为蝴蝶,从喧嚣的人生走向逍遥之境,是庄周的大幸;而蝴蝶梦为庄周,从逍遥之境步入喧嚣的人生,恐怕就是蝴蝶的悲哀了。但是这幸与不幸,在庄子看来,又会有多大区别呢?答案恐怕是,它们是“齐而为一”的。也许要能在无意识中融入道的思想,能在不经意间运用齐物理论,这才算是真正理解了庄子,真正读懂了《庄子》。 --- ### 2007-11-27 印象里京城的冬天是没有这么好的阳光的吧,也可能是心情好了,阳光也显得格外好了。说起来今年老天也还真不赖,给了很多的好天气,虽然每天还是天黑很早,但却也给了更多的看星星的时间。 突然觉得昆明这个词好遥远...一个生我养我的地方,现在却对她生疏了,心中有些忐忑不安。现在每年回家的时间加起来恐怕不到两个月,以后估计会越来越少吧。不过一份眷乡的感情总还是有的,也常常期盼回到家乡...那个给了我无数美好回忆的地方。 给以前的老师打了电话,和很多同学又联系上了,不知道为什么,特别想念你们...或许是向往以前的像猫一样恬适的生活吧...笑。自己是活得越来越像狗了。 是不是应该自嘲一下呢?自己也不知道..呵呵 --- ### 2007-12-05 真的好久没有去大礼堂了哦,看了中戏的巡演的《伪君子》,真是不错。 其实在拿票的时候,并不知道竹子也要上台演的。之后看到了是中戏05级排的,想起来她不正是在那里么,打了个电话一问,还真是巧...虽然只是个很小的角色,不过还是很为她高兴...恩,主角都是从边上伴舞的小配角做起的。加油~~~相信你~~下次再来演一定是主角哦,我也一定给你献花捧场~~^_^ 总觉得结局有点突兀,怀疑是他们自己改过,回来查了查原著,发现剧本完全是忠于原著的...甚至很多台词都照搬了。只能是赞叹下莫里哀大人活跃的思想了...或者是他到最后发现一直写下去收不住笔而草草来个结尾?总之是。结局有点遗憾... 恩...今天终于学会骑车带人了...原来很多事情并不是很难... --- ### 2007-12-14 大学的教授们基本没把心思花在讲课上,备课的疏忽和讲解思路的混乱很容易就能看得出来。不过毕竟他们主要任务不是教书吧,所以也算差强人意了。课后往往只好靠自学来弥补,而一个一个的Project的价值也在一次次对概念和思想的考验中体现了出来。 最近身边包括自己最常被提到的词儿大概就是Project了。不知道为什么,这样一个几乎是每天都在接触的概念居然缺乏一个完美的中文译名,只好每天说这次Project如何如何。确实也算是舶来物吧,在教育全盘西化的今天,貌似就连一个名词都鲜有人想去弄一个好的译名了。 电信那边的同志们刚刚交了计网的Project,又要马上投身到CPU的开发,而微电的我们也在为一个加法器优化绞尽脑汁。每天面对一个小小的HSpice窗口,没有了图形化设计的繁杂与虚华,在长长的令人生畏的表单和程序后面,却有了更加细致深邃的思考。写代码,跑仿真,看输出,找原因...之后再改代码,跑仿真,看输出,找原因...我知道在不远的某处会有我想得到的结果,但是在那光的彼岸和自己所站立的孤岛之间,却没有一条宽敞的大道。 有时候真的会觉得做各样的Project是一件很有趣的事情。从一开始的懵懂,一步步地对一个新鲜事物有了了解,最后好像是完全明白一样的豁然开朗。再之后,也许有发现严重错误后的低落,有一次成功的惊喜,有逐步总结经验后对满满的收获的笑脸,当然也许也会有历尽荆棘伤痕累累却还是失败的泪水。从体验人生来看,Project恰如人生...喜怒哀乐怆伤悲,人间百味都融在了这几页代码...在外人看来,这也许是满纸荒唐言,但自身体会后,方解其中味。 现在能做的,大概是尽量心情淡泊地完成它们吧 仿真尚未完成,同志仍须努力... 自信!拼搏!坚持! --- ### 2007-12-25 一切平安,勿念。惟忙而已...圣诞节什么的,不存在的。 流水账一样地汇报下最近的情况吧。 本来以为DIC的Project已经算圆满了,但是又飞出组数据直接把我打回原形...所以这几天的生活主旋律还是无尽地调电路。在有了传说中的CosmosCsope的帮助下,我终于可以抛弃一直不发送的Avanwaves,并且清楚地看到电路的问题所在。最后在各位同志的帮助下,达到了一个比较好的结果..在这里先谢谢帮忙的大家啦~~ 往前可以回忆到周六...恩,去考了六级。反正不管从哪里都是说六级如何如何BT,所以作弊满天,怨声载道。舆论威力确实大..害得我小紧张了一下,从周五中午就开始看英语——难得还会在考前准备啊...很久没有过那样做题的感觉了...恩..最近一次大概是两年半前了。结果..什么嘛...这就是六级...这不是轻松裸考么?总感觉那几天时间有些浪费... 昨天是平安夜...胡乱出去吃了顿饭,算是小小的庆祝下。三年了,我一直说北京没有圣诞气氛,昨天更应证了这一点。出去倒是人山人海,但是却找不到一个能算是有圣诞餐吃的地方...残念...不过仅止于此罢了。 汇报完毕..继续干活。 --- ### 2008-01-20 DIC给了10分钟的延长时间,我什么字都没写...因为提前了5分钟我就做完了,在增加的时间里也不想再思考什么了...于是,这个学期就这么Over了。 总结下一个学期的得失,对学习的投入不如上个学期,但是最终的结果应该会稍好...也许确实是微电的东西比较简单吧~~或者说是学起来比较对我的胃口?笑。 接下来的日子,好好休整一下。发两天呆先...嗯。 --- ### 2008-02-03 其实还是蛮顺利的,不过也还是没有跳出每次回家总会有点事情的怪圈。 这次本来要坐的航班居然被直接取消了..还好手脚快及时改签了另一班的最后的座位,居然还能提前半个小时回家...也算是因祸得福。 昆明确实是一个让人犯懒的地方哈,带回家的书一点想要翻开的冲动都没有。算了吧,一心要休息..那就好好休息好了。 踏上家乡的土地,这个学期才算真正的完结了...喵,一个很happy和有点小完美的学期。啦啦~开始玩 --- ### 2008-02-08 11点了,在学校的话,正是拉闸断电人声鼎沸的时候,而现在的我,在家里享受一份淡淡的静谧。 一年前的誓言,在今天回首看来,似乎完成的并不是那么理想,不过却也算是基本履行了。差强人意之中,是妈妈温馨的笑容和爸爸依旧不变的大孩子脾气。我不知道自己欠了他们多少,我也不知道以后是不是能偿还他们那么多。于是我想,珍惜现在,才是最真切的。 大年过到了现在,也觉得就那么一回事儿了。大概人都是会疲惫的,在快一年的奔忙之后,回到安逸的家里,却一时有些适应不了。走亲戚串巷子访同学拜老师,一切过后,我赫然发现,在园子里默默无闻的我原来在这边还有这般的影响。接近三年过去了,当年的小小的光环虽不算光辉耀眼,却也还未泯然众人吧。决心,一直是有的,行动,也一直在做的。诚然,起点本来就低,如果再不以后天努力加以弥补的话,也许真的是无可救药了....但是脑中有中声音一直告诫我,淡定... 是呵...成就一番功名,立下千秋伟业,哪一个男儿不曾有过这样的豪迈壮志,但是现实总会将人无情地打入深渊。我也许还算个幸运儿,想想看,有多少人在自己所谓事业都还没有起步之时,就被扼杀在襁褓之中,又有多少人被迫接受社会的现实,为了生计疲于奔波,却还无法经济自主。于是,我开始庆幸起来,庆幸起自己的命运。我知道这是可耻的,我知道“我命在我不在天”,但很多时候,我又想起另一句“谋事在人,成事在天”的话来。说来好笑,中国的古人们真是把辩证法运用到了极致,怎么说怎么有理,但是正因如此,中国古代的哲学显得凌乱无章,缺乏希腊哲学那一份严谨。后人往往便在前贤的自相矛盾的“谆谆教诲”中苦苦找寻一个可能并不存在的答案,越陷越深,无法自拔。 于是,我不想那么多。望窗外望去,天空泛出一些微红,也许明天会是一个雪天。北京今年很奇怪,居然一直大晴天,多么期盼一场雪呀..可是雪又怎样?这些雪花落下来,多么白,多么好看。过几天太阳出来,每一片雪花都变得无影无踪。到得明年冬天,又有许许多多雪花,只不过已不是今年这些雪花罢了...过眼浮云,看似实在,实则缥缈,去去来来,反复无常。并不记得自己是什么时候有了第一次对人生的感慨,只记得那时候的心似乎和现在一样,空空的。我向来不在意什么积极的想法消极的想法,那只是统治者们所谓的标准罢了。一个人,应该有他独立的思考,而非人云亦云的如木偶愚民般任人操纵。 望着渐渐浓郁的夜色,我困了。脑子里蛮乱的,好罢,什么都不想,睡觉才是真的。活到我的年纪上,也应该知道什么是伤感了,整天学老爸做小孩子也不是办法。 想你,希望今天能梦到你。 --- ### 2008-02-24 吼,新学期开始了! 一起努力吧! --- ### 2008-03-03 腰痛中。可能是还没有掌握好打网球的用力方式,每次下来都会觉得很疲,不过洗过澡以后果然还是神清气爽呐 已经感受不到冬天了,风吹过来,都没有了刀割的凉意,而一天天温暖起来的太阳,也告诉我春天就在眼前了。北京的春天的短我是感受过的了,这个春天一定要把很多很多的春装拿出来穿。在家的念高中的时候,一年四季都可以穿的衣服,到这里只有短短几十天甚至十几天能够穿出来,每次只挑那么几件,其余的全部躺在衣柜的角落里..可惜了可惜了... 顺便抱怨一句...我现在有点恨费曼.... --- ### 2008-07-08 实习的第二天。 早上提前了半小时来办公室,用无比难用的键盘敲东西... 上海果然很热,前天来的时候似乎是38.8,不过从昨儿开始终于是长期进驻空调房了。办公室生物还是很可怕的,长期坐在办公桌前面吹吹空调各干各的事,都不想起来,我也基本没发现什么交流的机会。学生生活比起办公室生活来说真可谓小宅见大宅... 现在还没有什么具体的活儿给我们做,都还在处于基础知识恶补阶段。这么多年以来,终于明白了“大学里学的是学习的方法”这句话。学的知识那是一点用都没有...学了C和C++,人家随便一用都是C#...还建议用2008版的VS。这辈子学再快估计也不会有更新速度快了。于是预计这个星期刻苦猛补,争取能在这段时间把C#和SQL弄得比较清楚吧。据说之后会是一个为公司软件做一个类似wiki一样的东西..其实还是蛮期待的。 就到这里吧,受不了这个磨手的键盘,等我找到好用的键盘再继续... --- ### 2008-07-16 实习的第二周。 换了新键盘,快乐地写程序中。 这学期的成绩都出来了,本来这个学期就没有太好好学习吧,结果最后的结果要比预想的好很多,除了可恶的MEMS和...恩,和教MEMES的“严苛”的老师和助教,其他可以说基本是满意的了。也许这是最后一个为成绩挣扎的日子了吧,大四貌似据说是能过就行...没有压力... 还是说实习的事情,已经开始动手做WIKI了,进展很顺利,除了某人的用户管理感觉有一些些跟不上进度...不过应该很快会好起来咯。我的工作任务就是....没人做的都我做....听起来很惨...实际嘛.. 也还是很惨 和好几个同事已经蛮熟了。姜宁是个好人,带着一堆啥都不懂的孩子搞开发..其实关键代码都是他写的...好人啊好人...会不会有背后发卡的嫌疑..自己汗一个先~~~ 老妈20号过来审查,王燕21号过来审查..还有电信的老师25号过来审查...不知道是过来看工作还是担心我们在被虐.. 就这样先~有人偷看不写了... --- ### 2008-09-29 尘埃落定。 从8月中就开始的推研大战至今圆满结束。 幕落下,有人得意欢笑,也有人暗自神伤...幸运地,我站在了前一类人当中。嘛,或者只是自认为站在了前一类人当中。不过不管怎样,在经历了各种艰难险阻和几番紧张又带有些许失意的心态后,我还是那到了一个蛮不错的结果。在今年如此险恶的推研环境下,以一个看上去没有任何优势的名次,踉踉跄跄拿到了基本可以说最后一个系内名额。于是就这样,今后的三年还是会在这个熟悉又陌生的园子里度过了... 也没有什么好总结的,几句话而已:看好合适的,联系可能的,排序很重要,机会都不放。前面三年的GPA固然很重要,但是这几天的努力联系和准备还是能加上不少分。不论如何,机会其实是很多的,忠告就是,就算分数不够看,也不要认为自己不行,主动跑动积极联系机会才会到来,自信丧失坐以待毙就真的走投无路咯。 嗯...在中科院的经历再一次让我发现清华的牌子还是很有用的...感谢自己一直的努力... 不过回头看看老爸的月收入,发现自己还没到可以松懈的时候,继续加油... --- ### 2008-10-18 3点,似乎已经成为我周末睡觉的标准时间了。我自然知道长此以往必将出现种种问题,但是周末的晚上确实是我整周之中唯一能够踏踏实实拿出来的整片的时间了。 前夜和昨夜的月都很圆,但是在北京永远的雾朦胧下,却是难见到那久违的狡黠。今天无意中翻到一篇文章,教你怎么用傻瓜相机拍月亮。起初还对这样的“入门级”文章嗤之以鼻,认为玩儿来玩去不就是个傻瓜数码机么,顶多就对对焦按按快门的事情。结果随意一扫就被几张效果图震住了。说实话那样的月亮我只有在游戏里才能看得到,想不到居然还能照出那样好的效果:整个月亮拉到很大,表面笼罩一层白光,上面的阴影坑洼都清晰可见,边缘似乎有些模糊,整个月亮几乎要跃纸而出。然后赶忙仔细看了看拍摄教程,很不幸的是,一段之后就头晕脑胀了。那哪儿是什么傻瓜相机教程呀..一段里面不懂的名词就七八个。直到最后才以“就算学会了,北京的月亮也绝不可能达到那种效果”为由,心安理得的去做其他事情了。 研是推完了,但其实生活并没有想象的那么轻松惬意。发现在这里念了三年书,把自己原来的一些优点给丢掉了。也许也是因为高手太多吧...三年以前的我,绝不会认得现在这么一个被功利主义渲染的我...很多事情会不由自主的想到于己的利益。如此,往往做事就蹑手蹑脚,思此顾彼。人变得不单纯了,不知道该算是优点还是缺点..至少,我很讨厌现在这样的自己,我从来都不愿意成为那种斤斤计较一点点得失的人。 不止一次被人说看起来不成熟,现在来看,貌似已经成为我的典型形象了。也好也好...至少可以显得不那么可怕... 城府这种东西,从来不是被看出来的。 以上胡言乱语,写在睡前... 现在,是时候去睡觉了。 --- ### 2008-10-22 不知道是因为深秋天凉人容易犯困还是看应变硅材料比较无味,整天的都很没有精神干活儿。微机原理的课已经两节没有去上了,X86的保护模式还真的一点都不懂...不知道这样一直下去的话会不会被挂掉... 生活方面倒是很顺利,又到了一年秋冬,万物萧索的时令了。秋风的肃杀算不上什么,但是一旦配上黄叶凋落和夕阳的余晖,就不免让人有些伤感。叶绿叶黄,春夏秋冬,人生的韶华就这样一点点流逝。古典音乐的美好能让人沉醉能让人保有恬淡的心境,但是青春本身的不安和变动却让人更向往着新的生活。新老交替乃是自然常理,但是听着楼下大一小朋友们的那饱有激情又略显生涩的合唱排练,心中却是不知自己是算新,抑或是老... 但是,至少明天的太阳还是新的~阔里瓦~~要抓紧时间加油呀 有句话很好: 英雄的少年呀~朝着朝阳奔跑,去创造属于自己的奇迹吧~~... 可是..貌似我早已不是少年了 笑...或者是苦笑。 --- ### 2010-02-28 鞭炮齐鸣,礼花冲天,我又回来写日记了。 回头看看之前的BLOG,发现写博确实是一件有百益无一害的事情呀。生活的点点滴滴总是在不经意间就被遗忘,但若是能在回首时看到一路走来的足迹,这确实是一种幸福。 一年多过去了,的确发生了很多的事情。 回来的时候看到一个07级EE的小师弟/妹到BLOG里找一题束缚态波函数证明的博文,不由会心一笑。一代又一代的清华EEer,一次又一次的基础练习。而正是这样的轮回,推动着时代的进步吧。 说实话,人变得成熟了不少,但是自己也能感觉到,还需要太多的磨砺。心态上早已没有了当初的锋芒,但是行动上却还是咄咄逼人。今天是元宵,也算是过年的真正结束。再过40分钟将迎来3月,新的一年的工作和学习马上就要正式开始。窗外的小雪零零落落,大概会是冬天结束的标志吧~ 新的春天,必会有新的希望,而只要通过努力,也必将有新的果实。 以上与君共勉,也作为归来感言。 --- ### 2010-03-02 星期一是开例会的日子。照例早早在实验室等待,顺便研究Sentaurus,一直到七点开会,BOSS布置近期任务。 今天除了原来的几个老家伙外,又来了两只新的小朋友...基本都是外校过来做本科生毕设的,貌似据说过两天还会有两个深圳的工硕和两个本校的本科生加入。对比起自己做本科毕设时候郭磊+赵硕的双人组合来说,现在的阵容还真是前所未有的强大啊。 但是很不幸的是人多就意味着活儿也多..本来还想着刚开始能轻松几天,但是天煞的IEDM2009刚刚结束,论文成果一堆一堆,头儿的点子也一堆一堆。于是乎被活生生从Sentaurus里拽出来,然后被淹没在各种pdf文档里了... 其实看论文也是蛮享受的一件活儿,特别是国际大会议的论文集。格式规范,书写严谨,论证充足,思维新颖,这都是国内小期刊没得比的。假期的时候看过一篇Sentaurus的45nmStrain-Si的仿真,是某野鸡大学的一篇硕士毕业论文,还得到了08年的优秀硕士毕业论文。仔细看的时候,越来越觉得眼熟,到后来看到结果图时发现这完全就是Synopsys的技术文档的中文不完全翻译版,相当囧。论文的后半篇是一个FinFET的3D建模,一开始还以为原作者原来自己动过那么一点点手的,结果今天找SolvNet来一看,后半部分的FinFET也是人家现有的技术文档,这次是相当囧RZ了...就靠两篇技术文档的翻译,不仅顺利从野鸡大学毕业,还混到一篇国内核心期刊,以及优秀毕业生论文这样的奖项..人才..不愧是野鸡大学.. 不过最可恶的还是Synopsys,好好的搞什么SolvNet认证才能下载参考资料..弄得想要看技术文档还得巴巴地跑去设计室找管理员,麻烦的一米..这么做完全不利于软件发展嘛..这种专业软件还不把参考用法公开,让人去刨那几千页的Manual,完全不现实。今天一位小朋友还要用Sentaurus做他的本科毕设哩..跑过来说Sentaurus的事情,头疼中..应该怎么速成呢..要知道我可是学了两个月才刚刚入门啊..>-< 但是现在貌似可以把Sentaurus放到一边去了,下阶段主攻锗器件的几个技术难点,25nm的主流依然会是应变硅+HighK金属栅,但是远一点看11nm时候应变硅肯定失效了,新材料才是王道呀~锗器件在工作电压0.7V以下才能有可接受的漏电,这是最根本的问题所在。11nm以下更换材料已经是共识,到底最终谁会胜出?锗,III-V,亦或是碳纳米管?也难说会有意想不到的新东西出来呢。15nm或以上的时候时候硅就是无可替代的呢,有没有办法在工作电压大于0.7V的时候让锗基提前应用,将是我今后几个月主要研究的课题。 祝我好运吧.. --- ### 2010-03-14 窗外大雪纷飞。 这已经是3月中了,按京城惯例的话,明天就停止供暖了。本是桃花含苞樱花漫舞的三月,却还在下如此大的雪。已经记不清这是入冬到现在的第几场雪了,只知道今年的冬天就别冷,而且如春以后还保持每周一次的降雪,不得不说是气候的异常。可以预见的是,今年真正的春天,会短暂得很的了。 去年10月国庆60周年,为了得到一个晴天,之前进行了人工干预天气的降雨;再往前08的奥运,似乎也被报道人工控制天气。不知道现在这样的降雪算不算是老天的回应或者是警告咧... 相反的..昆明自从入冬以来就只有一个持续10分钟的小雨。今年春节回家的第一感受就是干旱,恩..据说是百年未遇的大旱..而且一直持续到了现在。昆明周边的供水据说已经开始限制了,希望快快下雨,不要影响春天的大好时光吧.哎~ 两会闭幕了,早上在青鸟锻炼的时候顺便看了温总的记者会。温总在的表现只能说差强人意,除了拿出拿手的引用以外,并没有回到什么实质的问题。..算了不提也罢~ 锻炼,学车,研究...加油 --- ### 2010-03-20 今天是3月20日,春分之前一天。 起了个大早,结果发现天色泛黄,拉开窗帘一看,果然是沙尘..这大概是我在北京所见到的第二大的沙尘天了。 前几天大雪纷飞,现在又沙尘肆虐,不得不让人唏嘘北京的生存环境之恶劣啊~以后还是离开这个地方为好... 本来应该是阳春三月,按往年来看,学校里应当已然是新芽绽出一篇春光景致了,可以说是春游踏青的好时节。但今年实际情况却让人提不起兴致,走到哪儿都是光秃秃一片,树木死气沉沉,映衬着发黄的天空和瑟瑟的冷风,再加上大片大片被铲掉已死草皮以后被翻起的堆堆黄土,俨然一副破败景象。 直接怀疑今年春天是否还会来...本来北京春天就已经很短,经过这么一折腾,我觉得可以直接进入夏天了吧。这悲剧的... 早起就是为了干活..那么就开始干活了。 写完日志再往窗外一看,比刚才更黄了...唉..不知道今天还能不能出去吃午饭。 URL: https://onevcat.com/2018/06/wwdc-2018/index.html.md Published At: 2018-06-05 12:15:00 +0900 # 开发者所需要知道的 WWDC 2018 新特性 ![](/assets/images/2018/wwdc.png) 一直阅读我的博客的朋友可能知道,我在每年 WWDC 之后都会写 (水) 一篇关于新 SDK 和开发工具的文章。之前这个系列叫做《开发者所需要知道的 iOS SDK 新特性》,但是最近虽然 Craig 嘴上说着不要,身体却很诚实地将 iOS 和 macOS 带到一起,所以今年我觉得可以改一改题目,就总览一下作为 Apple 生态圈的开发者,在今年 WWDC 上我个人的一些观察,以及可能应该注意的有趣的地方。 在会前,Apple 就已经放出消息要放慢增加新功能的脚步,转而提升软件稳定性和可靠性。所以 iOS 12 和 macOS 10.14 中对开发者来说并没有特别大的新功能,这也是意料之中。在一片“平平淡淡”的评论中,也许我们会更有时间和信息来打磨一款 app,正如前几天 [deno 项目下](https://github.com/denoland/deno/issues/25)的哥们儿说的:“求别更新了,老子学不动了”。 当然这是一句戏谑,作为程序员或者开发者,应该是这个星球上最需要学习的职业 (之一),我们就来看看今年的 WWDC 带给了我们哪些东西吧。 ## Machine Learning 这算是时代的潮流。前几天 Google I/O 所展示的 AI 打电话当然令人印象深刻,不过其实 Apple 也很早就押宝 AI 和 ML 了。区别在于,Apple 家的天赋分配和 Google 不太一样,Google 更多地是用“集中式”的方式,通过第一方资源完成训练和模型部署,用户进行使用,在此期间第三方开发者能做的事情十分有限。今年 Google 推出了基于 Firebase 的 ML Kit,让移动开发者可以处理一些现成的常见 ML 任务 (比如文本识别,面部检测等),这部分任务在 iOS 中其实很早就已经在 Foundation 中有提供,之后又抽离出了像是 Vision 之类的专用框架。而第三方开发者自行训练模型的部分,Google 今年则给出了 TensorFlow Lite 和 Firebase 进行 host 的组合。 而 Apple 与之不同,从一开始 Apple 就寄希望于“复制” App Store 的成功模式,也就是将 ML 相关的功能提供给开发者,去年的 Core ML 框架就是一个这样的尝试。当然 Core ML 本身比较简陋,它通过将已有的其他格式的模型“转换”为自己能够理解的模型,然后对新的输入进行求值。模型训练在 Mac 平台上一直是一个软肋,因为最近 Mac 已经不再配置 Nvidia 的显卡,TensorFlow 甚至都已经不再为 macOS 提供带 GPU 支持的版本,对于日常工作在 Apple 平台的开发者来说,训练一个自己的模型一度是相当困难的事情。 2016 年 Apple 曾经收购了一家 ML 的初创公司 Turi,在今年这一收购案终于是开花结果。基于 Turi 的[模型训练框架 Create ML](https://developer.apple.com/documentation/create_ml) 可以利用 Mac 的 GPU (以及 Metal) 进行训练,并直接得到 Core ML 可用的模型。结合自家 Playground 的更新,Apple 更是祭出了 CreateMLUI 这种“傻瓜式”但效果还很不错的可视化方式,现在开发者只需要将训练数据和测试数据拖拽到 Playground UI 中,就能在 Apple 自家的模型的基础上,得到很好的结果。这大大降低了一般开发者在第三方 app 中集成 AI 特性的门槛,有很多时候用户并不一定需要 Google Assistant 那样复杂的应用,而在一般的各类 app 中集成的 AI,对 app 易用性的提升会相当显著。 另外,去年的 Vision 框架在结合 CoreML 使用时表现很不错,让不少第三方 app 可以很简单地利用计算机视觉的一些成果。而今年 Apple 在这方面更进一步,带来了自然语言处理的框架。这也是今年 WWDC 在系统中新增的少数几个框架之一。 Apple 虽然在 Keynote 上一句 AI 都没有提过,但是它却“润物细无声”,贯穿在照片搜索,Finder,Shortcuts 等方方面面。不管你是否承认,Apple 也已经从移动导向逐渐过度到了 AI 导向,而对于移动开发者来说,也许基本的 AI 开发将是不可回避的话题。虽然对模型的训练现在的业界主流依然是 TensorFlow,或者说套上 Keras 的前端,但 Apple 的出现和对 Turi 的持续投资,让它成为了另一种可能。 #### 推荐观看的相关 Session - [Machine Learning Get-Together](https://developer.apple.com/videos/play/wwdc2018/110/) - [Introducing Create ML](https://developer.apple.com/videos/play/wwdc2018/703/) - What’s New in Core ML [Part 1](https://developer.apple.com/videos/play/wwdc2018/708/) [Part 2](https://developer.apple.com/videos/play/wwdc2018/709/) - [A Guide to Turi Create](https://developer.apple.com/videos/play/wwdc2018/712/) - [Introducing Natural Language Framework](https://developer.apple.com/videos/play/wwdc2018/713/) - [Vision with Core ML](https://developer.apple.com/videos/play/wwdc2018/717/) ## Xcode 暗色的 Xcode 果然是大家的最爱!但是除了“科技以换色为本”以外,在一段不长的时间的试用后,我已经决定把 Xcode 10 beta 作为日常使用的工具了。Apple 看起来是确实履行了改善软件质量的承诺,相比于前几个版本的 Xcode beta,这次的稳定性相当值得称赞。新的编译系统和更加顺滑的编辑器,以及一些深度集成的小功能 (边栏的 git status indicator,或者多光标编辑模式等),让 Xcode 的编辑功能追上了主流。另外,语法高亮和自动补全的处理显然也得到了照料,即使是 beta 版本,也比 Xcode 9 正式版本身要稳定得多。 基本上现在开始将 Xcode 10 作为日常主力工具,使用 Swift 4.1 兼容的语法进行开发,这样可以让自己每天过得舒服一点,并且可以保持用 CI 上的 Xcode 9 进行测试构建和提交。我想这大概会是我到九月前这段时期的选择了。 Playground 在推出时被寄予厚望,但是实际表现却非常一般:响应速度慢,经常崩溃无响应, #### 推荐观看的相关 Session - [Getting the Most out of Playgrounds in Xcode](https://developer.apple.com/videos/play/wwdc2018/402/) - [Building Faster in Xcode](https://developer.apple.com/videos/play/wwdc2018/408/) ### Swift Swift 4.2 的编译器同时支持 Swift 3 (虽然应该是最后一个支持 3 的版本了) 和 Swift 4 的代码,所以不需要预先进行代码迁移。新版本继续向着更易用的 Swift 的方向进行努力,比如将更多的 C-like 的代码 (比如 CoreAnimation 的一整套 API) 改成更 modern 的形式,比如为数字和数组添加 `random`,比如将很多 String-base 的 API 加上强类型或者 KeyPath 等等。 Swift 5 被延期到了明年,ABI 稳定显然是最近 Swift 的主攻方向,因为只有 ABI 稳定,Apple 才可能开始在自家框架中使用 Swift。 除了自家平台之外,可能值得一提的是 [Swift for TensorFlow](https://github.com/tensorflow/swift) 的分支。这是 Swift 的创始人 Chris Lattner 在加入 Google 后所主导的一样项目,希望使用 Swift 来操作 TensorFlow 来构建 tensor graph 模型,由于有编译阶段的支持,可以纵览全局,所以往往可以比 Python 提供更好的性能。它使用了 dynamicMember 来提供 Python 兼容的代码形式,思路非常有趣。鉴于 Chris 在 Swift 社区的影响力,以及 Apple 本身想要把 Swift 打造成多平台,多用途的语言,这个分支最终合并也非不可能。 #### 推荐观看的相关 Session - [What's New in Swift](https://developer.apple.com/videos/play/wwdc2018/401/) - [Swift Generics](https://developer.apple.com/videos/play/wwdc2018/406/) - [Getting to Know Swift Package Manager](https://developer.apple.com/videos/play/wwdc2018/411/) ### 性能调优和工具支持 优化是没有极限的,这部分属于内力比拼了。每年的性能相关或者偏底层的内容,可能不能理解给你带来什么收益,但是却是作为一个技术者,在成长道路上不可或缺的部分。今年因为新的 feature 比较少,所以优化部分的内容要比以往都多。 #### 推荐观看的相关 Session - [What's New in Testing](https://developer.apple.com/videos/play/wwdc2018/403/) - [Measuring Performance Using Logging](https://developer.apple.com/videos/play/wwdc2018/405/) - [Practical Approaches to Great App Performance](https://developer.apple.com/videos/play/wwdc2018/407/) - [Understanding Crashes and Crash Logs](https://developer.apple.com/videos/play/wwdc2018/414/) - [iOS Memory Deep Dive](https://developer.apple.com/videos/play/wwdc2018/416/) - [Embracing Algorithms](https://developer.apple.com/videos/play/wwdc2018/223/) ## Siri with Shortcuts 和 ML 领域类似,另一个开花结果的案子是对 Workflow 的收购:Shortcuts app 整个就是 Siri 版的 Workflow。Siri 在面对 Amazon 和 Google 这样的“后来者”挑战时,确实显得力不从心,而从 Keynote 的演示来看,Shortcuts 可能会是一剂良药。 和近几年的其他很多功能一样,开发者通过操作 `NSUserActivity` 来配置简单的 Shortcuts 以获取 Siri 的推荐。如果你想要更加自定义的行为和表现,那还需要定义和开发新的 Intent app extension。这部分内容都被作为 SiriKit 的追加内容,希望能够为用户带来每天使用 Siri 的理由吧。 #### 推荐观看的相关 Session - [Introduction to Siri Shortcuts](https://developer.apple.com/videos/play/wwdc2018/211/) - [Building for Voice with Siri Shortcuts](https://developer.apple.com/videos/play/wwdc2018/214/) ## 总结 当然,上面的都是我个人认为会比较重要的内容。每个人经历不同,所关注的兴趣点自然也不一样。WWDC 18 里还有不少规模更小一些的话题,可能也会在某种场合下特别重要,可以纵览以后根据需求再深入研究。 总之,今年的 WWDC 应该是对开发者比较友好的。在从 iOS 7 改头换面开始,iOS 就在添加新功能的快车道上一路狂奔。而今年虽然也有新功能,但是很明显在力度上有所缓和,Apple 更多地将精力放在了改善现有软件的品质上,期望能在资本市场和用户体验之间找到平衡。对于开发者来说,抓住这个空隙,多想一想如何改善自己的产品,如何定义未来 app 的走向,甚至如何打磨自己,并对今后的三五年进行规划,可能更多地是我们当下需要踏实下来多加思考的东西。 URL: https://onevcat.com/2018/05/mvc-wrong-use/index.html.md Published At: 2018-05-11 09:15:00 +0900 # 关于 MVC 的一个常见的误用 > 写在前面:[ObjC 中国](https://objccn.io) (或者说我个人) 现在正和 objc.io 合作打造一本关于 [app 架构](https://www.objc.io/books/app-architecture/)的书籍。英文版本已经提前预售,书本身也进入了最后的 review 阶段。我们也将在第一时间进行本书中文版的工作,还请大家关注。 > > 本文的内容也是有关 app 架构的一些思考,如果你对架构方面的话题有兴趣的话,我之前还写过一篇利用 reducer 的[单向数据流动的函数式 View Controller](https://onevcat.com/2017/07/state-based-viewcontroller/) 的文章可供参考。 如何避免把 Model View Controller 写成 Massive View Controller 已经是老生常谈的问题了。不管是拆分 View Controller 的功能 (使用多个 Child View Controller),还是换用“广义”的 MVC 框架 (比如 MVVM 或者 VIPER),又或者更激进一点,转换思路使用 Reactive 模式或 Reducer 模式,其实所想要解决的问题本质在于,我们要如何才能更清晰地管理“用户操作,模型变更,UI 反馈”这一数据流动的方式。 非传统的 MVC 可以帮助我们遵循一些更不容易犯错的编程范式 (这一点和 Java 很像,使用冗杂的 pattern 来规范开发,让新人也能写出“成熟”的代码),但是如果不从根本上理解数据流动在 MVC 中的角色,那不过就是末学肤受,迟早会出现问题。 ### 例子 举一个非常简单的 View Controller 的例子。假设我们有一个 Table View Controller 来记录 To Do 列表,我们可以通过点击导航栏的加号按钮来追加一个条目,用 Swipe cell 的方式删除条目。我们希望最多同时只能存在 10 条待办项目。这个 View Controller 的代码非常简单,可能也是很多开发者每天会写的代码。包括设置 Playground 和添加按钮等等,一共也就 60 行。我将它放到了[这个 gist 中](https://gist.github.com/onevcat/4042d4d0f156b986e4755a7d4370bb9c),你可以全部复制下来扔到 Playground 里查看效果。 这里简单对比较关键的代码进行一些解释。首先是模型定义: ```swift // 定义简单的 ToDo Model struct ToDoItem { let id: UUID let title: String init(title: String) { self.id = UUID() self.title = title } } ``` 然后我们使用 `UITableViewController` 的子类进行待办事项的展示和添加: ```swift class ToDoListViewController: UITableViewController { // 保存当前待办事项 var items: [ToDoItem] = [] // 点击添加按钮 @objc func addButtonPressed(_ sender: Any) { let newCount = items.count + 1 let title = "ToDo Item \(newCount)" // 更新 `items` items.append(.init(title: title)) // 为 table view 添加新行 let indexPath = IndexPath(row: newCount - 1, section: 0) tableView.insertRows(at: [indexPath], with: .automatic) // 确定是否达到列表上限,如果达到,禁用 addButton if newCount >= 10 { addButton?.isEnabled = false } } } ``` 接下来,处理 table view 的展示,这部分内容乏善可陈: ```swift extension ToDoListViewController { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return items.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) cell.textLabel?.text = items[indexPath.row].title return cell } } ``` 最后,实现滑动 cell 删除的功能: ```swift extension ToDoListViewController { override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { _, _, done in // 用户确认删除,从 `items` 中移除该事项 self.items.remove(at: indexPath.row) // 从 table view 中移除对应行 self.tableView.deleteRows(at: [indexPath], with: .automatic) // 维护 addButton 状态 if self.items.count < 10 { self.addButton?.isEnabled = true } done(true) } return UISwipeActionsConfiguration(actions: [deleteAction]) } } ``` 效果如下: ![](/assets/images/2018/todo-demo.gif) 看起来一切正常工作!不过,你能看出有什么问题吗?抑或说你觉得这段代码已经完美无瑕了? ### 风险 简单来说,这也已经是对 MVC 的误用了。上面的代码存在着这些潜在问题: 1. Model 层“寄生”在ViewController 中 在这段代码中,View Controller 里的 `items` 充当了 model。 这导致了几个问题:我们难以从外界维护或者同步 `items` 的状态,添加和删除操作被“绑定”在了这个 View Controller 里,如果你还想通过其他 View Controller 维护待办列表的话,就不得不考虑数据同步的问题 (我们会在稍后看到几个具体的这方面的例子);另外,这样的设置导致 `items` 难以测试。你几乎无法为添加/删除/修改待办列表进行 Model 层的测试。 2. 违反数据流动规则和单一职责规则 如果我们仔细思考,会发现,用户点击添加按钮,或者侧滑删除 cell 时,在 View Controller 中其实发生了这些事情: 1. 维护 Model (也就是 `items`) 2. 增删 table view 的 cell 3. 维护 `addButton` 的可用状态 也就是说,UI 操作不仅导致了 Model 的变更,还同时导致了 UI 的变化。理想化的数据流动应该是单向的:UI 操作 -> 经由 View Controller 进行模型更新 -> 新的模型经由 View Controller 更新 UI -> 等待新的 UI 操作,而在例子中,我们变成了“经由 View Controller 进行模型更新以及 UI 操作”。虽然看起来这是很不起眼的变更,但是会在项目复杂后带来麻烦。 也许你现在并不觉得有什么问题,让我们来假设一些情景,你可以思考一下如何实现吧。 #### 场景一 首先来看看待办条目的编辑,我们可能需要一个详情页面,用来编辑某个待办的细节,比如为 `ToDoItem` 添加上 `date`,`location` 和 `detail` 这类的属性。另外,PM 和用户也许希望在详情页面中也能直接删除这个正在编辑的待办。 以现在的实现来看,一个很朴素的想法是新建 `ToDoEditViewController`,然后设置 `delegate` 来告诉 `ToDoListViewController` 某个 `ToDoItem` 发生了变化,然后在 `ToDoListViewController` 进行对 `items` 进行操作: ```swift protocol ToDoEditViewControllerDelegate: class { func editViewController(_ viewController: ToDoEditViewController, editToDoItem original: ToDoItem, to new: ToDoItem) func editViewController(_ viewController: ToDoEditViewController, remove item: ToDoItem) } // 在 ToDoListViewController 中 extension ToDoListViewController: ToDoEditViewControllerDelegate { func editViewController(_ viewController: ToDoEditViewController, remove item: ToDoItem) { guard let index = (items.index { $0.id == item.id }) else { return } items.remove(at: index) let indexPath = IndexPath(row: index, section: 0) tableView.deleteRows(at: [indexPath], with: .automatic) if self.items.count < 10 { self.addButton?.isEnabled = true } } func editViewController(_ viewController: ToDoEditViewController, editToDoItem original: ToDoItem, to new: ToDoItem) { //... } } ``` 有一部分和之前重复的代码,虽然可以通过将它们提取成像是 `removeItem(at index: Int)` 这样的方法,但是并不能改变非单一功能的问题。`ToDoEditViewController` 本身也无法和 `items` 通讯,因此它扮演的角色几乎就是一个“专用”的 View,一旦脱离了 `ToDoListViewController`,则“难堪重任”。 #### 场景二 另外,纯单机的 app 已经跟不上时代了,不管是 iCloud 同步还是自建服务器,我们总是想要一个后端来为用户跨设备保存列表,这是一个非常可能的增强。在现有架构下,把从服务器获取已有条目的逻辑放到 `ToDoListViewController` 也是很自然的想法: ```swift override func viewDidLoad() { super.viewDidLoad() //.. NetworkService.getExistingToDoItems().then { items in self.items = items self.tableView.reloadData() if self.items.count >= 10 { self.addButton?.isEnabled = false } } } ``` 这种简单的实现面临很多挑战,是我们在实际 app 中不得不考虑的: 1. 是不是应该需要在 `getExistingToDoItems` 过程中 block 掉 UI,否则用户在请求完成前所添加的条目将被覆盖。 2. 在添加和删除条目的时候,我们都需要进行网络请求,另外我们也需要根据请求返回的状态更新添加按钮的状态。 3. Block 用户输入将让 app 变为没网无法使用,不进行 block 的话则需要考虑数据同步的问题。 4. 另外,我们需不需要在没网时依然让用户可以进行增加或删除,并缓存操作,等到有网时再将这些缓存反映给服务器。 5. 如果需要实现 4,那么还要考虑操作结果导致超出条目最大数量限制的错误处理,以及多设备间数据冲突处理的问题。 是不是突然感觉有些头大? ### 改善 这些问题的来源其实都是我们为了“省事”,选择了一个**不那么有效的 Model**,以及**存在风险的数据流动方式**。或者说,我们没有正确和严格地使用 MVC 架构。 关于 MVC,斯坦福的 CS193p Paul 老师有一张非常经典的图,相信很多 iOS 的开发者也都看过: ![](/assets/images/2018/mvc.png) 我们的例子中,我们等于把 Model 放到了 Controller 里,而且 Model 也无法与 Controller 进行有效的通讯 (图中的 Notification & KVO 部分)。这导致 Controller 承载了太多的功能,这往往是光荣地迈向 Massive View Controller 的第一步。 #### 单独的 Model 当务之急是将 Model 层提取出来,为了说明简单,暂时先只考虑纯本地的情况: ```swift extension ToDoItem: Equatable { public static func == (lhs: ToDoItem, rhs: ToDoItem) -> Bool { return lhs.id == rhs.id } } class ToDoStore { static let shared = ToDoStore() private(set) var items: [ToDoItem] = [] private init() {} func append(item: ToDoItem) { items.append(item) } func append(newItems: [ToDoItem]) { items.append(contentsOf: newItems) } func remove(item: ToDoItem) { guard let index = items.index(of: item) else { return } remove(at: index) } func remove(at index: Int) { items.remove(at: index) } func edit(original: ToDoItem, new: ToDoItem) { guard let index = items.index(of: original) else { return } items[index] = new } var count: Int { return items.count } func item(at index: Int) -> ToDoItem { return items[index] } } ``` 当然,为了一步到位,也可以直接把上面的 `NetworkService` 加上,写成异步 API,例如: ```swift func getAll() -> Promise<[ToDoItem]> { return NetworkService.getExistingToDoItems() .then { items in self.items = items return Promise.value(items) } } func append(item: ToDoItem) -> Promise { return NetworkService.appendToDoItem(item: item) .then { self.items.append(item) return Promise.value(()) } } ``` > 为了好看,这里用了一些 [PromiseKit 的东西](https://github.com/mxcl/PromiseKit),如果你不熟悉 Promise,也不用担心,可以将它们简单地看作 closure 的形式就好,这并不会影响继续阅读本文: ```swift func getAll(completion: @escaping (([ToDoItem]?, Error?) -> Void)?) { NetworkService.getExistingToDoItems { response, error in if let error = error { completion?(nil, error) } else { self.items = response.items completion?(response.items, nil) } } } ``` 这样,我们就可以将 `items` 从 `ToDoListViewController` 拿出来。对单独提取的 Model 进行测试变得非常容易,纯 Model 的操作与 Controller 无关,`ToDoEditViewController` 也不再需要将行为 delegate 回 `ToDoListViewController`,编辑条目的 View Controller 可以通过成为了真正意义上的 View Controller,而不止是 `ToDoListViewController` 的“隶属 View”。 单独的 `ToDoStore` 作为模型带来的另一个好处是,因为它与具体的 View Controller 分离了,在进行持久化时,我们可以有更多的选择。不论是从网络获取,还是保存在本地的数据库,这些操作都不必 (也不应写在 View Controller 中)。如果有多种数据来源,我们可以轻松地创建类似 `ToDoStoreCoordinator` 或者 `ToDoStoreDataProvider` 这样的类型。既可以满足单一职责,也易于覆盖完整的测试。 #### 单向数据流动 接下来,将数据流动按照 MVC 的标准进行梳理就是自然而然的事情了。我们的目标是避免 UI 行为直接影响 UI,而是由 Model 的状态通过 Controller 来确定 UI 状态。这需要我们的 Model 能够以某种“非直接”的方式向 Controller 进行汇报。按照上面的 MVC 图,我们使用 Notification 来搞定。 对 `ToDoStore` 进行一些改造: ```swift class ToDoStore { enum ChangeBehavior { case add([Int]) case remove([Int]) case reload } static func diff(original: [ToDoItem], now: [ToDoItem]) -> ChangeBehavior { let originalSet = Set(original) let nowSet = Set(now) if originalSet.isSubset(of: nowSet) { // Appended let added = nowSet.subtracting(originalSet) let indexes = added.compactMap { now.index(of: $0) } return .add(indexes) } else if (nowSet.isSubset(of: originalSet)) { // Removed let removed = originalSet.subtracting(nowSet) let indexes = removed.compactMap { original.index(of: $0) } return .remove(indexes) } else { // Both appended and removed return .reload } } private var items: [ToDoItem] = [] { didSet { let behavior = ToDoStore.diff(original: oldValue, now: items) NotificationCenter.default.post( name: .toDoStoreDidChangedNotification, object: self, typedUserInfo: [.toDoStoreDidChangedChangeBehaviorKey: behavior] ) } } // ... } ``` 这里添加了 `ChangeBehavior` 作为“提示”,来具体告诉外界 Model 中发生了什么。`diff` 方法通过比较原始 `items` 和当前 `items` 来确定发生了哪种 `ChangeBehavior`。最后,使用 `items` 的 `didSet` 来发送 Notification。 > 由于 Swift 的数组是值类型,对于 `items` 的元素增加,删除,修改或者整体变量替换,都会触发 `didSet` 的调用。Swift 的值语义编程带来了很大的便利。 在 `ToDoListViewController`,现在只需要订阅这个通知,然后根据消息内容进行 UI 反馈即可: ```swift class ToDoListViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() //... NotificationCenter.default.addObserver( self, selector: #selector(todoItemsDidChange), name: .toDoStoreDidChangedNotification, object: nil) } private func syncTableView(for behavior: ToDoStore.ChangeBehavior) { switch behavior { case .add(let indexes): let indexPathes = indexes.map { IndexPath(row: $0, section: 0) } tableView.insertRows(at: indexPathes, with: .automatic) case .remove(let indexes): let indexPathes = indexes.map { IndexPath(row: $0, section: 0) } tableView.deleteRows(at: indexPathes, with: .automatic) case .reload: tableView.reloadData() } } private func updateAddButtonState() { addButton?.isEnabled = ToDoStore.shared.count < 10 } @objc func todoItemsDidChange(_ notification: Notification) { let behavior = notification.getUserInfo(for: .toDoStoreDidChangedChangeBehaviorKey) syncTableView(for: behavior) updateAddButtonState() } } ``` > Notification 本身有很长的历史,是一套基于字符串的松散 API。这里通过扩展和泛型的方式,由 `.toDoStoreDidChangedNotification`,`.toDoStoreDidChangedChangeBehaviorKey` 和 `post(name:object:typedUserInfo)` 以及 `getUserInfo(for:)` 构成了一套更 Swifty 的类型安全的 `NotificationCenter` 和 `userInfo` 的使用方式。如果你感兴趣的话,可以参看[最后的代码](https://gist.github.com/onevcat/9e08111cebb1967cb96a737ed40f9f14)。 最后,我们可以把之前用来维护 table view cell 和 `addButton` 状态的代码都删除了。用户操作 UI 唯一的作用就是触发模型的更新,然后模型更新通过通知来刷新 UI: ```swift class ToDoListViewController: UITableViewController { // 保存当前待办事项 // var items: [ToDoItem] = [] // 点击添加按钮 @objc func addButtonPressed(_ sender: Any) { // let newCount = items.count + 1 // let title = "ToDo Item \(newCount)" // 更新 `items` // items.append(.init(title: title)) // 为 table view 添加新行 // let indexPath = IndexPath(row: newCount - 1, section: 0) // tableView.insertRows(at: [indexPath], with: .automatic) // 确定是否达到列表上限,如果达到,禁用 addButton // if newCount >= 10 { // addButton?.isEnabled = false // } let store = ToDoStore.shared let newCount = store.count + 1 let title = "ToDo Item \(newCount)" store.append(item: .init(title: title)) } } extension ToDoListViewController { override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { _, _, done in // 用户确认删除,从 `items` 中移除该事项 // self.items.remove(at: indexPath.row) // 从 table view 中移除对应行 // self.tableView.deleteRows(at: [indexPath], with: .automatic) // 维护 addButton 状态 // if self.items.count < 10 { // self.addButton?.isEnabled = true // } ToDoStore.shared.remove(at: indexPath.row) done(true) } return UISwipeActionsConfiguration(actions: [deleteAction]) } } ``` 现在,不妨再考虑一下上一节中场景一 (编辑条目) 和场景二 (网络同步) 的需求,是不是觉得结构会清晰很多呢? 1. 我们现在有了一个单独的可以测试的 Model 层,通过简单的 Mock,`ToDoListViewController` 也可以被方便地测试。 2. UI 操作 -> 经由 Controller 进行模型变更 -> 经由 Controller 将当前模型“映射”为 UI 状态,这个数据流动方向是严格可预测的 (并且应当时刻牢记需要保持这个循环)。这大大减少了 Controller 层的负担。 3. 由于模型层不再被单一 View Controller 持有,其他的 Controller (不单指像是编辑用的 Edit View Controller 这样的视图控制器,也包括比如负责下载的 Controller 等等这类数据控制器) 也可以操作模型层。在此同时,所有的模型结果会被自动且正确地反应到 View 上,这为多 Controller 协同工作和更复杂的场景提供了坚实的基础。 这个例子的修改后的最终版本[可以在这里找到](https://gist.github.com/onevcat/9e08111cebb1967cb96a737ed40f9f14)。 #### 其他选项 MVC 本身的概念相当简单,同时它也给了开发者很大的自由度。Massive View Controller 往往就是利用了这个自由度,“随意”地将逻辑放在 controller 层所造成的后果。 有一些其他架构选择,最常用的比如 MVVM 和响应式编程 (比如 RxSwift)。MVVM 可以说几乎就是一个 MVC,不过通过 View Model 层来将数据和视图进行绑定。如果你写过 Reactive 架构的话,可能会发现我们在本文中 MVC 的 Controller 层的通知接收和 Rx 的事件流非常相似。不同之处在于,响应式编程“借用”了 MVVM 的思路,提供了一套 API 将事件流与 UI 状态进行绑定 (RxCocoa)。 这些“超越” MVC 的架构方式无一例外地加入了额外的规则和限制,提供了相对 MVC 来说更小的自由度。这可以在一定程度上规范开发者的行为,提供更加统一的代码 (当然代价是额外的学习成本)。完全理解和严格遵守 MVC 的思想,我们其实也可以将 MVC 用得“小而美”。第一步,就从避免文中这类常见“错误”开始吧~ > 能够使用简单的架构来搭建复杂的工程,制作出让其他开发者可以轻松理解的软件,避免高额的后续维护成本,让软件可持续发展并长期活跃,应该是每个开发者在构建软件是必须考虑的事情。 URL: https://onevcat.com/2018/03/swift-meta/index.html.md Published At: 2018-03-12 09:15:00 +0900 # 不同角度看问题 - 从 Codable 到 Swift 元编程 > 最近开设了一个[小专栏](https://xiaozhuanlan.com/onevcat),用来记录日常开发时遇到的问题和解决方案,同时也会收藏一些学习时记录的笔记,随想等。其中一些长文 (包括本文) 会首发于专栏,之后再同步到博客这边。虽然现在的文章还不多,但是因为计划更新比较勤快,所以适当进行收费,也算是对自己写作的一种鼓励和鞭笞。欢迎感兴趣的同学进行订阅,谢谢~ ## 起源 前几天看到同事的一个 P-R,里面有将一个类型转换为字典的方法。在我们所使用的 API 中,某些方法需要接受 JSON 兼容的字典 (也就是说,字典中键值对的 `value` 只能是数字,字符串,布尔值,以及包含它们的嵌套字典或者数组等),因为项目开始是在好几年前了,所以一直都是在需要的时候使用下面这样手写生成字典的方法: ```swift struct Cat { let name: String let age: Int func toDictionary() -> [String: Any] { return ["name": name, "age": age] } } let kitten = Cat(name: "kitten", age: 2) kitten.toDictionary() // ["name": "kitten", "age": 2] ``` 显然这是很蠢的做法: 1. 对于每一个需要处理的类型,我们都需要 `toDictionary()` 这样的模板代码; 2. 每次进行属性的更改或增删,都要维护该方法的内容; 3. 字典的 key 只是普通字符串,很可能出现 typo 错误或者没有及时根据类型定义变化进行更新的情况。 对于一个有所追求的项目来说,解决这部分遗留问题具有相当的高优先级。 ## Codable 在 Swift 4 引入 `Codable` 之后,我们有更优秀的方式来做这件事:那就是将 `Cat` 声明为 `Codable` (或者至少声明为 `Encodable` - 记住 `Codable` 其实就是 `Decodable & Encodable`),然后使用相关的 encoder 来进行编码。不过 Swift 标准库中并没有直接将一个对象编码为字典的编码器,我们可以进行一些变通,先将需要处理的类型声明为 `Codable`,然后使用 `JSONEncoder` 将其转换为 JSON 数据,最后再从 JSON 数据中拿到对应的字典: ```swift struct Cat: Codable { let name: String let age: Int } let kitten = Cat(name: "kitten", age: 2) let encoder = JSONEncoder() do { let data = try encoder.encode(kitten) let dictionary = try JSONSerialization.jsonObject(with: data, options: []) // ["name": "kitten", "age": 2] } catch { print(error) } ``` 这种方式也是同事提交的 P-R 中所使用的方式。我个人认为这种方法已经足够优秀了,它没有添加任何难以理解的部分,我们只需要将 `encoder` 在全局进行统一的配置,然后用它来对任意 `Codable` 进行编码即可。唯一美中不足的是,`JSONEncoder` 本身其实[在内部](https://github.com/apple/swift/blob/1e110b8f63836734113cdb28970ebcea8fd383c2/stdlib/public/SDK/Foundation/JSONEncoder.swift#L214-L231)就是先编码为字典,然后再从字典转换为数据的。在这里我们又“多此一举”地将数据转换回字典,稍显浪费。但是在非瓶颈的代码路径上,这一点性能损失完全可以接受的。 如果想要追求完美,那么我们可能需要仿照 [`_JSONEncoder`](https://github.com/apple/swift/blob/1e110b8f63836734113cdb28970ebcea8fd383c2/stdlib/public/SDK/Foundation/JSONEncoder.swift#L237) 重新实现 `KeyedEncodingContainer` 的部分,来将 `Encodable` 对象编码到容器中 (因为我们只需要编码为字典,所以可以忽略掉 `unkeyedContainer` 和 `singleValueContainer` 的部分)。整个过程不会很复杂,但是代码显得有些“啰嗦”。如果你没有自己手动实现过一个 Codable encoder 的话,参照着 `_JSONEncoder` 的源码实现一个 `DictionaryEncoder` 对于你理解 Codable 系统的运作和细节,会是很好的练习。不过因为这篇文章的重点并不是 `Codable` 教学,所以这里就先跳过了。 > 标准库中要求 Codable 的编码器要满足 `Encoder` 协议,不过要注意,公开的 [`JSONEncoder`](https://github.com/apple/swift/blob/1e110b8f63836734113cdb28970ebcea8fd383c2/stdlib/public/SDK/Foundation/JSONEncoder.swift#L18) 类型其实并不遵守 `Encoder`,它只提供了一套易用的 API 封装,并将具体的编码工作代理给一个内部类型 `_JSONEncoder`,后者实际实现了 `Encoder`,并负责具体的编码逻辑。 ## Mirror Codable 的解决方案已经够好了,不过“好用的方式千篇一律,有趣的解法万万千千”,就这样解决问题也实在有些无聊,我们有没有一些更 hacky 更 cool 更 for fun 一点的做法呢? 当然有,在 review P-R 的时候第一想到的就是 `Mirror`。使用 `Mirror` 类型,可以让我们在运行时一窥某个类型的实例的内容,它也是 Swift 中为数不多的与运行时特性相关的手段。`Mirror` 的最基本的用法如下,你也可以在[官方文档](https://developer.apple.com/documentation/swift/mirror)中查看它的一些其他定义: ```swift struct Cat { let name: String let age: Int } let kitten = Cat(name: "kitten", age: 2) let mirror = Mirror(reflecting: kitten) for child in mirror.children { print("\(child.label!) - \(child.value)") } // 输出: // name - kitten // age - 2 ``` 通过访问实例中 `mirror.children` 的每一个 `child`,我们就可以得到所有的存储属性的 `label` 和 `value`。以 `label` 为字典键,`value` 为字典值,我们就能从任意类型构建出对应的字典了。 #### 字典中值的类型 不过注意,这个 `child` 中的值是以 `Any` 为类型的,也就是说,任意类型都可以在 `child.value` 中表达。而我们的需求是构建一个 JSON 兼容的字典,它不能包含我们自定义的 Swift 类型 (对于自定义类型,我们需要先转换为字典的形式)。所以还需要做一些额外的类型保证的工作,这里可以添加一个 `DictionaryValue` 协议,来表示目标字典能接受的类型: ```swift protocol DictionaryValue { var value: Any { get } } ``` 对于 JSON 兼容的字典来说,数字,字符串和布尔值都是可以接受的,它们不需要进行转换,在字典中就是它们自身: ```swift extension Int: DictionaryValue { var value: Any { return self } } extension Float: DictionaryValue { var value: Any { return self } } extension String: DictionaryValue { var value: Any { return self } } extension Bool: DictionaryValue { var value: Any { return self } } ``` > 严格来说,我们还需要对像是 `Int16`,`Double` 之类的其他数字类型进行 `DictionaryValue` 适配。不过对于一个「概念验证」的 demo 来说,上面的定义就足够了。 有了这些,我们就可以进一步对 `DictionaryValue` 进行协议扩展,让满足它的其他类型通过 `Mirror` 的方式来构建字典: ```swift extension DictionaryValue { var value: Any { let mirror = Mirror(reflecting: self) var result = [String: Any]() for child in mirror.children { // 如果无法获得正确的 key,报错 guard let key = child.label else { fatalError("Invalid key in child: \(child)") } // 如果 value 无法转换为 DictionaryValue,报错 if let value = child.value as? DictionaryValue { result[key] = value.value } else { fatalError("Invalid value in child: \(child)") } } return result } } ``` 现在,我们就可以将想要转换的类型声明为 `DictionaryValue`,然后调用 `value` 属性来获取字典了: ```swift struct Cat: DictionaryValue { let name: String let age: Int } let kitten = Cat(name: "kitten", age: 2) print(kitten.value) // ["name": "kitten", "age": 2] ``` 对于嵌套自定义 `DictionaryValue` 值的其他类型,字典转换也可以正常工作: ```swift struct Wizard: DictionaryValue { let name: String let cat: Cat } let wizard = Wizard(name: "Hermione", cat: kitten) print(wizard.value) // ["name": "Hermione", "cat": ["name": "kitten", "age": 2]] ``` #### 字典中的嵌套数组和字典 上面处理了类型中属性是一般值 (JSON 原始值以及嵌套其他 `DictionaryValue` 类型) 的情况,不过对于 JSON 中的数组和字典的情况还无法处理 (因为我们还没有让 `Array` 和 `Dictionary` 遵守 `DictionaryValue`)。对于数组或字典这样的容器中的值,如果这些值满足 `DictionaryValue` 的话,那么容器本身显然也是 `DictionaryValue` 的。用代码表示的话类似这样: ```swift extension Array: DictionaryValue where Element: DictionaryValue { var value: Any { return map { $0.value } } } extension Dictionary: DictionaryValue where Value: DictionaryValue { var value: Any { return mapValues { $0.value } } } ``` 在这里我们遇到一个非常“经典”的 Swift 的语言限制,那就是在 Swift 4.1 之前还不能写出上面这样的带有条件语句 (也就是 `where` 从句,`Element` 和 `Value` 满足 `DictionaryValue`) 的 extension。这个限制在 Swift 4.1 中[得到了解决](https://swift.org/blog/conditional-conformance/),不过再此之前,我们只能强制做一些变化: ```swift extension Array: DictionaryValue { var value: Any { return map { ($0 as! DictionaryValue).value } } } extension Dictionary: DictionaryValue { var value: Any { return mapValues { ($0 as! DictionaryValue).value } } } ``` 这么做我们失去了编译器的保证:对于任意的 `Array` 和 `Dictionary`,我们都将可以调用 `value`,不过,如果它们中的值不满足 `DictionaryValue` 的话,程序将会崩溃。当然,实际如果使用的时候可以考虑返回 `NSNull()`,来表示无法完成字典转换 (因为 `null` 也是有效的 JSON 值)。 有了数组和字典的支持,我们现在就可以使用 `Mirror` 的方法来对任意满足条件的类型进行转换了: ```swift struct Cat: DictionaryValue { let name: String let age: Int } struct Wizard: DictionaryValue { let name: String let cat: Cat } struct Gryffindor: DictionaryValue { let wizards: [Wizard] } let crooks = Cat(name: "Crookshanks", age: 2) let hermione = Wizard(name: "Hermione", cat: crooks) let hedwig = Cat(name: "hedwig", age: 3) let harry = Wizard(name: "Harry", cat: hedwig) let gryffindor = Gryffindor(wizards: [harry, hermione]) print(gryffindor.value) // ["wizards": // [ // ["name": "Harry", "cat": ["name": "hedwig", "age": 3]], // ["name": "Hermione", "cat": ["name": "Crookshanks", "age": 2]] // ] // ] ``` `Mirror` 很 cool,它让我们可以在运行时探索和列举实例的特性。除了上面用到的存储属性之外,对于集合类型,多元组以及枚举类型,`Mirror` 都可以对其进行探索。强大的运行时特性,也意味着额外的开销。`Mirror` 的文档明确告诉我们,这个类型更多是用来在 Playground 和调试器中进行输出和观察用的。如果我们想要以高效的方式来处理字典转换问题,也许应该试试看其他思路。 ## 代码生成 最高效的方式应该还是像一开始我们提到的纯手写了。但是显然这种重复劳动并不符合程序员的美学,对于这种“机械化”和“模板化”的工作,定义模板自动生成代码会是不错的选择。 ### Sourcery [Sourcery](https://github.com/krzysztofzablocki/Sourcery) 是一个 Swift 代码生成的开源命令行工具,它 (通过 [SourceKitten](https://github.com/jpsim/SourceKitten)) 使用 Apple 的 SourceKit 框架,来分析你的源码中的各种声明和标注,然后套用你预先定义的 [Stencil](https://github.com/kylef/Stencil) 模板 (一种语法和 [Mustache](https://mustache.github.io/#demo) 很相似的 Swift 模板语言) 进行代码生成。我们下面会先看一个使用 Sourcery 最简单的例子,来说明如何使用这个工具。然后再针对我们的字典转换问题进行实现。 安装 Sourcery 非常简单,`brew install sourcery` 即可。不过,如果你想要在实际项目中使用这个工具的话,我建议直接[从发布页面](https://github.com/krzysztofzablocki/Sourcery/releases)下载二进制文件,放到 Xcode 项目目录中,然后添加 Run Script 的 Build Phase 来在每次编译的时候自动生成。 #### EnumSet 来看一个简单的例子,假设我们在文件夹中有以下源码: ```swift // source.swift enum HogwartsHouse { case gryffindor case hufflepuff case ravenclaw case slytherin } ``` 很多时候我们会有想要得到 enum 中所有 case 的集合,以及确定一共有多少个 case 成员的需求。如果纯手写的话,大概是这样的: ```swift enum HogwartsHouse { // ... static let all: [HogwartsHouse] = [ .gryffindor, .hufflepuff, .ravenclaw, .slytherin ] static let count = 4 } ``` 显然这么做对于维护很不友好,没有人能确保时刻记住在添加新的 case 后一定会去更新 `all` 和 `count`。对其他有同样需求的 enum,我们也需要重复劳动。Sourcery 就是为了解决这样的需求而生的,相对于手写 `all` 和 `count`,我们可以定义一个空协议 `EnumSet`,然后让 `HogwartsHouse` 遵守它: ```swift protocol EnumSet {} extension HogwartsHouse: EnumSet {} ``` 这个定义为 Sourcery 提供了一些提示,Sourcery 需要一些方式来确定为哪部分代码进行代码生成,“实现了某个协议”这个条件就是一个很有用的提示。现在,我们可以创建模板文件了,在同一个文件夹中,新建 `enumset.stencil`,并书写下面的内容: 乍一看上去可能有些可怕,不过其实仔细辨识的话基底依然是 Swift。模板中被 {% raw %}`{% %}`{% endraw %} 包含的内容将被作为代码执行,{% raw %}`{{ }}`{% endraw %} 中的内容将被求值并嵌入到生成的文本中,而其他部分被直接作为文本复制到目标文件里。 第一行: 即“选取那些实现了 `EnumSet` 的类型,滤出其中所有的 enum 类型,然后对每个 enum 进行枚举”。接下来,我们对这个选出的 enum 类型,为它创建了一个 extension,并对其所有 case 进行迭代,生成 `all` 数组。最后,将 `count` 设置为成员个数。 一开始你可能会有不少疑问,`types.implementing` 是什么,我怎么能知道 `enum` 在有 `name`, `cases`,`hasAssociatedValues` 之类的属性?Sourcery 有非常[详尽的文档](https://cdn.rawgit.com/krzysztofzablocki/Sourcery/master/docs/index.html),对上述问题,你可以在 [Types](https://cdn.rawgit.com/krzysztofzablocki/Sourcery/master/docs/Classes/Types.html) 和 [Enum](https://cdn.rawgit.com/krzysztofzablocki/Sourcery/master/docs/Classes/Enum.html) 的相关页面找到答案。在初上手写模板逻辑时,参照文档是不可避免的。 一切就绪,现在我们可以将源文件喂给模板,来生成最后的代码了: ```bash sourcery --sources ./source.swift --templates ./enumset.stencil ``` > `--sources` 和 `--templates` 都可以接受文件夹,Sourcery 会按照后缀自行寻找源文件和模板文件,所以也可以用 `sourcery --sources ./ --templates ./` 来替代上面的命令。不过实际操作中还是建议将源文件和模板文件放在不同的文件夹下,方便管理。 在同一个文件夹下,可以看到生成的 `enumset.generated.swift` 文件: ```swift extension HogwartsHouse { static let all: [HogwartsHouse] = [ .gryffindor, .hufflepuff, .ravenclaw, .slytherin ] static let count: Int = 4 } ``` 问题解决。:] #### 字典转换 下面来进行字典转换。类似上面的做法,定义一个空协议,让想要转换的自定义类型满足协议: ```swift protocol DictionaryConvertible {} struct Cat: DictionaryConvertible { let name: String let age: Int } struct Wizard: DictionaryConvertible { let name: String let cat: Cat } struct Gryffindor: DictionaryConvertible { let wizards: [Wizard] } ``` 接下来就可以尝试以下书写模板代码了。屏上得来终觉浅,有了上面 `EnumSet` 的经验,我强烈建议你花一点时间自己完成 `DictionaryConvertible` 的模板。你可以参照 [Sourcery 文档](https://cdn.rawgit.com/krzysztofzablocki/Sourcery/master/docs/index.html) 关于单个 [Type](https://cdn.rawgit.com/krzysztofzablocki/Sourcery/master/docs/Classes/Type.html) 和 [Variable](https://cdn.rawgit.com/krzysztofzablocki/Sourcery/master/docs/Classes/Variable.html) 的部分的内容来实现。另外,可以考虑使用 `--watch` 模式来在文件改变时自动生成代码,来实时观察结果。 ```bash sourcery --sources ./ --templates ./ --watch ``` 最后,我的带有完整注释的对应的模板代码如下 (为了方便阅读,调整了一些格式): 生成的代码如下: ```swift // 生成的代码 extension Cat { var value: [String: Any] { return [ "name": name, "age": age ] } } extension Gryffindor { var value: [String: Any] { return [ "wizards": wizards.map { $0.value } ] } } extension Wizard { var value: [String: Any] { return [ "name": name, "cat": cat.value ] } } ``` 最后,我们还需要在原来的 Swift 文件中加上一些原始类型的扩展,这样对于原始类型值的数组和字典,我们的生成代码也能正确处理: ```swift extension Int { var value: Int { return self } } extension String { var value: String { return self } } extension Bool { var value: Bool { return self } } ``` > 当然,你也可以考虑使用代码生成的方式来搞定,不过因为兼容的类型不会改变,直接写死亦无伤大雅。 相比于 JSON `Codable` 和 `Mirror` 的做法,这显然是运行时最高效的方式。除了使用 Sourcery 内建的类型匹配系统和 API 外,你还可以在源码中添加 Sourcery 的标注:`/// sourcery:`。被标注的内容将可以[通过 `annotations`](https://cdn.rawgit.com/krzysztofzablocki/Sourcery/master/docs/Protocols/Annotated.html#/s:15SourceryRuntime9AnnotatedP11annotationss10DictionaryVySSSo8NSObjectCGv)进行访问,这使得 Sourcery 几乎“无所不能”。 ### gyb 代码生成方式的另一个“流行”选择时 gyb (Generate Your Boilerplate)。[gyb](https://github.com/apple/swift/blob/master/utils/gyb.py) 严格来说就是一个 Python 脚本,它将预定义的值填充到模板中。这个工具被大量用于 Swift 项目本身的开发,标准库中有不少以 `.gyb` 作为后缀的文件,比如 [Array](https://github.com/apple/swift/blob/32146274e73fe07bf3f9633f49624cc6728f1ae3/stdlib/public/core/Arrays.swift.gyb) 就是通过 gyb 生成的。 gyb 设计的最初目的主要是为了解决像是 `Int8`,`Int16`,`Int32` 等这一系列十分类似但又必须加以区分的类型中模板代码问题的。(鉴于 Apple 自己都有可能用其他工具来替换掉它,) 我们这里就不展开介绍了。如果你对 gyb 感兴趣,可以看看这篇[简明教程](http://swift.gg/2016/03/04/a-short-swift-gyb-tutorial/)。 这里引出 gyb,主要是想说明,挖掘 Swift 源码 (特别是标准库源码,因为标准库本身大部分也是由 Swift 写的),是非常有意思的一件事情。今后如果有机会我可能也会写一些阅读 Swift 标准库源码的文章,和大家一起探讨 Swift 源码中一些有趣的事情 :P ## AST & libSyntax 我们说到,Sourcery 是依赖于 SourceKitten 获取源码信息的,而一路向下的话,SourceKitten 本身是对 SourceKit (`sourcekitd.framework`) 的高层级封装,最后它们都是对抽象语法树 (Abstract Syntax Tree, AST) 进行解析操作。编译器将源码 token 化,并构建 AST。 在上面的 Sourcery 的例子中,我们实际上做的首先是通过 AST 获取全部的源码信息,然后将语法单元进行组合,生成 Sourcery API 中的各个对象。接着,将这些对象传递给 Stencil 模板进行“渲染”,得到生成的源码。除了使用模板以外,还有一种直接操作 AST,通过代码“生成”代码的方式,那就是 libSyntax。 [libSyntax](https://github.com/apple/swift/tree/master/lib/Syntax) 相对鲜为人知,它作为 Swift 项目的一个工具库,现在被用于 Swift 代码的重构 (在 Xcode 9 中 Cmd + 点击,你应该可以看到重命名,提取方法等一系列重构操作的菜单)。通过 libSyntax 提供的 API,你可以生成结构化的代码,比如下面的代码片段,可以生成一个名成为 `name`,类型为 `type` 的 `let` 变量声明: > 注意,为了使用 SwiftSyntax,你需要安装并切换为 Swift 4.1 的工具链 (至少在本文写作时如此,libSyntax 还没有确定会不会最终集成在 Swift 4.1 中),并为 libSyntax 指定正确的 Runpath 和 Library 搜索路径。关于如何在 Xcode 中使用 libSyntax,可以参考[项目主页](https://github.com/apple/swift/tree/master/lib/Syntax#try-libsyntax-in-xcode)。 SwiftSyntax 这一封装为我们提供了 Swift 类型安全的方式,来操作和生成代码。结合使用工厂类 `SyntaxFactory` 和各种类型的 builder (希望你还记得设计模式那一整套东西 :P ),可以“方便”地生成我们需要的代码。比如下面的 `createLetDecl` 为我们生成一个 `let` 的变量声明,我们之后会用它作为更进一步的例子的构建模块: ```swift import Foundation import SwiftSyntax // Trivia 指的是像空格,回车,tab,分号等元素 func createLetDecl(name: String, type: String, leadingTrivia: Trivia = .zero, trailingTrivia: Trivia = .newlines(1)) -> VariableDeclSyntax { // 创建 let 关键字 (`let`) let letKeyword = SyntaxFactory.makeLetKeyword(leadingTrivia: leadingTrivia, trailingTrivia: .spaces(1)) // 根据 name 创建属性名 (`name`) let nameId = SyntaxFactory.makeIdentifier(name) // 组合类型标记 (比如 `: Int` 部分) let typeId = SyntaxFactory.makeTypeIdentifier(type, trailingTrivia: trailingTrivia) let colon = SyntaxFactory.makeColonToken(trailingTrivia: .spaces(1)) let typeAnnotation = SyntaxFactory.makeTypeAnnotation(colon: colon, type: typeId) let member = IdentifierPatternSyntax { builder in builder.useIdentifier(nameId) } let patterBinding = SyntaxFactory.makePatternBinding(pattern: member, typeAnnotation: typeAnnotation, initializer: nil, accessor: nil, trailingComma: nil) let list = SyntaxFactory.makePatternBindingList([patterBinding]) // 生成属性声明 return SyntaxFactory.makeVariableDecl(attributes: nil, modifiers: nil, letOrVarKeyword: letKeyword, bindings: list) } let nameDecl = createLetDecl("name", "String") // let name: String let ageDecl = createLetDecl("age", "Int") // let age: Int ``` 现在,可以尝试用类似的方式生成之前例子中的 `Cat` 结构体: ```swift let keyword = SyntaxFactory.makeStructKeyword(trailingTrivia: .spaces(1)) let catId = SyntaxFactory.makeIdentifier("Cat", trailingTrivia: .spaces(1)) let members = MemberDeclBlockSyntax { $0.useLeftBrace(SyntaxFactory.makeLeftBraceToken(trailingTrivia: .newlines(1))) $0.addDecl(createLetDecl(name: "name", type: "String", leadingTrivia: .spaces(4))) $0.addDecl(createLetDecl(name: "age", type: "Int", leadingTrivia: .spaces(4), trailingTrivia: .zero)) $0.useRightBrace(SyntaxFactory.makeRightBraceToken(leadingTrivia: .newlines(1))) } let catStruct = StructDeclSyntax { $0.useStructKeyword(keyword) $0.useIdentifier(catId) $0.useMembers(members) } print(catStruct) /* struct Cat { let name: String let age: Int } */ ``` SwiftSyntax 是一套功能完备的 Swift 源码生成工具,也就是说,除了变量声明和结构体,其他上至类、枚举、方法,下到访问控制关键字、冒号、逗号,都有对应的类型安全的方式进行操作。除了通过代码生成代码以外,SwiftSyntax 也支持遍历访问所有的 token。回到我们的字典转换的工作,我们需要做的就是,使用 libSyntax 遍历访问 token (或者想简单一些的话可以直接用 SourceKitten),找到我们感兴趣的需要转换的类,然后遍历它的属性声明。接下来将这些属性声明再通过 libSyntax 的各种 maker 和 builder 组织为字典的形式,以 extension 的形式写回对应文件中去。 由于这样来进行字典转换实在没有什么实用价值,所以不再浪费篇幅贴代码了。不过使用 libSyntax 来完成一些像是缩进/对齐/括号换行之类的 formatter 工具会是很不错的选择。你也可以仔细思考看看如果你是 Xcode 的开发者,会如何实现像是重命名或者方法提取这样的重构功能。(也许下一份工作就可以投 Apple 的 Xcode 团队或者 JetBrains 的 AppCode 团队了呢~) 顺带一提,作为元编程库的 libSyntax 本身也大量使用了 gyb 的方式来生成代码,也许你可以把它叫做“元元编程”。😂 > SwiftSyntax 非常强大,不过它还在持续的开发中,并没有达到稳定。因此最好也不要现在将它用在实际的项目中,它的文档几乎没有,部分语法支持还[没有完整实现](https://github.com/apple/swift/blob/master/lib/Syntax/Status.md),很多细节也还没有最终确定。(不过也正是这样,才满足一个好玩的“玩具”的特质。) ## 总结 不管是运行时的反射 (类似 `Mirror`),还是编译前生成代码,都可以归类到“元编程”的范畴里。绕了一大圈,其实对于本文中的例子来说,可能简单地使用 Codable 就已经足够好。不过,从多个角度看这个问题的话,我们能发现不少有趣的其他解决方案,这有益无害。 实际上,有很多工作更适合使用元编程来处理:比如在处理事件统计时,我们可以自动通过定义生成所需要的统计类型;在写网络 API Client 时,可以生成请求的定义;对于重复的单元测试,可以用模板批量生成 mock 或者 stub 帮助简化流程和保持测试代码与产品代码的同步;在策划人员难以直接修改源码时,可以为他们提供配置文件,最后再按照将配置文件生成需要的代码等。每个方面都有值得进一步思考和研究的深度内容,而这种元编程的能力可以让我们避免直接对代码进行重复维护,依靠更加可靠的自动机制避免引入人为错误,这在已有代码需要大范围重复变更的时候尤为有效。 在今后有类似的重复体力劳动需求时,不妨考虑使用元编程的方法稍微放飞自我。:) URL: https://onevcat.com/2017/10/swift-error-category/index.html.md Published At: 2017-10-17 12:15:00 +0900 # 关于 Swift Error 的分类 在去年我应 IBM 编辑的邀请写过一篇关于 [Swift 2 中 throws 的文章](https://onevcat.com/2016/03/swift-throws/)。现在回头看,Swift 2 其实是 Swift 语言发展的一个挺重要的节点:如果说 Swift 1 是一个更偏向于验证阶段的产品的话,Swift 2 中加入的特性为这门语言的基石进行了补足。在那篇文章里我们主要深入探索了新的 throw 关键字背后的事情,而同一时期其实 Swift 官方有过一次关于错误处理的讨论。随着 Swift 3 的开源,这些原始文档也被一同公开,展示了 Swift 设计的过程和轨迹。如果你对这篇 Swift 2 中的错误处理的宣言感兴趣的话,可以在 GitHub 上 Swift 项目文档中[找到原文](https://github.com/apple/swift/blob/master/docs/ErrorHandling.rst)。 最近参加了日本这边的一个社区办的 iOS 会议,其中 [koher](https://twitter.com/koher) 给出了一个关于[错误处理的 session](https://iosdc.jp/2017/node/1305),里面也提到了这篇文档,正确理解和思考 Swift 错误机制的类型非常有意思,它也可以指导我们在不同场景下对应使用正确的处理机制。 ## Swift 错误类型的种类 ### Simple domain error 简单的,显而易见的错误。这类错误的最大特点是我们不需要知道原因,只需要知道错误发生,并且想要进行处理。用来表示这种错误发生的方法一般就是返回一个 `nil` 值。在 Swift 中,这类错误最常见的情况就是将某个字符串转换为整数,或者在字典尝试用某个不存在的 key 获取元素: ```swift // Simple Domain Error 的例子 let num = Int("hello world") // nil let element = dic["key_not_exist"] // nil ``` 在使用层面 (或者说应用逻辑) 上,这类错误一般用 `if let` 的可选值绑定或者是 `guard let` 提前进行返回处理即可,不需要再在语言层面上进行额外处理。 ### Recoverable error 正如其名,这类错误应该是被容许,并且是可以恢复的。可恢复错误的发生是正常的程序路径之一,而作为开发者,我们应当去检出这类错误发生的情况,并进一步对它们进行处理,让它们恢复到我们期望的程序路径上。 这类错误在 Objective-C 的时代通常用 `NSError` 类型来表示,而在 Swift 里则是 `throw` 和 `Error` 的组合。一般我们需要检查错误的类型,并作出合理的响应。而选择忽视这类错误往往是不明智的,因为它们是用户正常使用过程中可能会出现的情况,我们应该尝试对其恢复,或者至少向用户给出合理的提示,让他们知道发生了什么。像是网络请求超时,或者写入文件时磁盘空间不足: ```swift // 网络请求 let url = URL(string: "https://www.example.com/")! let task = URLSession.shared.dataTask(with: url) { data, response, error in if let error = error { // 提示用户 self.showErrorAlert("Error: \(error.localizedDescription)") } let data = data! // ... } // 写入文件 func write(data: Data, to url: URL) { do { try data.write(to: url) } catch let error as NSError { if error.code == NSFileWriteOutOfSpaceError { // 尝试通过释放空间自动恢复 removeUnusedFiles() write(data: data, to: url) } else { // 其他错误,提示用户 showErrorAlert("Error: \(error.localizedDescription)") } } catch { showErrorAlert("Error: \(error.localizedDescription)") } } ``` ### Universal error 这类错误理论上可以恢复,但是由于语言本身的特性所决定,我们难以得知这类错误的来源,所以一般来说也不会去处理这种错误。这类错误包括类似下面这些情形: ```swift // 内存不足 [Int](repeating: 100, count: .max) // 调用栈溢出 func foo() { foo() } foo() ``` 我们可以通过设计一些手段来对这些错误进行处理,比如:检测当前的内存占用并在超过一定值后警告,或者监视栈 frame 数进行限制等。但是一般来说这是不必要的,也不可能涵盖全部的错误情况。更多情况下,这是由于代码触碰到了设备的物理限制和边界情况所造成的,一般我们也不去进行处理(除非是人为操成的 bug)。 在 Swift 中,各种被使用 `fatalError` 进行强制终止的错误一般都可以归类到 Universal error。 ### Logic failure 逻辑错误是程序员的失误所造成的错误,它们应该在开发时通过代码进行修正并完全避免,而不是等到运行时再进行恢复和处理。 常见的 Logic failure 包括有: ```swift // 强制解包一个 `nil` 可选值 var name: String? = nil name! // 数组越界访问 let arr = [1,2,3] let num = arr[3] // 计算溢出 var a = Int.max a += 1 // 强制 try 但是出现错误 try! JSONDecoder().decode(Foo.self, from: Data()) ``` 这类错误在实现中触发的一般是 [`assert` 或者 `precondition`](https://github.com/apple/swift/blob/a05cd35a7f8e3cc70e0666bc34b5056a543eafd4/stdlib/public/core/Collection.swift#L1009-L1046)。 #### 断言的作用范围和错误转换 和 `fatalError` 不同,`assert` 只在进行编译优化的 `-O` 配置下是不触发的,而如果更进一步,将编译优化选项配置为 `-Ounchecked` 的话,`precondition` 也将不触发。此时,各方法中的 `precondition` 将被跳过,因此我们可以得到最快的运行速度。但是相对地代码的安全性也将降低,因为对于越界访问或者计算溢出等错误,我们得到的将是不确定的行为。 | 函数 | faltaError | precondition | assert | | ----------- | ---------- | ------------ | -------- | | -Onone | 触发 | 触发 | 触发 | | -O | 触发 | 触发 | | | -Ounchecked | 触发 | | | 对于 Universal error 一般使用 `fatalError`,而对于 Logic failure 一般使用 `assert` 或者 `precondition`。遵守这个规则会有助于我们在编码时对错误进行界定。而有时候我们也希望能尽可能多地在开发的时候捕获 Logic failure,而在产品发布后尽量减少 crash 比例。这种情况下,相比于直接将 Logic failure 转换为可恢复的错误,我们最好是使用 `assert` 在内部进行检查,来让程序在开发时崩溃。 ## Quiz 光说不练假把式。让我们来实际判断一下下面这些情况下我们都应该选择用哪种错误处理方式吧~ ### #1 app 内资源加载 请听题。 假设我们在处理一个机器学习的模型,需要从磁盘读取一份预先训练好的模型。该模型以文件的方式存储在 app bundle 中,如果读取时没有找到该模型,我们应该如何处理这个错误? #### 方案 1 Simple domain error ```swift func loadModel() -> Model? { guard let path = Bundle.main.path(forResource: "my_pre_trained_model", ofType: "mdl") else { return nil } let url = URL(fileURLWithPath: path) guard let data = try? Data(contentOf: url) else { return nil } return try? ModelLoader.load(from: data) } ``` #### 方案 2 Recoverable error ```swift func loadModel() throws -> Model { guard let path = Bundle.main.path(forResource: "my_pre_trained_model", ofType: "mdl") else { throw AppError.FileNotExisting } let url = URL(fileURLWithPath: path) let data = try Data(contentOf: url) return try ModelLoader.load(from: data) } ``` #### 方案 3 Universal error ```swift func loadModel() -> Model { guard let path = Bundle.main.path(forResource: "my_pre_trained_model", ofType: "mdl") else { fatalError("Model file not existing") } let url = URL(fileURLWithPath: path) do { let data = try Data(contentOf: url) return try ModelLoader.load(from: data) } catch { fatalError("Model corrupted.") } } ``` #### 方案 4 Logic failure ```swift func loadModel() -> Model { let path = Bundle.main.path(forResource: "my_pre_trained_model", ofType: "mdl")! let url = URL(fileURLWithPath: path) let data = try! Data(contentOf: url) return try! ModelLoader.load(from: data) } ```
点击展开答案

正确答案应该是方案 4,使用 Logic failure 让代码直接崩溃

作为内建的存在于 app bundle 中模型或者配置文件,如果不存在或者无法初始化,在不考虑极端因素的前提下,一定是开发方面出现了问题,这不应该是一个可恢复的错误,无论重试多少次结果肯定是一样的。也许是开发者忘了将文件放到合适的位置,也许是文件本身出现了问题。不论是哪种情况,我们都会希望尽早发现并强制我们修正错误,而让代码崩溃可以很好地做到这一点。

使用 Universal error 同样可以让代码崩溃,但是 Universal error 更多是用在语言的边界情况下。而这里并非这种情况。

### #2 加载当前用户信息时发生错误 我们在用户登录后会将用户信息存储在本地,每次重新打开 app 时我们检测并使用用户信息。当用户信息不存在时,应该进行的处理: #### 方案 1 Simple domain error ```swift func loadUser() -> User? { let username = UserDefaults.standard.string(forKey: "com.onevcat.app.defaults.username") if let username { return User(name: username) } else { return nil } } ``` #### 方案 2 Recoverable error ```swift func loadUser() throws -> User { let username = UserDefaults.standard.string(forKey: "com.onevcat.app.defaults.username") if let username { return User(name: username) } else { throws AppError.UsernameNotExisting } } ``` #### 方案 3 Universal error ```swift func loadUser() -> User { let username = UserDefaults.standard.string(forKey: "com.onevcat.app.defaults.username") if let username { return User(name: username) } else { fatalError("User name not existing") } } ``` #### 方案 4 Logic failure ```swift func loadUser() -> User { let username = UserDefaults.standard.string(forKey: "com.onevcat.app.defaults.username") return User(name: username!) } ```
点击展开答案

首先肯定排除方案 3 和 4。“用户名不存在”是一个正常的现象,肯定不能直接 crash。所以我们应该在方案 1 和方案 2 中选择。

对于这种情况,选择方案 1 Simple domain error 会更好。因为用户信息不存在是很简单的一个状况,如果用户不存在,那么我们直接让用户登录即可,这并不需要知道额外的错误信息,返回 nil 就能够很好地表达意图了。

当然,我们不排除今后随着情况越来越复杂,会需要区分用户信息缺失的原因 (比如是否是新用户还没有注册,还是由于原用户注销等)。但是在当前的情况下来看,这属于过度设计,暂时并不需要考虑。如果之后业务复杂到这个程度,在编译器的帮助下将 Simple domain error 修改为 Recoverable error 也不是什么难事儿。

### #3 还没有实现的代码 假设你在为你的服务开发一个 iOS 框架,但是由于工期有限,有一些功能只定义了接口,没有进行具体实现。这些接口会在正式版中完成,但是我们需要预先发布给友商内测。所以除了在文档中明确标明这些内容,这些方法内部应该如何处理呢? #### 方案 1 Simple domain error ```swift func foo() -> Bar? { return nil } ``` #### 方案 2 Recoverable error ```swift func foo() throws -> Bar? { throw FrameworkError.NotImplemented } ``` #### 方案 3 Universal error ```swift func foo() -> Bar? { fatalError("Not implemented yet.") } ``` #### 方案 4 Logic failure ```swift func foo() -> Bar? { assertionFailure("Not implemented yet.") return nil } ```
点击展开答案

正确答案是方案 3 Universal error。对于没有实现的方法,返回 nil 或者抛出错误期待用户恢复都是没有道理的,这会进一步增加框架用户的迷惑。这里的问题是语言层面的边界情况,由于没有实现,我们需要给出强力的提醒。在任意 build 设定下,都不应该期待用户可以成功调用这个函数,所以 fatalError 是最佳选择。

### #4 调用设备上的传感器收集数据 调用传感器的 app 最有意思了!不管是相机还是陀螺仪,传感器相关的 app 总是能带给我们很多乐趣。那么,如果想要调用传感器获取数据时,发生了错误,应该怎么办呢? #### 方案 1 Simple domain error ```swift func getDataFromSensor() -> Data? { let sensorState = sensor.getState() guard sensorState == .normal else { return nil } return try? sensor.getData() } ``` #### 方案 2 Recoverable error ```swift func getDataFromSensor() throws -> Data { let sensorState = sensor.getState() guard sensorState == .normal else { throws SensorError.stateError } return try sensor.getData() } ``` #### 方案 3 Universal error ```swift func loadUser() -> Data { let sensorState = sensor.getState() guard sensorState == .normal, let data = try? sensor.getData() else { fatalError("Sensor get data failed!") } return data } ``` #### 方案 4 Logic failure ```swift func loadUser() -> Data { let sensorState = sensor.getState() assert(sensorState == .normal, "The sensor state is not normal") return try! sensor.getData() } ```
点击展开答案

传感器由于种种原因暂时不能使用 (比如正在被其他进程占用,或者甚至设备上不存在对应的传感器),是很有可能发生的情况。即使这个传感器的数据对应用是至关重要,不可或缺的,我们可能也会希望至少能给用户一些提示。基于这种考虑,使用方案 2 Recoverable error 是比较合理的选择。

方案 1 在传感器数据无关紧要的时候可能也会是一个更简单的选项。但是方案 3 和 4 会直接让程序崩溃,而且这实际上也并不是代码边界或者开发者的错误,所以不应该被考虑。

### Quiz 的一些总结 可以看到,其实在错误处理的时候,选用哪种错误是根据情景和处理需求而定的,我在参考答案也使用了很多诸如“可能”,“相较而言”等语句。虽然对于特定的场景,我们可以进行直观的考虑和决策,但这并不是教条主义般的一成不变。错误类型之间可以很容易地通过代码互相转换,这让我们在处理错误的时候可以自由选择使用的策略:比如 API 即使提供给我们的是 Recoverable 的 throws 形式,我们也还是可以按照需要,通过 `try ?` 将其转为 Simple domain error,或者用 `try !` 将其转为 Logic failure。 能切实理解使用情景,利用这些错误类型转换的方式,灵活选取使用场景下最合适的错误类型,才能说是真正理解了这四种错误的分类依据。 ## 参考链接 - [Error Handling in Swift 2.0](https://github.com/apple/swift/blob/master/docs/ErrorHandling.rst) - [Swiftのエラー4分類が素晴らしすぎるのでみんなに知ってほしい](https://qiita.com/koher/items/a7a12e7e18d2bb7d8c77) URL: https://onevcat.com/2017/07/state-based-viewcontroller/index.html.md Published At: 2017-07-13 10:05:00 +0900 # 单向数据流动的函数式 View Controller View Controller 向来是 MVC (Model-View-View Controller) 中最让人头疼的一环,MVC 架构本身并不复杂,但开发者很容易将大量代码扔到用于协调 View 和 Model 的 Controller 中。你不能说这是一种错误,因为 View Controller 所承担的本来就是胶水代码和业务逻辑的部分。但是,持续这样做必定将导致 Model View Controller 变成 Massive View Controller,代码也就一天天烂下去,直到没人敢碰。 对于采用 MVC 架构的项目来说,其实最大的挑战在于维护 Controller。而想要有良好维护的 Controller,最大的挑战又在于保持良好的测试覆盖。因为往往 View Controller 中会包含很多状态,而且会有不少异步操作和用户触发的事件,所以测试 Controller 从来都不是一件简单的事情。 > 这一点对于一些类似的其他架构也是一样的。比如 MVVM 或者 VIPER,广义上它们其实都是 MVC,只不过使用 View Model 或者 Presenter 来做 Controller 而已。它们对应的控制器的职责依然是协调 Model 和 View。 在这篇文章里,我会先实现一个很常见的 MVC 架构,然后对状态和状态改变的部分进行抽象及重构,最终得到一个纯函数式的易于测试的 View Controller 类。希望通过这个例子,能够给你在日常维护 View Controller 的工作中带来一些启示或者帮助。 如果你对 React 和 Redux 有所了解的话,文中的一些方法可能你会很熟悉。不过即使你不了解它们,也并不会妨碍你理解本文。我不会去细究概念上的东西,而会从一个大家都所熟知的例子开始进行介绍,所以完全不用担心。你可能需要对 Swift 有一些了解,本文会涉及一些基本的值类型和引用类型的区别,如果你对此不是很明白的话,可以参看一些其他资料,比如我以前写的[这篇文章](http://swifter.tips/value-reference/)。 整个示例项目我放在了 [GitHub](https://github.com/onevcat/ToDoDemo) 上,你可以在各个分支中找到对应的项目源码。 ## 传统 MVC 实现 我们用一个经典的 ToDo 应用作为示例。这个项目可以从网络加载待办事项,我们通过输入文本进行添加,或者点击对应条目进行删除:
注意几个细节: 1. 打开应用后加载已有待办列表时花费了一些时间,一般来说,我们会从网络请求进行加载,这应该是一个异步操作。在示例项目里,我们不会真的去进行网络请求,而是使用一个本地存储来模拟这个过程。 2. 标题栏的数字表示当前已有的待办项目,随着待办的增减,这个数字会相应变化。 3. 可以使用第一个 cell 输入,并用右上角的加号添加一个待办。我们希望待办事项的标题长度至少为三个字符,在不满足长度的时候,添加按钮不可用。 实现这些并没有太大难度,一个刚入门 iOS 的新人也应该能毫无压力搞定。我们先来实现模拟异步获取已有待办的部分。新建一个文件 ToDoStore.swift: ```swift import Foundation let dummy = [ "Buy the milk", "Take my dog", "Rent a car" ] struct ToDoStore { static let shared = ToDoStore() func getToDoItems(completionHandler: (([String]) -> Void)?) { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { completionHandler?(dummy) } } } ``` 为了简明,我们使用简单的 `String` 来代表一条待办。这里我们等待了两秒后才调用回调,传回一组预先定义的待办事项。 由于整个界面就是一个 Table View,所以我们创建一个 `UITableViewController` 子类来实现需求。在 TableViewController.swift 中,我们定义一个属性 `todos` 来存放需要显示在列表中的待办事项,然后在 `viewDidLoad` 里从 `ToDoStore` 中进行加载并刷新 `tableView`: ```swift class TableViewController: UITableViewController { var todos: [String] = [] override func viewDidLoad() { super.viewDidLoad() ToDoStore.shared.getToDoItems { (data) in self.todos += data self.title = "TODO - (\(self.todos.count))" self.tableView.reloadData() } } } ``` 当然,我们现在需要提供 `UITableViewDataSource` 的相关方法。首先,我们的 Table View 有两个 section,一个负责输入新的待办,另一个负责展示现有的条目。为了让代码清晰表意自解释,我选择在 `TableViewController` 里内嵌一个 `Section` 枚举: ```swift class TableViewController: UITableViewController { enum Section: Int { case input = 0, todos, max } //... } ``` 这样,我们就可以实现 `UITableViewDataSource` 所需要的方法了: ```swift class TableViewController: UITableViewController { override func numberOfSections(in tableView: UITableView) -> Int { return Section.max.rawValue } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { guard let section = Section(rawValue: section) else { fatalError() } switch section { case .input: return 1 case .todos: return todos.count case .max: fatalError() } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let section = Section(rawValue: indexPath.section) else { fatalError() } switch section { case .input: // 返回 input cell case .todos: // 返回 todo item cell let cell = tableView.dequeueReusableCell(withIdentifier: todoCellReuseId, for: indexPath) cell.textLabel?.text = todos[indexPath.row] return cell default: fatalError() } } } ``` `.todos` 的情况下很简单,我们就用标准的 `UITableViewCell` 就好。对于 `.input` 的情况,我们需要在 cell 里嵌一个 `UITextField`,并且要在其中的文本改变时能告知 `TableViewController`。我们可以使用传统的 delegate 的模式来实现,下面是 TableViewInputCell.swift 的内容: ```swift protocol TableViewInputCellDelegate: class { func inputChanged(cell: TableViewInputCell, text: String) } class TableViewInputCell: UITableViewCell { weak var delegate: TableViewInputCellDelegate? @IBOutlet weak var textField: UITextField! @objc @IBAction func textFieldValueChanged(_ sender: UITextField) { delegate?.inputChanged(cell: self, text: sender.text ?? "") } } ``` 我们在 Storyboard 中创建对应的 table view 和这个 cell,然后将其中的 text field 的 `.editingChanged` 事件绑到 `textFieldValueChanged` 上。每次当用户进行输入时,`delegate` 的方法将被调用。 在 `TableViewController` 里,现在可以返回 `.input` 的 cell,并设置对应的代理方法来更新添加按钮了: ```swift class TableViewController: UITableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let section = Section(rawValue: indexPath.section) else { fatalError() } switch section { case .input: let cell = tableView.dequeueReusableCell(withIdentifier: inputCellReuseId, for: indexPath) as! TableViewInputCell cell.delegate = self return cell //... } } } extension TableViewController: TableViewInputCellDelegate { func inputChanged(cell: TableViewInputCell, text: String) { let isItemLengthEnough = text.count >= 3 navigationItem.rightBarButtonItem?.isEnabled = isItemLengthEnough } } ``` 现在,运行程序后等待一段时间,读入的待办事项就可以被展示了。接下来,添加待办和移除待办的部分很容易实现: ```swift class TableViewController: UITableViewController { // 添加待办 @IBAction func addButtonPressed(_ sender: Any) { let inputIndexPath = IndexPath(row: 0, section: Section.input.rawValue) guard let inputCell = tableView.cellForRow(at: inputIndexPath) as? TableViewInputCell, let text = inputCell.textField.text else { return } todos.insert(text, at: 0) inputCell.textField.text = "" title = "TODO - (\(todos.count))" tableView.reloadData() } // 移除待办 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == Section.todos.rawValue else { return } todos.remove(at: indexPath.row) title = "TODO - (\(todos.count))" tableView.reloadData() } } ``` > 为了保持简单,这里我们直接 `tableView.reloadData()` 了,讲道理的话更好的选择是只针对变动的部分做 `insert` 或者 `remove`,但为了简单起见,我们就直接重载整个 table view 了。 好了,这是一个非常简单的一百行都不到的 View Controller,可能也是我们每天都会写的代码,所以我们就不吹捧这样的代码“条理清晰”或者“简洁明了”了,你我都知道这只是在 View Controller 规模尚小时的假象而已。让我们直接来看看潜在的问题: 1. UI 相关的代码散落各处 - 重载 `tableView` 和设置 `title` 的代码出现了三次,设置右上 button 的 `isEnabled` 的代码存在于 extension 中,添加新项目时我们先获取了输入的 cell,然后再读取 cell 中的文本。这些散落在各处的 UI 操作将会成为隐患,因为你可能在代码的任意部分操作这些 UI,而它们的状态将随着代码的复杂变得“飘忽不定”。 2. 因为 1 的状态复杂,使得 View Controller 难以测试 - 举个例子,如果你想测试 `title` 的文字正确,你可能需要手动向列表里添加一个待办事项,这涉及到调用 `addButtonPressed`,而这个方法需要读取 `inputCell` 的文本,那么你可能还需要先去设置这个 cell 中 `UITextField` 的 `text` 值。当然你也可以用依赖注入的方式给 `add` 方法一个文本参数,或者将 `todos.insert` 和之后的内容提取成一个新的方法,但是无论怎么样,对于 model 的操作和对于 UI 的更新都没有分离 (因为毕竟我们写的就是“胶水代码”)。这正是你觉得 View Controller 难以测试的最主要原因。 3. 因为 2 的难以测试,最后让 View Controller 难以重构 - 状态和 UI 复杂度的增加往往会导致多个 UI 操作维护着同一个变量,或者多个状态变量去更新同一个 UI 元素。不论是哪种情况,都让测试变得几乎不可能,也会让后续的开发人员 (其实往往就是你自己!) 在面对复杂情况下难以正确地继续开发。Massive View Controller 最终的结果常常是牵一发而动全身,一个微小的改动可能都需要花费大量的时间进行验证,而且还没有人敢拍胸脯保证正确性。这会让项目逐渐陷入泥潭。 这些问题最终导致,这样一个 View Controller 难以 scaling。在逐渐被代码填满到一两千行时,这个 View Controller 将彻底“死去”,对它的维护和更改会困难重重。 > 你可以在 GitHub repo 的 [basic 分支](https://github.com/onevcat/ToDoDemo/tree/basic)找到对应这部分的代码。 ## 基于 State 的 View Controller ### 通过提取 State 统合 UI 操作 上面的三个问题其实环环相扣,如果我们能将 UI 相关代码集中起来,并用单一的状态去管理它,就可以让 View Controller 的复杂度降低很多。我们尝试看看! 在这个简单的界面中,和 UI 相关的 model 包括待办条目 `todos` (用来组织 table view 和更新标题栏) 以及输入的 `text` (用来决定添加按钮的 enable 和添加 todo 时的内容)。我们将这两个变量进行简单的封装,在 `TableViewController` 里添加一个内嵌的 `State` 结构体: ```swift class TableViewController: UITableViewController { struct State { let todos: [String] let text: String } var state = State(todos: [], text: "") } ``` 这样一来,我们就有一个统一按照状态更新 UI 的地方了。使用 `state` 的 `didSet` 即可: ```swift var state = State(todos: [], text: "") { didSet { if oldValue.todos != state.todos { tableView.reloadData() title = "TODO - (\(state.todos.count))" } if (oldValue.text != state.text) { let isItemLengthEnough = state.text.count >= 3 navigationItem.rightBarButtonItem?.isEnabled = isItemLengthEnough let inputIndexPath = IndexPath(row: 0, section: Section.input.rawValue) let inputCell = tableView.cellForRow(at: inputIndexPath) as? TableViewInputCell inputCell?.textField.text = state.text } } } ``` 这里我们将新值和旧值进行了一些比较,以避免不必要的 UI 更新。接下来,就可以将原来 `TableViewController` 中对 UI 的操作换成对 `state` 的操作了。 比如,在 `viewDidLoad` 中: ```swift // 变更前 ToDoStore.shared.getToDoItems { (data) in self.todos += data self.title = "TODO - (\(self.todos.count))" self.tableView.reloadData() } // 变更后 ToDoStore.shared.getToDoItems { (data) in self.state = State(todos: self.state.todos + data, text: self.state.text) } ``` 点击 cell 移除待办时: ```swift // 变更前 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == Section.todos.rawValue else { return } todos.remove(at: indexPath.row) title = "TODO - (\(todos.count))" tableView.reloadData() } // 变更后 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == Section.todos.rawValue else { return } let newTodos = Array(state.todos[..= 3 navigationItem.rightBarButtonItem?.isEnabled = isItemLengthEnough } // 变更后 func inputChanged(cell: TableViewInputCell, text: String) { state = State(todos: state.todos, text: text) } ``` 另外,最值得一提的可能是添加待办事项时的代码变化。可以看到引入统一的状态变更后,代码变得非常简单清晰: ```swift // 变更前 @IBAction func addButtonPressed(_ sender: Any) { let inputIndexPath = IndexPath(row: 0, section: Section.input.rawValue) guard let inputCell = tableView.cellForRow(at: inputIndexPath) as? TableViewInputCell, let text = inputCell.textField.text else { return } todos.insert(text, at: 0) inputCell.textField.text = "" title = "TODO - (\(todos.count))" tableView.reloadData() } // 变更后 @IBAction func addButtonPressed(_ sender: Any) { state = State(todos: [state.text] + state.todos, text: "") } ``` > 如果你对 React 比较熟悉的话,可以从中发现一些类似的思想。React 里我们自上而下传递 `Props`,并且在 Component 自身通过 `setState` 进行状态管理。所有的 `Component` 都是基于传入的 `Props` 和自身的 `State` 的。View Controller 中的不同之处在于,React 使用了更为描述式的方式更新 UI (虚拟 DOM),而现在我们可能需要用过程语言自己进行实现。除此之外,使用 `State` 的 `TableViewController` 在工作方式上与 React 的 `Component` 十分类似。 ### 测试 State View Controller 在基于 `State` 的实现下,用户的操作被统一为状态的变更,而状态的变更将统一地去更新当前的 UI。这让 View Controller 的测试变得容易很多。我们可以将本来混杂在一起的行为分离开来:首先,测试状态变更可以导致正确的 UI;然后,测试用户输入可以导致正确的状态变更,这样即可覆盖 View Controller 的测试。 让我们先来测试状态变更导致的 UI 变化,在单元测试中: ```swift func testSettingState() { // 初始状态 XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewController.Section.todos.rawValue), 0) XCTAssertEqual(controller.title, "TODO - (0)") XCTAssertFalse(controller.navigationItem.rightBarButtonItem!.isEnabled) // ([], "") -> (["1", "2", "3"], "abc") controller.state = TableViewController.State(todos: ["1", "2", "3"], text: "abc") XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewController.Section.todos.rawValue), 3) XCTAssertEqual(controller.tableView.cellForRow(at: todoItemIndexPath(row: 1))?.textLabel?.text, "2") XCTAssertEqual(controller.title, "TODO - (3)") XCTAssertTrue(controller.navigationItem.rightBarButtonItem!.isEnabled) // (["1", "2", "3"], "abc") -> ([], "") controller.state = TableViewController.State(todos: [], text: "") XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewController.Section.todos.rawValue), 0) XCTAssertEqual(controller.title, "TODO - (0)") XCTAssertFalse(controller.navigationItem.rightBarButtonItem!.isEnabled) } ``` 这里的初始状态是我们在 Storyboard 或者相应的 `viewDidLoad` 之类的方法里设定的 UI。我们稍后会对这个状态进行进一步的讨论。 接下来,我们就可以测试用户的交互行为导致的状态变更了: ```swift func testAdding() { let testItem = "Test Item" let originalTodos = controller.state.todos controller.state = TableViewController.State(todos: originalTodos, text: testItem) controller.addButtonPressed(self) XCTAssertEqual(controller.state.todos, [testItem] + originalTodos) XCTAssertEqual(controller.state.text, "") } func testRemoving() { controller.state = TableViewController.State(todos: ["1", "2", "3"], text: "") controller.tableView(controller.tableView, didSelectRowAt: todoItemIndexPath(row: 1)) XCTAssertEqual(controller.state.todos, ["1", "3"]) } func testInputChanged() { controller.inputChanged(cell: TableViewInputCell(), text: "Hello") XCTAssertEqual(controller.state.text, "Hello") } ``` 看起来很赞,我们的单元测试覆盖了各种用户交互,配合上 state 变更导致的 UI 变化,我们几乎可以确定这个 View Controller 将会按照我们的设想正确工作了! > 在上面我只贴出了一些关键的变更,关于测试的配置以及一些其他细节,你可以参看 GitHub repo 的 [state 分支](https://github.com/onevcat/ToDoDemo/tree/state)。 ### State View Controller 的问题 这种基于 State 的 View Controller 虽然比原来好了很多,但是依然存在一些问题,也还有大量的改进空间。下面是几个主要的忧虑: 1. 初始化时的 UI - 我们上面说到过,初始状态的 UI 是我们在 Storyboard 或者相应的 `viewDidLoad` 之类的方法里设定的。这将导致一个问题,那就是我们无法通过设置 `state` 属性的方式来设置初始 UI。因为 `state` 的 `didSet` 不会在 controller 初始化中首次赋值时被调用,因此如果我们在 `viewDidLoad` 中添加如下语句的话,会因为新的状态和初始相同,而导致 UI 不发生更新: ```swift override func viewDidLoad() { super.viewDidLoad() // UI 更新会被跳过,因为该 state 和初始值的一样 state = State(todos: [], text: "") } ``` 在初始 UI 设置正确的情况下,这倒没什么问题。但是如果 UI 状态原本存在不对的话,就将导致接下来的 UI 都是错误的。从更高层次来看,也就是 `state` 属性对 UI 的控制不仅仅涉及到新的状态,同时也取决于原有的 `state` 值。这会导致一些额外复杂度,是我们想要避免的。理想状态下,UI 的更新应该只和输入有关,而与当前状态无关 (也就是“纯函数式”,我们稍后再具体介绍)。 2. `State` 难以扩展 - 现在 `State` 中只有两个变量 `todos` 和 `text`,如果 View Controller 中还需要其他的变量,我们可以将它继续添加到 `State` 结构体中。不过在实践中这会十分困难,因为我们需要更新所有的 `state` 赋值的部分。比如,如果我们添加一个 `loading` 来表示正在加载待办: ```swift struct State { let todos: [String] let text: String let loading: Bool } override func viewDidLoad() { super.viewDidLoad() state = State(todos: self.state.todos + data, text: self.state.text, loading: true) ToDoStore.shared.getToDoItems { (data) in self.state = State(todos: self.state.todos + data, text: self.state.text, loading: false) } } ``` 除此之外,像是添加待办,删除待办等存在 `state` 赋值的地方,我们都需要在原来的初始化方法上加上 `loading` 参数。试想,如果我们稍后又添加了一个变量,我们则需要再次维护所有这些地方,这显然是无法接受的。 当然,因为 `State` 是值类型,我们可以将 `State` 中的变量声明从 `let` 改为 `var`,这样我们就可以直接设置 `state` 中的属性了,例如: ```swift state.todos = state.todos + data state.loading = true ``` 这种情况下,`State` 的 `didSet` 将被调用多次,虽然不太舒服,但倒也不是很大的问题。更关键的地方在于,这样一来我们又将状态的维护零散地分落在各个地方。当状态中的变量越来越多,而且状态自身之间有所依赖的话,这么做又将我们置于麻烦之中。我们还需要注意,如果 `State` 中包含引用类型,那么它将失去完全的值语义,也就是说,如果你去改变了 `state` 中引用类型里的某个变量时,`state` 的 `didSet` 将不会被调用。这让我们在使用时需要如履薄冰,一旦这种情况发生,调试也会相对困难。 3. Data Source 重用 - 我们其实有机会将 Table View 的 Data Source 部分提取出来,让它在不同的 View Controller 中被重复利用。但是现在新引入的 `state` 阻止了这一可能性。如果我们想要重用 `dataSource`,我们需要将 `state.todos` 从中分离出来,或者是找一种方法在 `dataSource` 中同步待办事项的 model。 4. 异步操作的测试 - 在 `TableViewController` 的测试中,有一个地方我们没有覆盖到,那就是 `viewDidLoad` 中用来加载待办的 `ToDoStore.shared.getToDoItems`。在不引入 stub 的情况下,测试这类异步操作会非常困难,但是引入 stub 本身现在看来也不是特别方便。我们有没有什么好方法可以测试 View Controller 中的异步操作呢? 我们可以引入一些改变,来将 `TableViewController` 的 UI 部分变为纯函数式实现,并利用单向数据流来驱动 View Controller,就可以解决这些问题。 ## 对 View Controller 的进一步改造 在着手大幅调整代码之前,我想先介绍一些基本概念。 ### 什么是纯函数 纯函数 (Pure Function) 是指一个函数如果有相同的输入,则它产生相同的输出。换言之,也就是一个函数的动作不依赖于外部变量之类的状态,一旦输入给定,那么输出则唯一确定。对于 app 而言,我们总是会和一定的用户输入打交道,也必然会需要按照用户的输入和已知状态来更新 UI 作为“输出”。所以在 app 中,特别是 View Controller 中操作 UI 的部分,我会倾向于将“纯函数”定义为:在确定的输入下,某个函数给出确定的 UI。 上面的 `State` 为我们打造一个纯函数的 View Controller 提供了坚实的一步,但是它还并不是纯函数。对于任意的新的 `state`,输出的 UI 在一定程度上还是依赖于原来的 `state`。不过我们可以通过将原来的 `state` 提取出来,换成一个用于更新 UI 的纯函数,即可解决这个问题。新的函数签名看起来大概会是这样: ```swift func updateViews(state: State, previousState: State?) ``` 这样,当我们给定原状态和现状态时,将得到确定的 UI,我们稍后会来看看这个方法的具体实现。 ### 单向数据流 我们想要对 State View Controller 做的另一个改进是简化和统一状态维护的相关工作。我们知道,任何新的状态都是在原有状态的基础上通过一些改变所得到的。举例来说,在待办事项的 demo 中,新加一个待办意味着在原状态的 `state.todos` 的基础上,接收到用户的添加的行为,然后在数组中加上待办事项,并输出新的状态: ```swift if userWantToAddItem { state.todos = state.todos + [item] } ``` 其他的操作也皆是如此。将这个过成进行一些抽象,我们可以得到这样一个公式: ``` 新状态 = f(旧状态, 用户行为) ``` 或者用 Swift 的语言,就是: ```swift func reducer(state: State, userAction: Action) -> State ``` 如果你对函数式编程有所了解,应该很容易看出,这其实就是 `reduce` 函数的 `transformer`,它接受一个已有状态 `State` 和一个输入 `Action`,将 `Action` 作用于 `state`,并给出新的 `State`。结合 Swift 标准库中的 `reduce` 的函数签名,我们可以轻而易举地看到两者的关联: ```swift func reduce(_ initialResult: Result, _ nextPartialResult: (Result, Element) throws -> Result) rethrows -> Result ``` 其中 `reducer` 对应的正是 `reduce` 中的 `nextPartialResult` 部分,这也是我们将它称为 `reducer` 的原因。 有了 `reducer(state: State, userAction: Action) -> State`,接下来我们就可以将用户操作抽象为 `Action`,并将所有的状态更新集中处理了。为了让这个过程一般化,我们会统一使用一个 `Store` 类型来存储状态,并通过向 `Store` 发送 `Action` 来更新其中的状态。而希望接收到状态更新的对象 (这个例子中是 `TableViewController` 实例) 可以订阅状态变化,以更新 UI。订阅者不参与直接改变状态,而只是发送可能改变状态的行为,然后接受状态变化并更新 UI,以此形成单向的数据流动。而因为更新 UI 的代码将会是纯函数的,所以 View Controller 的 UI 也将是可预期及可测试的。 ### 异步状态 对于像 `ToDoStore.shared.getToDoItems` 这样的异步操作,我们也希望能够纳入到 `Action` 和 `reducer` 的体系中。异步操作对于状态的立即改变 (比如设置 `state.loading` 并显示一个 Loading Indicator),我们可以通过向 `State` 中添加成员来达到。要触发这个异步操作,我们可以为它添加一个新的 `Action`,相对于普通 `Action` 仅仅只是改变 `state`,我们希望它还能有一定“副作用”,也就是在订阅者中能实际触发这个异步操作。这需要我们稍微更新一下 `reducer` 的定义,除了返回新的 `State` 以外,我们还希望对异步操作返回一个额外的 `Command`: ```swift func reducer(state: State, userAction: Action) -> (State, Command?) ``` `Command` 只是触发异步操作的手段,它不应该和状态变化有关,所以它没有出现在 `reducer` 的输入一侧。如果你现在不太理解的话也没有关系,先只需要记住这个函数签名,我们会在之后的例子中详细地看到这部分的工作方式。 将这些结合起来,我们将要实现的 View Controller 的架构类似于下图: ![](/assets/images/2017/view-controller-states.svg) ### 使用单向数据流和 reducer 改进 View Controller 准备工作够多了,让我们来在 State View Controller 的基础上进行改进吧。 为了能够尽量通用,我们先来定义几个协议: ```swift protocol ActionType {} protocol StateType {} protocol CommandType {} ``` 除了限制协议类型以外,上面这几个 `protocol` 并没有其他特别的意义。接下来,我们在 `TableViewController` 中定义对应的 `Action`,`State` 和 `Command`: ```swift class TableViewController: UITableViewController { struct State: StateType { var dataSource = TableViewControllerDataSource(todos: [], owner: nil) var text: String = "" } enum Action: ActionType { case updateText(text: String) case addToDos(items: [String]) case removeToDo(index: Int) case loadToDos } enum Command: CommandType { case loadToDos(completion: ([String]) -> Void ) } //... } ``` 为了将 `dataSource` 提取出来,我们在 `State` 中把原来的 `todos` 换成了整个的 `dataSource`。`TableViewControllerDataSource` 就是标准的 `UITableViewDataSource`,它包含 `todos` 和用来作为 `inputCell` 设定 `delegate` 的 `owner`。基本上就是将原来 `TableViewController` 的 Data Source 部分的代码搬过去,部分关键代码如下: ```swift class TableViewControllerDataSource: NSObject, UITableViewDataSource { var todos: [String] weak var owner: TableViewController? init(todos: [String], owner: TableViewController?) { self.todos = todos self.owner = owner } //... func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { //... let cell = tableView.dequeueReusableCell(withIdentifier: inputCellReuseId, for: indexPath) as! TableViewInputCell cell.delegate = owner return cell } } ``` 这是基本的将 Data Source 分离出 View Controller 的方法,本身很简单,也不是本文的重点。 注意 `Command` 中包含的 `loadToDos` 成员,它关联了一个方法作为结束时的回调,我们稍后会在这个方法里向 `store` 发送 `.addToDos` 的 `Action`。 准备好必要的类型后,我们就可以实现核心的 `reducer` 了: ```swift lazy var reducer: (State, Action) -> (state: State, command: Command?) = { [weak self] (state: State, action: Action) in var state = state var command: Command? = nil switch action { case .updateText(let text): state.text = text case .addToDos(let items): state.dataSource = TableViewControllerDataSource(todos: items + state.dataSource.todos, owner: state.dataSource.owner) case .removeToDo(let index): let oldTodos = state.dataSource.todos state.dataSource = TableViewControllerDataSource(todos: Array(oldTodos[.. 为了避免 `reducer` 持有 `self` 而造成内存泄漏,这里我们所实现的是一个 lazy 的 `reducer` 成员。其中 `self` 被标记为弱引用,这样一来,我们就不需要担心 `store`,View Controller 和 `reducer` 之间的引用环了。 对于 `.updateText`,`.addToDos` 和 `.removeToDo`,我们都只是根据已有状态衍生出新的状态。唯一值得注意的是 `.loadToDos`,它将让 `reducer` 函数返回非空的 `Command`。 接下来我们需要一个存储状态和响应 `Action` 的类型,我们将它叫做 `Store`: ```swift class Store { let reducer: (_ state: S, _ action: A) -> (S, C?) var subscriber: ((_ state: S, _ previousState: S, _ command: C?) -> Void)? var state: S init(reducer: @escaping (S, A) -> (S, C?), initialState: S) { self.reducer = reducer self.state = initialState } func dispatch(_ action: A) { let previousState = state let (nextState, command) = reducer(state, action) state = nextState subscriber?(state, previousState, command) } func subscribe(_ handler: @escaping (S, S, C?) -> Void) { self.subscriber = handler } func unsubscribe() { self.subscriber = nil } } ``` 千万不要被这些泛型吓到,它们都非常简单。这个 `Store` 接受一个 `reducer` 和一个初始状态 `initialState` 作为输入。它提供了 `dispatch` 方法,持有该 `store` 的类型可以通过 `dispatch` 向其发送 `Action`,`store` 将根据 `reducer` 提供的方式生成新的 `state` 和必要的 `command`,然后通知它的订阅者。 在 `TableViewController` 中增加一个 `store` 变量,并在 `viewDidLoad` 中初始化它: ```swift class TableViewController: UITableViewController { var store: Store! override func viewDidLoad() { super.viewDidLoad() let dataSource = TableViewControllerDataSource(todos: [], owner: self) store = Store(reducer: reducer, initialState: State(dataSource: dataSource, text: "")) // 订阅 store store.subscribe { [weak self] state, previousState, command in self?.stateDidChanged(state: state, previousState: previousState, command: command) } // 初始化 UI stateDidChanged(state: store.state, previousState: nil, command: nil) // 开始异步加载 ToDos store.dispatch(.loadToDos) } //... } ``` 将 `stateDidChanged` 添加到 `store.subscribe` 后,每次 `store` 状态改变时,`stateDidChanged` 都将被调用。现在我们还没有实现这个方法,它的具体内容如下: ```swift func stateDidChanged(state: State, previousState: State?, command: Command?) { if let command = command { switch command { case .loadToDos(let handler): ToDoStore.shared.getToDoItems(completionHandler: handler) } } if previousState == nil || previousState!.dataSource.todos != state.dataSource.todos { let dataSource = state.dataSource tableView.dataSource = dataSource tableView.reloadData() title = "TODO - (\(dataSource.todos.count))" } if previousState == nil || previousState!.text != state.text { let isItemLengthEnough = state.text.count >= 3 navigationItem.rightBarButtonItem?.isEnabled = isItemLengthEnough let inputIndexPath = IndexPath(row: 0, section: TableViewControllerDataSource.Section.input.rawValue) let inputCell = tableView.cellForRow(at: inputIndexPath) as? TableViewInputCell inputCell?.textField.text = state.text } } ``` 同时,我们就可以把之前 `Command.loadTodos` 的回调补全了: ```swift lazy var reducer: (State, Action) -> (state: State, command: Command?) = { [weak self] (state: State, action: Action) in var state = state var command: Command? = nil switch action { // ... case .loadToDos: command = Command.loadToDos { data in // 发送额外的 .addToDos self?.store.dispatch(.addToDos(items: data)) } } return (state, command) } ``` `stateDidChanged` 现在是一个纯函数式的 UI 更新方法,它的输出 (UI) 只取决于输入的 `state` 和 `previousState`。另一个输入 `Command` 负责触发一些不影响输出的“副作用”,在实践中,除了发送请求这样的异步操作外,View Controller 的转换,弹窗之类的交互都可以通过 `Command` 来进行。`Command` 本身不应该影响 `State` 的转换,它需要通过再次发送 `Action` 来改变状态,以此才能影响 UI。 到这里,我们基本上拥有所有的部件了。最后的收尾工作相当容易,把之前的直接的状态变更代码换成事件发送即可: ```swift override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == TableViewControllerDataSource.Section.todos.rawValue else { return } store.dispatch(.removeToDo(index: indexPath.row)) } @IBAction func addButtonPressed(_ sender: Any) { store.dispatch(.addToDos(items: [store.state.text])) store.dispatch(.updateText(text: "")) } func inputChanged(cell: TableViewInputCell, text: String) { store.dispatch(.updateText(text: text)) } ``` ### 测试纯函数式 View Controller 折腾了这么半天,归根结底,其实我们想要的是一个高度可测试的 View Controller。基于高度可测试性,我们就能拥有高度的可维护性。`stateDidChanged` 现在是一个纯函数,与 `controller` 的当前状态无关,测试它将非常容易: ```swift func testUpdateView() { let state1 = TableViewController.State( dataSource:TableViewControllerDataSource(todos: [], owner: nil), text: "" ) // 从 nil 状态转换为 state1 controller.stateDidChanged(state: state1, previousState: nil, command: nil) XCTAssertEqual(controller.title, "TODO - (0)") XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewControllerDataSource.Section.todos.rawValue), 0) XCTAssertFalse(controller.navigationItem.rightBarButtonItem!.isEnabled) let state2 = TableViewController.State( dataSource:TableViewControllerDataSource(todos: ["1", "3"], owner: nil), text: "Hello" ) // 从 state1 状态转换为 state2 controller.stateDidChanged(state: state2, previousState: state1, command: nil) XCTAssertEqual(controller.title, "TODO - (2)") XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewControllerDataSource.Section.todos.rawValue), 2) XCTAssertEqual(controller.tableView.cellForRow(at: todoItemIndexPath(row: 1))?.textLabel?.text, "3") XCTAssertTrue(controller.navigationItem.rightBarButtonItem!.isEnabled) } ``` 作为单元测试,能覆盖产品代码就意味着覆盖了绝大多数使用情况。除此之外,如果你愿意,你也可以写出各种状态间的转换,覆盖尽可能多的边界情况。这可以保证你的代码不会因为新的修改发生退化。 虽然我们没有明说,但是 `TableViewController` 中的另一个重要的函数 `reducer` 也是纯函数。对它的测试同样简单,比如: ```swift func testReducerUpdateTextFromEmpty() { let initState = TableViewController.State() let state = controller.reducer(initState, .updateText(text: "123")).state XCTAssertEqual(state.text, "123") } ``` 输出的 `state` 只与输入的 `initState` 和 `action` 有关,它与 View Controller 的状态完全无关。`reducer` 中的其他方法的测试如出一辙,在此不再赘言。 最后,让我们来看看 State View Controller 中没有被测试的加载部分的内容。由于现在加载新的待办事项也是由一个 `Action` 来触发的,我们可以通过检查 `reducer` 返回的 `Command` 来确认加载的结果: ```swift func testLoadToDos() { let initState = TableViewController.State() let (_, command) = controller.reducer(initState, .loadToDos) XCTAssertNotNil(command) switch command! { case .loadToDos(let handler): handler(["2", "3"]) XCTAssertEqual(controller.store.state.dataSource.todos, ["2", "3"]) // 现在 Command 只有 .loadToDos 一个命令。如果存在多个 Command,可以去下面的注释, // 这样在命令不符时可以让测试失败 // default: // XCTFail("The command should be .loadToDos") } } ``` 我们检查了返回的命令是否是 `.loadToDos`,而且 `.loadToDos` 的 `handler` 充当了天然的 stub。通过用一组 dummy 数据 (`["2", "3"]`) 进行调用,我们可以检查 `store` 中的状态是否如我们预期,这样我们就用同步的方式测试了异步加载的过程! > 可能有同学会有疑问,认为这里没有测试 `ToDoStore.shared.getToDoItems`。但是记住,我们这里要测试的是 View Controller,而不是网络层。对于 `ToDoStore` 的测试应该放在单独的地方进行。 > > 你可以在 GitHub repo 的 [reducer 分支](https://github.com/onevcat/ToDoDemo/tree/reducer)中找到对应这部分的代码。 ## 总结 可能你已经见过类似的单向数据流的方式了,比如 [Redux](https://github.com/reactjs/redux),或者更古老一些的 [Flux](http://facebook.github.io/flux/)。甚至在 Swift 中,也有 [ReSwift](https://github.com/ReSwift/ReSwift) 实现了类似的想法。在这篇文章中,我们保持了基本的 MVC 架构,而使用了这种方法改进了 View Controller 的设计。 在例子中,我们的 `Store` 位于 View Controller 中。其实只要存在状态变化,这套方式可以在任何地方适用。你完全可以在其他的层级中引入 `Store`。只要能保证数据的单向流动,以及完整的状态变更覆盖测试,这套方式就具有良好的扩展性。 相对于大刀阔斧地改造,或者使用全新的设计模式,这种稍微小一些改进更容易在日常中进行探索和实践,它不存在什么外部依赖,可以被直接用在新建的 View Controller 中,你也可以逐步将已有类进行改造。毕竟绝大多数 iOS 开发者可能都会把大量时间花在 View Controller 上,所以能否写出易于测试,易于维护的 View Controller,多少将决定一个 iOS 开发者的幸福程度。所以花一些时间琢磨如何写好 View Controller,应该是每个 iOSer 的必修课。 ### 一些推荐的参考资料 如果你对函数式编程的一些概念感兴趣,不妨看看我和一些同仁翻译的[《函数式 Swift》](https://objccn.io/products/functional-swift/)一书,里面对像是值类型、纯函数、引用透明等特性进行了详细的阐述。如果你想更多接触一些类似的架构方法,我个人推荐研读一下 [React](https://facebook.github.io/react/) 的资料,特别是如何[以 React 的思想思考](https://facebook.github.io/react/docs/thinking-in-react.html)的相关内容。如果你还有余力,即使你日常每天还是做 CocoaTouch 的 native 开发,也不妨尝试用 React Native 来构建一些项目。相信你会在这个过程中开阔眼界,得到新的领悟。 URL: https://onevcat.com/2017/06/ios-11-sdk/index.html.md Published At: 2017-06-06 12:15:00 +0900 # 开发者所需要知道的 iOS 11 SDK 新特性 ![](/assets/images/2017/wwdc-2017.jpg) 年纪大了过了能熬夜看 WWDC 的时代了,但是还是在小小宝的哭闹和妈妈大人换尿布的催促中起了个大早。于是算趁着“热乎”把 WWDC 2017 的 Keynote 看完了。和往年差不多,虽然 WWDC 是一个开发者会议,但是 Keynote 并不是专门针对我们开发者的,它还承担了公司状况说明,新品发布等功能。作为技术人员,可能接下来的 session 会更有意义。要用一句话来评价今年 Keynote 所展现出来的内容的话,就是小步革新。大的技术方面可以说只有 ARKit 可堪研究,但是我们还是看到了类似跨 app 拖拽,新的 Files 应用这样进一步突破 iOS 原有桎梏的更新 (iMessage 转账什么的就不提了,我大天朝威武,移动支付领域领先世界至少三年)。iOS 11,特别是配合新的硬件,相信会给用户带来不错的体验。 作为 iOS 开发者,和往年一样,我整理了一下在可能需要关注的地方。 ### 新增框架 新加入 SDK 的大的框架有两个,分别是负责简化和集成机器学习的 Core ML 和用来创建增强现实 (AR) 应用的 ARKit。 #### Core ML 自从 AlphaGo 出现以来,深度学习毫无疑问成了行业热点。而 Google 也在去年就转变 Mobile-first 到 AI-first 的战略。可以说一线的互联网企业几乎都在押宝 AI,目前看来机器学习,特别是深度学习是最有希望的一条道路。 如果你不是很熟悉机器学习的话,我想我可以在这里“僭越”地做一些简介。你可以先把机器学习的模型看作一个黑盒函数,你给定一些输入 (可能是一段文字,或者一张图片),这个函数会给出特定的输出 (比如这段文字中的人名地名,或者图片中出现的商店名牌等)。一开始这个模型可能非常粗糙,完全不能给出正确的结果,但是你可以使用大量已有的数据和正确的结果,来对模型进行训练,甚至改进。在所使用的模型足够优化,以及训练量足够大的情况下,这个黑盒模型将不仅对训练数据有较高的准确率,也往往能对未知的实际输入给出正确的返回。这样的模型就是一个训练好的可以实际使用的模型。 对机器学习模型的训练是一项很重的工作,[Core ML](https://developer.apple.com/machine-learning/) 所扮演的角色更多的是将已经训练好的模型转换为 iOS 可以理解的形式,并且将新的数据“喂给”模型,获取输出。抽象问题和创建模型虽然并不难,但是对模型的改进和训练可以说是值得研究一辈子的事情,这篇文章的读者可能也不太会对此感冒。好在 Apple 提供了[一系列的工具](https://developer.apple.com/documentation/coreml/converting_trained_models_to_core_ml)用来将各类机器学习模型转换为 Core ML 可以理解的形式。籍此,你就可以轻松地在你的 iOS app 里使用前人训练出的模型。这在以前可能会需要你自己去寻找模型,然后写一些 C++ 的代码来跨平台调用,而且难以利用 iOS 设备的 GPU 性能和 Metal (除非你自己写一些 shader 来进行矩阵运算)。Core ML 将使用模型的门槛降低了很多。 Core ML 在背后驱动了 iOS 的视觉识别的 [Vision](https://developer.apple.com/documentation/vision) 框架和 Foundation 中的语义分析相关 API。普通开发者可以从这些高层的 API 中直接获益,比如人脸图片或者文字识别等。这部分内容在以前版本的 SDK 中也存在,不过在 iOS 11 SDK 中它们被集中到了新的框架中,并将一些更具体和底层的控制开放出来。比如你可以使用 Vision 中的高层接口,但是同时指定底层所使用的模型。这给 iOS 的计算机视觉带来了新的可能。 Google 或者 Samsung 在 Android AI 上的努力,大多是在自带的应用中集成服务。相比起来,Apple 基于对自己生态和硬件的控制,将更多的选择权交给了第三方开发者。 #### ARKit Keynote 上的 AR 的演示可以说是唯一的亮点了。iOS SDK 11 中 Apple 给开发者,特别是 AR 相关的开发者带来了一个很棒的礼物,那就是 [ARKit](https://developer.apple.com/documentation/arkit)。AR 可以说并非什么新技术,像是 Pokémon Go 这样的游戏也验证了 AR 在游戏上的潜力。不过除了 IP 和新鲜感之外,个人认为 Pokémon Go 并没有资格代表 AR 技术的潜力。现场的演示像我们展示了一种可能,粗略看来,ARKit 利用单镜头和陀螺仪,在对平面的识别和虚拟物体的稳定上做得相当出色。几乎可以肯定,那么不做最早,只做最好的 Apple 似乎在这一刻回到了舞台上 ARKit 极大降低了普通开发者玩 AR 的门槛,也是 Apple 现阶段用来抗衡 VR 的选项。可以畅想一下更多类似 Pokémon Go 的 AR 游戏 (结合实境的虚拟宠物什么的大概是最容易想到的) 能在 ARKit 和 SceneKit 的帮助下面世,甚至在 iPad Pro 现有技能上做像是 AR 电影这样能全方位展示的多媒体可能也不再是单纯的梦想。 而与之相应的,是一套并不很复杂的 API。涉及的 View 几乎是作为 SceneKit 的延伸,再加上在真实世界的定为也已经由系统帮助处理,开发者需要做的大抵就是将虚拟物体放在屏幕的合适位置,并让物体之间互动。而利用 Core ML 来对相机内的实际物体进行识别和交互,可以说也让各类特效的相机或者摄影 app 充满了想像空间。 ### Xcode #### 编辑器和编译器 速度就是生命,而开发者的生命都浪费在了等待编译上。Swift 自问世以来就备受好评,但是缓慢的编译速度,时有时无的语法提示,无法进行重构等工具链上的欠缺成为了最重要的黑点。Xcode 9 中编辑器进行了重写,支持了对 Swift 代码的重构 (虽然还很基础),将 VCS 提到了更重要的位置,并添加了 GitHub 集成,可以进行同局域网的无线部署和调试。 ![](/assets/images/2017/xcode-git.png) 新的编译系统是使用 Swift 重写的,在进行了一些对比以后,编译速度确实有了不小的提升。虽然不知道是不是由于换成了 Swift 4,不过正在做的公司项目的总编译时间从原来的三分半缩短到了两分钟半左右,可以说相当明显了。 Xcode 9 中的索引系统也使用了新的引擎,据称在大型项目中搜索最高可以达到 50 倍的速度。不过可能由于笔者所参加的项目不够大,这一点体会不太明显。项目里的 Swift 代码依然面临失色的情况。这可能是索引系统和编译系统没有能很好协同造成的,毕竟还是 beta 版本的软件,也许应该多给 Xcode 团队一些时间 (虽然可能到最后也就这样了)。 由于 Swift 4 编译器也提供了 Swift 3 的兼容 (在 Build Setting 中设置 Swift 版本即可),所以如果没有什么意外的话,我可能会在之后的日常开发中使用 Xcode 9 beta,然后在打包和发布时再切回 Xcode 8 了。毕竟每次完整编译节省一分半钟的时间,还是一件很诱人的事情。 这次的 beta 版本质量出人意料地好,也许是因为这一两年来都是小幅革新式的改良,让 Apple 的软件团队有相对充足的时间进行开发的结果?总之,Xcode 9 beta 现在已经能很好地工作了。 #### Named Color 这是个人很喜欢的一个变化。现在你可以在 xcassets 里添加颜色,然后在代码或者 IB 中引用这个颜色了。大概是这样的: ![](/assets/images/2017/named-colors.png) 像是使用 IB 来构建 UI 的时候,一个很头疼的事情就是设计师表示我们要不换个主题色。你很可能需要到处寻找这个颜色进行替换。但是现在你只需要在 xcassets 里改一下,就能反应到 IB 中的所有地方了。 ### 其他值得注意的变更 剩下的都是些小变化了,简单浏览了下,把我觉得值得一提的列举出来,并附上参考的链接。 * [拖拽](https://developer.apple.com/documentation/uikit/drag_and_drop) - 很标准的一套 iOS API,不出意外地,iOS 系统帮助我们处理了绝大部分工作,开发者几乎只需要处理结果。`UITextView` 和 `UITextField` 原生支持拖拽,`UICollectionView` 和 `UITableView` 的拖拽有一系列专用的 delegate 来表明拖拽的发生和结束。而你也可以对任意 `UIView` 子类定义拖拽行为。和 mac 上的拖拽不同,iOS 的拖拽充分尊重了多点触控的屏幕,所以可能你需要对一次多个的拖拽行为做些特别处理。 * 新的 Navigation title 设计 - iOS 11 的大多数系统 app 都采用了新的设计,放大了导航栏的标题字体。如果你想采用这项设计的话也非常简单,设置 navigation bar 的 [`prefersLargeTitles`](https://developer.apple.com/documentation/uikit/uinavigationbar/2908999-preferslargetitles) 即可。 * [FileProvider 和 FileProviderUI](https://developer.apple.com/documentation/fileprovider) - 提供一套类似 Files app 的界面,让你可以获取用户设备上或者云端的文件。相信会成为以后文档相关类 app 的标配。 * 不再支持 32 位 app - 虽然在 beta 1 中依然可以运行 32 位 app,但是 Apple 明确指出了将在后续的 iOS 11 beta 中取消支持。所以如果你想让自己的程序运行在 iOS 11 的设备上,进行 64 位的重新编译是必须步骤。 * [DeviceCheck](https://developer.apple.com/documentation/devicecheck) - 每天要用广告 ID 追踪用户的开发者现在有了更好地选择 (当然前提是用来做正经事儿)。DeviceCheck 允许你通过你的服务器与 Apple 服务器通讯,并为单个设备设置两个 bit 的数据。简单说,你在设备上用 DeviceCheck API 生成一个 token,然后将这个 token 发给自己的服务器,再由自己的服务器与 Apple 的 API 进行通讯,来更新或者查询该设备的值。这两个 bit 的数据用来追踪用户比如是否已经领取奖励这类信息。 * [PDFKit](https://developer.apple.com/documentation/pdfkit) - 这是一个在 macOS 上已经长期存在的框架,但却在 iOS 上姗姗来迟。你可以使用这个框架显示和操作 pdf 文件。 * [IdentityLookup](https://developer.apple.com/documentation/identitylookup) - 可以自己开发一个 app extension 来拦截系统 SMS 和 MMS 的信息。系统的信息 app 在接到未知的人的短信时,会询问所有开启的过滤扩展,如果扩展表示该消息应当被拦截,那么这则信息将不会传递给你。扩展有机会访问到事先指定的 server 来进行判断 (所以说你可以光明正大地获取用户短信内容了,不过当然考虑到隐私,这些访问都是匿名加密的,Apple 也禁止这类扩展在 container 里进行写入)。 * [Core NFC](https://developer.apple.com/documentation/corenfc) - 在 iPhone 7 和 iPhone 7 Plus 上提供基础的近场通讯读取功能。看起来很 promising,只要你有合适的 NFC 标签,手机就可以进行读取。但是考虑到无法后台常驻,实用性就打了折扣。不过笔者不是很熟这块,也许能有更合适的场景也未可知。 * [Auto Fill](https://developer.apple.com/videos/play/wwdc2017/206/) - 从 iCloud Keychain 中获取密码,然后自动填充的功能现在开放给第三方开发者了。UITextInputTraits 的 [textContentType](https://developer.apple.com/documentation/uikit/uitextcontenttype) 中添加了 `username` 和 `password`,对适合的 text view 或者 text field 的 content type 进行配置,并填写 Info.plist 的相关内容,就可以在要求输入用户名密码时获取键盘上方的自动填充,帮助用户快速登录。 暂时先这么多,我之后如果发现什么有意思的事情再逐渐补充。如果你觉得还有什么值得一提的变化,也欢迎在评论里留言,我也会添加进去。 URL: https://onevcat.com/2017/04/storyboard-argue/index.html.md Published At: 2017-04-27 10:45:00 +0900 # 再看关于 Storyboard 的一些争论 从 iOS 5 的时代 Apple 推出 Storyboard (以下简称 SB) 后,关于使用这种方式构建 UI 的争论就在 Cocoa 开发者社区里一直发生着。我在 2013 年写过一篇关于[代码手写 UI,xib 和 SB 之间的取舍](https://onevcat.com/2013/12/code-vs-xib-vs-storyboard/)的文章。在四五年后的今天,SB 得到了多次进化,大家也积攒了很多关于使用 SB 进行开发的经验,我们不妨再回头看看当初的忧虑,并结合 SB 开发的现状,来提取一些现阶段被认为比较好的实践。 这篇文章缘起为对[使用 SB 的方式](http://www.jianshu.com/p/478998f0a274)一文 (及其[英文原文](https://medium.cobeisfresh.com/a-case-for-using-storyboards-on-ios-3bbe69efbdf4)) 的回应,我对其中部分意见有一些不同的看法。不过正如原文作者在最后一段所说,你应该选择最适合自己的使用方式。所以我的意见或者说所谓的「好的实践」,也只是从我自己的观点出发所得到的结论。本文将首先对原文提出的几个论点逐个分析,然后介绍一些我自己在日常使用 SB 时的经验和方式。 (反正关于 Storyboard 或者 Interface Builder 已经吵了那么多年了,也不在乎多这么一篇-。-) ## 原文分析 ### Storyboard 冲突风险和加载 原文中有一个非常激进的观点,那就是: > 每个 SB 里只放一个 UIViewController 我无法赞同这个观点。如果在 iOS 3 或者 4 时代有 xib 使用经验的开发者会知道,这基本就是将 SB 倒退到 xib 的用法。原文中提到这么做的原因主要有三点: > - 减少两个开发者同时开发一个 View Controller 时的 git 冲突 > - 加速 storyboard 加载,因为只需要加载一个 UIViewController > - 只用 initial view controller 就可以从 SB 中加载想要的 View Controller 在 Xcode 7 [引入了 SB reference](https://developer.apple.com/videos/play/wwdc2015/215/) 以后,「SB 容易冲突」已经彻底变成假命题了。通过合理地划分功能模块和每个开发者负责的部分,我们可以完全避免 SB 的修改冲突。最近两三年以来我们在实际项目中完全没有出现过 SB 冲突的情况。 另外,即使 SB 划分出现问题,影响也是可控的。在单个的 SB 文件中,每个 View Controller 有各自的范围域,因此即使存在不同开发者同时着手一个 SB 文件的情况,只要他们不同时修改同一个 View Controller 的内容,也并不会在 View Controller 上产生冲突。在 SB 文件中确实存在一些共用的部分,比如 IB 的版本,系统的版本等,但它们并不影响实质的 UI,而且可以通过统一开发成员的环境来避免冲突。因此,一个 SB 中多个 VC 和一个 SB 中一个 VC,其实所带来的冲突风险几乎是一样的。 关于 SB 的加载,可以看出原作者可能并没有搞清 UI 加载的整个流程,不求甚解地认为 SB 文件中 View Controller 越多加载时间越长,但事实并非如此。细心的同学 (或者项目中有很多 SB 文件的同学) 会发现,在编译的时候 Xcode 有一个 Compiling Storyboard files 的过程: ![](/assets/images/2017/compiling-sb.png) 编译过程中,项目里用到的 SB 文件也会被编译,并以 `storyboardc` 为扩展名保存在最终的 app 包内。这个文件和 `.bundle` 或者 `.framework` 类似,实际上是一个文件夹,里面存储了一个描述该编译后的 SB 信息的 `Info.plist` 文件,以及一系列 `.nib` 文件。原来的 SB 中的每个对象 (或者说,一般就是每个 View Controller) 将会被编译为一个单独的 `.nib`,而 `.nib` 中包含了编码后的对应的对象层级。在加载一个 SB,并从中读取单个 View Controller 时,首先系统会找到编译后的 `.storyboardc` 文件,从 `Info.plist` 中获取所需的 View Controller 类型和 nib 的关系,来完成 `UIStoryboard` 的初始化。接下来读取对应的某个 nib,并使用 `UINibDecoder` 进行解码,将 nib 二进制还原为实际的对象,最后调用该对象的 `initWithCoder:` 完成各个属性的解码。在完成这些工作后,`awakeFromNib` 被调用,来通知开发者从 nib 的加载已经完毕。 如果你理解这个过程,就可以看出,从只有单个 View Controller 的 SB 中加载这个 VC,与从多个 View Controller 中加载一个的情况,在速度上并不会有什么区别。硬要说的话,如果使用太多 SB 文件,反而会在初始化 `UIStoryboard` 时需要读取更多的 `Info.plist`,反而造成性能下降 (相对地我们可以使用 View Controller 的 `storyboard` 属性来获取当前 VC 所属的 `UIStoryboard`,从而避免多次初始化同一个 Storyboard,不过这点性能损失其实无关紧要)。 关于第三点,原作者使用了一段代码来展示如何通过类似这样的方法来创建类型安全的对象: ```swift let feed = FeedViewController.instance() // `feed` is of type `FeedViewController` ``` 这么做有几个前提,首先它需要按照 View Controller 类型名字来创建 SB 文件,其次还需要为 `UIViewController` 添加按照类型名字寻找 SB 文件的辅助方法。这并不是一个很明显的优点,它肯定会引入 `NSStringFromClass` 这种动态的东西,而且其实我们有很多更好的方式来创建类型安全的 View Controller。我会在第二部分介绍一些相关的内容。 ### Segue 的使用 原文中第二个主要观点是: > 不要使用 Segue Segue 的基本作用是串联不同的 View Controller,完成各 VC 的迁移或者组织。在第一个观点 (一个 SB 文件只含有一个 VC) 的前提下,不使用 Segue 是自然而然的推论,因为同一个 SB 中没有多个 VC 的关系需要组织,segue 的作用被大大降低。但是作者使用了一个不是很好的例子想要强行说明使用 segue 以及 `prepare(for:sender:)` 的坏处。下面是原文中的一段示例代码: ```swift class UsersViewController: UIViewController, UITableViewDelegate { private enum SegueIdentifier { static let showUserDetails = "showUserDetails" } var usernames: [String] = ["Marin"] func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { usernameToSend = usernames[indexPath.row] performSegue(withIdentifier: SegueIdentifier.showUserDetails, sender: nil) } private var usernameToSend: String? override func prepare(for segue: UIStoryboardSegue, sender: Any?) { switch segue.identifier { case SegueIdentifier.showUserDetails?: guard let usernameToSend = usernameToSend else { assertionFailure("No username provided!") return } let destination = segue.destination as! UserDetailViewController destination.username = usernameToSend default: break } } } ``` 简单说,这段代码做的就是在用户点击 table view 中某个 cell 的时候,将点击的内容保存到 View Controller 的一个成员变量 `usernameToSend` 中,然后调用 `performSegue(withIdentifier:sender:)`。接下来,在 `prepare(for:sender:)` 中获取保存的这个成员变量,并且设置给目标 View Controller。对于 table view 来说,这是一个不太必要的做法。我们完全可以直接将 cell 通过 segue 连接到目标 View Controller 上,然后在 `prepare(for:sender:)` 中使用 table view 的 `indexPathForSelectedRow` 获取需要的数据,并对目标 View Controller 进行设置。可能原作者不太清楚 `UITableView` 有这么一个 API,所以用了不太好的例子。 那么 segue 有问题吗?我的回答是有,但是问题不大。实际开发中确实存在不少类似原作者说到的情形,需要将数据在 `prepare(for:sender:)` 中传递给目标 View Controller,不过这种情况的数据很多时候已经存在于当前 View Controller 中 (比如需要传递文本框中输入的文字,或者当前 VC 的 model 的某个属性等)。相比于变量的问题,segue 带来的更大的挑战在于 View Controller 之间迁移的管理。现在我们可以通过代码进行转场 (`pushViewController(:animated)` 或者 `present(:animated:completion)`),也可以使用 SB 里点击控件的 segue,甚至还可以从代码中调用 `performSegue`,在不同的地方进行管理让代码变得复杂和难以理解,所以我们可能需要考虑如何以集中的方式进行管理。objc.io 的 Swift Talk 的[第五期视频 - Connecting View Controllers](https://talk.objc.io/episodes/S01E05-connecting-view-controllers) (而且是免费的) 对这个问题进行了一些探讨,并给出了一种集中管理 View Controller 之间迁移的方式。其中使用回调的方法可以借鉴,但是我个人对整个思路运用在实际项目里存有疑虑,大家也不妨作为参考了解。 除了管理转场外,segue 还能够提供方便的 Container View 的 embed 关系,也可以在使用像是 `UIPageViewController` 这样的多个 VC 关系的时候,用来提供一些初始化时运行的代码,又或者是用 unwind 来方便地实现 dismiss。这些「附加」的功能都让我们少写很多代码,开发效率得到提升,不去尝试使用的话可以说是相当可惜。 ### 要爱,不要拒绝 GUI 原文作者的最后一个主要观点是: > 所有的属性都在代码中设置 作者在原文一开始就提到,人都是视觉动物,使用 SB 的一大目标就是直观地理解界面。通过 SB 画布我们可以迅速获得要进行开发的 View Controller 的信息,这比阅读代码要快得多。但是,如果所有属性都在代码中进行设置的话,这一优势还剩多少呢? 作者提议在 SB 中对添加的 View 或者 ViewController 保留所有默认设置 (甚至是 view 的背景颜色,或者 label 文字等),然后使用代码对它们进行设置。在这一点上,原文作者的顾虑是对于 UI 元素样式的更改。作者希望通过使用一些常量来保存像是字体,颜色等,并在代码中将它们分别赋值给 UI 元素,这样能做到设计改变时只在一处进行更改就可以对应。 这种做法带来的缺点相当明显,那就是为了设置这些属性,你需要很多的 IBOutlet,以及很多额外的工作量。我的建议是,对于那些不会随着程序状态改变的内容,最好尽量使用 SB 直接进行设置。比如一个 label 上的文字,除非这些文字确实需要改变 (比如显示的是用户名,或者当前评论数之类),否则完全没有必要添加 `@IBOutlet`,直接设置 text 会简单得多。其他像是 `UIScrollView` 的 Cancellable Content Touches 等属性,如果不需要在程序中根据程序状态进行改变,也最好直接在 IB 里设置。作者在原文里提到,“通过扫描代码来寻找 view 的属性要比在 storyboard 中寻找一个勾号来的容易”,关于这一点,我认为其实两者并没有什么不同。举例来说,通过 IB 将 `UIScrollView` 的 Cancellable Content Touches 设置为 `false`,在对应的 SB 文件中的 scroll view 里会加上 `canCancelContentTouches="NO"` 这样的属性。通过全局搜索的方式找到这个属性也是轻而易举的。甚至你可以直接修改 SB 的源码达到目的,而根本不需要打开 Xcode 或者 IB。基于查找的可能性,批量的替换和更新与使用代码来设置也并无异。并不存在说在代码里更容易被找到这种情况。 > 不过要注意的是,SB 中的属性在 Xcode 的查找结果中是被过滤,不会出现的,所以可能需要使用其他的文本编辑器来全局查找。 关于像是字体或者颜色这样的 view 样式,作者的顾虑可以理解。IB 现在缺乏良好的做样式的方法,这也是大家诟病已久的问题。在 Font 选择中存在 style 的选项,让我们可以从 Body,Headline 之类的项目中进行选择,看起来很好: ![](/assets/images/2017/font-style.png) 但是这仅仅只是为了支持 Dynamic Type,设置这些值和调用 `UIFont` 的 `preferredFont(forTextStyle:)` 获取特定字体是一样的。我们并不能自行定义这些字体样式,也不能进行添加。颜色也一样,Xcode 并没有提供一个类似可以在 IB 里使用的项目颜色版或者颜色变量的概念。 关于 view 样式,最常见也是最简单的解决方案大概有两种。 第一种是使用自定义的子类,来统一设置字体或者颜色这些属性。比如说你的项目里可以会有 `HeaderLabel`,或者 `BodyLable` 这样的 `UILabel` 的子类,然后在子类里相应的方法中设置字体。这种方式来得比较直接,你可以通过更改 IB 里的 label 类型来适用字体。但是缺点在于当项目变大以后,可能 label 的类型会变得很多。另外,对于非全局性的修改,比如只针对某一个特定 label 调整的时候会比较麻烦,很可能你会想只针对个例做个别调整,而不是专门为这种情况建立新的子类,而这个决定往往会让你之前为了统一样式所做的努力付之一炬。 另外一种方式是为目标 view 的类型添加像是 `style` 属性,然后使用 runtime attribute 来设置。简单的想法大概是这样的,比如针对字体: ```swift extension UIFont { enum Style: String { case p = "p" case h1 = "h1" case defalt = "" } static func font(forStyle string: String?) -> UIFont { guard let fontStyle = Style(rawValue: string ?? "") else { fatalError("Unrecognized font style.") } switch fontStyle { case .p: return .systemFont(ofSize: 14) case .h1: return .boldSystemFont(ofSize: 22) case .defalt: return .systemFont(ofSize: 17) } } } ``` 这段代码为 `UIFont` 添加了一个静态方法,通过输入的字符串获取不同样式的字体。 然后,我们为需要字体样式支持的类型添加设置 `style` 的扩展,比如对 `UILabel`: ```swift extension UILabel { var style: String { get { fatalError("Getting the label style is not permitted.") } set { font = UIFont.font(forStyle: newValue) } } } ``` 在使用的时候,我们在 IB 里想要适用样式的 `UILabel` 添加 runtime attribute 就可以了: ![](/assets/images/2017/runtime-attribute.png) 不过不论哪种做法,缺点都是我们无法在 IB 中直观地看到 label 的变化。当然,可以通过为自定义的 `UILabel` 子类实现 `@IBDesignable` 来克服这个缺点,不过这也需要额外的工作量。还是希望 Xcode 和 IB 能够进步,原生支持类似的样式组织方式吧。不过就因此放弃简单明了的 UI 构建方式,未免有些过于武断。 基本上我对原文的每个观点已经提出了我的想法,不过正如原文作者最后说的那样,你应该选择你自己的使用风格,并决定要如何使用 Storyboard。 > It’s not all or nothing. 原文作者就只将 IB 和 Storyboard 作为一个设置 view 层次和添加 layout 约束的工具,这确实是 SB 的强项所在,但是我认为它的功能要远比这强大的多。正确地理解 SB 的设计思想和哲学,正确地在可控范围内使用 SB,对于发掘这个工具的潜力,对于进一步提高开发效率,都会带来好处。 本文下一部分将会简单介绍几个使用 SB 的实践。 ## 实践经验 ### 以类型安全的方式使用 Storyboard 原文作者提到使用单个 VC 的 Storyboard 可以以类型安全的方式进行创建。其实这并不是必要条件,甚至我们通过别的方式可以做得更好。在 Cocoa 框架中,为了灵活性,确实有很多基于字符串的 API,这导致了一定程度的不安全。Apple 自己为了 API 的通用性和兼容性,不太可能对现有的类型不安全的 API 进行大幅修改,不过通过一些合适的封装,我们依然可以让 API 更加安全。不管是我个人的项目还是公司的项目,其实都在使用像是 [R.swift](https://github.com/mac-cain13/R.swift) 这样的工具。这个项目通过扫描你的各种基于字符串命名的资源 (比如图片名,View Controller 和 segue 的 identifier 等),创建一个使用类型来获取资源的方式。相比与原作者的类型安全的手法,这显然是一种更成熟和完善的方式。 比如原来我们可以要用这样的代码来从 SB 里获取 View Controller: ```swift let myImage = UIImage(names: "myImage") let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "myViewController") as! MyViewController ``` 在 R.swift 的帮助下,我们将可以使用下面的代码: ```swift let myImage = R.image.myImage() // myImage: UIImage? let viewController = R.storyboard.main.myViewController() // viewController: MyViewController? ``` 这种做法在保证类型安全的同时,还可以在编译时就确认相应资源的存在。要是你修改了 SB 中 View Controller 的 identifier,但是没有修改相应代码的话,你会得到一个编译错误。 R.swift 除了可以针对图片和 View Controller 外,也可以用在本地化字符串、Segue、nib 文件或者 cell 等一系列含有字符串 identifier 的地方。通过在项目中引入 R.swift 进行管理,我们在开发中避免了很多可能的资源使用上的危险和 bug,也在自动补全的帮助下节省了无数时间,而像是使用 Storyboard 并从中创建 View Controller 这样的工作也变得完全不值一提了。 ### 利用 @IBInspectable 减少代码设置 通过 IB 设置 view 的属性有一个局限,那就是有一些属性没有暴露在 IB 的设置面板中,或者是设置的时候有可能要“转个弯”。虽然在 IB 面板中已经包含了八九成经常使用的属性,但是难免会有「漏网之鱼」。我们在工程实践中最常遇到的情形有两种:为一个显示文字的 view 设置本地化字符串,以及为一个 image view 设置圆角。 这两个课题我们都使用在对应的 view 中添加 `@IBInspectable` 的 extension 方法来解决。比如对于本地化字符串的问题,我们会有类似这样的 extension: ```swift extension UILabel { @IBInspectable var localizedKey: String? { set { guard let newValue = newValue else { return } text = NSLocalizedString(newValue, comment: "") } get { return text } } } extension UIButton { @IBInspectable var localizedKey: String? { set { guard let newValue = newValue else { return } setTitle(NSLocalizedString(newValue, comment: ""), for: .normal) } get { return titleLabel?.text } } } extension UITextField { @IBInspectable var localizedKey: String? { set { guard let newValue = newValue else { return } placeholder = NSLocalizedString(newValue, comment: "") } get { return placeholder } } } ``` 这样,在 IB 中我们就可以利用对应类型的 Localized Key 来直接设置本地化字符串了: ![](/assets/images/2017/setting-localized-ib.png) 设置圆角也类似,为 `UIImageView` (或者甚至是 `UIView`) 引入这样的扩展,并直接在 IB 中进行设置,可以避免很多模板代码: ```swift @IBInspectable var cornerRadius: CGFloat { get { return layer.cornerRadius } set { layer.cornerRadius = newValue layer.masksToBounds = newValue > 0 } } ``` `@IBInspectable` 实际上和上面提到的 `UILabel` 的 style 方法一样,它们都使用了 runtime attribute。显然,你也可以把 `UILabel` style 写成一个 `@IBInspectable`,来方便在 IB 中直接设置样式。 ### @IBOutlet 的 didSet 虽然这个小技巧并不会对 IB 或者 SB 的使用带来实质性的改善,但是我觉得还是值得一提。如果我们由于某种原因,确实需要在代码中设置一些 view 的属性,在连接 `@IBOutlet` 后,不少开发者会选择在 `viewDidLoad` 中进行设置。其实个人认为一个更合适的地方是在该 `@IBoutlet` 的 `didSet` 中进行。`@IBoutlet` 所修饰的也是一个属性,这个关键词所做的仅只是将属性暴露给 IB,所以它的各种属性观察方法 (`willSet`,`didSet` 等) 也会被正常调用。比如,下面我们实际项目中的一段代码: ```swift @IBOutlet var myTextField: UITextField! { didSet { // Workaround for https://openradar.appspot.com/28751703 myTextField.layer.borderWidth = 1.0 myTextField.layer.borderColor = UIColor.lineGreen.cgColor } } ``` 这么做可以让设置 view 的代码和 view 本身相对集中,也可以使 `viewDidLoad` 更加干净。 ### 继承和重用的问题 夸了 Storyboard 这么多,当然不是说它没有缺点。事实不仅如此,SB 还有很多很多可以改善的地方,其中,使用 SB 来实现继承和重用是最困难的地方。 Storyboard 不允许放置单独的 view,所以如果想要通过 IB 来实现 view 的重用的话,我们需要回退到 xib 文件。即使如此,想要在 SB 的 View Controller 中初始化一个通过 xib 加载的 view 也并不是一件很容易的事情。一般对于这种需求,我们会选择在 `init(coder:)` 中加载目标 nib 然后将它作为 subview 添加到目标 view 中。整个过程需要开发者对 nib 加载 view 和 View Controller 的过程有比较清楚的了解,但不幸的是 Apple 把这个过程藏得有些深,所以绝大多数开发者并不关心、也不是很清楚这个过程,就认为这是不可能的。 对于 view 的继承的话更困难一些。依然是由于二进制 nib 将通过解码的方式进行还原,所以在设置父类的属性时需要特别注意。另外,子类的 UI 是否应该通过创建新的 xib 进行构建,还是应该通过代码将父类的 UI 加到子类上,也会是艰难的选择。相比起来,使用代码进行 view 的继承和重用就要容易得多,方法也明确得多。 不光是单独的 view,SB 中 View Controller 的继承和重用也面临着同样的问题。View Controller 的重用相对简单,通过 storyboard 初始化对应的 View Controller,或者通过 segue 就可以了。继承则更麻烦,不过好在相比起 view 的继承,View Controller 的继承关系并不会特别复杂,在 UIKit 中对于 `UIViewController` 的继承最常用的基本也就 `UITableViewController`,`UICollectionViewController`,而作为最终展示给用户的 view 的管理代码来说,也很少有需要继承一个已经高度专用,并使用 IB 构建的 View Controller。如果你在项目中出现这种继承的需求,首先对继承的必要性进行考虑会是不错的选择。如果可以通过不同的配置重用已有的 View Controller,那么说明「继承」可能只是一个伪需求。 不管如何,不能否认,因为构建 UI 的方式是对 xml 文件的编码和解码,由此带来了继承和重用的困难,这是 IB 或者说 SB 的最大的短板。 ## 总结 本文旨在介绍一些我自己对 Storyboard 的看法,和我日常开发中的使用方式。并不是说什么「你应该这样使用」或者「最佳实践就应当如此这般」。你可以选择使用纯代码构建 UI,但同时 Apple 也为我们提供了更快捷的 IB 和 Storyboard 的方式。在我这么几年的使用经验来看,SB 的设计并没有这么不堪,而相比于以前使用代码或者 xib 的方式,现在的开发方式确实让效率得到了提高。开发者根据自己的需求和理解对工具进行选择,每个人的选择和使用的方式都是值得尊重。只要愿意拥抱变化,勇于尝试新的事物,并从中找到合适自己的东西,那么使用什么样的方式本身其实便没有那么重要了。 最后,愿你的技术历久弥新,愿你的生活光芒万丈。 URL: https://onevcat.com/2017/02/ownership/index.html.md Published At: 2017-02-27 15:40:00 +0900 # 所有权宣言 - Swift 官方文章 Ownership Manifesto 译文评注版 Swift 团队最近在邮件列表里向社区发了一封邮件,讲述了关于内存所有权方面的一些未来的改变方向。作为上层 API 的使用者来说,我们可能并不需要了解背后所有的事实,但是 Apple 的这封邮件中对 Swift 的值和对象的内存管理进行了很全面的表述,一步步说明了前因后果。如果你想深入学习和了解 Swift 的话,这篇文章是非常棒的参考资料。我尝试翻译了一下全文,并且加上了一些自己的注解。虽然这篇文章比较长,但是如果你想要进阶 Swift 的话,不妨花时间通读全文 (甚至通读全文若干遍)。 如果你没有时间通读全文,又想简单了解一下到底发生了什么的话,可以往下翻到最后,有一个我自己的简易的总结版本。 这篇文档本身是对今后 Swift 方向的一个提案,所以涉及的关键字和具体实现细节可能会有出入,不过这并不影响文章背后的思想。您可以在 Swift 的 repo 里找到[这篇文档的原文](https://github.com/apple/swift/blob/master/docs/OwnershipManifesto.md)。 这篇文章很长,读起来会比较花时间,不过因为内容还算循序渐进,只要静下心来就不会有太多困难。我在有些部分添加了个人的注解,会补充介绍一些背景知识和我自己的看法,你可以将它看成是我个人在读译本文时的笔记 (和吐槽),它们将以 “译者注” 的方式在文中以引出出现。不过只是一家之言,仅供参考,还望斧正。 ## 介绍 将“所有权”作为一个重要特性添加到 Swift 中,会为程序员带来很多好处。这份文档同时扮演了所有权这一特性的“宣言”和“元提案”的角色。在本文中我们会陈述所有权相关工作的基本目的,并描述要达到这些目的所使用的一般方法。我们还会为一系列特定的变化和特性进行提案,每个提案都会在将来在更细的粒度上分别进行讨论。这篇文档想要做的是在全局上提供一个框架,以帮助理解每个变化所带来的贡献。 ### 问题现状 在 Swift 中广泛使用的值类型写时复制 (copy-on-write) 特性取得了很大成功。不过,这个特性也还有一些不足: > 译者注:Swift 中的“写时复制”是指,值类型只在被改动前进行复制。传统意义上的值类型会在被传递或者被赋值给其他变量时就发生复制行为,但是这将会带来极大的,也是不必要的性能损耗。写时复制将在值被传递和赋值给变量时首先检查其引用计数,如果引用计数为 1 (唯一引用),那么意味着并没有其他变量持有该值,对当前值的复制也就可以完全避免,以此在保持值类型不可变性的优良特性的同时,保证使用效率。Swift 中像是 `Array` 和 `Dictionary` 这样的类型都是值类型,但是底层实现确是引用类型,它们都利用了写时复制的技术来保证效率。 * 引用计数和引用唯一性的测试必然导致额外开销。 * 在大多数情况下引用计数可以决定性能特性,但是分析和预判写时复制的性能还是十分复杂。 * 在任何时候值都有可能被复制,这种复制会使值“逃逸”出原有作用范围,这会导致绝大部分底层缓冲区都会被申请在堆内存上。如果能在栈上申请内存的话,会比现在高效得多,但是这需要我们能够阻止,或者至少识别出那些将要逃逸的值。 有些低层级的程序对于性能有着更严格的要求。通常它们并不要求绝对的高性能,但是却需要**可以预测**的性能特性。比如说,处理音频对于一个现在处理器来说并不是什么繁杂的工作,就算使用很高的采样率一般也能应付自如。但是只要有一点点预期外的停顿,就会立刻引起用户的注意。 > 译者注:也就是说,相比于绝对的高性能,我们可能更希望有平稳的性能特性,来处理这些工作。避免代码性能上出现“尖刺”,让程序运行在可以预估的水平。这样一样,即使绝对性能不足,针对用户体验我们也可以很好的对策 (比如降低码率),这可能比整体提升更重要,也更容易。 另一个很常见的编程任务是优化现有代码,比如你在处理某项工作时遇到了性能上的瓶颈。通常我们会找到执行时间或者是内存使用上的“热点”,然后以某种方式修复它们。但是当这些热点是由于隐式的值复制导致的话,Swift 中现在几乎没有工具能对应这种情况。程序员可能会尝试退回到用非安全指针来处理,但是这种行为将让你丧失标准库中集合类型所带来的安全性和表达能力的优势。 > 译者注:如果你觉得退回到非安全指针太过的话,也许退回到使用 `NSArray` 或者 `NSDictionary` 也会是一种选择。但是要注意数组或是字典中的类型最好也是 `NSObject` 子类,这样的回退才有意义。由于 Swift 中的类型和 Foundation 中的类型也存在一些隐式的桥接转换,这方面的性能开销往往被忽视。但是这样的妥协方式也并不理想,你同样失去了 Swift 的类型安全和泛型特性,同时可能你还需要大幅修改已有的模型类型,往往也得不偿失。 我们认为,通过引入一些可选的特性,我们将能够纠正这些问题。我们将这一系列特性统合称为**所有权**。 ### 什么是所有权? **所有权**是指某段代码具有最终销毁一个值的责任。**所有权系统**则是管理和转移所有权的一整套规则及约定。 任何具有销毁概念的语言,也都有所有权的概念。在像是 C 和非 ARC 的 Objective-C 这样的语言中,所有权是由程序员自行进行管理的。在其他一些语言中,像是一部分 C++ 里,所有权由语言进行管理。即使在隐式内存管理的语言中,也存在有所有权概念的库,这是因为除了内存以外,还有其他的编程资源,而理解那些代码应该释放那些资源,是一件非常重要的事情。 > 译者注:除了内存以外,其他的资源可能包括比如音频单元控制权、端口等等。这些资源也需要申请和释放,它们在这方面的运行逻辑和内存有相似之处。 Swift 已经有一套所有权系统了,但是它往往“鲜为人知”:这套系统是语言的实现细节,程序员几乎没有办法对其施加影响。我们想要提案的内容可以总结为以下几点: - 我们应该向所有权系统中添加一条核心规则 - 独占性原则 (Law of Exclusivity)。这条原则应该阻止以互相冲突的方式同时访问某个变量 (比如,将一个变量以 `inout` 的方式传递给两个不同的函数)。这应该是一个必须的非可选改变,但是我们相信,这个改变对绝大多数的程序都不会产生不利影响。 - 我们应该添加一些特性,给程序员一定的手段来控制类型系统。首先,是允许被“共享”的值能传递下去。这将是一个可选的变更,它将由一系列的标注和语言特性组成,而程序员可以简单地选择不使用它。 - 我们应该添加一个特性,来让程序员表达唯一的所有权。换句话说,就是表达某个类型不能被隐式地复制。这将是一个可选的特性,能为那些想在这个层级进行控制的富有经验的程序员提供可行方式。我们不打算让普通的 Swift 程序也能够与这样的类型一同工作。 以此三个改变作为支柱,我们将要把这门语言的所有权系统从实现细节提升到一个更加可见的层面。这三个改变虽然优先级稍有不同,但是它们确是不可分割的,我们稍后会详述原因。由于这邪恶原因,我们将三者进行捆绑内聚,并将它们统称作“所有权”特性。 ### 更多细节 Swift 现在的所有权系统中的基本问题在于复制,有关所有权的这三个改变全都在尝试避免复制。 在程序里,一个值可能会被用在很多地方。我们的实现需要保证在这些被用到地方,值的复制是存在且可用的。只要这个值的类型是可以复制的,那么我们只要复制这个值就肯定可以满足使用的需求了。但是,对于绝大多数的使用场景来说,实际上它们自己并不需要对复制的值拥有所有权。确实存在需要这么做的情况:比如一个变量并不拥有它当前的值,它只能将值存储在别处,而存储的地方是被其他东西持有的。但是这种做法一般并没有什么实际用处。而像是从类的实例中读取一个值这样的简单操作,只要求实例本身可用,而不要求读取代码自身实际拥有那个值的所有权。有时候这种差别十分明显,但是有时候却又难以分辨。举例来说,编译器在一般情况下是无法知道某个函数将对它的参数做怎样的操作的。在是否传递值的所有权这件事上,编译器只能回退到默认规则。当默认规则不正确的时候,程序就会在运行时多出额外的复制操作。所以,我们能做的是在某些方面让程序能被写得更加明确,这能帮助编译器了解它们是否需要值的所有权。 我们想要支持不可复制的类型,而这个方法和我们的想法是吻合的。对于大多数资源的销毁,唯一性十分重要:内存只能被回收一次,文件只能被关闭一次,锁也只能被释放一次。很自然地,对于这类资源的引用的所有者,应该也是唯一的,它不应该能被复制。当然,我们可以人为地允许所有权被共享,譬如我们可以添加一个引用计数,只在计数变为 0 时销毁资源,但是这势必会对使用这些资源带来额外的开销。更糟的是,这种做法会引入并发 (concurrency) 和[可重入](https://zh.wikipedia.org/wiki/可重入) (re-entrancy) 的问题。如果所有权是唯一的,并且语言本身强制规定了对资源的某些操作只能发生在拥有这个资源的代码中的话,那么自然而然地,同一时间内就只能有一段代码执行这些操作。而一旦所有权可以被共享,这一特性就随之消失了。所以,在一门语言里添加对不可复制的类型的支持会十分有意思,因为它能让我们以优秀和高效的抽象表达形式来操作资源。不过,要支持这些类型的话,我们需要完整应对抽象的所有方面,比如正确地标注函数的参数,以指明其是否需要所有权转移。如果标注不正确的话,只是使用增加复制的方式,编译器也无法保证在幕后一切都正确运行。 > 译者注:所有权唯一将会使资源管理的问题极为简化,但是事实上这样也会让程序变得无用。通过巧妙的语言设计 (或者说增加编译器开发者和语言开发者的压力),可以在保持唯一性的同时与程序其他部分“共享”,不过这么做也会带来很多的复杂度。本文后面就将展示这些复杂度以及对应的方式。 想要将这些问题里的任何一个解决好,我们都需要解决变量的非独占访问 (non-exclusive access) 的问题。Swift 现在是允许对一个同样的变量进行嵌套式访问的。比如说,你可以将同一个变量作为两个不同的 `inout` 参数进行传递,或者是在一个变量上调用某个方法,并且在这个方法所接受的回调参数中再去访问同一个变量。这类行为基本上是不被鼓励的,不过它们也没有被禁止。不仅如此,编译器和标准库在这种时候都必须“卑躬屈膝”,以保证如果发生问题的时候程序不要表现得过于离谱。举例来说,在发生原地的元素替换更改时,`Array` 必须要持有它自己的内存。若不这样做的话,试想要是在更改的时候我们以某种方式把原来的数组变量重新赋了值,那么这块内存就将被释放掉,而元素却还正在被更改。同样地,编译器一般也很难证明某个内存里的值在一个函数中不同的地方是否相同,因为它只能假设任何一个非透明的函数调用都有可能重写内存。这导致的结果是编译器只能像一个被迫害妄想症患者那样到处添加复制,保证冗余。更糟糕的是,非独占访问极大地限制了显式标注的实用性。比方说,一个 `shared` 参数只有在保证该参数在整个方法调用中都有效时,才有意义。但是,只有通过对一个变量的当前值进行复制并传递复制的值,才能可靠地保证该值可以在可重入的方式下进行更改。另外,非独占访问也让特定的重要的模式变得不可能实现,比如无法“盗取”当前值并创建新值,在执行的途中别的代码要是可以获取某个变量的话,是一件很糟糕的事情。要解决这个问题,唯一的方法是建立一个规则,来阻止多个上下文在同一时间访问同一变量。这就是我们的提案之一 - 独占性原则。 所有这三个目标都是紧密相连,并且互为加强的。独占性原则使得显式标注在默认情况下能确实优化代码,并且对不可复制的类型进行强制规范。显式标注在独占性原则的作用下,可以带来更多的优化机会,并让我们在函数中使用不可复制类型。不可复制类型能够验证即使对于可复制类型来说,标注也是最优选项,它们也为独占性原则能直接适用创造了更多的情境。 ### 成功的标准 如上所述,开发核心团队希望能将所有权作为可选的加强引入 Swift。程序员在很大程度上应该可以忽略所有权的问题,也不必为之操心。如果这一点被证明无法满足的话,我们会拒绝关于所有权的提案,而不会将这个明显的负担强加到普通的程序中去。 > 译者注:这实在是一个好消息。 独占性原则会引入一些新的静态和动态的限制。我们相信这些限制只会影响很小的一部分代码,而且这部分代码我们应该已经在文档中写明了可能产生非确定的结果。当我们进行动态限制的时候,还会造成一些性能上的损失。我们希望它所带来的优化潜力能够至少“弥补”这个损失。我们也会为程序员提供工具,来在必要的时候跳过这些安全检查。在文档后面的部分,我们会讨论很多这方面的限制。 ## 核心定义 ### 值 任何关于所有权系统的讨论都会基于更低的抽象层级。我们将要讨论的是一些语言实现方面的话题。在这个上下文中,当我们提到“值”这个词时,我们所表述的是具有特定语义的,用户口中的值的实例。 举例来说,下面的 Swift 代码: ``` var x = [1,2,3] var y = x ``` 人们通常会说这里 `x` 和 `y` 具有相同的值。让我们把这种值称为**语义值**。但是在实现层面上,因为变量 `x` 和 `y` 是能被独立改变的,所以 `y` 的值必须是 `x` 的值的复制。我们把这个叫做**值的实例**。一个值实例可以保持不变,并在内存中到处移动,不过进行复制的话则一定会导致新的值实例。在这篇文档剩下的部分,当我们不加修饰地使用“值”这个词时,我们指的是值实例这种更低层级的表述。 复制和销毁一个值实例的意义,随着类型不同稍有区别: * 有些类型只需要按照字节表示进行操作,而不需要额外工作,我们将这种类型叫做**平凡类型** (trivial)。比如,`Int` 和 `Float` 就是平凡类型,那些只包含平凡值的 `struct` 或者 `enum` 也是平凡类型。我们在本文中关于所有权的大部分表述都不适用于这种类型的值。不过独占性原则在这里依然适用。 * 对于引用类型,值实例是一个对某个对象的引用。复制这个值实例意味着创建一个新的引用,这将使引用计数增加。销毁这个值实例意味着销毁一个引用,这会使引用计数减少。不断减少引用计数,最后当然它会变成 0,并导致对象被销毁。但是需要特别注意的是,我们这里谈到的复制和销毁值,只是对引用计数的操作,而不是复制或者销毁对象本身。 * 对于写时复制的类型,值实例中包含了一个指向内存缓冲区的引用,它的工作方式和引用类型基本相同。我们要再次提醒,复制值并不意味着将缓冲区中的内容复制到一个新的缓冲区中。 对每种类型,使用的规则是相似的。 > 译者注:在 Swift 中,值类型和引用类型的区别是相当重要的。当前 Swift 的最大的使用场景是和 Cocoa 框架合作制作 app,而 Cocoa 包括 Foundation 仍然是一个引用类型占主导地位的框架。值类型在 Swift 里使用非常广泛,你几乎很难避免混用两种类型。从 Swift 3 开始,开发团队正在将 Foundation 框架逐步转换为值类型 (比如 `NSURL` 到 `URL`,`NSData` 到 `Data` 的转换),但是在底层它们包含了一个指向原来的对象类型的桥接。这就使得上面的最后一种情况 (写时复制类型) 变得非常普遍。另外,在我们自己创建的 Swift `struct` 和 `enum` 中,也经常会有引用类型作为成员的情况存在。而在这种情况下,写时复制并不是直接具备的特性,它需要我们进行额外的实现,否则我们就只能将它看作是引用类型来使用,否则很可能出现问题。在处理包含引用类型的值时,务必多多斟酌,特别小心。 ### 内存 一般来说,一个值可以以两种方式中的一种被持有:它可能是“临时”的,也就是说一个特定的执行上下文对这个值进行了计算,并将它当作操作数;或者它可以是“静止”的,被存放在内存某处。 对于临时值,它们的所有权规则十分直接,我们无需多加关注。临时值是由一些表达式所创建的结果,这些表达式被用在特定的地方,而这些值也就只需要在被用在这些地方。所以语言实现需要做的事情就很清楚了:只需要完成将它们直接送到需要的地方就可以了,而不必强制对它们进行复制。用户已经明白会发生的是什么,因此这部分没有实际需要改进的必要。 那么,我们关于所有权的讨论将很大程度上围绕内存中保存的值来进行。在 Swift 中,关于对内存的处理,有五个紧密相关的概念。 **存储声明** (storage declaration) 是一个语法概念,它声明了相关内存在这门语言里被处理的方式。现在,存储声明通过 `let`,`var` 和 `subscript` 引入。存储声明是带有类型的,它也包含了一些定义,来规定读取和写入存储时的方式。`var` 或者 `let` 的默认实现除了创建一个新变量来存储值以外并没有做什么。不过存储声明也可以是被计算出来的,也就是,并没有必要说一个变量背后一定会有对应的存储。 **存储引用表达式** (storage reference expression) 也是一个语法概念,它是一个对存储进行引用的表达式。这个概念与其他语言的 "l-value" 比较相似,不过不同的是它不需要一定被用在赋值语句的左边,因为存储也可能是不变的。 > 译者注:本文中会多次提到存储引用表达式,所以为了确保能理解什么是存储引用表达式,我在这里啰嗦几句。所谓的 l-value 表达式,指的是一个指向某个具体存储位置的表达式。在其他语言中,l-value 应该是可以被赋值的,而在 Swift 中,l-value 并不需要能被赋值,是因为有常量值的存在,存储将不会改变。举例来说,比如对某个点 `Ponit` 的坐标的访问表达式 `p.x` 就是一个存储引用表达式,它访问的是具体存储的 x 值。如果 `x` 是以 `var` 的形式声明的,那么它和其他语言的 "l-value" 就是等同的,如果 `x` 的定义方式是 `let`,则不可赋值,但这并不影响 `p.x` 作为存储引用表达式存在。另外,如果正方形有一个面积计算属性 `var area: Float { retrun side * side }`,`square.area` 则不是存储引用表达式,因为它求值后并不是对存储的引用。 **存储引用** (storage reference) 是一个语言语义的概念,它声明了一个指向特定存储的完全填满的引用。换句话说,它是存储引用表达式抽象求值后的结果,不过它并不会实际去访问存储。如果存储是一个成员,那么基本会包含值或是指向存储的引用。如果存储是一个下标 (subscript),它将包含索引的值。比如,像是 `widgets[i].weight` 这样的存储引用表达式可能被抽象求值为下述存储引用: * 属性 `var weight: Double` 的存储 * 下标 `subscript(index: Int)` 在索引值 `19: Int` 位置的存储 * 本地变量 `var widgets: [Widget]` 的存储 **变量** 是一个语义概念,它指的是内存中存储一个值的唯一地点。变量不需要是可变的 (至少在我们的文档中它不需要可变)。通常来说,存储声明是变量被创建的原因,不过它们也会在内存中被动态地创建 (比如使用 `UnsafeRawPointer`)。变量总是属于某一个特定的类型,也有一定的**生命周期**,在编程语言中,生命周期是指从变量开始存在的时间点到它被销毁的时间点之间的时间。 **内存地址** (memory location) 是指内存中一连串的可以被标记位置的范围。在 Swift 里,这里基本上是一个实现细节上的概念。Swift 不保证任意的变量会在它的生命周期中都保持在同一个内存地址上,Swift 甚至不能保证变量一定是被存储在内存地址上。但是也有将变量临时强制放置在一个不变的地址上的时候,比如以 `inout` 方式将变量传递给 `withUnsafeMutablePointer` 时就遵循这条规则。 ### 访问 对于存储引用表达式的某种特定的求值被称为访问。访问的方式有三种:**读取**,**赋值**,以及**修改**。赋值和修改都是**写操作**,不同的是赋值会将原来的值完全替换掉,而不会去读取它。修改的话需要依赖旧值。 所有的存储引用表达式都可以基于表达式出现的上下文,被归类到这三种访问类型中的一种。需要注意,这种归类是表面上的工作:它只依赖于当前上下文的语义规则,而不会去在程序的更深层次进行考虑和分析,也不会关心动态行为。比如,通过 `inout` 参数传递的存储引用不会关心被调用者有没有实际使用当前值,也不关心到底有没有实施写操作或者只是简单地使用它,这个访问在调用者里总是会被判断为更改访问。 存储引用表达式的求值可以分为两个阶段:首先会求得一个存储引用,之后,对存储引用的访问会持续一段时间。这两个阶段通常是接连进行的,但是在复杂的情况下,它们也可以被分开单独执行,比如在 `inout` 参数不是调用的最后一个参数时,就会发生这种情况。阶段分离的目的是将访问的持续时间最小化,而同时保持适应 Swift 的从左到右的最容易进行扩展的求值规则。 > 译者注:如果能够接受之前的五个概念的分别的话,将表达式的求值和使用 (对存储的访问) 过程分开处理也就是自然而然的事情了。虽然这让心智模型变得更加复杂,但是却能对应更多的使用情况,而且相对而言付出的代价可以接受。 ## 独占性原则 建立起这些概念后,我们就能简要地提出这提案的第一个部分 - 独占性原则了。所谓独占性原则,是指: > 如果一个存储引用表达式的求值结果是一个由变量所实现的存储引用,那么对这个引用的访问的持续时间段,不应该与其他任何对这个相同变量的访问持续时间段产生重合,除非这两个访问都是读取访问。 这里有一个地方故意说得比较模糊:这条原则只指出了访问“不应该”产生重合,但是它没有指出如何强制做到这一点。这是因为我们将对不同类型的存储使用不同的方法来强制这个机制。我们将在下一个大节里讨论那些机制。首先,我们想要谈一谈这条规则会带来的一些结果,以及我们满足这条规则所要使用的策略。 ### 独占性的持续时间 独占性原则说的是访问在它们的持续时间内必须是独占的。这个持续时间是由导致该次访问的直接上下文所决定的。也就是说,这是程序的一种**静态**特性,而从介绍部分中我们知道,横在我们面前的安全问题是一个**动态**的问题。按照一般经验,我们知道使用静态的方式来解决动态问题往往只能在保守的范围内生效;在动态的程序中,肯定会存在方案失效的时候。所以,一个自然的问题是,在这里要如何才能让一个通用的原则生效。 举例来说,当我们用 `inout` 参数的方式来传递存储的时候,访问会贯穿与整个调用所持续的过程中。这需要调用方保证在调用过程中没有其他对这个存储进行访问。这么一刀切的手法会不会有点太过粗糙?因为毕竟在被调用的函数中可能会有很多地方其实并不会用到这个 `inout` 参数。也许我们应该在追踪 `inout` 参数的访问这件事情上再细一些,我们可以在被调用的函数中来进行追踪,而不是粗暴地在整个调用者上施加独占性原则。其实问题在于,这个想法实在是太动态了,所以我们很难为它提供一个高效的实现。 调用方的 `inout` 规则有一个关键的优点;对于被传递的存储到底是什么,调用方有着大量的信息。这意味着调用方规则通常能以纯静态的方式让独占性原则适用,而不必添加动态检查或是做一些猜疑性质的假设。比如,要是有一个函数调用了某个本地变量上的 `mutating` 方法 (`mutating` 方法实际做的就是将 `self` 作为 `inout` 参数传入),除非变量被一个逃逸闭包 (escaping closure) 所捕获,否则函数就能轻而易举地检查每次对变量的访问,并确认这些访问和调用没有重叠,以此来保证满足独占性原则。不仅如此,这个保证还能被向下传递给被调用者,被调用者可以使用这些信息来证明它自己的访问是安全的。 > 译者注:我们往往会认为实际工作中 `inout` 的使用非常罕见,这说明你忽视了 `mutating` 方法的实质就是 `inout` 参数调用。在标准库中,很多关于数组或者字典的变更的方法都是 `mutating` 的,也符合这个原则。 相反,被调用方对于 `inout` 的规则却无法从这样的信息中受益:这些信息在调用发生的时候就被抛弃了。这就造成了我们在介绍一节中谈到的现今普遍的优化问题。比如,假设被调用者从参数加载了一个值,然后调用一个优化器无法进行推断的函数: ``` extension Array { mutating func organize(_ predicate: (Element) -> Bool) { let first = self[0] if !predicate(first) { return } ... // something here uses first } } ``` 在被调用方的规则下,优化器必须把 `self[0]` 的值复制到 `first` 中,因为它只能假设 `predicate` 有可能以某种形式改变 `self` 上绑定的值。在调用方规则下,优化器则能在数组没有被改变的时候,一直使用数组中的元素值,而不需要进行复制。 不仅如此,试想上面的例子里,如果要遵守被调用方规则的话,我们能写的代码会变为怎样呢?像这样的高阶操作不应该需要担忧调用者传入的 `predicate` 会以再入的方式改变数组。上面例子里,像使用本地变量 `first` 而不是反复地访问 `self[0]` 这样简单的实现选择,在语义上就会变得特别重要;而想要维护这种事情的难度是不可想象的。所以 Swift 库一般都禁止这种再入式的访问。不过,因为标准库并不能完全地阻止程序员这么做,所以实现必须在运行时做一些额外的工作,来确保这样的代码不会导致未定义的行为,或者是让整个进程发生错误。如果作出限制的话,这些限制只会作用于那些在良好书写的代码中本不应该出现的情况,所以对大多数程序员来说,并没有什么损失。 因此,这个提案提出了类似调用方的 `inout` 那样的访问持续时间的规则,它能让接下来的调用有机会被优化,同时保证需要付出的语义代价很小。 > 译者注:也就是说,所需要修改的代码很少,代码“变丑”或者“变复杂”的程度在可控范围之内。 ### 值和引用类型的构成 我们已经谈了很多关于**变量**的内容了。读者可能会想要知道,**属性** (property) 的情况是如何的。 在我们上面陈述的定义体系中,属性是一个存储声明,一个存储属性会在它的容器中创建一个对应的变量。对这个变量的访问显然需要遵守独占性原则,但是因为属性是被组织到一起放在一个容器中的,这会不会导致一些额外的限制?特别是独占性原则应不应该阻止那些对同一个变量或者值,但是是通过不同属性所进行的访问。 属性可以被分为三种类型: - 值类型的实例属性, - 引用类型的实例属性,以及 - 在任意类型上的 `static` 和 `class` 属性。 > 译者注:相比于 Objective-C,Swift 中的属性似乎并不是特别明显。因为 Objective-C 毕竟有 `@property` 这种显式的方式声明属性,而在 Swift 中,写在具体类型而非方法中的“变量声明”将自动成为属性。 我们提议总是将引用类型属性和 `static` 属性看作各自独立的属性,而在某个特定 (但是很重要) 的特殊情况以外,将值类型的属性当作是非独立的来进行处理。这可能会带来很大的限制,对为何这个提议是必要的,以及为什么我们对不同类型的属性加以区别,我们会详加说明。主要有三个原因。 #### 独立性和容器 第一个原因和容器有关。 对值类型来说,访问单个的属性和访问值的整体都是可能的。显然,访问一个单个属性和访问值的整体是冲突的,因为访问值的整体其实就是同时访问这个值里所有的属性。举例来说,比如有一个变量 `p: Point` (这个变量并不需要是一个本地变量),它包含三个存储属性 `x`,`y` 和 `z`。要是能够同时并且独立地改变 `p` 和 `p.x` 的话,独占性原则就会有一个巨大的漏洞。所以我们必须在这里强制独占性原则,我们有三个选择: (在阅读关于强制适用独占性原则的部分后,再来看这节内容会更容易理解。) 第一种方法是简单地将 `p.x` 的访问也看作是对 `p` 的访问。这很巧妙地就将漏洞堵上了,因为我们对 `p` 所适用的独占性原则很自然地将冲突的访问排除了。但是这也同时将对于其他属性的同时访问给排除了,因为对 `p` 中其他属性的访问都会触发对 `p` 的访问,从而使独占性原则生效。 另外两种方法需要让这种关系倒过来。我们可以将所有对单独的存储属性的独占性原则分离出来,而不是对整体值进行独占:对于 `p` 的访问会被看作是对 `p.x`,`p.y` 和 `p.z` 的方式。或者我们可以将独占性适用的方式参数化,并且记录正在被访问的属性的路径,比如 "",".x" 之类。不过,这些方式存在两个问题。 首先,我们并不总是知道全部的属性,或者那些属性是存储属性;某个类型的实现对我们来说有可能是不透明的,比如泛型或者还原的类型。对计算属性的访问必须被当作对整个值的访问,因为它需要将变量传递给 getter 或者 setter,而这些访问方法会是 `inout` 或者 `shared` 的。所以实际上它是和其他所有属性冲突的。使用动态的信息是可以让它们正常工作,但是这会在值类型的访问方法中引入很多记录方法,这和值类型被作为低耗费的抽象工具这一核心设计目标大相径庭。 其次,虽然这种模式可以相对容易地应用在独占性的静态适用上,但是想用在动态中就需要一大堆动态的记录,这也与我们的性能目标格格不入。 > 译者注:这里考虑的属性访问路径和 Objective-C 的 KVC 有形似之处。不过这也正是 Swift 所极力避免避免的问题。类似这样的标注,或者说动态的修改,对于性能的损失是不可忽视的,在尽可能的情况下,我们都希望适用静态的方法来保证独占性。只有在确实无法静态决定的情况下,再使用动态方式。 所以,虽然有方法能让我们将对不同的属性的访问和对整体值的访问独立开来,但是这要求我们强制对整体值使用独占性原则,而且还需要两种属性都是存储属性。虽然这是一种很重要的特殊情况,但是它也仅仅只是一种特殊情况。对于其他情况,我们必须回退到一般的规则,认为对于一个属性的访问同时也是对整体值的访问。 这些思考对于 `static` 属性以及引用类型的属性是不适用的。在 Swift 中没有同时访问一个类中所有属性的语言结构,而且说一个类型所有的 `static` 属性本身就是没有意义的事情,因为任何一个模块都能够在任何时候向某个类型添加 `static` 属性。 #### 独立访问的具体表现 第二个原因是基于用户期望的考虑。 避免对于不同属性的重叠访问最多只会造成一些小麻烦。在独占性原则下,我们至少可以避免“让人惊讶的长距离访问”:调用变量上的一个变量有可能开启一长串不明显的事件序列,然后最后返回并修改了原来的变量。现在有独占性原则,这就不会再发生了,因为这将会导致两个对同一变量互相冲突且重叠的访问。 作为对比,引用类型中有许多已经成为习惯的模式正是依赖这种“基于通知”的方式进行更新的。实际上,在 UI 代码中,同一个对象上的不同属性被并发修改并不是一件罕见的事儿:比如被用户 UI 操作修改的同时,被一些后台操作修改。阻止独立访问将会打破这种做法,这是无法接受的。 对于 `static` 属性,程序员期望它们是独立的全局变量;在一个全局变量被访问的时候,去禁止别的访问访问,这是说不通的。 #### 独立性和优化器 第三点和属性的优化潜力有关。 独占性原则的一部分目标是让一大类的优化能够适用于值。比如,值类型上的一个非 `mutating` 方法可以假设 `self` 在方法调用期间会完全保持一致。它不需要担心某个未知的函数会在调用期间返回并修改了 `self` 的值,因为这种修改将违背独占性原则。即使在 `mutating` 的方法中,除非知会这个方法,否则也没有其他代码能访问 `self`。这些假设对于优化 Swift 代码是非常关键的。 不过,这些假设一般来说对全局变量和引用类型属性的内容来说都不适用。类的引用可以被随意地共享,所以优化器必须假定某个未知方法可能会访问到同一个实例。另外,系统中的任何代码 (如果忽略访问权限控制的话) 都有可能访问到全局变量。所以就算将对不同属性的访问当作非独立的来对待,语言的实现能获得的好处也及其有限。 #### 下标 虽然现在这门语言中在技术上来说下标从来不会是存储属性,但大多数的讨论对下标依然适用。通过下标访问值类型的一个部分和访问整个值是同等对待的,对和这个值的其他访问发生重叠的时候,也应如此考虑。这导致的最重要的结果就是两个不同的数组元素不能被同时访问。这会妨碍到某些操作数组时的通常做法,不过有些 (比如并发地改变一个数组的不同切片这类) 事情在 Swift 中本来就充满了问题。我们认为,通过有目的的对集合的 API 进行改进,可以将缓和所带来的主要影响。 > 译者注:在日常开发中,对于数组的操作可能是并发编程中比较常见的多线程问题。在很大程度上,下标操作和实例属性的访问类似,我们可以通过加锁或者 GCD 做 barrier 的方式来确保数组线程安全。如果能在语言层面将独占性原则解决的话,将极大程度降低并发程序开发的难度。这也意味着今后的标准库中我们可以获得线程安全的数组,或者甚至整个标准库乃至第三方代码都会是默认线程安全的。 ## 独占性原则的强制适用 想要让独占性原则适用,我们有三种可行机制:静态强制,动态强制,以及未定义强制。所要选择的机制必须能由存储声明简单地决定,因为存储的定义和对它的所有直接的访问方法都必须满足声明的要求。一般来说,我们通过存储被声明为的类型,它的容器 (如果存在的话),以及任何存在的声明标记 (比如 `var` 或者 `inout` 之类) 来决定适用哪种机制。 ### 静态强制 在静态强制的机制下,编译器将检查独占性原则是否被违反,如果违反,则给出编译错误。因为这种方法安全可靠,并且不会产生运行时的损耗,所以应该是优先考虑的机制。 这种机制只能在所有东西都能被完美决定时使用。比如,对于值类型,因为独占性原则递归适用于所有属性,这保证了基本的存储是被独占访问的,所以独占性原则可以适用。对一般的引用类型则不适用,因为无法证明对于某个特定对象的引用是对这个对象的唯一的引用。不过,如果我们能够支持唯一引用的 class 类型的话,独占性原则就可以静态地适用它们的属性了。 在一些想定的情况下,编译器可能会为了保持源码兼容性和避免发生错误,从而隐式地插入复制操作。这应该只在需要源码兼容的模式下被使用。 > Swift 4 的路线图已经发布,主要会在 `String` 的部分引入部分的源码非兼容改动。同时 Swift 4 的编译器会支持通过特定的编译标记来在文件粒度上支持选择按 Swift 3 还是 Swift 4 进行编译。这种方式比现在 Swift 2.3 和 Swift 3 共存的方式要进步一些,不过也并不是说 Swift 4 就不用做迁移了...不过看情况似乎会比 2 到 3 的时候容易很多,因为至少我们可以做到一个文件一个文件进行迁移。 静态强制会被用在: - 各类的非可变变量 - 本地变量,除非被闭包的使用影响 (之后会详述) - `inout` 参数 - 值类型的实例属性 ### 动态强制 在动态强制下,语言实现将会维护一个记录,来确定每个变量现在是否正在被访问。如果发现冲突,它就将触发一个动态错误。如果编译器侦测到动态强制下一定会发现冲突的话,也可以由编译器给出一个静态错误。 进行记录时,对于每个变量需要两个 bit,将它标记为“未访问”,“读取”和“已修改”三种状态中的一种。虽然多个读取操作能够在同时一起生效,不过只要做法稍微聪明些,我们就可以通过在访问时将原来状态进行存储的方式,来避免对所有的访问进行完全的记录。 我们应该尽最大努力进行记录。这个做法需要能可靠地检测出那些必然会违反独占性原则的情况。我们没有要求它检测竞态条件 (race condition) 的状况,不过好消息是虽然没做要求,但它通常还是可以检测出竞态,这是一件好事。记录**必须**要能正确处理并发读取的情况,而不应该比如将记录永久地停在“读取”状态。但是,在并发读取时,即使是还有活动的读取者,将记录值设为“未读取”的状态却是可以接受的。这可以让记录使用非原子 (non-atomic) 操作。不过,在将一个类的不同属性的记录值打包到一个单独的字节时,却必须使用原子操作,因为在一个类中,对不同变量的并发访问是允许的。 > 译者注:对于对 Objective-C 不熟悉的读者来说,原子操作和非原子操作可能是比较陌生的概念。原子操作指的是不会被线程调度机制打断的调用。比如在满足原子操作的 getter 中,同一个属性的 setter 被调用,那么 getter 还是能返回完整的正确值,但是非原子操作的属性则不具备这个特性,因此非原子要快得多。Swift 现在没有设置原子属性的语法,所有的属性默认都是非原子操作。如果需要在 Swift 中让属性满足原子操作,现在我们可能需要自行进行加/解锁。另外注意,原子/非原子操作和线程安全的程序并没有太大关系,它只是针对一个属性的一次读写操作所做的特性设定。 当编译器检测到一个“实例内部”的访问时,也就是说在这种情况下,访问期间没有别的代码会执行,也就没有对同一个变量进行再入式访问的可能。此时,编译器就能避免更新记录的值,而只需要检查它现在是否具有一个恰当的值。这对读取来说很正常,因为读取操作往往只会在访问过程中复制值。当变量是 `private` 或者 `internal` 时,编译器可以检测到所有可能的访问都是内部访问,它就能够将所有的记录都去掉。我们希望这应该是非常常见的情形。 动态强制会被用在: - 使用了闭包,且有必要时的本地变量 (之后会详述) - class 类型的实例属性 - `static` 和 `class` 属性 - 全局变量 我们需要为动态强制提供一个标注,来让它在特定的属性和类中降级去使用未定义强制的机制。当有人觉得动态强制的性能消耗太重时,这可以为他们提供一种将这个特性去除的方式。在独占性实现后的早期阶段,留有余地是尤其重要的,因为我们可能还在探索不同的实现方法,有可能还没有找到全面的优化方式。 在之后,我们可以对类实例进行隔离,这让我们可以对一些类实例的属性使用静态强制。 ### 未定义强制 未定义强制的意思是冲突既不会被静态检测,也不会被动态检测,冲突的结果将是未定义行为。对于 Swift “默认安全”的设计下的一般代码来说,这不是一个好的机制,但是这确实是像不安全指针 (unsafe pointer) 这类东西的唯一真正选择。 > 译者注:`Unsafe` 家族继续在 Swift 中扮演垃圾桶角色。对于 C 的库,如果没有更好的替代,可能我们也只能接受牺牲安全性的事实。但是还是建议在真的要在 Swift 中使用 C 库之前,再三斟酌。诚然花力气去把 C 库用 Swift 进行重写不是一件很讨好的事情,但是还是应该在代码的安全特性和直接使用 C 库的便捷程度中进行权衡。如果选择使用 C 代码,建议尽量使用 struct 来进行一定的封装,避免过多地使用 Unsafe 类型来进行交互。 未定义强制将被用于: - 不安全指针的 `memory` 属性。 > 译者注:Swift 1 和 2 里是 `memory` 属性,Swift 3 中应该已经被改名为 `pointee` 了。如果说是 Swift 团队又打算把它改回 `memory` 的话...我也很无语。希望只是原作者的笔误。 ### 被闭包捕获的本地变量的独占性 独占性原则的静态强制依赖于我们能够静态地知道对变量的使用发生在哪里。对于本地变量来说,这种分析通常都很直接,但是当一个变量被闭包所捕获后,控制流就会让使用情况变得难以理解,从而使整个事情变复杂。就算是非逃逸的闭包,也是有可能被重入或者并发执行的。对于闭包捕获的变量,我们采取以下原则: - 如果闭包 `C` 有可能逃逸,那么假设有被 `C` 捕获的任意变量 `V`,对 `V` 的有可能在一段逃逸时间后才被执行的访问 (也包括在 `C` 本身中的访问),都必须遵守动态强制的原则,除非所有的访问都是读取访问。 - 如果闭包 `C` 不会逃逸出函数,那么它在函数内的使用地点都是已知的。在每处使用时,这个闭包要么被直接调用,要么被作为参数传递给另一个调用。对于每次这种调用时的非逃逸闭包,对每个被闭包 `C` 所捕获的变量 `V`,如果任意一个闭包含有对 `V` 的写操作,那这些闭包内的所有的访问都必须使用动态强制,并且这个闭包调用会被静态强制认为是试图对 `V` 进行写操作的调用。除此之外,所有的访问都可以使用静态强制,而且对闭包的调用会被视作对 `V` 的读取操作。 可能这些规则会随着时间而逐渐改进。比如我们应该可以对闭包的直接调用的规则进行一些改善。 ## 所有权使用的具体的工具 ### 共享值 本章中的很多讨论里会出现一个新概念:**共享值** (shared value)。正如其名,共享值指的是一个被当前上下文和拥有它的另一部分程序所共享的值。因为程序的多个部分能够同时使用这个值,所以为了贯彻独占性原则,这个值对于所有上下文 (包括拥有这个值的上下文) 来说,都必须是只读的。这一概念可以使程序对值进行抽象,而不必对它们进行复制。这和 `inout` 可以让程序对变量进行抽象有异曲同工之妙。 > 译者注:程序对值或者变量进行抽象可能不太容易理解。可以参考 `inout` 的实现方式,其实 `inout` 使程序在函数返回前对传入的参数进行赋值操作,就是一种抽象行为:这个关键字 `inout`,将传入变量且在返回时重新赋值变量这个操作,抽象为了一个修饰词。下述的 `shared` 与此类似,只不过它所抽象的目标对象是值。 (熟悉 Rust 的读者可能会在共享值和 Rust 的不变借入 (immutable borrow) 的概念之间找到相似之处。) > 译者注:不愧是把大半个 Rust 团队挖过来了...本来还打算今年学一下 Rust,现在看来把 Swift 4 学好就行了...最近三年果然还是坚持了每年学一门新语言,它们分别叫 Swift 2,Swift 3 和 Swift 4。 当一个共享值的源值是存储引用时,共享值实际上就是一个对存储的不可变引用。存储在共享值持续期间被作为读取操作进行访问,这样依赖,独占性原则会保证在访问期间不会有其他访问能对原来的变量进行修改。有些类型的共享值还可能被绑定到临时值上 (比如一个 r-value)。因为临时值总是被当前执行上下文所拥有,而且只在一个地方被使用,所以这不会带来额外的语义上的问题。 就像是普通的变量或者是 `let` 绑定时那样,我们可以在进行绑定后的作用域中使用共享值。如果要使用共享值的地方也要求所有权的话,Swift 将简单地对这个值进行隐式的赋值 - 这和普通的变量或者 `let` 绑定依然是一样的。 #### 共享值的局限 文档的这个部分将描述几种生成和使用共享值的方式。不过,我们现在的设计还没有提供一个通用的,“一等公民”的机制来使用共享值。程序并不能返回一个共享值,不能构建一个共享值组成的数组,也不能将共享值存储为 `struct` 的字段等。这些限制和 `inout` 引用所存在的限制是相似的。事实上,它们之间的相似非常多,因此我们甚至可以引入一个术语来包含它们两者:我们将它们叫做**暂态量** (ephemerals)。 我们的设计没有给暂态量提供完备的支持,这是精心考量的决定,这主要是基于三点考虑: - 我们需要将这个提案的范围限制在未来几个月内可以确实实现的范围内。我们希望这个提案能给语言及其实现带来大量好处,但是提案本身已经涉及广阔,而且略有激进了。对暂态量的完整支持将会给实现和设计增加很多复杂度,这显然会导致提案超出预计范围。另外,余下的语言设计问题都很庞大,而且已经有几个现存的语言尝试了将暂态量作为一等特性,不过它们的结果并不能说完全令人满意。 - 类型系统是在复杂度与表述清晰度之间的权衡。只要将类型系统做得更复杂,你就总是能接受更多的程序,但是这并不一定是好的权衡。在像 Rust 这样的重视引用的语言中,背后的生命周期限定 (lifetime-qualification) 系统向用户模型中添加了很多复杂度。这些复杂度对用户来说确实成为了负担。而且它依然不可避免地时不时要回退到不安全的代码,来绕开所有权系统的一些限制。在当前看来,将作用域限定引入 Swift 并不是一个可以直接做出的决定。 - 在 Swift 中,一个类似 Rust 的生命周期 (作用域) 系统的功能并不需要像 Rust 中那样强大。Swift 有意地提供了一个让类型的作者和 Swift 编译器本身都能够保留很多实现自由度的语言模型。 比如,Swift 中的多态存储就比 Rust 的要灵活一些。Swift 里的 `MutableCollection` 会实现一个通过索引来访问元素的 `subscript` 下标方法,但是这个方法几乎可以以任何方式来进行实现并满足这个需求。如果有代码访问了这个 `subscript`,而它又正好是通过直接访问底层内存来实现的话,这个访问就将会发生在原地。但是如果 `subscript` 是以 getter 和 setter 的计算属性方式方式实现的话,访问将会发生在一个临时变量中,getter 和 setter 则会在需要时被调用。因为 Swift 的访问模型是高度词法化的,它保留了在访问的末端运行任意代码的可能性。想象一下,如果我们要实现一个循环,来将这些临时的可变引用添加到一个数组里,我们就需要在循环的每次迭代里都能把任意的代码添加到执行队列里,这样才能在函数操作完数组后进行清理工作。这肯定不会是一个低损耗的抽象!一个在生命周期规则下的,和 Rust 更相似的 `MutableCollection` 接口,需要保证 `subscript` 返回的是一个指向已存在的内存的指针;这样一来,下标就完全不能支持计算方式的实现了。 > 译者注:Swift 的下标访问是一个很有意思的话题。与一般的值变量的复制行为不同,数组下标的访问正是直接的原地访问。但是这是借助于额外的地址器 (Addressors) 来完成的。在数组的下标方法中,并没有返回对应下标元素的值,而是返回了可以获取到元素值的更底层的访问方法 (accessor)。这样一来,写时复制的优化便可以对数组下标访问适用。而那些我们直接返回值的下标方法并不能从中获益,因为它们其实还是通过“计算”来返回下标访问,虽然这个计算本身仅仅只是返回了单个的值。有关更多地址器的问题,Swift 团队也有[详细的文档](https://github.com/apple/swift/blob/master/docs/proposals/Accessors.rst#addressors)进行介绍。 对于简单的 `struct` 成员,也存在同样的问题。Rust 的生命周期规则中有这样规定:如果你有一个指向 `struct` 的指针,那么你可以创建一个指向该 `struct` 中的一个字段的指针,并且这个新指针会和原来的指针具有同样的生命周期。不过,这条规则不仅假定了字段是确实被存储在内存中的,而且假定了这个字段是被**简单**存储的,也就是说,你可以用一个简单的指针指向它,而且这个指针将满足所指向类型的指针的标准应用二进制接口 (Application Binary Interface, ABI)。这意味着 Rust 不能使用很多内存布局优化手段,比如像是将很多布尔字段打包到一个字节里,或者仅仅是减少某个字段的对齐方式等。我们不愿意将这种保证引入到 Swift 中。 基于上述原因,虽然我们对进一步探索能够承载暂态值更多应用的更复杂的系统抱有理论上的兴趣,我们现在暂时并不打算进行相关的提案。因为这样一个系统所主要包含的是对类型系统的改变,所以我们并不担心这会在长期导致 ABI 稳定上的问题。我们也不用担心这会造成源码不兼容的情况。我们相信,关于这方面任何的增强都可以当作是对我们所提案的特性的扩展和推论来完成。 ### 本地暂态量绑定 在 Swift 中,对存储进行抽象,就只能将这个存储通过 `inout` 参数的方式传递,这是一个很蠢的限制。想要一个本地 `inout` 绑定的程序员,可以通过引入一个闭包,并且立即调用这个闭包的方式来轻易地绕开这个限制。这件原本是很简单的事情,不应该要如此麻烦才能达成。 共享值会让这个限制更加明显,用本地的共享值对一个本地的 `let` 值进行替换是一件很有意思的事情:共享值可以避免进行复制,而为此付出的代价是阻止其他的对原来存储的访问。我们不会鼓励程序员在他们的代码中通篇去使用 `shared` 来代替 `let`,特别是优化器通常都能够将复制操作给去除掉。但是,优化器也并不是永远都能移除复制操作,因此 `shared` 这个微优化在一些特定的情形下会很有用。而且,当与不可复制类型打交道时,去除掉正式的复制操作可能在语义上也是必要的。 我们提议直接去除掉这些限制: ``` inout root = &tree.root shared elements = self.queue ``` 本地暂态量的初始赋值是必须的,而且它必须是一个存储引用表达式。对于这类值的访问持续到剩余作用域的结束。 > 译者注:也就是说,让 `inout` 和 `shared` 的声明方式能够被一般程序员使用。不过其实对于绝大多数顶层应用的开发者来说,应该是不太用得到这两个声明关键字的。 ### 函数参数 函数参数是程序里最重要的对值进行抽象的方式。Swift 现在提供三种方式的参数传递: - 通过具有所有权的值进行传递。这是一般参数的规则,我们无法显式地指明使用该方式。 - 通过共享的值进行传递。这是对 `nonmutating` 方法的 `self` 参数的规则,我们无法显式地指明使用该方式。 - 通过引用传递。这是对 `inout` 参数和 `mutating` 方法的 `self` 参数的规则。 > 译者注:没错,那些 `nonmutating` 的方法也具有 `self` 参数。(否则你就无法在方法内部使用 `self` 了!) 我们提议,允许我们可以指明使用那些非标准的情况: - 函数的参数可以被显式指明为 `owned`: ``` func append(_ values: owned [Element]) { ... } ``` `owned` 不能和 `shared` 或者 `inout` 一起使用。 它只是对默认情况的一种显式的表达。我们不应该希望用户经常把它们写出来,除非用户正在处理不可复制的类型。 - 函数的参数可以被显式指明为 `shared`。 ``` func ==(left: shared String, right: shared String) -> Bool { ... } ``` `shared` 不能和 `owned` 或 `inout` 一起使用。 如果函数参数是一个存储引用表达式的话,该存储在调用期间将被作为读取来进行访问。否则,参数表达式将被作为 r-value 来求值,并且临时值在调用中被共享。允许函数参数的临时值被共享是非常重要的,很多函数仅仅是因为自己事实上并不会去拥有参数,它们的参数就将被标记为 `shared`。而其实上,在语义上这些参数被作为对一个已存在的变量的引用时,才是标记 `shared` 的更为重要的情况。举个例子,我们这里将等号操作符的参数标为 `shared`,是因为它们需要在不事先声明的情况下,就能够对不可复制值也进行比较。同时,这也不能妨碍程序员去比较一般的字面值。 和 `inout` 一样,`shared` 是函数类型的一部分。不过与 `inout` 不同,大多数函数兼容性检查 (比如方法重写的检查和函数转换的检查) 在 `shared` 和 `owned` 不匹配时也应该成功。如果一个带有 `owned` 参数的函数被转换 (或是重写) 为了一个 `shared` 参数的函数,参数类型实际上必须是可复制的。 - 方法可以被显式声明为 `consuming`。 ``` consuming func moveElements(into collection: inout [Element]) { ... } ``` 这会使 `self` 被当作一个 `owned` 值传入,所以 `consuming` 不能和 `mutating` 或 `nonmutating` 混用。 在方法内,`self` 依然是一个不可变的绑定值。 > 译者注:这里提出的 `consuming` 实际上是对 `mutating` 的一种更加严谨的细分。如果没有添加相应的约定,那么在使用 `mutating` 时,`self` 的独占性保证只能动态进行。而这也正是 `struct` 中 `mutating` 现在不受程序员待见的原因之一。 ### 函数结果 我们在本节的开头进行过一些讨论,想要对 Swift 的词法访问模型进行扩展,让它能支持从函数中返回暂态量并不是一件容易的事。实现这样访问,需要在访问的开始和结束时都执行一些和存储相关的代码。而在一个函数返回后,访问其实就没有进一步执行代码的能力了。 当然了,我们可以返回一个包含暂态量的回调,然后等待调用者使用完这个暂态量后再调用回调,这样我们就能处理暂态量的存储代码了。然而,单单只是这样做还不够,因为被调用者有可能会依赖于它的调用者所做出的保证。举例来说,比如 `struct` 上的一个 `mutating`,它想要返回的是对一个存储属性的 `inout` 引用。想要一切正确,我们不仅要保证在访问属性后方法能够进行清理工作,还要保证绑定在 `self` 上的变量也一直有效。我们真正想要做的是在被调用侧以及调用侧所有有效的作用域内对当前上下文进行维护,并且简单地将暂态量作为参数,在调用侧进入一个新的嵌套的作用域。在编程语言中,这是一个已经被充分理解的情况了,那就是协程 (co-routine)。(因为作用域限制,你也可以将它想象为一个回调函数的语法糖,其中的 `return` 和 `break` 等都按照期望工作。) 事实上,协程可以用来解决很多有关暂态量的问题。我们会在接下来的几个子章节内探索这个问题。 > 译者注:协程的概念可以帮助简化线程调度的问题,也是一个良好的异步编程模型的基础。 ### `for` 循环 和三种传递参数的方式相同,我们也可以将对一个序列进行循环的方式分为三种。每种方式都可以用一个 `for` 循环来表达。 #### Consuming 迭代 第一种迭代方式是 Swift 中我们已经很属性的方式了:消耗 (consuming) 迭代,这种迭代中每一步都由一个 `owned` 值来代表。这也是我们对那些值可能是按需生成的任意序列的唯一的迭代方式。对于不可复制类型的集合,这种迭代方式可以让集合最终被结构,同时循环将获取集合中元素的所有权。因为这种方式会取得序列所产生的值的所有权,而且任意一个序列都不能被多次迭代,所以对于 `Sequence` 来说,这是一个 `consuming` 操作。 我们可以显式地将迭代变量声明为 `owned` 来指明这种迭代方式: ``` for owned employee in company.employees { newCompany.employees.append(employee) } ``` 当非可变的迭代的要求不能被满足的时候,这种方式也应该被默认地使用。(而且,这也是保证源码兼容性所必须的。) 接下来两种方式只对集合有意义,而不适用于任意的序列。 > 译者注:Swift 中,集合 (`Collection`) 一定是序列 (`Sequence`),但是序列不一定是集合。 #### Non-mutating 迭代 非可变迭代 (non-mutating iteration) 所做的事情是简单地访问集合中的每个元素,并且保持它们完好不变。这样,我们就不需要复制这些元素了;迭代变量可以简单地绑定给一个 `shared` 的值。这就是 `Collection` 上的 `nonmutating` 操作。 我们可以显式地将迭代变量声明为 `shared` 来指明这种迭代方式: ``` for shared employee in company.employees { if !employee.respected { throw CatastrophicHRFailure() } } ``` 在序列类型满足 `Collection` 时,这种行为是默认行为,因为对于集合来说,这是一种更优化的做法。 ``` for employee in company.employees { if !employee.respected { throw CatastrophicHRFailure() } } ``` 如果序列操作的是一个存储引用表达式的话,在循环持续过程中,存储会被访问。注意,这意味着独占性原则将隐式地保证在迭代期间集合不会被修改。程序可以对操作使用固有函数 (intrinsic function) `copy` 来显式地要求迭代作用在存储的复制值上。 > 译者注:其实我们或多或少已经从 Swift 的值特性和独占性中获取好处了。比如对于一个可变数组,我们可以在迭代它的同时,修改它内容: > ```swift var array = [1,2,3] for v in array { let index = array.index(of: v)! array.remove(at: index) } ``` > 这正得益于对于迭代变量的复制,而这种操作在很多其他语言里是难以想象的。不过,这种语义上没有问题的做法却可能在实际中给程序员造成一些困扰。使用明确的标注来规范这种写法确实会是更好的选择。 > > 关于固有函数,是指实现由编译器进行处理的那些“内嵌”在语言中的函数。我们会在后面的章节再进行详细说明。 #### Mutating 迭代 一个可变迭代将访问每个元素,并且有可能对元素作出改变。所迭代的变量是一个对元素的 `inout` 引用。这是对 `MutableCollection` 的 `mutating` 操作。 这种方式必须显式地用 `inout` 来声明迭代变量: ``` for inout employee in company.employees { employee.respected = true } ``` 序列操作的必须是一个存储引用表达式。在循环的持续时间中,存储将会被访问,和上面一种方式一样,这将阻止对于集合的重叠访问。(但是如果集合类型定义的操作是一个非可变操作的话,这条规则便不适用,比如一个引用语义的集合就是如此。) #### 表达可变和不可变迭代 可变迭代和不可变迭代都要求集合在迭代的每一步创建一个暂态量。在 Swift 中,我们有若干种表达的方式,但是最合理的方式应该是使用协程。因为协程在为调用者产生 (yield) 值的时候不会丢弃自己的执行上下文,所以一个协程产生多个值就是很正常的用法了,这也非常符合循环的基本代码模式。由此产生的一类协程通常被称为生成器 (generator),这也正是很多种主要语言实现迭代的方式。在 Swift 中,为了也能实现这种模式,我们需要允许对生成器函数进行定义,比如: ``` mutating generator iterateMutable() -> inout Element { var i = startIndex, e = endIndex while i != e { yield &self[i] self.formIndex(after: &i) } } ``` 对于使用者一方,用生成器来实现 `for` 循环的方式是很明显的;不过,如何直接在代码中允许生成器的使用却不那么明显的事情。如上所述,因为逻辑上整个协程必须运行在对原来值访问的作用域中,所以对于协程使用的方式,有一些有趣的限制。对于一般的生成器而言,如果生成器函数返回的确实是某种类型的生成器对象,那么编译器必须确保这个对象不会逃逸出访问范围。这是复杂度的一个重要来源。 ### 一般化的访问方法 Swift 现在提供的用来获取属性和下标的工具相当粗糙:基本上只有 `get` 和 `set` 方法。对于性能很关键的任务来说,这些工具是远远不足的,因为它们并不支持直接对值进行访问,而一定会发生复制。标准库中可以使用稍微多一些的工具,可以在特定有限的情况下提供直接的访问,但是它们仍然很弱,而且基于不少原因,我们并不希望将它们暴露给用户。 所有权给我们提供了一个重新审视这个问题的机会,因为 `get` 返回的是一个拥有所有权的值,所以它无法用于那些不可复制类型。访问方法 (getter 或者 setter) 真正需要的是产生一个共享值的能力,而不只是单单能返回值。同样地,想要达成这一目的的一个可行方式是让访问方法能使用某种特殊的协程。和生成器不同,这个协程只能进行一次发生。而且我们没有必要为程序员设计调用它的方式,因为这种协程只会被用在访问方法中。 我们的想法是,不去定义 `get` 和 `set`,而是将在存储声明中定义 `read` 和 `modify`: ``` var x: String var y: String var first: String { read { if x < y { yield x } else { yield y } } modify { if x < y { yield &x } else { yield &y } } } ``` 一个存储声明必须定义 `get` 或者 `read` (或者定义为存储属性) 中的一个,但是不应该进行同时定义。 如果想要可变的话,存储声明必须再定义 `set` 或者 `modify` 中的一个。不过也可以选择同时定义**两者**,这种情况下 `set` 会被用作赋值,而 `modify` 会被用作更改。这在优化某些复杂的计算属性时会很有用,因为它可以允许更改操作原地进行,而不用强制对首先读取的旧值进行重新赋值。不过,需要特别注意,`modify` 的行为必须和 `get` 和 `set` 的行为相一致。 ### 固有函数 #### `move` Swift 优化器一般会尝试将值进行移动,而不是复制它们,但是强制进行移动也有其意义。正因如此,我们提议加入 `move` 函数。从概念上说,`move` 函数是一个 Swift 标准库的顶层函数: ``` func move(_ value: T) -> T { return value } ``` 然后,这个函数有一些特定的含义。该函数不能被间接使用,参数表达式必须是某种形式的本地所有的存储:它可以是一个 `let`,一个 `var`,或者是一个 `inout` 的绑定。调用 `move` 函数在语义上等同将当前值从参数变量中移动出来,并将其以表达式制定的类型进行返回。返回的变量在最终初始化分析中将作为未初始化来对待。接下来变量所发生的事情依赖于变量的种类而定: - `var` 变量将被作为未初始化而简单传回。除非它被赋以新值或者被再次初始化,否则对它的使用都是非法的。 - `inout` 绑定和 `var` 类似,不过它不能在未初始化的情况下离开作用域。或者说,如果程序要离开一个有 `inout` 绑定的作用域的话,程序必须为这个变量赋新的值,而不论它是以何种方式离开作用域 (包括抛出错误的时候)。将 `inout` 暂时作为未定义变量的安全性是由独占性原则所保证的。 - `let` 变量不能被再次初始化,所以它不能再被使用。 这对于现在的最终初始化分析是一个直接的补充,它能确保在使用一个本地变量之前,它总是被初始化过的。 #### `copy` `copy` 是 Swift 标准库中的一个顶层函数: ``` func copy(_ value: T) -> T { return value } ``` 参数必须是一个存储引用表达式。函数的语义和上面的代码一致:参数值会被返回。该函数的意义如下: - 它可以阻止语法上的特殊转换。举例来说,我们上面讨论过,如果 `shared` 参数是一个存储引用,那么存储在调用期间是被访问的。程序员可以通过事前在存储引用上调用 `copy` 来阻止这种访问,并且强制复制操作在函数调用前完成。 - 对于那些阻止隐式复制的类型来说,这是必须的。我们会对不可复制类型进行进一步叙述。 #### `endScope` `endScope` 是 Swift 标准库中的顶层函数: ``` func endScope(_ value: T) -> () {} ``` 参数必须是一个引用本地 `let`,`var` 或者独立的 (非参数,非循环) `inout` 或者 `shared` 声明。如果参数是 `let` 或者 `var`,则变量会被立即销毁。如果参数是 `inout` 或者 `shared`,则访问将立即终止。 最终初始化分析必须保证声明在这个调用后没有再被使用。如果存储是一个被逃逸闭包捕获的 `var` 的话,应该给出错误。 对于想要在控制流到达作用域结尾前就想要终止访问的情形来说,这很有用。同样地,对销毁值时的微优化也能起到作用。 `enScope` 保证在调用的时候输入的值是被销毁的,或者对它的访问已经结束。不过它没有承诺这些事情确实发生在这个时间点:具体的实现仍然可以更早地结束它们。 ### 透镜 (Lenses) 现在,Swift 中所有的存储引用表达式都是**具体**的:每一个组件都静态地对应一种存储声明。在社区中,大家在允许程序对存储进行抽象这件事上一致兴趣盎然,比如说: ``` let prop = Widget.weight ``` 这里 `prop` 会是一个对 `weight` 属性的抽象引用,它的类型是 `(Widget) -> Double`。 > 译者注:对于类型上的方法来说,这种透镜抽象是一直存在的 - 因为方法不存在所有权的内存问题。 这个特性和所有权模型有关,因为一个一般的函数的结果一定是一个 `owned` 的值:不会是 `shared`,也不是可变值。这意味着,上述这种透镜抽象只能抽象**读操作**,而不能对应**写操作**,而且我们只能为可复制的属性创建这种抽象。这也意味着使用透镜的代码会比使用具体存储引用的代码需要更多的复制。 设想,要是透镜抽象不是简单的函数,而是它们各自类型的值。那么透镜的使用将会变成一个对静态未知成员进行访问的抽象的存储引用表达式。这就需要语言的实现能够执行某种程度的动态访问。然而,访问未知的属性和访问实现未知的已知属性有几乎一样的问题;也就是说,为了实现泛型和还原类型,语言已经需要做类似的事情了。 总体来说,只要我们有所有权模型,这样的特性就正好可以符合我们的模型。 ## 不可复制的类型 不可复制的类型在很多高级的情况中会十分有用。比如,它们可以被用来高效地表达唯一的所有权。它们也可以用来表达一些含有像是原子类型这样的某种独立标识的值。它们还可以被用作一种正式的机制,来鼓励代码能够更高效地和那些复制起来开销很大的类型 (比如很大的) 一起使用。它们之间统一的主题是,我们不想类型被隐式地复制。 Swift 中处理不可复制类型的复杂度主要有两个来源: - 语言必须提供能将值进行移动和共享,而不强制进行复制的工具。我们已经对这些工具进行了提案,因为它们对优化可复制类型的使用也同等重要。 - 泛型系统必须能够表达不可复制的类型,同时不引入大量的源码兼容性问题,也不需要强制所有人使用不可复制类型。 如果不是因为这两个原因的话,这个特性本身是很小的。就只需要用我们上面提到的 `move` 固有函数那样,隐式地使用移动来代替复制,并且在遇到无法适用的情况下给出诊断信息即可。 ### `moveonly` 上下文 不过,泛型确实会是一个问题。在 Swift 中,最直白的为可复制特性建模的方式无非就是添加一个 `Copyable` 协议,让那些可以被复制的类型遵守这个协议。这样一来,不加限制的类型参数 `T` 就无法被假设为可复制类型。不过,这么做对源码兼容性和可用性来说都会是巨大的灾难,而且我们也不想让程序员在首次被介绍使用泛型代码的时候就去操心那些不可复制类型的问题。 另外,我们也不想让类型需要显式地被声明为支持 `Copyable`。对复制的支持应该是默认的。 所以,逻辑上来说解决的方式是,维持现在所有类型都是可复制类型的默认假设,然后允许上下文选择将这个假设关掉。我们将这些上下文叫做 `moveonly` 上下文。在一个 `moveonly` 上下文中词法嵌套的所有上下文也都隐式地成为 `moveonly`。 一个类型可以提供 `moveonly` 上下文: ``` moveonly struct Array { // Element and Array are not assumed to be copyable here } ``` 这将阻止在该类型声明,它的泛型参数 (如果有的话),以及它们在继承链上所关联的类型上进行 `Copyable` 假设。 扩展也可以提供 `moveonly` 上下文: ``` moveonly extension Array { // Element and Array are not assumed to be copyable here } ``` 不过使用带有条件的协议遵守时,类型可以声明为条件可复制: ``` moveonly extension Array: Copyable where Element: Copyable { ... } ``` 不论是在约束条件里满足还是直接满足 `Copyable`,它都会是一个类型的继承特性,并且一定要在定义该类型的同一个模块中进行声明。(或者有可能的话,应该在同一个文件中进行声明。) 对于一个类型的非 `moveonly` 的扩展,将会把可复制性的假设重新引入这个类型及其泛型参数中。这么做的目的是为了标准库中的类型能够在不打破现有扩展的兼容性的同时,添加对不可复制元素的支持。如果一个类型没有进行任何的 `Copyable` 声明,那么为它添加一个非 `moveonly` 的扩展将会发生错误。 > 译者注:这里的意思是,针对那些 `moveonly` 定义的类型,我们不能为它随意添加非 `moveonly` 的扩展。这是显而易见的,否则就会发生复制特性的冲突。而对于那些非 `moveonly` 的类型 (因为它们是隐式默认支持复制,或者说满足 `Copyable` 的),以及在条件约束下满足 `Copyable` 的情况来说,添加非 `moveonly` 扩展是没有问题的。 函数也可以定义一个 `moveonly` 上下文: ``` extension Array { moveonly func report(_ u: U) } ``` 这将会使任何新的泛型参数和它们的继承关联类型上的复制假设无效。 很多关于 `moveonly` 上下文的细节我们扔在考虑之中。关于这个问题,在我们最终寻找到正确的设计之前,还需要很多的进行语言实现的经验。 我们正在考虑的一种可能性是,对于可复制类型的值,`moveonly` 上下文也将会取消其可复制假设。对于那些需要特别注意复制操作的代码来说,这会提供一种重要的优化工具。 ### 不可复制类型的 `deinit` 对那些定义为 `moveonly` 的不遵守 (也不条件遵守) `Copyable` 的值类型,可以为其定义一个 `deinit` 方法。注意,`deinit` 必须被定义在类型的主定义域内,而不能定义在扩展中。 在值不再被需要时,`deinit` 将会被调用以销毁这个值。这让不可复制类型可以被用来表达对于资源的唯一所有权。比如说,这里有一个简单的处理文件的类型,它保证了值被销毁时,文件句柄一定会被关闭: ``` moveonly struct File { var descriptor: Int32 init(filename: String) throws { descriptor = Darwin.open(filename, O_RDONLY) // 在 `init` 里任何非正常退出都会阻止 deinit 被调用 if descriptor == -1 { throw ... } } deinit { _ = Darwin.close(descriptor) } consuming func close() throws { if Darwin.fsync(descriptor) != 0 { throw ... } // 这是一个 consuming 函数,所以它拥有对自己的所有权。 // 其他的任何方式都不会对 self 产生消耗,所以函数将在 // 退出时通过调用 deinit 进行销毁。 // 而 deinit 将会通过描述符实际关闭文件句柄。 } } ``` Swift 对值的销毁 (以及对 `deinit` 的调用) 发生在一个值被最后使用后,以及正式解构的时间点前的期间内。不过这个定义中对于“使用”的定义暂时还没有完全决定。 如果这个值类型是一个 `struct`,那么 `deinit` 中 `self` 只能被用来引用类型的存储属性。`self` 的存储属性会被当作本地 `let` 常量被看待,并用于最终初始化分析;也就是说,它们是属于 `deinit` 方法,并且可以被移出去的。 如果值类型是一个 `enum` 的话,`deinit` 里的 `self` 只能被当作 `switch` 的操作数来使用。在 `switch` 内,任何一个用来初始化对应绑定的关联值,都拥有对这些值的所有权。这样的 `switch` 会使 `self` 处于未初始化状态。 ### 显式可复制类型 在不可复制类型里,还有一种我们在探索的想法,那就是将一个类型声明为不可被隐式复制。比如,一个很大的结构体可以被正式地进行复制,但是如果不必要地对它进行复制的话,就会对性能产生过大的影响。这样的类型应该需要遵守 `Copyable`,而且它应该在调用 `copy` 函数时请求一份复制。不过,编译器应当像在处理不可复制类型那样,在任何隐式复制发生时给出诊断信息。 ## 实现的优先级 这篇文档陈列了很多工作,我们可以将其总结如下: - 强制独占性原则: - 静态强制 - 动态强制 - 动态强制的优化 - 新的标注和声明: - `shared` 参数 - `consuming` 方法 - 本地 `shared` 和 `inout` 声明 - 新的固有函数和它们的区别: - `move` 函数及其关联的影响 - `endScope` 函数及其关联的影响 - 协程特性: - 通用的访问方法 - 生成器 - 不可复制类型 - 未来的设计工作 - 不可复制类型的区别 - `moveonly` 上下文 在接下来的版本中,最主要的目的是 ABI 稳定。对于这些特性的优先级划分和分析必须以它们对 ABI 的影响为中心。在将这一点纳入考虑后,我们主要对 ABI 方面有如下思考: 独占性原则将会改变对参数作出的保证,因此它将影响 ABI。我们必须在 ABI 锁定之前将这条规则纳入到语言中,否则我们将永远失去改变这个保守假设的机会。不过,除非我们打算将一部分工作放到运行时去做,否则具体的实现独占性原则的方式并不会对 ABI 产生影响。况且将部分工作放到运行时并不是必要的,它在未来的发布版本中也可以被改变。(另外需要说明,在技术上独占性原则可能给优化器造成重大的影响,但是这应该是一个普通的项目进程上的考虑,而不会影响到 ABI。) > 译者注:Swift 的 ABI 稳定是一个提了有两年的议题了。现在看来,Swift 4 中 ABI 稳定依然无法达成,也就是说不同 Swift 编译器编译出的二进制并不能互相通用 (举例来说,就是新版本 Swift 的 app 不能调用旧版本的 Swift 框架)。如果没有 ABI 稳定,Swift app 就还是必须包含 Swift 运行库的复制,我们也不可能使用二进制的框架。Apple 当前内部 app 和自己的框架几乎都不是 Swift 版本的,也在很大程度上受到 Swift ABI 稳定性的限制。 标准库会积极地在参数上适配所有权标注。那些标注肯定会影响这些库的 ABI。库开发者需要时间来进行适配,更重要的是,它们需要一些方式来验证标注是有用的。不幸的是,用来验证的最好的方法是实现不可复制的类型,而这在优先级列表上是排名很低的任务。 通用的访问方法所需要的工作包括,将“最通用”的属性和下标标准访问方法从 `get`/`set`/`materializeForSet` 变更为 `read`/`set`/`modify`。这对所有的多态属性和下标访问都会带来 ABI 影响,所以它也必须先做。不过,这个 ABI 的变更可以在不实际将协程方式的访问方法引入 Swift 的前提下完成。重要的只是保证我们所使用的 ABI 在今后能够满足协程的要求。 生成器部分的工作可能会改变核心的集合协议。显然这会影响 ABI。和通用化的访问方法不同,我们绝对需要实现生成器,来让 ABI 满足我们的需求。 不可复制的类型和算法只会影响到标准库中对它们进行适配的范围的 ABI。如果库想要在标准的集合中对它们进行适配和扩展的话,就必须发生在 ABI 稳定之前。 新的本地声明和固有函数不会影响 ABI。(和大多数情况一下,影响最少的工作往往也是最简单的工作。) 看起来,想在标准库中适配所有权和不可复制类型,会有很多工作要做,但是这对不可复制类型的可用性来说十分重要。如果我们不能创建一个包含不可复制类型的 `Array` 的话,对语言来说,这将会是非常大的限制。 > 译者注:长求总?好的,总结一下全文, > 这个提案提出在今后的 Swift 版本 (极大可能是 Swift 4) 中引入如下变化: > > - 强制的独占性原则,违反该原则的代码将发生错误: > - 如果在静态能检出违反独占性原则,则给出编译错误 > - 如果在运行时动态检出违反独占性原则,则在违反时让代码崩溃 > - 对于不安全指针的情况,独占性原则行为将是未定义 > - 添加 `shared`,`owned` 和 `consuming` 关键字 > - `shared` 用在参数或者声明上,表示不获取值的所有权,以此避免不必要的值复制。 > - `owned` 和 `consuming` 分别用在变量和函数上,表示非 `shared` 方式的调用。 > - 增强访问方法,在 `get` 和 `set` 的基础上,添加 `read` 和 `modify` 方法。其中 `read` 对应 `shared` 参数,`modify` 对应 `inout`。 > - 新的迭代方式,可以使用 `shared` 或 `inout` 来规定迭代变量的所有权。作为衍生,我们需要协程的方式进行实现。也就是说,可能会在语言中引入原生的生成器 (generator)。这也是进一步进行异步编程的基础,想要了解更多这方面内容,可以参看我的[另一篇博文](https://onevcat.com/2016/12/concurrency/)。 > - 引入一系列所有权有关的固有函数,如 `move`,`copy` 和 `endScope`。它们为 Swift 的高级用户提供自行进行所有权管理的可能性。 > - 对于不可复制的类型,引入 `moveonly` 来去除默认的可复制假设。 > > 另外,这篇宣言只是一些基础的设想和讨论。也就是说,有些细节,比如关键字的名字或者具体的实现方式还可能有变。不过,Swift 团队想要明确值的所有权的问题的方向应该是不变的。 > > 通过这些新的手段和方式,我们可以厘清所有权,避免复制和进行相关优化,但是这并不意味着作为 Swift 的最终用户的程序员在开发时的写法会发生翻天覆地的变化。不过,理解 Swift 中值的所有权变化,可以让我们对这门语言的设计有更深的了解和思考。 > > 如果你暂时无法理解和接受这些内容,在 Swift 4 发布后,我们应该可以获得更多面向语言的“使用者”的信息和例子,来帮助我们最终作出决定。 URL: https://onevcat.com/2017/02/mailme-app/index.html.md Published At: 2017-02-22 10:05:00 +0900 # 使用邮件来进行信息管理,顺便介绍最近写的一个小 app - Mail Me 距离上一次自己在 App Store 发布个人 app 已经过去了两年多了。这段时间里把精力主要都放在了公司项目和继续进一步的学习中,倒也在日常工作和出书等方面取得了一些进展。个人 app 这块近两年虽然有写一些便捷的效率类应用,但是几次审核都被 Apple 无情拒掉以后,也就安心弄成自用的小工具了。看着自己逐渐发霉的开发者证书,果然觉得还是找时间倒腾点什么比较好。于是就有了现在想要介绍给大家的这个工具,[Mail Me](https://mailmeapp.com) - 一个可以帮助你快速给自己发送邮件的小 app。 ### 基于邮件的信息管理方式 开发这个 app 的想法的来源也算偶然。去年初在 @Swift 大会上发现 [objc.io](https://www.objc.io) 的 [Chris](https://twitter.com/chriseidhof) 有一个很有趣的习惯,他会把我们谈话中提到的重要的事情不停地用邮件的方式发到自己的邮箱里。发送的内容包括像是对某个特性的讨论的结论,或是有疑问还等待确认的问题,甚至对那些他承诺了稍后再去深入研究的内容,也给自己发送了提醒。这样的收集信息的方式其实很有意思,相比与使用像是 To-Do 应用或者某些 GTD 的方式来说,似乎给自己发邮件这件事情并不那么 fashion。但是这将近一年来我也尝试了使用最基础的邮件对突发杂乱信息进行管理,发现效果很好。 最显而易见的好处是,使用邮件管理减轻了每天的信息获取负担。不论是公司业务还是开源项目,或者其他各种联络中,我对电子邮件的使用是绝对依赖的,收件箱也是我每天肯定会去检查好多次的地方。如果使用 To-Do 或者是专业的 GTD 应用,其实相当于给自己增加了额外的信息获取负担,并不高效,而且过程会更复杂。将待办事项通过邮件发给自己,可以在每次检查邮件时都看到,省去了确认和获取的环节,信息也相对更加集中。在事件结束后,及时将完成的事情归档,保持收件箱的干净。整个流程基本和一般的 GTD 思想一致,但是信息上下文不再被割裂,而是和邮件检查一同进行,保证效率。 不仅如此,其实我发现像是知识收集这样的事情用邮件来做也十分合适。我曾经是 Evernote 的粉丝,但是在 Evernote 越来越笨重而且基本没有我希望的功能后,我一直就在寻觅新的笔记软件。其间也尝试过 Simplenote 或者 Dropbox Paper 这类产品,但是总有些这样那样的不舒服。最后我选择了无数据库的方式,简单地用一个文件夹和 .md 文件来管理。这种方式显然是“纯天然”的笔记方式,但是不足也十分明显,那就是笔记内容难以组织和检索。而说到检索,映入脑海的首先就是 Google 这样的搜索引擎。如果我们的知识库能有 Google 这样的强力索引,那么之后的寻找和整理自然也不会是问题。万幸,在 Gmail 中,Google 就提供了一个同样强力的邮件搜索系统给我们使用。另外,邮箱系统强大的规则和分类,也为零散知识的自动归类整理提供了可能。于是我想,为什么不干脆直接用邮件来进行知识管理呢? 在这一年里,我把待办事项和知识管理的工作都交给了邮箱。将电子邮箱作为个人信息的中转和仓库,以发送给自己的邮件的方式来进行信息的管理。一年实践下来,我大概通过邮箱完成了上千的待办和接近 200 多条知识条目。对比起之前的方式,感觉节省下了不少时间。 在实践这种知识管理方法的过程中,最大的痛点在于我找不到一个能帮助我快速给自己发送邮件的客户端。在桌面系统上,macOS 自带的邮件客户端或者各类第三方 app 勉强可堪一用。另外,我也可以用像 Alfred 这类工具编写工作流,让我能迅速捕获信息,给自己发送邮件。但是在 iOS 上就更惨一些:我几乎每次都需要经历打开邮件客户端,等待启动,新建邮件,填写我自己的邮箱等这一系列步骤,才能开始给自己写邮件。每次重复这些无用的操作十分无趣,而且浪费时间。更麻烦的是,很可能你的操作速度赶不上思考或者和别人的交流速度,导致信息遗漏,甚至是打断思考的流程,这样就更得不偿失。而市面上的现有类似 app 功能都过于简单,且常年不更。所以我决定写一个专门给自己发送邮件的 app,以帮助我更好地实践邮件信息管理。 ### 使用 Mail Me 帮助邮件信息管理 这个 app 叫 Mail Me,顾名思义,它做的事情就是**给自己发邮件**。我同时使用 Mail Me 和 iOS 的邮件客户端进行了一些测试,想看看这款 app 能为我节省多少时间。 #### 一般编写 - 两倍速 Mail Me 的基本功能很简单,在配置后它将记下你的邮箱。之后你就可以简单地给自己发送邮件,而不必每次再填写自己的邮箱信息了。因为 Mail Me 是一个轻量级的应用,不存在邮箱客户端启动时代收邮件等工作,启动时间一秒不到。在 Mail Me 极简的界面中,你只需要集中精力编写内容,然后轻点发送,就可以给自己发送一封邮件了。一封提醒我下班后买牛奶回家的邮件从打开 app,输入内容,到发送完毕大约花费了我 8 秒时间。 而在系统邮件客户端中,除了等待打开以外,还需要经历输入发送目标,确认空白主题等一系列操作。就算在有收件人自动补全的情况下,在按下发送按钮时,就已经耗时 16 秒。 另外,除了立即发送以外,Mail Me 还支持延时发送。你可以指定一个未来的时间,Mail Me 将帮你预约邮件发送,这样你就能将邮件看作一个待办事项的提醒了。 #### 通知中心发送 - 五倍速 你也许会说,8 秒和 16 秒,又能有多少差距呢?确实,对于上面的情况,Mail Me 带来的速度优势并不足以产生决定性的影响,也许凑合使用系统邮件未尝不可。但是,通常情况下我们的使用场景是从兜里掏出手机,点亮屏幕,解锁手机,找到 app。加上这一系列操作后,可能使用系统邮箱 app 发送一封给自己的邮件就不是 16 秒,而是 30 秒以上了。如果你没有 Touch ID 帮助解锁的话,就还需要先输入手机密码,则整个过程更慢。 那么,我们来看看从锁屏界面开始,发送同样一封邮件,使用 Mail Me 会比原来慢多少呢? 慢多少?开玩笑吧,当然是会更快!实际的结果是,使用 Mail Me 我只花了 7 秒,比打开 app 进行一般编写的情况还要快上 1 秒 (没错就是启动时间)。在这种情况下 (也是我自己最经常使用的情况) Mail Me 要比打开邮件客户端快出 5 倍有余。 使用 Mail Me 可以直接从通知中心进行编写和发送,这对实践邮件信息管理提供了极大的便利。我相信直接在今日挂件里进行操作,真正做到了没有任何干扰,而是完全专注内容。你不需要在一大堆 app 图标里寻找需要的那个,在发送邮件之前也不会有任何多余动作。 另外,通知中心中还支持直接模板发送。如果你有想要多次发送的邮件,大可将其设置为模板,然后一次点击即可进行发送,更加方便。 #### 从其他 app 内发送 - 十倍速以上 对于知识爆炸的现在,如何快速准确收集知识是十分重要的。Mail Me 对笔记和收集的场景当然也进行了一些对应,除了打开 app 和从通知中心发送,Mail Me 支持从其他 app 内直接发送邮件。比如你可以 Safari 里选择一段文字,并快速将文字内容和网页链接发送给自己;你也可以将通讯录里的联系人通过邮件的方式进行备忘。Mail Me 的快速发送支持几乎所有的文本格式,因此你可以在绝大部分带有分享功能的其他 app 中使用 Mail Me 给自己发送邮件。 我现在已经很难想像再用传统的邮件客户端完成这件事情了。我需要经历复制,退出当前 app,打开邮件客户端,新建邮件,输入自己的邮箱,选中文本编辑并长按,粘贴,最后发送这一系列噩梦。如果我想要保留一个出处的链接以供之后参考,我还需要再切回 Safari,复制地址,再切回邮件...大概这么折腾一下以后,当初收集知识的心已经完全被磨灭了吧... #### 其他的注意事项 单独使用 Mail Me 来进行信息的管理已经可以节省不少时间,不过如果想要发挥这套邮件信息管理的全部潜力,则必须配合合适的邮箱规则。你可以对来自 Mail Me 的邮件进行归类,设置一些关键字或者按邮件标题分到不同的邮箱和邮箱文件夹中。如果你使用的邮箱是像 Gmail 那样拥有强大的索引和搜索功能的话,假以时日,建立一套完整的个人信息管理体制将轻而易举。 另外,在收集之后整理时,善用转发和回复,也可以将还不完善原件进行补充。而使用对话方式呈现的邮件及回复,可以说天然就是带有版本管理和 log 记录的信息流。这也有助于帮你回忆起所收集信息的前因后果。 ### 总之 我花了一段时间尝试用邮件管理知识和待办,觉得很棒,而且现在也坚持在这样做。但是之前自己在 iPhone 上缺一个顺手的工具,所以就花空余时间撸了一个。然后估算了一下节省下来的时间,经过搜索,过去快一年间我大概给自己发送了 2000 封邮件,其中 1100 多个待办,200 多个知识条目,剩下 700 多是杂七杂八的普通邮件。那么,**如果使用 Mail Me,相比下来节省的时间至少有 41500 秒** (700 * (16 - 8) + 1100 * (30 - 7) + 200 * (60 - 7)),或者说 **11.5 小时**。嗯...Mail Me 这个 app 的总开发时间可能都没有 11.5 小时,为什么我没有早一点想到写一个 app 来干这事儿呢。XD 不多说了,你可以在 [Mail Me 的官方网站](https://mailmeapp.com)找到一些其他信息,也可以直奔 [App Store 免费下载](https://itunes.apple.com/app/id1196289723?ls=1&mt=8)这款应用。如果你对用邮件管理个人信息的方式感兴趣,并且想尝试一下的话,不妨可以试试 Mail Me 这款 app。(我在 app 里“无耻”地加了一个广告和内购,也希望能够把每年的开发者证书钱垫一下。) 另外,我计划在之后拿出一篇博文来简单说说在开发 Mail Me 的过程中值得一提的技术方面的一些小技巧,也欢迎大家继续关注。 那么,是时候给自己发封邮件提醒自己要好好休息一下了。 URL: https://onevcat.com/2016/12/concurrency/index.html.md Published At: 2016-12-20 12:53:11 +0900 # Swift 并发编程现状和展望 - async/await 和参与者模式 > 这篇文章不是针对当前版本 Swift 3 的,而是对预计于 2018 年发布的 Swift 5 的一些特性的猜想。如果两年后我还记得这篇文章,可能会回来更新一波。在此之前,请当作一篇对现代语言并发编程特性的不太严谨科普文来看待。 CPU 速度已经很多年没有大的突破了,硬件行业更多地将重点放在多核心技术上,而与之对应,软件中并发编程的概念也越来越重要。如何利用多核心 CPU,以及拥有密集计算单元的 GPU,来进行快速的处理和计算,是很多开发者十分感兴趣的事情。在今年年初 Swift 4 的展望中,Swift 项目的负责人 Chris Lattern 表示可能并不会这么快提供语言层级的并发编程支持,不过最近 Chris 又在 IBM 的一次关于[编译器的分享](http://researcher.watson.ibm.com/researcher/files/us-lmandel/lattner.pdf)中明确提到,有很大可能会在 Swift 5 中添加语言级别的并发特性。 ![](/assets/images/2016/chris.jpg) 这对 Swift 生态是一个好消息,也是一个大消息。不过这其实并不是什么新鲜的事情,甚至可以说是一门现代语言发展的必经路径和必备特性。因为 Objective-C/Swift 现在缺乏这方面的内容,所以很多专注于 iOS 的开发者对并发编程会很陌生。我在这篇文章里结合 Swift 现状简单介绍了一些这门语言里并发编程可能的使用方式,希望能帮助大家初窥门径。(虽然我自己也还摸不到门径在何方...) ## Swift 现有的并发模型 Swift 现在没有语言层面的并发机制,不过我们确实有一些基于库的线程调度的方案,来进行并发操作。 ### 基于闭包的线程调度 虽然恍如隔世,不过 GCD (Grand Central Dispatch) 确实是从 iOS 4 才开始走进我们的视野的。在 GCD 和 block 被加入之前,我们想要新开一个线程需要用到 `NSThread` 或者 `NSOperation`,然后使用 delegate 的方式来接收回调。这种书写方式太过古老,也相当麻烦,容易出错。GCD 为我们带来了一套很简单的 API,可以让我们在线程中进行调度。在很长一段时间里,这套 API 成为了 iOS 中多线程编程的主流方式。Swift 继承了这套 API,并且在 Swift 3 中将它们重新导入为了更符合 Swift 语法习惯的形式。现在我们可以将一个操作很容易地派发到后台进行,首先创建一个后台队列,然后调用 `async` 并传入需要执行的闭包即可: ```swift let backgroundQueue = DispatchQueue(label: "com.onevcat.concurrency.backgroundQueue") backgroundQueue.async { let result = 1 + 2 } ``` 在 `async` 的闭包中,我们还可以继续进行派发,最常见的用法就是开一个后台线程进行耗时操作 (从网络获取数据,或者 I/O 等),然后在数据准备完成后,回到主线程更新 UI: ```swift let backgroundQueue = DispatchQueue(label: "com.onevcat.concurrency.backgroundQueue") backgroundQueue.async { let url = URL(string: "https://api.onevcat.com/users/onevcat")! guard let data = try? Data(contentsOf: url) else { return } let user = User(data: data) DispatchQueue.main.async { self.userView.nameLabel.text = user.name // ... } } ``` 当然,现在估计已经不会有人再这么做网络请求了。我们可以使用专门的 `URLSession` 来进行访问。`URLSession` 和对应的 `dataTask` 会将网络请求派发到后台线程,我们不再需要显式对其指定。不过更新 UI 的工作还是需要回到主线程: ```swift let url = URL(string: "https://api.onevcat.com/users/onevcat")! URLSession.shared.dataTask(with: url) { (data, res, err) in guard let data = try? Data(contentsOf: url) else { return } let user = User(data: data) DispatchQueue.main.async { self.userView.nameLabel.text = user.name // ... } }.resume() ``` ### 回调地狱 基于闭包模型的方式,不论是直接派发还是通过 `URLSession` 的封装进行操作,都面临一个严重的问题。这个问题最早在 JavaScript 中臭名昭著,那就是回调地狱 (callback hell)。 试想一下我们如果有一系列需要依次进行的网络操作:先进行登录,然后使用返回的 token 获取用户信息,接下来通过用户 ID 获取好友列表,最后对某个好友点赞。使用传统的闭包方式,这段代码会是这样: ```swift LoginRequest(userName: "onevcat", password: "123").send() { token, err in if let token = token { UserProfileRequest(token: token).send() { user, err in if let user = user { GetFriendListRequest(user: user).send() { friends, err in if let friends = friends { LikeFriendRequest(target: friends.first).send() { result, err in if let result = result, result { print("Success") self.updateUI() } } else { print("Error: \(err)") } } else { print("Error: \(err)") } } } else { print("Error: \(err)") } } } else { print("Error: \(err)") } } ``` 这已经是使用了尾随闭包特性简化后的代码了,如果使用完整的闭包形式的话,你会看到一大堆 `})` 堆叠起来。`else` 路径上几乎不可能确定对应关系,而对于成功的代码路径来说,你也需要很多额外的精力来理解这些代码。一旦这种基于闭包的回调太多,并嵌套起来,阅读它们的时候就好似身陷地狱。 ![](/assets/images/2016/confused.jpg) 不幸的是,在 Cocoa 框架中我们似乎对此没太多好办法。不过我们确实有很多方法来解决回调地狱的问题,其中最成功的应该是 Promise 或者 Future 的方案。 ### Promise/Future 在深入 Promise 或 Future 之前,我们先来将上面的回调做一些整理。可以看到,所有的请求在回调时都包含了两个输入值,一个是像 `token`,`user` 这样我们接下来会使用到的结果,另一个是代表错误的 `err`。我们可以创建一个泛型类型来代表它们: ```swift enum Result { case success(T) case failure(Error) } ``` 重构 `send` 方法接收的回调类型后,上面的 API 调用就可以变为: ```swift LoginRequest(userName: "onevcat", password: "123").send() { result in switch result { case .success(let token): UserProfileRequest(token: token).send() { result in switch result { case .success(let user): // ... case .failure(let error): print("Error: \(error)") } } case .failure(let error): print("Error: \(error)") } } ``` 看起来并没有什么改善,对么?我们只不过使用一堆 `({})` 的地狱换成了 `switch...case` 的地狱。但是,我们如果将 request 包装一下,情况就会完全不同。 ```swift struct Promise { init(resolvers: (_ fulfill: @escaping (T) -> Void, _ reject: @escaping (Error) -> Void) -> Void) { //... // 存储 fulfill 和 reject。 // 当 fulfill 被调用时解析为 then;当 reject 被调用时解析为 error。 } // 存储的 then 方法,调用者提供的参数闭包将在 fulfill 时调用 func then(_ body: (T) -> U) -> Promise { return Promise{ //... } } // 调用者提供该方法,参数闭包当 reject 时调用 func `catch`(_ body: (Error) -> Void) { //... } } extension Request { var promise: Promise { return Promise { fulfill, reject in self.send() { result in switch result { case .success(let r): fulfill(r) case .failure(let e): reject(e) } } } } } ``` 我们这里没有给出 `Promise` 的具体实现,而只是给出了概念性的说明。`Promise` 是一个泛型类型,它的初始化方法接受一个以 `fulfill` 和 `reject` 作为参数的函数作为参数 (一开始这可能有点拗口,你可以结合代码再读一次)。这个类型里还提供了 `then` 和 `catch` 方法,`then` 方法的参数是另一个闭包,在 `fulfill` 被调用时,我们可以执行这个闭包,并返回新的 `Promise` (之后会看到具体的使用例子):而在 `reject` 被调用时,通过 `catch` 方法中断这个过程。 在接下来的 `Request` 的扩展中,我们定义了一个返回 `Promise` 的计算属性,它将初始化一个内容类型为 `Response` 的 `Promise` (这里的 `Response` 是定义在 `Request` 协议中的代表该请求对应的响应的类型,想了解更多相关的内容,可以看看我之前的一篇[使用面向协议编程](/2016/12/pop-cocoa-2/)的文章)。我们在 `.success` 时调用 `fulfill`,在 `.failure` 时调用 `reject`。 现在,上面的回调地狱可以用 `then` 和 `catch` 的形式进行展平了: ```swift LoginRequest(userName: "onevcat", password: "123").promise .then { token in return UserProfileRequest(token: token).promise }.then { user in return GetFriendListRequest(user: user).promise }.then { friends in return LikeFriendRequest(target: friends.first).promise }.then { _ in print("Succeed!") self.updateUI() // 我们这里还需要在 Promise 中添加一个无返回的 then 的重载 // 篇幅有限,略过 // ... }.catch { error in print("Error: \(error)") } ``` `Promise` 本质上就是一个对闭包或者说 `Result` 类型的封装,它将未来可能的结果所对应的闭包先存储起来,然后当确实得到结果 (比如网络请求返回) 的时候,再执行对应的闭包。通过使用 `then`,我们可以避免闭包的重叠嵌套,而是使用调用链的方式将异步操作串接起来。`Future` 和 `Promise` 其实是同样思想的不同命名,两者基本指代的是一件事儿。在 Swift 中,有一些封装得很好的第三方库,可以让我们以这样的方式来书写代码,[PromiseKit](https://github.com/mxcl/PromiseKit) 和 [BrightFutures](https://github.com/Thomvis/BrightFutures) 就是其中的佼佼者,它们确实能帮助避免回调地狱的问题,让嵌套的异步代码变得整洁。 ![](/assets/images/2016/future.jpg) ## async/await,“串行”模式的异步编程 虽然 Promise/Future 的方式能解决一部分问题,但是我们看看上面的代码,依然有不少问题。 1. 我们用了很多并不直观的操作,对于每个 request,我们都生成了额外的 `Promise`,并用 `then` 串联。这些其实都是模板代码,应该可以被更好地解决。 2. 各个 `then` 闭包中的值只在自己固定的作用域中有效,这有时候很不方便。比如如果我们的 `LikeFriend` 请求需要同时发送当前用户的 token 的话,我们只能在最外层添加临时变量来持有这些结果: ```swift var myToken: String = "" LoginRequest(userName: "onevcat", password: "123").promise .then { token in myToken = token return UserProfileRequest(token: token).promise } //... .then { print("Token is \(myToken)") // ... } ``` 3. Swift 内建的 throw 的错误处理方式并不能很好地和这里的 `Result` 和 `catch { error in ... }` 的方式合作。Swift throw 是一种同步的错误处理方式,如果想要在异步世界中使用这种的话,会显得格格不入。语法上有不少理解的困难,代码也会迅速变得十分丑陋。 如果从语言层面着手的话,这些问题都是可以被解决的。如果对微软技术栈有所关心的同学应该知道,早在 2012 年 C# 5.0 发布时,就包含了一个让业界惊为天人的特性,那就是 `async` 和 `await` 关键字。这两个关键字可以让我们用类似同步的书写方式来写异步代码,这让思维模型变得十分简单。Swift 5 中有望引入类似的语法结构,如果我们有 async/await,我们上面的例子将会变成这样的形式: ```swift @IBAction func bunttonPressed(_ sender: Any?) { // 1 doSomething() print("Button Pressed") } // 2 async func doSomething() { print("Doing something...") do { // 3 let token = await LoginRequest(userName: "onevcat", password: "123").sendAsync() let user = await UserProfileRequest(token: token).sendAsync() let friends = await GetFriendListRequest(user: user).sendAsync() let result = await LikeFriendRequest(target: friends.first).sendAsync() print("Finished") // 4 updateUI() } catch ... { // 5 //... } } extension Request { // 6 async func sendAsync() -> Response { let dataTask = ... let data = await dataTask.resumeAsync() return Response.parse(data: data) } } ``` > 注意,以上代码是根据现在 Swift 语法,对如果存在 `async` 和 `await` 时语言的形式的推测。虽然这不代表今后 Swift 中异步编程模型就是这样,或者说 `async` 和 `await` 就是这样使用,但是应该代表了一个被其他语言验证过的可行方向。 按照注释的编号,进行一些简单的说明: 1. 这就是我们通常的 `@IBAction`,点击后执行 `doSomething`。 2. `doSomething` 被 `async` 关键字修饰,表示这是一个异步方法。`async` 关键字所做的事情只有一件,那就是允许在这个方法内使用 `await` 关键字来等待一个长时间操作完成。在这个方法里的语句将被以同步方式执行,直到遇到第一个 `await`。控制台将会打印 "Doing something..."。 3. 遇到的第一个 await。此时这个 `doSomething` 方法将进入等待状态,该方法将会“返回”,也即离开栈域。接下来 `bunttonPressed` 中 `doSomething` 调用之后的语句将被执行,控制台打印 "Button Pressed"。 4. `token`,`user`,`friends` 和 `result` 将被依次 `await` 执行,直到获得最终结果,并进行 `updateUI`。 5. 理论上 `await` 关键字在语义上应该包含 `throws`,所以我们需要将它们包裹在 `do...catch` 中,而且可以使用 Swift 内建的异常处理机制来对请求操作中发生的错误进行捕获和处理。换句话说,我们如果对错误不感兴趣,也可以使用类似 `try?` 和 `try!` 的 6. 对于 `Request`,我们需要添加 `async` 版本的发送请求的方法。`dataTask` 的 `resumeAsync` 方法是在 Foundation 中针对内建异步编程所重写的版本。我们在此等待它的结果,然后将结果解析为 model 后返回。 我们上面已经说过,可以将 `Promise` 看作是对 `Result` 的封装,而这里我们依然可以类比进行理解,将 `async` 看作是对 `Promise` 的封装。对于 `sendAsync` 方法,我们完全可以将它理解返回 `Promise`,只不过配合 `await`,这个 `Promise` 将直接以同步的方式被解包为结果。(或者说,`await` 是这样一个关键字,它可以等待 `Promise` 完成,并获取它的结果。) ```swift func sendAsync() throws -> Promise { // ... } // await request.sendAsync() // doABC() // 等价于 (try request.sendAsync()).then { // doABC() } ``` 不仅在网络请求中可以使用,对于所有的 I/O 操作,Cocoa 应当也会提供一套对应的异步 API。甚至于对于等待用户操作和输入,或者等待某个动画的结束,都是可以使用 `async/await` 的潜在场景。如果你对响应式编程有所了解的话,不难发现,其实响应式编程想要解决的就是异步代码难以维护的问题,而在使用 `async/await` 后,部分的异步代码可以变为以同步形式书写,这会让代码书写起来简单很多。 Swift 的 `async` 和 `await` 很可能将会是基于 [Coroutine](https://en.wikipedia.org/wiki/Coroutine) 进行实现的。不过也有可能和 C# 类似,编译器通过将 `async` 和 `await` 的代码编译为带有状态机的片段,并进行调度。Swift 5 的预计发布时间会是 2018 年底,所以现在谈论这些技术细节可能还为时过早。 ## 参与者 (actor) 模型 讲了半天 `async` 和 `await`,它们所要解决的是异步编程的问题。而从异步编程到并发编程,我们还需要一步,那就是将多个异步操作组织起来同时进行。当然,我们可以简单地同时调用多个 `async` 方法来进行并发运算,或者是使用某些像是 GCD 里 `group` 之类的特殊语法来将复数个 `async` 打包放在一起进行调用。但是不论何种方式,都会面临一个问题,那就是这套方式使用的是命令式 (imperative) 的语法,而非描述性的 (declarative),这将导致扩展起来相对困难。 并发编程相对复杂,而且与人类天生的思考方式相违背,所以我们希望尽可能让并发编程的模型保持简单,同时避免直接与线程或者调度这类事务打交道。基于这些考虑,Swift 很可能会参考 [Erlang](http://www.erlang.org) 和 [AKKA](http://akka.io) 中已经很成功的参与者模型 (actor model) 的方式实现并发编程,这样开发者将可以使用默认的分布式方式和描述性的语言来进行并发任务。 所谓参与者,是一种程序上的抽象概念,它被视为并发运算的基本单元。参与者能做的事情就是接收消息,并且基于收到的消息做某种运算。这和面向对象的想法有相似之处,一个对象也接收消息 (或者说,接受方法调用),并且根据消息 (被调用的方法) 作出响应。它们之间最大的不同在于,参与者之间永远相互隔离,它们不会共享某块内存。一个参与者中的状态永远是私有的,它不能被另一个参与者改变。 和面向对象世界中“万物皆对象”的思想相同,参与者模式里,所有的东西也都是参与者。单个的参与者能力十分有限,不过我们可以创建一个参与者的“管理者”,或者叫做 actor system,它在接收到特定消息时可以创建新的参与者,并向它们发送消息。这些新的参与者将实际负责运算或者操作,在接到消息后根据自身的内部状态进行工作。在 Swift 5 中,可能会用下面的方式来定义一个参与者: ```swift // 1 struct Message { let target: String } // 2 actor NetworkRequestHandler { var localState: UserID async func processRequest(connection: Connection) { // ... // 在这里你可以 await 一个耗时操作 // 并改变 `localState` 或者向 system 发消息 } // 3 message { Message(let m): processRequest(connection: Connection(m.target)) } } // 4 let system = ActorSystem(identifier: "MySystem") let actor = system.actorOf() actor.tell(Message(target: "https://onevcat.com")) ``` > 再次注意,这些代码只是对 Swift 5 中可能出现的参与者模式的一种猜想。最后的实现肯定会和这有所区别。不过如果 Swift 中要加入参与者,应该会和这里的表述类似。 1. 这里的 `Message` 是我们定义的消息类型。 2. 使用 `actor` 关键字来定义一个参与者模型,它其中包含了内部状态和异步操作,以及一个隐式的操作队列。 3. 定义了这个 actor 需要接收的消息和需要作出的响应。 4. 创建了一个 actor system (`ActorSystem` 这里没有给出实现,可能会包含在 Swift 标准库中)。然后创建了一个 `NetworkRequestHandler` 参与者,并向它发送一条消息。 这个参与者封装了一个异步方法以及一个内部状态,另外,因为该参与者会使用一个自己的 DispatchQueue 以避免和其他线程共享状态。通过 actor system 进行创建,并在接收到某个消息后执行异步的运算方法,我们就可以很容易地写出并发处理的代码,而不必关心它们的内部状态和调度问题了。现在,你可以通过 `ActorSystem` 来创建很多参与者,然后发送不同消息给它们,并进行各自的操作。并发编程变得前所未有的简单。 参与者模式相比于传统的自己调度有两个显著的优点: 首先,因为参与者之间的通讯是消息发送,这意味着并发运算不必被局限在一个进程里,甚至不必局限在一台设备里。只要保证消息能够被发送 (比如使用 [IPC](https://en.wikipedia.org/wiki/Inter-process_communication) 或者 [DMA](https://en.wikipedia.org/wiki/Direct_memory_access)),你就完全可以使用分布式的方式,使用多种设备 (多台电脑,或者多个 GPU) 进行并发操作,这带来的是无限可能的扩展性。 另外,由于参与者之间可以发送消息,那些操作发生异常的参与者有机会通知 system 自己的状态,而 actor system 也可以根据这个状态来重置这些出问题的参与者,或者甚至是无视它们并创建新的参与者继续任务。这使得整个参与者系统拥有“自愈”的能力,在传统并发编程中想要处理这件事情是非常困难的,而参与者模型的系统得益于此,可以最大限度保障系统的稳定性。 ## 这些东西有什么用 两年下来,Swift 已经证明了自己是一门非常优秀的 app 语言。即使 Xcode 每日虐我千百遍,但是现在让我回去写 Objective-C 的话,我从内心是绝对抗拒的。Swift 的野心不仅于此,从 Swift 的开源和进化方向,我们很容易看出这门语言希望在服务器端也有所建树。而内建的异步支持以及参与者模式的并发编程,无疑会为 Swift 在服务器端的运用添加厚重的砝码。异步模型对写 app 也会有所帮助,更简化的控制流程以及隐藏起来的线程切换,会让我们写出更加简明优雅的代码。 C# 的 async/await 曾经为开发者们带来一股清流,Elixir 或者说 Erlang 可以说是世界上最优秀的并发编程语言,JVM 上的 AKKA 也正在支撑着无数的亿级服务。我很好奇当 Swift 遇到这一切的时候,它们之间的化学反应会迸发出怎样的火花。虽然每天还在 Swift 3 的世界中挣扎,但是我想我的心已经飞跃到 Swift 5 的并发世界中去了。 URL: https://onevcat.com/2016/12/pop-cocoa-2/index.html.md Published At: 2016-12-01 11:22:11 +0900 # 面向协议编程与 Cocoa 的邂逅 (下) 本文是笔者在 MDCC 16 (移动开发者大会) 上 iOS 专场中的主题演讲的文字整理。您可以在[这里](https://speakerdeck.com/onevcat/mian-xiang-xie-yi-bian-cheng-yu-cocoa-de-xie-hou)找到演讲使用的 Keynote,部分示例代码可以在 MDCC 2016 的[官方 repo](https://github.com/MDCC2016/ProtocolNetwork) 中找到。 在[上半部分][previous]主要介绍了一些理论方面的内容,包括面向对象编程存在的问题,面向协议的基本概念和决策模型等。本文 (下) 主要展示了一些笔者日常使用面向协议思想和 Cocoa 开发结合的示例代码,并对其进行了一些解说。 ## 转・热恋 - 在日常开发中使用协议 WWDC 2015 在 POP 方面有一个非常优秀的主题演讲:[#408 Protocol-Oriented Programming in Swift](https://developer.apple.com/videos/play/wwdc2015/408/)。Apple 的工程师通过举了画图表和排序两个例子,来阐释 POP 的思想。我们可以使用 POP 来解耦,通过组合的方式让代码有更好的重用性。不过在 #408 中,涉及的内容偏向理论,而我们每天的 app 开发更多的面临的还是和 Cocoa 框架打交道。在看过 #408 以后,我们就一直在思考,如何把 POP 的思想运用到日常的开发中? 我们在这个部分会举一个实际的例子,来看看 POP 是如何帮助我们写出更好的代码的。 ### 基于 Protocol 的网络请求 网络请求层是实践 POP 的一个理想场所。我们在接下的例子中将从零开始,用最简单的面向协议的方式先构建一个不那么完美的网络请求和模型层,它可能包含一些不合理的设计和耦合,但是却是初步最容易得到的结果。然后我们将逐步捋清各部分的所属,并用分离职责的方式来进行重构。最后我们会为这个网络请求层进行测试。通过这个例子,我希望能够设计出包括类型安全,解耦合,易于测试和良好的扩展性等诸多优秀特性在内的 POP 代码。 > Talk is cheap, show me the code. #### 初步实现 首先,我们想要做的事情是从一个 API 请求一个 JSON,然后将它转换为 Swift 中可用的实例。作为例子的 API 非常简单,你可以直接访问 [https://api.onevcat.com/users/onevcat](https://api.onevcat.com/users/onevcat) 来查看返回: ```js {"name":"onevcat","message":"Welcome to MDCC 16!"} ``` 我们可以新建一个项目,并添加 `User.swift` 来作为模型: ```swift // User.swift import Foundation struct User { let name: String let message: String init?(data: Data) { guard let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return nil } guard let name = obj?["name"] as? String else { return nil } guard let message = obj?["message"] as? String else { return nil } self.name = name self.message = message } } ``` `User.init(data:)` 将输入的数据 (从网络请求 API 获取) 解析为 JSON 对象,然后从中取出 `name` 和 `message`,并构建代表 API 返回的 `User` 实例,非常简单。 现在让我们来看看有趣的部分,也就是如何使用 POP 的方式从 URL 请求数据,并生成对应的 `User`。首先,我们可以创建一个 protocol 来代表请求。对于一个请求,我们需要知道它的请求路径,HTTP 方法,所需要的参数等信息。一开始这个协议可能是这样的: ```swift enum HTTPMethod: String { case GET case POST } protocol Request { var host: String { get } var path: String { get } var method: HTTPMethod { get } var parameter: [String: Any] { get } } ``` 将 `host` 和 `path` 拼接起来可以得到我们需要请求的 API 地址。为了简化,`HTTPMethod` 现在只包含了 `GET` 和 `POST` 两种请求方式,而在我们的例子中,我们只会使用到 `GET` 请求。 现在,可以新建一个 `UserRequest` 来实现 `Request` 协议: ```swift struct UserRequest: Request { let name: String let host = "https://api.onevcat.com" var path: String { return "/users/\(name)" } let method: HTTPMethod = .GET let parameter: [String: Any] = [:] } ``` `UserRequest` 中有一个未定义初始值的 `name` 属性,其他的属性都是为了满足协议所定义的。因为请求的参数用户名 `name` 会通过 URL 进行传递,所以 `parameter` 是一个空字典就足够了。有了协议定义和一个满足定义的具体请求,现在我们需要发送请求。为了任意请求都可以通过同样的方法发送,我们将发送的方法定义在 `Request` 协议扩展上: ```swift extension Request { func send(handler: @escaping (User?) -> Void) { // ... send 的实现 } } ``` 在 `send(handler:)` 的参数中,我们定义了可逃逸的 `(User?) -> Void`,在请求完成后,我们调用这个 `handler` 方法来通知调用者请求是否完成,如果一切正常,则将一个 `User` 实例传回,否则传回 `nil`。 我们想要这个 `send` 方法对于所有的 `Request` 都通用,所以显然回调的参数类型不能是 `User`。通过在 `Request` 协议中添加一个关联类型,我们可以将回调参数进行抽象。在 `Request` 最后添加: ```swift protocol Request { ... associatedtype Response } ``` 然后在 `UserRequest` 中,我们也相应地添加类型定义,以满足协议: ```swift struct UserRequest: Request { ... typealias Response = User } ``` 现在,我们来重新实现 `send` 方法,现在,我们可以用 `Response` 代替具体的 `User`,让 `send` 一般化。我们这里使用 `URLSession` 来发送请求: ```swift extension Request { func send(handler: @escaping (Response?) -> Void) { let url = URL(string: host.appending(path))! var request = URLRequest(url: url) request.httpMethod = method.rawValue // 在示例中我们不需要 `httpBody`,实践中可能需要将 parameter 转为 data // request.httpBody = ... let task = URLSession.shared.dataTask(with: request) { data, res, error in // 处理结果 print(data) } task.resume() } } ``` 通过拼接 `host` 和 `path`,可以得到 API 的 entry point。根据这个 URL 创建请求,进行配置,生成 data task 并将请求发送。剩下的工作就是将回调中的 `data` 转换为合适的对象类型,并调用 `handler` 通知外部调用者了。对于 `User` 我们知道可以使用 `User.init(data:)`,但是对于一般的 `Response`,我们还不知道要如何将数据转为模型。我们可以在 `Request` 里再定义一个 `parse(data:)` 方法,来要求满足该协议的具体类型提供合适的实现。这样一来,提供转换方法的任务就被“下放”到了 `UserRequest`: ```swift protocol Request { ... associatedtype Response func parse(data: Data) -> Response? } struct UserRequest: Request { ... typealias Response = User func parse(data: Data) -> User? { return User(data: data) } } ``` 有了将 `data` 转换为 `Response` 的方法后,我们就可以对请求的结果进行处理了: ```swift extension Request { func send(handler: @escaping (Response?) -> Void) { let url = URL(string: host.appending(path))! var request = URLRequest(url: url) request.httpMethod = method.rawValue // 在示例中我们不需要 `httpBody`,实践中可能需要将 parameter 转为 data // request.httpBody = ... let task = URLSession.shared.dataTask(with: request) { data, _, error in if let data = data, let res = parse(data: data) { DispatchQueue.main.async { handler(res) } } else { DispatchQueue.main.async { handler(nil) } } } task.resume() } } ``` 现在,我们来试试看请求一下这个 API: ```swift let request = UserRequest(name: "onevcat") request.send { user in if let user = user { print("\(user.message) from \(user.name)") } } // Welcome to MDCC 16! from onevcat ``` #### 重构,关注点分离 虽然能够实现需求,但是上面的实现可以说非常糟糕。让我们看看现在 `Request` 的定义和扩展: ```swift protocol Request { var host: String { get } var path: String { get } var method: HTTPMethod { get } var parameter: [String: Any] { get } associatedtype Response func parse(data: Data) -> Response? } extension Request { func send(handler: @escaping (Response?) -> Void) { ... } } ``` 这里最大的问题在于,`Request` 管理了太多的东西。一个 `Request` 应该做的事情应该仅仅是定义请求入口和期望的响应类型,而现在 `Request` 不光定义了 `host` 的值,还对如何解析数据了如指掌。最后 `send` 方法被绑死在了 `URLSession` 的实现上,而且是作为 `Request` 的一部分存在。这是很不合理的,因为这意味着我们无法在不更改请求的情况下更新发送请求的方式,它们被耦合在了一起。这样的结构让测试变得异常困难,我们可能需要通过 stub 和 mock 的方式对请求拦截,然后返回构造的数据,这会用到 `NSURLProtocol` 的内容,或者是引入一些第三方的测试框架,大大增加了项目的复杂度。在 Objective-C 时期这可能是一个可选项,但是在 Swift 的新时代,我们有好得多的方法来处理这件事情。 让我们开始着手重构刚才的代码,并为它们加上测试吧。首先我们将 `send(handler:)` 从 `Request` 分离出来。我们需要一个单独的类型来负责发送请求。这里基于 POP 的开发方式,我们从定义一个可以发送请求的协议开始: ```swift protocol Client { func send(_ r: Request, handler: @escaping (Request.Response?) -> Void) } // 编译错误 ``` 从上面的声明从语义上来说是挺明确的,但是因为 `Request` 是含有关联类型的协议,所以它并不能作为独立的类型来使用,我们只能够将它作为类型约束,来限制输入参数 `request`。正确的声明方式应当是: ```swift protocol Client { func send(_ r: T, handler: @escaping (T.Response?) -> Void) var host: String { get } } ``` 除了使用 `` 这个泛型方式以外,我们还将 `host` 从 `Request` 移动到了 `Client` 里,这是更适合它的地方。现在,我们可以把含有 `send` 的 `Request` 协议扩展删除,重新创建一个类型来满足 `Client` 了。和之前一样,它将使用 `URLSession` 来发送请求: ```swift struct URLSessionClient: Client { let host = "https://api.onevcat.com" func send(_ r: T, handler: @escaping (T.Response?) -> Void) { let url = URL(string: host.appending(r.path))! var request = URLRequest(url: url) request.httpMethod = r.method.rawValue let task = URLSession.shared.dataTask(with: request) { data, _, error in if let data = data, let res = r.parse(data: data) { DispatchQueue.main.async { handler(res) } } else { DispatchQueue.main.async { handler(nil) } } } task.resume() } } ``` 现在发送请求的部分和请求本身分离开了,而且我们使用协议的方式定义了 `Client`。除了 `URLSessionClient` 以外,我们还可以使用任意的类型来满足这个协议,并发送请求。这样网络层的具体实现和请求本身就不再相关了,我们之后在测试的时候会进一步看到这么做所带来的好处。 现在这个的实现里还有一个问题,那就是 `Request` 的 `parse` 方法。请求不应该也不需要知道如何解析得到的数据,这项工作应该交给 `Response` 来做。而现在我们没有对 `Response` 进行任何限定。接下来我们将新增一个协议,满足这个协议的类型将知道如何将一个 `data` 转换为实际的类型: ```swift protocol Decodable { static func parse(data: Data) -> Self? } ``` `Decodable` 定义了一个静态的 `parse` 方法,现在我们需要在 `Request` 的 `Response` 关联类型中为它加上这个限制,这样我们可以保证所有的 `Response` 都可以对数据进行解析,原来 `Request` 中的 `parse` 声明也就可以移除了: ```swift // 最终的 Request 协议 protocol Request { var path: String { get } var method: HTTPMethod { get } var parameter: [String: Any] { get } // associatedtype Response // func parse(data: Data) -> Response? associatedtype Response: Decodable } ``` 最后要做的就是让 `User` 满足 `Decodable`,并且修改上面 `URLSessionClient` 的解析部分的代码,让它使用 `Response` 中的 `parse` 方法: ```swift extension User: Decodable { static func parse(data: Data) -> User? { return User(data: data) } } struct URLSessionClient: Client { func send(_ r: T, handler: @escaping (T.Response?) -> Void) { ... // if let data = data, let res = parse(data: data) { if let data = data, let res = T.Response.parse(data: data) { ... } } } ``` 最后,将 `UserRequest` 中不再需要的 `host` 和 `parse` 等清理一下,一个类型安全,解耦合的面向协议的网络层就呈现在我们眼前了。想要调用 `UserRequest` 时,我们可以这样写: ```swift URLSessionClient().send(UserRequest(name: "onevcat")) { user in if let user = user { print("\(user.message) from \(user.name)") } } ``` 当然,你也可以为 `URLSessionClient` 添加一个单例来减少请求时的创建开销,或者为请求添加 Promise 的调用方式等等。在 POP 的组织下,这些改动都很自然,也不会牵扯到请求的其他部分。你可以用和 `UserRequest` 类型相似的方式,为网络层添加其他的 API 请求,只需要定义请求所必要的内容,而不用担心会触及网络方面的具体实现。 #### 网络层测试 将 `Client` 声明为协议给我们带来了额外的好处,那就是我们不在局限于使用某种特定的技术 (比如这里的 `URLSession`) 来实现网络请求。利用 POP,你只是定义了一个发送请求的协议,你可以很容易地使用像是 AFNetworking 或者 Alamofire 这样的成熟的第三方框架来构建具体的数据并处理请求的底层实现。我们甚至可以提供一组“虚假”的对请求的响应,用来进行测试。这和传统的 stub & mock 的方式在概念上是接近的,但是实现起来要简单得多,也明确得多。我们现在来看一看具体应该怎么做。 我们先准备一个文本文件,将它添加到项目的测试 target 中,作为网络请求返回的内容: ```js // 文件名:users:onevcat {"name":"Wei Wang", "message": "hello"} ``` 接下来,可以创建一个新的类型,让它满足 `Client` 协议。但是与 `URLSessionClient` 不同,这个新类型的 `send` 方法并不会实际去创建请求,并发送给服务器。我们在测试时需要验证的是一个请求发出后如果服务器按照文档正确响应,那么我们应该也可以得到正确的模型实例。所以这个新的 `Client` 需要做的事情就是从本地文件中加载定义好的结果,然后验证模型实例是否正确: ```swift struct LocalFileClient: Client { func send(_ r: T, handler: @escaping (T.Response?) -> Void) { switch r.path { case "/users/onevcat": guard let fileURL = Bundle(for: ProtocolNetworkTests.self).url(forResource: "users:onevcat", withExtension: "") else { fatalError() } guard let data = try? Data(contentsOf: fileURL) else { fatalError() } handler(T.Response.parse(data: data)) default: fatalError("Unknown path") } } // 为了满足 `Client` 的要求,实际我们不会发送请求 let host = "" } ``` `LocalFileClient` 做的事情很简单,它先检查输入请求的 `path` 属性,如果是 `/users/onevcat` (也就是我们需要测试的请求),那么就从测试的 bundle 中读取预先定义的文件,将其作为返回结果进行 `parse`,然后调用 `handler`。如果我们需要增加其他请求的测试,可以添加新的 `case` 项。另外,加载本地文件资源的部分应该使用更通用的写法,不过因为我们这里只是示例,就不过多纠结了。 在 `LocalFileClient` 的帮助下,现在可以很容易地对 `UserRequest` 进行测试了: ```swift func testUserRequest() { let client = LocalFileClient() client.send(UserRequest(name: "onevcat")) { user in XCTAssertNotNil(user) XCTAssertEqual(user!.name, "Wei Wang") } } ``` 通过这种方法,我们没有依赖任何第三方测试库,也没有使用 url 代理或者运行时消息转发等等这些复杂的技术,就可以进行请求测试了。保持简单的代码和逻辑,对于项目维护和发展是至关重要的。 #### 可扩展性 因为高度解耦,这种基于 POP 的实现为代码的扩展提供了相对宽松的可能性。我们刚才已经说过,你不必自行去实现一个完整的 `Client`,而可以依赖于现有的网络请求框架,实现请求发送的方法即可。也就是说,你也可以很容易地将某个正在使用的请求方式替换为另外的方式,而不会影响到请求的定义和使用。类似地,在 `Response` 的处理上,现在我们定义了 `Decodable`,用自己手写的方式在解析模型。我们完全也可以使用任意的第三方 JSON 解析库,来帮助我们迅速构建模型类型,这仅仅只需要实现一个将 `Data` 转换为对应模型类型的方法即可。 如果你对 POP 方式的网络请求和模型解析感兴趣的话,不妨可以看看 [APIKit](https://github.com/ishkawa/APIKit) 这个框架,我们在示例中所展示的方法,正是这个框架的核心思想。 ## 合・陪伴 - 使用协议帮助改善代码设计 通过面向协议的编程,我们可以从传统的继承上解放出来,用一种更灵活的方式,搭积木一样对程序进行组装。每个协议专注于自己的功能,特别得益于协议扩展,我们可以减少类和继承带来的共享状态的风险,让代码更加清晰。 高度的协议化有助于解耦、测试以及扩展,而结合泛型来使用协议,更可以让我们免于动态调用和类型转换的苦恼,保证了代码的安全性。 ## 提问环节 主题演讲后有几位朋友提了一些很有意义的问题,在这里我也稍作整理。有可能问题和回答与当时的情形会有小的出入,仅供参考。 **我刚才在看 demo 的时候发现,你都是直接先写 `protocol`,而不是 `struct` 或者 `class`。是不是我们在实践 POP 的时候都应该直接先定义协议?** > 我直接写 `protocol` 是因为我已经对我要做什么有充分的了解,并且希望演讲不要超时。但是实际开发的时候你可能会无法一开始就写出合适的协议定义。建议可以像我在 demo 中做的那样,先“粗略”地进行定义,然后通过不断重构来得到一个最终的版本。当然,你也可以先用纸笔勾勒一个轮廓,然后再去定义和实现协议。当然了,也没人规定一定需要先定义协议,你完全也可以从普通类型开始写起,然后等发现共通点或者遇到我们之前提到的困境时,再回头看看是不是面向协议更加合适,这需要一定的 POP 经验。 **既然 POP 有这么多好处,那我们是不是不再需要面向对象,可以全面转向面向协议了?** > 答案可能让你失望。在我们的日常项目中,每天打交道的 Cocoa 其实还是一个带有浓厚 OOP 色彩的框架。也就是说,可能一段时期内我们不可能抛弃 OOP。不过 POP 其实可以和 OOP “和谐共处”,我们也已经看到了不少使用 POP 改善代码设计的例子。另外需要补充的是,POP 其实也并不是银弹,它有不好的一面。最大的问题是协议会增加代码的抽象层级 (这点上和类继承是一样的),特别是当你的协议又继承了其他协议的时候,这个问题尤为严重。在经过若干层的继承后,满足末端的协议会变得困难,你也难以确定某个方法究竟满足的是哪个协议的要求。这会让代码迅速变得复杂。如果一个协议并没有能描述很多共通点,或者说能让人很快理解的话,可能使用基本的类型还会更简单一些。 **谢谢你的演讲,想问一下你们在项目中使用 POP 的情况** > 我们在项目里用了很多 POP 的概念。上面 demo 里的网络请求的例子就是从实际项目中抽出来的,我们觉得这样的请求写起来非常轻松,因为代码很简单,新人进来交接也十分惬意。除了模型层之外,我们在 view 和 view controller 层也用了一些 POP 的代码,比如从 nib 创建 view 的 `NibCreatable`,支持分页请求 tableview controller 的 `NextPageLoadable`,空列表时显示页面的 `EmptyPage` 等等。因为时间有限,不可能展开一一说明,所以这里我只挑选了一个具有代表性,又不是很复杂的网络的例子。其实每个协议都让我们的代码,特别是 View Controller 变短,而且使测试变为可能。可以说,我们的项目从 POP 受益良多,而且我们应该会继续使用下去。 ## 推荐资料 几个我认为在 POP 实践中值得一看的资料,愿意再进行深入了解的朋友不妨一看。 * [Protocol-Oriented Programming in Swift](https://developer.apple.com/videos/play/wwdc2015/408/) - WWDC 15 #408 * [Protocols with Associated Types](https://www.youtube.com/watch?v=XWoNjiSPqI8) - @alexisgallagher * [Protocol Oriented Programming in the Real World](http://matthewpalmer.net/blog/2015/08/30/protocol-oriented-programming-in-the-real-world/) - @_matthewpalmer * [Practical Protocol-Oriented-Programming](https://realm.io/news/appbuilders-natasha-muraschev-practical-protocol-oriented-programming/) - @natashatherobot [previous]: /2016/11/pop-cocoa-1/ URL: https://onevcat.com/2016/11/pop-cocoa-1/index.html.md Published At: 2016-11-29 10:22:11 +0900 # 面向协议编程与 Cocoa 的邂逅 (上) 本文是笔者在 MDCC 16 (移动开发者大会) 上 iOS 专场中的主题演讲的文字整理。您可以在[这里](https://speakerdeck.com/onevcat/mian-xiang-xie-yi-bian-cheng-yu-cocoa-de-xie-hou)找到演讲使用的 Keynote,部分示例代码可以在 MDCC 2016 的[官方 repo](https://github.com/MDCC2016/ProtocolNetwork) 中找到。因为全部内容比较长,所以分成了上下两个部分,本文 (上) 主要介绍了一些理论方面的内容,包括面向对象编程存在的问题,面向协议的基本概念和决策模型等,[下半部分][next]主要展示了一些笔者日常使用面向协议思想和 Cocoa 开发结合的示例代码,并对其进行了一些解说。 ## 引子 面向协议编程 (Protocol Oriented Programming,以下简称 POP) 是 Apple 在 2015 年 WWDC 上提出的 Swift 的一种编程范式。相比与传统的面向对象编程 (OOP),POP 显得更加灵活。结合 Swift 的值语义特性和 Swift 标准库的实现,这一年来大家发现了很多 POP 的应用场景。本次演讲希望能在介绍 POP 思想的基础上,引入一些日常开发中可以使用 POP 的场景,让与会来宾能够开始在日常工作中尝试 POP,并改善代码设计。 ## 起・初识 - 什么是 Swift 协议 ### Protocol Swift 标准库中有 50 多个复杂不一的协议,几乎所有的实际类型都是满足若干协议的。protocol 是 Swift 语言的底座,语言的其他部分正是在这个底座上组织和建立起来的。这和我们熟知的面向对象的构建方式很不一样。 一个最简单但是有实际用处的 Swift 协议定义如下: ```swift protocol Greetable { var name: String { get } func greet() } ``` 这几行代码定义了一个名为 `Greetable` 的协议,其中有一个 `name` 属性的定义,以及一个 `greet` 方法的定义。 所谓协议,就是一组属性和/或方法的定义,而如果某个具体类型想要遵守一个协议,那它需要实现这个协议所定义的所有这些内容。协议实际上做的事情不过是“关于实现的约定”。 ### 面向对象 在深入 Swift 协议的概念之前,我想先重新让大家回顾一下面向对象。相信我们不论在教科书或者是博客等各种地方对这个名词都十分熟悉了。那么有一个很有意思,但是其实并不是每个程序员都想过的问题,面向对象的核心思想究竟是什么? 我们先来看一段面向对象的代码: ```swift class Animal { var leg: Int { return 2 } func eat() { print("eat food.") } func run() { print("run with \(leg) legs") } } class Tiger: Animal { override var leg: Int { return 4 } override func eat() { print("eat meat.") } } let tiger = Tiger() tiger.eat() // "eat meat" tiger.run() // "run with 4 legs" ``` 父类 `Animal` 定义了动物的 `leg` (这里应该使用虚类,但是 Swift 中没有这个概念,所以先请无视这里的 `return 2`),以及动物的 `eat` 和 `run` 方法,并为它们提供了实现。子类的 `Tiger` 根据自身情况重写了 `leg` (4 条腿)和 `eat` (吃肉),而对于 `run`,父类的实现已经满足需求,因此不必重写。 我们看到 `Tiger` 和 `Animal` 共享了一部分代码,这部分代码被封装到了父类中,而除了 `Tiger` 的其他的子类也能够使用 `Animal` 的这些代码。这其实就是 OOP 的核心思想 - 使用封装和继承,将一系列相关的内容放到一起。我们的前辈们为了能够对真实世界的对象进行建模,发展出了面向对象编程的概念,但是这套理念有一些缺陷。虽然我们努力用这套抽象和继承的方法进行建模,但是实际的事物往往是一系列**特质的组合**,而不单单是以一脉相承并逐渐扩展的方式构建的。所以最近大家越来越发现面向对象很多时候其实不能很好地对事物进行抽象,我们可能需要寻找另一种更好的方式。 ### 面向对象编程的困境 #### 横切关注点 我们再来看一个例子。这次让我们远离动物世界,回到 Cocoa,假设我们有一个 `ViewController`,它继承自 `UIViewController`,我们向其中添加一个 `myMethod`: ```swift class ViewCotroller: UIViewController { // 继承 // view, isFirstResponder()... // 新加 func myMethod() { } } ``` 如果这时候我们又有一个继承自 `UITableViewController` 的 `AnotherViewController`,我们也想向其中添加同样的 `myMethod`: ```swift class AnotherViewController: UITableViewController { // 继承 // tableView, isFirstResponder()... // 新加 func myMethod() { } } ``` 这时,我们迎来了 OOP 的第一个大困境,那就是我们很难在不同继承关系的类里共用代码。这里的问题用“行话”来说叫做“横切关注点” (Cross-Cutting Concerns)。我们的关注点 `myMethod` 位于两条继承链 (`UIViewController` -> `ViewCotroller` 和 `UIViewController` -> `UITableViewController` -> `AnotherViewController`) 的横切面上。面向对象是一种不错的抽象方式,但是肯定不是最好的方式。它无法描述两个不同事物具有某个相同特性这一点。在这里,特性的组合要比继承更贴切事物的本质。 想要解决这个问题,我们有几个方案: - Copy & Paste 这是一个比较糟糕的解决方案,但是演讲现场还是有不少朋友选择了这个方案,特别是在工期很紧,无暇优化的情况下。这诚然可以理解,但是这也是坏代码的开头。我们应该尽量避免这种做法。 - 引入 BaseViewController 在一个继承自 `UIViewController` 的 `BaseViewController` 上添加需要共享的代码,或者干脆在 `UIViewController` 上添加 extension。看起来这是一个稍微靠谱的做法,但是如果不断这么做,会让所谓的 `Base` 很快变成垃圾堆。职责不明确,任何东西都能扔进 `Base`,你完全不知道哪些类走了 `Base`,而这个“超级类”对代码的影响也会不可预估。 - 依赖注入 通过外界传入一个带有 `myMethod` 的对象,用新的类型来提供这个功能。这是一个稍好的方式,但是引入额外的依赖关系,可能也是我们不太愿意看到的。 - 多继承 当然,Swift 是不支持多继承的。不过如果有多继承的话,我们确实可以从多个父类进行继承,并将 `myMethod` 添加到合适的地方。有一些语言选择了支持多继承 (比如 C++),但是它会带来 OOP 中另一个著名的问题:菱形缺陷。 #### 菱形缺陷 上面的例子中,如果我们有多继承,那么 `ViewController` 和 `AnotherViewController` 的关系可能会是这样的: ![](/assets/images/2016/diamond.png) 在上面这种拓扑结构中,我们只需要在 `ViewController` 中实现 `myMethod`,在 `AnotherViewController` 中也就可以继承并使用它了。看起来很完美,我们避免了重复。但是多继承有一个无法回避的问题,就是两个父类都实现了同样的方法时,子类该怎么办?我们很难确定应该继承哪一个父类的方法。因为多继承的拓扑结构是一个菱形,所以这个问题又被叫做菱形缺陷 (Diamond Problem)。像是 C++ 这样的语言选择粗暴地将菱形缺陷的问题交给程序员处理,这无疑非常复杂,并且增加了人为错误的可能性。而绝大多数现代语言对多继承这个特性选择避而远之。 #### 动态派发安全性 Objective-C 恰如其名,是一门典型的 OOP 语言,同时它继承了 Small Talk 的消息发送机制。这套机制十分灵活,是 OC 的基础思想,但是有时候相对危险。考虑下面的代码: ```objc ViewController *v1 = ... [v1 myMethod]; AnotherViewController *v2 = ... [v2 myMethod]; NSArray *array = @[v1, v2]; for (id obj in array) { [obj myMethod]; } ``` 我们如果在 `ViewController` 和 `AnotherViewController` 中都实现了 `myMethod` 的话,这段代码是没有问题的。`myMethod` 将会被动态发送给 `array` 中的 `v1` 和 `v2`。但是,要是我们有一个没有实现 `myMethod` 的类型,会如何呢? ```objc NSObject *v3 = [NSObject new] // v3 没有实现 `myMethod` NSArray *array = @[v1, v2, v3]; for (id obj in array) { [obj myMethod]; } // Runtime error: // unrecognized selector sent to instance blabla ``` 编译依然可以通过,但是显然,程序将在运行时崩溃。Objective-C 是不安全的,编译器默认你知道某个方法确实有实现,这是消息发送的灵活性所必须付出的代价。而在 app 开发看来,用可能的崩溃来换取灵活性,显然这个代价太大了。虽然这不是 OOP 范式的问题,但它确实在 Objective-C 时代给我们带来了切肤之痛。 #### 三大困境 我们可以总结一下 OOP 面临的这几个问题。 - 动态派发安全性 - 横切关注点 - 菱形缺陷 首先,在 OC 中动态派发让我们承担了在运行时才发现错误的风险,这很有可能是发生在上线产品中的错误。其次,横切关注点让我们难以对对象进行完美的建模,代码的重用也会更加糟糕。 ## 承・相知 - 协议扩展和面向协议编程 ### 使用协议解决 OOP 困境 协议并不是什么新东西,也不是 Swift 的发明。在 Java 和 C# 里,它叫做 `Interface`。而 Swift 中的 protocol 将这个概念继承了下来,并发扬光大。让我们回到一开始定义的那个简单协议,并尝试着实现这个协议: ```swift protocol Greetable { var name: String { get } func greet() } ``` ```swift struct Person: Greetable { let name: String func greet() { print("你好 \(name)") } } Person(name: "Wei Wang").greet() ``` 实现很简单,`Person` 结构体通过实现 `name` 和 `greet` 来满足 `Greetable`。在调用时,我们就可以使用 `Greetable` 中定义的方法了。 #### 动态派发安全性 除了 `Person`,其他类型也可以实现 `Greetable`,比如 `Cat`: ```swift struct Cat: Greetable { let name: String func greet() { print("meow~ \(name)") } } ``` 现在,我们就可以将协议作为标准类型,来对方法调用进行动态派发了: ```swift let array: [Greetable] = [ Person(name: "Wei Wang"), Cat(name: "onevcat")] for obj in array { obj.greet() } // 你好 Wei Wang // meow~ onevcat ``` 对于没有实现 Greetbale 的类型,编译器将返回错误,因此不存在消息误发送的情况: ```swift struct Bug: Greetable { let name: String } // Compiler Error: // 'Bug' does not conform to protocol 'Greetable' // protocol requires function 'greet()' ``` 这样一来,动态派发安全性的问题迎刃而解。如果你保持在 Swift 的世界里,那这个你的所有代码都是安全的。 * ✅ 动态派发安全性 * 横切关注点 * 菱形缺陷 #### 横切关注点 使用协议和协议扩展,我们可以很好地共享代码。回到上一节的 `myMethod` 方法,我们来看看如何使用协议来搞定它。首先,我们可以定义一个含有 `myMethod` 的协议: ```swift protocol P { func myMethod() } ``` 注意这个协议没有提供任何的实现。我们依然需要在实际类型遵守这个协议的时候为它提供具体的实现: ```swift // class ViewController: UIViewController extension ViewController: P { func myMethod() { doWork() } } // class AnotherViewController: UITableViewController extension AnotherViewController: P { func myMethod() { doWork() } } ``` 你可能不禁要问,这和 Copy & Paste 的解决方式有何不同?没错,答案就是 -- 没有不同。不过稍安勿躁,我们还有其他科技可以解决这个问题,那就是协议扩展。协议本身并不是很强大,只是静态类型语言的编译器保证,在很多静态语言中也有类似的概念。那到底是什么让 Swift 成为了一门协议优先的语言?真正使协议发生质变,并让大家如此关注的原因,其实是在 WWDC 2015 和 Swift 2 发布时,Apple 为协议引入了一个新特性,协议扩展,它为 Swift 语言带来了一次革命性的变化。 所谓协议扩展,就是我们可以为一个协议提供默认的实现。对于 `P`,可以在 `extension P` 中为 `myMethod` 添加一个实现: ```swift protocol P { func myMethod() } extension P { func myMethod() { doWork() } } ``` 有了这个协议扩展后,我们只需要简单地声明 `ViewController` 和 `AnotherViewController` 遵守 `P`,就可以直接使用 `myMethod` 的实现了: ```swift extension ViewController: P { } extension AnotherViewController: P { } viewController.myMethod() anotherViewController.myMethod() ``` 不仅如此,除了已经定义过的方法,我们甚至可以在扩展中添加协议里没有定义过的方法。在这些额外的方法中,我们可以依赖协议定义过的方法进行操作。我们之后会看到更多的例子。总结下来: * 协议定义 * 提供实现的入口 * 遵循协议的类型需要对其进行实现 * 协议扩展 * 为入口提供默认实现 * 根据入口提供额外实现 这样一来,横切点关注的问题也简单安全地得到了解决。 * ✅ 动态派发安全性 * ✅ 横切关注点 * 菱形缺陷 #### 菱形缺陷 最后我们看看多继承。多继承中存在的一个重要问题是菱形缺陷,也就是子类无法确定使用哪个父类的方法。在协议的对应方面,这个问题虽然依然存在,但却是可以唯一安全地确定的。我们来看一个多个协议中出现同名元素的例子: ```swift protocol Nameable { var name: String { get } } protocol Identifiable { var name: String { get } var id: Int { get } } ``` 如果有一个类型,需要同时实现两个协议的话,它**必须**提供一个 `name` 属性,来**同时**满足两个协议的要求: ```swift struct Person: Nameable, Identifiable { let name: String let id: Int } // `name` 属性同时满足 Nameable 和 Identifiable 的 name ``` 这里比较有意思,又有点让人困惑的是,如果我们为其中的某个协议进行了扩展,在其中提供了默认的 `name` 实现,会如何。考虑下面的代码: ```swift extension Nameable { var name: String { return "default name" } } struct Person: Nameable, Identifiable { // let name: String let id: Int } // Identifiable 也将使用 Nameable extension 中的 name ``` 这样的编译是可以通过的,虽然 `Person` 中没有定义 `name`,但是通过 `Nameable` 的 `name` (因为它是静态派发的),`Person` 依然可以遵守 `Identifiable`。不过,当 `Nameable` 和 `Identifiable` 都有 `name` 的协议扩展的话,就无法编译了: ```swift extension Nameable { var name: String { return "default name" } } extension Identifiable { var name: String { return "another default name" } } struct Person: Nameable, Identifiable { // let name: String let id: Int } // 无法编译,name 属性冲突 ``` 这种情况下,`Person` 无法确定要使用哪个协议扩展中 `name` 的定义。在同时实现两个含有同名元素的协议,**并且**它们都提供了默认扩展时,我们需要在具体的类型中明确地提供实现。这里我们将 `Person` 中的 `name` 进行实现就可以了: ```swift extension Nameable { var name: String { return "default name" } } extension Identifiable { var name: String { return "another default name" } } struct Person: Nameable, Identifiable { let name: String let id: Int } Person(name: "onevcat", id: 123).name // onevcat ``` 这里的行为看起来和菱形问题很像,但是有一些本质不同。首先,这个问题出现的前提条件是同名元素**以及**同时提供了实现,而协议扩展对于协议本身来说并不是必须的。其次,我们在具体类型中提供的实现一定是安全和确定的。当然,菱形缺陷没有被完全解决,Swift 还不能很好地处理多个协议的冲突,这是 Swift 现在的不足。 * ✅ 动态派发安全性 * ✅ 横切关注点 * ❓菱形缺陷 [本文的下半部分][next]将展示一些笔者日常使用面向协议思想和 Cocoa 开发结合的示例代码,并对其进行了一些解说。 [next]: /2016/12/pop-cocoa-2/ URL: https://onevcat.com/2016/08/notification/index.html.md Published At: 2016-08-08 10:22:11 +0900 # 活久见的重构 - iOS 10 UserNotifications 框架解析 ## TL;DR iOS 10 中以前杂乱的和通知相关的 API 都被统一了,现在开发者可以使用独立的 UserNotifications.framework 来集中管理和使用 iOS 系统中通知的功能。在此基础上,Apple 还增加了撤回单条通知,更新已展示通知,中途修改通知内容,在通知中展示图片视频,自定义通知 UI 等一系列新功能,非常强大。 对于开发者来说,相较于之前版本,iOS 10 提供了一套非常易用的通知处理接口,是 SDK 的一次重大重构。而之前的绝大部分通知相关 API 都已经被标为弃用 (deprecated)。 这篇文章将首先回顾一下 Notification 的发展历史和现状,然后通过一些例子来展示 iOS 10 SDK 中相应的使用方式,来说明新 SDK 中通知可以做的事情以及它们的使用方式。 您可以在 WWDC 16 的 [Introduction to Notifications](https://developer.apple.com/videos/play/wwdc2016/707/) 和 [Advanced Notifications](https://developer.apple.com/videos/play/wwdc2016/708/) 这两个 Session 中找到详细信息;另外也不要忘了参照 [UserNotifications 的官方文档](https://developer.apple.com/reference/usernotifications)以及本文的[实例项目 UserNotificationDemo](https://github.com/onevcat/UserNotificationDemo)。 ## Notification 历史和现状 碎片化时间是移动设备用户在使用应用时的一大特点,用户希望随时拿起手机就能查看资讯,处理事务,而通知可以在重要的事件和信息发生时提醒用户。完美的通知展示可以很好地帮助用户使用应用,体现出应用的价值,进而有很大可能将用户带回应用,提高活跃度。正因如此,不论是 Apple 还是第三方开发者们,都很重视通知相关的开发工作,而通知也成为了很多应用的必备功能,开发者们都希望通知能带来更好地体验和更多的用户。 但是理想的丰满并不能弥补现实的骨感。自从在 iOS 3 引入 Push Notification 后,之后几乎每个版本 Apple 都在加强这方面的功能。我们可以回顾一下整个历程和相关的主要 API: * iOS 3 - 引入推送通知 `UIApplication` 的 `registerForRemoteNotificationTypes` 与 `UIApplicationDelegate` 的 `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`,`application(_:didReceiveRemoteNotification:)` * iOS 4 - 引入本地通知 `scheduleLocalNotification`,`presentLocalNotificationNow:`,` application(_:didReceive:)` * iOS 5 - 加入通知中心页面 * iOS 6 - 通知中心页面与 iCloud 同步 * iOS 7 - 后台静默推送 `application(_:didReceiveRemoteNotification:fetchCompletionHandle:)` * iOS 8 - 重新设计 notification 权限请求,Actionable 通知 `registerUserNotificationSettings(_:)`,`UIUserNotificationAction` 与 `UIUserNotificationCategory`,`application(_:handleActionWithIdentifier:forRemoteNotification:completionHandler:)` 等 * iOS 9 - Text Input action,基于 HTTP/2 的推送请求 `UIUserNotificationActionBehavior`,全新的 Provider API 等 有点晕,不是么?一个开发者很难在不借助于文档的帮助下区分 `application(_:didReceiveRemoteNotification:)` 和 `application(_:didReceiveRemoteNotification:fetchCompletionHandle:)`,新入行的开发者也不可能明白 `registerForRemoteNotificationTypes` 和 `registerUserNotificationSettings(_:)` 之间是不是有什么关系,Remote 和 Local Notification 除了在初始化方式之外那些细微的区别也让人抓狂,而很多 API 都被随意地放在了 `UIApplication` 或者 `UIApplicationDelegate` 中。除此之外,应用已经在前台时,远程推送是无法直接显示的,要先捕获到远程来的通知,然后再发起一个本地通知才能完成显示。更让人郁闷的是,应用在运行时和非运行时捕获通知的路径还不一致。虽然这些种种问题都是由一定历史原因造成的,但不可否认,正是混乱的组织方式和之前版本的考虑不周,使得 iOS 通知方面的开发一直称不上“让人愉悦”,甚至有不少“坏代码”的味道。 另一方面,现在的通知功能相对还是简单,我们能做的只是本地或者远程发起通知,然后显示给用户。虽然 iOS 8 和 9 中添加了按钮和文本来进行交互,但是已发出的通知不能更新,通知的内容也只是在发起时唯一确定,而这些内容也只能是简单的文本。 想要在现有基础上扩展通知的功能,势必会让原本就盘根错节的 API 更加难以理解。 在 iOS 10 中新加入 UserNotifications 框架,可以说是 iOS SDK 发展到现在的最大规模的一次重构。新版本里通知的相关功能被提取到了单独的框架,通知也不再区分类型,而有了更统一的行为。我们接下来就将由浅入深地解析这个重构后的框架的使用方式。 ## UserNotifications 框架解析 ### 基本流程 iOS 10 中通知相关的操作遵循下面的流程: ![](/assets/images/2016/notification-flow.png) 首先你需要向用户请求推送权限,然后发送通知。对于发送出的通知,如果你的应用位于后台或者没有运行的话,系统将通过用户允许的方式 (弹窗,横幅,或者是在通知中心) 进行显示。如果你的应用已经位于前台正在运行,你可以自行决定要不要显示这个通知。最后,如果你希望用户点击通知能有打开应用以外的额外功能的话,你也需要进行处理。 ### 权限申请 #### 通用权限 iOS 8 之前,本地推送 (`UILocalNotification`) 和远程推送 (Remote Notification) 是区分对待的,应用只需要在进行远程推送时获取用户同意。iOS 8 对这一行为进行了规范,因为无论是本地推送还是远程推送,其实在用户看来表现是一致的,都是打断用户的行为。因此从 iOS 8 开始,这两种通知都需要申请权限。iOS 10 里进一步消除了本地通知和推送通知的区别。向用户申请通知权限非常简单: ```swift UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in if granted { // 用户允许进行通知 } } ``` 当然,在使用 UN 开头的 API 的时候,不要忘记导入 UserNotifications 框架: ```swift import UserNotifications ``` 第一次调用这个方法时,会弹出一个系统弹窗。 ![](/assets/images/2016/notification-auth-alert.png) 要注意的是,一旦用户拒绝了这个请求,再次调用该方法也不会再进行弹窗,想要应用有机会接收到通知的话,用户必须自行前往系统的设置中为你的应用打开通知,如果不是杀手级应用,想让用户主动去在茫茫多 app 中找到你的那个并专门为你开启通知,往往是不可能的。因此,在合适的时候弹出请求窗,在请求权限前预先进行说明,以此增加通过的概率应该是开发者和策划人员的必修课。相比与直接简单粗暴地在启动的时候就进行弹窗,耐心诱导会是更明智的选择。 #### 远程推送 一旦用户同意后,你就可以在应用中发送本地通知了。不过如果你通过服务器发送远程通知的话,还需要多一个获取用户 token 的操作。你的服务器可以使用这个 token 将用向 Apple Push Notification 的服务器提交请求,然后 APNs 通过 token 识别设备和应用,将通知推给用户。 提交 token 请求和获得 token 的回调是现在“唯二”不在新框架中的 API。我们使用 `UIApplication` 的 `registerForRemoteNotifications` 来注册远程通知,在 `AppDelegate` 的 `application(_:didRegisterForRemoteNotificationsWithDeviceToken)` 中获取用户 token: ```swift // 向 APNs 请求 token: UIApplication.shared.registerForRemoteNotifications() // AppDelegate.swift func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let tokenString = deviceToken.hexString print("Get Push token: \(tokenString)") } ``` 获取得到的 `deviceToken` 是一个 `Data` 类型,为了方便使用和传递,我们一般会选择将它转换为一个字符串。Swift 3 中可以使用下面的 `Data` 扩展来构造出适合传递给 Apple 的字符串: ```swift extension Data { var hexString: String { return withUnsafeBytes {(bytes: UnsafePointer) -> String in let buffer = UnsafeBufferPointer(start: bytes, count: count) return buffer.map {String(format: "%02hhx", $0)}.reduce("", { $0 + $1 }) } } } ``` #### 权限设置 用户可以在系统设置中修改你的应用的通知权限,除了打开和关闭全部通知权限外,用户也可以限制你的应用只能进行某种形式的通知显示,比如只允许横幅而不允许弹窗及通知中心显示等。一般来说你不应该对用户的选择进行干涉,但是如果你的应用确实需要某种特定场景的推送的话,你可以对当前用户进行的设置进行检查: ``` UNUserNotificationCenter.current().getNotificationSettings { settings in print(settings.authorizationStatus) // .authorized | .denied | .notDetermined print(settings.badgeSetting) // .enabled | .disabled | .notSupported // etc... } ``` > 关于权限方面的使用,可以参考 Demo 中 [`AuthorizationViewController`](https://github.com/onevcat/UserNotificationDemo/blob/master/UserNotificationDemo/AuthorizationViewController.swift) 的内容。 ### 发送通知 UserNotifications 中对通知进行了统一。我们通过通知的内容 (`UNNotificationContent`),发送的时机 (`UNNotificationTrigger`) 以及一个发送通知的 `String` 类型的标识符,来生成一个 `UNNotificationRequest` 类型的发送请求。最后,我们将这个请求添加到 `UNUserNotificationCenter.current()` 中,就可以等待通知到达了: ```swift // 1. 创建通知内容 let content = UNMutableNotificationContent() content.title = "Time Interval Notification" content.body = "My first notification" // 2. 创建发送触发 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) // 3. 发送请求标识符 let requestIdentifier = "com.onevcat.usernotification.myFirstNotification" // 4. 创建一个发送请求 let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger) // 将请求添加到发送中心 UNUserNotificationCenter.current().add(request) { error in if error == nil { print("Time Interval Notification scheduled: \(requestIdentifier)") } } ``` 1. iOS 10 中通知不仅支持简单的一行文字,你还可以添加 `title` 和 `subtitle`,来用粗体字的形式强调通知的目的。对于远程推送,iOS 10 之前一般只含有消息的推送 payload 是这样的: ```json { "aps":{ "alert":"Test", "sound":"default", "badge":1 } } ``` 如果我们想要加入 `title` 和 `subtitle` 的话,则需要将 `alert` 从字符串换为字典,新的 payload 是: ```json { "aps":{ "alert":{ "title":"I am title", "subtitle":"I am subtitle", "body":"I am body" }, "sound":"default", "badge":1 } } ``` 好消息是,后一种字典的方法其实在 iOS 8.2 的时候就已经存在了。虽然当时 `title` 只是用在 Apple Watch 上的,但是设置好 `body` 的话在 iOS 上还是可以显示的,所以针对 iOS 10 添加标题时是可以保证前向兼容的。 另外,如果要进行本地化对应,在设置这些内容文本时,本地可以使用 `String.localizedUserNotificationString(forKey: "your_key", arguments: [])` 的方式来从 Localizable.strings 文件中取出本地化字符串,而远程推送的话,也可以在 payload 的 alert 中使用 `loc-key` 或者 `title-loc-key` 来进行指定。关于 payload 中的 key,可以参考[这篇文档](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html)。 2. 触发器是只对本地通知而言的,远程推送的通知的话默认会在收到后立即显示。现在 UserNotifications 框架中提供了三种触发器,分别是:在一定时间后触发 `UNTimeIntervalNotificationTrigger`,在某月某日某时触发 `UNCalendarNotificationTrigger` 以及在用户进入或是离开某个区域时触发 `UNLocationNotificationTrigger`。 3. 请求标识符可以用来区分不同的通知请求,在将一个通知请求提交后,通过特定 API 我们能够使用这个标识符来取消或者更新这个通知。我们将在稍后再提到具体用法。 4. 在新版本的通知框架中,Apple 借用了一部分网络请求的概念。我们组织并发送一个通知请求,然后将这个请求提交给 `UNUserNotificationCenter` 进行处理。我们会在 delegate 中接收到这个通知请求对应的 response,另外我们也有机会在应用的 extension 中对 request 进行处理。我们在接下来的章节会看到更多这方面的内容。 在提交通知请求后,我们锁屏或者将应用切到后台,并等待设定的时间后,就能看到我们的通知出现在通知中心或者屏幕横幅了: ![](/assets/images/2016/notification-alert.png) > 关于最基础的通知发送,可以参考 Demo 中 [`TimeIntervalViewController`](https://github.com/onevcat/UserNotificationDemo/blob/master/UserNotificationDemo/TimeIntervalViewController.swift) 的内容。 ### 取消和更新 在创建通知请求时,我们已经指定了标识符。这个标识符可以用来管理通知。在 iOS 10 之前,我们很难取消掉某一个特定的通知,也不能主动移除或者更新已经展示的通知。想象一下你需要推送用户账户内的余额变化情况,多次的余额增减或者变化很容易让用户十分困惑 - 到底哪条通知才是最正确的?又或者在推送一场比赛的比分时,频繁的通知必然导致用户通知中心数量爆炸,而大部分中途的比分对于用户来说只是噪音。 iOS 10 中,UserNotifications 框架提供了一系列管理通知的 API,你可以做到: * 取消还未展示的通知 * 更新还未展示的通知 * 移除已经展示过的通知 * 更新已经展示过的通知 其中关键就在于在创建请求时使用同样的标识符。 比如,从通知中心中移除一个展示过的通知: ```swift let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false) let identifier = "com.onevcat.usernotification.notificationWillBeRemovedAfterDisplayed" let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in if error != nil { print("Notification request added: \(identifier)") } } delay(4) { print("Notification request removed: \(identifier)") UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) } ``` 类似地,我们可以使用 `removePendingNotificationRequests`,来取消还未展示的通知请求。对于更新通知,不论是否已经展示,都和一开始添加请求时一样,再次将请求提交给 `UNUserNotificationCenter` 即可: ```swift // let request: UNNotificationRequest = ... UNUserNotificationCenter.current().add(request) { error in if error != nil { print("Notification request added: \(identifier)") } } delay(2) { let newTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) // Add new request with the same identifier to update a notification. let newRequest = UNNotificationRequest(identifier: identifier, content:newContent, trigger: newTrigger) UNUserNotificationCenter.current().add(newRequest) { error in if error != nil { print("Notification request updated: \(identifier)") } } } ``` 远程推送可以进行通知的更新,在使用 Provider API 向 APNs 提交请求时,在 HTTP/2 的 header 中 `apns-collapse-id` key 的内容将被作为该推送的标识符进行使用。多次推送同一标识符的通知即可进行更新。 > 对应本地的 `removeDeliveredNotifications`,现在还不能通过类似的方式,向 APNs 发送一个包含 collapse id 的 DELETE 请求来删除已经展示的推送,APNs 服务器并不接受一个 DELETE 请求。不过从技术上来说 Apple 方面应该不存在什么问题,我们可以拭目以待。现在如果想要消除一个远程推送,可以选择使用后台静默推送的方式来从本地发起一个删除通知的调用。关于后台推送的部分,可以参考我之前的一篇关于 [iOS7 中的多任务](https://onevcat.com/2013/08/ios7-background-multitask/)的文章。 > 关于通知管理,可以参考 Demo 中 [`ManagementViewController`](https://github.com/onevcat/UserNotificationDemo/blob/master/UserNotificationDemo/ManagementViewController.swift) 的内容。为了能够简单地测试远程推送,一般我们都会用一些方便发送通知的工具,[Knuff](https://github.com/KnuffApp/Knuff) 就是其中之一。我也为 Knuff 添加了 `apns-collapse-id` 的支持,你可以在这个 [fork 的 repo](https://github.com/onevcat/Knuff) 或者是原 repo 的 [pull request](https://github.com/KnuffApp/Knuff/pull/52) 中找到相关信息。 ### 处理通知 #### 应用内展示通知 现在系统可以在应用处于后台或者退出的时候向用户展示通知了。不过,当应用处于前台时,收到的通知是无法进行展示的。如果我们希望在应用内也能显示通知的话,需要额外的工作。 `UNUserNotificationCenterDelegate` 提供了两个方法,分别对应如何在应用内展示通知,和收到通知响应时要如何处理的工作。我们可以实现这个接口中的对应方法来在应用内展示通知: ```swift class NotificationHandler: NSObject, UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.alert, .sound]) // 如果不想显示某个通知,可以直接用空 options 调用 completionHandler: // completionHandler([]) } } ``` 实现后,将 `NotificationHandler` 的实例赋值给 `UNUserNotificationCenter` 的 `delegate` 属性就可以了。没有特殊理由的话,AppDelegate 的 `application(_:didFinishLaunchingWithOptions:)` 就是一个不错的选择: ```swift class AppDelegate: UIResponder, UIApplicationDelegate { let notificationHandler = NotificationHandler() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool { UNUserNotificationCenter.current().delegate = notificationHandler return true } } ``` #### 对通知进行响应 `UNUserNotificationCenterDelegate` 中还有一个方法,`userNotificationCenter(_:didReceive:withCompletionHandler:)`。这个代理方法会在用户与你推送的通知进行交互时被调用,包括用户通过通知打开了你的应用,或者点击或者触发了某个 action (我们之后会提到 actionable 的通知)。因为涉及到打开应用的行为,所以实现了这个方法的 delegate 必须在 `applicationDidFinishLaunching:` 返回前就完成设置,这也是我们之前推荐将 `NotificationHandler` 尽早进行赋值的理由。 一个最简单的实现自然是什么也不错,直接告诉系统你已经完成了所有工作。 ```swift func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { completionHandler() } ``` 想让这个方法变得有趣一点的话,在创建通知的内容时,我们可以在请求中附带一些信息: ```swift let content = UNMutableNotificationContent() content.title = "Time Interval Notification" content.body = "My first notification" content.userInfo = ["name": "onevcat"] ``` 在该方法里,我们将获取到这个推送请求对应的 response,`UNNotificationResponse` 是一个几乎包括了通知的所有信息的对象,从中我们可以再次获取到 `userInfo` 中的信息: ```swift func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if let name = response.notification.request.content.userInfo["name"] as? String { print("I know it's you! \(name)") } completionHandler() } ``` 更好的消息是,远程推送的 payload 内的内容也会出现在这个 `userInfo` 中,这样一来,不论是本地推送还是远程推送,处理的路径得到了统一。通过 `userInfo` 的内容来决定页面跳转或者是进行其他操作,都会有很大空间。 ### Actionable 通知发送和处理 #### 注册 Category iOS 8 和 9 中 Apple 引入了可以交互的通知,这是通过将一簇 action 放到一个 category 中,将这个 category 进行注册,最后在发送通知时将通知的 category 设置为要使用的 category 来实现的。 ![](/assets/images/2016/notification-category.png) 注册一个 category 非常容易: ```swift private func registerNotificationCategory() { let saySomethingCategory: UNNotificationCategory = { // 1 let inputAction = UNTextInputNotificationAction( identifier: "action.input", title: "Input", options: [.foreground], textInputButtonTitle: "Send", textInputPlaceholder: "What do you want to say...") // 2 let goodbyeAction = UNNotificationAction( identifier: "action.goodbye", title: "Goodbye", options: [.foreground]) let cancelAction = UNNotificationAction( identifier: "action.cancel", title: "Cancel", options: [.destructive]) // 3 return UNNotificationCategory(identifier:"saySomethingCategory", actions: [inputAction, goodbyeAction, cancelAction], intentIdentifiers: [], options: [.customDismissAction]) }() UNUserNotificationCenter.current().setNotificationCategories([saySomethingCategory]) } ``` 1. `UNTextInputNotificationAction` 代表一个输入文本的 action,你可以自定义框的按钮 title 和 placeholder。你稍后会使用 `identifier` 来对 action 进行区分。 2. 普通的 `UNNotificationAction` 对应标准的按钮。 3. 为 category 指定一个 `identifier`,我们将在实际发送通知的时候用这个标识符进行设置,这样系统就知道这个通知对应哪个 category 了。 当然,不要忘了在程序启动时调用这个方法进行注册: ```swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool { registerNotificationCategory() UNUserNotificationCenter.current().delegate = notificationHandler return true } ``` #### 发送一个带有 action 的通知 在完成 category 注册后,发送一个 actionable 通知就非常简单了,只需要在创建 `UNNotificationContent` 时把 `categoryIdentifier` 设置为需要的 category id 即可: ```swift content.categoryIdentifier = "saySomethingCategory" ``` 尝试展示这个通知,在下拉或者使用 3D touch 展开通知后,就可以看到对应的 action 了: ![](/assets/images/2016/notification-actions.png) 远程推送也可以使用 category,只需要在 payload 中添加 `category` 字段,并指定预先定义的 category id 就可以了: ```json { "aps":{ "alert":"Please say something", "category":"saySomething" } } ``` #### 处理 actionable 通知 和普通的通知并无二致,actionable 通知也会走到 `didReceive` 的 delegate 方法,我们通过 request 中包含的 `categoryIdentifier` 和 response 里的 `actionIdentifier` 就可以轻易判定是哪个通知的哪个操作被执行了。对于 `UNTextInputNotificationAction` 触发的 response,直接将它转换为一个 `UNTextInputNotificationResponse`,就可以拿到其中的用户输入了: ```swift func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if let category = UserNotificationCategoryType(rawValue: response.notification.request.content.categoryIdentifier) { switch category { case .saySomething: handleSaySomthing(response: response) } } completionHandler() } private func handleSaySomthing(response: UNNotificationResponse) { let text: String if let actionType = SaySomethingCategoryAction(rawValue: response.actionIdentifier) { switch actionType { case .input: text = (response as! UNTextInputNotificationResponse).userText case .goodbye: text = "Goodbye" case .none: text = "" } } else { // Only tap or clear. (You will not receive this callback when user clear your notification unless you set .customDismissAction as the option of category) text = "" } if !text.isEmpty { UIAlertController.showConfirmAlertFromTopViewController(message: "You just said \(text)") } } ``` 上面的代码先判断通知响应是否属于 "saySomething",然后从用户输入或者是选择中提取字符串,并且弹出一个 alert 作为响应结果。当然,更多的情况下我们会发送一个网络请求,或者是根据用户操作更新一些 UI 等。 > 关于 Actionable 的通知,可以参考 Demo 中 [`ActionableViewController`](https://github.com/onevcat/UserNotificationDemo/blob/master/UserNotificationDemo/ActionableViewController.swift) 的内容。 ### Notification Extension iOS 10 中添加了很多 extension,作为应用与系统整合的入口。与通知相关的 extension 有两个:Service Extension 和 Content Extension。前者可以让我们有机会在收到远程推送的通知后,展示之前对通知内容进行修改;后者可以用来自定义通知视图的样式。 ![](/assets/images/2016/notification-extensions.png) #### 截取并修改通知内容 `NotificationService` 的模板已经为我们进行了基本的实现: ```swift class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? // 1 override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) if let bestAttemptContent = bestAttemptContent { if request.identifier == "mutableContent" { bestAttemptContent.body = "\(bestAttemptContent.body), onevcat" } contentHandler(bestAttemptContent) } } // 2 override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { contentHandler(bestAttemptContent) } } } ``` 1. `didReceive:` 方法中有一个等待发送的通知请求,我们通过修改这个请求中的 content 内容,然后在限制的时间内将修改后的内容调用通过 `contentHandler` 返还给系统,就可以显示这个修改过的通知了。 2. 在一定时间内没有调用 `contentHandler` 的话,系统会调用这个方法,来告诉你大限已到。你可以选择什么都不做,这样的话系统将当作什么都没发生,简单地显示原来的通知。可能你其实已经设置好了绝大部分内容,只是有很少一部分没有完成,这时你也可以像例子中这样调用 `contentHandler` 来显示一个变更“中途”的通知。 Service Extension 现在只对远程推送的通知起效,你可以在推送 payload 中增加一个 `mutable-content` 值为 1 的项来启用内容修改: ```json { "aps":{ "alert":{ "title":"Greetings", "body":"Long time no see" }, "mutable-content":1 } } ``` 这个 payload 的推送得到的结果,注意 body 后面附上了名字。 ![](/assets/images/2016/notification-mutable-content.png) 使用在本机截取推送并替换内容的方式,可以完成端到端 (end-to-end) 的推送加密。你在服务器推送 payload 中加入加密过的文本,在客户端接到通知后使用预先定义或者获取过的密钥进行解密,然后立即显示。这样一来,即使推送信道被第三方截取,其中所传递的内容也还是安全的。使用这种方式来发送密码或者敏感信息,对于一些金融业务应用和聊天应用来说,应该是必备的特性。 #### 在通知中展示图片/视频 相比于旧版本的通知,iOS 10 中另一个亮眼功能是多媒体的推送。开发者现在可以在通知中嵌入图片或者视频,这极大丰富了推送内容的可读性和趣味性。 为本地通知添加多媒体内容十分简单,只需要通过本地磁盘上的文件 URL 创建一个 `UNNotificationAttachment` 对象,然后将这个对象放到数组中赋值给 content 的 `attachments` 属性就行了: ```swift let content = UNMutableNotificationContent() content.title = "Image Notification" content.body = "Show me an image!" if let imageURL = Bundle.main.url(forResource: "image", withExtension: "jpg"), let attachment = try? UNNotificationAttachment(identifier: "imageAttachment", url: imageURL, options: nil) { content.attachments = [attachment] } ``` 在显示时,横幅或者弹窗将附带设置的图片,使用 3D Touch pop 通知或者下拉通知显示详细内容时,图片也会被放大展示: ![](/assets/images/2016/notification-thumbnail.png) ![](/assets/images/2016/notification-image.png) 除了图片以外,通知还支持音频以及视频。你可以将 MP3 或者 MP4 这样的文件提供给系统来在通知中进行展示和播放。不过,这些文件都有尺寸的限制,比如图片不能超过 10MB,视频不能超过 50MB 等,不过对于一般的能在通知中展示的内容来说,这个尺寸应该是绰绰有余了。关于支持的文件格式和尺寸,可以在[文档](https://developer.apple.com/reference/usernotifications/unnotificationattachment)中进行确认。在创建 `UNNotificationAttachment` 时,如果遇到了不支持的格式,SDK 也会抛出错误。 通过远程推送的方式,你也可以显示图片等多媒体内容。这要借助于上一节所提到的通过 Notification Service Extension 来修改推送通知内容的技术。一般做法是,我们在推送的 payload 中指定需要加载的图片资源地址,这个地址可以是应用 bundle 内已经存在的资源,也可以是网络的资源。不过因为在创建 `UNNotificationAttachment` 时我们只能使用本地资源,所以如果多媒体还不在本地的话,我们需要先将其下载到本地。在完成 `UNNotificationAttachment` 创建后,我们就可以和本地通知一样,将它设置给 `attachments` 属性,然后调用 `contentHandler` 了。 简单的示例 payload 如下: ```json { "aps":{ "alert":{ "title":"Image Notification", "body":"Show me an image from web!" }, "mutable-content":1 }, "image": "https://onevcat.com/assets/images/background-cover.jpg" } ``` `mutable-content` 表示我们会在接收到通知时对内容进行更改,`image` 指明了目标图片的地址。 在 `NotificationService` 里,加入如下代码来下载图片,并将其保存到磁盘缓存中: ```swift private func downloadAndSave(url: URL, handler: @escaping (_ localURL: URL?) -> Void) { let task = URLSession.shared.dataTask(with: url, completionHandler: { data, res, error in var localURL: URL? = nil if let data = data { let ext = (url.absoluteString as NSString).pathExtension let cacheURL = URL(fileURLWithPath: FileManager.default.cachesDirectory) let url = cacheURL.appendingPathComponent(url.absoluteString.md5).appendingPathExtension(ext) if let _ = try? data.write(to: url) { localURL = url } } handler(localURL) }) task.resume() } ``` 然后在 `didReceive:` 中,接收到这类通知时提取图片地址,下载,并生成 attachment,进行通知展示: ```swift if let imageURLString = bestAttemptContent.userInfo["image"] as? String, let URL = URL(string: imageURLString) { downloadAndSave(url: URL) { localURL in if let localURL = localURL { do { let attachment = try UNNotificationAttachment(identifier: "image_downloaded", url: localURL, options: nil) bestAttemptContent.attachments = [attachment] } catch { print(error) } } contentHandler(bestAttemptContent) } } ``` 关于在通知中展示图片或者视频,有几点想补充说明: * `UNNotificationContent` 的 `attachments` 虽然是一个数组,但是系统只会展示第一个 attachment 对象的内容。不过你依然可以发送多个 attachments,然后在要展示的时候再重新安排它们的顺序,以显示最符合情景的图片或者视频。另外,你也可能会在自定义通知展示 UI 时用到多个 attachment。我们接下来一节中会看到一个相关的例子。 * 在当前 beta (iOS 10 beta 4) 中,`serviceExtensionTimeWillExpire` 被调用之前,你有 30 秒时间来处理和更改通知内容。对于一般的图片来说,这个时间是足够的。但是如果你推送的是体积较大的视频内容,用户又恰巧处在糟糕的网络环境的话,很有可能无法及时下载完成。 * 如果你想在远程推送来的通知中显示应用 bundle 内的资源的话,要注意 extension 的 bundle 和 app main bundle 并不是一回事儿。你可以选择将图片资源放到 extension bundle 中,也可以选择放在 main bundle 里。总之,你需要保证能够获取到正确的,并且你具有读取权限的 url。关于从 extension 中访问 main bundle,可以参看[这篇回答](http://stackoverflow.com/questions/26189060/get-the-main-app-bundle-from-within-extension)。 * 系统在创建 attachement 时会根据提供的 url 后缀确定文件类型,如果没有后缀,或者后缀无法不正确的话,你可以在创建时通过 `UNNotificationAttachmentOptionsTypeHintKey` 来[指定资源类型](https://developer.apple.com/reference/usernotifications/unnotificationattachmentoptionstypehintkey)。 * 如果使用的图片和视频文件不在你的 bundle 内部,它们将被移动到系统的负责通知的文件夹下,然后在当通知被移除后删除。如果媒体文件在 bundle 内部,它们将被复制到通知文件夹下。每个应用能使用的媒体文件的文件大小总和是有限制,超过限制后创建 attachment 时将抛出异常。可能的所有错误可以在 `UNError` 中找到。 * 你可以访问一个已经创建的 attachment 的内容,但是要注意权限问题。可以使用 `startAccessingSecurityScopedResource` 来暂时获取以创建的 attachment 的访问权限。比如: ```swift let content = notification.request.content if let attachment = content.attachments.first { if attachment.url.startAccessingSecurityScopedResource() { eventImage.image = UIImage(contentsOfFile: attachment.url.path!) attachment.url.stopAccessingSecurityScopedResource() } } ``` > 关于 Service Extension 和多媒体通知的使用,可以参考 Demo 中 [`NotificationService`](https://github.com/onevcat/UserNotificationDemo/blob/master/NotificationService/NotificationService.swift) 和 [`MediaViewController`](https://github.com/onevcat/UserNotificationDemo/blob/master/UserNotificationDemo/MediaViewController.swift) 的内容。 #### 自定义通知视图样式 iOS 10 SDK 新加的另一个 Content Extension 可以用来自定义通知的详细页面的视图。新建一个 Notification Content Extension,Xcode 为我们准备的模板中包含了一个实现了 `UNNotificationContentExtension` 的 `UIViewController` 子类。这个 extension 中有一个必须实现的方法 `didReceive(_:)`,在系统需要显示自定义样式的通知详情视图时,这个方法将被调用,你需要在其中配置你的 UI。而 UI 本身可以通过这个 extension 中的 MainInterface.storyboard 来进行定义。自定义 UI 的通知是和通知 category 绑定的,我们需要在 extension 的 Info.plist 里指定这个通知样式所对应的 category 标识符: ![](/assets/images/2016/notification-content-info.png) 系统在接收到通知后会先查找有没有能够处理这类通知的 content extension,如果存在,那么就交给 extension 来进行处理。另外,在构建 UI 时,我们可以通过 Info.plist 控制通知详细视图的尺寸,以及是否显示原始的通知。关于 Content Extension 中的 Info.plist 的 key,可以在[这个文档](https://developer.apple.com/reference/usernotificationsui/unnotificationcontentextension)中找到详细信息。 虽然我们可以使用包括按钮在内的各种 UI,但是系统不允许我们对这些 UI 进行交互。点击通知视图 UI 本身会将我们导航到应用中,不过我们可以通过 action 的方式来对自定义 UI 进行更新。`UNNotificationContentExtension` 为我们提供了一个可选方法 `didReceive(_:completionHandler:)`,它会在用户选择了某个 action 时被调用,你有机会在这里更新通知的 UI。如果有 UI 更新,那么在方法的 `completionHandler` 中,开发者可以选择传递 `.doNotDismiss` 来保持通知继续被显示。如果没有继续显示的必要,可以选择 `.dismissAndForwardAction` 或者 `.dismiss`,前者将把通知的 action 继续传递给应用的 `UNUserNotificationCenterDelegate` 中的 `userNotificationCenter(:didReceive:withCompletionHandler)`,而后者将直接解散这个通知。 如果你的自定义 UI 包含视频等,你还可以实现 `UNNotificationContentExtension` 里的 `media` 开头的一系列属性,它将为你提供一些视频播放的控件和相关方法。 > 关于 Content Extension 和自定义通知样式,可以参考 Demo 中 [`NotificationViewController`](https://github.com/onevcat/UserNotificationDemo/blob/master/NotificationContent/NotificationViewController.swift) 和 [`CustomizeUIViewController`](https://github.com/onevcat/UserNotificationDemo/blob/master/UserNotificationDemo/CustomizeUIViewController.swift) 的内容。 ## 总结 iOS 10 SDK 中对通知这块进行了 iOS 系统发布以来最大的一次重构,很多“老朋友”都被标记为了 deprecated: ### iOS 10 中被标为弃用的 API * UILocalNotification * UIMutableUserNotificationAction * UIMutableUserNotificationCategory * UIUserNotificationAction * UIUserNotificationCategory * UIUserNotificationSettings * handleActionWithIdentifier:forLocalNotification: * handleActionWithIdentifier:forRemoteNotification: * didReceiveLocalNotification:withCompletion: * didReceiveRemoteNotification:withCompletion: 等一系列在 `UIKit` 中的发送和处理通知的类型及方法。 ### 现状以及尽快使用新的 API 相比于 iOS 早期时代的 API,新的 API 展现出了高度的模块化和统一特性,易用性也非常好,是一套更加先进的 API。如果有可能,特别是如果你的应用是重度依赖通知特性的话,直接从 iOS 10 开始可以让你充分使用在新通知体系的各种特性。 虽然原来的 API 都被标为弃用了,但是如果你需要支持 iOS 10 之前的系统的话,你还是需要使用原来的 API。我们可以使用 ```swift if #available(iOS 10.0, *) { // Use UserNotification } ``` 的方式来指针对 iOS 10 进行新通知的适配,并让 iOS 10 的用户享受到新通知带来的便利特性,然后在将来版本升级到只支持 iOS 10 以上时再移除掉所有被弃用的代码。对于优化和梳理通知相关代码来说,新 API 对代码设计和组织上带来的好处足以弥补适配上的麻烦,而且它还能为你的应用提供更好的通知特性和体验,何乐不为呢? URL: https://onevcat.com/2016/06/ios-10-ats/index.html.md Published At: 2016-06-17 13:34:11 +0900 # 关于 iOS 10 中 ATS 的问题 ![](/assets/images/2016/https-lock.png) > 本文于 2016 年 11 月 28 日按照 Apple 最新的文档和 Xcode 8 中的表现进行了部分更新。 WWDC 15 提出的 ATS (App Transport Security) 是 Apple 在推进网络通讯安全的一个重要方式。在 iOS 9 和 OS X 10.11 中,默认情况下非 HTTPS 的网络访问是被禁止的。当然,因为这样的推进影响面非常广,作为缓冲,我们可以在 Info.plist 中添加 `NSAppTransportSecurity` 字典并且将 `NSAllowsArbitraryLoads` 设置为 `YES` 来禁用 ATS。相信大家都已经对这个非常熟悉了,因为我自己也维护了一些网络相关的框架,所以我还自己准备了一个[小脚本](https://gist.github.com/onevcat/b4604aecb4ce55651a4a)来快速关闭 ATS。 不过,WWDC 16 中,Apple 表示将继续在 iOS 10 和 macOS 10.12 里收紧对普通 HTTP 的访问限制。从 2017 年 1 月 1 日起,所有的新提交 app 默认是不允许使用 `NSAllowsArbitraryLoads` 来绕过 ATS 限制的,也就是说,我们最好保证 app 的所有网络请求都是 HTTPS 加密的,否则可能会在应用审核时遇到麻烦。 现在 (2016-11-28),这方面的相关规定和几个事实如下: 1. 默认情况下你的 app 可以访问加密足够强 ([TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) v1.2 以上,AES-128 和 SHA-2 以及 ECDHC 等) 的 HTTPS 内容。这对所有的网络请求都有效,包括 `NSURLSession`,通过 AVFoundation 访问的流媒体,`UIWebView` 以及 `WKWebView` 等。 2. 你依然可以添加 `NSAllowsArbitraryLoads` 为 `YES` 来全面禁用 ATS,不过如果你这么做的话,需要在提交 app 时进行说明,为什么需要访问非 HTTPS 内容。一般来说,可能简单粗暴地开启这个选项,而又无法找到正当理由的 app 会难以通过审核。 3. 相比于使用 `NSAllowsArbitraryLoads` 将全部 HTTP 内容开放,选择使用 `NSExceptionDomains` 来针对特定的域名,通过设定该域名下的 `NSExceptionAllowsInsecureHTTPLoads` 来开放 HTTP 应该要相对容易过审核。“需要访问的域名是第三方服务器,他们没有进行 HTTPS 对应”会是审核时的一个可选理由,但是这应该只需要针对特定域名,而非全面开放。如果访问的是自己的服务器的话,可能这个理由会无法通过。 4. 对于网页浏览和视频播放的行为,iOS 10 中新加入了 `NSAllowsArbitraryLoadsInWebContent` 和 `NSAllowsArbitraryLoadsForMedia` 键。通过将它们设置为 `YES`,可以让你的 app 中的 `UIWebView`、`WKWebView` 或者使用 `AVFoundation` 播放的在线视频不受 ATS 的限制。虽然依然需要在审核时进行说明,但这也应该是绝大多数使用了相关特性的 app 的首选。坏消息是这个键在 iOS 9 中并不会起作用。 总结一下就是以下两点: 1. 对于 API 请求,基本上是必须使用 HTTPS 的,特别是如果你们自己可以管理服务器的话。可能需要后端的同学尽快升级到 HTTPS (不过话说虽然是用 Let's Encrypt 的,我一个个人博客都启用 HTTPS 了,作为 API 的用户服务器,还不开 HTTPS 真有点说不过去)。如果使用的是第三方的 API,而他们没有提供 HTTPS 支持的话,需要在 `NSExceptionDomains` 中进行添加。 2. 如果你的 app 只支持 iOS 10,并且有用户可以自由输入网址进行浏览的功能,或者是在线视频音频播放功能的话,只加入 `NSAllowsArbitraryLoadsInWebContent` 或/和 `NSAllowsArbitraryLoadsForMedia`,并且将组件换成 `UIWebView` 或 `WKWebView`,以及 `AVFoundation` 中的 player 就可以了。如果你还需要支持 iOS 9,并且需要访问网页和视频的话,可能只能去开启 `NSAllowsArbitraryLoads` 然后提交时进行说明,并且看 Apple 审核员的脸色决定让不让通过了。除了 `WKWebKit` 以外,另外一个访问网页的选择是使用 `SFSafariViewController`。因为其实 `SFSafariViewController` 就是一个独立于 app 的 Safari 进程,所以它完全不受 ATS 的限制。 3. 如果你需要使用内网,可以设置 `NSAllowsLocalNetworking`,而不必担心 SSL 连接的问题。 另外,当 `NSAllowsArbitraryLoads` 和 `NSAllowsArbitraryLoadsInWebContent` 或 `NSAllowsArbitraryLoadsForMedia` 同时存在时,根据系统不同,表现的行为也会不一样。简单说,iOS 9 只看 `NSAllowsArbitraryLoads`,而 iOS 10 会优先看 `InWebContent` 和 `ForMedia` 的部分。在 iOS 10 中,要是后两者存在的话,在相关部分就会忽略掉 `NSAllowsArbitraryLoads`;如果不存在,则遵循 `NSAllowsArbitraryLoads` 的设定。说起来可能有点复杂,我在这里总结了一下根据 `NSAppTransportSecurity` 中设定条件不同,所对应的系统版本和请求组件的行为的不同,可以作为你设置这个字典时的参考 (表中使用了 `NSAllowsArbitraryLoadsInWebContent` 作为例子,`NSAllowsArbitraryLoadsForMedia` 也同理): ATS 设定 | 使用的组件 | iOS 9 HTTP | iOS 10 HTTP | 备注 -------------------------------- | --------- |:---------:|:---------:| ------- NSAllowsArbitraryLoads: NO | WebView | ❌ | ❌ | 默认行为 | URLSession | ❌ | ❌ | NSAllowsArbitraryLoads: YES | WebView | ✅ | ✅ | 彻底禁用 ATS | URLSession | ✅ | ✅ | 审核时需要说明理由 NSAllowsArbitraryLoads: NO & NSAllowsArbitraryLoadsInWebContent: YES | WebView | ❌ | ✅ | 只对网页内容禁用 ATS | URLSession | ❌ | ❌ | 保证 API 的安全性 NSAllowsArbitraryLoads: NO & NSAllowsArbitraryLoadsInWebContent: NO | WebView | ❌ | ❌ | | URLSession | ❌ | ❌ | NSAllowsArbitraryLoads: YES & NSAllowsArbitraryLoadsInWebContent: NO | WebView | ✅ | ❌ | 对于 iOS 10,NSAllowsArbitraryLoadsInWebContent 存在时忽略 NSAllowsArbitraryLoads 的设置 | URLSession | ✅ | ❌ | iOS 9 将继续使用 NSAllowsArbitraryLoads NSAllowsArbitraryLoads: YES & NSAllowsArbitraryLoadsInWebContent: YES | WebView | ✅ | ✅ | 对于 iOS 10,NSAllowsArbitraryLoadsInWebContent 存在时忽略 NSAllowsArbitraryLoads 的设置 | URLSession | ✅ | ❌ | iOS 9 将继续使用 NSAllowsArbitraryLoads > 该列表是根据 Apple prerelease 的[文档](https://developer.apple.com/library/prerelease/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CocoaKeys.html)中关于 `NSAppTransportSecurity` 和 `NSAllowsArbitraryLoadsInWebContent` 部分的描述作出的。如果您发现这个行为发生了变化,或者上面的列表存在问题,欢迎留言,我会进行更正。 作为参考,这里将有效的 `NSAppTransportSecurity` 字典结构也一并附上: ```js NSAppTransportSecurity : Dictionary { NSAllowsArbitraryLoads : Boolean NSAllowsArbitraryLoadsForMedia : Boolean NSAllowsArbitraryLoadsInWebContent : Boolean NSAllowsLocalNetworking : Boolean NSExceptionDomains : Dictionary { : Dictionary { NSIncludesSubdomains : Boolean NSExceptionAllowsInsecureHTTPLoads : Boolean NSExceptionMinimumTLSVersion : String NSExceptionRequiresForwardSecrecy : Boolean // Default value is YES NSRequiresCertificateTransparency : Boolean } } } ``` 不得不说,Apple 使用自己现在的强势地位,在推动技术进步上的做的努力是有目共睹的。不论是前几天强制支持 IPv6,还是现在的 HTTPS,其实都不是很容易就能作出的决定。而为用户构建一个更安全的使用环境,可能不仅是 Apple 单方面可以做的,也是需要开发者来配合的一件事情。尽快适配更进步和安全的使用方式,会是一件双赢的事情。 URL: https://onevcat.com/2016/06/ios-10-sdk/index.html.md Published At: 2016-06-15 15:10:11 +0900 # 开发者所需要知道的 iOS 10 SDK 新特性 ![](/assets/images/2016/ios-10-title.png) ### 总览 距离 iPhone 横空出世已经过去了 9 个年头,iOS 的版本号也跨入了两位数。在我们回顾过去四五年 iOS 系统的发展的时候,不免感叹变化速度之快,迭代周期之短。[iOS 7](https://onevcat.com/2013/06/developer-should-know-about-ios7/) 翻天覆地的全新设计,[iOS 8](https://onevcat.com/2014/07/developer-should-know-about-ios8/) 中 Size Classes 的出现,应用扩展,以及 Cloud Kit 的加入,[iOS 9](https://onevcat.com/2015/06/ios9-sdk/) 里的分屏多任务特性等等。Apple 近年都是在 WWDC 发布新的系统和软件,然后在秋季和冬季 (或者来年春季) 召开硬件产品的发布会。WWDC 上每一项软件的更新其实都预示了相应的硬件的方向,相信今年也不会例外。 对于开发者来说,好消息是 iOS 10 中并没有加入太多内容。按照适配的需求,来年的 iOS 开发至少应该可以从 iOS 8 甚至 iOS 9 开始,我们将有时间对之前的版本特性进行更好的梳理,消化和实践。相比于开疆扩土,iOS 10 更专注的是对现有内容的改进,以弥补之前迅速发展所留下的一些问题,这其实正是 Apple 当下所亟需做的事情。 ### 生态整合与 Extension 开发 在 iOS 10 里 Apple 延续了前几年的策略,那就是进行平台整合。全世界现在没有另外一家厂商在掌握了包括桌面,移动到穿戴的一系列硬件设备的同时,还掌控了相应的从操作系统,到应用软件,再到软件商店这样一套完整的布局。Apple 显然也非常明白这个优势意味着什么。所以近年来 Apple 一直强调平台整合,如果你的应用能够同时在 iOS,watchOS 以及 macOS 上工作的话,毫无疑问将会更容易吸引用户以及 Apple 的喜爱。 另外一点则是各个应用之间的整合和交互。不难发现,随着近年来 extension 开发的兴起,Apple 逐渐在从 app 是“用户体验的核心”这个理念中转移,变为用户应该也可以在通知中心,桌面挂件或者手表这样的地方完成必要交互。而应用之间的交互在以前可以说是 iOS 系统的禁区,但是去年随着 [Workflow](https://workflow.is) 的成功,Apple 对于应用之间的交互有助于用户生产力的提升有了清晰的认识。今年 SDK 中几个重大更新其实都是围绕这个主题来进行的。 iOS 10 中,Apple 为我们添加了茫茫多 extension 的新模板,以至于在同事之间开玩笑都是我们马上就要丢掉 iOS app 开发者的工作,而转变为 iOS extension 开发者这样了。新加入的扩展的种类和数量都足以说明使用应用扩展以及进行扩展开发在今后 iOS 开发中的重要地位。如果你对扩展开发还一无所知,可以先看看这篇[入门文章](https://onevcat.com/2014/08/notification-today-widget/),里面简单介绍了关于扩展的基本概念,不同开发 target 之间代码共享的方式,以及通用的扩展开发方法等。 ![](/assets/images/2016/extentions-ios-10.png) #### SiriKit Siri API 的开放自然是 iOS 10 SDK 中最激动人心也是亮眼的特性。SiriKit 为我们提供一全套从语音识别到代码处理,最后向用户展示结果的流程。Apple 加入了一套全新的框架 Intents.framework 来表示 Siri 获取并解析的结果。你的应用需要提供一些关键字表明可以接受相关输入,而 Siri 扩展只需要监听系统识别的用户意图 (intent),作出合适的响应,修改以及实际操作,最后通过 IntentsUI.framework 提供反馈。整个过程非常清晰明了,但是这也意味着开发者所能拥有的自由度有限。 在 iOS 10 中,我们只能用 SiriKit 来做六类事情,分别是: * 语音和视频通话 * 发送消息 * 发送或接收付款 * 搜索照片 * 约车 * 管理健身 如果你的应用恰好正在处理这些领域的问题的话,添加 Intents Extension 的支持会是很棒的选择。它将提高用户使用你的应用的可能性,也能让用户在其他像是地图这样的系统级应用中使用你的服务。 > SiriKit 笔记 (待填坑) #### User Notifications 通知中心向来是 iOS 上的兵家必争之地。如何提供适时有效的通知,往往决定了用户活跃和留存的可能性。在 iOS 10 上,Apple 对通知进行了加强和革新。现在,为了更好地处理和管理通知,和本地及推送通知相关的 API 被封装到了全新的框架 UserNotifications.framework 中。在 iOS 10 中,开发者的服务器有机会在本地或者远程通知发送给用户之前再进行修改。 另外,在之前加入了 notification action 以及 text input 的基础上,iOS 10 又新增了为通知添加音频,图片,甚至视频的功能。现在,你的通知不仅仅是提醒用户回到应用的入口,更成为了一个展示应用内容,向用户传递多媒体信息的窗口。 > User Notifications 笔记 - [活久见的重构 - iOS 10 UserNotifications 框架解析](https://onevcat.com/2016/08/notification/) #### iMessage Apps Message 应用大概是 Apple 在宣传 iOS 10 时着力最多的部分了。虽然新的贴纸包,自动转换颜文字,发送全屏效果等功能都很酷炫,但是对于程序开发者来说,可能还是对 iMessage Apps 更感兴趣。Xcode 8 中,Apple 在 iOS Application 模板中添加了一类新的项目类型,Messages Application。同时,模拟器甚至还开发了新的双人对话模式,以供开发者调试这类 app。 虽然名义上是独立 app,但实际上工作的依然是一个 extension。在该扩展中,Messages.framework 将承担与系统的 message 界面交互的主要职责。你通过提供一个自定义的 View Controller,来获取用户在使用你的 message app 时进行对话的上下文,以及发送接收等操作,并做出合适的响应。这个扩展在用来进行直接在 Message 应用中一些自定义共享会很好玩。但是鉴于 Apple 暂时没有打算将 Message.app 跨平台的原因,可能也注定了这只会是一种补充,而无法成为主流。 > iMessage Apps 笔记 (待填坑) ### IDE 和工具改进 除了整合平台战略思想下的一些 SDK 改变,今年和 iOS 开发者相关的更多的是开发工具的进步和革新了。 #### Xcode 8 Xcode 8 展现出了很多有意思的新特性,比如更强大的 View Debugging,可以帮助我们追查内存引用问题的 Memory Debugging 等。这些工具十分强大,也将帮助我们在开发过程中及早发现问题,而不要将它们带入在最终产品中去。 在 app 签名方面,Apple 终于意识到了他们在 Xcode 7 中所犯得错误。我想可能不止一个人被证书和描述文件出问题时的 "Fix Issue" 按钮坑过。这个按钮不仅不会修正问题,反而会直接注销现有的开发者证书,然后“自作主张”地重新申请。大多数情况下,这让事情变得更加糟糕。特别是对于新加入的开发者,他们并不理解 Apple 的证书系统,错误的操作和处置,往往让开发环境变得不可挽回。Xcode 8 中,同一个开发者帐号现在允许多个开发证书,而完全重做的 app 签名系统也足够好用,并且避免了误操作的可能性。在兼顾自动配置的基础上,也为大型项目和复杂的 CI 环境提供了足够灵活的配置空间,这绝对值得点赞。 另外 Xcode 终于提供了进行代码编辑器扩展的能力。现在开发者可以创建 `XCSourceEditorExtension` 来对 Xcode 的功能进行扩展了,在没有文档帮助和官方支持的情况下摸索着为 Xcode 制作插件的历史也即将结束。 > Xcode 8 笔记 (待填坑) #### Swift 3 Swift 开源已经过去半年时间。在 Swift 2.2 中我们已经看到了开源的社区力量对语言产生的深刻影响,而在 Swift 3 中这一影响的效果将更加明显。 最大的变化在于 Foundation 框架的重新导入,可能过一段时间再回头看的话,这将标志着 Swift 与 Objective-C 彻底分家。Foundation 框架中的 API 现在以更符合 Swift 的方式被导入到语言中。大体来说,这些变化包括去除 `NS` 前缀,将绝大部分 class 转换为 struct (虽然底层还是 copy-on-write 的引用实现,可以参看 `ReferenceConvertible` 协议的内容),去掉 API 中重复的语义等。如果在当前你还能看出 Swift 和 Objective-C 在使用 Foundation 或者说开发 app 时同根同源的话,Swift 3 正式发布后可能情况会大不相同。 由于引用类型向值类型的转换,也将导致我们在使用 Swift 开发时的思考方式发生变化。以往的 Foundation 框架中类型的可变性是由不可变类型和它的可变类型版本 (比如 `NSData` 和 `NSMutableData`) 来进行区分的。而在 Swift 3 中,一般来说将只有作为结构体的不可变类型 (比如 `Data`),对于这类结构体的改变,将会是更安全的基于写时复制的行为,而不再是原来可变对象那样的危险的内存操作。这在很多时候除了保证数据共享时的安全性以外,内部的引用特性也保证了调用速度。实际上,因为减少了不必要的复制 (比如根据一个不可变对象创建相应的可变对象),实际上通过 Swift 3 的 API 使用 Foundation 的速度将比原来更快! 关于 Swift 3 的更多内容,我会在我的《Swifter - 100 个 Swift 必备 tips》一书中通过补充章节的方式进行说明。同时,该书现有的 Swift 2 相关的描述和示例也会按照 Swift 3 的语法规范和特性进行更新,以适应最新版本。您可以访问 [swifter.tips](http://swifter.tips/buy) 获取这本书的更多相关内容。 ### Apple 生态和其他 另外影响比较重大消息是,在 iOS 9 引入的 ATS 将在来年更加严格。2017 年起,新提交的 app 将不再被允许进行 http 的访问,所有的 app 内的网络请求必须加密,通过 https 完成。所以如果你家 app 的服务器或者某些访问页面还是 http 的话,需要尽早过度到 https。 另外,watchOS 3 和 tvOS 也有一些新的内容。其中最重要的当属 watchOS 中可以使用 SceneKit 和 SpriteKit。虽然这两个框架本意是做游戏,但是 watch 的小屏幕和低性能可能并不足以支撑我们在这样一个受限平台很好的作品。但是这两个框架可以为交互乏味的 watchOS 提供很好的动画效果的补充,可能会是它们在 watchOS 上更合适的用途。 最后,OS X 改名为 macOS,有些媒体和开发者将其解读为去乔布斯化,其实我更倾向于这是一种强迫症和完美主义的基本需求。不管名字如何改变,Apple 在 iOS,macOS,watchOS 和 tvOS 这四个产品线上的布局已经完成,整个生态现在看来也还十分健康。Apple 在用户权益和隐私上的重视,以及像是在 https 上的推动,无疑都是这个时代前进的动力。 ### 总结 像往年一样,我会在之后逐渐补充 session 笔记,通过一些简单的例子说明相关的新增框架的使用方式。届时你也可以在这篇文章中找到相关链接,或者通过[订阅](https://store.objccn.io/subscribe/)的方式来确保在第一时间获取相关内容 (订阅时请不要使用 QQ 邮箱,会无法收到邮件)。不过直接访问 Apple 开发者网站 [WWDC 相关的内容](https://developer.apple.com/wwdc/)会是获取这些知识更快的方式。 Happy WWDC. Happy Coding! URL: https://onevcat.com/2016/04/first-wwdc/index.html.md Published At: 2016-04-19 21:10:11 +0900 # 写给初次参加 WWDC 的开发者的简明攻略 ![](/assets/images/2016/wwdc-2016-logo.jpg) 今天 Apple 宣布了 WWDC 16 的抽选开始,而 4 月 22 日周五将出抽选结果并开始购票。随着我们国内开发者收入水平的逐步提升,以及日益增长的与全球开发者接触和自我提高的需求,最近参与 WWDC 的中国开发者明显比以前要多,而抽选机制也正给了我们很好的参加机会,至少我们不需要熬夜和一群“疯子”在十几秒内抢票了。 如果您看到这篇文章的时候已经抽中了门票,并且付款成功并计划在 6 月的时候前往三藩的话,先大声说“恭喜恭喜恭喜你”!鉴于很多的参会者可能是第一次参加 WWDC,甚至可能是第一次到美国或者说出国进行访问,我准备了这篇简单的攻略,希望能够在你前往 WWDC 的旅途中有所帮助。这篇文章里的内容是我自己参加 WWDC 时的一些经验,可能会有过时或者比较主观的地方,还请见谅。 ## 入手门票后要做什么 如果你没有抽到门票的话,可以先把这篇帖子放到收藏夹里,明年再来看。所谓君子报仇,哦不..参加 WWDC,来年不晚! 要是你还没有护照,现在第一要务就是赶快去照相办护照,然后再回来看这篇文章。因为一般要七个工作日才能拿到护照,再加上办理签证等一系列手续,所以如果再拖几天的话你的行程基本就可以告吹了。 如果护照在手,那抽到门票以后需要考虑的第一件事情自然就是确定行程,办理签证了。WWDC 16 的举办日期是从 6 月 13 日周一到 17 日周五的一周,但是因为在会议开始前一天的周日白天,就需要去会场报道拿 T-shirt 和参会牌,所以你应该至少在 12 号早上到达三藩。考虑到如果你可能会想要拜访一下 Infinite Loop 的 Apple 总部或者是体验下当地民风民俗 (黑人叔叔教做人),顺便倒一倒时差的话,那么你可能周六甚至是周五就到达会比较好。 确定大概行程之后,可以先预定酒店。Apple 会在会议期间提供一些指定的宾馆,以折扣价的方式提供给参会者,在你收到门票的时候应该能够获得相关的预定信息。按照信息联系 Apple 或者酒店就可以预定了。一般来说,这些宾馆都在会场附近,你可以步行十分钟左右达到会场的地方。当然,因为是在城区 (downtown) 里,就算优惠过价格一般也不菲。如果有一同参加的小伙伴,完全可以搭伙住一个标间。这样一来这一周时间至少有人可以一起说话交流,不至于太无聊,二来城区的治安相对硅谷来说还是存在客观差距,所以结伴出行会是更好的选择。向 Apple 预定宾馆的时候最好还是动作快一些,因为 Apple 提供的宾馆其实有可能不能满足全部参会者的需求。另外,因为要提早到达,所以可能开会前的一两天需要另找住处。不过不论是 [Booking.com](http://www.booking.com/) 还是 [Airbnb](https://www.airbnb.com) 在三藩找一个住处都还是很简单。 在等待酒店预订结果的同时,就可以准备办理签证了。你可以在[美领馆的网站](http://www.ustraveldocs.com/cn_zh/cn-niv-visaapply.asp)上找到相关信息。简单说就是填写并提交 [DS-160](https://ceac.state.gov/genniv/) 表,支付签证费用,预约面谈时间等等。在填表的时候签证类型可以选择 B-1 商务或者 B-2 旅游,应该区别不大,而且现在美国签证要求也比较低,只要有稳定工作收入的话,应该难度都不大。如果没有自信的话,也可以[联系 Apple](https://developer.apple.com/contact/submit/?subject=wwdc) 让他们给你发一个 WWDC 的参会证明,这可以提高出签的几率。不过根据我个人的经验,Apple 的这个证明发到你手上的速度会比较慢。因为从约签到面试可能会要等一两周才有空位 (根据地方不同会有所区别),然后面试到出签又要三四天,最后再快递到家。所以说你至少应该准备三周时间来准备签证。有可能你并来不及等到 Apple 的证明发到你手上你就需要去面试了,不过好在这个证明一般来说只是聊胜于无的东西。面试时自然是资料越全越容易过,如果你已经准备好了来回机票,以及在美住宿的订单证明等等一系列东西的话,可能想不过签都很难。 关于签证,网上有很多其他信息了,就不再多补充了。 ## 关于交通 一般应该会买到 [San Francisco International Airport (SFO)](http://www.flysfo.com) 的机票吧,从机场可以直接坐 [BART (Bay Area Rapid Transit)](https://zh.wikipedia.org/wiki/舊金山灣區捷運系統) 一路往北到达 downtown (如果你预定的酒店在三藩市区的话)。 ![](https://upload.wikimedia.org/wikipedia/commons/0/04/BARTMapDay.svg) 美国其实是一个有车就很方便没车很痛苦的国家,三藩市也毫不例外。公共交通相比日本是比较可怜,基本上想去哪儿都不是很容易找到很好的路线。如果想要去 Apple 总部的话基本就是先 BART 或者 LOCAL train 到 Sunnyvale 之类的地方,再看是找 Uber 还是转公交之类的。这里还是推荐先装好 Uber 并且绑上可以美元结算的信用卡,在硅谷一片游历的时候会方便不少。 Palo Alto 和 Mountain View 一片有很多值得一去或者感受一下的地方,比如 Apple 啊 Google 啊 Facebook 啊之类的,作为 IT 从业者的话去参拜 + 照相留念都很好。不过因为一般来说都是不让随便进的,所以如果想参观公司内部的话可能需要提前寻找和预约在相应公司里工作的小伙伴。 Apple 估计很快就会搬到新总部去,所以今年可能是 Inifinite Loop 最后一次接待 WWDC 的开发者了。一般 Apple 总部的 Apple Store 会在 WWDC 开始前一天延长营业。和世界上其他的 Apple Store 不同,你在这里可以买到很多像是马克杯、衬衫、笔记本 (不是电脑) 或者钥匙链这样的周边。因为开起会来你肯定是没什么时间过来的了,因此如果要去逛逛逛或者买买买,请一定提前做好计划。 ![](/assets/images/2016/wwdc-infinite-loop.jpg) 另外,如果时间充裕的话还也可以顺路去逛逛斯坦福感受下这个地球上最好的大学 (之一) 的氛围,就在 Palo Alto 站下来走一下就到。在斯坦福的对外书店里你也可以找到很多纪念品。 ![](/assets/images/2016/wwdc-stanford.jpg) ## 关于天气 三藩六月份并不热,并不是可以短袖短裤的时节。一般来说一个普通的长袖衬衫,或者再加一件外套都是可以的。另外早晚和中午温差会比较大,如果早晚出门的话可能最好加件厚一点的外衣。 穿衣基本可以参照往年 WWDC 演讲者和观众来,就不会有什么错。 ## 关于注册和 Keynote 值得注意的是,今年的注册地点和周一 Keynote 的场所从原来的 Moscone West 搬到了 Bill Graham Civic Auditorium。离 Bill Graham Civic Auditorium 最近的车站是 Civic Center,而离 Moscone West 最近的车站是 Powell Street,注意确认好目的地,不要走错地方。 注册一定要在周日完成,建议一早就过去拿会牌和 T-shirt,然后就可以到处乱逛了。会牌非常重要,请一定保管好,之后进出会场只看有没有挂好会牌。因为会牌丢失不补,所以要是不小心弄丢的话,就只好安心享受三藩一周的美好度假时光了。 Keynote 以前会出现去晚了排队排不到进不了大厅,只能在外面看转播的悲剧。不过今年换了地方场地够大应该没问题了。不过如果想要坐一个好位置的话还是需要提前去排队。Keynote 开始的时间是周一上午十点,从以往几届的经验来看,前一天晚上八九点就开始排队的也大有人在。如果想要前排正中的位置,可能最迟晚上十一点就得去排,记得带上帐篷或者折叠床或者至少一把椅子。要是位置无所谓的话,还是建议多养养精神再去,毕竟 WWDC 是一周的持久战,体力相当重要。个人建议早上四五点左右开始排队会比较好,不至于坐到太后面,前一天也可以好好休息。排队过程中会有工作人员给大家发水,进会场以后肯定是又累又饿,不过好消息是会一路提供一些点心充饥。 接下来就享受真正的第一手资讯的 Keynote 吧!你能比世界上其他人早好几秒知道 Keynote 的内容,想想都有点小激动呢! ## 关于 Session 第一天没有技术 Session,早上 Keynote 然后下午的话按照惯例是 Platforms State of the Union 以及 Apple Design Awards。前者会统一介绍新 SDK 中的亮点,是所有 session 的总览,后者会介绍上一年里最优秀的几个 app,也代表了 Apple 所鼓励和看好的开发方向。虽然不会介绍具体技术相关内容,但是这两部分可以说也是 WWDC 的精华和浓缩,请千万不要错过。 技术 Session 从第二天开始,并且会回到 Moscone West 进行。Moscone West 有三层楼,不同的 Session 在各个会议厅同时进行,所以你必须做出取舍,选择你最感兴趣的内容去听。WWDC 的 app 会在 Keynote 之后更新各个 Session 的标题和简介,第一天晚上你就可以用 app 为接下来四天进行规划,排好“课程表”。三层楼中顶层的 Presidio 是最大的一个会议厅,所以 Apple 一般会把最重要的 session 放在这里。如果你不确定你想听什么 session 的话,直接进 Presidio 可能会是不错的选择。 有时候 session 的内容和你想像得会差得比较远,如果你发现对某个刚开始的 session 其实不感兴趣,要是位置比较靠后或者比较偏,就完全可以溜出去,找另外的会场和 session。不过如果起身不方便或者需要太多人让你的话,还是老老实实坐着听完比较好。 关于 session 内容则是仁者见仁智者见智了。因为在场的开发者水平也参差不齐,所以有些地方可能会讲得比较含糊 (真想彻底说明白太花费时间)。不过现场所能收获的气氛和第一时间的思考还是很赞的。 当然所有 session 都是英文的,现场也没有像是字幕啊同传啊之类的东西。虽然一般来说不会用什么太深奥的词,而且有投影和 demo 帮助理解,但是如果英文听力不太好的话可能还是会比较吃力。一般当天晚上晚上就会放出 session 的视频,最慢第二天也会放出前一天的视频,所以当场内容就算不懂,也还有视频进行补救,而且光听 session 肯定是不能全部理解的,看文档和动手写 demo 是必要的。所以就算一时没听太明白也不必特别在意,做好笔记标记提醒自己稍后再来回顾就好。 ## 关于 Labs 大家都知道,session 是公开的,所有注册的开发者都可以在会后看到全部 session 的视频。而 labs 才是参会者独享的资源。 所谓 Labs,就是 Apple 的工程师提供的一对一甚至多对一的解答和交流。Lab 按照 framework 或者部门职能进行分组。如果你在开发中遇到某些百思不得其解的问题,或者是有什么建议,或者某些需求无法实现,你都可以在 Lab 中找对应的小组去寻求帮助。另外,每年也会有很多开发者带着他们的 app 或者 demo 到 UI Design 或者 CocoaTouch 的 Lab 去询问如何改进 app 的设计或者交互。往往你能从这些工程师或者设计师口中套出一些现在比较吃香的设计理念,甚至如果你的设计非常符合他们的胃口的话,他们还会考虑在 App Store 上推荐你的 app。 App Store 团队的 lab 的话会有一些 App Store 的编辑参加。如果你的 app 能打动这些编辑,或者是要到他们的邮箱或者电话的话,之后你上架 app 前都可以先和他们联系让他们试用你的 app。因为 App Store 的推荐基本还是人为进行的,所以主动联系的话往往可以增加被推荐的几率。建立一些人脉关系也是参加 WWDC 的一大目的。 Moscone West 的一楼注册台后面就是铺开的 Lab 大厅。一般 session 的时间和 lab 开放的时间是冲突的。不过每个 lab 会有好几个时间段,所以你可以挑好时间过来。不过 lab 也是讲先来后到的,所以还是在开门之后尽早去会比较好。 ## 关于 Bash 这个 Bash 不是我们用的命令行的 bash,而是在周四下午 session 结束后的一个聚会,聚会上会提供食物,还将邀请一个知名乐队来为大家演奏。其实 Bash 就是 Apple 提供的一个和其他开发者轻松交流的机会 (前提是你能找得到对方在哪儿)。 今年的 Bash 预计在 Keynote 的 Bill Graham Civic Auditorium 进行,所以有可能一反以前户外的常态,变成一个户内的聚会。在周四上午 session 入场的时候,一般会发 Bash 的入场凭证 (纸做的手环什么的),你可以选择要或者不要。如果不要的话就是说你不打算参加,也就没有资格入场了。建议不要白不要,除非你有其他更重要的安排。因为周五的 session 相对来说会比较水一些,所以有些开发者会考虑不参加周五的会议,这样的话 Bash 对他们来说就是本次 WWDC 的结束了。如果你选择继续听周五的 session 的话 (毕竟钱都交了),要注意一下周五 Moscone 的闭馆时间会比之前几天早一些,小心不要因为滞留被关在里面过夜。 ## 关于吃的 ![](/assets/images/2016/wwdc-lunch.jpg) 周一到周五是有午餐提供的,但是虽说是午饭,对于我们天朝大吃货国出来的人来说可以用难以下咽来形容。每天选择很少,而且很固定,基本就是在烤牛肉三明治,烤鸡三明治以及猪肉三明治 (差点忘了,还有为素食主义者提供的蔬菜三明治) 之间进行选择。一般第一天 Keynote 以后大家都还是会选择在会场吃个饭,顺便可以下载 beta 版的 Xcode 和 iOS。但是之后几天的话,会有一些开发者选择出去吃饭。Moscone West 出门 4th Street 对面就有很多吃饭的地方,顺着 Howard St 往下稍微走个五分钟也有不少餐馆,如果你对吃的比较追求的话可以去试试。 周四因为有 Bash,所以会额外提供一些吃的东西,我个人觉得也是整个 WWDC 里 Apple 提供的唯一还算能吃的东西。主要会有像是披萨,烤肉,炒面以及各种啤酒或者软饮料之类的东西。如果你喜欢的话,可以敞开不断去拿,直到 Live 的演唱会开始。 会议中其他天的晚饭是不提供的,你可以自由选择,比如像是约上几个好朋友一起去吃牛排之类的。当然一个人的话也有像是麦当劳或者汉堡王这样的绝对安全的选择。和国内不同的是,如果是点菜类的餐馆,一般是会需要给小费的。基本上最后结账的时候给一点整钱然后说不用找了这样就行。或者刷卡的时候直接把小费和卡塞一起交给服务员就好,大家也不必再多说什么。 另外,Session 之间是提供免费的茶点和饮料的。不过因为 session 之间间隔都比较短,而且下一个 session 如果去晚了就没有好的位置了,所以很多时候要是饿了渴了的话可以提前一点出来找吃的。 ## 关于其他活动 如果你认为去 WWDC 只是去参加 Apple 的 Session 的话,你就大错特错了。WWDC 是一个和全球其他 Apple 开发者认识和结交的绝好机会。平时你有没有在 GitHub 上和某个国外小伙伴一起贡献代码?你有没有倾心于某个开源库并想见一见他的作者?平时读书的时候有个问题萦绕在你心中,一直想要请教原书作者?在 WWDC 上你会发现你有这样的机会。 相比于一次纯粹的技术交流和新 SDK 展示,WWDC 现在显然带有有更多的社交属性,可以帮助你结识很多其他开发者。当然,这是有前提的,那就是你积极联络和热心参与。[AltConf](http://altconf.com) 是一个最有名的活动,而现在已经演变成了另一场和 WWDC 同时召开的会议。很多有名的第三方开发者会在 AltConf 上分享他们过去一年的经验,以及对本次 WWDC 新公布的内容的一些看法。另外,你也可以关注 [WWDC Parties](https://2016.wwdcparties.com) 的内容以及他们的 [Twitter](https://twitter.com/wwdcparties) 帐号,上面会对一些 WWDC 期间举行的 meetup 进行汇总,这些 meetup 基本上时间来说都安排在晚上,和 session 并不冲突,如果有你感兴趣的内容的话,也千万不要错过。 另外,每年都会有开发者整理一些像是 WWDC Attendee List 的名单,比如[前年](http://swinden.com/wwdc-2014-attendee-list/)和[去年](http://swinden.com/wwdc-2015-attendee-list/)都有,不出意外的话今年应该也会继续。你可以关注一下并填写表单加入这个 list,而这个 list 也会维护一个 Slack 群,为开发者们互相认识提供良好的平台。 ## 关于其他 在 Moscone West 的会场会有 Company Store,出售一些 WWDC 相关的纪念品,比如 Polo 衫或者是带 WWDC logo 的帽子之类的。虽然价格不算便宜,但也还是合理,而且是切切实实的限量发行版本,所以很受欢迎。一般要是等到最后一天的话基本就是卖光光的节奏,所以如果想要的话最好早一点去买。 ![](/assets/images/2016/wwdc-restroom.jpg) 虽然有不少报道说会场的厕所会变成这个样子,但是实际上来看其实还好,并没有那么夸张。第一天的 Keynote 因为从排队,到开场,再到结束,会花费很长时间,Keynote 结束以后可能会比较悲惨一些。所以建议尽量在 Keynote 之前找机会解决一下,或者就少喝一点水,这样能减小到时候尴尬的可能性。 另外,如果有计划参加各种 parties 的话,最好注意一下时间不要太晚。要是自己没有车,又住在指定的 downtown 里的宾馆的话,那么在晚上天黑以后步行回宾馆其实并不是很好的选择。建议最好是至少两人结伴出行,并且叫 Uber 直接送回住处。身上尽量不用带太多钱,但最好带一些零钱之类的,毕竟安全比什么都重要。 其他的话..等我想起来再补充吧。 希望这篇不太“简明”的简明攻略能够帮到你。祝你 WWDC 参会顺利,旅游愉快。期待你与我们分享你的 WWDC 见闻~ URL: https://onevcat.com/2016/04/objccn-plan/index.html.md Published At: 2016-04-07 10:51:24 +0900 # ObjC 中国的工作回顾和之后的计划 小时候因为成绩还算凑合,所以经常会被任命做个班干部什么的。其实这并不是一份很有意思的工作,除了上课要被老师重点“关照”点名起来回答问题以外,最烦人的事情就是开学要写工作计划,期末要写工作总结了。耗时耗力不说,写出来的东西也并不会有什么人看。 所以我大抵对写计划和写总结这样的事情是抵触的。 顺便还希望这篇总结加计划的东西能有人有兴趣看。 时隔十几二十年后,再提笔 (其实是拿键盘) 开始写一份工作回顾和计划的时候,我却是怀着满心欢喜的。从 2014 年 3 月[第一个 commit](https://github.com/objccn/articles/commit/b5ba88afeea0e6c7d0bcbf49145cd72799d8c4ed) 开始,[ObjC 中国](http://objccn.io)这个由国内 iOS 开发者社区自行发起的翻译项目在两年内不断地发展壮大。到现在为止,这个项目陆续吸引了接近六十名国内优秀的 iOS 开发人员,我们一同完成了对 objc.io 原文期刊的翻译以及不断维护。 ### 新书 在今天,我可以高兴地向大家宣布,ObjC 中国将迎来一个新的开始。我们与 objc.io 进行了更加深入的合作,计划将他们的三本著作进行翻译,并希望能带给国内读者最好的阅读体验。目前,我们的第一本翻译书籍**《函数式 Swift》(Functional Swift)** 已经全部完成,并且将于今天开始出售。除了高质量的翻译以外,我们也争取到了更符合国内物价水平的出售价格。objc.io 的另外两本书籍,**《Core Data》**和**《Swift 进阶》(Advanced Swift)** 也正在翻译和校审中,我们稍后会给出关于它们的更多的信息。 > 您现在就可以访问[这个页面](https://store.objccn.io/products/functional-swift/)并以英文原版 1/4 的售价获取《函数式 Swift》的电子版本。 ### ObjC 中国的缘起 因为在 Twitter 上关注了不少国外的开发者,所以在 objc.io 的第一期期刊发布之前我就订阅了他们的邮件。而 objc.io 持续地发布高质量的文章,也直接奠定了它在国内外 iOS 开发社区中的地位。国内不少开发者都在很早就开始了对于 objc.io 的文章的翻译,据我所知,像是[唐天勇](https://github.com/tang3w)、[webfrogs](https://github.com/webfrogs) 等都是先行者。大家的出发点都是对于技术的喜爱和对钻研的热衷,希望在自我挑战的同时惠泽其他开发者。但是这类做法相对来说缺乏组织性,从而导致了大量的重复劳动以及各自迥异的翻译风格。而且当时也并没有一个成型的地方能够找到所有的内容,每篇文章如同闪亮的宝石,但却分散在偌大的互联网海洋之中,难以寻觅。 2014 年初的时候我经历了很多次的所谓“封闭开发”,因为我个人的开发速度比较快一些,而又不太好 (也不太愿意) 去插手其他同事的工作,所以在完成工作任务之余,就有一些自己的额外时间用来学习和提高。虽然我自己基本能够无障碍阅读 objc.io 的文章,但还是深感如果能有一个集中且高质量的翻译版本的话,必能对国内的 iOS 开发行业起到一些促进作用。于是抱着试一试的想法先向 objc.io 的创始人们发了邮件,询问是否能得到翻译授权。幸运的是他们很快回复了邮件,表示愿意授权由我来组织进行翻译,同时还很热心地提醒了他们注意到还有其他来自中国的开发者正在打算做同样的事情。本来我打算做这件事的目的就是减少重复劳动,提高翻译效率,因此我在第一时间联系了这些正在打算做同样事情的开发者。又很幸运,当时已经组织了一些翻译的[方一雄](https://github.com/FangYiXiong)、[破船](https://github.com/BeyondVincent)和 [Answer Huang](https://github.com/answer-huang) 都表示同意我的想法,因此,我们一同成立了 ObjC 中国,来完整并有序地翻译 objc.io 的内容。 可以说 ObjC 中国从一开始就得到了国内 iOS 开发者社区的帮助。我们收集并获取了很多已经存在的优秀译文的授权,原译者们也大都十分慷慨并支持我们的行动。而在翻译质量和速度的保证下,ObjC 中国逐渐吸引了更多的优秀开发者。我们采取的是在 objc.io 原文发布后个人认领的方式来分配任务,在项目中后期的时候,经常会出现原文发布两三分钟之内,译文的任务就已经被全部认领完成的盛况。而译者们也都殚精竭虑来保证译文质量,对于一个公益性质的项目来说,这确实难能可贵。 在 objc.io 的期刊完结后,objc.io 陆续出版了三本优秀的 iOS 开发书籍,内容涵盖了函数式编程,Core Data,以及一些进阶的 Swift 相关话题。在去年七月底 objc.io 的创始人给我们发邮件表示希望能将这三本书也翻译成中文版,这非常符合 ObjC 中国“将高质量的内容以更容易理解的方式带给国内开发者”的初衷,因此在几次探讨后我们共同决定启动对这三本书籍的翻译计划。我们从之前期刊翻译的贡献者中挑选了几位翻译质量优秀并且态度负责的译者,同时也从社区中征集了几名愿意帮助我们翻译的开发者,开始了这三本图书的翻译。 ### 收获 在两年之后的今天,二十四期涵盖了 iOS 开发方方面面的期刊依然是不少开发者进阶和学习的优秀资料。虽然之前我在国内 iOS 开发者中间可能有一点名气,但是更多的是来自在微博或者博客上吹水,而自己实际上并没有为社区做过什么大的贡献。我想,所谓的为世界做出一点贡献或者说回馈开发者社区,我现在大概是做到了的。这也是一件我自己觉着值得骄傲和自豪的事情。 当然,组织和维护这样一个体系是需要花费不少精力的,但是所能得到的收获 -- 不论是对自己能力的锻炼还是精神上的满足 -- 是完全值得这些付出的。我非常享受和其他大牛一起进步的过程,在翻译过程中,因为每一篇文章都需要进行审核和校对,因此我精读了所有这些文章,这对我自己在 iOS 开发上的进步起到了至关重要的作用。另外,我需要做不少其他的工作,像是架设网站,设计 logo,研究文字排版等等,这都是在日常工作里难以接触到的。 最重要的收获是获得了和其他开发者们一同工作的机会。因为我身处国外,所以很难有条件与国内的 iOS 开发者们见面,更别说一同工作。而 ObjC 中国恰好为我自己提供了一个非常优秀的平台,在这里我能有机会和热心认领文章的朋友交流思想,一同探讨技术细节。通过这个项目,我结识了很多优秀的 iOS 开发者,我从中也受益颇丰。 另外值得一提的是,这项工作给了我不少接触国外开发者的机会。与 objc.io 三名创始人来回的邮件沟通,和 [Chris](https://github.com/chriseidhof) 在北京和东京见了两面讨论翻译细节,机缘巧合加入了一个国外的 iOS Slack 频道等等。如果没有组织 ObjC 中国项目的话,我应该是不会得到这些机会的。 ### 计划 我们刚刚完成了《函数式 Swift》这本书的翻译和校对工作,我们由衷地希望您能够喜欢这本书的风格和内容。接下来,我们会将精力集中于翻译剩下的两本图书:《Core Data》和《Swift 进阶》。这两本书不论从内容和深度上来说,都会比《函数式 Swift》要广阔和深入,因此对我们也会是前所未有的挑战。不过,我们有之前期刊翻译时所积累的宝贵经验,我们有热心负责的译者和校对人员,我们有原版图书作者的耐心解答和解释,在这些条件的保障下,我们有足够的信心为大家带来一系列优秀的图书。 ObjC 中国的网站正在重新设计中,我们在之后将会把之前期刊的内容也迁移到[新的站点](https://store.objccn.io/)上来,以提供和书籍一样的统一的和更好的阅读体验。 另外,因为和 Chris 也算有些私交,所以我可以透露一下 objc.io 之后的计划。在晚些时候 objc.io 网站可能会重新启动并开始更新,不过这次它们会以新的形式给我们带来新的内容。不出意外的话,ObjC 中国也将继续与 objc.io 进行合作,为国内开发者带来高质量翻译。另一方面,我们也有计划进行一些原创的内容,或者直接在 objc.io 上发表内容,将中国 iOS 开发者的声音带给这个美好的世界。 ### 最后 服务器和域名和 GitHub 私有 repo 的钱都是我垫着的,我在想能不能找 Chris 给报销... URL: https://onevcat.com/2016/03/swift-throws/index.html.md Published At: 2016-03-29 15:37:24 +0900 # Swift 2 throws 全解析 - 从原理到实践 本文最初于 2015 年 12 月发布在 IBM developerWorks 中国网站发表,其网址是 [http://www.ibm.com/developerworks/cn/mobile/mo-cn-swift/index.html](http://www.ibm.com/developerworks/cn/mobile/mo-cn-swift/index.html)。如需转载请保留此行声明。 ## Swift 2 错误处理简介 throws 关键字和异常处理机制是 Swift 2 中新加入的重要特性。Apple 希望通过在语言层面对异常处理的流程进行规范和统一,来让代码更加安全,同时让开发者可以更加及时可靠地处理这些错误。Swift 2 中所有的同步 Cocoa API 的 `NSError` 都已经被 throw 关键字取代,举个例子,在文件操作中复制文件的 API 在 Swift 1 中使用的是和 Objective-C 类似的 `NSError` 指针方式: ```swift func copyItemAtPath(_ srcPath: String, toPath dstPath: String, error: NSErrorPointer) ``` 而在 Swift 2 中,变为了 throws: ```swift func copyItemAtPath(_ srcPath: String, toPath dstPath: String) throws ``` 使用时,Swift 1.x 中我们需要创建并传入 `NSError` 的指针,在方法调用后检查指针的内容,来判断是否成功: ```swift let fileManager = NSFileManager.defaultManager() var error: NSError? fileManager.copyItemAtPath(srcPath, toPath: dstPath, error: &error) if error != nil { // 发生了错误 } else { // 复制成功 } ``` 在实践中,因为这个 API 仅会在极其特定的条件下 (比如磁盘空间不足) 会出错,所以开发者为了方便,有时会直接传入 nil 来忽视掉这个错误: ```swift let fileManager = NSFileManager.defaultManager() // 不关心是否发生错误 fileManager.copyItemAtPath(srcPath, toPath: dstPath, error: nil) ``` 这种做法无形中降低了应用的可靠性以及从错误中恢复的能力。为了解决这个问题,Swift 2 中在编译器层级就对 throws 进行了限定。被标记为 throws 的 API,我们需要完整的 `try catch` 来捕获可能的异常,否则无法编译通过: ```swift let fileManager = NSFileManager.defaultManager() do { try fileManager.copyItemAtPath(srcPath, toPath: dstPath) } catch let error as NSError { // 发生了错误 print(error.localizedDescription) } ``` 对于非 Cocoa 框架的 API,我们也可以通过声明 `ErrorType` 并在出错时进行 throw 操作。这为错误处理提供了统一的处理出口,有益于提高应用质量。 ## throws 技术内幕 throws 关键字究竟做了些什么,我们可以用稍微底层一点的手法来进行一些探索。 ### Swift 编译器,SIL 及汇编 所有的 Swift 源文件都要经过 Swift 编译器编译后才能执行。Swift 编译过程遵循非常经典的 LLVM 编译架构:编译器前端首先对 Swift 源码进行词法分析和语法分析,生成 Swift 抽象语法树 (AST),然后从 AST 生成 Swift 中间语言 (Swift Intermediate Language,SIL),接下来 SIL 被翻译成通用的 LLVM 中间表述 (LLVM Intermediate Representation, LLVM IR),最后通过编译器后端的优化,得到汇编语言。整个过程可以用下面的框图来表示: ![](/assets/images/2016/compiler-flow.png) Swift 编译器提供了非常灵活的命令行工具:swiftc,这个命令行工具可以运行在不同模式下,我们通过控制命令行参数能获取到 Swift 源码编译到各个阶段的结果。使用 `swiftc --help` 我们能得知各个模式的使用方法,这篇文章会用到下面几个模式,它们分别将 Swift 源代码编译为 SIL,LLVM IR 和汇编语言。 ```sh > swiftc --help ... MODES: -emit-sil Emit canonical SIL file(s) -emit-ir Emit LLVM IR file(s) -emit-assembly Emit assembly file(s) (-S) ... ``` 在 Swift 开源之前,将源码编译到各个阶段是探索 Swift 原理和实现方式的重要方式。即使是在 Swift 开源后的今天,在面对一段代码时,想要知道编译结果和底层的行为,最快的方式还是查看编译后的语句。我们接下来将会分析一段简单的 throw 代码,来看看 Swift 的异常机制到底是如何运作的。 ### throw,try,catch 深层解析 为了保持问题的简单,我们定义一个最简单的 `ErrorType` 并用一个方法来将其抛出,源代码如下: ```swift // throw.swift enum MyError: ErrorType { case SampleError } func throwMe(shouldThrow: Bool) throws -> Bool { if shouldThrow { throw MyError.SampleError } return true } ``` 使用 swiftc 将其编译为 SIL: ```sh swiftc -emit-sil -O -o ./throw.sil ./throw.swift ``` 在输出文件中,可以找到 `throwMe` 的对应 Swift 中间语言表述: ``` // throw.throwMe (Swift.Bool) throws -> Swift.Bool sil hidden @_TF5throw7throwMeFzSbSb : $@convention(thin) (Bool) -> (Bool, @error ErrorType) { bb0(%0 : $Bool): debug_value %0 : $Bool // let shouldThrow // id: %1 %2 = struct_extract %0 : $Bool, #Bool.value // user: %3 cond_br %2, bb1, bb2 // id: %3 bb1: // Preds: bb0 ... throw %4#0 : $ErrorType // id: %7 bb2: // Preds: bb0 ... return %9 : $Bool // id: %10 } ``` `_TF5throw7throwMeFzSbSb` 是 `throwMe` 方法 [Mangling](https://mikeash.com/pyblog/friday-qa-2014-08-15-swift-name-mangling.html) 以后的名字。在去掉一些噪音后,我们可以将这个方法的签名等效看做: ```swift throwMe(shouldThrow: Bool) -> (Bool, ErrorType) ``` 它其实是返回的是一个 `(Bool, ErrorType)` 的多元组。和一般的多元组不同的是,第二个元素 `ErrorType` 被一个 `@error` 修饰了。这个修饰让多元组具有了“排他性”,也就是只要多元组的第一个元素被返回即可:在条件分支 `bb2` (也即没有抛出异常的正常分支) 中,仅只有 Bool 值被返回了。而对于发生错误需要抛出的处理,SIL 层面还并没有具体实现,只是生成了对应的错误枚举对象,然后对其调用了 throw 命令。 这就是说,我们想要探索 throw 的话,还需要更深入一层。用 swiftc 将源代码编译为 LLVM IR: ``` swiftc -emit-ir -O -o ./throw.ir ./throw.swift ``` 结果中 `throwMe` 的关键部分为: ``` define hidden i1 @_TF5throw7throwMeFzSbSb(i1, %swift.refcounted* nocapture readnone, %swift.error** nocapture) #0 { } ``` 这是我们非常熟悉的形式,参数中的 `swift.error**` 和 Swift 1 以及 Objective-C 中使用 `NSError` 指针来获取和存储错误的做法是一致的。在示例的这种情况下,LLVM 后端针对 swift.error 进行了额外处理,最终得到的汇编码的伪码是这样的 (在未启用 -O 优化的条件下): ``` int __TF5throw7throwMeFzSbSb(int arg0) { rax = arg0; var_8 = rdx; if ((rax & 0x1) == 0x0) { rax = 0x1; } else { rax = swift_allocError(0x1000011c8, __TWPO5throw7MyErrorSs9ErrorTypeS_); var_18 = rax; swift_willThrow(rax); rax = var_8; *rax = var_18; } return rax; } ``` 函数最终的返回是一个 int,它有可能是一个实际的整数值,也有可能是一个指向错误地址的指针。这和 Swift 1 中传入 `NSErrorPointer` 来存储错误指针地址有明显不同:首先直接使用返回值我们就可以判断调用是否出现错误,而不必使用额外的空间进行存储;其次整个过程中没有使用到 `NSError` 或者 Objective-C Runtime 的任何内容,在性能上要优于传统的错误处理方式。 我们在了解了 throw 的底层机理后,对于 `try catch` 代码块的理解自然也就水到渠成了。加入一个 `try catch` 后的 SIL 相关部分是: ``` try_apply %15(%16) : $@convention(thin) (Bool) -> (Bool, @error ErrorType), normal bb1, error bb9 // id: %17 bb1(%18 : $Bool): ... bb9(%80 : $ErrorType): ... ``` 其他层级的实现也与此类似,都是对返回值进行类型判断,然后进入不同的条件分支进行处理。 ## ErrorType 和 NSError throw 语句的作用对象是一个实现了 `ErrorType` 接口的值,本节将探讨 `ErrorType` 背后的内容,以及 `NSError` 与它的关系。在 Swift 公开的标准库中,`ErrorType` 接口并没有公开的方法: ```swift public protocol ErrorType { } ``` 这个接口有一个 extension,但是也没有公开的内容: ```swift extension ErrorType { } ``` 我们可以通过使用 LLDB 的类型检索来获取关于这个接口的更多信息。在调试器中运行 `type lookup ErrorType`: ``` (lldb) type lookup ErrorType protocol ErrorType { var _domain: Swift.String { get } var _code: Swift.Int { get } } extension ErrorType { var _domain: Swift.String { get {} } } ``` 可以看到这个接口实际上需要实现两个属性:domain 描述错误的所属域,code 标记具体的错误号,这和传统的 `NSError` 中定义一个错误所需要的内容是一致的。事实上 `NSError` 在 Swift 2 中也实现了 `ErrorType` 接口,它简单地返回错误的域和错误代码信息,这是 Swift 1 到 2 的错误处理相关 API 转换的兼容性的保证。 虽然 Cocoa/CocoaTouch 框架中的 throw API 抛出的都是 `NSError`,但是应用开发者更为常用的表述错误的类型应该是 enum,这也是 Apple 对于 throw 的推荐用法。对于实现了 `ErrorType` 的 enum 类型,其错误代码将根据 enum 中 case 声明的顺序从 0 开始编号,而错误域的名字就是它的类型全名 (Module 名 + 类型名): ```swift MyError.InvalidUser._code: 0 MyError.InvalidUser._domain: ModuleName.MyError MyError.InvalidPassword._code: 1 MyError.InvalidPassword._domain: ModuleName.MyError ``` 这虽然为按照错误号来处理错误提供了可能性,但是我们在实践中应当尽量依赖 enum case 而非错误号来对错误进行辨别,这可以提高稳定性,同时降低维护的压力。除了 enum 以外,struct 和 class 也是可以实现 `ErrorType` 接口,并作为被 throw 的对象的。在使用非 enum 值来表示错误的时候,我们可能需要显式地指定 `_code` 和 `_domain`,以区分不同的错误。 ## throws 的一些实践 ### 异步操作中的异常处理 带有 throw 的方法现在只能工作在同步 API 中,这受限于异常抛出方法的基本思想。一个可以抛出的方法实际上做的事情是执行一个闭包,接着选择返回一个值或者是抛出一个异常。直接使用一个 throw 方法,我们无法在返回或抛出之前异步地执行操作并根据操作的结果来决定方法行为。要改变这一点,理论上我们可以通过将闭包的执行和对结果的操作进行分离,来达到“异步抛出”的效果。假设有一个同步方法可以抛出异常: ```swift func syncFunc(arg: A) throws -> R ``` 通过为其添加一次调用,可以将闭包执行部分和结果判断及返回部分分离: ```swift func syncFunc(arg: A)() throws -> R ``` 这相当于将原来的方法改写为了: ```swift func syncFunc(arg: A) -> (Void throws -> R) ``` 这样,单次对 `syncFunc` 的调用将返回一个 `Void throws -> R` 类型的方法,这使我们有机会执行代码而不是直接返回或抛出。在执行 `syncFunc` 返回后,我们还需要对其结果用 `try` 来进行判断是否抛出异常。利用这个特点,我们就可以将这个同步的抛出方法改写为异步形式: ```swift func asyncFunc(arg: A, callback: (Void throws -> R) -> Void) { // 处理操作 let result: () throws -> R = { // 根据结果抛出异常或者正常返回 } return callback(result) } // 调用 asyncFunc(arg: someArg) { (result) -> Void in do { let r = try result() // 正常返回 } catch _ { // 出现异常 } } ``` 绕了一大个圈子,我们最后发现这么做本质上其实和简单地使用 `Result` 来表示异步方法的结果并没有本质区别,反而增加了代码阅读和理解的难度,也破坏了 Swift 异常机制原本的设计意图,其实并不是可取的选项。除开某些非常特殊的用例外,对于异步 API 现在并不适合使用 throw 来进行错误判断。 ### 异常处理的测试 在 XCTest 中暂时还没有直接对 Swift 2 异常处理进行测试的方法,如果想要测试某个调用应当/不应当抛出某个异常的话,我们可以对 XCTest 框架的方法进行一些额外但很简单包装,传入 block 并运行,然后在 try 块或是 catch 块内进行 XCTAssert 的断言检测。在 Apple 开发者论坛有关于这个问题的更[详细的讨论](https://forums.developer.apple.com/thread/5824),完整的示例代码和使用例子可以在[这里](https://gist.github.com/onevcat/128ab20a4a9177ca3c82)找到。 ### 类型安全的异常抛出 Swift 2 中异常另一个严重的不足是类型不安全。throw 语句可以作用于任意满足 `ErrorType` 的类型,你可以 throw 任意域的错误。而在 catch 块中我们也同样可以匹配任意的错误类型,这一切都没有编译器保证。由于这个原因,现在的异常处理机制并不好用,需要处理异常的开发者往往需要通读文档才能知道可能会有哪些异常,而文档的维护又是额外的工作。缺少强制机制来保证异常抛出和捕获的类型的正确性,这为程序中 bug 的出现埋下了隐患。 事实上从我们之前对 throw 底层实现的分析来看,在语言层面上实现只抛出某一特定类型的错误并不是很困难的事情。但是考虑到与 `NSError` 和传统错误处理 API 兼容问题,Swift 2 中并没有这样实现,也许我们在之后的 Swift 版本中能看到限定类型的异常机制。 ### 异常的调试和断点 Swift 的异常抛出并不是传统意义的 exception,在调试时抛出异常并不会触发 Exception 断点。另外,throw 本身是语言的关键字,而不是一个 symbol,它也不能触发 Symbolic 类型的断点。如果我们希望在所有 throw 语句执行的时候让程序停住的话,需要一些额外的技巧。在之前 throw 的汇编实现中,可以看到所有 throw 语句在返回前都会进行一次 `swift_willThrow` 的调用,这就是一个有效的 Symbolic 语句,我们设置一个 `swift_willThrow` 的 Symbolic 断点,就可以让程序在 throw 的时候停住,并使用调用栈信息来获知程序在哪里抛出了异常。 > 补充,在最新版本的 Xcode 中,Apple 直接为我们在断点类型中加上了 “Swift Error Breakpoint” 的选项,它背后做的就是在 `swift_willThrow` 上添加一个断点。不过因为有了更直接的方法,我们现在不再需要手动去添加这个符号断点了。 ![](/assets/images/2016/swift-error-breakpoint.png) ## 参考资料 * MikeAsh Friday Q&A,Swift 中 Name Mangling 的定义和使用:[Friday Q&A: Swift Name Mangling](https://mikeash.com/pyblog/friday-qa-2014-08-15-swift-name-mangling.html) * Apple 开发者论坛,关于 Swift 中 throw 的测试方法:[How to write a unit test which passes if a function throws?](https://forums.developer.apple.com/thread/5824) URL: https://onevcat.com/2016/02/swift-performance/index.html.md Published At: 2016-02-25 11:32:24 +0900 # Swift 性能探索和优化分析 ![](/assets/images/2016/taylor-swift.jpg) 本文首发在 CSDN《程序员》杂志,订阅地址 [http://dingyue.programmer.com.cn/](http://dingyue.programmer.com.cn/)。 Apple 在推出 Swift 时就将其冠以先进,安全和高效的新一代编程语言之名。前两点在 Swift 的语法和语言特性中已经表现得淋漓尽致:像是尾随闭包,枚举关联值,可选值和强制的类型安全等都是 Swift 显而易见的优点。但是对于高效一点,就没有那么明显了。在 2014 年 WWDC 大会上 Apple 宣称 Swift 具有超越 Objective-C 的性能,甚至某些情况下可以媲美和超过 C。但是在 Swift 正式发布后,很多开发者发现似乎 Swift 性能并没有像宣传的那样优秀。甚至在 Swift 经过了一年半的演进的今天,稍有不慎就容易掉进语言性能的陷阱中。本文将分析一些使用 Swift 进行 iOS/OS X 开发时性能上的考量和做法,同时,笔者结合自己这一年多来使用 Swift 进行开发的经验,也给出了一些对应办法。 ## 为什么 Swift 的性能值得期待 Swift 具有一门高效语言所需要具备的绝大部分特点。与 Ruby 或者 Python 这样的解释型语言不需要再做什么对比了,相较于其前辈的 Objective-C,Swift 在编译期间就完成了方法的绑定,因此方法调用上不再是类似于 Smalltalk 的消息发送,而是直接获取方法地址并进行调用。虽然 Objective-C 对运行时查找方法的过程进行了缓存和大量的优化,但是不可否认 Swift 的调用方式会更加迅速和高效。 另外,与 Objective-C 不同,Swift 是一门强类型的语言,这意味 Swift 的运行时和代码编译期间的类型是一致的,这样编译器可以得到足够的信息来在生成中间码和机器码时进行优化。虽然都使用 LLVM 工具链进行编译,但是 Swift 的编译过程相比于 Objective-C 要多一个环节 -- 生成 Swift 中间代码 (Swift Intermediate Language,SIL)。SIL 中包含有很多根据类型假定的转换,这为之后进一步在更低层级优化提供了良好的基础,分析 SIL 也是我们探索 Swift 性能的有效方法。 最后,Swift 具有良好的内存使用的策略和结构。Swift 标准库中绝大部分类型都是 `struct`,对值类型的使用范围之广,在近期的编程语言中可谓首屈一指。原本值类型不可变性的特点,往往导致对于值的使用和修改意味着创建新的对象,但是 Swift 巧妙地规避了不必要的值类型复制,而仅只在必要时进行内存分配。这使得 Swift 在享受不可变性带来的便利以及避免不必要的共享状态的同时,还能够保持性能上的优秀。 ## 对性能进行测试 《计算机程序设计艺术》和 TeX 的作者[高德纳][Donald]曾经在论文中说过: > 过早的优化是万恶之源。 和很多人理解的不同,这并不是说我们不应该在项目的早期就开始进行优化,而是指我们需要弄清代码中性能真正的问题和希望达到的目标后再开始进行优化。因此,我们需要知道性能问题到底出在哪儿。对程序性能的测试一定是优化的第一步。 在 Cocoa 开发中,对于性能的测试有几种常见的方式。其中最简单是直接通过输出 log 来监测某一段程序运行所消耗的时间。在 Cocoa 中我们可以使用 [`CACurrentMediaTime`][cacurrentmediatime-doc] 来获取精确的时间。这个方法将会调用 mach 底层的 `mach_absolute_time()`,它的返回是一个基于 [Mach absolute time unit][mach-time] 的数字,我们通过在方法调用前后分别获取两次时刻,并计算它们的间隔,就可以了解方法的执行时间: ```swift let start = CACurrentMediaTime() // ... let end = CACurrentMediaTime() print("测量时间:\(end - start)") ``` 为了方便使用,我们还可以将这段代码封装到一个方法中,这样我们就能在项目中需要测试性能的地方方便地使用它了: ```swift func measure(f: ()->()) { let start = CACurrentMediaTime() f() let end = CACurrentMediaTime() print("测量时间:\(end - start)") } measure { doSomeHeavyWork() } ``` `CACurrentMediaTime` 和 log 的方法适合于我们对既有代码进行探索,另一种有效的方法是使用 Instruments 的 Time Profiler 来在更高层面寻找代码的性能弱点。将程序挂载到 Time Profiler 后,每一个方法调用的耗时都将被记录。 当我们寻找到需要进行优化的代码路径后,为其建立一个单元测试来持续地检测代码的性能是很好的做法。在 Xcode 中默认的测试框架 XCTest 提供了检测并汇报性能的方法:`measureBlock`。通过将测试的代码块放到 `measureBlock` 中,Xcode 在测试时就会多次运行这段代码,并统计平均耗时。更方便的是,你可以设定一个基准,Xcode 会记录每次的耗时并在性能没有达到预期时进行提醒。这保证了随着项目开发,关键的代码路径不会发生性能上的退化。 ```swift func testPerformance() { measureBlock() { // 需要性能测试的代码 } } ``` ![](/assets/images/2016/test-measure.png) ## 优化手段,常见误用及对策 ### 多线程、算法及数据结构优化 在确定了需要进行性能改善的代码后,一个最根本的优化方式是在程序设计层面进行改良。在移动客户端,对于影响了 UI 流畅度的代码,我们可以将其放到后台线程进行运行。Grand Central Dispatch (GCD) 或者 `NSOperation` 可以让我们方便地在不同线程中切换,而不太需要去担心线程调度的问题。一个使用 GCD 将繁重工作放到后台线程,然后在完成后回到主线程操作 UI 的典型例子是这样的: ```swift let queue = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0) dispatch_async(queue) { // 运行时间较长的代码,放到后台线程运行 dispatch_async(dispatch_get_main_queue()) { // 结束后返回主线程操作 UI } } ``` 将工作放到其他线程虽然可以避免主线程阻塞,但它并不能减少这些代码实际的执行时间。进一步地,我们可以考虑改进算法和使用的数据结构来提高效率。根据实际项目中遇到的问题的不同,我们会有不同的解决方式,在这篇文章中,我们难以覆盖和深入去分析各种情况,所以这里我们只会提及一些共通的原则。 对于重复的工作,合理地利用缓存的方式可以极大提高效率,这是在优化时可以优先考虑的方式。Cocoa 开发中 `NSCache` 是专门用来管理缓存的一个类,合理地使用和配置 `NSCache` 把开发者中从管理缓存存储和失效的工作中解放出来。关于 `NSCache` 的详细使用方法,可以参看 NSHipster 关于这方面的[文章][nscache-nshipster]以及 Apple 的[相关文档][nscache-doc]。 在程序开发时,数据结构使用上的选择也是重要的一环。Swift 标准库提供了一些很基本的数据结构,比如 `Array`、`Dictionary` 和 `Set` 等。这些数据结构都是配合泛型的,在保证数据类型安全的同时,一般来说也能为我们提供足够的性能。关于这些数据的容器类型方法所对应的复杂度,Apple 都在标准库的文档或者注释中进行了标记。如果标准库所提供的类型和方法无法满足性能上的要求,或者没有符合业务需求的数据结构的话,那么考虑使用自己实现的数据结构也是可选项。 如果项目中有很多数学计算方面的工作导致了效率问题的话,考虑并发计算能极大改善程序性能。iOS 和 OS X 都有针对数学或者图形计算等数字信号处理方面进行了专门优化的框架:[Accelerate.framework][accelerate-doc],利用相关的 API,我们可以轻松快速地完成很多经典的数字或者图像处理问题。因为这个框架只提供一组 C API,所以在 Swift 中直接使用会有一定困难。如果你的项目中要处理的计算相对简单的话,也可以使用 [Surge][surge],它是一个基于 Accelerate 框架的 Swift 项目,让我们能在代码里从并发计算中获得难以置信的性能提升。 ### 编译器优化 Swift 编译器十分智能,它能在编译期间帮助我们移除不需要的代码,或者将某些方法进行内联 (inline) 处理。编译器优化的强度可以在编译时通过参数进行控制,Xcode 工程默认情况下有 Debug 和 Release 两种编译配置,在 Debug 模式下,LLVM Code Generation 和 Swift Code Generation 都不开启优化,这能保证编译速度。而在 Release 模式下,LLVM 默认使用 "Fastest, Smallest [-Os]",Swift Compiler 默认使用 "Fast [-O]",作为优化级别。我们另外还有几个额外的优化级别可以选择,优化级别越高,编译器对于源码的改动幅度和开启的优化力度也就越大,同时编译期间消耗的时间也就越多。虽然绝大部分情况下没有问题,但是仍然需要当心的是,一些优化等级采用的是激进的优化策略,而禁用了一些检查。这可能在源码很复杂的情况下导致潜在的错误。如果你使用了很高的优化级别,请再三测试 Release 和 Debug 条件下程序运行的逻辑,以防止编译器优化所带来的问题。 值得一提的是,Swift 编译器有一个很有用的优化等级:"Fast, Whole Module Optimization",也即 `-O -whole-module-optimization`。在这个优化等级下,Swift 编译器将会同时考虑整个 module 中所有源码的情况,并将那些没有被继承和重载的类型和方法标记为 `final`,这将尽可能地避免动态派发的调用,或者甚至将方法进行内联处理以加速运行。开启这个额外的优化将会大幅增加编译时间,所以应该只在应用要发布的时候打开这个选项。 虽然现在编译器在进行优化的时候已经足够智能了,但是在面对编写得非常复杂的情况时,很多本应实施的优化可能失效。因此保持代码的整洁、干净和简单,可以让编译器优化良好工作,以得到高效的机器码。 ### 尽量使用 Swift 类型 为了和 Objective-C 协同工作,很多 Swift 标准库类型和对应的 Cocoa 类型是可以隐式的类型转换的,比如 `Swift.Array` 与 `NSArray`,`Swift.String` 和 `NSString` 等。虽然我们不需要在语言层面做类型转换,但是这个过程却不是免费的。在转换次数很多的时候,这往往会成为性能的瓶颈。一个常见的 Swift 和 Objective-C 混用的例子是 JSON 解析。考虑以下代码: ```swift let jsonData: NSData = //... let jsonObject = try? NSJSONSerialization .JSONObjectWithData(jsonData, options: []) as? [String: AnyObject] ``` 这是我们日常开发中很常见的代码,使用 `NSJSONSerialization` 将数据转换为 JSON 对象后,我们得到的是一个 NSObject 对象。在 Swift 中使用时,我们一般会先将其转换为 `[String: AnyObject]`,这个转换在一次性处理成千上万条 JSON 数据时会带来严重的性能退化。Swift 3 中我们可能可以基于 Swift 的 Foundation 框架来解决这个问题,但是现在,如果存在这样的情况,一种处理方式是避免使用 Swift 的字典类型,而使用 `NSDictionary`。另外,适当地使用 lazy 加载的方法,也是避免一次性进行过多的类型转换的好思路。 尽可能避免混合地使用 Swift 类型和 `NSObject` 子类,会对性能的提高有所帮助。 ### 避免无意义的 log,保持好的编码习惯 在调试程序时,很多开发者喜欢用输出 log 的方式对代码的运行进行追踪,帮助理解。Swift 编译器并不会帮我们将 `print` 或者 `debugPrint` 删去,在最终 app 中它们会把内容输出到终端,造成性能的损失。我们当然可以在发布时用查找的方式将所有这些 log 输出语句删除或者注释掉,但是更好的方法是通过添加条件编译来将这些语句排除在 Release 版本外。在 Xcode 的 Build Setting 中,在 **Other Swift flags** 的 Debug 栏中加入 `-D DEBUG` 即可加入一个编译标识。 ![](/assets/images/2016/debug-flag.png) 之后我们就可以通过将 `print` 或者 `debugPrint` 包装一下: ```swift func dPrint(item: Any) { #if DEBUG print(item) #endif } ``` 这样,在 Release 版本中,`dPrint` 将会是一个空方法,所有对这个方法的调用都会被编译器剔除掉。需要注意的是,在这种封装下,如果你传入的 `items` 是一个表达式而不是直接的变量的话,这个表达式还是会被先执行求值的。如果这对性能也产生了可测的影响的话,我们最好用 `@autoclosure` 修饰参数来重新包装 `print`。这可以将求值运行推迟到方法内部,这样在 Release 时这个求值也会被一并去掉: ```swift func dPrint(@autoclosure item: () -> Any) { #if DEBUG print(item()) #endif } dPrint(resultFromHeavyWork()) // Release 版本中 resultFromHeavyWork() 不会被执行 ``` ## 小结 Swift 还是一门很新的语言,并且处于高速发展中。因为现在 Swift 只用于 Cocoa 开发,因此它和 Cocoa 框架还有着千丝万缕的联系。很多时候由于这些原因,我们对于 Swift 性能的评估并不公正。这门语言本身设计就是以高性能为考量的,而随着 Swift 的开源和进一步的进化,以及配套框架的全面重写,相信在语言层面上我们能获得更好的性能和编译器的支持。 最好的优化就是不用优化。在软件开发中,保证书写正确简洁的代码,在项目开始阶段就注意可能存在的性能缺陷,将可扩展性的考虑纳入软件构建中,按照实际需求进行优化,不要陷入为了优化而优化的怪圈,这些往往都可以让我们避免额外的优化时间,让我们的工作得更加愉快。 ### 参考 - [Swift Intermediate Language][sil] - [NSCache - NSHipster][nscache-nshipster] - [NSCache 文档][nscache-doc] - [Surge][surge] [Donald]: https://zh.wikipedia.org/wiki/高德纳 [cacurrentmediatime-doc]: https://developer.apple.com/library/mac/documentation/Cocoa/Reference/CoreAnimation_functions/index.html#//apple_ref/c/func/CACurrentMediaTime [mach-time]: https://developer.apple.com/library/mac/qa/qa1398/_index.html [sil]: http://llvm.org/devmtg/2015-10/slides/GroffLattner-SILHighLevelIR.pdf [nscache-nshipster]: http://nshipster.com/nscache/ [nscache-doc]: https://developer.apple.com/library/ios/documentation/Cocoa/Reference/NSCache_Class/ [accelerate-doc]: https://developer.apple.com/library/tvos/documentation/Accelerate/Reference/AccelerateFWRef/index.html [surge]: https://github.com/mattt/Surge URL: https://onevcat.com/2016/01/create-framework/index.html.md Published At: 2016-01-19 15:32:24 +0900 # 如何打造一个让人愉快的框架 > 这是我在今年 1 月 10 日 [@Swift 开发者大会](http://atswift.io) 上演讲的文字稿。相关的视频还在制作中,没有到现场的朋友可以通过这个文字稿了解到这个 session 的内容。 虽然我的工作是程序员,但是最近半年其实我的主要干的事儿是养了一个小孩。 所以这半年来可以说没有积累到什么技术,反而是积累了不少养小孩的心得。 当知道了有这么次会议可以分享这半年来的心得的时候,我毫不犹豫地选定了主题。那就是 > 如何打造一个让人愉快的**小孩** 但考虑到这是一次开发者会议...当我把这个想法和题目提交给大会的时候,被残酷地拒绝了。考虑到我们是一次开发者大会,所以我需要找一些更合适的主题。其实如果你对自己的代码有感情的话,我们开发和维护的项目或者框架就如同自己的孩子一般这也是我所能找到的两者的共同点。所以,我将原来拟定的主题换了两个字: > 如何打造一个让人愉快的**框架** 在正式开始前,我想先给大家分享一个故事。我们那儿的 iOS 开发小组里有一个叫做武田君的人,他的代码写得不错,做事也非常严谨,可以说是楷模般的员工。但是他有一个致命的弱点 -- 喜欢自己发明轮子。他出于本能地抗拒在代码中使用第三方框架,所以接到开发任务以后他一般都要花比其他小伙伴更多的时间才能完成。 武田君其实在各个方面都有建树...比如 - 网络请求 - 模型解析 - 导航效果 - 视图动画 ... 不过虽然造了很多轮子,但是代码的重用比较糟糕,耦合严重。在新项目中使用的话,只能复制粘贴,然后针对项目修修补补。因为承担的任务总是没有办法完成,他一直是项目deadline的决定者,在日本这种社会,压力可想而知。就在我这次回国之前,武田君来向我借了一本我本科时候最喜欢的书。就是这本: ![](/assets/images/2016/book-cover.jpg) 我有时候就想,到底是什么让一个开发者面临如此大的精神压力,我们有什么办法来缓解这种压力。在我们有限的开发生涯中,应该如何有效利用时间来做一些更有价值的事情。 显然,我们不可能一天建成罗马,也不可能一个人建成罗马。我们需要一些方法把自己和别人写的代码组织起来,高效地利用,并以此为基础构建软件。这就涉及到使用和维护框架。如何利用框架迅速构建应用,以及在开发和发布一个框架的时候应该注意一些什么,这是我今天想讲的主题。当然,为了让大家安心和专注于今天的内容,而不是挂念武田君的命运,特此声明: > 以上故事纯属虚构,如有雷同实属巧合 ## 使用框架 在了解如何制作框架之前,先让我们看看如何使用框架。可以说,如果你想成为一个框架的提供者,首先你必须是一个优秀的使用者。 在 iOS 开发的早期,使用框架其实并不是一件让人愉悦的事情。可能有几年经验的开发者都有这样的体会,那就是: > 忘不了 那些年,被手动引用和 `.a` 文件所支配的恐惧 其实恐惧源于未知,回想一下,当我们刚接触软件开发的时候,懵懵懂懂地引用了一个静态库,然后面对一排排编译器报错时候手足无措的绝望。但是当我们了解了静态库的话,我们就能克服这种恐惧了。 ### 什么是静态库 (Static Library) 所谓静态库,或者说 .a 文件,就是一系列从源码编译的目标文件的集合。它是你的源码的实现所对应的二进制。配合上公共的 .h 文件,我们可以获取到 .a 中暴露的方法或者成员等。在最后编译 app 的时候.a 将被链接到最终的可执行文件中,之后每次都随着app的可执行二进制文件一同加载,你不能控制加载的方式和时机,所以称为静态库。 在 iOS 8 之前,iOS 只支持以静态库的方式来使用第三方的代码。 ### 什么是动态框架 (Dynamic Framework) 与静态相对应的当然是动态。我们每天使用的 iOS 系统的框架是以 .framework 结尾的,它们就是动态框架。 Framework 其实是一个 bundle,或者说是一个特殊的文件夹。系统的 framework 是存在于系统内部,而不会打包进 app 中。app 的启动的时候会检查所需要的动态框架是否已经加载。像 UIKit 之类的常用系统框架一般已经在内存中,就不需要再次加载,这可以保证 app 启动速度。相比静态库,framework 是自包含的,你不需要关心头文件位置等,使用起来很方便。 ### Universal Framework iOS 8 之前也有一些第三方库提供 .framework 文件,但是它们实质上都是静态库,只不过通过一些方法进行了包装,相比传统的 .a 要好用一些。像是原来的 Dropbox 和 Facebook 等都使用这种方法来提供 SDK。不过因为已经脱离时代,所以在此略过不说。有兴趣和需要的朋友可以参看一下[这里](https://github.com/kstenerud/iOS-Universal-Framework)和[这里](https://github.com/jverkoey/iOS-Framework)。 ### Library v.s. Framework 对比静态库和动态框架,后者是有不少优势的。 首先,静态库不能包含像 xib 文件,图片这样的资源文件,其他开发者必须将它们复制到 app 的 main bundle 中才能使用,维护和更新非常困难;而 framework 则可以将资源文件包含在自己的 bundle 中。 其次,静态库必须打包到二进制文件中,这在以前的 iOS 开发中不是很大的问题。但是随着 iOS 扩展(比如通知中心扩展或者 Action 扩展)开发的出现,你现在可能需要将同一个 .a 包含在 app 本体以及扩展的二进制文件中,这是不必要的重复。 最后,静态库只能随应用 binary 一起加载,而动态框架加载到内存后就不需要再次加载,二次启动速度加快。另外,使用时也可以控制加载时机。 动态框架有非常多的优点,但是遗憾的是以前 Apple 不允许第三方框架使用动态方式,而只有系统框架可以通过动态方式加载。 很多时候我们都想问,Apple,凭什么? 好吧,这种事也不是一次两次了...不过好消息是:。 ### Cocoa Touch Framework Apple 从 iOS 8 开始允许开发者有条件地创建和使用动态框架,这种框架叫做 Cocoa Touch Framework。 虽然同样是动态框架,但是和系统 framework 不同,app 中的使用的 Cocoa Touch Framework 在打包和提交 app 时会被放到 app bundle 中,运行在沙盒里,而不是系统中。也就是说,不同的 app 就算使用了同样的 framework,但还是会有多份的框架被分别签名,打包和加载。 Cocoa Touch Framework 的推出主要是为了解决两个问题:首先是应对刚才提到的从 iOS 8 开始的扩展开发。其次是因为 Swift,在 Swift 开源之前,它是不支持编译为静态库的。虽然在开源后有编译为静态库的可能性,但是因为 Binary Interface 未确定,现在也还无法实用。这些问题会在 Swift 3 中将被解决,但这至少要等到今年下半年了。 现在,Swift runtime 不在系统中,而是打包在各个 app 里的。所以如果要使用 Swift 静态框架,由于 ABI 不兼容,所以我们将不得不在静态包中再包含一次 runtime,可能导致同一个 app 包中包括多个版本的运行时,暂时是不可取的。 ### 包和依赖管理 在使用框架的时候,用一些包管理和依赖管理工具可以简化使用流程。其中现在使用最广泛的应该是 [CocoaPods](http://cocoapods.org](http://cocoapods.org)。 CocoaPods 是一个已经有五年历史的 ruby 程序,可以帮助获取和管理依赖框架。 CocoaPods 的主要原理是框架的提供者通过编写合适的 PodSpec 文件来提供框架的基本信息,包括仓库地址,需要编译的文件,依赖等 用户使用 Podfile 文件指定想要使用的框架,CocoaPods 会创建一个新的工程来管理这些框架和它们的依赖,并把所有这些框架编译到成一个静态的 libPod.a。然后新建一个 workspace 包含你原来的项目和这个新的框架项目,最后在原来的项目中使用这个 libPods.a 这是一种“侵入式”的集成方式,它会修改你的项目配置和结构。 本来 CocoaPods 已经准备在前年发布 1.0 版本,但是 Swift 和动态框架的横空出世打乱了这个计划。因为必须提供对这两者的支持。不过最近 1.0.0 的 beta 已经公布,相信这个历时五年的项目将在最近很快迎来正式发布。 从 0.36.0 开始,可以通过在 Podfile 中添加 `use_frameworks!` 来编译 CocoaTouch Framework,也就是动态框架。 因为现在 Swift 的代码只能被编译为动态框架,所以如果你使用的依赖中包含 Swift 代码,又想使用 CocoaPods 来管理的话,必须选择开启这个选项。 `use_frameworks!` 会把项目的依赖全部改为 framework。也就是说这是一个 none or all 的更改。你无法指定某几个框架编译为动态,某几个编译为静态。我们可以这么理解:假设 Pod A 是动态框架,Pod B 是静态,Pod A 依赖 Pod B。要是 app 也依赖 Pod B:那么要么 Pod A 在 link 的时候找不到 Pod B 的符号,要么 A 和 app 都包含 B,都是无解的情况。 使用 CocoaPods 很简单,用 Podfile 来描述你需要使用和依赖哪些框架,然后执行 pod install 就可以了。下面是一个典型的 Podfile 的结构。 ```ruby # Podfile platform :ios, '8.0' use_frameworks! target 'MyApp' do pod 'AFNetworking', '~> 2.6' pod 'ORStackView', '~> 3.0' pod 'SwiftyJSON', '~> 2.3' end ``` ```bash $ pod install ``` [Carthage](https://github.com/Carthage/Carthage) 是另外的一个选择,它是在 Cocoa Touch Framework 和 Swift 发布后出现的专门针对 Framework 进行的包管理工具。 Carthage 相比 CocoaPods,采用的是完全不同的一条道路。Carthage 只支持动态框架,它仅负责将项目 clone 到本地并将对应的 Cocoa Framework target 进行构建。之后你需要自行将构建好的 framework 添加到项目中。和 CocoaPods 需要提交和维护框架信息不同,Carthage 是去中心化的 它直接从 git 仓库获取项目,而不需要依靠 podspec 类似的文件来管理。 使用上来说,Carthage 和 CocoaPods 类似之处在于也通过一个文件 `Cartfile` 来指定依赖关系。 ```ruby # Cartfile github "ReactiveCocoa/ReactiveCocoa" github "onevcat/Kingfisher" ~> 1.8 github "https://enterprise.local/hello/repo.git" ``` ```bash $ carthage update ``` 在使用 Framework 的时候,我们需要将用到的框架 Embedded Binary 的方式链接到希望的 App target 中。 随着上个月 Swift 开源,有了新的可能的选项,那就是 [Swift Package Manager](https://swift.org/package-manager/)。这可能是未来的包管理方式,但是现在暂时不支持 iOS 和 tvOS (也就是说 UIKit 并不支持)。 Package Manager 实际上做的事情和 Carthage 相似,不过是通过 `llbuild` (low level build system)的跨平台编译工具将 Swift 编译为 .a 静态库。 这个项目很新,从去年 11 月才开始。不过因为是 Apple 官方支持,所以今后很可能会集成到 Xcode 工具链中,成为项目的标配,非常值得期待。但是现在暂时还无法用于应用开发。 ## 创建框架 作为框架的用户你可能知道这些就能够很好地使用各个框架了。但是如果你想要创建一个框架的话,还远远不够。接下来我们说一说如何创建一个框架。 Xcode 为我们准备了 framework target 的模板,直接创建这个 target,就可以开始编写框架了。 添加源文件,编写代码,编译,完成,就是这么简单。 app 开发所得到产品直接面向最终用户;而框架开发得到的是一个中间产品,它面向的是其他开发者。对于一款 app,我们更注重使用各种手段来保证用户体验,最终目的是解决用户使用的问题。而框架的侧重点与 app 稍有不同,像是集成上的便利程度,使用上是否方便,升级的兼容等都需要考虑。虽然框架的开发和 app 的开发有不少不同,但是也有不少共通的规则和需要遵循的思维方式。 ### API 设计 #### 最小化原则 基于框架开发的特点,相较于 app 开发,需要更着重地考虑 API 的设计。你标记为 public 的内容将是框架使用者能看到的内容。提供什么样的 API 在很大程度上决定了其他的开发者会如何使用你的框架。 在 API 设计的时候,从原则上来说,我们一开始可以提供尽可能少的接口来完成必要的任务,这有助于在框架初期控制框架的复杂程度。 之后随着逐步的开发和框架使用场景的扩展,我们可以添加公共接口或者将原来的 internal 或者 private 接口标记为 public 供外界使用。 ```swift // Do this public func mustMethod() { ... } func onlyUsedInFramework() { ... } private func onlyUsedInFile() { ... } ``` ```swift // Don't do this public func mustMethod() { ... } public func onlyUsedInFramework() { ... } public func onlyUsedInFile() { ... } ``` #### 命名考虑 在决定了 public 接口以后,我们很快就会迎来编程界的最难的问题之一,命名。 在 Objective-C 时代 Cocoa 开发的类型或者方法名称就以一个长字著称,Swift 时代保留了这个光荣传统。Swift 程序的命名应该尽量表意清晰,不用奇怪的缩写。在 Cocoa 的世界里,精确比简短更有吸引力。 几个例子,相比于简单的 `remove`,`removeAt` 更能表达出从一个集合类型中移除元素的方式。而 `remove` 可能导致误解,是移除特定的 int 还是从某个 index 移除? ```swift // Do this public mutating func removeAt(position: Index) -> Element ``` ```swift // Don't do this public mutating func remove(i: Int) -> Element // <- index or element? ``` 同样,`recursivelyFetch` 表达了递归地获取,而 `fetch` 很可能被理解为仅获取当前输入。 ```swift // Do this public func recursivelyFetch(urls: [(String, Range)]) throws -> [T] ``` ```swift // Don't do this public func fetch(urls: [(String, Range)]) throws -> [T] // <- how? ``` 另外需要注意方法名应该是动词或者动词短语开头,而属性名应该是名词。当遇到冲突时,(比如这里的 displayName,既可以是名字也可以是动词)应该特别注意属性和方法的上下文造成的理解不同。更好的方式是避免名动皆可的词语,比如把 displayName 换为 screenName,就不会产生歧义了。 ```swift public var displayName: String public var screenName: String // <- Better ``` ```swift // Don't do this public func displayName() -> String // <- noun or verb? Why returning `String`? ``` 在命名 API 时一个有用的诀窍是为你的 API 写文档。如果你用一句话无法将一个方法的内容表述清楚的话,这往往就意味着 API 的名字有改进的余地。好的 API 设计可以让有经验的开发者猜得八九不离十,看文档更多地只是为了确认细节。一个 API 如果能做到不需要看文档就能被使用,那么它肯定是成功的。 关于 API 的命名,Apple 官方给出了一个很详细的[指南](https://swift.org/documentation/api-design-guidelines/) (Swift API Design Guidelines),相信每个开发者的必读内容。遵守这个准则,和其他开发者一道,用约定俗称的方式来进行编程和交流,这对提高框架质量非常,非常,非常重要(重要的事情要说三遍,如果你在我的演讲中只能记住一页的话,我希望是这一页。如果你还没有看过这个指南,建议去看一看,只需要花十分钟时间。) #### 优先测试,测试驱动开发 你应该是你自己写的框架的第一个用户,最简单的使用你自己的框架的方式就是编写测试。据我所知,在 app 开发中,很多时候单元测试被忽视了。但是在框架开发中,这是很重要的一个环节。可能没有人会敢使用没有测试的框架。除了保证功能正确以外,通过测试,你可以发现框架中设计不合理的地方,并在第一时间进行改善。 为框架编写测试的方式和为 app 测试类似, Swift 2 开始可以使用 @testable 来把框架引入到测试 module。这样的话可以调用 internal 方法。 不过对于框架来说,理论上只测试 public 就够了。但是我个人推荐使用 testable,来对一些重要的 internal 的方法也进行测试。这可以提高开发和交付时的信心。 ```swift // In Test Target import XCTest @testable import YourFramework class FrameworkTypeTests: XCTestCase { // ... } ``` --- ### 开发时的选择 #### 命名冲突 在 Objective-C 中的 static library 里一个常见问题是同样的符号在链接时会导致冲突。 Swift 中我们可以通过 module 来提供类似命名空间隔离,从而避免符号冲突。但是在对系统已有的类添加 extension 的时候还是需要特别注意命名的问题。 ```swift // F1.framework extension UIImage { public method() { print("F1") } } // F2.framework extension UIImage { public method() { print("F2") } } ``` 比如在框架 F1 和 F2 中我们都对 UIImage 定义了 method 方法,分别就输出自己来自哪个框架。 如果我们需要在同一个文件里的话引入的话: ```swift // app import F1 import F2 UIImage().method() // Ambiguous use of 'method()' ``` 在 app 中的一个文件里同时 import F1 和 F2,就会产生编译错误,因为 F1 和 F2 都为同一个类型 UIImage 定义了 method,编译器无法确定使用哪个方法。 当然因为有 import 控制,在使用的时候注意一下源文件的划分,避免同时 import F1 和 F2,似乎就能解决这个问题。 ```swift // app import F1 UIImage().method() // 输出 F2 (结果不确定) ``` 确实,只 import F1 的话,编译错误没有了,但是运行的时候有可能看到虽然 import 的是 F1,但是实际上调用到的是 F2 中的方法。 这是因为虽然有命名空间隔离,但 NSObject 的 extension 实际上还是依赖于 Objective-C runtime 的,这两个框架都在 app 启动时候被加载,运行时究竟调用了哪个方法是和加载顺序相关的,并不确定。 这种问题可以实际遇到的话,会非常难调试。 所以我们开发框架时的选择,对于已存在类型的 `extension`,**必须添加前缀**, 这和以前我们写 Objective-C 的 Category 的时候的原则是一样的。 上面的例子里,在开发的时候,不应该写这样的代码,而应该加上合适的前缀,以减少冲突的可能性。 ```swift // Do this // F1.framework extension UIImage { public f1_method() { print("F1") } } // F2.framework extension UIImage { public f2_method() { print("F2") } } ``` #### 资源 bundle 刚才提到过,framework 的一大优势是可以在自己的 bundle 中包含资源文件。在使用时,不需要关心框架的用户的环境,直接访问自己的类型的 bundle 就可以获取框架内的资源。 ```swift let bundle = NSBundle(forClass: ClassInFramework.self) let path = bundle.pathForResource("resource", ofType: "png") ``` ## 发布框架 最后说说发布和维护一个框架。辛苦制作的框架的最终目的其实就是让别人使用,一个没有人用的框架可以说是没有价值的。 如果你想让更多的人知道你的框架,那抛开各种爱国感情和个人喜好,可以说 iOS 或者 Swift 开发者的发布目的地只有一个,那就是 GitHub。 当然在像是开源中国或者 CSDN 这样的代码托管服务上发布也是很好的选择,但是不可否认的现状是只有在 GitHub 上你才能很方便地和全世界其他地方的开发者分享和交流你的代码。 ### 选择依赖工具 关于发布,另外一个重要的问题,一般你需要选择支持一个或多个依赖管理工具。 #### CocoaPods 刚才也提到,CocoaPods 用 podspec 文件来描述项目信息,使用 CocoaPods 提供的命令行工具 可以创建一个 podspec 模板,我们要做的就是按照项目的情况编辑这个文件。 比如这里列出了一个podspec的基本结构,可以看到包含了很多项目信息。关于更详细的用法,可以参看 CocoaPods 的[文档](https://guides.cocoapods.org/making/getting-setup-with-trunk.html)。 ```bash pod spec create MyFramework ``` ```swift Pod::Spec.new do |s| s.name = "MyFramework" s.version = "1.0.2" s.summary = "My first framework" s.description = <<-DESC It's my first framework. DESC s.ios.deployment_target = "8.0" s.source = { :git => "https://github.com/onevcat/myframework.git", :tag => s.version } s.source_files = "Class/*.{h,swift}" s.public_header_files = ["MyFramework/MyFramework.h"] end ``` 提交到 CocoaPods 也很简单,使用它们的命令行工具来检查 podspec 语法和项目是否正常编译,最后推送 podspec 到 CocoaPods 的主仓库就可以了。 ```bash # 打 tag git tag 1.0.2 && git push origin --tags # podspec 文法检查 pod spec lint MyFramework.podspec # 提交到 CocoaPods 中心仓库 pod trunk push MyFramework.podspec ``` #### Carthage 另一个应该考虑尽量支持的是 Carthage,因为它的用户数量也不可小觑。 支持 Carthage 比 CocoaPods 要简单很多,你需要做的只是保证你的框架 target 能正确编译,然后在 Manage Scheme 里把这个 target 标记为 Shared 就行了。 #### Swift Package Manager Swift Package Manager 暂时还不能用于 iOS 项目的依赖管理,但是对于那些并不依赖 iOS 平台的框架来说,现在就可以开始支持 Swift Package Manager 了。 Swift Package Manager 按照文件夹组织来确定模块,你需要把你的代码放到项目根目录下的 Sources 文件夹里。 然后在根目录下创建 Package.swift 文件来定义 package 信息。这就是一个普通的 swift 源码文件,你需要做的是在里面定义一个 package 成员,为它指定名字和依赖关系等等。Package Manager 命令将根据这个文件和文件夹的层次来构建你的框架。 ```swift // Package.swift import PackageDescription let package = Package( name: "MyKit", dependencies: [ .Package(url: "https://github.com/onevcat/anotherPacakge.git", majorVersion: 1) ] ) ``` ### 版本管理 在发布时另外一个需要特别注意的是版本。在 Podfile 或者 Cartfile 中指定依赖版本的时候我们可以看到类似这样的小飘箭头的符号,这代表版本兼容。比如兼容 2.6.1 表示高于 2.6.1 的 2.6.x 版本都可以使用,而 2.7 或以上不行;同理,如果兼容 2.6 的话,2.6,2.7,2.8 等等这些版本都是兼容的,而 3.0 不行。当然也可以使用 >= 或者是 = 这些符号。 ```ruby # Podfile pod 'AFNetworking', '~> 2.6.1' # 2.6.x 兼容 (2.6.1, 2.6.2, 2.6.9 等,不包含 2.7) # Podfile pod 'AFNetworking', '~> 2.6' # 2.x 兼容 (2.6.1, 2.7, 2.8 等,不包含 3.0) # Cartfile github "Mantle/Mantle" >= 1.1 # 大于等于 1.1 (1.1,1.1.4, 1.3, 2.1 等) ``` #### Semantic Versioning 和版本兼容 那什么叫版本兼容呢?我们看到的这套版本管理的方法叫做 [Semantic Versioning](http://semver.org)。它一般通过三个数字来定义版本。 > `x(major).y(minor).z(patch)` - major - 公共 API 改动或者删减 - minor - 新添加了公共 API - patch - bug 修正等 - `0.x.y` 只遵守最后一条 major 的更改表示用户必须修改自己的代码才能继续使用框架;minor 表示框架添加了新的 API,但是现有用户不需要修改代码可以保持原有行为不变;而 patch 则代表 API 没有改变,仅只是内部修正。 在这个约定下,同样的 major 版本号就意味着用户不需要修改现有代码就能继续使用这个框架,所以这是使用最广的一个依赖方式,在这个兼容保证下,用户可以自由升级 minor 版本号。 但是有一个例外,那就是还没有正式到达 1.0.0 版本号的框架。 这种框架代表还在早期开发,没有正式发布,API 还在调整中,开发者只需要遵守 patch 的规则,也就是说 0.1.1 和 0.1.2 只有小的修正。但是 0.2 和 0.1 是可以完全不兼容。如果你正在使用一个未正式发布的框架的时候,需要小心这一点。 框架的版本应该和 git 的 tag 对应,这可以和大多数版本管理工具兼容 一般来说用户会默认你的框架时遵循 Semantic Versioning 和兼容规则。 我们在设置版本的时候可能会注意到 Info.plist 中的 Version 和 Build 这两个值。虽然 CocoaPods 或者 Carthage 这样的包管理系统并不是使用 Info.plist 里的内容来确定依赖关系,但是我们最好还是保持这里的版本号和 git tag 的一致性。 当我们编译框架项目的时候,会在头文件或者 module map 里看到这样的定义。 框架的用户想要在运行时知道所使用的框架的版本号的话,使用这些属性会十分方便。这在做框架版本迁移的时候可能会有用。所以作为开发者,也应该维护这两个值来帮助我们确定框架版本。 ```c // MyFramework.h //! Project version string for MyFramework. FOUNDATION_EXPORT const unsigned char MyFrameworkVersionString[]; // 1.8.3 //! Project version number for MyFramework. FOUNDATION_EXPORT double MyFrameworkVersionNumber; // 347 // Exported module map //! Project version number for MyFramework. public var MyFrameworkVersionNumber: Double // 并没有导出 MyFrameworkVersionString ``` ### 持续集成 在框架开发中,一个优秀的持续集成环境是至关重要的。CI 可以保证潜在的贡献者在有保障的情况下对代码进行修改,减小了框架的维护压力。大部分 CI 环境对于开源项目都是免费的,得益于此,我们可以利用这个星球上最优秀的 CI 来确保我们的代码正常工作。 就 iOS 或者 OSX 开发来说,Travis CI, CircleCI, Coveralls,Codecov 等都是很好的选择。 开发总是有趣的,但是发布一般都很无聊。因为发布流程每次都一样,非常机械。无非就是跑测试,打 tag,上传代码,写 release log,更新 podspec 等等。虽然简单,但是费时费力,容易出错。对于这种情景,自动化流程显然是最好的选择。而相比于自己写发布脚本,在 Cocoa 社区我们有更好的工具,那就是 [fastlane](https://fastlane.tools)。 fastlane 是一系列 Cocoa 开发的工具的集合,包括跑测试,打包 app,自动截图,管理 iTunes Connect 等等。 不单单是 app 开发,在框架开发中,我们也可以利用到 fastlane 里很多很方便的命令。 使用 fastlane 做持续发布很简单,建立自己的合适的 Fastfile 文件,然后把你想做什么写进去就好了。比如这里是一个简单的 Fastfile 的例子: ```ruby # Fastfile desc "Release new version" lane :release do |options| target_version = options[:version] raise "The version is missed." if target_version.nil? ensure_git_branch # 确认 master 分支 ensure_git_status_clean # 确认没有未提交的文件 scan # 运行测试 sync_build_number_to_git # 将 build 号设为 git commit 数 increment_version_number(version_number: target_version) # 设置版本号 version_bump_podspec(path: "Kingfisher.podspec", version_number: target_version) # 更新 podspec git_commit_all(message: "Bump version to #{target_version}") # 提交版本号修改 add_git_tag tag: target_version # 设置 tag push_to_git_remote # 推送到 git 仓库 pod_push # 提交到 CocoaPods end $ fastlane release version:1.8.4 ``` AFNetworking 在 3.0 版本开始加入了 fastlane 做自动集成和发布,可以说把开源项目的 CI 做到了极致。在这里强烈推荐大家有空可以看一看[这个项目](https://github.com/AFNetworking/fastlane),除了使用 fastlane 简化流程以外,这个项目里还介绍了一些发布框架时的最佳实践。 我们能不能创造出像 AFNetworking 这样优秀的框架呢?一个优秀的框架包含哪些要求? ### 创建一个优秀的框架 一个优秀的框架必定包含这些特性:详尽的文档说明,可以指导后来开发者或者协作者迅速上手的注释, 完善的测试保证功能正确以及不发生退化,简短易读可维护的代码,可以让使用者了解版本变化的更新日志,对于issue的解答等等。 我们知道在科技界或者说 IT 界会有很多喜欢跑分的朋友。其实跑分这个事情可以辩证来看,它有其有意义的一面。跑分高的不一定优秀,但是优秀的跑分一般一定都会高。 不止在硬件类的产品,其实在框架开发中我们其实也可以做类似的跑分来检验我们的框架质量如何。 那就是 [CocoaPods Quality](https://cocoapods.org/pods/Kingfisher/quality),它是一个给开源框架打分的索引类的项目,会按照项目的受欢迎程度和完整度,并基于我们上面说的这些标准来对项目质量进行评判。 对于框架使用者来说,这可以成为一个选择框架时的[重要参考](https://guides.cocoapods.org/making/quality-indexes),分数越高基本可以确定可能遇到的坑会越少。 而对于框架的开发者来说,努力提高这个分数的同时,代码和框架质量肯定也得到了提高,这是一个自我完善的良好途径。在遇到功能类似的框架,我们也可以说“不服?跑个分” ### 可能的问题 最后想和大家探讨一下在框架开发中几个比较常见和需要特别注意的问题。 首先是兼容性的保证这里的兼容性不是 API 的兼容性,而是逻辑上的兼容性。 最可能出现问题的地方就是在不同版本中对数据持久化部分的处理是否兼容, 包括数据库和Key-archiving。比如在新版本中添加了一个属性,如何从老版本中进行迁移如果处理不当,很可能就造成严重错误甚至 crash。 另一个问题是重复的依赖。Swift 运行时还没有包含在设备中,如果对于框架,将 `EMBEDDED_CONTENT_CONTAINS_SWIFT` 设为 `YES` 的话,Swift 运行库将会被复制到框架中,这不是我们想见到的。在框架开发中这个 flag 一定是 NO,我们应该在 app 的 target 中进行设置。另外,可能你的框架会依赖其他框架,不要在项目中通过 copy file 把依赖的框架 copy 到框架 target 中,而是应该通过 Podfile 和 Cartfile 来解决依赖问题。 在决定框架依赖的时候,可能遇到的最大的问题就是不同框架的依赖可能[无法兼容](https://github.com/apple/swift-package-manager/blob/master/Documentation/DependencyHells.md)。 比如说一个 app 同时依赖了框架 A 和框架 B,而这两个框架又都依赖另一个框架 C。如果 A 中指定了兼容 1.1.2 而 B 中指定的是精确的 1.6.1 的话,app 的依赖关系就无法兼容了。 在框架开发中,如果我们依赖了别的框架,就必须考虑和其他框架及应用的兼容。 为了避免这种依赖无法满足的情况,我们最好尽量选择最宽松的依赖关系。 一般情况下我们没有必要限定依赖的版本,如果被依赖的框架遵守我们上面提到的版本管理的规则的话,我们并没有必要去选择固定某一个版本,而应该尽可能放宽依赖限制以避免无法兼容。 如果在使用框架中遇到这样的情况的话,去向依赖版本较旧的框架的维护者提 issue 或者发 pull request 会是好选择。 有一些开发者表示在转向使用 Framework 以后遇到首次应用加载速度变长的问题 ([参考 1](https://github.com/artsy/eigen/issues/586),[参考 2](rdar://22948371](http://openradar.appspot.com/radar?id=4867644041723904))。 社区讨论和探索结果表明可能是 Dynamic linker 在验证证书的时候的问题。 这个时间和 app 中 dynamic framework 的数量为 n^2 时间复杂度。不过现在大家发现这可能是 Apple 在证书管理上的一个 bug,应该是只发生在开发阶段。可能现在比较安全的做法是控制使用的框架数量在合理范围之内,就我们公司的产品来说,并没有在生产环境遇到这个问题。如果你在 app 开发中遇到类似情况,这算是一个小提醒。 最后,因为现在 Swift 现在 Binary Interface 还没有稳定,不论是框架还是应用项目中所有的 Swift 代码都必须用同样版本的编译器进行编译。就是说,每当 Swift 版本升级,原来 build 过的 framework 需要重新构建否则无法通过编译。对框架开发者来说,保持使用最新 release 版本的编译器来发布框架就不会有大的问题。 在 Swift 3.0 以后语言的二进制接口将会稳定,届时 Swift 也将被集成到 iOS 系统中。也就是说到今年下半年的话这个问题就将不再存在。 ## 从今天开始开发框架 做一个小的总结。现在这个时机对于中国的 Cocoa 开发者来说是非常好的时代,GitHub 中国用户很多,国内 iOS 开发圈子大家的分享精神和新东西的传播速度也非常快。可以说,我们中国开发者正在离这个世界的中心舞台越来越近,只要出现好东西的话,应该很快就能得到国内开发者的关注,继而登上 GitHub Trending 页面被世界所知。不要说五年,可能在两年之前,这都是难以想象的。 > Write the code, change the world. Swift 是随着这句口号诞生的,而现在开发者改变这个世界的力度可以说是前所未有的。 对于国内的开发者来说,我们真的应该希望少一些像 MingGeJS 这样的东西,而多一些能帮助这个世界的项目,以认真的态度多写一些有意义的代码,回馈开源社区,这于人于己都是一件好事。 希望中国的开发者能够在 Swift 这个新时代创造出更多世界级的框架,让这些框架能帮助全球的开发者一起构建更优秀的软件。 URL: https://onevcat.com/2015/12/2015-final/index.html.md Published At: 2015-12-29 01:03:40 +0900 # 写在 2015 的尾巴 上一次写类似年终总结的东西已经是大概快十年前的事情了,那时候还刚进大学,每天也就喜欢发一些无病呻吟的东西。回望之后,发现那些蹉跎掉的岁月确实无法再重新来过,不过也让我懂得了,幸好我们还能珍惜当下。 今年于我来说,注定是不平凡的一年。愈到年关,写作的冲动就愈发强烈,它驱使着我去记录下些什么,所以有了这篇写给自己的“阔别已久”的年终总结。 ## 无论何时,无论何地,平安就好 前几天因为北京雾霾很凶,看到有人在说柴静的雾霾报告,自己之前没看过,所以就找来补了补课。《穹顶之下》确实是一部非常好的新闻调查片子,除了有关雾霾的数据和结论以外,里面有两句话让我印象深刻,一句是“我不是多怕死,我只是不想这么活”,另一句是“一切,平安就好”。 算下来我明年也会满三十岁,不出意外的话,看起来我是懵懵懂懂走掉了人生的一小半旅途(左思右想,在这里还是不要立什么 flag 了)。十年前,我想过要改变这个世界,但是那时候能力不足,只有空谈和做梦的时候能改变世界,可以说是天资愚钝,不得其所;十年后,遑论多少,我确实是在以自己的力量改变着世界,哪怕仅只有那么一点点。但是现在,已经没有了当年的豪言壮志、慷慨激昂。古人说三十而立,意思是人到三十,应当会有属于自己的不可替代的位置。其实这句话只说对了一半,说对了人对于社会的那一半。但我们还有着对家庭的一半,我们每个人从出生起就已经有自己不可替代的位置了,那就是在父母的眼中。小时候出门玩耍,父母会在阳台上高呼注意安全;长大了上学,父母会在机场叮嘱小心谨慎;现在虽然远游在外,已然好几年没有回家,但是科技的进步让我们随时可以“见面”,每次他们也不忘一句提醒。以前我不懂,觉得啰嗦,无趣,直到去年同学发生的一些意外,加上今年自己也初为人父,方知这一片用心良苦。人的生命实在太脆弱了,而恰恰正是这脆弱得随时可能熄灭的一点点烛光,却牵动了太多人的心绪。 平安,其实并不仅是运气或者上天的恩赐,而更多的是自我的争取。子曾经曰过:“笃信好学,守死善道。危邦不入,乱邦不居。”虽说原意更偏向仕途前景,但是这何尝不是一种生活的警醒。你可以不喜欢这其中略微消极和中庸的思想,但是避开已知和可能的危险,也正是为了更长久地“守死善道”。 所以,想对自己的父母,对自己爱的人和爱自己的人们,对看这篇文章的你,说一句,无论何时,无论何地,平安就好。 ## 好奇的目光始终是最美的 今年最重要的事情当然是女儿满夏的出生。这半年来看着小朋友一天天长大,是一件让人非常开心的事情。她除了每天精力过剩不乖乖睡觉以外,给我的生活带来了很多很美好的时光。现在满夏正在学习如何很好地爬行,这会是她主动来进行探索的重要的一步。而在此之前,她已经学会了用触摸和撕咬的方式来了解这个对她来说还尚处陌生的美好世界。 真的想要感谢这个美好的世界。我们的父母在带大我们的时候,没有纸尿布,没有婴儿车,也没有如此多彩的玩具和针对婴儿的服务。但是二十多年来一切的变化,使得看护婴儿不再是一件艰难和特别劳心的事情。在满夏成长的每一天,我们都能一起开心度过,她在以她的节奏学习这个世界,而我也能从她的身上学到一些珍贵的品质 -- 对这个世界的好奇。 ![](/assets/images/2015/manxia.jpg) 无论是装机器人的纸盒,还是爬行垫的边角,再或者是 iPhone,只要是能塞进嘴里的东西,统统都被尝了个遍。因为这个世界对她来说整个都是新的,那些我们这些成年人已经习以为常的东西,在她的眼里都是最新鲜的存在。婴儿这种与生俱来的好奇其实我们每个人都有过,但是在活过三十年后,怎样的好奇才不会倦怠呢?我想大概没有。教主生前说过一句很有名的话,stay hungry stay foolish,而这句话有一个更美好的中文版本: > 求知若饥,虚心若愚 虽然这个翻译可以说[偏离原意很多](http://www.zhihu.com/question/19557797),但是并不妨碍这句话表达出求知好奇的重要性。如果一个人对身边的事物不再敏感,不再想去探索,那么可以说这个人生命和精神实际上也就走到了尽头。无论何时,无论何地,都要保持不断的学习,这是这半年来我从满夏那里所学到的东西。 所以我会同她一起看小画书,一起尝试防滑垫的味道,一起把头磕在柜子的边边角角,当然,也一起观察天上的星星和月亮。陪她玩,陪她笑,陪她哭,陪她闹。我想,如果我和她能一起成长,那这就是我们给彼此最好的礼物。 ## 拥抱,敬畏,感恩,回馈 今年在开源社区花了不少时间,1144 个开源提交,发布了几个还算有人用的框架,也参与了几个知名项目的开发。在公司里,完成了一个 SDK 的整合维护,作为主力开发参与两个完整项目,算是为今年的开发工作交上了一份满意的答卷。 ![](/assets/images/2015/github-2015.png) [objc 中国](http://objccn.io)的连载随着原刊的完结而结束,算是把一个跨越了两年的项目有始有终地做完了。这期间结识了很多国内 iOS 开发领域乐于分享的同行。他们在开发领域的专业和对翻译工作的尽心,让 objc 中国项目可以顺利地进行和完成。在这里有机会想统一地感谢这个项目的所有[贡献者](https://github.com/objccn/articles/graphs/contributors),谢谢他们为中文 objc 社区的繁荣和发展做出的不懈努力。同时,也感谢无数开源项目给予我们的帮助和启迪。正如 objc 中国这个项目的初衷,我们爱这个世界,愿程序让这个世界变得更美好! 今年是我在 LINE 工作的第一个完整的一年,入职面试的时候就针对 Swift 聊了很多,在公司这一年来也一直在使用 Swift 进行开发,并帮助公司的其他开发者逐渐过渡到 Swift。到目前为止这个进程进行得很顺利,现在 LINE 的新设的 app 已经全面转向使用 Swift,iOS 团队的其他工程师们也十分喜欢并且倾向于使用这门新语言。加上 LINE 比较自由的工作时间和宽松的企业氛围,可以说虽然比起国内 IT 业界来说,我算“收入微薄”,但这一年的工作确实十分开心。 2015 年间,我也陆陆续续用 Swift 写了一些开源框架,包括图像下载和缓存的 [Kingfisher](https://github.com/onevcat/Kingfisher),APNG 的解码库 [APNGKit](https://github.com/onevcat/APNGKit),以及前两天刚完成的一个小品级工具 [Rainbow](https://github.com/onevcat/Rainbow)。侥幸,这些框架也还或多或少受到了欢迎,同时有不少开发者在使用它们。其实不能免俗地说,我最初做开源的目的还是积攒人气,提高声望。在 2013 年的时候开发了一个给代码加注释的 Xcode 插件,获得了像 [iOS Dev Weekly](https://iosdevweekly.com) 的 [Dave Verwer](https://twitter.com/daveverwer) 和 [NSHipster](http://nshipster.com) 的 [mattt](https://twitter.com/mattt) 的关注和推广,获得了不少好评。那时候发现原来除了骗一波星星以外,我写的东西还是能帮助到其他人的。2014 年咬牙自掏机票门票参加了 WWDC,也在那里见到了很多不同地方的开发者。这个时代真的是一个神奇的时代,地球两端的人只要愿意,在 12 小时内就能互相见面。我们没有理由不拥抱这个世界,去和其他人交流,去一起探索。因为 Apple 的统一平台,开发者们有更多的机会认识彼此,这确实是一件幸事。 在今年,我和世界上其他 iOS 开发者的交流也在逐渐变多,这主要来源于向一些开源项目进行提交时候和这些项目开发者的交流。在这个过程中,我发现像是 [fastlane](https://github.com/fastlane/fastlane) 的 [Felix](https://github.com/KrauseFx),或者是 [Swift Package Manager](https://github.com/apple/swift-package-manager) 的 [Max Howell](https://github.com/mxcl) 这些维护者们都十分谦虚,很有责任心,并愿意接纳讨论。前几天还和老婆调侃说起知不知道那个翻转二叉树被拒的悲剧,转眼过几天我们居然有机会一起写代码,在 Twitter 上互 fo 和聊天,这种感觉真的非常奇妙。 我们在工作中向开源社区索取了很多,有机会的话,感恩和回馈会是对开源社区最好的回报。以认真的态度多写一些有意义的代码,这不仅会是对个人的提升,同时也能促进社区的发展,于人于己都是一件好事。中国的开发者数目众多,中国在互联网和移动开发中得声音也越来越响亮,我们应该以怎样的姿态来面对这个越来越开放的世界。如何拥抱变化,敬畏世界,感恩开源,回馈社区,也许是新时代每个有志于软件开发的工程师所需要审视和考虑的问题。 ## 真 总结 2015 年马上就要过去,要用一句话总结今年的话,那应该是“生活进入正轨,事业稳步发展”。一切都好,勿念。 ### P.S. 按照国际惯例,似乎应该写一份书单。但是因为好像今年读的技术书籍以外的书就只有一本反革命的日本右翼的书,所以还是不写为妙。取而代之,列一个最近已经买过和打算去买的比较有意思的东西的清单和一句话评语吧,主要目的是帮大家长长草剁剁手为大家春节准备礼物提供参考.. * [第四代 Apple TV](http://www.apple.com/tv/) - 已成为饭后闲时在 YouTube 看反动新闻的主力工具 * [Sphero SPRK 机器球](http://www.sphero.com/sphero-sprk) - 用来逗满夏玩的球型机器人,已成为其最喜欢的食物之一 * [Magic Trackpad 2](http://www.apple.com/cn/shop/product/MJ2R2/magic-trackpad-2) - 工作用主力输入设备,作为触摸板死忠表示很满意 * [飞利浦 HealtyWhite 电动牙刷](http://www.philips.com.cn/c-p/HX6712_04/sonicare-healthywhite-sonic-electric-toothbrush/overview) - 每次用震得欲仙欲死,欲罢不能 * [Dyson V6 Fluffy 吸尘器](http://shop.dyson.cn/vacuums/cordless-vacuums/v6-fluffy-209573-01) - 吸得真心干净,而且超方便,指哪儿哪儿干净 * [飞利浦 Shaver 9000 刮胡刀](http://www.philips.com.cn/c-p/S9111_12/shaver-series-9000-wet-and-dry-electric-shaver-with-smartclick-precision-trimmer-and-aquatec-wet-dry) - 刮得还算干净,但是没有 Dyson 吸尘器吸得干净,不过清洗方便是大优点 * [Wacom 影拓 Photo 绘图板](http://www.wacom.com/en-us/products/pen-tablets/intuos-photo) - 第一块入门级的板子,用来 PS 照片挺好 * [Beats Solo2 Wireless 高保真耳机](http://cn.beatsbydre.com/探索/按类别浏览/rose-gold/MLLG2.html) - 美妙自由的音乐体验,专注工作值得拥有 * [坚果文青版 蘇芳](http://www.smartisan.com/jianguo/#/overview) - 完美的 Android 4 系测试机 * [iPhone 6s 粉色和 iPad mini 新款](http://www.apple.com/cn/) - 老婆大人的日常用机,我只有看的份 URL: https://onevcat.com/2015/10/apple-tv/index.html.md Published At: 2015-10-31 16:49:46 +0900 # 当 App Store 遇上电视,开发者的第四代 Apple TV 开箱体验 ### 引子 2015 年 9 月,San Francisco。今年接近 100 华氏度的气温要比往年都更热,而 Apple 例行的秋季发布会也如期在这里举行。自从 iPhone 一战成名后,每年的 iPhone 旗舰机型都是移动通讯设备的业界标杆。而今年秋季发布会大家也自然地将重点放在了最新的 iPhone 6s 上。手机乏善可陈,除了硬件参数的一些常规升级外,我们并没有看到 iPhone 有多大进步。不过这也是大家预料之中,每隔两年一款的 s 系列定位就只是对之前版本的补充。另外,更大屏幕的 iPad Pro 也传闻已久。虽然 Apple 有意进军生产力市场,但是在平板电脑日渐衰颓的今天,一款并没有实质改变的设备是否能够力挽狂澜,还有待观察。 要说这场发布会上能配得上三藩 9 月气温的产品,可能就只剩新款 Apple TV 了。相比起它的前辈,新款的 Apple TV 拥有一颗 A8 处理器,运行了全新和独立的操作系统 tvOS,第三方开发者可以为 tvOS 开发独立运行的 app,并且它拥有一个全新的 TV App Store。在之前一两年时间里,Apple 开放了 iOS extension 和 Apple Watch app 的开发,以求完善平台用户体验。但是不论是通知中心还是手表 app,其实都是依附在已有的 iOS app 上的,仅仅是功能的补充。Apple TV 的 App Store 可以说是自 iOS 2 时代开放 App Store 以来 iOS 开发生态中最大的一次变革。开发者们现在有了全新的交互方式,全新的使用场景,可以再一次释放创造力。 这样的设备,作为一个 iOS 开发者,不论是利益所使还是兴趣所驱,结论自然都跑不离三个字,那就是“买买买”。 ### 开箱 因为自己的开发者账号是中国区的,所以没法申请到之前的一美金的 Developer Kit,既然薅不到资本主义的羊毛,那就只能出血自己购入了。不过还好 150 美金的售价相比起其他 Apple 产品来说确实只是小菜一碟。于是顺利成章地大前天下了单,然后顺理成章地今天快递盒子就出现在了门口。 ![apple-tv-1](/assets/images/2015/apple-tv-1.jpg) 包装的话和前几代产品差不错,颜色继承了一贯的黑乎乎,并没有像其他的 Apple 产品那样有别的颜色可以选择。不过考虑到电视机作为传统的黑色家电,也很少出现亮丽的色彩。也许想要等到白色或者金色的 Apple TV 的话,估计要看 Apple 自己动手做电视机了。 ![apple-tv-2](/assets/images/2015/apple-tv-2.jpg) 打开包装盒以后的内容,电视盒子的主体部分和带触摸板和 Siri 的遥控器并排摆放。本体的尺寸在长宽上和前代保持一致,但是厚度增加了不少。这主要还是由于需要集成的东西变多了,毕竟本代 Apple TV 内含足够强劲的 A8 以及 2G 内存,另外,蓝牙 wifi 什么的自不用说,也是一应俱全。遥控器上半部分是玻璃的触控板,为系统的操作提供了全新的交互方式,我之后会再提及。上一张内含物全部排开的图: ![apple-tv-3](/assets/images/2015/apple-tv-3.jpg) 左右两根线分别是电源线和一根普通的 USB-Lightning 的线缆,左下的说明书的话写有开始的欢迎语,以及一些关于本体接口和遥控器按键的简单说明。和 Apple 其他很多产品类似,这份说明书对于大部分人来说应该是不需要的。 值得一提的是,Apple 依然在看不到的地方做了不少功课。我们在放置 Apple TV 的时候,一般还是按照正常的方向放置的。在平时不可能看得到的底面上,Apple 也好不吝啬地做了大大的 logo。另外,设备整体摸上去触感也很不错。 ![apple-tv-4](/assets/images/2015/apple-tv-4.jpg) ### 安装 其实算不上安装。现在的电子产品要是不能开箱后插上电源就能傻瓜使用的话,是会受不少诟病的。当然啦,Apple TV 这样的机顶盒产品的话除了电源线,还需要一根 HDMI 来和高清电视连接。需要注意的是,HDMI 线是不包含在 Apple TV 盒子里的,需要自行准备。虽然现在客厅娱乐化家里或多或少应该都能翻出一两根 HDMI 的线,但是也不排除正好没有。所以在购买的时候可以确定一下,没有的话直接在 Apple Store 里买一根也是不错的选择。 ![apple-tv-5](/assets/images/2015/apple-tv-5.jpg) 本体背面除了电源线和 HDMI 接口外,还有两个插口,分别是以太网网线接口和一个 USB-C 接口。平时使用的话直接用内置的 wifi 就可以了,没有必要插网线。这个接口的目的应该是用来在 tvOS 出问题变砖的时候强制重装系统时使用的。当然,如果你选择不用无线网络的话,平时插根网线用应该也是可以的。另一个 USB-C 的接口是为开发者准备的,还是需要连线才能够将 Apple TV 与开发的 Mac 连接。另外,在从本地恢复 tvOS 系统的时候也需要 USB-C 的连接才能在 iTunes 中找到 Apple TV 设备。 ![apple-tv-6](/assets/images/2015/apple-tv-6.jpg) 开机以后是很常规的欢迎和语言选择等等,操作的话就靠新的遥控器上半部分的触控板了。tvOS 使用焦点系统进行操控,同一时间屏幕上有且仅有一个焦点 UI 元素,用户通过在遥控器触摸板上的滑动来控制焦点移动,通过按压触摸板来确认。虽说是玻璃触摸板,但是从材质上来说遥控器的下半部分非触摸的区域却更接近于日常使用的手机屏幕,而上半部分的触摸板反而金属质感强烈。我一开始的时候有些不太习惯,拿反了好几次遥控器。不过在经过一段时间适应以后就不会弄错了。 初始设置的话十分方便,你可以用过使用 iOS 设备通过蓝牙来对新的 Apple TV 进行设定。这省去了输入网络密码,登陆 Apple ID 等等一系列复杂的输入过程。如果说在手机的小屏幕上进行键盘输入体验糟糕的话,在 Apple TV 上用遥控器进行输入简直就是灾难:键盘按键从 a 到 z 一字排开,你需要来回滚动焦点选择需要的字符。而且如果你像我一样在使用一些密码管理软件来管理密码,而非自己记忆的话,每次问你输入密码的时候估计都会有砸电视的冲动。还好,Apple 在第一次还是很人性化地帮我们省了这个步骤。 ![apple-tv-7](/assets/images/2015/apple-tv-7.jpg) 成功登陆了自己的中国区的 Apple ID 以后,我们就进入到 tvOS 的主界面了,眼前情况只能用惨不忍睹来形容。只有照片,搜索,家庭共享和设置四个选项,和砖并没有太大区别.. ![apple-tv-8](/assets/images/2015/apple-tv-8.jpg) 赶快吓得祭出了自己日本区的账号,更换账号以后,终于看到 iTunes 和 App Store 等内容了。Apple TV 现在在大陆还没有上市和销售的消息,鉴于之前音乐电影图书的 iTunes Store 在中国正式上线,可能 Apple 也正在努力想让 Apple TV 能在国内开始销售。但是之前有广电总局机顶盒只能用国产 TVOS1.0 的禁令,所以最终 Apple TV 有没有可能在中国上市,还是一个未知数。 总之,你现在想要用 Apple TV 来做客厅娱乐的话,一个欧美或者日本区的 Apple ID 应该是必要的。 ![apple-tv-9](/assets/images/2015/apple-tv-9.jpg) ![apple-tv-10](/assets/images/2015/apple-tv-10.jpg) 作为开发者,最关心的自然是 App Store 了。可以看到虽然是第一天,但是商店中已经有不少 app 了,像是常看的视频 app 的 YouTube,vimeo,TED 等等一应俱全。Netflix 最近也登陆了日本,并且取得了很好的成绩,也出现在了日本区商店的推荐首页上。其他的像是 NHK,ESPN 等等频道也推出了自己的 app。 ### 使用 使用上来说,交互设计十分简单清晰。焦点的内容会被稍微放大显示,点击触摸板选择,点击 Menu 按钮后退,这对一个普通的有其他电子产品使用经验的用户来说,几乎是没有学习成本的。观看电视节目的话要做的就是在主页面上滑动找到 app 并打开,然后选择想看的内容就可以了。 由于字符输入的困难,我选择了在设置中将购买 app 需要确认 Apple ID 关掉了。Apple TV 作为一款家庭设备的话其实如果家里有小朋友的话这还是挺危险的一件事情,但是相比起每次要打开手机显示密码再花三分钟划来划去输入密码的话,我还是选择前者.. 在要求用户登录和输入上,值得一提的是 YouTube 的处理。YouTube 没有选择让用户直接在 Apple TV 上输入用户密码,而是采用了让用户使用其他设备进行登陆:访问特定网址进行正常登陆,然后输入特定的序列码来进行回调和授权。其实猜测这样做的很大原因是 YouTube 要求的是标准的 OAuth 2.0 登陆,需要访问 Google 账号,而在 tvOS 上是没有 WebView 支持的。也就是说,想要像在 iOS 或者 Android 上弹出 web view 的认证的话是不可能的了,除非用 TVML 专门为 Apple TV 写一套流程和界面。于此相比,现在几乎拥有 Apple TV 的用户都不太可能没有其他设备,使用更方便的设备进行登陆会是一个很不错的选择。 ![apple-tv-11](/assets/images/2015/apple-tv-11.jpg) 除了电视节目,App Store 中现在另一个大类就是游戏了。在发布会上已经有开发商展示了一些游戏,比如 Crossy Road: ![apple-tv-12](/assets/images/2015/apple-tv-12.jpg) 这类游戏依然交互简单,相比起在手机上来说,你的操作不会影响到屏幕显示,你可以一直看到完整的屏幕 (而且这块屏幕比手机大得多的多得多..),手持遥控器窝在沙发里懒洋洋地划来划去的感觉相比去抬着手拿设备来说也要舒适一些。 如果你认为 Apple TV 只能胜任这样的像素风小游戏的话你就错了。Gameloft 也在第一时间将《狂野飙车8》移植到了 Apple TV 上。因为 Apple TV 的 app 有 200 MB 的尺寸限制,所以对于这样的 3D 游戏,基本需要能够进行额外的资源加载。自己维护一套 asset 的规则或者使用 Apple 的 On-Demand Resources 都是可选项。Gameloft 也采用了额外的资源下载: ![apple-tv-13](/assets/images/2015/apple-tv-13.jpg) 得益于 A8 CPU 和 2GB 内存,游戏运行的帧率完全没有问题,游戏始终能跑在满帧。在操作时,游戏利用了遥控器中的重力感应和陀螺仪来识别遥控器方向,以此控制赛车。除了使用遥控器以外,游戏也支持 MFi 的各种第三方手柄或者方向盘,作为一款赛车类游戏来说,体验还是相当完整。当然,《狂野飙车8》还是属于移动端为主的视觉效果。光影和细节上相比主机游戏还是有不小差距,但是毕竟相比于 iPhone 或者 iPad,更大的屏幕意味着更接近于标准游戏主机的体验,玩起来还是挺过瘾的,可以说这次 Apple 在游戏方向上迈出了坚实的一步。 ![apple-tv-14](/assets/images/2015/apple-tv-14.jpg) ### 开发 回到开发的正题上,Xcode 7.1 包含了 tvOS 的 SDK,相信有兴趣的同学已经把玩过 tvOS SDK 和模拟器了。想要把程序部署到实际设备上的话,我们需要一根 USB-C 到 USB-A 的线缆将 Apple TV 与 Mac 进行连接(如果你用的是 MacBook 的话那就是两头 USB-C),然后选择正确的 provisioning file 或者直接交给 Xcode fix 以后就可以直接部署了。所以作为开发者,在购买 Apple TV 作为开发设备的时候,千万不要忘了一并买一根转接线,否则你将无法将 TV 连接到你的开发设备上。 Apple TV 的 app 的话,基本来说有两种形式:一是基于网络的一套方案,使用 TVJS (一套为 Apple TV 设计的 Javascript 框架),TVML (Apple TV 专用的类似 XML 的标记语言,用来构建界面) 和 TVMLKit (胶水代码) 来构建,Apple 把这类的 app 叫做 Client-server Apps。这类 app 的开发更偏向于类似前端和网站开发:你使用 Apple 提供的常用模板,然后从自己的服务器中获取内容后,将这些内容填写到模板中进行显示。使用这种开发方法的话,基本是在和 Javascript 和 XML 相关的内容打交道。如果你已经有现成的数据 (视频) 源,那么使用这种方法能让你直接将数据填到模板里,以此来迅速地构建出符合 Apple TV 标准交互的 app。 另一类 app 类似于传统 iOS app,使用一个 UIKit 的子集,一些 tvOS 的独特的特性以及 Auto layout 来进行 app 构建,Apple 将其称为 Custom Apps。如果你已经是一个 iOS 开发者的话,这种模式的 app 会让你感到如沐春风。基础控件,导航,网络等等,一切都是那么熟悉。不过相比起完整的 iOS,tvOS 有着完全不同的交互方式和体验,很多时候我们都需要注意不能将 iOS 开发中已有的经验简单粗暴地照搬。 对于开发者来说,直接获取利润的方式和普通的 iOS app 基本一致,也就是付费 app,内购以及广告。现在暂时在 tvOS 上还没有成熟的广告平台 (不知道 Admob 之类的平台商有没有在这方面有什么进展),只能靠内容商自己想办法。因为是全新的平台,所以现在 App Store 上 app 数量还比较有限,可以说暂时还是一片蓝海。如果 Apple TV 能够获得成功进入大多数人的客厅的话,这绝对是一个极具潜力的消费市场。 但是不得不吐槽的是,现在 App Store 的应用发现还非常不足,只有推荐,已购买和搜索三个栏目。没有分类榜单,也没有方式能打开指向 App Store 的链接 (因为 Apple 铁了心不想把网页浏览加入到 tvOS 里)。也就是说,想要发现一个 app 现在只有两种方式:被 Apple 推荐出现在首页,或者是用户在搜索中输入你的 app 名字/关键字进行搜索。搜索的方法能为 app 带来的曝光度几乎为零,而且也没有办法使用链接进行推广。用户想要下载你的 app,不得不经过痛苦的搜索过程 (还记得我们说了好几次的 Apple TV 上输入不便的问题么)。所以对广大普通开发者来说,相比起开发中的技术问题,如何让自己开发的 app 让用户看到知道,会是更大的挑战。 ![apple-tv-15](/assets/images/2015/apple-tv-15.jpg) 不过输入和 App Store 的问题都不是无解。在平台化如此强大的今天,直接用 Siri 进行语音输入,使用 iCloud 密码或者 Touch ID,都是很好的输入解决方案。App Store 的话只要 Apple 愿意,添加一些分类榜单或者更完善的 app 发现机制也只是时间上的问题。对于像一个 tvOS 这样一个全新的系统和平台生态,我们还是不应该太过着急,多给它一些时间来调整。 我之后可能会看时间安排,有空的话会稍微再写一点 Apple TV 开发相关的内容 :) URL: https://onevcat.com/2015/09/ui-testing/index.html.md Published At: 2015-09-27 20:58:47 +0900 # WWDC15 Session笔记 - Xcode 7 UI 测试初窥 ![](/assets/images/2015/ui-testing-title.png) Unit Test 在 iOS 开发中已经有足够多的讨论了。Objective-C 时代除了 Xcode 集成的 XCTest 以外,还有很多的测试相关的工具链可以使用,比如专注于提供 Mock 和 Stub 的 [OCMock](http://ocmock.org),使用行为驱动测试的 [Kiwi](https://github.com/kiwi-bdd/Kiwi) 或者 [Specta](https://github.com/specta/specta) 等等。在 Swift 中,我们可以继续使用 XCTest 来进行测试,而 Swift 的 mock 和 stub 的处理,我们甚至不需要再借助于第三方框架,而使用 Swift 自身可以在方法中内嵌类型的特性来完成。关于这方面的内容,可以参看下 NSHipster [这篇文章](http://nshipster.com/xctestcase/)里关于 Mocking in Swift 部分的内容。 不过这些都是单元测试 (Unit Test) 的相关内容。单元测试非常适合用来做 app 的逻辑以及网络接口方面的测试,但是一个 app 所面向的最终人群还是使用的用户。对于用户来说,app 的功能和 UI 界面是否正确是判断这个 app 是否合格的更为直接标准。而传统的单元测试很难对 app 的功能或者 UI 进行测试。iOS 的开源社区有过一系列的尝试,比如 [KIF 框架](https://github.com/kif-framework/KIF),Apple 自己的 [Automating UI Testing](https://developer.apple.com/library/mac/documentation/DeveloperTools/Conceptual/InstrumentsUserGuide/UsingtheAutomationInstrument/UsingtheAutomationInstrument.html) 或者 Facebook 的[截图测试](https://github.com/facebook/ios-snapshot-test-case)等。关于这些 UI 测试框架的更详细的介绍,可以参看 objc 中国上的 [UI 测试](http://objccn.io/issue-15-6/)和[截图测试](http://objccn.io/issue-15-7/)两篇文章。不过这些方法有一个共同的特点,那就是配置起来十分繁琐,使用上也有诸多不便。测试的目的是保证代码的质量和发布时的信心,以加速开发和迭代的效率;但是如果测试本身太过于难写复杂的话,反而会拖累开发速度。这大概也是 UI 测试所面临的最大窘境 -- 往往开发者在一个项目里写了一两个 UI 测试用例后,就会觉得难以维护,怯于巨大的时间成本,继而放弃。 Apple 在 Xcode 7 中新加入了一套 UI Testing 的工具,其目的就是解决这个问题。新的 UI Testing 比以往的解决方案要简单不少,特别是在创建测试用例的时候更集成了录制的功能,这有希望让 UI Testing 变得更为普及。这篇文章将通过一个简单的例子来说明 Xcode 7 中 UI Testing 的基本概念和使用方法。 本文是我的 [WWDC15 笔记](http://onevcat.com/2015/06/ios9-sdk/)中的一篇,本文所参考的有: * [UI Testing in Xcode](https://developer.apple.com/videos/wwdc/2015/?id=406) ### UI Testing 和 Accessibility 在开始实际深入到 UI Testing 之前,我们可能需要补充一点关于 app 可用性 (Accessibility) 的基本知识。[UI Accessibility](https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/iPhoneAccessibility/Introduction/Introduction.html) 早在 iOS 3.0 就被引入了,用来辅助身体不便的人士使用 app。VoiceOver 是 Apple 的屏幕阅读技术,而 UI Accessibility 的基本原则就是对屏幕上的 UI 元素进行分类和标记。两者配合,通过阅读或者聆听这些元素,用户就可以在不接触屏幕的情况下通过声音来使用 app。 Accessibility 的核心思想是对 UI 元素进行分类和标记 -- 将屏幕上的 UI 分类为像是按钮,文本框,cell 或者是静态文本 (也就是 label) 这样的类型,然后使用 identifier 来区分不同的 UI 元素。用户可以通过语音控制 app 的按钮点击,或是询问某个 label 的内容等等,十分方便。iOS SDK 中的控件都实现了默认的 Accessibility 支持,而我们如果使用自定义的控件的话,则需要自行使用 Accessibility 的 API 来进行添加。 但是因为最初 Accessibility 和 VoiceOver 都是基于英文的,所以在国内的 iOS 应用中并不是十分受到重视。不仅如此,因为添加完备的可用性支持对于开发者来说也是不小的额外工作量,所以除非应用有特殊的使用场景,对于 Accessibility 的支持和重视程度都十分有限。但是在 UI 测试中,可用性的作用就非常大了。UI 测试的本质就是定位在屏幕上的元素,实现一些像是点击或者拖动这样的操作交互,然后获取 UI 的状态进行断言来判断是否符合我们的预期。这个过程及其需求与 Accessibility 的功能是高度吻合的。这也是为什么 iOS 中大部分的 UI 测试框架都是基于 UI Accessibility 的原因,Xcode 7 的 UI Testing 也不例外。 ### 集成 UI Testing 在项目中加入 UI Testing 的支持非常容易。如果是新项目的话,在新建项目时 UI Testing 就已经是默认选上的了: ![](/assets/images/2015/ui-testing-1.png) 如果你要在已有项目中添加 UI Testing 的话,可以新建一个 iOS UI Testing 的 target: ![](/assets/images/2015/ui-testing-2.png) 无论使用那种方法,Xcode 将为你配置好你所需要的 UI 测试环境。我们这里通过一个简单的例子来说明 UI Testing 的基本使用方法。这个 app 非常简单,只有两个主要界面。首先是输入用户名密码的登陆界面,在登陆之后的带有一个 Switcher 的界面。用户可以通过点击这个开关来将下面的签到次数 +1。这个项目的代码可以在 GitHub 的[这个仓库](git@github.com:onevcat/UITestDemo.git)中找到。 ![](/assets/images/2015/ui-testing-3.png) ### UI 行为录制和第一个测试 相比起其他一些 UI 测试框架,Xcode 的 UI Testing 最为诱人的优点在于可以直接录制操作。我们首先来看看 UI Testing 的基本结构吧。在新建 UI Testing 后,我们会得到一个 `{ProjectName}UITests` 文件,默认实现是: ```Swift import XCTest class UITestDemoUITests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. XCUIApplication().launch() // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // Use recording to get started writing UI tests. // Use XCTAssert and related functions to verify your tests produce the correct results. } } ``` 在 `setUp` 中,我们使用 `XCUIApplication` 的 `launch` 方法来启动测试 app。和单元测试的思路类似,在每一个 UI Testing 执行之前,我们都希望从一个“干净”的 app 状态开始进行。`XCUIApplication` 是 `UIApplication` 在测试进程中的代理 (proxy),我们可以在 UI 测试中通过这个类型和应用本身进行一些交互,比如开始或者终止一个 app。我们先来测试在没有输入时直接点击 Login 按钮的运行情况。在 test 文件中加入一个方法,`testEmptyUserNameAndPassword`,在模拟器中运行程序后,将输入光标放在方法实现中,并点击工具栏上的录制按钮,就可以进行实时录制了: ![](/assets/images/2015/ui-testing-4.png) 第一个测试非常简单,我们直接保持用户名和密码文本框为空,直接点击 login。这时 UI 录制会记录下这次点击行为: ```swift func testEmptyUserNameAndPassword() { XCUIApplication().buttons["Login"].tap() } ``` `XCUIApplication()` 我们刚才说过,是一个在 UI Testing 中代表整个 app 的对象。然后我们使用 `buttons` 来获取当前屏幕上所有的按钮的代理。使用 `buttons` 来获取一个对 app 的 query 对象,它可以用来寻找 app 内所有被标记为按钮的 UI 元素,其实上它是 `XCUIApplication().descendantsMatchingType(.Button)` 的简写形式。同样地,我们还有像是 `TextField`,`Cell` 之类的类型,完整的类型列表可以在[这里](http://masilotti.com/xctest-documentation/Constants/XCUIElementType.html)找到。类似这样的从 app 中寻找元素的方法,所得到返回是一个 `XCUIElementQuery` 对象。除了 `descendantsMatchingType` 以外,还有仅获取当前层级子元素的 `childrenMatchingType` 和所有包含的元素的 `containingType`。我们可以通过级联和结合使用这些方法获取到我们想要的层级的元素。 当得到一个可用的 `XCUIElementQuery` 后,我们就可以进一步地获取代表 app 中具体 UI 元素的 `XCUIElement` 了。和 `XCUIApplication` 类似,`XCUIElement` 也只是 app 中的 UI 元素在测试框架中的代理。我们不能直接通过得到的 `XCUIElement` 来直接访问被测 app 中的元素,而只能通过 Accessibility 中的像是 `identifier` 或者 `frame` 这样的属性来获取 UI 的信息。关于具体的可用属性,可以参看 [`XCUIElementAttributes`](http://masilotti.com/xctest-documentation/Protocols/XCUIElementAttributes.html) 的文档。 > 其实 `XCUIApplication` 是 `XCUIElement` 的子类,了解到这一点后,我们就不难理解 `XCUIApplication` 也是一个代理的事实了。 在这里 `XCUIApplication().buttons["Login"]`,做的是在应用当前界面中找到所有的按钮,然后找到 Login 按钮。接下来我们对这个 UI 代理发送 `tap` 进行点击。在我们的 app 中,点击 Login 后我们模拟了一个网络请求,在没有填写用户名和密码的情况下,将弹出一个 alert 来提示用户需要输入必要的登陆信息: ![](/assets/images/2015/ui-testing-5.png) 虽然 UI Testing 的交互会等待 UI 空闲后再进行之后的交互操作,但是由于登陆是在后台线程完成的,UI 其实已经空闲下来了,因此我们在测试中也需要等待一段时间,然后对这个 alert 是否弹出进行判断。将 `testEmptyUserNameAndPassword` 中的内容改写为: ```swift func testEmptyUserNameAndPassword() { let app = XCUIApplication() app.buttons["Login"].tap() let alerts = app.alerts let label = app.alerts.staticTexts["Empty username/password"] let alertCount = NSPredicate(format: "count == 1") let labelExist = NSPredicate(format: "exists == 1") expectationForPredicate(alertCount, evaluatedWithObject: alerts, handler: nil) expectationForPredicate(labelExist, evaluatedWithObject: label, handler: nil) waitForExpectationsWithTimeout(5, handler: nil) } ``` 注意我们这里用了一个预言期望,而不是直接采用断言。按照一般思维来说,我们可能会倾向于使用像是 dispatch_after 来让断言延迟,类似这样: ```swift func testEmptyUserNameAndPassword() { //... app.buttons["Login"].tap() let expection = expectationWithDescription("wait for login") dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(3 * NSEC_PER_SEC)), dispatch_get_main_queue()) { () -> Void in XCTAssertEqual(alerts.count, 1, "There should be an alert.") expection.fulfill() } waitForExpectationsWithTimeout(5, handler: nil) } ``` 但是你会发现这段代码中 block 的部分并不会执行,这是因为在 UI Testing 中有不能 dispatch 到主线程的限制。我们可以通过把 main thread 改为其他 thread 来让代码进入 block,但是这会导致断言崩溃。因此,对于这种需要在一定时间之后再进行判断的测试例,可以使用 `expectationForPredicate` 来对未来的状态作出假设并测试在规定的超时时间内是否得到理想的结果。在 `testEmptyUserNameAndPassword` 的例子中,我们应该在点击 Login 后得到的是一个 alert 框,并且其中有一个 label,文本是 "Empty username/password"。Cmd+U,测试通过!你也可以打开模拟器查看整个过程,同时试着更改一下 Predicate 中的内容,看看运行的结果,来证明测试确实有效。 ### 文本输入和 ViewController 切换 接下来可以试着测试下登陆成功。我们有一组可用的用户名/密码,现在要做的是用 UI Testing 的方式在用户名和密码的文本框中。最简单的方式还是直接使用 UI 动作的录制功能。对应的测试代码如下: ```swift func testLoginSuccessfully() { let app = XCUIApplication() let element = app.otherElements.containingType(.NavigationBar, identifier:"Login").childrenMatchingType(.Other).element.childrenMatchingType(.Other).element.childrenMatchingType(.Other).element.childrenMatchingType(.Other).elementBoundByIndex(1) let textField = element.childrenMatchingType(.Other).elementBoundByIndex(0).childrenMatchingType(.TextField).element textField.tap() textField.typeText("onevcat") element.childrenMatchingType(.Other).elementBoundByIndex(1).childrenMatchingType(.SecureTextField).element.typeText("123") // Other more test code } ``` 自动录制生成的代码使用了很多 query 来查询文本框,虽然这么做是可以找到合适的文本框,但是现在的做法显然难以理解。这是因为我们没有对这两个 textfield 的 identifier 进行设置,因此无法用下标的方式进行访问。我们可以通过在 Interface Builder 或者代码中进行设置。 ![](/assets/images/2015/ui-testing-6.png) 然后就可以在测试方法中把寻找元素的 query 改为更好看的方式,并且加上测试 ViewController 切换的相关代码了: ```swift func testLoginSuccessfully() { let userName = "onevcat" let password = "123" let app = XCUIApplication() let userNameTextField = app.textFields["username"] userNameTextField.tap() userNameTextField.typeText(userName) let passwordTextField = app.secureTextFields["password"] passwordTextField.tap() passwordTextField.typeText(password) app.buttons["Login"].tap() let navTitle = app.navigationBars[userName].staticTexts[userName] expectationForPredicate(NSPredicate(format: "exists == 1"), evaluatedWithObject: navTitle, handler: nil) waitForExpectationsWithTimeout(5, handler: nil) } ``` > 注意在当前的 Xcode 版本 (7.0 7A218) 中 UI 录制在对于有 identifier 的文本框时,没有自动插入 `tap()`,这会导致测试时出现 “UI Testing Failure - Neither element nor any descendant has keyboard focus on secureTextField” 的错误。我们可以手动在输入文本 (`typeText`) 之前加入 `tap` 的调用。相信在之后的 Xcode 版本中这个问题会得到修正。 对于 ViewController 切换的判断,我们可以通过判断 navigation bar 上的 title 是否正确来加以判断。 ### 实时的 UI 反馈测试和关于 XCUIElementQuery 的说明 我们接下来测试 DetailViewController 中的 Switcher 点击。在成功登陆之后,我们可以看到一个默认为 off 状态的 switcher 按钮。点击打开这个按钮,下面的 count label 计数就会加一。首先我们需要成功登陆,在上面的测试例 (`testLoginSuccessfully`) 我们已经测试了成功登陆,我们先在新的测试方法中模拟登陆过程: ```swift func testSwitchAndCount() { let userName = "onevcat" let password = "123" let app = XCUIApplication() let userNameTextField = app.textFields["username"] userNameTextField.tap() userNameTextField.typeText(userName) let passwordTextField = app.secureTextFields["password"] passwordTextField.tap() passwordTextField.typeText(password) app.buttons["Login"].tap() // To be continued.. } ``` 接下来因为 Login 是在后台进行的,我们需要等一段时间,让新的 DetailViewController 出现。在上面两个测试例中,我们直接用 expectationForPredicate 来作为断言,这样 Xcode 只需要在超时之前观测到符合断言的变化即可以结束测试。而在这里,我们要在新的 View 里进行 UI 交互,这就需要一定时间的等待 (包括模拟的网络请求和 UI 迁移的动画等)。因为 UI 测试和 app 本身是在不同进程中运行的,我们可以简单地使用 `sleep` 来等待。接下来我们点击这个 switcher 并添加断言。在上面代码中注释的地方接着写: ```swift func testSwitchAndCount() { //... sleep(3) let switcher = app.switches["checkin"] let l = app.staticTexts["countLabel"] switcher.tap() XCTAssertEqual(l.label, "1", "Count label should be 1 after clicking check in.") switcher.tap() XCTAssertEqual(l.label, "0", "And 0 after clicking again.") } ``` `checkin` 和 `countLabel` 是我们在 IB 中为 UI 设置的 identifier。默认情况下,我们可以通过 `label` 属性来获取一个 Label 的文字值。 到此为止,这个简单的 demo 就书写完毕了。当然,实际的 app 中的情况会比这种 demo 复杂得多,但是基本的思路和步骤是一致的 -- 通过 query 寻找要交互的 UI 元素,进行交互,判断结果。在 UI 录制的帮助下,我们一般只需要关心如何书写断言和对结果进行判断,这大大节省了书写和维护测试的时间。 对于 `XCUIElementQuery`,还有一点需要特别说明的。Query 的执行是延迟的,它和最后我们得到的 `XCUIElement` 并不是一一对应的。和 `NSURL` 与请求到的内容的关系类似,随着时间的变化,同一个 URL 有可能请求到不同的内容。我们生成 Query,然后在通过下标或者是访问方法获取的时候才真正从 app 中寻找对应的 UI 元素。这就是说,随着我们的 UI 的变化,同样的 query 也是有可能获取到不用的元素的。这在某些元素会消失或者 identifier 变化的时候是需要特别注意的。 ### 小结 UI Testing 在易用性上比 KIF 这样的框架要有所进步,随着 UI Testing 的推出,Apple 也将原来的 UI Automation 一系列内容标记为弃用。这意味着 UI Testing 至少在今后一段时间内将会是 iOS 开发中的首选工具。但是我们也应该看到,基于 Accessibility 的测试方式有时候并不是很直接。在这个限制下,我们只能得到 UI 的代理对象,而不是 UI 元素本身,这让我们无法得到关于 UI 元素更多的信息 (比如直接获取 UI 元素中的内容,或者与 ViewController 中的相关的值),现在的 UI Testing 在很大程度上还停留在比较简易的阶段。 但是相比使用 UIAutomation 在 Instruments 中用 JavaScript 与 app 交互,我们现在可以用 Swift 或者 Objective-C 直接在 Xcode 里进行 UI 测试了,这使得测试时可以方便地进行和被调试。Xcode 7.0 中的 UI Testing 作为第一个版本,还有不少限制和 bug,使用起来也有不少“小技巧”,很多时候可能并没有像单元测试那样直接。但即便如此,使用 UI Testing 来作为人工检查的替代和防止开发过程中 bug 引入还是很有意义的,相比起开发人员,也许 QA 人员从 UI 录制方面收益更多。如果 QA 职位的员工可以掌握一些基本的 UI Testing 内容的话,应该可以极大地缩短他们的工作时间和压力。而且相信 Apple 也会不断改进和迭代 UI Testing,并以此驱动 app 质量的提升,所以尽早掌握这一技术还是十分必要的。 URL: https://onevcat.com/2015/08/watchos2/index.html.md Published At: 2015-08-03 23:23:34 +0900 # WWDC15 Session笔记 - 30 分钟开发一个简单的 watchOS 2 app Apple Watch 和 watchOS 第一代产品只允许用户在 iPhone 设备上进行计算,然后将结果传输到手表上进行显示。在这个框架下,手表充当的功能在很大程度上只是手机的另一块小一些的显示器。而在 watchOS 2 中,Apple 开放了在手表端直接进行计算的能力,一些之前无法完成的 app 现在也可以进行构建了。本文将通过一个很简单的天气 app 的例子,讲解一下 watchOS 2 中新引入的一些特性的使用方法。 本文是我的 [WWDC15 笔记](http://onevcat.com/2015/06/ios9-sdk/)中的一篇,在 WWDC15 中涉及到 watchOS 2 的相关内容的 session 非常多,本文所参考的有: * [Introducing WatchKit for watchOS 2](https://developer.apple.com/videos/wwdc/2015/?id=105) * [WatchKit In-Depth, Part 1](https://developer.apple.com/videos/wwdc/2015/?id=207) * [WatchKit In-Depth, Part 2](https://developer.apple.com/videos/wwdc/2015/?id=208) * [Introducing Watch Connectivity](https://developer.apple.com/videos/wwdc/2015/?id=713) * [Building Watch Apps](https://developer.apple.com/videos/wwdc/2015/?id=108) * [Creating Complications with ClockKit](https://developer.apple.com/videos/wwdc/2015/?id=209) ## 项目简介 作为一个示例项目,我们就来构建一个最简单的天气 app 吧。本文将一步步带你从零开始构建一个相对完整的 iOS + watch app。这个 app 的 iOS 端很简单,从数据源取到数据,然后解析成天气的 model 后,通过一个 PageViewController 显示出来。为了让 demo 更有说服力,我们将展示当前日期以及前后两天的天气情况,包括天气状况和气温。在手表端,我们希望构建一个类似的 app,可以展示这几天的天气情况。另外我们当然也介绍如何利用 watchOS 2 的一些新特性,比如 complications 和 Time Travel 等等。 ## 开始 虽然本文的重点是 watchOS,但是为了完整性,我们还是从开头开始来构建这个 app 吧。因为不管是 watchOS 1 还是 2,一个手表 app 都是无法脱离手机 app 单独存在和申请的。所以我们首先来做的是一个像模像样的 iOS app 吧。 ### 新建项目 第一步当然是使用 Xcode 7 新建一个工程,这里我们直接选择 iOS App with WatchKit App,这样 Xcode 将直接帮助我们建立一个带有 watchOS app 的 iOS 应用。 ![step-1](/assets/images/2015/step-1.png) 在接下来的画面中,我们选中 Include Complication 选项,因为我们希望制作一个包含有 Complication 的 watch app。 ![step-2](/assets/images/2015/step-2.png) ### UI 这个 app 的 UI 部分比较简单,我将使用到的素材都放到了[这里](/assets/images/2015/WatchWeatherImage.zip)。你可以下载这些素材,并把它们解压并拖拽到项目 iOS app 的 Assets.xcassets 里去: ![step-3](/assets/images/2015/step-3.png) 接下来,我们来构建 UI 部分。我们想要使用 PageViewController 来作为 app 的导航,首先,在 Main.StoryBoard 中删掉原来的 ViewController,并新加一个 Page View Controller,然后在它的 Attributes Inspector 中将 Transition Style 改为 Scroll,并勾选上 Is Initial View Controller。这将使这个 view controller 成为整个 app 的入口。 ![step-4](/assets/images/2015/step-4.png) 接下来,我们需要将这个 Page View Controller 和代码关联起来。首先将 ViewController.swift 文件中,将 ViewController 的继承关系从 `UIViewController` 改为 `UIPageViewController`。 ```swift class ViewController: UIPageViewController { ... } ``` 然后我们就可以在 StoryBoard 文件中将刚才的 Page View Controller 的 class 改为我们的 `ViewController` 了。 ![step-5](/assets/images/2015/step-5.png) 另外我们还需要一个实际展示天气的 View Controller。创建一个继承自 `UIViewController` 的 `WeatherViewController`,然后将 WeatherViewController.swift 的内容替换为: ```swift import UIKit class WeatherViewController: UIViewController { enum Day: Int { case DayBeforeYesterday = -2 case Yesterday case Today case Tomorrow case DayAfterTomorrow } var day: Day? } ``` 这里仅只是定义了一个 `Day` 的枚举,它将用来标记这个 `WeatherViewController` 所代表的日期 (可能你会说把 `Day` 在 ViewController 里并不是很好的选择,没错,但是放在这里有助于我们快速搭建 app,在之后我们会对此进行重构)。接下来,我们在 StoryBoard 中添加一个 ViewController,并将它的 class 改为 `WeatherViewController`。我们可以在这里构建 UI,对于这个 demo 来说,一个简单的背景,加上表示天气的图标和表示温度的标签就足够了。因为这里并不是一个关于 Auto Layout 或是 Size Class 的 demo,所以就不详细一步步地做了,我随意拖了拖 UI 和约束,最后结果如下图所示。 ![step-6](/assets/images/2015/step-6.png) 接下来就是从 StoryBoard 中把需要的 IBOutlet 拖出来。我们需要天气图标,最高最低温度的 label。完成这些 UI 工作之后的项目可以在 GitHub 的[这个 tag](https://github.com/onevcat/WatchWeather/releases/tag/ui-setup) 下找到,如果你不想自己完成这些步骤的话,也可以直接使用这个 tag 的源文件来继续下面的 demo。当然,如果你对 AutoLayout 和 Interface Builder 还不熟悉的话,这会是一个很好的机会来从简单的布局入手,去理解对 IB 的使用。关于更多 IB 和 StoryBoard 的教程,推荐 Raywenderlich 的这两篇系列文章:[Storyboards Tutorial in Swift](http://www.raywenderlich.com/81879/storyboards-tutorial-swift-part-1) 和 [Auto Layout Tutoria](http://www.raywenderlich.com/83129/beginning-auto-layout-tutorial-swift-part-1)。 然后我们可以考虑先把 Page View Controller 的框架实现出来。在 `ViewController.swift` 中,我们首先在 `ViewController` 类中加入以下方法: ```swift func weatherViewControllerForDay(day: WeatherViewController.Day) -> UIViewController { let vc = storyboard?.instantiateViewControllerWithIdentifier("WeatherViewController") as! WeatherViewController let nav = UINavigationController(rootViewController: vc) vc.day = day return nav } ``` 这将从当前的 StroyBoard 里寻找 id 为 "WeatherViewController" 的 ViewController,并且初始化它。我们希望能为每一天的天气显示一个 title,一个比较理想的做法就是直接将我们的 WeatherViewController 嵌套在 navigation controller 里,这样我们就可以直接使用 navigation bar 来显示标题,而不用去操心它的布局了。我们刚才并没有为 `WeatherViewController` 指定 id,在 StoryBoard 中,找到 WeatherViewController,然后在 Identity 里添加即可: ![step-7](/assets/images/2015/step-7.png) 接下来我们来实现 `UIPageViewControllerDataSource`。在 `ViewController.swift` 的 `viewDidLoad` 里加入: ```swift dataSource = self let vc = weatherViewControllerForDay(.Today) setViewControllers([vc], direction: .Forward, animated: true, completion: nil) ``` 首先它将 `viewController` 自己设置为 dataSource。然后设定了初始需要表示的 viewController。对于 `UIPageViewControllerDataSource` 的实现,我们在同一文件中加入一个 `ViewController` 的 extension 来搞定: ```swift extension ViewController: UIPageViewControllerDataSource { func pageViewController(pageViewController: UIPageViewController, viewControllerBeforeViewController viewController: UIViewController) -> UIViewController? { guard let nav = viewController as? UINavigationController, viewController = nav.viewControllers.first as? WeatherViewController, day = viewController.day else { return nil } if day == .DayBeforeYesterday { return nil } guard let earlierDay = WeatherViewController.Day(rawValue: day.rawValue - 1) else { return nil } return self.weatherViewControllerForDay(earlierDay) } func pageViewController(pageViewController: UIPageViewController, viewControllerAfterViewController viewController: UIViewController) -> UIViewController? { guard let nav = viewController as? UINavigationController, viewController = nav.viewControllers.first as? WeatherViewController, day = viewController.day else { return nil } if day == .DayAfterTomorrow { return nil } guard let laterDay = WeatherViewController.Day(rawValue: day.rawValue + 1) else { return nil } return self.weatherViewControllerForDay(laterDay) } } ``` 这两个方法分别根据输入的 View Controller 对象来确定前一个和后一个 View Controller,如果返回 `nil` 则说明没有之前/后的页面了。另外,我们可能还想要先将 title 显示出来,以确定现在的架构是否正确工作。在 `WeatherViewController.swift` 的 Day 枚举里添加如下属性: ```swift var title: String { let result: String switch self { case .DayBeforeYesterday: result = "前天" case .Yesterday: result = "昨天" case .Today: result = "今天" case .Tomorrow: result = "明天" case .DayAfterTomorrow: result = "后天" } return result } ``` 然后将 `day` 属性改为: ```swift var day: Day? { didSet { title = day?.title } } ``` 运行 app,现在我们应该可以在五个页面之间进行切换了。你也可以从 GitHub 上[对应的 tag](https://github.com/onevcat/WatchWeather/releases/tag/basic-workflow) 中下载到目前为止的项目。 ![step-8](/assets/images/2015/step-8.png) ### 重构和 Model 很难有人一次性就把代码写得完美无瑕,这也是重构的意义。重构从来不是一个“等待项目完成后再开始”的活动,而是应该随着项目的展开和进行,一旦发现有可能存在问题的地方,就尽快进行改进。比如在上面我们将 `Day` 放在了 `WeatherViewController` 中,这显然不是一个很好地选择。这个枚举更接近于 Model 层的东西而非控制层,我们应该将它迁移到另外的地方。同样现在还需要实现的还有天气的 Model,即表征天气状况和高低温度的对象。我们将这些内容提取出来,放到一个 framework 中去,以便使用的维护。 ![step-9](/assets/images/2015/step-9.png) 我们首先对现有的 `Day` 进行迁移。创建一个新的 Cocoa Touch Framework target,命名为 `WatchWeatherKit`。在这个 target 中新建 `Day.swift` 文件,其中内容为: ```swift public enum Day: Int { case DayBeforeYesterday = -2 case Yesterday case Today case Tomorrow case DayAfterTomorrow public var title: String { let result: String switch self { case .DayBeforeYesterday: result = "前天" case .Yesterday: result = "昨天" case .Today: result = "今天" case .Tomorrow: result = "明天" case .DayAfterTomorrow: result = "后天" } return result } } ``` 这就是原来存在于 `WeatherViewController` 中的代码,只不过将必要的内容申明为了 `public`,这样我们才能在别的 target 中使用它们。我们现在可以将原来的 Day 整个删除掉了,接下来,我们在 `WeatherViewController.swift` 和 `ViewController.swift` 最上面加入 `import WatchWeatherKit`,并将 `WeatherViewController.Day` 改为 `Day`。现在 `Day` 枚举就被隔离出 View Controller 了。 然后实现天气的 Model。在 `WatchWeatherKit` 里新建 `Weather.swift`,并书写如下代码: ```swift import Foundation public struct Weather { public enum State: Int { case Sunny, Cloudy, Rain, Snow } public let state: State public let highTemperature: Int public let lowTemperature: Int public let day: Day public init?(json: [String: AnyObject]) { guard let stateNumber = json["state"] as? Int, state = State(rawValue: stateNumber), highTemperature = json["high_temp"] as? Int, lowTemperature = json["low_temp"] as? Int, dayNumber = json["day"] as? Int, day = Day(rawValue: dayNumber) else { return nil } self.state = state self.highTemperature = highTemperature self.lowTemperature = lowTemperature self.day = day } } ``` Model 包含了天气的状态信息和最高最低温度,我们稍后会用一个 JSON 字符串中拿到字典,然后初始化它。如果字典中信息不全的话将直接返回 `nil` 表示天气对象创建失败。到此为止的项目可以在 GitHub 的 [model tag](https://github.com/onevcat/WatchWeather/releases/tag/model) 中找到。 ### 获取天气信息 接下来的任务是获取天气的 JSON,作为一个 demo 我们完全可以用一个本地文件替代网络请求部分。不过因为之后在介绍 watch app 时会用到使用手表进行网络请求,所以这里我们还是从网络来获取天气信息。为了简单,假设我们从服务器收到的 JSON 是这个样子的: ```json {"weathers": [ {"day": -2, "state": 0, "low_temp": 18, "high_temp": 25}, {"day": -1, "state": 2, "low_temp": 9, "high_temp": 14}, {"day": 0, "state": 1, "low_temp": 12, "high_temp": 16}, {"day": 1, "state": 3, "low_temp": 2, "high_temp": 6}, {"day": 2, "state": 0, "low_temp": 19, "high_temp": 28} ]} ``` 其中 `day` 0 表示今天,`state` 是天气状况的代码。 我们已经有 `Weather` 这个 Model 类型了,现在我们需要一个 API Client 来获取这个信息。在 `WeatherWatchKit` target 中新建一个文件 `WeatherClient.swift`,并填写以下代码: ```swift import Foundation public let WatchWeatherKitErrorDomain = "com.onevcat.WatchWeatherKit.error" public struct WatchWeatherKitError { public static let CorruptedJSON = 1000 } public struct WeatherClient { public static let sharedClient = WeatherClient() let session = NSURLSession.sharedSession() public func requestWeathers(handler: ((weather: [Weather?]?, error: NSError?) -> Void)?) { guard let url = NSURL(string: "https://raw.githubusercontent.com/onevcat/WatchWeather/master/Data/data.json") else { handler?(weather: nil, error: NSError(domain: NSURLErrorDomain, code: NSURLErrorBadURL, userInfo: nil)) return } let task = session.dataTaskWithURL(url) { (data, response, error) -> Void in if error != nil { handler?(weather: nil, error: error) } else { do { let object = try NSJSONSerialization.JSONObjectWithData(data!, options: .AllowFragments) if let dictionary = object as? [String: AnyObject] { handler?(weather: Weather.parseWeatherResult(dictionary), error: nil) } } catch _ { handler?(weather: nil, error: NSError(domain: WatchWeatherKitErrorDomain, code: WatchWeatherKitError.CorruptedJSON, userInfo: nil)) } } } task!.resume() } } ``` 其实我们的 client 现在有点过度封装和耦合,不过作为 demo 来说的话还不错。它现在只有一个方法,就是从[网络源](https://raw.githubusercontent.com/onevcat/WatchWeather/master/Data/data.json)请求一个 JSON 然后进行解析。解析的代码 `parseWeatherResult` 我们放在了 `Weather` 中,以一个 extension 的形式存在: ```swift // MARK: - Parsing weather request extension Weather { static func parseWeatherResult(dictionary: [String: AnyObject]) -> [Weather?]? { if let weathers = dictionary["weathers"] as? [[String: AnyObject]] { return weathers.map{ Weather(json: $0) } } else { return nil } } } ``` 我们在 ViewController 中使用这个方法即可获取到天气信息,就可以构建我们的 UI 了。在 `ViewController.swift` 中,加入一个属性来存储天气数据: ```swift var data: [Day: Weather]? ``` 然后更改 `viewDidLoad` 的代码: ```swift override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. dataSource = self let vc = UIViewController() vc.view.backgroundColor = UIColor.whiteColor() setViewControllers([vc], direction: .Forward, animated: true, completion: nil) UIApplication.sharedApplication().networkActivityIndicatorVisible = true WeatherClient.sharedClient.requestWeathers { (weather, error) -> Void in UIApplication.sharedApplication().networkActivityIndicatorVisible = false if error == nil && weather != nil { for w in weather! where w != nil { self.data[w!.day] = w } let vc = self.weatherViewControllerForDay(.Today) self.setViewControllers([vc], direction: .Forward, animated: false, completion: nil) } else { let alert = UIAlertController(title: "Error", message: error?.description ?? "Unknown Error", preferredStyle: .Alert) alert.addAction(UIAlertAction(title: "OK", style: .Default, handler: nil)) self.presentViewController(alert, animated: true, completion: nil) } } } ``` 在这里一开始使用了一个临时的 `UIViewController` 来作为 PageViewController 在网络请求时的初始视图控制 (虽然在我们的例子中这个初始视图就是一块白屏幕)。接下来进行网络请求,并把得到的数据存储在 `data` 变量中以待使用。之后我们需要把这些数据传递给不同日期的 ViewController,在 `weatherViewControllerForDay` 方法中,换为对 weather 做设定,而非 `day`: ```swift func weatherViewControllerForDay(day: Day) -> UIViewController { let vc = self.storyboard?.instantiateViewControllerWithIdentifier("WeatherViewController") as! WeatherViewController let nav = UINavigationController(rootViewController: vc) vc.weather = data[day] return nav } ``` 同时我们还需要修改一下 `WeatherViewController`,将原来的: ```swift var day: Day? { didSet { title = day?.title } } ``` 改为 ```swift var weather: Weather? { didSet { title = weather?.day.title } } ``` 另外还需要在 `UIPageViewControllerDataSource` 的两个方法中,把对应的 `viewController.day` 换为 `viewController.weather?.day`。最后我们要做的是在 `WeatherViewController` 的 `viewDidLoad` 中根据 model 更新 UI: ```swift override func viewDidLoad() { super.viewDidLoad() lowTemprature.text = "\(weather!.lowTemperature)℃" highTemprature.text = "\(weather!.highTemperature)℃" let imageName: String switch weather!.state { case .Sunny: imageName = "sunny" case .Cloudy: imageName = "cloudy" case .Rain: imageName = "rain" case .Snow: imageName = "snow" } weatherImage.image = UIImage(named: imageName) } ``` > 一个可能的改进是新建一个 `WeatherViewModel` 来将对 View 的内容和 Model 的映射关系代码从 ViewController 里分理出去,如果有兴趣的话你可以自己研究下。 到此我们的 iOS 端的代码就全部完成了,运行一下看看,Perfect!到现在为止的项目可以在[这里](tag ios)找到。 ![step-10](/assets/images/2015/step-10.png) ## Watch App ### UI 构建 终于进入正题了,我们可以开始设计和制作 watch app 了。 首先我们把需要的图片添加到 watch app target 的 Assets.xcassets 中,这样在之后用户安装 app 时这些图片将被存放在手表中,我们可以直接快速地从手表本地读取。UI 的设计非常简单,在 Watch app 的 Interface.storyboard 中,我们先将代表天气状态的图片和温度标签拖拽到 InterfaceController 中,并将它们连接到 `InterfaceController.swift` 中的 IBOutlet 去。 ```swift @IBOutlet var weatherImage: WKInterfaceImage! @IBOutlet var highTempratureLabel: WKInterfaceLabel! @IBOutlet var lowTempratureLabel: WKInterfaceLabel! ``` 接下来,我们将它复制四次,并用 next page 的 segue 串联起来,并设置它们的 title。这样,在最后的 watch app 里我们就会有五个可以左右 scorll 滑动的页面,分别表示从前天到后天的五个日子。 ![step-11](/assets/images/2015/step-11.png) 为了标记和区分这五个 InterfaceController 实例。因为使用 next page 级联的 WKInterfaceController 会被依次创建,所以我们可以在 `awakeWithContext` 方法中用一个静态变量计数。在这里,我们想要将序号为 2 的 InterfaceController (也就是代表 “今天” 的那个) 设为当前 page。在 `InterfaceController.swift` 里添加一个静态变量: ```swift static var index = 0 ``` 然后在 `awakeWithContext` 方法中加入: ```swift InterfaceController.index = InterfaceController.index + 1 if (InterfaceController.index == 2) { becomeCurrentPage() } ``` ### WatchKit Framework 和 iOS app 类似,我们希望能够使用框架来组织代码。watch app 中的天气 model 和网络请求部分的内容其实和 iOS app 中的是完全一样的,我们没有理由重复开发。在一个 watch app 中,其实 app 本身只负责图形显示,实际的代码都是在 extension 中的。在 watchOS 2 之前,因为 extension 是在手机端,和 iOS app 处于同样的物理设备中,所以我们可以简单地将为 iOS app 中创建的框架使用在 watch extension target 中。但是在 watchOS 2 中发生了变化,因为 extension 现在直接将运行在手表上,我们无法与 iOS app 共享同一个框架了。取而代之,我们需要为手表 app 创建新的属于自己的 framewok,然后将合适的文件添加到这个 framework 中去。 为项目新建一个 target,类型选择为 Watch OS 的 Watch Framework。 ![step-12](/assets/images/2015/step-12.png) 接下来,我们把之前的 `Day.swift`,`Weather.swift` 和 `WeatherClient.swift` 三个文件添加到这个新的 target (在这里我们叫它 WatchWeatherWatchKit) 里去。我们将在新的这个 watch framework 中重用这三个文件。这样做相较于直接把这三个文件放到 watch extension target 中来说,会更易于管理组织和模块分割,也是 Apple 所推荐的使用方式。 ![step-13](/assets/images/2015/step-13.png) 接下来我们需要手动在 watch extension 里将这个新的 framework 链接进来。在 `WatchWeather WatchKit Extension` target 的 General 页面中,将 `WatchWeatherWatchKit` 添加到 Embedded Binaries 中。Xcode 将会自动把它加到 Link Binary With Libraries 里去。这时候如果你尝试编译 watch app,可能会得到一个警告:"Linking against dylib not safe for use in application extensions"。这是因为不论是 iOS app 的 extension 还是 watchOS 的 extension,所能使用的 API 都只是完整 iOS SDK 的子集。编译器无法确定我们所动态链接的框架是否含有一些 extension 无法调用的 API。要解决这个警告,我们可以通过在 `WatchWeatherWatchKit` 的 Build Setting 中将 "Require Only App-Extension-Safe API" 设置为 `YES` 来将 target 里可用的 API 限制在 extension 中。 ![step-14](/assets/images/2015/step-14.png) 是时候来实现我们的 app 了。首先一刻都不能再忍受的是 `InterfaceController.swift` 中的 `index`。我们既然有了 `WatchWeatherWatchKit`,就可以利用已有的模型将这里写得更清楚。在 `InterfaceController.swift` 中,首先在文件上面 `import WatchWeatherWatchKit`,然后修改 `index` 的定义,并添加一个字典来临时保存这些 Interface Controller,以便之后使用: ```swift static var index = Day.DayBeforeYesterday.rawValue static var controllers = [Day: InterfaceController]() ``` 将刚才我们的在 `awakeWithContext` 中添加的内容删掉,改为: ```swift override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) // Configure interface objects here. guard let day = Day(rawValue: InterfaceController.index) else { return } InterfaceController.controllers[day] = self InterfaceController.index = InterfaceController.index + 1 if day == .Today { becomeCurrentPage() } } ``` 现在表意就要清楚不少了。 接下来就是获取天气信息了。和 iOS app 中一样,我们可以直接使用 `WeatherClient` 来获取。在 `InterfaceController.swift` 中加入以下代码: ```swift var weather: Weather? { didSet { if let w = weather { updateWeather(w) } } } func request() { WeatherClient.sharedClient.requestWeathers({ [weak self] (weathers, error) -> Void in if let weathers = weathers { for weather in weathers where weather != nil { guard let controller = InterfaceController.controllers[weather!.day] else { continue } controller.weather = weather } } else { // 2 let action = WKAlertAction(title: "Retry", style: .Default, handler: { () -> Void in self?.request() }) let errorMessage = (error != nil) ? error!.description : "Unknown Error" self?.presentAlertControllerWithTitle("Error", message: errorMessage, preferredStyle: .Alert, actions: [action]) } }) } ``` 如果我们获取到了天气,就设置 `weather` 属性并调用 `updateWeather` 方法依次对相应的 InterfaceController 的 UI 进行设置。如果出现了错误,我们这里简单地用一个 watchOS 2 中新加的 alert view 来进行提示并让用户重试。在这个方法的下面加上更新 UI 的方法 `updateWeather`: ```swift func updateWeather(weather: Weather) { lowTempratureLabel.setText("\(weather.lowTemperature)℃") highTempratureLabel.setText("\(weather.highTemperature)℃") let imageName: String switch weather.state { case .Sunny: imageName = "sunny" case .Cloudy: imageName = "cloudy" case .Rain: imageName = "rain" case .Snow: imageName = "snow" } weatherImage.setImageNamed(imageName) } ``` 我们只需要网络请求进行一次就可以了,所以在这里我们用一个 once_token 来限定一开始的 request 只执行一次。在 `InterfaceController.swift` 中加上一个类变量: ```swift static var token: dispatch_once_t = 0 ``` 然后在 `awakeWithContext` 的最后用 `dispatch_once` 来开始请求: ```swift dispatch_once(&InterfaceController.token) { () -> Void in self.request() } ``` 最后,在 `willActivate` 中也需要刷新 UI: ```swift override func willActivate() { super.willActivate() if let w = weather { updateWeather(w) } } ``` 应该就这么多了。选定手表 scheme,运行程序,除了图标的尺寸不太对以及网络请求时还显示默认的天气状况和温度以外,其他的看起来还不赖: ![step-15](/assets/images/2015/step-15.png) 至于显示默认值的问题,我们可以通过简单地在 StoryBoard 中将图和标签内容设为空来改善,在此就不再赘述了。 值得一提的是,如果你多测试几次,比如关闭整个 app (或者模拟器),然后再运行的话,可能会有一定几率遇到下面这样的错误: ![step-16](/assets/images/2015/step-16.png) 如果你还记得的话,这个 1000 错误就是我们定义在 `WeatherClient.swift` 中的 `CorruptedJSON` 错误。调试一下,你就会发现在请求返回时得到的数据存在问题,会得到一个内容被完整复制了一遍的返回 (比如正确的数据 {a:1},但是我们得到的是 {a:1} {a:1})。虽然我不是太明白为什么会出现这样的状况,但这应该是 `NSURLSession` 在 watchOS SDK 上的一个缓存上的 bug。我之后会尝试向 Apple 提交一个 radar 来汇报这个问题。现在的话,我们可以通过设置不带缓存的 `NSURLSessionConfiguration` 来绕开这个问题。将 WeatherClient 中的 `session` 属性改为以下即可: ```swift let session = NSURLSession(configuration: NSURLSessionConfiguration.ephemeralSessionConfiguration()) ``` 至此,我们的 watch app 本体就完成了。到这一步为止的项目可以在[这个 tag](https://github.com/onevcat/WatchWeather/releases/tag/watch-app) 找到。Notification 和 Glance 两个特性相对简单,基本只是界面的制作,为了节省篇幅 (其实这篇文章已经够长了,如果你需要休息一下的话,这里会是一个很好地机会),就不再详细说明了。你可以分别在[这里](https://developer.apple.com/library/ios/documentation/General/Conceptual/WatchKitProgrammingGuide/ImplementingaGlance.html#//apple_ref/doc/uid/TP40014969-CH5-SW1)和[这里](https://developer.apple.com/library/ios/documentation/General/Conceptual/WatchKitProgrammingGuide/BasicSupport.html#//apple_ref/doc/uid/TP40014969-CH18-SW1)找到开发两者所需要的一切知识。 在下一节中,我们将着重于 watchOS 2 的新特性。首先是 complications。 ### Complications Complications 是 watchOS 2 新加入的特性,它是表盘上除了时间以外的一些功能性的小部件。比如我们的天气 app 里,将今天的天气状况显示在表盘上就是一个非常理想的应用场景,这样用户就不需要打开你的 app 就能看到今天的天气状况了 (其实今天的天气的话用户抬头望窗外就能知道。如果是一个实际的天气 app 的话,显示明天或者两小时后的天气状况会更理想,但是作为 demo 就先这样吧..)。我们在这一小节中将为刚才的天气 app 实现一个 complication。 Complications 可以是不同的形状,如图所示: ![step-17](/assets/images/2015/step-17.png) 根据用户表盘选择的不同,表盘上对应的可用的 complications 形状也各不相同。如果你想要你的 complication 在所有表盘上都能使用的话,你需要实现所有的形状。掌管 complications 或者说是表盘相关的框架并不是我们一直使用的 WatchKit,而是一个 watchOS 2 中全新框架,ClockKit。ClockKit 会提供一些模板给我们,并在一定时间点向我们请求数据。我们依照模板使用我们的数据来实现 complication,最后 ClockKit 负责帮助我们将其渲染在表盘上。在 ClockKit 请求数据时,它会唤醒我们的 watch extension。我们需要在 extension 中实现数据源,并以一段时间线的方式把数据提供给 ClockKit。这样做有两个好处,首先 ClockKit 可以一次性获取到很多数据,这样它就能在合适的时候更新 complication 的显示,而不必再次唤醒 extension 来请求数据。其次,因为有一条时间线的数据,我们就可以使用 Time Travel 来查看 complication 已经过去的和即将到来的状况,这在某些场合下会十分方便。 理论已经说了很多了,来实际操作一下吧。 首先,因为我们在新建项目的时候已经选择了包含 complications,所以我们并不需要再进行额外的配置就可以开始了。如果你不小心没有选中这个选项,或者是想在已有项目中进行添加的话,你就需要手动配置,在 extension 的 target 里的 Complications Configuration 中指定数据源的 class 和支持的形状。在运行时,系统会使用在这个设置中指定的类型名字去初始化一个的实例,然后调用这个实例中实现的数据源方法。我们要做的就是在被询问这些方法时,尽快地提供需要的数据。 第一步是实现数据源,这在在我们的项目中已经配置好了,就是 `ComplicationController.swift`。这是一个实现了 `CLKComplicationDataSource` 的类型,打开文件可以看到所有的方法都已经有默认空实现了,我们现在要做的就是把这些空填上。其中最关键的是 `getCurrentTimelineEntryForComplication:withHandler:`,我们需要通过这个方法来提供当前表盘所要显示的 complication。罗马不是一天建成的,项目也不是。我们先提供一个 dummy 的数据来让流程运作起来。在 ComplicationController.swift 中,将这个方法的内容换成: ```swift func getCurrentTimelineEntryForComplication(complication: CLKComplication, withHandler handler: ((CLKComplicationTimelineEntry?) -> Void)) { // Call the handler with the current timeline entry var entry : CLKComplicationTimelineEntry? let now = NSDate() // Create the template and timeline entry. if complication.family == .ModularSmall { let imageTemplate = CLKComplicationTemplateModularSmallSimpleImage() imageTemplate.imageProvider = CLKImageProvider(backgroundImage:UIImage(named: "sunny")!, backgroundColor: nil) // Create the entry. entry = CLKComplicationTimelineEntry(date: now, complicationTemplate: imageTemplate) } else { // ...configure entries for other complication families. } // Pass the timeline entry back to ClockKit. handler(entry) } ``` 在这个方法中,系统会提供给我们所需要的 complication 的类型,我们要做的是使用合适的系统所提供的模板 (这里是 `CLKComplicationTemplateModularSmallSimpleImage`) 以及我们自己的数据,来构建一个 `CLKComplicationTimelineEntry` 对象,然后再 handler 中返回给系统。结合天气 app 的特点,我们这里选择了一个小的简单图片的模板。另外因为篇幅有限,这里只实现了 `.ModularSmall`。在实际的项目中,你应该支持尽量多的 complication 类型,这样可以保证你的用户在不同的表盘上都能使用。 在提供具体的数据时,我们使用 template 的 `imageProvider` 或者 `textProvider`。在我们现在使用的这个模板中,只有一个简单的 `imageProvider`,我们从 extension 的 Assets Category 中获取并设置合适的图像就可以了 (对于 `.ModularSmall` 来说,需要图像的尺寸为 52px 和 58px 的 @2x。关于其他模板的图像尺寸要求,可以参考[文档](https://developer.apple.com/library/prerelease/watchos/documentation/ClockKit/Reference/ClockKit_framework/index.html#//apple_ref/doc/uid/TP40016082))。 运行程序,选取一个带有 `ModularSmall` complication 的表盘 (如果是在模拟器的话,可以使用 Shift+Cmd+2 然后点击表盘来打开表盘选择界面),然后在 complication 中选择 WatchWeather,可以看到以下的结果: ![step-18](/assets/images/2015/step-18.png) 看起来不错,我们的小太阳已经在界面上熠熠生辉了,接下来就是要实现把实际的数据替换进来。对于 complication 来说,我们需要以尽可能快的速度去调用 handler 来向系统提供数据。我们并没有那么多时间去从网络上获取数据,所以需要使用之前在 watch app 或者是 iOS app 中获取到的数据来组织 complication。为了在 complication 中能直接获取数据,我们需要在用 Client 获取到数据后把它存在本地。这里我们用 UserDefaults 就已经足够了。在 `Weather.swift` 中加入以下 extension: ```swift public extension Weather { static func storeWeathersResult(dictionary: [String: AnyObject]) { let userDefault = NSUserDefaults.standardUserDefaults() userDefault.setObject(dictionary, forKey: kWeatherResultsKey) userDefault.setObject(NSDate(), forKey: kWeatherRequestDateKey) userDefault.synchronize() } public static func storedWeathers() -> (requestDate: NSDate?, weathers: [Weather?]?) { let userDefault = NSUserDefaults.standardUserDefaults() let date = userDefault.objectForKey(kWeatherRequestDateKey) as? NSDate let weathers: [Weather?]? if let dic = userDefault.objectForKey(kWeatherResultsKey) as? [String: AnyObject] { weathers = parseWeatherResult(dic) } else { weathers = nil } return (date, weathers) } } ``` 这里我们需要知道获取到这组数据时的时间,我们以当前时间作为获取时间进行存储。一个更加合适的做法应该是在请求的返回中包含每个天气状况所对应的时间信息。但是因为我们并没有真正的服务器,也并非实际的请求,所以这里就先简单粗暴地用本地时间了。接下来,在每次请求成功后,我们调用 `storeWeathersResult` 将结果存储起来。在 `WeatherClient.swift` 中,把 ```swift dispatch_async(dispatch_get_main_queue(), { () -> Void in handler?(weathers: Weather.parseWeatherResult(dictionary), error: nil) }) ``` 这段代码改为: ```swift dispatch_async(dispatch_get_main_queue(), { () -> Void in let weathers = Weather.parseWeatherResult(dictionary) if weathers != nil { Weather.storeWeathersResult(dictionary) } handler?(weathers: weathers, error: nil) }) ``` 接下来我们还需要另外一项准备工作。Complication 的时间线是以一组 `CLKComplicationTimelineEntry` 来表示的,一个 entry 中包含了 template 和对应的 `NSDate`。watchOS 将在当前时间超过这个 `NSDate` 时表示。所以如果我们需要显示当天的天气情况的话,就需要将对应的日期设定为当日的 0 点 0 分。对于其他几个日期的天气来说,这个状况也是一样的。我们需要添加一个方法来通过 Weather 的 `day` 属性和请求的当日日期来返回一个对应 entry 中需要的日期。为了运算简便,我们这里引入一个第三方框架,[SwiftDate](https://github.com/malcommac/SwiftDate)。将这个项目导入我们 app,然后在 `Weather.swift` 中添加: ```swift public extension Weather { public func dateByDayWithRequestDate(requestDate: NSDate) -> NSDate { let dayOffset = day.rawValue let date = requestDate.set(componentsDict: ["hour":0, "minute":0, "second":0])! return date + dayOffset.day } } ``` 接下来我们就可以更新 `ComplicationController.swift` 的内容了。首先我们需要实现 `getTimelineStartDateForComplication:withHandler:` 和 `getTimelineEndDateForComplication:withHandler:` 来告诉系统我们所能提供 complication 的日期区间: ```swift func getTimelineStartDateForComplication(complication: CLKComplication, withHandler handler: (NSDate?) -> Void) { var date: NSDate? = nil let (reqestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { if w!.day == .DayBeforeYesterday { date = w!.dateByDayWithRequestDate(requestDate) break } } } handler(date) } func getTimelineEndDateForComplication(complication: CLKComplication, withHandler handler: (NSDate?) -> Void) { var date: NSDate? = nil let (reqestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { if w!.day == .DayAfterTomorrow { date = w!.dateByDayWithRequestDate(requestDate) + 1.day - 1.second break } } } handler(date) } ``` 最早的时间是前天的 00:00,这是毫无疑问的。但是最晚的可显示时间并不是后天的 00:00,而是 23:59:59,这里一定需要注意。 另外,为了之后创建 template 能容易一些,我们添加一个由 `Weather.State` 创建 template 的方法: ```swift private func templateForComplication(complication: CLKComplication, weatherState: Weather.State) -> CLKComplicationTemplate? { let imageTemplate: CLKComplicationTemplate? if complication.family == .ModularSmall { imageTemplate = CLKComplicationTemplateModularSmallSimpleImage() let imageName: String switch weatherState { case .Sunny: imageName = "sunny" case .Cloudy: imageName = "cloudy" case .Rain: imageName = "rain" case .Snow: imageName = "snow" } (imageTemplate as! CLKComplicationTemplateModularSmallSimpleImage).imageProvider = CLKImageProvider(backgroundImage:UIImage(named: imageName)!, backgroundColor: nil) } else { imageTemplate = nil } return imageTemplate } ``` 接下来就是实现核心的三个提供时间轴的方法了,虽然很长,但是做的事情却差不多: ```swift func getCurrentTimelineEntryForComplication(complication: CLKComplication, withHandler handler: ((CLKComplicationTimelineEntry?) -> Void)) { // Call the handler with the current timeline entry var entry : CLKComplicationTimelineEntry? // Create the template and timeline entry. let (requestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { let weatherDate = w!.dateByDayWithRequestDate(requestDate) if weatherDate == NSDate.today() { if let template = templateForComplication(complication, weatherState: w!.state) { entry = CLKComplicationTimelineEntry(date: weatherDate, complicationTemplate: template) } } } } // Pass the timeline entry back to ClockKit. handler(entry) } func getTimelineEntriesForComplication(complication: CLKComplication, beforeDate date: NSDate, limit: Int, withHandler handler: (([CLKComplicationTimelineEntry]?) -> Void)) { // Call the handler with the timeline entries prior to the given date var entries = [CLKComplicationTimelineEntry]() let (requestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { let weatherDate = w!.dateByDayWithRequestDate(requestDate) if weatherDate < date { if let template = templateForComplication(complication, weatherState: w!.state) { let entry = CLKComplicationTimelineEntry(date: weatherDate, complicationTemplate: template) entries.append(entry) if entries.count == limit { break } } } } } handler(entries) } func getTimelineEntriesForComplication(complication: CLKComplication, afterDate date: NSDate, limit: Int, withHandler handler: (([CLKComplicationTimelineEntry]?) -> Void)) { // Call the handler with the timeline entries after to the given date var entries = [CLKComplicationTimelineEntry]() let (requestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { let weatherDate = w!.dateByDayWithRequestDate(requestDate) if weatherDate > date { if let template = templateForComplication(complication, weatherState: w!.state) { let entry = CLKComplicationTimelineEntry(date: weatherDate, complicationTemplate: template) entries.append(entry) if entries.count == limit { break } } } } } handler(entries) } ``` 代码来说非常简单。`getCurrentTimelineEntryForComplication` 中我们找到今天的 `Weather` 对象,然后构建合适的 entry。而对于 `beforeDate` 和 `afterDate` 两个版本的方法,按照系统提供的 `date` 我们需要组织在这个 `date` 之前或者之后的所有 entry,并将它们放到一个数组中去调用回调。这两个方法中还为我们提供了一个 `limit` 参数,我们的结果数应该不超过这个数字。在实现这三个方法后,我们的时间线就算是构建完毕了。 另外,我们还可以通过实现 `getPlaceholderTemplateForComplication:withHandler:` 来提供一个在表盘定制界面是会用到的占位图像。 ```swift func getPlaceholderTemplateForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTemplate?) -> Void) { // This method will be called once per supported complication, and the results will be cached handler(templateForComplication(complication, weatherState: .Sunny)) } ``` 这样,在自定义表盘界面我们也可以在选择到我们的 complication 时看到表示我们的 complication 的样式了: ![step-19](/assets/images/2015/step-19.png) `ComplicationController` 中最后需要实现的是 `getNextRequestedUpdateDateWithHandler`。系统会在你的 watch app 被运行时更新时间线,另外要是你的 app 一直没有被运行的话,你可以通过这个方法提供给系统一个参考时间,用来建议系统应该在什么时候为你更新时间线。这个时间应该尽可能长,以节省电池的电量。在我们的天气的例子中,每天一次更新也许会是个不错的选择: ```swift func getNextRequestedUpdateDateWithHandler(handler: (NSDate?) -> Void) { // Call the handler with the date when you would next like to be given the opportunity to update your complication content handler(NSDate.tomorrow()); } ``` > 你也许会注意到,因为我们这里要是不开启 watch app 的话,其实天气数据时不会更新的,这样我们设定刷新时间线似乎并没有什么意义 - 因为不开 watch app 的话数据并不会变化,而开了 watch app 的话时间线就会直接被刷新。这里我们考虑到了之后使用 Watch Connectivity 从手机端刷新 watch 数据的可能性,所以做了每天刷新一次的设置。我们在稍后会详细将这方面内容。 另外,我们还需要记得在 watch app 数据更新之后,强制 reload 一下 complication 的数据。在 ComplicationController.swift 中加入: ```swift static func reloadComplications() { let server = CLKComplicationServer.sharedInstance() for complication in server.activeComplications { server.reloadTimelineForComplication(complication) } } ``` 然后在 `InterfaceController.swift` 的 `request` 中,在请求成功返回后调用一下这个方法就可以了。 现在,我们的 watch app 已经支持 complication 了。同时,因为我们努力提供了之前和之后的数据,我们免费得到了 Time Travel 的支持。现在你不仅可以在表盘上看到今天的天气,通过旋转 Digital Crown 你还能了解到之前和之后的天气状况了: ![step-20](/assets/images/2015/step-20.gif) 到这里为止的项目代码可以在 [complication tag](https://github.com/onevcat/WatchWeather/releases/tag/complication) 中找到。 ### Watch Connectivity 在 watchOS 1 时代,watch 的 extension 是和 iOS app 一样,存在于手机里的。所以在 watch extension 和 iOS app 之间共享数据是比较简单的,和其他 extension 类似,使用 app group 将 app 本体和 extension 设为同一组 app,就可以在一个共享容器中共享数据了。但是这在 watchOS 2 中发生了改变。因为 watchOS 2 的手表 extension 是直接存在于手表中的,所以之前的 app group 的方法对于 watch app 来说已经失效。Watch extension 现在会使用自己的一套数据存储 (如果你之前注意到了的话,我们在请求数据后将它存到了 UserDefaults 中,但是手机和手表的 UserDefaults 是不同的,所以我们不用担心数据被不小心覆盖)。如果我们想要在 iOS 设备和手表之间共享数据的话,我们需要使用新的 Watch Connectivity 框架。 `WatchConnectivity` 框架所扮演的角色就是 iOS app 和 watch extension 之间的桥梁,利用这个框架你可以在两者之间互相传递数据。在这个例子中,我们会用 `WatchConnectivity` 来改善我们的天气 app 的表现 -- 我们打算实现无论在手表还是 iOS app 中,每天最多只进行一次请求。在一个设备上请求后,我们会把数据传递到配对的另一个设备上,这样在另一个设备上打开 app 时,就可以直接显示天气状况,而不再需要请求一次了。 我们在 iOS app 和 watchOS app 中都可以使用 WatchConnectivity。首先我们需要检查设备上是否能使用 session,因为在一部分设备 (比如 iPad) 上,这个框架是不能使用的。这可以通过 `WCSession.isSupported()` 来判断。在确认平台上可以使用后,我们可以设定 delegate 来监听事件,然后开始这个 session。当我们有一个已经启动的 session 后,就可以通过框架的方法来向配对的另一个设备发送数据了。 大致来说数据发送分为后台发送和即时消息两类。当 iOS app 和 watch app 都在前台的时候,我们可以通过 `-sendMessage:replyHandler:errorHandler:` 来在两者之间发送消息,这在 iOS app 和 watch app 之间需要互动的时候是非常有用的。另一种是后台发送,在 iOS 或 watch app 中有一者不在前台时,我们就需要考虑使用这种方式。后台通讯有三种方式:通过 Application Context,通过 User Info,以及传送文件。文件传送简单明了就是传递一个文件,另外两个都是传递一个字典,不同之处在于 Application Context 将会使用新的数据覆盖原来的内容,而 User Info 则可以使多次内容形成队列进行传送。每种方式都会在另外一方的 session 开始运行后调用相应的 delegate 方法,于是我们就能知道有数据发送过来了。 结合天气 app 的特点,我们应该选择使用 Application Context 来收发数据。这篇文章已经太长了,所以我们这里只做从 iOS 到 watchOS 的发送了。因为反过来的代码其实完全一样,我会在 repo 中完成,在这里就不再重复一遍了。 首先是在 iOS app 中启动 session。在 `ViewController.swift` 中添加一个属性:`var session: WCSession?`,然后在 `viewDidLoad:` 中添加: ```swift if WCSession.isSupported() { session = WCSession.defaultSession() session!.delegate = self session!.activateSession() } ``` 为了让 `self` 成为 session 的 delegate,我们需要声明 `ViewController` 实现 `WCSessionDelegate`。这里我们先在文件最后添加一个空的 extension 即可: ```swift extension ViewController: WCSessionDelegate { } ``` 注意我们一定需要设定 session 的 delegate,即使它什么都没有做。一个没有 delegate 的 session 是不能被启动或正确使用的。 然后就是发送数据了。在 `requestWeathers` 的回调中,数据请求一切正常的分支最后,添加一段 ```swift if error == nil && weather != nil { //... if let dic = Weather.storedWeathersDictionary() { do { try self.session?.updateApplicationContext(dic) } catch _ { } } } else { ... } ``` 这里的 `storedWeathersDictionary` 是个新加入的方法,它返回存储在 User Defaults 中的内容的字典表现形式 (我们在请求返回的时候就已经将结果内容存储在 User Defaults 里了,希望你还记得)。 在 watchOS app 一侧,我们类似地启动一个 session。在 `InterfaceController.swift` 的 `awakeWithContext` 中的 `dispatch_once` 里,添加 ```swift if WCSession.isSupported() { InterfaceController.session = WCSession.defaultSession() InterfaceController.session!.delegate = self InterfaceController.session!.activateSession() } ``` 然后添加一个 extension 来接收传输过来的数据: ```swift extension InterfaceController: WCSessionDelegate { func session(session: WCSession, didReceiveApplicationContext applicationContext: [String : AnyObject]) { guard let dictionary = applicationContext[kWeatherResultsKey] as? [String: AnyObject] else { return } guard let date = applicationContext[kWeatherRequestDateKey] as? NSDate else { return } Weather.storeWeathersResult(dictionary, requestDate: date) } } ``` 最后,在请求数据之前我们可以判断一下已经存储在 User Defaults 中的内容是否是今天请求的。如果是的话,就不再需要进行请求,而是直接使用存储的内容来刷新界面,否则的话进行请求并存储。将原来的 `self.request()` 改为: ```swift dispatch_async(dispatch_get_main_queue()) { () -> Void in if self.shouldRequest() { self.request() } else { let (_, weathers) = Weather.storedWeathers() if let weathers = weathers { self.updateWeathers(weathers) } } } ``` 如果你只是单纯地 copy 这些代码的话,在之前项目的基础上应该是不能编译的。这是因为在这里我并没有列举出所有的改动,而只是写出了关于 WatchConnectivity 的相关内容。这里涉及到了每次启动或者从后台切换到 app 时都需要检测并刷新界面,所以我们还需要一些额外的重构来达到这个目的。这些内容我们在此也略过了。同理,在 watchOS app 需要请求,并且请求结束的时候,我们也可以如前所述,通过几乎一样的代码和方式将请求得到的内容发回给 iOS app。这样,当我们打开 iOS app 时,也就不需要再次进行网络请求了。 这部分的完整的代码可以在这个 repo 的[最终的 tag](https://github.com/onevcat/WatchWeather/releases/tag/connectivity) 上找到,您可以尝试自己实现一下,也可以直接找这里的代码进行参考。如果后续还有修正的话,我会直接在 master 上进行。 ## 总结 本文从零开始完成了一个 iOS 和 Apple Watch 上的天气情况的 app。虽然说数据源上用的是一个 stub,但是在其他方面还算是比较完整的。本来主要的目的是探索下 watchOS 2 中的几个新 API 的用法,主要是 complication 和 WatchConnectivity。但是发现如果只是单纯地照搬文档的话一是不够直观,二是很难说明问题,所以干脆不如从头开始,和大家一起完成一个 app 来的更实在。 Apple Watch 作为 Apple 的新产品线,其实所扮演的角色会非常重要。watchOS 一代由于种种限制,开发者们很难发挥出设备的优势来做出一些有意思的 app。在一代系统中,手表更多地还是只是一块 iPhone 的额外屏幕。但是在 watchOS 2 中,这一状况有望改善。更加合理和灵活的 app 组织方式以及在手表上的 native 开发,使得 Apple Watch 的可用范围提升了不止一个档次。而在经历了大半年的彷徨之后,Apple Watch 开发也逐渐趋于稳定,系统的架构和 API 也逐渐合理。其实 Apple Watch 还是一款非常有希望的产品,相信随着设备的进一步成熟和 SDK 的更加开放,我们会有机会像是直接利用 Digital Crown 或者其他一个手表特性来开发令人耳目一新的 app。个人的对于 Apple Watch 开发的建议是,现在最好能紧跟上 watch 开发的脚步,尽量进行积累,这样你才有可能在之后的爆发中取得先机和灵感。 就这么多吧 (其实已经很多了),祝编程愉快~ URL: https://onevcat.com/2015/06/multitasking/index.html.md Published At: 2015-06-15 16:25:07 +0900 # WWDC15 Session笔记 - iOS 9 多任务分屏要点 本文是我的 [WWDC15 笔记](http://onevcat.com/2015/06/ios9-sdk/)中的一篇,涉及的 Session 有 - [Getting Started with Multitasking on iPad in iOS 9](https://developer.apple.com/videos/wwdc/2015/?id=205) - [Multitasking Essentials for Media-Based Apps on iPad in iOS 9](https://developer.apple.com/videos/wwdc/2015/?id=211) - [Optimizing Your App for Multitasking on iPad in iOS 9](https://developer.apple.com/videos/wwdc/2015/?id=212) ### iOS 9 多任务综述 iOS 9 中最引人注目的新特性就是多任务了,在很久以前的越狱开发里就已经出现过类似的插件,而像是 Windows Surface 系列上也已经有分屏多任务的特性,可以让用户同时使用两个或多个 app。iOS 9 中也新加入类似的特性。iOS 9 中的多任务有三种表现形式,临时出现和交互的滑动覆盖 (Slide Over),真正的分屏同时操作两个 app 的分割视图 (Split View),以及在其他 app 里依然可以进行视频播放的画中画 (Picture in Picture) 模式。 ![](/assets/images/2015/multitasking-1.jpg) 在关于多任务的文档中,Apple 明确指出: > 绝大部分 app 都应当适配 Slide Over 和 Split View 因为这正是 iOS 9 的核心功能之一,也是你的用户所期望看到的。另一方面,支持多任务也增加了你的用户打开和使用你的 app 的可能。不过多任务有一点限制,那就是在能够安装 iOS 9 的 iPad 设备上,仅只有性能最强大的 iPad Air 2 和之后的机型支持分割视图模式,而其他像是 iPad mini 2,iPad mini 3 以及 iPad Air 只支持滑动覆盖和画中画两种模式。这在一定程度上应该还是基于移动设备资源和性能限制的考虑做出的决策,毕竟要保证良好的使用体验为前提,多任务才会有意义。 对于开发者来说,虽然多种布局看起来很复杂,但是实际上如果紧跟 Apple 的技术步伐的话,将自己的 iPad app 进行多任务适配并不会是一件非常困难的事情。因为滑动覆盖模式和分割视图模式所采用的布局其实就是 Compact Width 的布局,而这一概念就是 WWDC14 上引入的基于屏幕特征的 UI 布局方式。如果你已经在使用这套布局方式了的话,那么可以说多任务视图的支持也就顺带自动完成了。不过如果你完全没有使用过甚至没有听说过这套布局方法的话,我去年的[一篇笔记](http://onevcat.com/2014/07/ios-ui-unique/)可能能帮你对此有初步了解,在下一节里我也会稍微再稍微复习一下相关概念和基本用法。 ### Adaptive UI 复习 Adaptive UI 是 Apple 在 iOS 8 提出的概念。在此之前,我们如果想要同时为 iPhone 和 iPad 开发 app 的话,很可能会写很多设备判断的代码,比如这样: ```swifts if UI_USER_INTERFACE_IDIOM() == .Pad { // 设备是 iPad } ``` 除此之外,如果我们想要同时适配横向和纵向的话,我们会需要类似这样的代码: ```swift if UIInterfaceOrientationIsPortrait(orientation) { // 屏幕是竖屏 } ``` 这些判断和分支不仅难写难读,也使适配开发困难重重。从 iOS 8 之后,开发者不应该再依赖这样设备向来进行 UI 适配,而应该转而使用新的 Size Class 体系。Apple 将自家的移动设备按照尺寸区别,将纵横两个方向设计了 Regular 和 Compact 的组合。比如 iPhone 在竖屏时宽度是 Compact,高度是 Regular,横屏时 iPhone 6 Plus 宽度是 Regular,高度是 Compact,而其他 iPhone 在横屏时高度和宽度都是 Compact;iPad 不论型号和方向,宽度及高度都是 Regular。现有的设备的 Size Class 如下图所示: ![](/assets/images/2015/multitasking-2.jpg) 针对 Size Class 进行开发的思想下,我们不再关心具体设备的型号或者尺寸,而是根据特定的 Size Class 的特性来展示内容。在 Regular 的宽度下,我们可以在水平方向上展示更多的内容,比如同时显示 Master 和 Detail View Controller 等。同样地,我们也不应该再关心设备旋转的问题,而是转而关心 Size Class 的变化。在开发时,如果是使用 Interface Builder 的话,在制作 UI 时就注意为不同的 Size Class 配置合适的约束和布局,在大多数情况下就已经足够了。如果使用代码的话,`UITraitCollection` 类将是使用和操作 Size Class 的关键。我们可以根据当前工作的 `UIViewController` 的 `traitCollection` 属性来设置合适的布局,并且在 ` -willTransitionToTraitCollection:withTransitionCoordinator:` 和 ` -viewWillTransitionToSize:withTransitionCoordinator:` 被调用时对 UI 布局做出正确的响应。 虽然并不是理论上不可行,但是使用纯手写来操作 Size Class 会是一件异常痛苦的事情,我们还是应该尽可能地使用 IB 来减少这部分的工作量,加快开发效率。 ### iPad 中的多任务适配 对于 iOS 9 中的多任务,滑动覆盖和分割视图的初始位置,新打开的 app 的尺寸都将是设备尺寸的 1/3。不过这个比例并不重要,我们需要记住的是新打开的 app 将运行在 Compact Width 和 Regular Height 的 Size Class 下。也就是说,如果你的 iPad app 使用了 Size Class 进行布局,并且是支持 iPhone 竖屏的,那么恭喜,你只需要换到 iOS 9 SDK 并且重新编译你的 app,就搞定了。 因为本文的重点不是教你怎么开发一个 Adaptive UI 的 app,所以并不打算在这方面深入下去。如果你在去年缺了课,不是很了解这方面的话,[这篇教程](http://www.raywenderlich.com/83276/beginning-adaptive-layout-tutorial)可能可以帮你快速了解并掌握这些内容。如果你想要直接上手看看 iOS 9 中的 多任务是如何工作的话,可以新建一个 Master-Detail Application,并将其安装到 iPad 模拟器上。Master-Detail 的模板工程为我们搭设了一个很好的适配 Size Class 的框架,让项目可以在任何设备上都表现良好。同样你也可以观察它在 iOS 9 的 iPad 上的表现。 但是其实并不是所有的 app 都应该适配多任务,比如一个需要全屏才能体验的游戏就是典型。如果你不想你的 app 可以作为多任务的副 app 被使用的话,你可以在 Info.plist 中添加 `UIRequiresFullScreen` 并将其设为 `true`。 Easy enough?没错,要适配 iPad 的多任务,你需要做的就只有按照标准流程开发一个全平台通用 app,仅此而已。 1. 使用 iOS 9 SDK 构建你的 app; 2. 支持所有的方向和对应的 Size Class; 3. 使用 launch storyboard 作为 app 启动页面。 虽说没太多特别值得一提的内容,但是也还是有一些需要注意的小细节。 ### 一些值得注意的小细节 在以前是不存在 app 在前台还要和别的 app 共享屏幕这种事情的,所以 `UIScreen.bounds` 和主窗口的 `UIWindow.bounds` 使用上来说基本是同义词。但是在多任务时代,`UIWindow` 就有可能只有 1/3 或者 1/2 屏幕大小了。如果你在之前的 app 中有使用它来定义你的视图的话,就有必要为多任务做特殊的处理了。不过虽然滑动覆盖和分割视图都是在右侧展示,但是它们的 Window 的 origin 依然是 (0, 0),这也方便了我们定义视图。 第二个细节是现在 iPad UI 的 Size Class 是会发生变化的。以前不论是竖直还是水平,iPad 屏幕的 Size 总是长宽均为 Regular 的。但是在 iOS 9 中情况就不一样了,你的 app 可能被作为附加 app 通过多任务模式打开,可能会在多任务时被用户拖动从而变成全屏 app (这时 Size Class 将从 Compact 的宽度变为 Regular),甚至可能你的 app 作为主 app 被使用是会因为用户拖动而变成 Compact 宽度的 app: ![](/assets/images/2015/size_classes.png) 换句话说,你不知道你的 app 的 Size Class 会不会变化,以及何时变化,这都是用户操作的结果。因此在开发时,就必须充分考虑到这一点,力求在尺寸变化时呈现给用户良好的效果。根据屏幕大小进行合适的 UI 设计和调整自不用说,另外还应当注意在合适的时机利用 `transitionCoordinator` 的 `-animateAlongsideTransition:` 来进行布局动画,让切换更加自然。 由于多任务带来了多个 app 同台运行的可能性,因此你的 app 必定会面临和别的 app 一起运行的情况。在开发移动应用时永远不能忘记的是设备平台的限制。相比于桌面设备,移动端只有有限的内存,而两个甚至三个 app 同时在前台运行,就需要我们精心设计内存的使用。对于一般开发者来说,合理地分配内存,监听 Memory Warning 来释放 cache 和不必要的 view controller,避免循环引用等等,应该成为熟练掌握的日常开发基本功。 最后一个细节是对完美的苛求了。在 iOS 9 中多任务也通过 App Switcher 来进行 app 之间的切换的。所以在你的 app 被切换到后台时,系统会保存你的 app 的当前状态的截图,以供之后切换时显示。你的 app 现在有可能被作为 Regular 的全屏 app 使用,也可能使用 Compact 布局,所以在截图时系统也会依次保存两份截图。用户可能会在全屏模式下把你的 app 关闭,然后通过多任务再将你的 app 作为附加 app 打开,这时最好能保证 App Switcher 中的截图和 app 打开后用户看到的截图一致,以获取最好的体验。可能这并不是一个很大的问题,但是如果追求极致的用户体验的话,这也是必行的。对于那些含有用户敏感数据,需要将截图模糊处理的 app,现在也需要注意同时将两种布局的截图都进行处理。 ### 画中画模式 iOS 9 中多任务的另一种表现形式就是视频的画中画模式:即使退出了,你的视频 app 也可以在用户使用别的 app 的时候保持播放,比如一边看美剧一边写日记或者发邮件。这大概会是所有的视频类 app 都必须要支持的特性了,实现起来也很容易: 1. 使用 iOS 9 SDK 构建你的 app; 2. 在 app 的 Capabilities 里,将 Background Modes 的 "Audio, AirPlay, and Picture in Picture" 勾选上 (Xcode 7 beta 中暂时为 "Audio and AirPlay"); 3. 将 AudioSession Catogory [设置为合适的选项](https://gist.github.com/onevcat/82defadf559968c6a3bc),比如 `AVAudioSessionCategoryPlayback` 4. 使用 AVKit,AVFoundation 或者 WebKit 框架来播放视频。 在 iOS 9 中,一直伴随我们的 MediaPlayer 框架中的视频播放部分正式宣布寿终正寝。也就是说,如果你在使用 `MPMoviePlayerViewController` 或者 `MPMoviePlayerController` 在播放视频的话,你就无法使用画中画的特性了,因此尽快转型到新的视频播放框架会是急迫的适配任务。因为画中画模式是基于 `AVPlayerLayer` 的。当切换到画中画时,会将正在播放视频的 layer 取出,然后进行缩小后添加到新的界面的 layer 上。这也是旧的 MediaPlayer 框架无法支持画中画的主要原因。 如果你使用 `AVPlayerViewController` 的话,一旦满足这些简单的条件以后,你应该就可以在使用相应框架全屏播放视频时看到右下角的画中画按钮了。不论是点击这个按钮进入画中画模式还是直接使用 Home 键切换到后台,已经在播放的视频就将缩小到屏幕右下角成为画中画,并保持播放。在画中画模式下,系统会在视频的 AVPlayerLayer 上添加一套默认控件,用来控制暂停/继续,关闭,以及返回 app。前两个控制没什么可多说的,返回 app 的话需要我们自己处理返回后的操作。一般来说我们希望能够恢复到全屏模式并且继续播放这个视频,因为 `AVPlayerViewController` 进行播放时我们一般不会去操作 `AVPlayerLayer`,在恢复时就需要实现 `AVPlayerViewControllerDelegate` 中的 `-playerViewController:restoreUserInterfaceForPictureInPictureStopWithCompletionHandler:` 来根据传入的 ViewController 重建 UI,并将 `true` 通过 CompletionHandler 返回给系统,已告知系统恢复成功 (当然如果无法恢复的话需要传递 false)。 我们也可以直接用 `AVPlayerLayer` 来构建的自定义的播放器。这时我们需要通过传入所使用的 `AVPlayerLayer` 来创建一个 `AVPictureInPictureController`。`AVPictureInPictureController` 提供了检查是否支持画中画模式的 API,以及其他一些控制画中画行为的方法。与直接使用 `AVPlayerViewController` 不太一样的是,在恢复时,系统将会把画中画时缩小的 `AVPlayerLayer` 返还到之前的 view 上。我们可以通过 `AVPictureInPictureControllerDelegate` 中的相应方法来获知画中画的执行情况,并结合自己 app 的情况来恢复 UI。 ### 总结 通过之前几年的布局,在 AutoLayout 和 Size Class 的基础上,Apple 在 iOS 9 中放出了多任务这一杀手锏。可以说同屏执行多个 app 的需求从初代 iPad 开始就一直存在,而现在总算是姗姗来迟。在 OS X 10.11 中,Apple 也将类似的特性引入了 OSX app 的全屏模式中,可以说是统一 OSX 和 iOS 这两个平台的进一步尝试。 但是 iPad 上的多任务还是有一些不足的。最大的问题是 app 依然是运行在沙盒中的,这就意味着在 iOS 上我们还是无法在两个 app 之间进行通讯:比如同时打开照片和一个笔记 app,我们无法通过拖拽方式将某张图片直接拖到笔记中去。虽然在 iOS 中也有 [XPC 服务](https://developer.apple.com/library/mac/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingXPCServices.html),但是第三方开发者现在并不能使用,这在一定程度上还是限制了多任务的可能性。 不过总体来说,多任务特性使得 iPad 的实用性大大上升,这也肯定会是未来用户最常用以及最希望在 app 中看到的特性之一。花一点时间,学习 Adaptive UI 的制作方式,让 app 支持多任务运行,会是一件很合算的事情。 URL: https://onevcat.com/2015/06/ios9-sdk/index.html.md Published At: 2015-06-11 10:24:16 +0900 # 开发者所需要知道的 iOS 9 SDK 新特性 ![](/assets/images/2015/wwdc15.jpg) 本文为InfoQ中文站特供稿件,[首发地址](http://www.infoq.com/cn/news/2015/06/ios9-sdk)。如需转载,请与InfoQ中文站联系。 年年岁岁花相似,岁岁年年人不同。今年的 WWDC 一如既往的热闹,但是因为要照顾家里刚出生的宝宝以及宝宝的娘,就只能在家里的“窝里蹲”家庭影院来关注这一全球 Apple 开发者的盛会了。 生命不息,学习不止。一如以往几年,我会陆续写一些关于 WWDC 和新的 SDK 里我觉得有意思和我自己重点关注和学习的内容。现在回头看前几年写的东西,愈来愈感觉到以前青葱岁月的自己真是傻得可爱。不过一路以来的成长轨迹倒是很明显,也希望自己能就这样淡然地将这段旅程继续下去。 矫情结束,该干活了。让我们来看看今年的 WWDC 中我认为的开发者需要关注的一些内容吧。 ### 总览 iOS 9 时代开发者面临的最大的挑战和最急切的任务可能有两个方面,首先是如何利用和适配 iPad 的新的分屏多任务特性,其次是如何面对和利用 watchOS 2 来构建原生的手表 app。另外的新课题基本就都是现有框架的衍生和扩展,包括从单元测试扩展到 UI 测试,如何进一步占领和使用系统的通知中心及搜索页面,以及 Swift 2 的使用等。 可以说,经过了 iOS 7 和 iOS 8 连续两次重量级的变革和更新,对普通的 app 开发者来说,iOS 9 SDK 略归于缓和和平静,新的 SDK 在 API 和整体设计上并没有发生什么非常巨大的改变。开发者们也正可以利用这个机会喘息一下,尽快进一步熟悉和至少过渡到使用 iOS 8 SDK 的内容来构筑自己的 app (比如尝试使用 [Size Class 和 Presentation Controller](http://onevcat.com/2014/07/ios-ui-unique/) 等),尽快提升自己的职业技能和制作的 app 的水平,并保证能跟上滚滚向前的 Apple 车轮,应该是今年 Cocoa 开发者们的主要任务。 ### Multitasking 这可以说是 iOS 9 最大的卖点了。多任务特性,特别是分屏多任务使得 iPad 真正变得像一个堪当重任的个人电脑。虽然在很早以前就已经有越狱插件能让 iPad 同时运行多个程序,但是 Apple 还是很谨慎地到 2015 年才在自己性能最为强劲的移动设备上实装这个功能。iOS 9 中的多任务分为三种表现形式,分别是临时调出的滑动覆盖 (Slide Over),视频播放的画中画模式 (Picture in Picture) 以及真正的同时使用两个 app 的分割视图 (Split View)。现在能运行 iOS 9 的设备中只有最新的 iPad Air 2 支持分割视图方式,但是相信随着设备的更新,分割视图的使用方式很可能成为人们日常使用 iPad 的一种主流方式,因此提早进行准备是开发者们的必修功课。 虽然第一眼看上去感觉要支持多任务的视图会是一件非常复杂的事情,但是实际上如果你在前一年就紧跟 Apple 步伐的话,就很简单了。滑动覆盖和分割视图的 app 会使用 iOS 8 引入的 Size Class 中的 Compact Width 和 Regular Height 的设定,配合上 AutoLayout 来进行布局。也就是说,如果你的 app 之前就是 iPhone 和 iPad 通用的,并且已经使用了 Size Class 进行布局的话,基本上你不需要再额外做什么事儿就已经能支持 iOS 9 的多任务视图了。但是如果不幸你还没有使用这些技术的话,可能你会需要尽快迁移到这套布局方式中,才能完美支持了。 视频 app 的画中画模式相对简单一些,如果你使用 `AVPlayerViewController` 或者 `AVPlayerLayer` 来播放视频的话,那什么都不用做就已经支持了。但如果你之前选择的方案是 `MPMoviePlayerController` 或者 `MPMoviePlayerViewController` 的话,你可能也需要尽早迁移到 AVKit 的框架下来,因为 Media Player 将在 iOS 9 被标记为 deprecated 并不再继续维护。 > 相关专题笔记 > > [iOS 9 多任务分屏要点](http://onevcat.com/2015/06/multitasking/) ### watchOS 2 在新的 watchOS 2 中,Watch App 的架构发生了巨大改变。新系统中 Watch App 的 extension 将不像现在这样存在于 iPhone 中,而是会直接安装到手表里去,Apple Watch 从一个单纯的界面显示器进化为了可执行开发者代码的设备。得益于此,开发者们也可以在 extension 中访问到像数字表冠和 (虽然都只是很初级的访问,但是聊胜于无) 心跳计数这样的情报。虽然有所进步,但是其实 Apple 在 watchOS 2 里表现出来的态度还是十分谨慎,这可能和初代 Apple Watch 的设备限制有很大关系,所以实际上留给 app 开发者的电量和性能空间并不是十分广阔。但是相比起现在的 WatchKit 来说,可以脱离 iPhone 运行本身就是了不起的进步了。而为了和 iPhone 进行通讯,现在还添加了 WatchConnectivity 这个新框架。我们有足够的理由期待 Apple Watch 和 WatchKit 在接下来两三年里的表现。 > 相关专题笔记 > > [30 分钟开发一个简单的 watchOS 2 app](http://onevcat.com/2015/08/watchos2/) ### UI Test 在开发领域里,测试一直是保障产品质量关键。从 Xcode 4 以来,测试在 app 开发中的地位可谓是逐年上升。从 XCT 框架的引入,到测试 target 成为新建项目时的默认,再到去年加入的异步代码测试和性能测试。可以说现在 Xcode 自带的测试框架已经能满足绝大部分单元测试的需求了。 但是这并不够。开发一个 iOS app 从来都是更注重 UI 和用户体验的工作,而简单地单元测试可以很容易地保证 model 层的正确,却很难在 UI 方面有所作为。如何为一个 app 编写 UI 测试一直是 Cocoa 社区的难题之一。之前的话有像是 [KIF](https://github.com/kif-framework/KIF),[Automating](https://developer.apple.com/library/ios/documentation/DeveloperTools/Conceptual/InstrumentsUserGuide/UsingtheAutomationInstrument/UsingtheAutomationInstrument.html),甚至是 [FBSnapshotTestCase](https://github.com/facebook/ios-snapshot-test-case) 这种脑洞大开的方案。今年 Apple 给出了一个更加诱人的选项,那就是 Xcode 自带的 XCUITest 的一系列工具。 和大部分已有的 UI 测试工具类似,XCUI 使用 Accessibility 标记来确定 view,但因为是 Apple 自家的东西,它可以自动记录你的操作流程,所以你只需要书写最后的验证部分就可以了,比其他的 UI 测试工具方便很多。 > 相关专题笔记 > > [Xcode 7 UI 测试初窥](http://onevcat.com/2015/09/ui-testing/) ### Swift 2 Swift 经过了一年的改善和进步,现在已经可以很好地担任 app 开发的工作了。笔者自己也已经使用 Swift 作为日常工作的主要语言有半年多时间了,这半年里的总体感觉是越写越舒畅。Swift 2 里主要的改动是错误处理方面的变化,Apple 从 Cocoa 传统的基于 `NSError` 错误处理方式变为了 throw catch 的异常处理机制。这个转变确实可以让程序更加安全,新增的 ErrorType 也很好地将错误描述进行了统一。但是在实际接触了一两天之后,在语法上感觉要比原来的处理写的代码多一些。可能是长久以来使用 NSError 的习惯导致吧,笔者还并没有能很好地全面接受 Swift 2 中的异常机制。不过这次 Apple 做的相对激进,把 Cocoa API 中的 error 全数替换成了 throw。所以不管情不情愿,转型到异常处理是 Swift 开发者必须面对的了。 另外 Apple 新加了一些像是 `guard` 和 `defer` 这样的控制流关键字,这在其他一些语言里也是很实用的特性,这让 Swift 的书写更加简化,阅读起来更流畅。为了解决在运行时的不同 SDK 的可用性的问题,Apple 还在 Swift 2 里加入了 avaliable 块,以前我们需要自己去记忆 API 的可用性,并通过检查系统版本并进行对比来做这件事情。现在有了 avaliable 检测,编译器将会检查出那些可能出现版本不匹配的 API 调用,app 开发的安全性得到了进一步的保障。为了让整个 SDK 更适合 Swift 的语法习惯,Apple 终于在 Objective-C 中引入了泛型。这看似是 Objective-C 的加强,但是实际上却实实在在地是为 Swift 一统 Apple 开发开路。有了 Objective-C 泛型以后,用 Swift 访问 Cocoa API 基本不会再得到 `AnyObject` 类型了,这使得 Swift 的安全特性又上了一层台阶。 最后是 Swift 2 开源的消息。Swift 的编译器和标准库将在今年年底开源,对于一般的 app 开发者来说可能并不会带来什么巨变,但这确实意味着 Swift 将从一门 app 制作的专用语言转型为一门通用语言。最容易想到的就是基于 Swift 的后端开发,也许我们会在看到 Javascript 一统天下之前就能先感受一下 Swift 全栈的力量? > 关于 Swift 2 的新内容,我已经在我的[《Swifter - 100 个 Swift 必备 tips》](https://selfstore.io/products/171)一书的第二版中进行了详细的叙述。 ### App Thinning 笔者在日本工作,因为这边大家流量都是包月且溢出的,所以基本不会有人对 app 的尺寸介意,无非就是下载 5 秒还是 10 秒的区别。但是在和国内同行交流的时候,发现国内 app 开发对尺寸的要求近乎苛刻。因为 iOS app 为了后向兼容,现在都同时包含了 32 bit 和 64 bit 两个 slice。另外在图片资源方面,更是 1x 2x 3x 的图像一应俱全 (好吧现在 1x 应该不太需要了)。而用户使用 app 时,因为设备是特定的,其实只需要其中的一套资源。但是现在在购买和下载的时候却是把整个 app 包都下载了。 Apple 终于意识到了这件事情有多傻,iOS 9 中终于可以仅选择需要的内容 (Slicing) 下载了。这对用户来说是很大的利好,因为只需要升级到 iOS 9,就可以节省很多流量。对于开发者来说,并没有太多要做的事情,只需要使用 asset catalog 来管理素材标记 2x 3x 就可以了。 给 App 瘦身的另一个手段是提交 Bitcode 给 Apple,而不是最终的二进制。Bitcode 是 LLVM 的中间码,在编译器更新时,Apple 可以用你之前提交的 Bitcode 进行优化,这样你就不必在编译器更新后再次提交你的 app,也能享受到编译器改进所带来的好处。Bitcode 支持在新项目中是默认开启的,没有特别理由的话,你也不需要将它特意关掉。 最后就是按需加载的资源。这可能在游戏中应用场景会多一些。你可以用 tag 来组织像图像或者声音这样的资源,比如把它们标记为 level1,level2 这样。然后一开始只需要下载 level1 的内容,在玩的过程中再去下载 level2。或者也可以通过这个来推后下载那些需要内购才能获得的资源文件。在一些大型游戏里这是很常见的优化方法,现在在 iOS 9 里也可以方便地使用了。 ### 人工智能和搜索 API 如果说这届 WWDC Keynote 上还有什么留给我印象深刻的内容的话,我会给更加智能的手机助理投上一票。虽然看起来还很初级,比如就是插入耳机时播放你喜欢的音乐,推荐你可能会联系的人和打开的 app 等,但是这确实是很有意义的一步。现在的 Siri 只是一个问答系统,如果上下文中断,“她”甚至不记得前面两句话说了些什么。一个不会记住 Boss 习惯的秘书一定不是一个好护士,而 Apple 正在让 iPhone 向这方面努力。好消息是我们大概暂时还不用担心会碰到故意不通过图灵测试的机器,所以在人工智能上还有很大的空间可以发挥。 而[搜索 API](https://developer.apple.com/library/prerelease/ios/releasenotes/General/WhatsNewIniOS/Articles/iOS9.html#//apple_ref/doc/uid/TP40016198-DontLinkElementID_1) 实质上让 app 多了一个可能的入口。有些用户会非常频繁地使用搜索界面,这是一个绝好的展示你的 app 和提高打开率的机会。如果 app 类型合适的话,这是非常值得一做的追加特性。 ### 游戏相关 游戏类的 app 因为在不同的移动平台上的用户体验并没有鸿沟似的差异,所以是最容易跨平台的 - 毕竟现在无论哪个开发商都无法忽视安卓的份额。这也是 Apple 自家的 SpriteKit 和 SceneKit 这样的游戏框架一直不温不火的原因。比起被局限在 Apple 平台,更多的开发商选择像是 Unity 或者 Cocos2d-x 这样的跨平台方案。但是今年 Apple 还是持续加强了游戏方面的开发工具支持,包括负责状态机维护和寻路等的 GameplayKit 框架,负责录像和回放游戏过程的 ReplayKit 框架,以及物理建模的 Model I/O 框架。 这些其实都是在 Apple 的游戏开发体系中补充了一些游戏业界已经很成熟的算法和工具,为开发者节省了不少时间。对于个人开发者自制的游戏来说,Apple 的工具提供了相对低的门槛,易于上手。但是在现在大部分游戏开发都需要跨平台的年代,总感觉 Apple 体系是否能顺利走下去还需要进一步观察。 ### 其他 HomeKit,CloudKit,HealthKit 等等杂七杂八的框架。如果是 iOS Only 的 app 的话,使用 CloudKit 做 [BaaS](http://en.wikipedia.org/wiki/Mobile_Backend_as_a_service) 也许是不错的选择,但是也要面临今后跨平台数据难以共享的风险。其他几个框架专业性相对较强,大部分需要配合硬件支援,其实一直说智能硬件是下一个爆点,但是至少现在为止还没能爆出大的声响,更多的却已经进入到廉价竞争 (手环什么的你懂的),只能说期待这些设备的后续表现吧。 最后是一个对于刚入门或者打算投身到 Apple 开发中的朋友的福利。现在你可以不需要加入付费的开发者计划就能将 app 部署到自己的设备上了,而在以前这至少需要你加入 99 美金每年的开发者计划,这可以说进一步降低了进行 Apple 开发的门槛。 ### 总结 正如上面提到的,对开发者来说,今年的 WWDC 并没有像 13 年和 14 年那样颠覆性的变化,大多是对已有特性的加强补充和对开发工具链的增强。今年可以说是一个 Cocoa 开发者们沉淀之前知识,增进自己技能的好机会。现在 WWDC 15 还在如火如荼的进行之中。如果你打算尽早拥抱新 SDK 的变化的话,请不要犹豫,直接访问 Apple 的[开发者网站](https://developer.apple.com/),去寻找和观看自己感兴趣的话题吧。 URL: https://onevcat.com/2015/05/scheme/index.html.md Published At: 2015-05-08 12:59:45 +0900 # Scheme 初步 之前定了[每年学习一门语言](http://onevcat.com/2014/09/bye-kayac/)的目标,自然不能轻言放弃。今年目标:简单掌握 Scheme。 因为自己接触这门语言也不过寥寥数天,所以更多的会以引导的方式简单介绍语法,而不会 (也没有能力) 去探讨什么深入的东西。本文很多例程和图示参考了紫藤貴文的[《もうひとつの Scheme 入門》](http://www.shido.info/lisp/idx_scm.html)这篇深入浅出的教程,这篇教程现在也有[英译版](http://www.shido.info/lisp/idx_scm_e.html)和[中译版](http://deathking.github.io/yast-cn/index.html)。我自己是参照这篇教程入门的,一方面这篇教程可以说是能找到合适初学者学习的很好的材料,另一方面也希望能挑战一下自己的日文阅读能力 (结果对比日文和英文看下来发现果然还是日文版写的比较有趣,英文翻译版本就太严肃了,而中文版感觉是照着英文译版二次翻译的,有不少内容上的缺失和翻译生硬的问题,蛮可惜的)。因为中文的 Scheme 的资料其实很少,所以顺便把自己学习的过程和一些体会整理记录下来,算是作为备忘。本文只涉及 Scheme 里最基础的一些语法部分,要是恰好能够帮助到后来的学习者入门 Scheme,那更是再好不过。 ## 为什么选择学 Scheme 三方面的原因。 首先是自己基本上对函数式语言的接触为零。平时工作和自己的开发中基本不使用函数式编程,大脑已经被指令式程序占满,有时候总显得不很灵光。而像 Swift 这样的语言其实引入了一些函数式编程的可能性。多接触一些函数式的语言,可能会对实际工作中解决某些问题有所帮助。而 Scheme 比起另一门常用 Lisp 方言 Common Lisp 来说,要简单不少。比较适合像我这样非科班出身,CS 功力不足的开发者。 其次,[Structure and Interpretation of Computer Programs (SICP)](http://mitpress.mit.edu/sicp/) 里的例程都是使用 Scheme 写的。虽然不太有可能有时间补习这本经典,但是如果不会一点 Scheme 的话,那就完全没有机会去读这本书了。 最后,Scheme 很酷也很好玩,虽然在实际中可能并没有鸟用,但是和别人说起来自己会一点 Scheme 的话,那种感觉还是很棒的。 其实还有一点对 hacker 们很重要,如果你喜欢使用像 [Emacs](https://www.gnu.org/software/emacs/) 这样的基于 Lisp 的编辑器的话,使用 Scheme 就可以与它进行交互或者是扩展它的功能了。 那让我们尽快开始吧。 ## 成为 Scheme Hacker 的第一步 成为 Scheme Hacker 的第一步,当然是安装和配置运行环境,同时这也是最难跨过去的一步。想想有多少次你决心学习一门新语言的时候,在配置好开发环境后就再也没有碰过吧。所以我们需要尽快跨过这个步骤。 最简单的开发环境点击这个[链接](http://repl.it/languages/Scheme),然后你就可以开始用 Scheme 编程了。如果你更喜欢在本地环境和终端里操作的话,可以下载 [MIT/GNU Scheme](https://www.gnu.org/software/mit-scheme/)。在 OS X 上解包后是一个 .app 文件,运行 .app 包里的 `/Contents/Resources/mit-scheme` 就可以打开一个解释器了。 ![](/assets/images/2015/scheme-terminal.png) ## Hello 1 + 1 虽然大部分语言都是从 Hello World 开始的,但是对于 Scheme 来说,计算才是它的强项。所以我们从 1 + 1 开始。计算 1 + 1 程序在 Scheme 中是这样的: ```scheme 1 ]=> (+ 1 1) ;Value: 2 1 ]=> ``` `1 ]=> ` 是输入提示符,我们输入的内容是 `(+ 1 1)`,得到的结果是 2。虽然语句很简单,但是这里包含了 Scheme 的最基本的语素,有三个地方值得特别注意。 1. 成对的括号。一对括号表示的是一步计算,这里 `(+ 1 1)` 表示的就是 1 + 1 这一步运算。 2. 紧接括号的是函数名字,再然后是函数的参数。在这里,函数名字就是 "+",两个 1 是它的参数。Scheme 中大部分的运算符其实都是函数。 3. 使用空格,tab 或是换行符来分割函数名以及参数。 和别的很多语言一样,Scheme 在函数调用时也有计算优先级,会先对输入的参数进行计算,然后再进行函数调用。还是以上面的 1 + 1 为例。首先解释器看到加号,但是此时运算并没有开始。解释器会先计算第一个参数 1 的值 (其实就是 1),然后计算第二个参数 1 的值 (其实还是 1)。然后再用两个计算得到的值来进行加法运算。 另外,"+" 这个函数不仅可以接受两个参数,其实它是可以接受任意多个参数的。比如 `(+)` 的结果是 0,`(+ 1 2 3 4)` 的结果是 10。 学会加法以后,乘法自然也不在话下了。 ```scheme 1 ]=> (* 2 3) ;Value: 6 1 ]=> ``` 减法和除法稍微不同一些,因为它们并不满足交换律,所以可能会有疑问。但是只要记住参数是平等的,它们会顺次计算就可以了。举个例子: ```scheme 1 ]=> (- 10 5 3) ;Value: 2 1 ]=> (/ 20 2 2) ;Value: 5 ``` 对于除法,有两个需要注意的地方。首先和我们熟悉的很多语言不同,Scheme 是默认有分数的概念的。比如在 C 系语言中,如果只是在整数范围的话,我们计算 `10 / 3` 的结果会是 `3`;如果是浮点型的话结果为 `3.33333`。而在 Scheme 中,结果是这样的: ```scheme 1 ]=> (/ 10 3) ;Value: 10/3 ``` 这是一个分数,就是三分之十,绝对精确! 另一个需要注意的是,如果 `/` 只有一个输入的话,它的意思是取倒数。 ```scheme 1 ]=> (/ 2) ;Value: 1/2 ``` 如果你需要一个浮点数而不是分数的话,可以使用 `exact->inexact` 方法,将精确值转为非精确值: ```scheme 1 ]=> (exact->inexact (/ 10 3)) ;Value: 3.3333333333333335 ``` Scheme 也内建定义了一些其他的数学运算符号,如果你感兴趣,可以查看 R6RS 的[相关章节](http://www.r6rs.org/final/html/r6rs/r6rs-Z-H-14.html#node_sec_11.7.4)。 > R6RS (Revisedn Report on the Algorithmic Language Scheme, Version 6) 是当前的 Scheme 标准。 ## 定义变量和方法,Hello World 通过简单的 1 + 1 运算我们可以大概知道 Scheme 中的奇怪的括号开头的意思了。有了这个作为基础,我们可以来看看如何定义变量和方法了。 Scheme 中通过 `define` 来定义变量和方法: ```scheme ; s 是一个变量,值为 "Hello World" (define s "Hello World") ; f 是一个函数,它不接受参数,调用时返回 "Hello World" (define f (lambda () "Hello World")) ``` 上面的 `lambda` 可以生成一个闭包,它接受两个参数,第一个是一个空的列表 `()`,表示这个闭包不接受参数;第二个是 "Hello World" 这个字符串。在解释器中定义好两者之后,就可以进行调用了: ```scheme 1 ]=> (define s "Hello World") ;Value: s 1 ]=> (define f (lambda () "Hello World")) ;Value: f 1 ]=> s ;Value 24: "Hello World" 1 ]=> f ;Value 25: #[compound-procedure 25 f] 1 ]=> (f) ;Value 26: "Hello World" ``` 既然我们已经知道了 `lambda` 的意义和用法,那么定义一个接受参数的函数也就不是什么难事了。比如上面的 `f`,我们想要定义一个接受名字的函数的话: ```scheme 1 ]=> (define hello (lambda (name) (string-append "Hello " name "!") ) ) ;Value: hello 1 ]=> (hello "onevcat") ;Value 27: "Hello onevcat!" ``` 很简单,对吧?其实甚至可以更简单,define 的第一个参数可以是一个列表,其中第一个元素是函数名名字,之后的是参数列表。 > 用专业一点的术语来说的话,就是 define 的第一个参数是一个 cons cell 的话,它的 car 是函数名,cdr 是参数。关于这些概念我们稍后再仔细说说。 于是上面的方法可以简单地写作: ```scheme 1 ]=> (define (hello name) (string-append "Hello " name "!")) ;Value: hello 1 ]=> (hello "onevcat") ;Value 28: "Hello onevcat!" ``` 光说不练假把式,所以留个小练习给大家吧,用 `define` 来定义一个函数,让其为输入的数字 +1。如果你无压力地搞定了的话,我们就可以继续看看 Scheme 里的条件语句怎么写了。 ## 条件分支和布尔逻辑 不论是什么编程语言,条件分支或者类似的概念应该都是不可缺少的部分。在 Scheme 中,使用 `if` 可以进行条件分支的处理。和其他很多语言不一样的地方在于,函数式语言中函数才是一等公民,`if` 的行为也和一个其他的普通函数很相似,是作为一个函数来使用的。它的语法是: ``` (if condition ture_action false_action) ``` 与普通函数先进行输入的取值不同,`if` 将会先对 `condition` 运算式进行取值判断。如果结果是 `true` (在 Scheme 中用 `#t` 代表 true,`#f` 代表 false),则再对 `ture_action` 进行取值,否则就执行 `false_action`。比如我们可以实现一个 `abs` 函数来返回输入的绝对值: ```scheme 1 ]=> (define (abs input) (if (< input 0) (- input) input)) ;Value: abs 1 ]=> (abs 100) ;Value: 100 1 ]=> (abs -100) ;Value: 100 ``` 也许你已经猜到了,Scheme 的布尔逻辑也是遵循函数式的,最常用的就是 `and` 和 `or` 两种了。和常见 C 系语言类似的是,`and` 和 `or` 都会将参数从左到右取值,一旦遇到满足停止条件的值就会停止。但是和传统 C 系语言不同,布尔逻辑的函数返回的不一定就是 `#t` 或者 `#f`,而有可能是输入值,这和很多脚本语言的行为是比较一致的:`and` 会返回最后一个非 `#f` 的值,而 `or` 则返回第一个非 `#f` 的值: ```scheme 1 ]=> (and #f 0) ;Value: #f 1 ]=> (and 1 2 "Hello") ;Value 13: "Hello" 1 ]=> (or #f 0) ;Value: 0 1 ]=> (or 1 2 "Hello") ;Value: 1 1 ]=> (or #f #f #f) ;Value: #f ``` 在很多时候,Scheme 中的 `and` 和 `or` 并不全是用来做条件的组合,而是用来简化一些代码的写法,以及为了顺次执行一些代码的。比如说下面的函数在三个输入都为正数的情况下返回它们的乘积,可以想象和对比一下在指令式编程中同样功能的实现。 ```scheme (define (pro3and a b c) (and (positive? a) (positive? b) (positive? c) (* a b c) ) ) ``` 除了 `if` 之外,在 C 系语言里另一种常见的条件分支语句是 `switch`。Scheme 里对应的函数是 `cond`。`cond` 接受多个二元列表作为输入,从上至下依次判断列表的第一项是否满足,如果满足则返回第二项的求值结果并结束,否则一直继续到最后的 `else`: ```scheme (cond (predicate_1 clauses_1) (predicate_2 clauses_2) ...... (predicate_n clauses_n) (else clauses_else)) ``` 在新版的 Scheme 中,标准里加入了更多的流程控制的函数,它们包括 `begin`,`when` 和 `unless` 等。 `begin` 将顺次执行一系列语句: ```scheme (define (foo) (begin (display "hello") (newline) (display "world") ) ) ``` `when` 当条件满足时执行一系列代码,而 `unless` 在条件不满足时执行一系列代码。这些改动可以看出一些现代脚本语言的特色,但是新的标准据说也在 Scheme 社区造成了不小争论。虽然结合使用 `if`,`and` 和 `or` 肯定是可以写出等效的代码的,但是这些额外的分支控制语句确实增加了语言的便利性。 ## 循环 一门完备的编程语言必须的三个要素就是赋值,分支和循环。前两个我们已经看到了,现在来看看循环吧: ### do ```scheme 1 ]=> (do ((i 0 (+ i 1))) ; 初始值和 step 条件 ((> i 4)) ; 停止条件,取值为 #f 时停止 (display i) ; 循环主体 (命令) ) 01234 ;Value: #t ``` 唯一要解释的是这里的条件是停止条件,而不是我们习惯的进入循环主体的条件。 ### 递归 可以看出其实 `do` 写起来还是比较繁琐的。在 Scheme 中,一种更贴合语言特点的写法是使用递归的方式来完成循环: ```scheme 1 ]=> (define (count n) (and (display (- 4 n)) (if (= n 0) #t (count (- n 1))) ) ) ;Value: count 1 ]=> (count 4) 01234 ;Value: #t ``` ### 列表和递归 也许你会说,用递归的方式看起来一点也不简单,甚至代码要比上面的 `do` 的版本更难理解。现在看来确实是这样的,那是因为我们还没有接触 Scheme 里一些很独特的概念,cons cell 和 list。我们在上面介绍 `define` 的时候曾经提到过,cons cell 的 `car` 和 `cdr`。结合这个数据结构,Scheme 里的递归就会变得非常好用。 那么什么是 cons cell 呢?其实没有什么特别的,cons cell 就是一种数据结构,它对应了内存的两个地址,每个地址指向一个值。 ![](/assets/images/2015/cons2.png) 要初始化一个上面图示的 cons cell,可以使用 `cons` 函数: ```scheme 1 ]=> (cons 1 2) ;Value 13: (1 . 2) ``` 我们可以使用 `car` 和 `cdr` 来取得一个 cons cell 的两部分内容 (`car` 是 "Contents of Address part of Register" 的缩写,`cdr` 是 "Contents of Decrement part of Register"): ```scheme 1 ]=> (car (cons 1 2)) ;Value: 1 1 ]=> (cdr (cons 1 2)) ;Value: 2 ``` cons cell 每个节点的内容可以是任意的数据类型。一种最常见的结构是 `car` 中是数据,而 `cdr` 指向另一个 cons cell: ![](/assets/images/2015/conss2.png) 上面这样的数据结构对应的生成代码为: ```scheme 1 ]=> (cons 3 (cons 1 2)) ;Value 14: (3 1 . 2) ``` 有一种特殊的 cons cell 链,其最后一个 cons cell 的 `cdr` 为空列表 `'()`,这类数据结构就是 Scheme 中的列表。 ![](/assets/images/2015/list2.png) 对于列表,我们有一种更简单的创建方式,就是类似 `'(1 2 3)` 这样。对于列表来说,它的 `cdr` 值是一个子列表: ```scheme 1 ]=> '(1 2 3) ;Value 15: (1 2 3) 1 ]=> (car '(1 2 3)) ;Value: 1 1 ]=> (cdr '(1 2 3)) ;Value 16: (2 3) ``` 而循环其实质就是对一列数据进行处理的过程,结合 Scheme 列表的特性,我们意识到如果把列表运用在递归中的话,`car` 就是遍历的当前项,而 `cdr` 就是下一次递归的输入。Scheme 和递归调用可以说能配合得天衣无缝。 比如我们定义一个将列表中的所有数都加上 1 的函数的话,可以这么处理: ```scheme (define (ins_ls ls) (if (null? ls) '() (cons (+ (car ls) 1) (ins_ls (cdr ls))) ) ) (ins_ls '(1 2 3 4 5)) ;=> (2 3 4 5 6) ``` ### 尾递归 递归存在性能上的问题,因为递归的调用需要在栈上保持,然后再层层返回,这会造成很多额外的开销。对于小型的递归来说还勉强可以接受,但是对于递归调用太深的情况来说,这显然是不可扩展的做法。于是在 Scheme 中对于大型的递归我们一般会倾向于将它写为尾递归的方式。比如上面的加 1 函数,用尾递归重写的话: ```scheme (define (ins_ls ls) (ins_ls_interal ls '())) (define (ins_ls_interal ls ls0) (if (null? ls) ls0 (ins_ls_interal (cdr ls) (cons ( + (car ls) 1) ls0)))) (define (rev_ls ls) (rev_ls_internal ls '())) (define (rev_ls_internal ls ls0) (if (null? ls) ls0 (rev_ls_internal (cdr ls) (cons (car ls) ls0)))) (rev_ls (ins_ls '(1 2 3 4 5))) ;=> (2 3 4 5 6) ``` ## 函数式 上面介绍了 Scheme 的最基本的赋值,分支和循环。可以说用这些东西就能够写出一些基本的程序了。一开始会比较难理解 (特别是递归),但是相信随着深入下去和习惯以后就会好很多。到现在为止,除了在定义函数时,其实我们还没有直接触碰到 Scheme 的函数式特性。在 Scheme 里函数是一等公民,我们可以将一个函数作为参数传给另外的函数并进行调用,这就是高阶函数。 一个最简单的例子是排序的时候我们可以将一个返回布尔值的函数作为排序规则: ```scheme 1 ]=> (sort '(7883 9099 6729 2828 7754 4179 5340 2644 2958 2239) <) ;Value 13: (2239 2644 2828 2958 4179 5340 6729 7754 7883 9099) ``` 更甚于我们可以使用一个匿名函数来控制这个排序,比如按照模 100 之后的大小 (也就是数字的后两位) 进行排序: ```scheme 1 ]=> (sort '(7883 9099 6729 2828 7754 4179 5340 2644 2958 2239) (lambda (x y) (< (modulo x 100) (modulo y 100)))) ;Value 14: (2828 6729 2239 5340 2644 7754 2958 4179 7883 9099) ``` 类似这样的特性在一些 modern 的语言里并不算罕见,但是要知道 Scheme 可是有些年头的东西了。类似的还有 `map`,`filter` 等。比如上面的 list 加 1 的例子,用 `map` 函数就可以非常简单地实现: ```scheme (map (lambda (x) (+ x 1)) '(1 2 3 4 5)) ;=> (2 3 4 5 6) ``` ## 接下来... 篇幅有限,再往长写的话估计没什么人会想看完了。到这里为止关于 Scheme 的一些基础内容也算差不多了,大概阅读最简单的 Scheme 程序应该也没有太大问题了。在进一步的学习中,如果出现不认识的函数或者语法的话,可以求助 [SRFI](http://srfi.schemers.org/final-srfis.html) 下对应的文档或是在 [MIT/GNU Scheme 文档](http://www.gnu.org/software/mit-scheme/documentation/mit-scheme-ref/index.html#Top)中寻找。 本文一开始提到的[教程](http://www.shido.info/lisp/idx_scm_e.html)很适合入门,之后的话可以开始参看 [SICP](http://mitpress.mit.edu/sicp/),可以对程序设计和 Scheme 的思想有更深的了解 (虽然阅读 SICP 的目的不应该是学 Scheme,Scheme 只是帮助你进行阅读和练习的工具)。因为我自己也就是个愣头青的初学者,所以无法再给出其他建议了。如果您有什么好的资源或者建议,非常欢迎在评论中提出。 另外,相比起 Scheme,如果你想要在实际的工程中使用 Lisp 家族的语言的话,[Racket](http://racket-lang.org) 也许会是更好的选择。相比于面向数学和科学计算来说,Racket 支持对象类型等概念,更注重在项目实践方面的运用。 就这样吧,我要继续去和 Scheme 过周末了。 URL: https://onevcat.com/2015/03/cross-platform/index.html.md Published At: 2015-03-27 18:56:08 +0900 # 跨平台开发时代的 (再次) 到来? ![cross-platform](/assets/images/2015/cross-platform.png) 这篇文章主要想谈谈最近又刮起的移动开发跨平台之风,并着重介绍和对比一下像是 [Xamarin](https://xamarin.com),[NativeScript](https://www.nativescript.org) 和 [React Native](http://facebook.github.io/react-native/) 之类的东西。不会有特别深入的技术讨论,大家可以当作一篇科普类的文章来看。 ### 故事的开始 “一次编码,处处运行” 永远是程序员们的理想乡。二十年前 Java 正是举着这面大旗登场,击败了众多竞争对手。但是时至今日,事实已经证明了 Java 笨重的体型和缓慢的发展显然已经很难再抓住这个时代快速跃动的脚步。在新时代的移动大潮下,一个应用想要取胜,完美的使用体验可以说必不可少。使用 native 的方式固然对提升用户体验很有帮助,但是移动的现状是必须针对不同平台 (至少是 iOS 和 Android) 进行开发。这对于开发来说妥妥的是隐患和额外的负担:我们不仅需要在不同的项目间努力用不同的语言实现同样代码的同步,还要承担由此带来的后续维护任务。如果仅只限制在 iOS 和 Android 的话还行,但是如果还要继续向 Windows Phone 等平台拓展的话,所需要付出的代价和[工数](http://en.wikipedia.org/wiki/Man-hour)将几何级增长,这显然是难以接受的。于是,一个其实一直断断续续被提及但是从没有占据过统治地位的概念又一次走进了移动开发者们的视野,那就是跨平台开发。 ### 本地 HTML 和 JavaScript 因为每个平台都有浏览器,也都有 WebView 控件,所以我们可以使用 HTML,CSS 和 JavaScript 来将 web 的内容和体验搬到本地。通过这样做我们可以将逻辑和 UI 渲染部分都统一,以减少开发和维护成本。这种方式开发的 app 一般被称为 [Hybrid app](http://blogs.telerik.com/appbuilder/posts/12-06-14/what-is-a-hybrid-mobile-app-),像 [PhoneGap](http://phonegap.com) 或者 [Cordova](http://cordova.apache.org) 这样的解决方案就是典型的应用。除了使用前端开发的一套技巧来构建页面和交互以外,一般这类框架还会提供一些访问设备的接口,比如相机和 GPS 等。 ![hybrid-app](/assets/images/2015/hybrid-app.jpg) 虽然使用全网页的开发策略和环境可以带来代码维护的便利,但是这种方式是有致命弱点的,那就是缓慢的渲染速度和难以驾驭的动画效果。这两者对于用户体验是致命而且难以接受的。随着三年前 Facebook 使用 native 代码重新构建 Facebook 的手机 app 这一[标志性事件](https://www.facebook.com/notes/facebook-engineering/under-the-hood-rebuilding-facebook-for-ios/10151036091753920)的发生,曾经一度占领半壁江山的网页套壳的 app 的发展也日渐式微。特别在现在对于用户体验的追求几近苛刻的现在,呆板的动画效果和生硬的交互体验已经完全无法满足人民群众对高质量 app 的心理预期了。 ### 跨平台之心不死的我们该怎么办 想要解决用户体验的问题,基本还是需要回到 native 来进行开发,但是这种行为必然会与平台绑定。世界上总是有聪明人的,并且他们总会利用看起来更加聪明但是实际上却很笨的电脑来做那些很笨的事情 (恰得其所)。其中一件事情就是自动将某个平台的代码转换到另外的平台上去。有一家英国的小公司正在做这样的事情,[MyAppConverter](https://www.myappconverter.com) 想做的事情就是把 iOS 的代码自动转成 Java 的。但是很可惜,如果你尝试过的话,就知道他们的产品暂时还处于无法实用的状态。 在这条路的另一个分叉上有一家公司走得更远,它叫做 [Apportable](http://www.apportable.com)。他们在游戏的转换上已经取得了[很大的成果](https://dashboard.apportable.com/customers),像是 Kingdom Rush 或者 Mega Run 这样的大作都使用了这家的服务将游戏从 iOS 转换到 Android,并且非常成功。可以毫不夸张地说,Apportable 是除开直接使用像 Unity 或者 Cocos2d-x 以外的另一套诱人的游戏跨平台解决方案。基本上你可以使用 Objective-C 或者 Swift 来在熟悉的平台上开发,而不必去触碰像是 C++ 这样的怪兽 (虽然其实在游戏开发中也不会碰到很难的 C++)。 但是好消息终结于游戏开发了,因为游戏在不同平台上体验不会差别很大,也很少用到不同平台的不同特性,所以处理起来相对容易。当我们想开发一个非游戏的 app 时,事情就要复杂得多。虽然 Apportable [有一个计划](http://www.tengu.com)让 app 转换也能可行,但是估计还需要一段时间我们才能看到它的推出。 ### 新的希望 #### Xamarin 其实跨平台开发最大的问题还是针对不同的平台 UI 和体验的不同。如果忽视掉这个最困难的问题,只是共用逻辑部分的代码的话,问题一下子就简单不少。十多年前,当 .NET 刚刚被公布,大家对新时代的开发充满期待的同时,一群喜欢捣鼓的 Hacker 就在盘算要如何将 .NET 和 C# 搬到 Linux 上去。而这就是 [Mono](http://www.mono-project.com) 的起源。Mono 通过在其他平台上实现和 Windows 平台下功能相同的 Common Language Runtime 来运行 .NET 中间代码。现在 Mono 社区已经足够强大,并且不仅仅支持 Linux 平台,对移动设备也同样支持。Mono 背后的支撑企业 [Xamarin](http://xamarin.com) 也顺理成章并适时地推出了一整套的移动跨平台解决方案。 Xamarin 的思路相对简单,那就是使用 C# 来完成所有平台共用的,和平台无关的 app 逻辑部分;然后由于各个平台的 UI 和交互不同,使用预先由 Xamarin 封装好的 C# API 来访问和操控 native 的控件,进行分别针对不同平台的 UI 开发。 ![xamarin](/assets/images/2015/xamarin.png) 虽然只有逻辑部分实现了真正的跨平台,而表现层已然需要分别开发,但这确实也是一种在完整照顾用户体验的基础上的好方式 -- 至少开发语言得到了统一。因为 Xamarin 解决方案中的纯 C# 环境和有深厚的 .NET 技术背景做支撑,这个项目现在也受到了微软的支持和重视。 不过存在的致命问题是针对某个特定平台你所能使用的 API 是由 Xamarin 所决定的。也就是说一旦 iOS 或者 Android 平台推出了新的 SDK,加入了新的功能,你必须要等 Xamarin 的工程师先进行封装,然后才能在自己的项目中使用。这种延迟往往可能是致命的,因为现在 AppStore 对于新功能的首页推荐往往只会有新系统上线后的一两周,错过这段时间的话,可能你的 app 就再无翻身之日。而且如果你想使用一些第三方框架的话,将不得不自己动手将它们打包成二进制,并且写 binding 为它们提供 C# 的封装,除非已经有别人帮你[做过](https://github.com/mono/monotouch-bindings)这件事情了。 另外,因为 UI 部分还是各自为战,所以不同的代码库依然存在于项目之中,这对工作量的减少的帮助有限,并且之后的维护中还是存在无法同步和版本差异的隐患。但是总体来说,Xamarin 是一个很不错的解决跨平台开发的思路了。(如果抛开价格因素的话) #### NativeScript [NativeScript](https://www.nativescript.org) 是一家名叫 Telerik 的名不见经传保加利亚公司刚刚宣布的项目。虽然 Telerik 并不是很出名,但是却已经在 hybrid app 和跨平台开发这条路上走了很久。 JavaScript 因为广泛的群众基础和易学易用的语言特点,已经大有一统天下的趋势。而现在主流移动平台也都有强劲的处理 JavaScript 的能力 (iOS 7 以后的 JavaScriptCore 以及 Android 自带的 V8 JavaScript Engine),因为使用 JavaScript 来跨平台水到渠成地成为了一个可选项。 > 在此要吐槽一下,JavaScript 真的是一家公司,一个项目拯救回来的语言。V8 之前谁能想到 JavaScript 能有今日... NativeScript 的思路就是使用移动平台的 JavaScript 引擎来进行跨平台开发。逻辑部分自然无需多说,关键在于如何使用平台特性,JavaScript 要怎样才能调用 native 的东西呢。NativeScript 给出的答案是通过反射得到所有平台 API,预编译它们,然后将这些 API 注入到 JavaScript 运行环境,接下来在 Javascript 调用后拦截这个调用,并运行 native 代码。 > 在此不打算展开说 NativeScript 详细的原理,如果你对它感兴趣,不妨去看看 Telerik 的员工的写的这篇[博客](http://developer.telerik.com/featured/nativescript-works/)以及发布时的 [Keynote](https://www.youtube.com/watch?v=8hr4E9eodS4)。 ![nativescript-architecture](/assets/images/2015/nativescript-architecture.png) 这么做最大的好处是你可以任意使用最新的平台 API 以及各种第三方库。通过对元数据的反射和注入,NativeScript 的 JavaScript 运行环境总能找到它们,触发相应的调用以及最终访问到 iOS 或者 Android 的平台代码。最新版本的平台 SDK 或者第三方库的内容总是可以被获取和使用,而不需要有什么限制。 举个简单的例子,比如创建一个文件,为 iOS 开发的话,可以直接在 JavaScript 里写这样的代码: ``` var fileManager = NSFileManager.defaultManager(); fileManager.createFileAtPathContentsAttributes( path ); ``` 而对应的 Android 版本也许是: ``` new java.io.File( path ); ``` 你不需要担心 `NSFileManager` 或者 `java.io` 这类东西的存在,而是可以任意地使用它们! 如果仅只是这样的话,使用上还是非常不便。NativeScript 借助类似 node 的一套包管理系统,用 modules 对这些不同平台的代码进行了统一的封装。比如上面的代码,可以统一使用下面的形式替换: ``` var fs = require( "file-system" ); var file = new fs.File( path ); ``` 写过 node 的同学肯定对这样的形式很熟悉了,这里的 `file-system` 就是 NativeScript 进行的统一平台的封装。现在的完整的封装列表可以参见这个 [repo](https://github.com/NativeScript/cross-platform-modules)。因为写法很简单,所以开发者如果有需要的话,也可以创建自己的封装,甚至使用 npm 来发布和共享 (当然也有获取别人写的封装)。因为依赖于已有的成熟包管理系统,所以可以认为扩展性是有保证的。 对于 UI 的处理,NativeScript 选择了使用类似 Android 的 XML 的方式进行布局,然后用 CSS 来控制控件的样式。这是一种很有趣的想法,虽然 UI 的布局灵活性上无法与针对不同平台的 native 布局相比,但是其实和传统的 Android 布局已经很接近。举个布局文件的例子就可见一斑: ``` ``` 熟悉 Android 或者 Window Phone 开发的读者可能会感到找到了组织。你可能已经注意到,相比于 Android 的布局方式,NativeScript 天生支持 MVVM 和 data binding,这在开发中会十分方便 (但是性能上暂时就未知了)。而像是 `Button` 或者 `ListView` 这样的控件都是由 modules 映射到对应平台的系统标准控件。这些控件的话都是使用 css 来指定样式的,这与传统的网页开发没太大区别。 ![nativescript-ui](/assets/images/2015/nativescript-ui.png) NativeScript 代表的思路是使用大量 web 开发的技巧来进行 app 开发。这是一个很值得期待的方向,相信也会受到很多前端开发者的欢迎 -- 因为工具链和语言都非常熟悉。但是这个方向依然面临的最大挑战还是 UI,现在看来开发者是被限制在预先定义好的 UI 控件中的,而不能像传统 Hybrid app 那样使用 HTML5 的元素。这使得如何能开发出高度自定义的 UI 和交互成为问题。另一个可能存在的问题是最终 app 的尺寸。因为我们需要将整个元数据注入到运行环境中,也存在很多在不同语言中的编译,所以不可避免地会造成较大的 app 尺寸。最后一个挑战是对于像 app 这样的工程,没有类型检查和编译器的帮助,开发起来难度会比较大。另外在调试的时候也可能会有传统 app 开发中不曾遇到的问题。 总体来看,NativeScript 是很有希望的一个方案。如果它能实现自己的愿景,那必将是跨平台这块大蛋糕的有力竞争者。当然,现在 NativeScript 还太年轻,也还有[很多问题](https://www.nativescript.org/roadmap)。不妨多给这个项目一点时间,看看正式版本上线后的表现。 #### React Native Facebook 几个月前[公布](https://code.facebook.com/videos/786462671439502/react-js-conf-2015-keynote-introducing-react-native-/)了 React Native,而今天这个项目终于在万众期待下[发布](http://facebook.github.io/react-native/)了。 React Native 在一定程度上和 NativeScript 的概念类似:都是使用 JavaScript 和 native UI 来实现 app (所以说 JavaScript 真是有一桶浆糊的趋势..如果你现在还不会写几句 JavaScript 的话,建议尽早学一学)。但是它们的出发点略有不同,React Native 在首页上就写明了,使用这个库可以: > learn once, write anywhere 而并不是 "run anywhere"。所以说 React Native 想要达成的目标其实并不是一个跨平台 app 开发方案,而是让你能够使用相似的方法和同样的语言来在不同平台进行开发的工具。另外,React Native 的主要工作是构建响应式的 View,其长处在于根据应用所处的状态来决定 View 的表现状态。而对于其他一些系统平台的 API 来说,就显得比较无力。而正是由于这些要素,使得 React Native 确实不是一个跨平台的好选择。 那为什么我们还要在这篇以 “跨平台” 为主题的文章里谈及 React Native 呢? 因为虽然 Facebook 不是以跨平台为出发点,但是却不可能阻止工程师想要这么来使用它。从原理上来说,React Native 继承了 React.js 的虚拟 DOM 的思想,只不过这次变成了虚拟 View。事实上这个框架提供了一组 native 实现的 view (在 iOS 平台上是 `RCT` 开头的一系列类)。我们在写 JavaScript (更准确地说,对于 React Native,我们写的是带有 XML 的 JavaScript:[JSX](http://facebook.github.io/react/docs/jsx-in-depth.html)) 时,通过将虚拟 View 添加并绑定到注册的模块中,在 native 侧用 JavaScript 运行环境 (对于 iOS 来说也就是 JavaScriptCore) 执行编译并注入好的 JavaScript 代码,获取其对 UI 的调用,将其截取并桥接到 native 代码中进行对应部件的渲染。而在布局方面,依然是通过 CSS 来实现的。 这里整个过程和思路与 NativeScript 有相似之处,但是在与 native 桥接的时候采取的策略完全相反。React Native 是将 native 侧作为渲染的后端,去提供统一的 JavaScript 侧所需要的 View 的实体。NativeScript 基本算反其道行之,是在 JavaScript 里写分开的中间层来分别对应不同平台。 对于非 View 的处理,对于 iOS,React Native 提供了 `RCTBridgeModule` 协议,我们可以通过在 native 侧实现这个协议来提供 JavaScript 中的访问可能。另外,回调和事件发送等也可以通过相应的 native 代码来完成。 总结来说,如果想要把 React Native 作为一个跨平台方案来看的话 (实际上也并不应当如此),那么单靠 JavaScript 一侧是难以完成的,因为一款有意义的 app 不太可能完全不借助平台 API 的力量。但是毕竟这个项目背后是 Facebook,如果 Facebook 想要通过自己的影响力自立一派的话,必定会通过不断改进和工具链的完善,将 app 开发的风向引导至自己旗下。对于原来就使用 React.js 的开发者来说,这个框架降低了他们进入 app 开发的门槛。但是对于已经在做 native app 开发的人来说,是否值得和需要投入精力进行学习,还需要观察 Facebook 接下来动作。 不过现在 React Native 的正式发布才过去了不到 24 小时,我想我们有的是时间来思考和检阅这样一个框架。 ### 总结 当然还有一些其他方案,比如 [Titanium](http://www.appcelerator.com/titanium/) 等。现在使用跨平台方案开发 app 的案例并不算很多,但是无论在项目管理还是维护上,跨平台始终是一种诱惑。它们都解决了一些 Hybrid app 的遗留问题,但是它们又都有一些非 native app 的普遍面临的阴影。谁能找到一个好的方式来解决像是自定义 UI,API 扩展性以及 app 尺寸这样的问题,谁就将能在这个市场中取得领先或者胜利,从而引导之后的开发潮流。 但是谁又知道最后谁能取胜呢?也有可能大家在跨平台的道路上再一次全体失败。伺机而动也许是现在开发者们很好的选择,不过我的建议是提前[学点儿 JavaScript](http://www.codecademy.com/en/tracks/javascript) 总是不会出错的。 URL: https://onevcat.com/2015/01/swift-pointer/index.html.md Published At: 2015-01-19 12:38:21 +0900 # Swift 中的指针使用 Apple 期望在 Swift 中指针能够尽量减少登场几率,因此在 Swift 中指针被映射为了一个泛型类型,并且还比较抽象。这在一定程度上造成了在 Swift 中指针使用的困难,特别是对那些并不熟悉指针,也没有多少指针操作经验的开发者 (包括我自己也是) 来说,在 Swift 中使用指针确实是一个挑战。在这篇文章里,我希望能从最基本的使用开始,总结一下在 Swift 中使用指针的一些常见方式和场景。这篇文章假定你至少知道指针是什么,如果对指针本身的概念不太清楚的话,可以先看看这篇[五分钟 C 指针教程](http://denniskubes.com/2012/08/16/the-5-minute-guide-to-c-pointers/) (或者它的[中文版本](http://blog.jobbole.com/25409/)),应该会很有帮助。 ## 初步 在 Swift 中,指针都使用一个特殊的类型来表示,那就是 `UnsafePointer`。遵循了 Cocoa 的一贯不可变原则,`UnsafePointer` 也是不可变的。当然对应地,它还有一个可变变体,`UnsafeMutablePointer`。绝大部分时间里,C 中的指针都会被以这两种类型引入到 Swift 中:C 中 const 修饰的指针对应 `UnsafePointer` (最常见的应该就是 C 字符串的 `const char *` 了),而其他可变的指针则对应 `UnsafeMutablePointer`。除此之外,Swift 中存在表示一组连续数据指针的 `UnsafeBufferPointer`,表示非完整结构的不透明指针 `COpaquePointer` 等等。另外你可能已经注意到了,能够确定指向内容的指针类型都是泛型的 struct,我们可以通过这个泛型来对指针指向的类型进行约束以提供一定安全性。 对于一个 `UnsafePointer` 类型,我们可以通过 `pointee` 属性对其进行取值,如果这个指针是可变的 `UnsafeMutablePointer` 类型,我们还可以通过 `pointee` 对它进行赋值。比如我们想要写一个利用指针直接操作内存的计数器的话,可以这么做: func incrementor(ptr: UnsafeMutablePointer) { ptr.pointee += 1 } var a = 10 incrementor(&a) a // 11 这里和 C 的指针使用类似,我们通过在变量名前面加上 `&` 符号就可以将指向这个变量的指针传递到接受指针作为参数的方法中去。在上面的 `incrementor` 中我们通过直接操作 `pointee` 属性改变了指针指向的内容。 与这种做法类似的是使用 Swift 的 `inout` 关键字。我们在将变量传入 `inout` 参数的函数时,同样也使用 `&` 符号表示地址。不过区别是在函数体内部我们不需要处理指针类型,而是可以对参数直接进行操作。 func incrementor1(inout num: Int) { num += 1 } var b = 10 incrementor1(&b) b // 11 虽然 `&` 在参数传递时表示的意义和 C 中一样,是某个“变量的地址”,但是在 Swift 中我们没有办法直接通过这个符号获取一个 `UnsafePointer` 的实例。需要注意这一点和 C 有所不同: // 无法编译 let a = 100 let b = &a ## 指针初始化和内存管理 在 Swift 中不能直接取到现有对象的地址,我们还是可以创建新的 `UnsafeMutablePointer` 对象。与 Swift 中其他对象的自动内存管理不同,对于指针的管理,是需要我们手动进行内存的申请和释放的。一个 `UnsafeMutablePointer` 的内存有三种可能状态: * 内存没有被分配,这意味着这是一个 null 指针,或者是之前已经释放过 * 内存进行了分配,但是值还没有被初始化 * 内存进行了分配,并且值已经被初始化 其中只有第三种状态下的指针是可以保证正常使用的。`UnsafeMutablePointer` 的初始化方法 (`init`) 完成的都是从其他类型转换到 `UnsafeMutablePointer` 的工作。我们如果想要新建一个指针,需要做的是使用 `allocate(capacity:)` 这个类方法。该方法根据参数 `capacity: Int` 向系统申请 `capacity` 个数的对应泛型类型的内存。下面的代码申请了一个 `Int` 大小的内存,并返回指向这块内存的指针: var intPtr = UnsafeMutablePointer.allocate(capacity: 1) // "UnsafeMutablePointer(0x7FD3A8E00060)" 接下来应该做的是对这个指针的内容进行初始化,我们可以使用 `initialize(to:)` 方法来完成初始化: intPtr.initialize(to: 10) // intPtr.pointee 为 10 在完成初始化后,我们就可以通过 `pointee` 来操作指针指向的内存值了。 在使用之后,我们最好尽快释放指针指向的内容和指针本身。与 `initialize:` 配对使用的 `deinitialize:` 用来销毁指针指向的对象,而与 `allocate(capacity:)` 对应的 `deallocate(capacity:)` 用来释放之前申请的内存。它们都应该被配对使用: intPtr.deinitialize() intPtr.deallocate(capacity: 1) intPtr = nil > 注意其实在这里对于 `Int` 这样的在 C 中映射为 int 的 “平凡值” 来说,`deinitialize` 并不是必要的,因为这些值被分配在常量段上。但是对于像类的对象或者结构体实例来说,如果不保证初始化和摧毁配对的话,是会出现内存泄露的。所以没有特殊考虑的话,不论内存中到底是什么,保证 `initialize:` 和 `deinitialize` 配对会是一个好习惯。 ## 指向数组的指针 在 Swift 中将一个数组作为参数传递到 C API 时,Swift 已经帮助我们完成了转换,这在 Apple 的[官方博客](https://developer.apple.com/swift/blog/?id=6)中有个很好的例子: import Accelerate let a: [Float] = [1, 2, 3, 4] let b: [Float] = [0.5, 0.25, 0.125, 0.0625] var result: [Float] = [0, 0, 0, 0] vDSP_vadd(a, 1, b, 1, &result, 1, 4) // result now contains [1.5, 2.25, 3.125, 4.0625] 对于一般的接受 const 数组的 C API,其要求的类型为 `UnsafePointer`,而非 const 的数组则对应 `UnsafeMutablePointer`。使用时,对于 const 的参数,我们直接将 Swift 数组传入 (上例中的 `a` 和 `b`);而对于可变的数组,在前面加上 `&` 后传入即可 (上例中的 `result`)。 对于传参,Swift 进行了简化,使用起来非常方便。但是如果我们想要使用指针来像之前用 `pointee` 的方式直接操作数组的话,就需要借助一个特殊的类型:`UnsafeMutableBufferPointer`。Buffer Pointer 是一段连续的内存的指针,通常用来表达像是数组或者字典这样的集合类型。 var array = [1, 2, 3, 4, 5] var arrayPtr = UnsafeMutableBufferPointer(start: &array, count: array.count)    // baseAddress 是第一个元素的指针,类型为 UnsafeMutablePointer    if let basePtr = arrayPtr.baseAddress { print(basePtr.pointee) // 1 basePtr.pointee = 10 print(basePtr.pointee) // 10 //下一个元素 let nextPtr = basePtr.successor() print(nextPtr.pointee) // 2 } ## 指针操作和转换 ### withUnsafePointer / withUnsafeMutablePointer 上面我们说过,在 Swift 中不能像 C 里那样使用 `&` 符号直接获取地址来进行操作。如果我们想对某个变量进行指针操作,我们可以借助 `withUnsafePointer` 或 `withUnsafeMutablePointer` 这两个辅助方法。这两个方法接受两个参数,第一个是 `inout` 的任意类型,第二个是一个闭包。Swift 会将第一个输入转换为指针,然后将这个转换后的 `Unsafe` 的指针作为参数,去调用闭包。`withUnsafePointer` 或 `withUnsafeMutablePointer` 的差别是前者转化后的指针不可变,后者转化后的指针可变。使用起来大概是这个样子: var test = 10 test = withUnsafeMutablePointer(to: &test, { (ptr: UnsafeMutablePointer) -> Int in ptr.pointee += 1 return ptr.pointee }) test // 11 这里其实我们做了和文章一开始的 `incrementor` 相同的事情,区别在于不需要通过方法的调用来将值转换为指针。这么做的好处对于那些只会执行一次的指针操作来说是显而易见的,可以将“我们就是想对这个指针做点事儿”这个意图表达得更加清晰明确。 ### unsafeBitCast `unsafeBitCast` 是非常危险的操作,它会将一个指针指向的内存强制按位转换为目标的类型。因为这种转换是在 Swift 的类型管理之外进行的,因此编译器无法确保得到的类型是否确实正确,你必须明确地知道你在做什么。比如: let arr = NSArray(object: "meow") let str = unsafeBitCast(CFArrayGetValueAtIndex(arr, 0), to: CFString.self) str // “meow” 因为 `NSArray` 是可以存放任意 `NSObject` 对象的,当我们在使用 `CFArrayGetValueAtIndex` 从中取值的时候,得到的结果将是一个 `UnsafePointer`。由于我们很明白其中存放的是 `String` 对象,因此可以直接将其强制转换为 `CFString`。 关于 `unsafeBitCast` 一种更常见的使用场景是不同类型的指针之间进行转换。因为指针本身所占用的的大小是一定的,所以指针的类型进行转换是不会出什么致命问题的。这在与一些 C API 协作时会很常见。比如有很多 C API 要求的输入是 `void *`,对应到 Swift 中为 `UnsafePointer`。我们可以通过下面这样的方式将任意指针转换为 UnsafePointer。 var count = 100 let voidPtr = withUnsafePointer(to: &count, { (a: UnsafePointer) -> UnsafePointer in return unsafeBitCast(a, to: UnsafePointer.self) }) // voidPtr 是 UnsafePointer。相当于 C 中的 void * // 转换回 UnsafePointer let intPtr = unsafeBitCast(voidPtr, to: UnsafePointer.self) intPtr.pointee //100 ## 总结 Swift 从设计上来说就是以安全作为重要原则的,虽然可能有些啰嗦,但是还是要重申在 Swift 中直接使用和操作指针应该作为最后的手段,它们始终是无法确保安全的。从传统的 C 代码和与之无缝配合的 Objective-C 代码迁移到 Swift 并不是一件小工程,我们的代码库肯定会时不时出现一些和 C 协作的地方。我们当然可以选择使用 Swift 重写部分陈旧代码,但是对于像是安全或者性能至关重要的部分,我们可能除了继续使用 C API 以外别无选择。如果我们想要继续使用那些 API 的话,了解一些基本的 Swift 指针操作和使用的知识会很有帮助。 对于新的代码,尽量避免使用 `Unsafe` 开头的类型,意味着可以避免很多不必要的麻烦。Swift 给开发者带来的最大好处是可以让我们用更加先进的编程思想,进行更快和更专注的开发。只有在尊重这种思想的前提下,我们才能更好地享受这门新语言带来的种种优势。显然,这种思想是不包括到处使用 `UnsafePointer` 的 :) URL: https://onevcat.com/2014/11/watch-kit/index.html.md Published At: 2014-11-19 17:40:22 +0900 # Apple WatchKit 初探 ![](/assets/images/2014/watchkit-0.png) 随着今天凌晨 Apple 发布了第一版的 Watch Kit 的 API,对于开发者来说,这款新设备的一些更详细的信息也算是逐渐浮出水面。可以说第一版的 WatchKit 开放的功能总体还是令人满意的。Apple 在承诺逐渐开放的方向上继续前进。本来在 WWDC 之后预期 Today Widget 会是各类新颖 app 的舞台以及对 iOS 功能的极大扩展,但是随着像 Launcher 和 PCalc 这些创意型的 Today Widget 接连被下架事件,让开发者也不得不下调对 WatchKit 的预期。但是至少从现在的资料来看,WatchKit 是允许进行复杂交互以及完成一些独立功能的。虽然需要依托于 iPhone app,但是至少能够发挥的舞台和空间要比我原先想象的大不少。 当然,因为设备本身的无论是电量还是运算能力的限制,在进行 Watch app 开发的时候也还是有很多掣肘。现在 Watch app 仅仅只是作为视图显示和回传用户交互的存在,但是考虑到这是这款设备的第一版 SDK,另外 Apple 也有承诺之后会允许真正运行在 Watch 上的 app 的出现,Apple Watch 和 WatchKit 的未来还是很值得期待的。 废话不再多,我们来简单看看 WatchKit 的一些基本信息吧。 ## 我们能做什么 ### Watch app 架构 首先需要明确的是,在 iOS 系统上,app 本体是核心。所有的运行实体都是依托在本体上的:在 iOS 8 之前这是毋庸置疑的,而在 iOS 8 中添加的各种 Extension 也必须随同 app 本体捆绑,作为 app 的功能的补充。Watch app 虽然也类似于此,我们要针对 Apple Watch 进行开发,首先还是需要建立一个传统的 iOS app,然后在其中添加 Watch App 的 target。在添加之后,会发现项目中多出两个 target:其中一个是 WatchKit 的扩展,另一个是 Watch App。在项目相应的 group 下可以看到,WatchKit Extension 中含有代码 (`InterfaceController.h/m` 等),而 Watch App 里只包含了 `Interface.storyboard`。现在暂时看来 Watch App 依然是传统 iOS 设备 app 的扩展和衍生,Apple 估计会采取和 Extension 同样的态度来对待 Watch Kit。而原生可以直接运行在手表上的 app 有消息说 2015 年中后期才可能被允许。 ![](/assets/images/2014/watchkit-1.png) 在应用安装时,负责逻辑部分的 WatchKit Extension 将随 iOS app 的主 target 被一同安装到 iPhone 中,而负责界面部分的 WatchKit App 将会在主程序安装后由 iPhone 检测有没有配对的 Apple Watch,并提示安装到 Apple Watch 中。所以在实际使用时,所有的运算、逻辑以及控制实际上都是在 iPhone 中完成的。在需要界面刷新时,由 iPhone 向 Watch 发送指令进行描画并在手表盘面上显示。反过来,用户触摸手表交互时的信息也由手表传回给 iPhone 并进行处理。而这个过程 WatchKit 会在幕后为我们完成,并不需要开发者操心。我们需要知道的就是,原则上来说,我们应该将界面相关的内容放在 Watch App 的 target 中,而将所有代码逻辑等放到 Extension 里。 在手表上点击 app 图标运行 Watch App 时,手表将会负责唤醒手机上的 WatchKit Extension。而 WatchKit Extension 和 iOS app 之间的数据交互需求则由 App Groups 来完成,这和 Today Widget 以及其他一些 Extension 是一样的。如果你还没有了解过相关内容,可以参看我之前写过的一篇 [Today Extension 的教程](http://onevcat.com/2014/08/notification-today-widget/)。 ### 主要类 #### WKInterfaceController 和生命周期 `WKInterfaceController` 是 WatchKit 中的 `UIViewController` 一样的存在,也会是开发 Watch App 时花时间最多的类。每个 `WKInterfaceController` 或者其子类应该对应手表上的一个整屏内容。但是需要记住整个 WatchKit 是独立于 UIKit 而存在的,`WKInterfaceController` 是一个直接继承自 `NSObject` 的类,并没有像 `UIKit` 中 `UIResponser` 那样的对用户交互的响应功能和完备的回调。 不仅在功能上相对 `UIViewController` 简单很多,在生命周期上也进行了大幅简化。每个 `WKInterfaceController` 对象必然会被调用的生命周期方法有三个,分别是该对象被初始化时的 `-initWithContext:`,将要呈现时的 `-willActivate` 以及呈现结束后的 `-didDeactivate`。同样类比 `UIViewController` 的话,可以将它们理解为分别对应 `-viewDidLoad`,`viewWillAppear:` 以及 `-viewDidDisappear:`。虽然看方法名和实际使用上可能你会认为 `-initWithContext:` 应该对应 `UIViewController` 的 `init` 或者 `initWithCoder:` 这样的方法,但是事实上在 `-initWithContext:` 时 `WKInterfaceController` 中的“视图元素” (请注意这里我加上了引号,因为它们不是真正的视图,稍后会再说明) 都已经初始化完毕可用,这其实和 `-viewDidLoad` 中的行为更加相似。 我们一般在 `-initWithContext:` 和 `-willActivate` 中配置“视图元素”的属性,在 `-didDeactivate` 中停用像是 `NSTimer` 之类的会 hold 住 `self` 的对象。需要特别注意的是,在 `-didDeactivate` 中对“视图元素”属性进行设置是无效的,因为当前的 `WKInterfaceController` 已经非活跃。 #### WKInterfaceObject 及其子类 `WKInterfaceObject` 负责具体的界面元素设置,包括像是 `WKInterfaceButton`,`WKInterfaceLabel` 或 `WKInterfaceImage` 这类物件,也就是我们上面所提到的“视图元素”。可能一开始会产生错觉,觉得 `WKInterfaceObject` 应该对应 `UIView`,但其实上并非如此。`WKInterfaceObject` 只是 WatchKit 的实际的 View 的一个在 Watch Extension 端的代理,而非 View 本身。Watch App 中实际展现和渲染在屏幕上的 view 对于代码来说是非直接可见的,我们只能在 Extension target 中通过对应的代理对象对属性进行设置,然后在每个 run loop 需要刷新 UI 时由 WatchKit 将新的属性值从手机中传递给手表中的 Watch App 并进行界面刷新。 反过来,手表中的实际的 view 想要将用户交互事件传递给 iPhone 也需要通过 `WKInterfaceObject` 代理进行。每个可交互的 `WKInterfaceObject` 子类都对应了一个 action,比如 button 对应点击事件,switch 对应开或者关的状态,slider 对应一个浮点数值表明选取值等等。关联这些事件也很简单,直接从 StoryBoard 文件中 Ctrl 拖拽到实现中就能生成对应的事件了。虽然 UI 资源文件和代码实现是在不同的 target 中的,但是在 Xcode 中的协作已然天衣无缝。 Watch App 采取的布局方式和 iOS app 完全不同。你无法自由指定某个视图的具体坐标,当然也不能使用像 AutoLayout 或者 Size Classes 这样的灵活的界面布局方案。WatchKit 提供的布局可能性和灵活性相对较小,你只能在以“行”为基本单位的同时通过 group 来在行内进行“列”布局。这带来了相对简单的布局实现,当然,同时也是对界面交互的设计的一种挑战。 ![](/assets/images/2014/watchkit-2.png) 另外值得一提的是,随着 WatchKit 的出现及其开发方式的转变,[代码写 UI 还是使用 StoryBoard](http://onevcat.com/2013/12/code-vs-xib-vs-storyboard/) 这个争论了多年的话题可以暂时落下帷幕了。针对 Watch 的开发不能使用代码的方式。首先,所有的 `WKInterfaceObject` 对象都必须要设计的时候经由 StoryBoard 进行添加,运行时我们无法再向界面上添加或者移除元素 (如果有移除需要的,可以使用隐藏);其次 `WKInterfaceObject` 与布局相关的某些属性,比如行高行数等,不能够在运行时进行变更和设定。基本来说在运行时我们只能够改变视图的内容,以及通过隐藏某些视图元素来达到有限地改变布局 (其他视图元素会试图填充被隐藏的元素)。 总之,代码进行 UI 编写的传统,在 Apple 的不断努力下,于 WatchKit 发布的今天,被正式宣判了死刑。 #### Table 和 Context Menu 大部分 `WKInterfaceObject` 子类都很直接简单,但是有两个我想要单独说一说,那就是 `WKInterfaceTable` 和 `WKInterfaceMenu`。`UITableView` 大家都很熟悉了,在 WatchKit 中的 `WKInterfaceTable` 虽然也是用来展示一组数据,但是因为 WatchKit API 的数据传输的特点,使用上相较 `UITableView` 有很大不同和简化。首先不存在 DataSource 和 Delegate,`WKInterfaceTable` 中需要呈现的数据数量直接由其实例方法 `-setNumberOfRows:withRowType:` 进行设定。在进行设定后,使用 `-rowControllerAtIndex:` 枚举所有的 `rowController` 进行设定。这里的 `rowController` 是在 StoryBoard 中所设定的相当于 `UITableViewCell` 的东西,只不过和其他 `WKInterfaceObject` 一样,它是直接继承自 `NSObject` 的。你可以通过自定义 `rowController` 并连接 StoryBoard 的元素,并在取得 `rowController` 对其进行设定,即可完成 table 的显示。代码大概是这样的: ``` // MyRowController.swift import Foundation import WatchKit class MyRowController: NSObject { @IBOutlet weak var label: WKInterfaceLabel! } // InterfaceController.swift import WatchKit import Foundation class InterfaceController: WKInterfaceController { @IBOutlet weak var table: WKInterfaceTable! let data = ["Index 0","Index 1","Index 2"] override init(context: AnyObject?) { // Initialize variables here. super.init(context: context) // Configure interface objects here. NSLog("%@ init", self) // 注意需要在 StoryBoard 中设置 myRowControllerType // 类似 cell 的 reuse id table.setNumberOfRows(data.count, withRowType: "myRowControllerType") for (i, value) in enumerate(data) { if let rowController = table.rowControllerAtIndex(i) as? MyRowController { rowController.label.setText(value) } } } } ``` ![](/assets/images/2014/watchkit-3.png) 对于点击事件,并没有一个实际的 delegate 存在,而是类似于其他 `WKInterfaceObject` 那样通过 action 将点击了哪个 row 作为参数发送回 `WKInterfaceController` 进行处理。 另一个比较好玩的是 Context Menu,这是 WatchKit 独有的交互,在 iOS 中并不存在。在任意一个 `WKInterfaceController` 界面中,长按手表屏幕,如果当前 `WKInterfaceController` 中存在上下文菜单的话,就会尝试呼出找这个界面对应的 Context Menu。这个菜单最多可以提供四个按钮,用来针对当前环境向用户征询操作。因为手表屏幕有限,在信息显示的同时再放一些交互按钮是挺不现实的一件事情,也会很丑。而上下文菜单很好地解决了这个问题,相信长按呼出交互菜单这个操作会成为今后 Watch App 的一个很标准的交互操作。 添加 Context Menu 非常简单,在 StoryBoard 里向 `WKInterfaceController` 中添加一个 Menu,并在这个 Menu 里添加对应的 MenuItem 就行了。在 `WKInterfaceController` 我们也有对应的 API 来在运行时根据上下文环境进行 MenuItem 的添加 (这是少数几个允许我们在运行时添加元素的方法之一)。 ``` -addMenuItemWithItemIcon:title:action: -addMenuItemWithImageNamed:title:action: -addMenuItemWithImage:title:action: -clearAllMenuItems ``` ![](/assets/images/2014/watchkit-4.png) 但是 Menu 和 MenuItem 对应的类 `WKInterfaceMenu` 和 `WKInterfaceMenuItem` 我们是没有办法拿到的。没错,它们甚至都没有存在于文档里 :( ### 基础导航 `WKInterfaceController` 的内建的导航关系基本上分为三类。首先是像 `UINavigationController` 控制的类似栈的导航方式。相关的 API 有 `-pushControllerWithName:context:`,`-popController` 以及 `-popToRootController`。后两个我想不必太多解释,对于第一个方法,我们需要使用目标 controller 的 `Identifier` 字符串 (没有你只能在 StoryBoard 里进行设置) 进行创建。`context` 参数也会被传递到目标 controller 的 `-initWithContext:` 中,所以你可以以此来在 controller 中进行数据传递。 另一种是我们大家熟悉的 modal 形式,对应 API 是 `-presentControllerWithName:context:` 和 `-dismissController`。对于这种导航,和 `UIKit` 中的不同之处就是在目标 controller 中会默认在左上角加上一个 Cancel 按钮,点击的话会直接关闭被 present 的 controller。我只想说 Apple 终于想通了,每个 modal 出来的 controller 都是需要关闭的这个事实... 最后一种导航方式是类似 `UIPageController` 的分页式导航。在 iOS app 中,在应用第一次开始时的教学模块中这种导航方式非常常见,而在 WatchKit 里可以说得到了发扬光大。事实上我个人也认为这会是 WatchKit 里最符合使用习惯的导航方式。在 WatchKit 上的 page 导航可能会和 iOS app 的 Tab 导航所提供的功能相对应。 在实现上,page 导航需要在 StoryBoard 中用 segue 的方式将不同 page 进行连接,新添加的 `next page` segue 就是干这个的: ![](/assets/images/2014/watchkit-5.png) 另外 modal 导航的另一个 API `-presentControllerWithNames:contexts:` 接受复数个的 `names` 和 `context`,通过这种方式 modal 呼出的复数个 Controller 也将以 page 导航方式呈现。 当然,作为 StoryBoard 的经典使用方式,modal 和 push 的导航方式也是可以在 StoryBoard 中通过 segue 来实现的。同时 WatchKit 也为 segue 的方式提供了必要的 API。 ### 一些界面实践 因为整个架构和 `UIKit` 完全不同,所以很多之前的实践是无法直接搬到 WatchKit App 中的。 #### 图像处理 在 `UIKit` 中我们显示图片一般使用 `UIImageView`,然后为其 `image` 属性设置一个创建好的 `UIImage` 对象。而对于 WatchKit 来说,最佳实践是将图片存放在 Watch App 的 target 中 (也就是 StoryBoard 的那个 target),在对 `WKInterfaceImage` 进行图像设置时,尽量使用它的 `-setImageNamed:` 方法。这个方法将只会把图像名字通过手机传递到手表,然后由手表在自己的 bundle 中寻找图片并加载,是最快的途径。注意我们的代码是运行在于手表的 Watch App 不同的设备上的,虽然我们也可以先通过 `UIImage` 的相关方法生成 `UIImage` 对象,然后再用 `-setImage:` 或者 `-setImageData:` 来设置手表上的图片,但是这样的话我们就需要将图片放到 Extension 的 target 中,并且需要将图片的数据通过蓝牙传到手表,一般来说这会造成不可忽视的延迟,会很影响体验。 如果对于某些情况下,我们只能在 Extension 的 target 中获得图片 (比如从网络下载或者代码动态生成等),并且需要重复使用的话,最好用 `WKInterfaceDevice` 的 `-addCachedImage:name:` 方法将其缓存到手表中。这样,当我们之后再使用这张图片的时候就可以直接通过 `-setImageNamed:` 来快速地从手表上生成并使用了。每个 app 的 cache 的尺寸大约是 20M,超过的话 WatchKit 将会从最老的数据开始删除,以腾出空间存储新的数据。 #### 动画 因为无法拿到实际的视图元素,只有 `WKInterfaceObject` 这样的代理对象,以及布局系统的限制,所以复杂的动画,尤其是 `UIView` 系列或者是 `CALayer` 系列的动画是无法实现的。现在看来唯一可行的是帧动画,通过为 `WKInterfaceImage` 设置包含多个 image 的图像,或者是通过计时器定时替换图像的话,可以实现帧动画。虽然 Apple 自己的例子也通过这种方法实现了动画,但是对于设备的存储空间和能耗都可能会是挑战,还需要实际拿到设备以后进行实验和观察。 #### 其他 Cocoa Touch 框架的使用 Apple 建议最好不要使用那些需要 prompt 用户许可的特性,比如 CoreLocation 定位等。因为实际的代码是在手机上运行的,这类许可也会在手机上弹出,但是用户并不一定正好在看手机,所以很可能造成体验下降。另外大部分后台运行权限也是不建议的。 对于要获取这些数据和权限,Apple 建议还是在 iOS app 中完成,并通过 App Groups 进行数据共享,从而在 Watch Extension 中拿到这些数据。 #### 代码分享 因为现在一个项目会有很多不同的 target,所以使用 framework 的方式封装不同 target 的公用部分的代码,而只在各 target 中实现界面相关的代码应该是必行的了。这么做的优点不仅是可以减少代码重复,也会使代码测试和品质得到提升。如果还没有进行逻辑部分的框架化和测试分离的话,在实现像各类 Extension 或者 Watch App 时可能会遇到非常多的麻烦。 因为如果原有 app 有计划进行扩展推出各种 Extension 的话,将逻辑代码抽离并封装为 framework 应该是优先级最高的工作。另外新开的项目如果没有特殊原因,也强烈建议使用 framework 来组织通用代码。 ### Glance 和 Notification 除了 Watch App 本体以外,Glance 和手表的 Notification 也是重要的使用情景。Notification 虽然概念上比较简单,但是相对于 iOS 的通知来说是天差地别。WatchKit 的通知允许开发者自行构建界面,我们可以通过 payload 设置比较复杂和带有更多信息的通知,包括图像,大段文字甚至可以交互的按钮,而不是像 iOS 上那样被限制在文字和一个对话框内。首先无论是通过 Local 还是 Remote 进行的通知发送会先到达 iPhone,然后再由 iPhone 根据内容判断是否转发到手表。WatchKit App 接收到通知后先会显示一个简短的通知,告诉用户这个 app 有一个通知。如果用户对通知的内容感兴趣的话,可以点击或者抬手观看,这样由开发者自定义的长版本的通知就会显现。 Glance 是 WatchKit 的新概念,它允许 Watch App 展示一个布局固定的 `WKInterfaceController` 页面。它和 Watch App 本体相对地位相当于 iOS 上的 Today Widget 和 iOS app 本身的地位,是作为手表上的 app 的最重要的信息展示出现的。Glance 正如其名,是短时存在的提醒,不能存在可交互的元素。不过如果用户点击 Glance 页面的话,是可以启动到 Watch App 的。现在关于 Glance 本身如何启动和呈现还不是很明确,猜测是某种类似 Today Widget 的呈现方式?(比如按下两次表侧面的旋钮) ## 疑问和改进方向 WatchKit 总体令人满意,提供的 API 和开发环境已经足够开发者作出一些有趣的东西。但是有几个现在看来很明显的限制和希望能加强的方向。 首先是从现在来看 WatchKit 并没有提供任何获取设备传感信息的 API。不论是心跳、计步或者是用户是否正在佩戴 Watch 的信息我们都是拿不到的,这限制了很多数据收集和监视的健康类 app 的制作。如果希望请求数据,还是不得不转向使用 HealthKit。但是随着 iPhone 6 和 6s 的大屏化,在运动时携带 iPhone 的人可以说是变少了。如果 Watch 不能在没有 iPhone 配对的情况下收集记录,并在之后和 iPhone 连接后将数据回传的话,那 Apple 的健康牌就失败了一大半。相信 Apple 不会放过这种把用户捆绑的机会...不过如果第三方应用能实时获取用户的佩戴状况的话,相信会有很多有意思的应用出现。 另外作为在发布会上鼓吹的交互革命的旋钮和触感屏幕,现在看来并没有开放任何 API 供开发者使用,所以我们无法得知用户旋转了手表旋钮这个重要的交互事件。现在看来我们能获取的操作仅只是用户点击屏幕上的按钮或者拖动滑条这个层级,从这个角度来说,现在的 WatchKit 还远没达到可以颠覆移动应用的地步。 希望之后 Apple 会给我们带来其他的好消息吧。 总之,舞台已经搭好,之后唱什么戏,就要看我们的了。 URL: https://onevcat.com/2014/10/ib-customize-view/index.html.md Published At: 2014-10-25 00:02:07 +0900 # WWDC 2014 Session笔记 - 可视化开发,IB 的新时代 本文是我的 [WWDC 2014 笔记](http://onevcat.com/2014/07/developer-should-know-about-ios8/) 中的一篇,涉及的 Session 有 * [What's New in Xcode 6](http://devstreaming.apple.com/videos/wwdc/2014/401xxfkzfrjyb93/401/401_whats_new_in_xcode_6.pdf?dl=1) * [What's New in Interface Builder](http://devstreaming.apple.com/videos/wwdc/2014/411xx0xo98zzoor/411/411_whats_new_in_interface_builder.pdf?dl=1) 如果说在 WWDC 14 之前 Interface Builder (IB) 还是可选项的话,我相信在此之后 IB 已经是毫无疑问的 iOS 开发标配了,纯代码界面可以说已经渐行渐远,可以逐渐离开我们的视线了。 一言蔽之,就是 Apple 在催促大家使用 IB,特别是 Storyboard 做为界面开发的唯一选择这件事上,下定了决心,也做出了实际的行动。 如果是纯代码 UI 在此之前还能[有所挣扎](http://onevcat.com/2013/12/code-vs-xib-vs-storyboard/)的话,那么压死这个方案的最后一根稻草就是 Size Classes。我已经在[之前的笔记](http://onevcat.com/2014/07/ios-ui-unique/)中对这方面内容做了些简单的探索,但是还远远不够,也许在将来某一天我还会重新整理下 Size Classes 这个主题的内容,以及使用 IB 适配不同屏幕的一些实践,但是不是这次。这篇文章里想要介绍的是 Xcode 6 中为 IB 锦上添花的一个特性,那就是实时地预览自定义 view,这个特性让 IB 开发的流程更加直观可视,也可以减少很多无聊的参数配置和 UI 设置的时间。 ## 以前 IB 的不足 作为可视化开发的工具,IB 和 Storyboard 在组织和构建 ViewController 及其导航关系时已经做得很好的。对于 ViewController 的 view 画布上的诸如 `UILabel` 或者 `UIImageView` 这样的基础的类,IB 是能够很好地支持并实时在设计的时候进行显示的。但是对于那些自定义的类,之前的 IB 就束手无策了。我们能做的仅仅是在 IB 中拖放一个 `UIView`,然后通过将 `Custom Class` 属性设置为我们自定义的 `UIView` 的子类来在 “暗示” IB 在运行时初始化一个对应的子类。这样的问题是在开发自定义的 view 时,我们不得不一遍遍地修改代码并运行,再根据运行结果进行调整和修正。而实际上,单一对某个 view 的调试这种问题只涉及到设计层面,而非运行层面,如果我们能够在设计时就有一个实时地对自定义 view 的预览该多好。 没错,Apple 也是这么想的,并且在 Xcode 6 中,我们就已经可以创建这样的 `UIView` 子类了:利用新加入的 `@IBDesignable` 和 `@IBInspectable`,我们可以非常方便地完成在 IB 中实时显示自定义视图,甚至和其他一些内置 `UIView` 子类一样,直接在 IB 的 Inspector 改变某些属性,甚至我们还能通过设置断点来在 IB 中显示视图时进行调试。新的这些特性非常强大,使用起来却出乎意料的简单。下面我将通过一个实际的小例子加以说明。最终的完整例子已经放在 [GitHub](https://github.com/onevcat/ClockFaceView) 上了,现在我们从开始一步步开始吧。这些代码基于 Xcode 6.1 和 Swift 1.1。 ## 时钟 view 的例子 ### 单纯的自定义 view 假设我们有一个自定义的 view,用来描画一个时钟,如果有在读 [objc.io](http://objc.io) 或者 [objc 中国](http://objccn.io) 的读者,可能会发现这段代码是[动画一章一篇文章里代码](http://objccn.io/issue-12-2/)的改造过的 Swift 版本。 在这里我们有一个自定义的 `UIView` 的子类:`ClockFaceView`,其中嵌套了一个 `ClockFaceLayer` 作为 layer。如果我们不需要动画,我们也可以简单地使用 `-drawRect:` 来完成绘制。但是在这里我们还是选择使用添加 `CALayer` 的方式,这会使之后做动画简单好几个数量级 -- 因为我们可以简单地通过 CA 动画而不是每帧去计算绘制来完成动画 (在这篇帖子里不会涉及这些内容)。 // ClockFaceView.swift import UIKit class ClockFaceView : UIView { class ClockFaceLayer : CAShapeLayer { private let hourHand: CAShapeLayer private let minuteHand: CAShapeLayer override init() { hourHand = CAShapeLayer() minuteHand = CAShapeLayer() super.init() frame = CGRect(x: 0, y: 0, width: 200, height: 200) path = UIBezierPath(ovalInRect: CGRectInset(frame, 5, 5)).CGPath fillColor = UIColor.whiteColor().CGColor strokeColor = UIColor.blackColor().CGColor lineWidth = 4 hourHand.path = UIBezierPath(rect: CGRect(x: -2, y: -70, width: 4, height: 70)).CGPath hourHand.fillColor = UIColor.blackColor().CGColor hourHand.position = CGPoint(x: bounds.size.width / 2, y: bounds.size.height / 2) addSublayer(hourHand) minuteHand.path = UIBezierPath(rect: CGRect(x: -1, y: -90, width: 2, height: 90)).CGPath minuteHand.fillColor = UIColor.blackColor().CGColor minuteHand.position = CGPoint(x: bounds.size.width / 2, y: bounds.size.height / 2) addSublayer(minuteHand) } required init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } func refreshToHour(hour: Int, minute: Int) { hourHand.setAffineTransform(CGAffineTransformMakeRotation(CGFloat(Double(hour) / 12.0 * 2.0 * M_PI))) minuteHand.setAffineTransform(CGAffineTransformMakeRotation(CGFloat(Double(minute) / 60.0 * 2.0 * M_PI))) } } private let clockFace: ClockFaceLayer var time: NSDate? { didSet { refreshTime() } } private func refreshTime() { if let realTime = time { if let calendar = NSCalendar(calendarIdentifier: NSGregorianCalendar) { let components = calendar.components(NSCalendarUnit.CalendarUnitHour | NSCalendarUnit.CalendarUnitMinute, fromDate: realTime) clockFace.refreshToHour(components.hour, minute: components.minute) } } } override init(frame: CGRect) { clockFace = ClockFaceLayer() super.init(frame: frame) layer.addSublayer(clockFace) } required init(coder aDecoder: NSCoder) { clockFace = ClockFaceLayer() super.init(coder: aDecoder) layer.addSublayer(clockFace) } } 如果你没有耐心看完的话也没有关系,简单来说就是 `ClockFaceView` 在被初始化时会向自己添加一个 `ClockFaceLayer`,用来显示分针和时针。通过设置 `time` 属性我们可以更新时钟的位置。因为提供了 `initWithCoder:`,因此我们是可以直接从 IB 里加载这个 view 的。方法就是最普通的类型指定,并让 app 在加载时初始化对应的类型:在新建的 Single View Application 的 Storyboard 中添加一个 `UIView` 控件,然后设置好约束,并且将 Class 设置为 `ClockFaceView`: ![](/assets/images/2014/ibdesign-1.png) 运行应用,可以看到 `ClockFaceView` 被正确地初始化了,指针指向默认的 12 点整。通过为这个 view 建立 outlet 或者用其他 (比如 tag 的方式,虽然我不太喜欢这么做,但是我见过不少人这么弄) 方法找到这个 `ClockFaceView` 并设置时间的话,我们可以正确地改变其时针和分针的指向: // ViewController.swift class ViewController: UIViewController { @IBOutlet weak var clockFaceView: ClockFaceView! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. clockFaceView.time = NSDate() } } ![](/assets/images/2014/ibdesign-2.png) ### IBDesignable,IB 中自定义 view 的渲染 把大象装进冰箱有三个步骤,而让 IB 显示自定义 view 居然只有一个步骤! 只要我们在 `class ClockFaceView : UIView` 这个类型定义上面加上一个 `@IBDesignable` 的标记,就完成了! 在进行更改并等待编译和 IB 自动识别后,我们就可以在 IB 中原来一块白色的地方看到初始化后的时钟了: ![](/assets/images/2014/ibdesign-4.png) 如你所想,这个标记的作用是告诉 IB 如果遇到对应的 `UIView` 子类的话,可以对其进行渲染。深入一些来说,IB 将寻找你的子类中的 `-initWithFrame:` 方法,并给入当前自定义 view 的 frame 对其进行调用。需要注意的是,在使用 IB 初始化 view 时,被调用的是 `-initWithCoder:` 而非 frame 版本,所以说在想要实现自定义 view 在 IB 中的预览的话,我们至少必须实现这两个版本的初始化方法。不过好消息是,如果我们只添加了 `@IBDesignable`,而忘了实现 `-initWithFrame:` 的话,在 IB 渲染 view 时会给我们抛出大大的错误,所以因为遗漏而花大量时间在查找哪里出了问题这种事情应该不太可能发生。 ![](/assets/images/2014/ibdesign-3.png) ### 仅设计时的配置 现在在 IB 中我们显示的时钟只能默认地指向 0 点 0 分,这是因为在设计的时候,我们并没有机会去设定这个 view 的 `time` 属性,所以时针和分针都停留在了初始的位置上。在 Xcode 6 中可以在 `@IBDesignable` 标记的 `UIView` 子类中添加一个 `prepareForInterfaceBuilder` 方法。每次在 IB 即将把这个自定义的 view 渲染到画布之前会调用这个方法进行最后的配置。比如我们想在 IB 中这个时钟的 view 上显示当前时间的话,可以在 `ClockFaceView` 中加入这个方法: class ClockFaceView : UIView { //... override func prepareForInterfaceBuilder() { time = NSDate() } //... } 保存并切换到 IB,静待自动编译和执行,可以看到类似下面的结果: ![](/assets/images/2014/ibdesign-5.png) 挺好的...现在我们的 IB 不仅被用来设计界面了,还兼备了看时间的功能 - 虽然这个时钟并不是实时的,只有在切换编辑器界面到 IB 或者是修改了相关文件时才会进行刷新。 另外虽然这篇文章没有涉及,但是需要一提的是,如果你想要在 `prepareForInterfaceBuilder` 里加载图片的话,需要弄清楚 bundle 的概念。IB 使用的 bundle 和 app 运行时的 `mainBundle` 不是一个概念,我们需要在设计时的 IB 的 bundle 可以通过在自定义的 view 自身的 bundle 中进行查找并使用。比如想要加载一张名为 `image.png` 的图片的话: let bundle = NSBundle(forClass: self.dynamicType) if let fileName = bundle.pathForResource("image", ofType: "png") { if let image = UIImage(contentsOfFile: fileName) { // 在此处可以使用 image } } 在使用 IB 中的方法读取资源时一定要注意运行环境不同这一点。 ### 用 IBInspectable 在 IB 中调整属性 IBDesignable 的 view 的另一个很方便的地方是我们可以向 Inspector 中添加自定义的内容了。通过这样做,就可以直接在 IB 中对 view 进行一些编辑和配置。以前对于自定义 view,我们通常只能通过用类似 `IBOutlet` 的方式在代码中进行设置,或者是配置 Runtime Attribute 来进行,而现在我们有能力直接通过像给一个 `UILabel` 设定字符串或者给 `UIImageView` 设定图片这样的方式来设置自定义 view 的部分属性了,这也使得在 IB 中的自定义 view 的易用性和完整性得到了极大增强。 使用方法也非常简单,只需要在某个属性前加上 `@IBInspectable` 标记即可。比如我们可以在 `ClockFaceView` 中加入以下代码: class ClockFaceView : UIView { //... @IBInspectable var color: UIColor? { didSet { refreshColor() } } private func refreshColor() { if let realColor = color { clockFace.refreshColor(realColor) } } //... } 然后在 `ClockFaceLayer` 中加入对应的 `refreshColor` 方法: class ClockFaceLayer : CAShapeLayer { //... func refreshColor(color: UIColor) { hourHand.fillColor = color.CGColor minuteHand.fillColor = color.CGColor strokeColor = color.CGColor } //... } 我们对 `ClockFaceView` 中的 `color` 属性添加了 `@IBInspectable`,在保存和编译后,这会在 IB 中对应的 view 的 Attribute Inspector 中添加一个颜色选取的属性: ![](/assets/images/2014/ibdesign-6.png) 当我们在 IB 中设置这个属性的时候,对应的 `didSet` 将会被执行,通过 `refreshColor` 方法就可以直接改变 IB 中这个 view 的时针和分针的颜色了。 注意这个改变并不像 `prepareForInterfaceBuilder` 那样仅发生在设计时,我们直接运行代码,会看到运行时的颜色也是发生了改变的。其实 `@IBInspectable` 并没有做什么太神奇的事情,我们如果查看 IB 中这个 view 的 Identity Inspector 的话会看到刚才所设定的颜色值被作为了 Runtime Attribute 被使用了。其实手动直接在 Runtime Attributes 中设定颜色也有同样的效果,因此 `@IBInspectable` 唯一做的事情就是在 IB 面板上为我们提供了一个很方便地修改属性的入口,别没有其他太多神奇之处。 这个原理同时也决定了 `@IBInspectable` 是有一定限制的,即只有能够在 Runtime Attributes 中指定的类型才能够被标记后显示在 IB 中,这些类型包括 `Boolean`,`Number`,`String`,`Ponit`,`Size`,`Rect`,`Range`,`Color` 和 `Image`。像是如果想要把类似 `time` 这样的属性标记为 `@IBInspectable` 的话,在 IB 中还是无法显示的,因为 Xcode 并没有准备 `NSDate` 类型。不过其实通过 KVC 进行动态设定这种事情在原理上是没有问题的,界面的支持应该也可以通过 [Xcode 插件](http://onevcat.com/2013/02/xcode-plugin/)进行扩展,感觉上并不是一件特别困难的事情,有兴趣的同学不妨尝试,应该挺有意思 (当然也有可能会是个坑)。 ### 自定义渲染 view 的调试 对于简单的自定义 view 来说,实时显示和属性设定什么的并不是一件很难的事情。但是对于那些比较复杂的 view,如果我们遇到某些渲染上的问题的话,如果只能靠猜的话,就未免太可怜了。幸好,Apple 为 view 在 IB 中的渲染的调试也提供了相应的方法。在 view 的源代码中设置好断点,然后切到 IB,点选中我们的自定义的 view 后,我们就可以使用菜单里的 Editor -> Debug Selected Views 来让 IB 对这个自定义 view 进行渲染。如果触发了代码中的断点,那我们的代码就会被暂停在断点处,lldb 也会就位听我们调遣。一切都感觉良好,不是么? ![](/assets/images/2014/ibdesign-7.png) ## 总结 Xcode 6 中的很多 key feature 都是基于或者重度依赖 Interface Builder 的。比如 Size Classes,比如 xib 的启动画面,再比如本篇文章中说到的自定义 view 渲染等等。在 iOS 或者 Mac 开发中,IB 现在处于一个比以往任何时候都重要的时期,使用 IB 和这些方便的特性进行开发已经从可选项变为了必须项。很难想象没有 IB 的话要怎么才能使用这些工具,更进一步地说,很难想象没有 IB 的话开发者需要浪费多少时间在本应该迅速完成的工作中。 如果你还在使用代码来构建 UI 的话,现在也许是你最后的放下代码,拿起 IB 武装自己的机会了。一开始可能会有迷惑,会不习惯,会觉着被拽出了舒适区浑身无力。但是一旦适应以后,你不仅能够收获最新的技能和工具,也有机会站在一个全新的高度,来审视 app 中界面开发的种种,并从中找到乐趣。 P.S. 如果你不知道要从哪里入手,推荐可以从 raywenderlich 家的这篇 [AutoLayout 教程](http://www.raywenderlich.com/50317/beginning-auto-layout-tutorial-in-ios-7-part-1)开始你的 IB 之旅。 URL: https://onevcat.com/2014/09/bye-kayac/index.html.md Published At: 2014-09-24 21:57:31 +0900 # 偷得浮生半月闲 ![](/assets/images/2014/bye-kayac.jpg) 还有三天就正好是到日本两周年整的日子了。 最近忙完了包括写书和为 iOS 8 更新 app 在内的一摊子事儿,今天又从 Kayac 办妥了离职手续,所以正式进入无业状态,也算是和这一段时期的忙碌道别了。之后会在下个月中旬入职 Line,中间有半个月时间可以用来陪陪父母到处走走,以及看书充电,也算是忙里偷闲。 ## 近期的爱好 两年时间说长不长,说短也不短。现在回头回顾和总结这两年的生活,可以说是充实而丰满的。两年前自己在人生十字路口的选择看来还算明智,和老婆一起在这个国度里可以很专心地生活,这样就很好。自己觉得现在的精神状态也还能算及格,大抵是做到了恬静自然。因为这份心境,最近也培养了许多兴趣爱好,难得写一篇非技术文,就来小扒一下吧~ ### 写作 因为用一段很集中的时间写了一本[关于 Swift 的书](http://swifter.tips/buy),其中感悟还是颇深,并发现写作确实是一件很有意思的事情。在写书的过程中其实遇到很不少的挫折和苦楚,也体会到了这件事情实际上并没有表面看起来那么简单。因为毕竟以前的写作经验有限,除了高考的范式作文和学术论文以外,最多的就是博客里的一些技术文章了。高考作文我是有信心的,因为至少在最后的实战里拿到了满分;学术论文我是不关心的,因为能看到的人少之又少;技术博文我是很用心的,但因为毕竟是自己的一方天地,但就算写得不对不好也就是个“有限责任博客”,所以也没什么压力。真正到了写书出版,特别是有偿售卖的时候,境况就有很大不同了。 最大的是责任心。虽然说现在就算在技术出版界里狗屁文章多如牛毛,但是我自己在对待获取知识和传播知识这件事上是严肃认真的。遑论水平高低,在这个 $0.99 的 app 都得辛勤维护好几年的年代,我找不到一本动辄售价 10 美元的书却草草了事的理由 (当然在国内电子书可能想卖 10 美元还不太现实,于是我选了个低价出售)。对于这本书,因为 Swift 刚刚新生,可能还面临很多变动,所以我至少会在今后两年内维护和更新,这也是我选择电子版而不太倾向于那么快与出版社合作做纸质书的初衷。 另外,其实在这个特殊的年代和国度里,出书并算不上是一件回报很高的事情。其实想想,无数次去求证一些细节,或者把所有资料清查验证,一直是很困难的。特别是在现在的比十年前那个“信息爆炸”的年代更爆炸十倍以上的时候,对信息的过滤还来不及的情况下,却因为写书要接触到更多。实在担心脑容量不够用,或者精力被彻底耗尽。说白了现在发现认真地写一本书,确实是操着卖白粉的心,却赚到的是卖白菜的钱。(好吧我承认现在白菜也不便宜就是了) 虽然并不反对素食,但我也不喜欢天天只吃白菜..所以说,想靠写作或者出书来赚到生活费显然是不现实的。因此我决定继续将这件事情作为一个业余爱好,这样我就能很开心地投入其中了。 嘛,博客当然会一直写下去,书的话希望还有下一次这样的开心和投入其中的机会吧。 ### 摄影 最近入了个 70D 开始玩摄影,虽然只是个入门设备,但是和其他众多摄影爱好者一样,我觉得这会是我由富返贫的第一步。入手单反的主要原因还是 iPhone 的相机已经满足不了对照片质量的需求以及自己对摄像手法的追求了。在有些弱光条件下 iPhone 的表现实在连差强人意都说不上。另外,自己对于快门和光圈的控制欲也愈来愈强,用参数的搭配来造成的观感差异是一件非常有意思,以及值得去追求的事情。(虽然在 iOS 8 里 Apple 开放了相机的参数控制,并且也出现了像 [Manual](https://itunes.apple.com/cn/app/manual-custom-exposure-camera/id917146276?mt=8) 这样的 app,但还远远不够)。 暂时现在会只注重拍摄本身,而不会去涉及太多后期的事情。作品在整理之后都会放到 500px 上[我的页面](https://500px.com/onevcat)。因为刚刚入门,所以很多片子肯定都很幼稚,希望能与大家多交流,欢迎各种指导。 然后还顺便入了台打印机用来印照片,因为总是在手机平板或者电脑里看的话,总是觉着缺点什么。个人还是很喜欢小时候那样的一张张翻看实际照片的体验,可能算是一种对童年的回忆的向往吧。 当然这个爱好大概会长期坚持下去,因为总感觉不把镜头或者相机照坏的话,就亏大了。另外,大概终于可以把[自我介绍](http://about.onevcat.com/#/welcome)里的 “iPhone 摄影爱好者”改成真正的“摄影爱好者” 另外,还定了目标,希望有朝一日能有资格用上[类似这样的镜头](http://www.zeiss.com/camera-lenses/en_de/camera_lenses/otus/otus1485.html)!加油加油~ ### 编译器 这其实算是主职工作的延伸。因为自己并非 CS 出身,很多基础概念是缺失的,需要补习这方面的知识而已。主要途径是跟斯坦福的[编译器课程](https://www.coursera.org/course/compilers),然后找像龙书和针对性日常会用到的 LLVM 的一些书在看。 其实自己离开校园后就一直都在各种项目之间穿梭,很久没有静下心来学点东西了。之前研究 Swift 的时候觉得很多地方挺吃力的,于是就萌发了看一看计算机原理和编译相关内容的想法。折腾一番下来,好消息是其实入门没有想象中的难,比如像使用 lex 或者 bison 这样东西来作词法和语法分析器这样的活儿,基本也就是依靠经验和需求对语言进行设计;但是想要深入的话却似乎障碍重重,不论是各种情况的考虑,或者是编译器优化背后的故事,无一不是值得花费大量时间研究的内容。 但是这个过程确实很有意思,不断积累、体会和玩味。虽然现在来说只是我的玩具,但也许未来某天就会有新的想法,而我也必须为此准备。 ### APL 其实说起来 APL 是从去年就开始的兴趣爱好了。没错,就是那个 [A Programming Language](https://en.wikipedia.org/wiki/APL_(programming_language))。从前年开始决定了每年学一门新语言,2012 年的日语,去年的 APL,到今年的 Swift (众人:Swift 也算啊拖出去吊打..),算是把这个目标坚持了三年,其中最有意思的还是 APL。最早知道 APL 还是在读 [Masterminds of Programming](http://www.amazon.com/Masterminds-Programming-Conversations-Creators-Languages/dp/0596515170) 的时候,各种大师都提到从这门语言中受益良多,由此产生了兴趣。 ![](/assets/images/2014/apl-layout.png) 初识就觉得很 cool,连键盘都需要特制的..所以理所当然地很快就喜欢上这门语言。然后去年底大概断续花了两三个月的时间学了一下。虽然确实已经是很古老的语言,现在也基本没有使用的场景了,但是从那些“古怪”的符号中散发出的迷人的魅力还是让人心醉神怡。 在[这里](http://apl.onevcat.com)架设有一个 APL 的在线解释器,有兴趣的朋友可以去感受一下。 ## 说说 Kayac 虽然从 Kayac 离职了,但是 Kayac 的风格还是深深地烙在了我的身上。你可以说这是一种玩世不恭,也可以说是对传统的反叛,但是无论如何不可否认 Kayac 确实是很与众不同的一家企业。这并不是说 Kayac 一定能够 (或者需要) 做强做大,但是在同质化非常严重的这个年代,保持一份特立独行和与众不同的企业精神可以说非常难能可贵。 另外一方面,Kayac 里有不少中国员工,而且最近也开始非常重视中国市场。最近才和 IP 的原厂合作新推出了一款完成了中文本地化的作品[口袋酪农 - 银之匙](https://itunes.apple.com/cn/app/yin-zhi-shi-silver-spoon-guan/id904828672?l=en&mt=8)。虽然有点广告嫌疑,但是有兴趣的朋友不妨可以看看,感受一下 Kayac Style 的游戏制作。 也许很快会有中国事业部,也许如果我现在不离开的话这对我来说也会是职场上的一次很好的机会,但是谁又知道会如何呢?两年前我放弃了一个很好的机会来到日本,两年后我似乎又选择了同样的道路重新开始。虽然但是唯一可以肯定的是我并没有在原地打转,谁又知道下一个机会是不是就在前面的路口呢? ## 之后的生活 半个月的空闲时间最高!首先当然是陪父母逛日本,然后还会剩下十天左右打算做个新 app,当做对 iOS 8 的学习和用 Swift 练手。当然还有一篇 WWDC 14 笔记的更新,虽然已经拖得比较晚了,但是也总比坑掉强。 然后就是在新公司继续做好工作,估计还会在日本再待上一段时间。(好吧其实是日元贬值太厉害,没钱回国了..) URL: https://onevcat.com/2014/08/notification-today-widget/index.html.md Published At: 2014-08-03 11:50:39 +0900 # WWDC 2014 Session笔记 - iOS 通知中心扩展制作入门 本文是我的 [WWDC 2014 笔记](http://onevcat.com/2014/07/developer-should-know-about-ios8/) 中的一篇,涉及的 Session 有 * [Creating Extensions for iOS and OS X, Part 1](http://devstreaming.apple.com/videos/wwdc/2014/205xxqzduadzo14/205/205_hd_creating_extensions_for_ios_and_os_x,_part_1.mov?dl=1) * [Creating Extensions for iOS and OS X, Part 2](http://devstreaming.apple.com/videos/wwdc/2014/217xxsvxdga3rh5/217/217_hd_creating_extensions_for_ios_and_os_x_part_2.mov?dl=1) ## 总览 扩展 (Extension) 是 iOS 8 和 OSX 10.10 加入的一个非常大的功能点,开发者可以通过系统提供给我们的扩展接入点 (Extension point) 来为系统特定的服务提供某些附加的功能。对于 iOS 来说,可以使用的扩展接入点有以下几个: * Today 扩展 - 在下拉的通知中心的 "今天" 的面板中添加一个 widget * 分享扩展 - 点击分享按钮后将网站或者照片通过应用分享 * 动作扩展 - 点击 Action 按钮后通过判断上下文来将内容发送到应用 * 照片编辑扩展 - 在系统的照片应用中提供照片编辑的能力 * 文档提供扩展 - 提供和管理文件内容 * 自定义键盘 - 提供一个可以用在所有应用的替代系统键盘的自定义键盘或输入法 系统为我们提供的接入点虽然还比较有限,但是不少已经是在开发者和 iOS 的用户中呼声很高的了。而通过利用这些接入点提供相应的功能,也可以极大地丰富系统的功能和可用性。本文将先不失一般性地介绍一下各种扩展的共通特性,然后再以一个实际的例子着重介绍下通知中心的 Today 扩展的开发方法,以期为 iOS 8 的扩展的学习提供一个平滑的入口。 Apple 指出,iOS 8 中开发者的中心并不应该发生改变,依然应该是围绕 app。在 app 中提供优秀交互和有用的功能,现在是,将来也会是 iOS 应用开发的核心任务。而扩展在 iOS 中是不能以单独的形式存在的,也就是说我们不能直接在 AppStore 提供一个扩展的下载,扩展一定是随着一个应用一起打包提供的。用户在安装了带有扩展的应用后,将可以在通知中心的今日界面中,或者是系统的设置中来选择开启还是关闭你的扩展。而对于开发者来说,提供扩展的方式是在 app 的项目中加入相应的扩展的 target。因为扩展一般来说是展现在系统级别的 UI 或者是其他应用中的,Apple 特别指出,扩展应该保持轻巧迅速,并且专注功能单一,在不打扰或者中断用户使用当前应用的前提下完成自己的功能点。因为用户是可以自己选择禁用扩展的,所以如果你的扩展表现欠佳的话,很可能会遭到用户弃用,甚至导致他们将你的 app 也一并卸载。 ### 扩展的生命周期 扩展的生命周期和包含该扩展的你的容器 app (container app) 本身的生命周期是独立的,准确地说。它们是两个独立的进程,默认情况下互相不应该知道对方的存在。扩展需要对宿主 app (host app,即调用该扩展的 app) 的请求做出响应,当然,通过进行配置和一些手段,我们可以在扩展中访问和共享一些容器 app 的资源,这个我们稍后再说。 因为扩展其实是依赖于调用其的宿主 app 的,因此其生命周期也是由用户在宿主 app 中的行为所决定的。一般来说,用户在宿主 app 中触发了该扩展后,扩展的生命周期就开始了:比如在分享选项中选择了你的扩展,或者向通知中心中添加了你的 widget 等等。而所有的扩展都是由 ViewController 进行定义的,在用户决定使用某个扩展时,其对应的 ViewController 就会被加载,因此你可以像在编写传统 app 的 ViewController 那样获取到诸如 `viewDidLoad` 这样的方法,并进行界面构建及做相应的逻辑。扩展应该保持功能的单一专注,并且迅速处理任务,在执行完成必要的任务,或者是在后台预约完成任务后,一般需要尽快通过回调将控制权交回给宿主 app,至此生命周期结束。 按照 Apple 的说法,扩展可以使用的内存是远远低于 app 可以使用的内存的。在内存吃紧的时候,系统更倾向于优先搞掉扩展,而不会是把宿主 app 杀死。因此在开发扩展的时候,也一定需要注意内存占用的限制。另一点是比如像通知中心扩展,你的扩展可能会和其他开发人员的扩展共存,这样如果扩展阻塞了主线程的话,就会引起整个通知中心失去响应。这种情况下你的扩展和应用也就基本和用户说再见了.. ### 扩展和容器应用的交互 扩展和容器应用本身并不共享一个进程,但是作为扩展,其实是主体应用功能的延伸,肯定不可避免地需要使用到应用本身的逻辑甚至界面。在这种情况下,我们可以使用 iOS 8 新引入的自制 framework 的方式来组织需要重用的代码,这样在链接 framework 后 app 和扩展就都能使用相同的代码了。 另一个常见需求就是数据共享,即扩展和应用互相希望访问对方的数据。这可以通过开启 App Groups 和进行相应的配置来开启在两个进程间的数据共享。这包括了使用 ` NSUserDefaults` 进行小数据的共享,或者使用 `NSFileCoordinator` 和 `NSFilePresenter` 甚至是 CoreData 和 SQLite 来进行更大的文件或者是更复杂的数据交互。 另外,一直以来的自定义的 url scheme 也是从扩展向应用反馈数据和交互的渠道之一。 这些常见的手段和策略在接下来的 demo 中都会用到。一张图片能顶千言万语,而一个 demo 能顶千张图片。那么,我们开始吧。 ## Timer Demo Demo 做的应用是一个简单的计时器,即点击开始按钮后开始倒数计时,每秒根据剩余的时间来更新界面上的一个表示时间的 Label,然后在计时到 0 秒时弹出一个 alert,来告诉用户时间到,当然用户也可以使用 Stop 按钮来提前打断计时。其实这个 Demo 就是我的很早之前做的一个[番茄工作法的 app](http://pomo.onevcat.com) 的原型。 为了大家方便跟随这个 demo,我把初始的时候的代码放到 GitHub 的 [start-project](https://github.com/onevcat/TimerExtensionDemo/tree/start-project) 这个 tag 上了。语言当然是要用 Swift,界面因为不是 demo 的重点,所以就非常简单能表明意思就好了。但是虽然简单,却也是利用了[上一篇文章](http://onevcat.com/2014/07/ios-ui-unique/)中所提到的 Size Classes 来完成的不同屏幕的布局,所以至少可以说在思想上是完备的 iOS 8 兼容了 =_=.. 初始工程运行起来的界面大概是这样的: ![初始工程](/assets/images/2014/extension-demo-1.JPG) 简单说整个项目只有一个 ViewController,点击开始按钮时我们通过设定希望的计时时间来创建一个 `Timer` 实例,然后调用它的 `start` 方法。这个方法接收两个参数,分别是每次剩余时间更新,以及计时结束(不论是计时时间到的完成还是计时被用户打断)时的回调方法。另外这个方法返回一个 tuple,用来表示是否开始成功以及可能的错误。 剩余时间更新的回调中刷新界面 UI,计时结束的回调里回收了 `Timer` 实例,并且显示了一个 `UIAlertController`。用户通过点击 Stop 按钮可以直接调用 `stop` 方法来打断计时。直接简单,没什么其他的 trick。 我们现在计划为这个 app 做一个 Today 扩展,来在通知中心中显示并更新当前的剩余时间,并且在计时完成后显示一个按钮,点击后可以回到 app 本体,并弹出一个完成的提示。 ### 添加扩展 Target 第一步当然是为我们的 app 添加扩展。正如在总览中所提到的,扩展是项目中的一个单独的 target。在 Xcode 6 中, Apple 为我们准备了对应各类不同扩展点的 target 模板,这使得向 app 中添加扩展非常容易。对于我们现在想做的 Today 扩展,只需点选菜单的 File > New > Target...,然后选择 iOS 中的 Application Extension 的 Today Extension 就行了。 ![添加 target](/assets/images/2014/extension-tutorial.jpg) 在弹出的菜单中将新的 target 命名为 `SimpleTimerTodayExtenstion`,并且让 Xcode 自动生成新的 Scheme,以方便测试使用。我们的工程中现在会多出一个和新建的 target 同名的文件夹,里面主要包含了一个 .swift 的 ViewController 程序文件,一个叫做 `MainInterface` 的 storyboard 文件和 Info.plist。其中在 plist 里 的 `NSExtension` 中定义了这个 扩展的类型和入口,而配套的 ViewController 和 StoryBoard 就是我们的扩展的具体内容和实现了。 我们的主题程序在编译链接后会生成一个后缀为 `.app` 的包,里面包含主程序的二进制文件和各种资源。而扩展 target 将单独生成一个后缀名为 `.appex` 的文件包。这个文件包将随着主体程序被安装,并由用户选择激活或者添加(对于 Today widget 的话在通知中心 Today 视图中的编辑删增,对于其他的扩展的话,使用系统的设置进行管理)。我们可以看到,现在项目的 Product 中已经新增了一个扩展了。 ![扩展的product](/assets/images/2014/extension-tutorial-2.jpg) 如果你有心已经打开了 `MainInterface` 文件的话,可以注意到 Apple 已经为我们准备了一个默认的 Hello World 的 label 了。我们这时候只要运行主程序,扩展就会一并安装了。将 Scheme 设为 Simple Timer 的主程序,`Cmd + R`,然后点击 Home 键将 app 切到后台,拉下通知中心。这时候你应该能在 Toady 视图中找到叫做 `SimpleTimerTodayExtenstion` 的项目,显示了一个 Hello World 的标签。如果没有的话,可以点击下面的编辑按钮看看是不是没有启用,如果在编辑菜单中也没有的话,恭喜你遇到了和 Session 视频里的演讲者同样的 bug,你可能需要删除应用,清理工程,然后再安装试试看。一般来说卸载再安装可以解决现在的 beta 版大部分的无法加载的问题,如果还是遇到问题的话,你还可以尝试重启设备(按照以往几年的 SDK 的情况来看,beta 版里这很正常,正式版中应该就没什么问题了)。 如果一切正常的话,你能看到的通知中心应该类似这样: ![Hello World widget](/assets/images/2014/extension-demo-2.JPG) 这种方式运行的扩展我们无法对其进行调试,因为我们的调试器并没有 attach 到这个扩展的 target 上。有两种方法让我们调试扩展,一种是将 Scheme 设为之前 Xcode 为我们生成的 `SimpleTimerTodayExtenstion`,然后运行时选择从 Today 视图进行运行,如图;另一种是在扩展运行时使用菜单中的 Debug > Attach to Process > By Process Identifier (PID) or name,然后输入你的扩展的名字(在我们的 demo 中是 com.onevcat.SimpleTimer.SimpleTimerTodayExtension)来把调试器挂载到进程上去。 ![调试扩展](/assets/images/2014/extension-tutorial-4.jpg) ### 在应用和扩展间共享数据 - App Groups 扩展既然是个 ViewController,那各种连接 `IBOutlet`,使用 `viewDidLoad` 之类的生命周期方法来设置 UI 什么的自然不在话下。我们现在的第一个难点就是,如何获取应用主体在退出时计时器的剩余时间。只要知道了还剩多久以及何时退出,我们就能在通知中心中显示出计时器正确的剩余时间了。 对 iOS 开发者来说,沙盒限制了我们在设备上随意读取和写入。但是对于应用和其对应的扩展来说,Apple 在 iOS 8 中为我们提供了一种可能性,那就是 App Groups。App Groups 为同一个 vender 的应用或者扩展定义了一组域,在这个域中同一个 group 可以共享一些资源。对于我们的例子来说,我们只需要使用同一个 group 下的 `NSUserDefaults` 就能在主体应用不活跃时向其中存储数据,然后在扩展初始化时从同一处进行读取就行了。 首先我们需要开启 App Groups。得益于 Xcode 5 开始引入的 Capabilities,这变得非常简单(至少不再需要去 developer portal 了)。选择主 target `SimpleTimer`,打开它的 Capabilities 选项卡,找到 App Groups 并打开开关,然后添加一个你能记得的 group 名字,比如 `group.simpleTimerSharedDefaults`。接下来你还需要为 `SimpleTimerTodayExtension` 这个 target 进行同样的配置,只不过不再需要新建 group,而是勾选刚才创建的 group 就行。 ![启用 App Groups](/assets/images/2014/extension-tutorial-3.jpg) 然后让我们开始写代码吧!首先是在主体程序的 `ViewController.swift` 中添加一个程序失去前台的监听,在 `viewDidLoad` 中加入: ```swift NSNotificationCenter.defaultCenter() .addObserver(self, selector: "applicationWillResignActive",name: UIApplicationWillResignActiveNotification, object: nil) ``` 然后是所调用的 `applicationWillResignActive` 方法: ```swift @objc private func applicationWillResignActive() { if timer == nil { clearDefaults() } else { if timer.running { saveDefaults() } else { clearDefaults() } } } private func saveDefaults() { let userDefault = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults") userDefault.setInteger(Int(timer.leftTime), forKey: "com.onevcat.simpleTimer.lefttime") userDefault.setInteger(Int(NSDate().timeIntervalSince1970), forKey: "com.onevcat.simpleTimer.quitdate") userDefault.synchronize() } private func clearDefaults() { let userDefault = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults") userDefault.removeObjectForKey("com.onevcat.simpleTimer.lefttime") userDefault.removeObjectForKey("com.onevcat.simpleTimer.quitdate") userDefault.synchronize() } ``` 这样,在应用切到后台时,如果正在计时,我们就将当前的剩余时间和退出时的日期存到了 `NSUserDefaults` 中。这里注意,可能一般我们在使用 `NSUserDefaults` 时更多地是使用 `standardUserDefaults`,但是这里我们需要这两个数据能够被扩展访问到的话,我们必须使用在 App Groups 中定义的名字来使用 `NSUserDefaults`。 接下来,我们可以到扩展的 `TodayViewController.swift` 中去获取这些数据了。在扩展 ViewController 的 `viewDidLoad` 中,添加以下代码: ```swift let userDefaults = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults") let leftTimeWhenQuit = userDefaults.integerForKey("com.onevcat.simpleTimer.lefttime") let quitDate = userDefaults.integerForKey("com.onevcat.simpleTimer.quitdate") let passedTimeFromQuit = NSDate().timeIntervalSinceDate(NSDate(timeIntervalSince1970: NSTimeInterval(quitDate))) let leftTime = leftTimeWhenQuit - Int(passedTimeFromQuit) lblTImer.text = "\(leftTime)" ``` 当然别忘了把 StoryBoard 的那个 label 拖出来: ```swift @IBOutlet weak var lblTImer: UILabel! ``` 再次运行程序,并开始一个计时,然后按 Home 键切到后台,拉出通知中心,perfect,我们的扩展能够和主程序进行数据交互了: ![读取数据](/assets/images/2014/extension-demo-3.JPG) ### 在应用和扩展间共享代码 - Framework 接下来的任务是在 Today 界面中进行计时,来刷新我们的界面。这部分代码其实我们已经写过(当然..确切来说是我写的,你可能只是看过),没错,就是应用中的 `Timer.swift` 文件。我们只需要在扩展的 ViewController 中用剩余时间创建一个 `Timer` 的实例,然后在更新的 callback 里设置 label 就好了嘛。但是问题是,这部分代码是在应用中的,我们要如何在扩展中也能使用它呢? 一个最直接也是最简单的想法自然是把 `Timer.swift` 加入到扩展 target 的编译文件中去,这样在扩展中自然也就可以使用了。但是 iOS 8 开始 Apple 为我们提供了一个更好的选择,那就是做成 Framework。单个文件可能不会觉得有什么差别,但是随着需要共用的文件数量和种类的增加,将单个文件逐一添加到不同 target 这种管理方法很快就会将事情弄成一团乱麻。你需要考虑每一个新加或者删除的文件影响的范围,以及它们分别需要适用何处,这简直就是人间地狱。提供一个统一漂亮的 framework 会是更多人希望的选择(其实也差不多成为事实标准了)。使用 framework 进行模块化的另一个好处是可以得益于良好的访问控制,以保证你不会接触到不应该使用的东西,然后,Swift 的 namespace 是基于模块的,因此你也不再需要担心命名冲突等等一摊子 objc 时代的烦心事儿。 现在让我们把 `Timer.swift` 放到 framework 里吧。首先我们新建一个 framework 的 target。File > New > Target... 中选择 Framework & Library,选中 Cocoa Touch Framework (配图中的另外几个选项可能在你的 Xcode 中是没有的,请无视它们,这是历史遗留问题),然后确定。按照 Apple 对 framework 的命名规范,也许 `SimpleTimerKit` 会是一个不错的名字。 ![建立框架](/assets/images/2014/extension-tutorial-5.jpg) 接下来,我们将 `Timer.swift` 从应用中移动到 framework 中。很简单,首先将其从应用的 target 中移除,然后加入到新建的 `SimpleTimerKit` 的 Compile Sources 中。 ![添加 framework 文件](/assets/images/2014/extension-tutorial-6.jpg) 确认在应用中 link 了新的 framwork,并且在 ViewController.swift 中加上 `import SimpleTimerKit` 后试着编译看看...好多错误,基本都是 ViewController 中说找不到 Timer 之类的。这是因为原来的实现是在同一个 module 中的,默认的 `internal` 的访问层级就可以让 ViewController 访问到关于 `Timer` 和相应方法的信息。但是现在它们处于不同的 module 中,所以我们需要对 `Timer.swift` 的访问权限进行一些修改,在需要外部访问的地方加上 `public` 关键字。关于 Swift 中的访问控制,可以参考 Apple 关于 Swift 的这篇[官方博客](https://developer.apple.com/swift/blog/?id=5),简单说就是 `private` 只允许本文件访问,不写的话默认是 `internal`,允许统一 module 访问,而要提供给别的 module 使用的话,需要声明为 public。修改后的 `Timer.swift` 文件大概是[这个样子](https://github.com/onevcat/TimerExtensionDemo/blob/master/SimpleTimer/SimpleTimer/Timer.swift)的。 修改合适的访问权限后,接下来我们就可以将这个 framework 链接到扩展的 target 了。链接以后编译什么的可以通过,但是会多一个警告: ![警告](/assets/images/2014/extension-tutorial-7.jpg) 这是因为作为插件,需要遵守更严格的沙盒限制,所以有一些 API 是不能使用的。为了避免这个警告,我们需要在 framework 的 target 中声明在我们使用扩展可用的 API。具体在 `SimpleTimerKit` 的 target 的 General 选项卡中,将 Deployment Info 中的 Allow app extension API only 勾选上就可以了。关于在扩展里不能使用的 API,都已经被 Apple 标上了 `NS_EXTENSION_UNAVAILABLE`,在[这里](https://gist.github.com/joeymeyer/0cb033698bfa5a0420f6)有一份简单的列表可供参考,基本来说都是 runtime 的东西以及一些会让用户迷惑或很危险的操作(当然这个标记的方法很可能会不断变动,最终一切请以 Apple 的文档和实际代码为准)。 ![开启 Extension Only](/assets/images/2014/extension-tutorial-9.jpg) 接下来,在扩展的 ViewController 中也链接 `SimpleTimerKit` 并加入 `import SimpleTimerKit`,我们就可以在扩展中使用 `Timer` 了。将刚才的直接设置 label 的代码去掉,换成下面的: ```swift override func viewDidLoad() { //... if (leftTime > 0) { timer = Timer(timeInteral: NSTimeInterval(leftTime)) timer.start(updateTick: { [weak self] leftTick in self!.updateLabel() }, stopHandler: nil) } else { //Do nothing now } } private func updateLabel() { lblTimer.text = timer.leftTimeString } ``` 我们在扩展里也像在 app 内一样,创建 `Timer`,给定回调,坐等界面刷新。运行看看,先进入应用,开始一个计时。然后退出,打开通知中心。通知中心中现在也开始计时了,而且确实是从剩余的时间开始的,一切都很完美: ![通知中心计时](/assets/images/2014/extension-demo-4.JPG) ### 通过扩展启动主体应用 最后一个任务是,我们想要在通知中心计时完毕后,在扩展上呈现一个 "完成啦" 的按钮,并通过点击这个按钮能回到应用,并在应用内弹出结束的 alert。 这其实最关键的在于我们要如何启动主体容器应用,以及向其传递数据。可能很多同学会想到 URL Scheme,没错通过 URL Scheme 我们确实可以启动特定应用并携带数据。但是一个问题是为了通过 URL 启动应用,我们一般需要调用 `UIApplication` 的 openURL 方法。如果细心的刚才看了 `NS_EXTENSION_UNAVAILABLE` 的同学可能会发现这个方法是被禁用的(这也是很 make sense 的一件事情,因为说白了扩展通过 `sharedApplication` 拿到的其实是宿主应用,宿主应用表示凭什么要让你拿到啊!)。为了完成同样的操作,Apple 为扩展提供了一个 `NSExtensionContext` 类来与宿主应用进行交互。用户在宿主应用中启动扩展后,宿主应用提供一个上下文给扩展,里面最主要的是包含了 `inputItems` 这样的待处理的数据。当然对我们现在的需求来说,我们只要用到它的 `openURL(URL:,completionHandler:)` 方法就好了。 另外,我们可能还需要调整一下扩展 widget 的尺寸,以让我们有更多的空间显示按钮,这可以通过设定 `preferredContentSize` 来做到。在 `TodayViewController.swift` 中加入以下方法: ```swift private func showOpenAppButton() { lblTimer.text = "Finished" preferredContentSize = CGSizeMake(0, 100) let button = UIButton(frame: CGRectMake(0, 50, 50, 63)) button.setTitle("Open", forState: UIControlState.Normal) button.addTarget(self, action: "buttonPressed:", forControlEvents: UIControlEvents.TouchUpInside) view.addSubview(button) } ``` 在设定 `preferredContentSize` 时,指定的宽度都是无效的,系统会自动将其处理为整屏的宽度,所以扔个 0 进去就好了。在这里添加按钮时我偷了个懒,本来应该使用Auto Layout 和添加约束的,但是这并不是我们这个 demo 的重点。另一方面,为了代码清晰明了,就直接上坐标了。 然后添加这个按钮的 action: ```swift @objc private func buttonPressed(sender: AnyObject!) { extensionContext.openURL(NSURL(string: "simpleTimer://finished"), completionHandler: nil) } ``` 我们将传递的 URL 的 scheme 是 `simpleTimer`,以 host 的 `finished` 作为参数,就可以通知主体应用计时完成了。然后我们需要在计时完成时调用 `showOpenAppButton` 来显示按钮,更新 `viewDidLoad` 中的内容: ```swift override func viewDidLoad() { //... if (leftTime > 0) { timer = Timer(timeInteral: NSTimeInterval(leftTime)) timer.start(updateTick: { [weak self] leftTick in self!.updateLabel() }, stopHandler: { [weak self] finished in self!.showOpenAppButton() }) } else { showOpenAppButton() } } ``` 最后一步是在主体应用的 target 里设置合适的 URL Scheme: ![设置 url scheme](/assets/images/2014/extension-tutorial-8.jpg) 然后在 `AppDelegate.swift` 中捕获这个打开事件,并检测计时是否完成,然后做出相应: ```swift func application(application: UIApplication!, openURL url: NSURL!, sourceApplication: String!, annotation: AnyObject!) -> Bool { if url.scheme == "simpleTimer" { if url.host == "finished" { NSNotificationCenter.defaultCenter() .postNotificationName(taskDidFinishedInWidgetNotification, object: nil) } return true } return false } ``` 在这个例子里,我们发了个通知。而在 ViewController 中我们可以一开始就监听这个通知,然后收到后停止计时并弹出提示就行了。当然我们可能需要一些小的重构,比如添加是手动打断还是计时完成的判断以弹出不一样的对话框等等,这些都很简单再次就不赘述了。 ![完成](/assets/images/2014/extension-demo-5.JPG) 至此,我们就完成了一个很基本的通知中心扩展,完整的项目可以在 [GitHub repo 的 master](https://github.com/onevcat/TimerExtensionDemo) 上找到。这个计时器现在在应用中只在前台或者通知中心显示时工作,如果你退出应用后再打开应用,其实这段时间内是没有计时的。因此这个项目之后可能的改进就是在返回应用的时候添加一下计时的判定,来更新计时器的剩余时间,或者是已经完成了的话就直接结束计时。 ### 其他 其实在 Xcode 为我们生成的模板文件中,还有这么一段代码也很重要: ```swift func widgetPerformUpdateWithCompletionHandler(completionHandler: ((NCUpdateResult) -> Void)!) { // Perform any setup necessary in order to update the view. // If an error is encoutered, use NCUpdateResult.Failed // If there's no update required, use NCUpdateResult.NoData // If there's an update, use NCUpdateResult.NewData completionHandler(NCUpdateResult.NewData) } ``` 对于通知中心扩展,即使你的扩展现在不可见 (也就是用户没有拉开通知中心),系统也会时不时地调用实现了 `NCWidgetProviding` 的扩展的这个方法,来要求扩展刷新界面。这个机制和 [iOS 7 引入的后台机制](http://onevcat.com/2013/08/ios7-background-multitask/)是很相似的。在这个方法中我们一般可以做一些像 API 请求之类的事情,在获取到了数据并更新了界面,或者是失败后都使用提供的 `completionHandler` 来向系统进行报告。 值得注意的一点是 Xcode (至少现在的 beta 4) 所提供的模板文件的 ViewController 里虽然有这个方法,但是它默认并没有 conform 这个接口,所以要用的话,我们还需要在类声明时加上 `NCWidgetProviding`。 ## 总结 这个 Demo 主要涉及了通知中心的 Toady widget 的添加和一般交互。其实扩展是一个相当大块的内容,对于其他像是分享或者是 Action 的扩展,其使用方式又会有所不同。但是核心的概念,生命周期以及与本体应用交互的方法都是相似的。Xcode 在我们创建扩展时就为我们提供了非常好的模版文件,更多的时候我们要做的只不过是在相应的方法内填上我们的逻辑,而对于配置方面基本不太需要操心,这一点还是非常方便的。 就为了扩展这个功能,我已经迫不及待地想用上 iOS 8 了..不论是使用别人开发的扩展还是自己开发方便的扩展,都会让这个世界变得更美好。 URL: https://onevcat.com/2014/07/ios-ui-unique/index.html.md Published At: 2014-07-29 10:54:18 +0900 # WWDC 2014 Session笔记 - iOS界面开发的大一统 ![size-classes](/assets/images/2014/size-classes-header.png) 本文是我的 [WWDC 2014 笔记](http://onevcat.com/2014/07/developer-should-know-about-ios8/) 中的一篇,涉及的 Session 有 * [What's New in Cocoa Touch](http://devstreaming.apple.com/videos/wwdc/2014/202xx3ane09vxdz/202/202_hd_whats_new_in_cocoa_touch.mov?dl=1) * [Building Adaptive Apps with UIKit](http://devstreaming.apple.com/videos/wwdc/2014/216xxcnxc6wnkf3/216/216_hd_building_adaptive_apps_with_uikit.mov?dl=1) * [What's New in Interface Builder](http://devstreaming.apple.com/videos/wwdc/2014/411xx0xo98zzoor/411/411_hd_whats_new_in_interface_builder.mov?dl=1) * [View Controller Advancements in iOS 8](http://devstreaming.apple.com/videos/wwdc/2014/214xxq2mdbtmp23/214/214_hd_view_controller_advancements_in_ios_8.mov?dl=1) * [A Look Inside Presentation Controllers](http://devstreaming.apple.com/videos/wwdc/2014/228xxnfgueiskhi/228/228_hd_a_look_inside_presentation_controllers.mov?dl=1) iOS 8 和 OS X 10.10 中一个被强调了多次的主题就是大一统,Apple 希望通过 Hand-off 和各种体验的无缝切换和集成将用户黏在由 Apple 设备构成的生态圈中。而对开发者而言,今年除了 Swift 的一个大主题也是平台的统一。在 What's New in Cocoa Touch 的 Seesion 一开始,UIKit 的工程师 Luke 就指出了 iOS 8 SDK 的最重要的关键字就是自适应 (adaptivity)。这是一个很激动人心的词,首先自适应是一种设计哲学,尽量使事情保持简单,我们便可从中擢取优雅;另一方面,可能这也是 Apple 不得不做的转变。随着传说中的更大屏和超大屏的 iPhone 6 的到来,开发者在为 iOS 进行开发的时候似乎也开始面临着和安卓一样的设备尺寸的碎片化的问题。而 iOS 8 所着重希望解决的,就是这一问题。 ## Size Classes 首先最值得一说的是,iOS 8 应用在界面设计时,迎来了一个可以说是革命性的变化 - Size Classes。 ### 基本概念 在 iPad 和 iPhone 5 出现之前,iOS 设备就只有一种尺寸。我们在做屏幕适配时需要考虑的仅仅有设备方向而已。而很多应用并不支持转向,这样的话就完全没有屏幕适配的工作了。随着 iPad 和 iPhone 5,以及接下来的 iPhone 6 的推出,屏幕尺寸也变成了需要考虑的对象。在 iOS 7 之前,为一个应用,特别是 universal 的应用制作 UI 时,我们总会首先想我们的目标设备的长宽各是多少,方向变换以后布局又应该怎么改变,然后进行布局。iOS 6 引入了 Auto Layout 来帮助开发者使用约束进行布局,这使得在某些情况下我们不再需要考虑尺寸,而可以专注于使用约束来规定位置。 既然我们有了 Auto Layout,那么其实通过约束来指定视图的位置和尺寸是没有什么问题的了,从这个方面来说,屏幕的具体的尺寸和方向已经不那么重要了。但是实战中这还不够,Auto Layout 正如其名,只是一个根据约束来进行**布局**的方案,而在对应不同设备的具体情况下的体验上还有欠缺。一个最明显的问题是它不能根据设备类型来确定不同的交互体验。很多时候你还是需要判断设备到底是 iPhone 还是 iPad,以及现在的设备方向究竟是竖直还是水平来做出判断。这样的话我们还是难以彻底摆脱对于设备的判断和依赖,而之后如果有新的尺寸和设备出现的话,这种依赖关系显然显得十分脆弱的(想想要是有 iWatch 的话..)。 所以在 iOS 8 里,Apple 从最初的设计哲学上将原来的方式推翻了,并引入了一整套新的理念,来适应设备不断的发展。这就是 Size Classes。 不再根据设备屏幕的具体尺寸来进行区分,而是通过它们的感官表现,将其分为**普通** (Regular) 和**紧密** (Compact) 两个种类 (class)。开发者便可以无视具体的尺寸,而是对这这两类和它们的组合进行适配。这样不论在设计时还是代码上,我们都可以不再受限于具体的尺寸,而是变成遵循尺寸的视觉感官来进行适配。 ![Size classes](/assets/images/2014/size-classes-1.jpg) 简单来说,现在的 iPad 不论横屏还是竖屏,两个方向均是 Regular 的;而对于 iPhone,竖屏时竖直方向为 Regular,水平方向是 Compact,而在横屏时两个方向都是 Compact。要注意的是,这里和谈到的设备和方向,都仅仅只是为了给大家一个直观的印象。相信随着设备的变化,这个分类也会发生变动和更新。Size Classes 的设计哲学就是尺寸无关,在实际中我们也应该尽量把具体的尺寸抛开脑后,而去尽快习惯和适应新的体系。 ### UITraitCollection 和 UITraitEnvironment 为了表征 Size Classes,Apple 在 iOS 8 中引入了一个新的类,`UITraitCollection`。这个类封装了像水平和竖直方向的 Size Class 等信息。iOS 8 的 UIKit 中大多数 UI 的基础类 (包括 `UIScreen`,`UIWindow`,`UIViewController` 和 `UIView`) 都实现了 `UITraitEnvironment` 这个接口,通过其中的 `traitCollection` 这个属性,我们可以拿到对应的 `UITraitCollection` 对象,从而得知当前的 Size Class,并进一步确定界面的布局。 和 UIKit 中的响应者链正好相反,`traitCollection` 将会在 view hierarchy 中自上而下地进行传递。对于没有指定 `traitCollection` 的 UI 部件,将使用其父节点的 `traitCollection`。这在布局包含 childViewController 的界面的时候会相当有用。在 `UITraitEnvironment` 这个接口中另一个非常有用的是 `-traitCollectionDidChange:`。在 `traitCollection` 发生变化时,这个方法将被调用。在实际操作时,我们往往会在 ViewController 中重写 `-traitCollectionDidChange:` 或者 `-willTransitionToTraitCollection:withTransitionCoordinator:` 方法 (对于 ViewController 来说的话,后者也许是更好的选择,因为提供了转场上下文方便进行动画;但是对于普通的 View 来说就只有前面一个方法了),然后在其中对当前的 `traitCollection` 进行判断,并进行重新布局以及动画。代码看起来大概会是这个样子: ```objc - (void)willTransitionToTraitCollection:(UITraitCollection *)newCollection withTransitionCoordinator:(id )coordinator { [super willTransitionToTraitCollection:newCollection withTransitionCoordinator:coordinator]; [coordinator animateAlongsideTransition:^(id context) { if (newCollection.verticalSizeClass == UIUserInterfaceSizeClassCompact) { //To Do: modify something for compact vertical size } else { //To Do: modify something for other vertical size } [self.view setNeedsLayout]; } completion:nil]; } ``` 在两个 To Do 中,我们应该删除或者添加或者更改不同条件下的 Auto Layout 约束 (当然,你也可以干其他任何你想做的事情),然后调用 `-setNeedsLayout` 来在上下文中触发转移动画。如果你坚持用代码来处理的话,可能需要面临对于不同 Size Classes 来做移除旧的约束和添加新的约束这样的事情,可以说是很麻烦 (至少我觉得是麻烦的要死)。但是如果我们使用 IB 的话,这些事情和代码都可以省掉,我们可以非常方便地在 IB 中指定各种 Size Classes 的约束 (稍后会介绍如何使用 IB 来对应 Size Classes)。另外使用 IB 不仅可以节约成百上千行的布局代码,更可以从新的 Xcode 和 IB 中得到很多设计时就可以实时监视,查看并且调试的特性。可以说手写 UI 和使用 IB 设计的时间消耗和成本差距被进一步拉大,并且出现了很多手写 UI 无法实现,但是 IB 可以不假思索地完成的任务。从这个意义上来说,新的 IB 和 Size Classes 系统可以说无情地给手写代码判了个死缓。 另外,新的 API 和体系的引入也同时给很多我们熟悉的 UIViewController 的有关旋转的老朋友判了死刑,比如下面这些 API 都弃用了: ``` @property(nonatomic, readonly) UIInterfaceOrientation interfaceOrientation - willRotateToInterfaceOrientation:duration: - willAnimateRotationToInterfaceOrientation:duration: - didRotateFromInterfaceOrientation: - shouldAutomaticallyForwardRotationMethods ``` 现在全部统一到了 `viewWillTransitionToSize:withTransitionCoordinator:`,旋转的概念不再被提倡使用。其实仔细想想,所谓旋转,不过就是一种 Size 的改变而已,我们都被 Apple 骗了好多年,不是么? Farewell, I will NOT miss you at all. ### 在 Interface Builder 中使用 Size Classes 第一次接触 Xcode 6 和打开 IB 的时候你可能会惊呼,为什么我的画布变成正方形了。我在第一天 Keynote 结束后在 Moscone Center 的食堂里第一次打开的时候,还满以为自己找到了 iWatch 方形显示屏的确凿证据。到后来才知道,这是新的 Size Classes 对应的编辑方式。 既然我们不需要关心实际的具体尺寸,那么我们也就完全没有必要在 IB 中使用像 3.5/4 寸的 iPhone 或是 10 寸的 iPad 来分开对界面进行编辑。使用一个通用的具有 "代表" 性质的尺寸在新体系中确实更不容易使人迷惑。 在现在的 IB 界面的正下方,你可以看到一个 `wAny hAny` 的按钮 (因为今年 NDA 的一个明确限制是不能发相关软件截图,虽然其实可能没什么太大问题,但是还是尊重 license 比较好),这代表现在的 IB 是对应任意高度和任意宽度的。点击后便可以选择需要为哪种 Size Class 进行编辑。默认情况在 Any Any 下的修改会对任意设备和任意方向生效,而如果先进行选择后再进行编辑,就表示编辑只对选中的设定生效。这样我们就很容易在同一个 storyboard 文件里对不同的设备进行适配:按照设备需要添加或者编辑某些约束,或者是在特定尺寸下隐藏某些 view (使用 Attribute Inspector 里的 `Installed` 选框的加号添加)。这使得使用 IB 制作通用程序变简单了,我们不再需要为 iPhone 和 iPad 准备两套 storyboard 了。 可以发挥的想象空间实在太大,一套界面布局通吃所有设备的画面太美好,我都不敢想。 ### Size Classes 和 Image Asset 及 UIAppearence Image Asset 里也加入了对 Size Classes 的支持,也就是说,我们可以对不同的 Size Class 指定不同的图片了。在 Image Asset 的编辑面板中选择某张图片,Inspector 里现在多了一个 Width 和 Height 的组合,添加我们需要对应的 Size Class, 然后把合适的图拖上去,这样在运行时 SDK 就将从中挑选对应的 Size 的图进行替换了。不仅如此,在 IB 中我们也可以选择对应的 size 来直接在编辑时查看变化(新的 Xcode 和 IB 添加了非常多编辑时的可视化特性,关于这方面我有计划单独写一篇可视化开发的文章进行说明)。 这个特性一个最有用的地方在于对于不同屏幕尺寸可能我们需要的图像尺寸也有所不同。比如我们希望在 iPhone 竖屏或者 iPad 时的按钮高一些,而 iPhone 横屏时由于屏幕高度实在有限,我们希望得到一个扁一些的按钮。对于纯色按钮我们可以通过简单的约束和拉伸来实现,但是对于有图案的按钮,我们之前可能就需要在 VC 里写一些脏代码来处理了。现在,只需要指定好特定的 Image Asset,然后配置合适的 (比如不含有尺寸限制) 约束,我们就可以一行代码不写,就完成这样复杂的各个机型和方向的适配了。 实际做起来实在是太简单了..但拿个 demo 说明一下吧,比如下面这个实现了竖直方向 Compact 的时候将笑脸换成哭脸 -- 当然了,一行代码都不需要。 ![根据 Size Classes 指定图片](/assets/images/2014/size-classes-2.gif) 另外,在 iOS 7 中 UIImage 添加了一个 `renderingMode` 属性。我们可以使用 `imageWithRenderingMode:` 并传入一个合适的 `UIImageRenderingMode` 来指定这个 image 要不要以 [Template 的方式进行渲染](https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/UIKitUICatalog/index.html#//apple_ref/doc/uid/TP40012857-UIView-SW7)。在新的 Xcode 中,我们可以直接在 Image Asset 里的 `Render As` 选项来指定是不是需要作为 template 使用。而相应的,在 `UIApperance` 中,Apple 也为我们对于 Size Classes 添加了相应的方法。使用 `+appearanceForTraitCollection:` 方法,我们就可以针对不同 trait 下的应用的 apperance 进行很简单的设定。比如在上面的例子中,我们想让笑脸是绿色,而哭脸是红色的话,不要太简单。首先在 Image Asset 里的渲染选项设置为 `Template Image`,然后直接在 `AppDelegate` 里加上这样两行: ```objc UIView.appearanceForTraitCollection(UITraitCollection(verticalSizeClass:.Compact)).tintColor = UIColor.redColor() UIView.appearanceForTraitCollection(UITraitCollection(verticalSizeClass:.Regular)).tintColor = UIColor.greenColor() ``` ![image](/assets/images/2014/size-classes-3.gif) 完成,只不过拖拖鼠标,两行简单的代码,随后还能随喜换色,果然是大快所有人心的大好事。 ## UIViewController 的表现方式 ### UISplitViewController 在用 Regular 和 Compact 统一了 IB 界面设计之后,Apple 的工程师可能发现了一个让人两难的历史问题,这就是 `UISplitViewController`。一直做 iPhone 而没太涉及 iPad 的童鞋可能对着这个类不是很熟悉,因为它们是 iPad Only 的。iPad 推出时为了适应突然变大的屏幕,并且远离 "放大版 iTouch" 的诟病,Apple 为 iPad 专门设计了这个主从关系的 ViewControlle容器。事实也证明了这个设计在 iPad 上确实是被广泛使用,是非常成功的。 现在的问题是,如果我们只有一套 UI 画布的话,我们要怎么在这个单一的画布上处理和表现这个 iPad Only 的类呢? 答案是,让它在 iPhone 上也能用就行了。没错,现在你可以直接在 iPhone 上使用 SplitViewController 了。在 Regular 的宽度时,它保持原来的特性,在 DetailViewController 中显示内容,这是毫无疑问的。而在 Compact 中,我们第一想法就是以 push 的表现形式展示。在以前,我们可能需要写不少代码来处理这些事情,比如在 AppDelegate 中就在一开始判断设备是不是 iPad,然后为应用设定两套完全不同的导航:一套基于 `UINavigationController`,另一套基于 `UISplitViewController`。而现在我们只需要一套 `UISplitViewController`,并将它的 MasterViewController 设定为一个 navgationController 就可以轻松搞定所有情况了。 也许你会想,即使这样,我是不是还是需要判断设备是不是 iPad,或者现在的话是判断 Size Class 是不是 Compact,来决定是要做的到底是 navVC 的 push 还是改变 splitVC 的 viewControllers。其实不用,我们现在可以无痛地不加判断,直接用统一的方式来完成两种表现方式。这其中的奥妙在于我们不需要使用 (事实上 iOS 8 后 Apple 也不再提倡使用) `UINavigationController` 的 `pushViewController:animated:` 方法了 (又一个老朋友要和我们说再见了)。其实虽然很常用,但是这个方法是一直受到社区的议论的:因为正是这个方法的存在使得 ViewController 的耦合特性上了一个档次。在某个 ViewController 中这个方法的存在时,就意味着我们需要确保当前的 ViewController 必须处于一个导航栈的上下文中,这是完全和上下文耦合的一种方式 (虽然我们也可以很蛋疼地用判断 `navController` 是不是 `nil` 来绕开,但是毕竟真的很丑,不是么)。 我们现在有了新的展示 viewController 的方法,`-showViewController:sender:` 以及 `-showDetailViewController:sender:`。调用这两个方法时,将顺着包括调用 vc 自身的响应链而上,寻找最近的实现了这个方法的 ViewController 来执行相应代码。在 iOS SDK 的默认实现中,在 `UISplitViewController` 这样的容器类中,已经有这两个方法的实现方式,而 `UINavigationController` 也实现了 `-showViewController:sender:` 的版本。对于在 navController 栈中的 vc,会调用 push 方式进行展示,而对 splitVC,`showViewController:sender:` 将在 MasterViewController 中进行 push。而 `showDetailViewController:sender:` 将根据水平方向的 Size 的情况进行选择:对于 Regular 的情况,将在 DetailViewController 中显示新的 vc,而对于 Compact 的情况,将由所在上下文情况发回给下级的 navController 或者是直接以 modal 的方式展现。关于这部分的具体内容,可以仔细看看这个[示例项目](https://developer.apple.com/devcenter/download.action?path=/wwdc_2014/wwdc_2014_sample_code/adaptivephotosanadaptiveapplication.zip)和相关的[文档 (beta版)](https://developer.apple.com/library/prerelease/ios/documentation/UIKit/Reference/UISplitViewController_class/)。 这么设计的好处是显而易见的,首先是解除了原来的耦合,使得我们的 ViewController 可以不被局限于导航控制器上下文中;另外,这几个方法都是公开的,也就是说我们的 ViewController 可以实现这两个方法,截断响应链的响应,并实现我们自己的呈现方式。这在自定义 Container Controller 的时候会非常有用。 ### UIPresentationController iOS 7 中加入了一套实现非常漂亮的自定义转场动画的方法 (如果你还不知道或者不记得了,可以看看我去年的[这篇笔记](http://onevcat.com/2013/10/vc-transition-in-ios7/))。Apple 在解耦和重用上的努力确实令人惊叹。而今年,顺着自适应和平台开发统一的东风,在呈现 ViewController 的方式上 Apple 也做出了从 iOS SDK 诞生以来最大的改变。iOS 8 中新加入了一个非常重要的类 `UIPresentationController`,这个 `NSObject` 的子类将用来管理所有的 ViewController 的呈现。在实现方式上,这个类和去年的自定义转场的几个类一样,是完全解耦合的。而 Apple 也在自己的各种 viewController 呈现上完全统一地使用了这个类。 #### 再见 UIPopoverController 和 SplitViewController 类似,`UIPopoverController` 原来也只是 iPad 使用的,现在 iPhone 上也将适用。准确地说,现在我们不再使用 `UIPopoverController` 这个类 (虽然现在文档还没有将其标为 deprecated,但是估计也是迟早的事儿了),而是改用一个新的类 `UIPopoverPresentationController`。这是 `UIPresentationController` 的子类,专门用来负责呈现以 popover 的形式呈现内容,是 iOS 8 中用来替代原有的 `UIPopoverController` 的类。 比起原来的类,新的方式有什么优点呢?最大的优势是自适应,这和 `UISplitViewController` 在 iOS 8 下的表现是类似的。在 Compact 的宽度条件下,`UIPopoverPresentationController` 的呈现将会直接变成 modal 出来。这样我们基本就不再需要去判断 iPhone 还是 iPad (其实相关的判定方法也已经被标记成弃用了),就可以对应不同的设备了。以前我们可能要写类似这样的代码: ```swift if UIDevice.currentDevice().userInterfaceIdiom == .Pad { let popOverController = UIPopoverController(contentViewController: nextVC) popOverController.presentPopoverFromRect(aRect, inView: self.view, permittedArrowDirections: .Any, animated: true) } else { presentViewController(nextVC, animated: true, completion: nil) } ``` 而现在需要做的是: ```swift nextVC.modalPresentationStyle = .Popover let popover = nextVC.popoverPresentationController popover.sourceRect = aRect popover.permittedArrowDirections = .Any presentViewController(nextVC, animated: true, completion: nil) ``` 没有可恶的条件判断,一切配置井井有条,可读性也非常好。 除了自适应之外,新方式的另一个优点是非常容易自定义。我们可以通过继承 `UIPopoverPresentationController` 来实现我们自己想要的呈现方式。其实更准确地说,我们应该继承的是 `UIPresentationController`,主要通过实现 `-presentationTransitionWillBegin` 和 `-presentationTransitionDidEnd:` 来自定义我们的展示。像以前我们想要实现只占半个屏幕,后面原来的 view 还可见的 modal,或者是将从下到上的动画改为百叶窗或者渐隐渐现,那都是可费劲儿的事情。而在 `UIPresentationController` 的帮助下,一切变得十分自然和简单。在自己的 `UIPresentationController` 子类中: ```swift override func presentationTransitionWillBegin() { let transitionCoordinator = self.presentingViewController.transitionCoordinator() transitionCoordinator.animateAlongsideTransition({context in //Do animation here }, completion: nil) } override func presentationTransitionDidEnd(completed: Bool) { //Do clean here } ``` 具体的用法和 iOS 7 里的自定义转场很类似,设定需要进行呈现操作的 ViewController 的 transition delegate,在 `UIViewControllerTransitioningDelegate` 的 `-presentationControllerForPresentedViewController:sourceViewController:` 方法中使用 `-initWithPresentedViewController:presentingViewController:` 生成对应的 `UIPresentationController` 子类对象返回给 SDK,然后就可以喝茶看戏了。 #### 再见 UIAlertView, 再见 UIActionSheet 自适应和 `UIPresentationController` 给我们带来的另一个大变化是 `UIAlertView` 和 `UIActionSheet` 这两个类的消亡 (好吧其实算不上消亡,弃用而已)。现在,Alert 和 ActionSheet 的呈现也通过 `UIPresentationController` 来实现。原来在没有 Size Class 和需要处理旋转的黑暗年代 (抱歉在这里用了这个词,但是我真的一点也不怀念那段处理设备旋转的时光) 里,把这两个 view 显示出来其实幕后是一堆恶心的事情:创建新的 window,处理新 window 的大小和方向,然后将 alert 或者 action sheet 按合适的大小和方向加到窗口上,然后还要考虑处理转向,最后显示出来。虽然 Apple 帮我们做了这些事情,但是轮到我们使用时,往往它们也只能满足最基本的需求。在适配 iPhone 和 iPad 时,`UIAlertView` 还好,但是对于 `UIActionSheet` 我们往往又得进行不同处理,来选择是不是需要 popover。 另外一个要命的地方是因为这两个类是 iOS 2.0 开始就存在的爷爷级的类了,而最近一直也没什么大的更新,设计模式上还使用的是传统的 delegate 那一套东西。实际上对于这种很轻很明确的使用逻辑,block handler 才是最好的选择,君不见满 GitHub 的 block alert view 的代码,但是没辙,4.0 才出现的 block 一直由于种种原因,在这两个类中一直没有得到官方的认可和使用。 而作为替代品的 `UIAlertController` 正是为了解决这些问题而出现的,值得注意的是,这是一个 `UIViewController` 的子类。可能你会问 `UIAlertController` 对应替代 `UIAlertView`,这很好,但是 `UIActionSheet` 怎么办呢?哈..答案是也用 `UIAlertController`,在 `UIAlertController` 中有一个 `preferredStyle` 的属性,暂时给我们提供了两种选择 `ActionSheet` 和 `Alert`。在实际使用时,这个类的 API 还是很简单的,使用工厂方法创建对象,进行配置后直接 present 出来: ```swift let alert = UIAlertController(title: "Test", message: "Msg", preferredStyle: .Alert) let okAction = UIAlertAction(title: "OK", style: .Default) { [weak alert] action in print("OK Pressed") alert!.dismissViewControllerAnimated(true, completion: nil) } alert.addAction(okAction) presentViewController(alert, animated: true, completion: nil) ``` 使用上除了小心循环引用以外,并没有太多好说的。在 Alert 上加文本输入也变得非常简单了,使用 `-addTextFieldWithConfigurationHandler:` 每次向其上添加一个文本输入,然后在 handler 里拿数据就好了。 要记住的是,在幕后,做呈现的还是 `UIPresentationController`。 #### UISearchDisplayController -> UISearchController 最后想简单提一下在做搜索栏的时候的同样类似的改变。在 iOS 8 之前做搜索栏简直是一件让人崩溃的事情,而现在我们不再需要讨厌的 `UISearchDisplayController` 了,也没有莫名其妙的在视图树中强制插入 view 了 (如果你做过搜索栏,应该知道我在说什么)。这一切在 iOS 8 中也和前面说到的 alert 和 actionSheet 一样,被一个 `UIViewController` 的子类 `UISearchController` 替代了。背后的呈现机制自然也是 `UIPresentationController`,可见新的这个类在 iOS 8 中的重要性。 ## 总结 对于广大 iOS 开发者赖以生存的 UIKit 来说,这次最大的变化就是 Size Classes 的引入和新的 Presentation 系统了。在 Keynote 上 Craig 就告诉我们,iOS 8 SDK 将是 iOS 开发诞生以来最大的一次变革,此言不虚。虽然 iOS 8 SDK 的广泛使用估计还有要有个两年时间,但是不同设备的开发的 API 的统一这一步已然迈出,这也正是 Apple 之后的发展方向。正如两年前的 Auto Layout 正在今天大放光彩一样,之后 Size Classes 和新的 ViewController 也必将成为日常开发的主力工具。 程序员嘛,果然需要每年不断学习,才能跟上时代。 URL: https://onevcat.com/2014/07/developer-should-know-about-ios8/index.html.md Published At: 2014-07-15 22:27:05 +0900 # 开发者所需要知道的 iOS8 SDK 新特性 ![](/assets/images/2014/wwdc2014-badge.jpg) WWDC 2014 已经过去一个多月。最激动人心的莫过于 Swift 这门新语言的发布,我在之前已经写了一些关于这么语言的[第一印象](http://onevcat.com/2014/06/my-opinion-about-swift/)和一些[初步的探索](http://onevcat.com/2014/06/walk-in-swift/)。在写这篇文章的时候,Swift 随着 beta 3 得到了重大的更新,而这门语言现在也还在剧烈的变化之中。对于 Swift,现在大家的探索才刚刚上路,很多背后的机制还并不是非常清楚,或者有可能发生巨大的变化,因此在这里和之后的几篇文章,直到稳定的 1.0 版本出现,我不再打算继续深入针对 Swift 写什么文章。这基本出于对未来可能的变化会容易误导读到文章的新人的考虑,而并不是说建议我们现在可以放下 Swift,而安心等待正式版本。在我的观念里,对于一个尚不稳定的版本的探索和研究,远比之后被动去接受别人的结果要来的有趣得多,理解也会深入得多。因此如果您有时间的话,建议还是能尽早接触和使用会比较好。Github 上有一个[不错的 repo](https://github.com/ksm/SwiftInFlux),记录了 Swift 一路以来的变化,并探讨了不足以及以后可能的变化,希望深研 Swift 的同学不妨关注看看。 这篇总览先简要介绍下在我看来作为 iOS 开发者应该关注的开发时的变化,在之后一系列文章里我会对其中的某几个部分详细探讨一下,而其余的可能就在本文中做简介。总而言之,这次 WWDC 2014 的相关笔记(现在来说的话是暂定计划要写的内容)大概整理如下: * [开发者所需要知道的 iOS8 SDK 新特性](http://onevcat/2014/07/developer-should-know-about-ios8) * [iOS 界面开发的大一统](http://onevcat.com/2014/07/ios-ui-unique/) * [iOS 通知中心扩展制作入门](http://onevcat.com/2014/08/notification-today-widget/) * [可视化开发,IB 的新时代](http://onevcat.com/2014/10/ib-customize-view/) * iOS 和 Mac 整合开发 * 通知中心和应用使用重心的改变 --- ## 应用扩展 (Extension) 这是一个千呼万唤始出来的特性,也是一个可以发挥无限想象力的特性。现在 Apple 允许我们在 app 中添加一个新的 target,用来提供一些扩展功能:比如在系统的通知中心中显示一个自己的 widget,在某些应用的 Action 中加入自己的操作,在分享按扭里加入自己的条目,更甚至于添加自定义的键盘等等。每一种操作对应这一个应用扩展的入口,在开发中我们只需要在工程中新建立一个对应相应入口的 target,就能从一个很好的模板开始进行一些列开发,来实现这些传统意义上可能需要越狱才能实现的功能。 对于应用扩展,Apple 将其定义为 App 的功能的自然延伸,因此不是单独存在的,而是随着应用本体的包作为附属而被一同下载和安装到用户的设备中的,用户需要在之后选择将其开启。另外,由于应用扩展和应用是属于两个不同的 target 的,因此它们之间的数据和操作上的交互遵循的是另一套原则。关于应用扩展的更详细的内容,我计划在之后通过一个通知中心的 today 小框体控件的例子来详细说明。 > 专题相关笔记 > > [iOS 通知中心扩展制作入门](http://onevcat.com/2014/08/notification-today-widget/) ## App 开发时的统一 随着一代代的 iPhone 和 iPad 的出现,iOS 设备的屏幕尺寸也开始出现分裂的趋势。之前一套屏幕两个方向吃遍全世界的美好时光已然不再,现在至少已经有 3.5 寸,4寸和 10(7) 寸三种分辨率/尺寸的机型需要进行适配,再考虑到每个尺寸的横竖两种方向,以及日益呼声愈高的 4.7 寸和 5.5 寸的 iPhone,可以相见现在的布局方式已然不堪重负。虽然在 iOS 6 Apple 就推出了 Auto Layout 来辅助完成布局工作,解决了原来的相对布局的一些问题,但是在以绝对尺寸为度量的坐标系统中,难免还是有所掣肘。在 iOS 8 中,Apple 的工程师们可以说“极富想象力”地干脆把限制和表征屏幕尺寸的长宽数字给去掉了,取而代之使用 size classes 的概念,将长宽尺寸按照设备类型和方向归类为 regular 和 compact 两类。通过为不同的设备定义尺寸分类,用来定义同类型的操作特性,这使得开发者更容易利用一套 UI 来适配不同的屏幕。 iOS 8 在 UIKit 中添加了一整套使用 size classes 来进行布局的 API,并且将原有的比较复杂(或者说有些冗余)的 API 作废了。结合新的 Interface Builder 和 Auto Layout,可以说对于多尺寸屏幕的适配得到了前所未有的简化。 不仅如此,像是原来 iPad 专有的 SplitController 等也被以适应不同 regular 和 compact 的尺寸类型的形式 port 到了 iPhone 上,在程序设计方面两者更加统一了。另外,一直陪伴我们的 `UIAlertView` 和 `UIActionSheet` 这些老面孔也将退出舞台,取而代之全部统一以 UIViewController 来呈现。 这是一个好的开始,也是一个好的变化。可以看到 Apple 在避免平台碎片化上正在努力。 > 专题相关笔记 > > [iOS 界面开发的大一统](http://onevcat.com/2014/07/ios-ui-unique/) > > [可视化开发,IB 的新时代](http://onevcat.com/2014/10/ib-customize-view/) ## iCloud 相关 作为帮主的最后一件作品,iCloud 其实非常可惜,一直没有能在 Apple 的生态圈中特别出彩。首先主要是由于 iCloud 相关的开发和 API 使用起来有一定难度,另外就是之前的 SDK 在和 iCloud 相关的各种 API 或多或少都有一些小问题。在 iOS 7 中 iCloud,特别是 iCloud 和 CoreData 结合的部分的 API 的稳定性和易用性得到了很大的改善。而在 iOS 8 中,Apple 更进一步,推出了全新的被称为 Cloud Kit 的框架。如果您熟悉或者使用过像 [Parse](https://www.parse.com) 或者 [AVOS Cloud](https://cn.avoscloud.com) 之类的 [BaaS](http://en.wikipedia.org/wiki/Backend_as_a_service) 的话,可能会对这个框架感到亲切。但是和传统的 BaaS 稍有不同的是,Cloud Kit 更多的是倾向于使用 iCloud 对数据进行集成。你可以不更改应用现有的数据模型和结构,而只是使用 Cloud Kit 来从云端获取数据或者向云端存储数据。 相比与 Parse 和 AVOS 的 API,由于可以和系统深度集成,有很多在其他类似 BaaS 中没有的特性 (比如订阅某个公共对象)。但是因为是 Apple 自家产品,其缺点也是显而易见并且致命的 -- 你无法在非 Apple 的平台上使用这个框架。也就是说,如果你的应用火了,想接着出个安卓版的话,那就只能呵呵了。所以虽然 Cloud Kit 看起来很美好,而且基本等同于免费使用,但是因为平台的限制,而它所涉及的内容又是对跨平台需求很强又绕不开的数据,所以可能实际中能实用的机会并不太多。当然,如果应用是 for iOS only 的话,使用 Cloud Kit 应该是很不错的选择。 关于云端存储的另一个新变化是存储源的可变化。以前我们基本别无选择,想使用沙盒外的文件的话,要么就是 iCloud 同一个 container 内的文件,要么就需要来个像 Dropbox 这样的第三方库去做一堆登陆验证什么的。不论那种方式都可以说挺麻烦的。而现在随着 [iCloud Drive](https://www.apple.com/cn/ios/ios8/icloud-drive/) 的引入,在应用间共享访问文件就变得很容易了。更甚,我们现在可以使用 [UIDocumentPickerViewController](https://developer.apple.com/library/prerelease/ios/documentation/UIKit/Reference/UIDocumentPickerViewController_Class/index.html#//apple_ref/occ/cl/UIDocumentPickerViewController) 来从第三方存储 (以及第三方 app 通过应用扩展所实现的存储) 中选取文件。 ## Handoff 及其他 iOS 与 Mac 的协同开发 虽然 PC 市场一直疲软,但是得益于 iDevice 的销售和品牌接受度的回升,Mac 的销量反而逆市上扬。这点在国内尤为明显,确实可以感觉到身边开始使用 Mac 的人在逐渐变多,这对于我们这些 iOS 开发者来说其实是一个不错的机会。iOS 8 中的 Handoff 机制(就是可以在 Mac 上继续完成在 iOS 上半途的工作)给 for both iOS and Mac 的应用带来了一个不错的契合点和卖点。而近年来在整合两个系统上的动作,也可以看得出 Apple 确实希望利用庞大的 iOS 的开发人员资源来进一步完善和丰富 Mac。iOS 开发和 Mac 开发其实同根同源,因此在转换的时候并不是很困难的事情。 我们一直以来都可以写出跨两个平台的 Model 部分的代码,而只需要关心在表现上的区别。而现在 Cocoa 和 CocoaTouch 在官方支持自制 framework 后,利用 framework 来完成这一过程可以说更加简单了。 > 专题相关笔记 > > iOS 和 Mac 整合开发 ## Health Kit 和 Home Kit 这是对应两个现在很热的领域 -- 可穿戴式设备和智能家电 -- 所加入的框架。基本上来说 Apple 想做的事情就是以 iOS 为基础,为其他 app 建立一个平台以及成为用户数据的管理者。 Health Kit 就是一个用户体征参数的数据库,第三方应用可以向用户申请权限使用其中的数据或是向其中汇报数据。而 Home Kit 则以家庭,房间和设备的组织形式来管理和控制家中适配了 Home Kit 的智能家电。这两个超级年轻的框架的 API 相对都还比较简单,结构也很好,相信稍有经验的 iOS 开发者都能在很快掌握用法。唯一的限制在于作为普通开发者(比如我这样的只能自己业余玩的)可能手边现在不会有合适的设备来进行测试,所以很多东西其实没有办法验证。不过对于 Home Kit,Apple给我们提供了一个模拟器来模拟智能家电设备,您可以在 Xcode 6 的 Open Developer Tool 菜单中找到 Home Kit Accessory Simulator。使用模拟器可以发现,添加并且控制自定义的智能家电,用来前期开发还是蛮方便的。 如果能入手一些适配于 Health Kit 或者 Home Kit 的设备的话,我可能会补充一些关于这方面的开发心得。 ## 游戏方面 最大的改变莫过于 Scene Kit 的加入了。不过游戏天生的容易跨平台的特性 (并且也有这方面的强烈需求),与平台限制的 Sprite Kit 是冲突的,所以去年的 Sprite Kit 也还没多少人用。暂时看来这个世界现在是,并且在一段时间内还会是被 Cocos2dx/Unity 所统治的。Scene Kit 的未来估计会和 Sprite Kit 比较类似,作为对于一直进行 iOS 应用开发的开发者来说,有着不需要学习和熟悉新语言的优势,容易与系统的其他框架进行集成,所以用来转型还算不错的选择。但除此之外其他方面可能也并没有多少可以吸引人的地方了。 另一个重大改变是对于 A7 和以上级别的 GPU 推出了一套全新的称为 Metal 的绘制 API,从 Keynote 的 Zen Garden 的演示来看,Metal 的性能毋庸置疑是令人折服的,Metal 的渲染方式和着色器也相当有趣。但是其实这些内容更多地是偏向底层以及面向引擎开发的,对于使用游戏引擎来制作游戏的大多数开发者来说,并不需要知道或者理解其中的东西。在 A7 的芯片下使用 Apple 自家的 Sprite Kit 或者 Scene Kit 的话,就可以直接受益于 Metal,而其他一些知名的第三方引擎,比如 Unity 和 UE 也都会在 iOS 8 推出后支持 Metal。因此,作为引擎使用者,并不需要做出除了升级开发使用的游戏引擎之外的任何改变。 ## 其他重要改动 ### Local 和 Remote 通知的变化 现在需要显示 UI 或者播放声音的通知,包括 Local 通知也需要实现弹窗获得用户许可了。使用 `-registerUserNotificationSettings:` 来向用户获取许可。作为补偿,现在对于不需要打扰用户(也就是 iOS 7 加入的静默通知)的类型不再需要弹框获取用户许可。不过因为本地推送是需要许可的,所以无论怎样如果你想要依靠通知来提高用户留存率的话,现在都绕不开用户许可了。 另外,通知中心加入了非常方便的 Action 特性,用户可以在收到通知后,在不打开应用的情况下完成一些操作。可以说配合通知中心的 Today 扩展,用户现在在很可能可以在不打开应用的情况下就获取到他们想要的信息,并完成互动。这对于开发者可以说是一件喜忧参半的事情,一方面我们可以给用户提供更好更快的使用体验,但是另一方面这将降低用户打开应用的意愿。不过 Apple 现在的总体思路还是 app 的体验才是最重要的,所以正确的道路应该还是优先做好 app 的体验,并且摸索一个应用和通知之间的平衡点,让大家都满意。 > 专题相关笔记 > > 通知中心和应用使用重心的改变 ### CoreLocation CoreLocation 室内定位。现在 CL 可以给出在建筑物中的楼层定位信息了,直接访问 `CLLocation` 实例的 `floor`,如果当前位置可用的话,会返回一个包含位置信息的非 nil 的 `CLFloor` 以标识当前楼层。这个使得定位应用的可能性大大扩展了,想象一下在复杂的地铁站或者大厦里迷路的时候,还可以依赖定位系统,幸福感涌上心头啊。 ### Touch ID Touch ID API,说是开放了 Touch ID 的验证,但是实际上能做的事情还是比较有限。因为现在提供的 API 只能验证用户是不是手机主人本人,而不能给出一个识别的标志或者唯一编码,所以想用 Touch ID 做注册登陆什么的话可能还是不太现实。不过在进行支付验证之类的已登录后的再次确认操作时就比较好用。现在看来的话这组 API 就是为了简化像 Paypal 或者支付宝这样的第三方支付和确认的流程的。希望之后能继续放开,如果能给一个唯一标识的话,也许就可以干掉整个讨厌的注册和登陆系统了。 ### 相机和照片 新增加了 Photos.framework 框架,这个框架用于与系统内置的 Photo 应用进行交互,不仅可以替代原来的 Assets Library 作为照片和视频的选取,还能与 iCloud 照片流进行交互。除此之外,一个很重要的特性是还可以监听其他应用对于照片的改变,可以说整个框架非常灵活。 URL: https://onevcat.com/2014/06/walk-in-swift/index.html.md Published At: 2014-06-07 18:23:44 +0900 # 行走于 Swift 的世界中 > 2014 年 7 月 13 日更新:根据 beta 3 的情况修正了文中过时的部分 从周一 Swift 正式公布,到现在周五,这几天其实基本一直在关注和摸索 Swift 了。对于一门新语言来说,开荒阶段的探索自然是激动人心的,但是很多时候资料的缺失和细节的隐藏也让人着实苦恼。这一周,特别是最近几天的感受是,Swift 并不像我[上一篇表达自己初步看法的文章](http://onevcat.com/2014/06/my-opinion-about-swift/)里所说的那样,相对于 objc 来说有更好的学习曲线。甚至可以说 objc 在除了语法上比较特别以外,其概念还是比较容易的。而 Swift 在漂亮的语法之后其实隐藏了很多细节和实现,而如果无法理解这些细节和实现,就很难明白这门新语言在设计上的考虑。在实际编码中,也会有各种各样的为什么编译不通过,为什么运行时出错这样的问题。本文意在总结一下这几天看 Swift 时候遇到的自己觉得重要的一些概念,并重新整理一些对这门语言的想法。可能有些内容是需要您了解 Swift 的基本概念的,所以这并不是一篇教你怎么写 Swift 或者入门的文章,建议您先读读 Apple 官方给出的 Swift 的[电子书](https://itunes.apple.com/us/book/the-swift-programming-language/id881256329?mt=11),至少将第一章的 Tour 部分读完(这里也有质量很不错的但是暂时还没有完全翻译完成的[中文版本](http://numbbbbb.github.io/the-swift-programming-language-in-chinese/))。不过因为自己也才接触一周不到,肯定说不上深入,还希望大家一起探讨。 ### 类型?什么是类型? 这是一个基础的问题,类型 (Types) 在 Swift 中是非常重要的概念,在 Swift 中类型是用来描述和定义一组数据的有效值,以及指导它们如何进行操作的一个蓝图。这个概念和其他编程语言中“类”的概念很相似。Swift 的类型分为命名类型和复合类型两种;命名类型比较简单,就是我们日常用的 `类 (class)`,`结构体 (struct)`,`枚举 (enum)` 以及`接口 (protocol)`。在 Swift 中,这四种命名类型为我们定义了所有的基本结构,它们都可以有自己的成员变量和方法,这和其他一般的语言是不太一样的(比如很少有语言的enum可以有方法,protocol可以有变量)。另外一种类型是复合类型,包括`函数 (func)` 和 `多元组 (tuple)`。它们在使用的时候不会被命名,而是由 Swift 内部自己定义。 我们在实际做开发时,一般会接触很多的命名类型。在 Swift 的世界中,一切看得到的东西,都一定属于某一种类型。在 PlayGround 或者是项目中,通过在某个实际的被命名的类型上 `Cmd + 单击`,我们就能看到它的定义。比如在 Swift 世界中的所有基本型 `Int`,`String`,`Array`,`Dictionay` 等等,其实它们都是结构体。而这些基本类型通过定义本身,以及众多的 `extension`,实现了很多接口,共同提供了基本功能。这也正是 Swift 的类型的一种很常见的组织方式。 而相对的,Cocoa 框架中的类,基本都被映射为了 Swift 的 `class`。如果你有比较深厚的 objc 功底的话,应该会听说过 objc 的类其实是一组包含了元数据 (metadata) 的结构体,而在 objc 中我们可以使用 `+class` 来拿到某个 Class 的 isa,从而确定类的组成和描述。而在 Swift 的 native 层面上,在 type safe 的基础上,不再需要 isa 来指导对象如何构建,而这个过程会通过确定的命名类型完成。正因为这个原因,Swift 中干脆把 NSObject 的 `class` 方法都拿掉,因为 Swift 和 ObjC 在这个根本问题上的分歧,最终导致了在使用 Swift 调用 Cocoa 框架时的各种麻烦和问题。 ### 参照和值,Array和Dictionary背后的一些故事 > 2014 年 7 月 13 日更新 > 由于 beta 3 中 `Array` 被完全重写,这一节关于 `Array` 的一些行为和表述完全过时了。 > 关于 `Array` 的用法现在简化了很多,请参见新加的 “真 参照和值,Array和Dictionary背后的一些故事” 如果你坚持看到了这里,那么恭喜你...本文最无趣和枯燥的部分已经结束了(同时也应该吓走了不少抱着玩玩看的心态来看待 Swift 的读者吧..笑),那么开始说一些细节的东西吧。 首先要明白的概念是,参照和值。在 C 系语言里摸爬滚打过的同学都知道,我们在调用一个函数的时候,往里传的参数有两种可能。一种是传递类似一个数字或者结构体这样的基本元素,这时候这个整数的值会被在内存中复制一份然后传到函数内部;另一种情况是传递一个对象,为了性能和内存上的考虑,这时候一般不会去将对象的内容复制一遍,而是会传递的一个指向同一块内存的指针。 在 Swift 中一个与其他语言都不太一样的地方是,它的 Collection 类型,也就是 `Array` 和 `Dictionary`,并不是 `class` 类型,而是 `struct` 结构体。那么按照我们以往的经验,在传值或者赋值的时候应该是会复制一份。我们来试试看是不是这样的~ ```swift var dic = [0:0, 1:0, 2:0] var newDic = dic //Check dic and newDic dic[0] = 1 dic //[0: 1, 1: 0, 2: 0] newDic //[0: 0, 1: 0, 2: 0] var arr = [0,0,0] var newArr = arr arr[0] = 1 //Check arr and newArr arr //[1, 0, 0] newArr //[1, 0, 0] ``` `Dictionary` 的值没有问题,我们改变了 `dic` 中的值,但是 `newDic` 保持了原来的值,说明 `newDic` 确实被复制了一份。而当我们检查到 `Array` 的时候,发生了一点神奇的事情。虽然 `Array` 是 `struct`,但是当我们改变 `arr` 时,新的 `newArr` 也发生了改变,也就是说,`arr` 和 `newArr` 其实是同一个参照。这里的原因其实在 Apple 的[官方文档](https://developer.apple.com/library/prerelease/ios/documentation/swift/conceptual/swift_programming_language/ClassesAndStructures.html)中有一些说明。Swift 考虑到实际使用的情景,对 `Array` 做了特殊的处理。除非需要(比如 `Array` 的大小发生改变,或者显式地要求进行复制),否则 `Array` 在传递的时候会使用参照。 在这里如果你想要只改变 `arr` 的值,而保持新赋予的 `newArr` 不变的话,你需要显式地对 `arr` 进行 `copy()`,像下面这样。 ```swift var arr = [0,0,0] var copiedArr = arr.copy() arr[0] = 1 arr //[1, 0, 0] copiedArr //[0, 0, 0] ``` 这时候 `arr` 和 `copiedArr` 将指向不同的内存地址,对原来的数组重新赋值的时候,就不会再影响新的数组了。另一种等效的做法是通过 `Array` 的初始化方法建立一个新的 `Array`: ```swift var arr = [0,0,0] var newArr = Array(arr) arr[0] = 1 arr //[1, 0, 0] newArr //[0, 0, 0] ``` 值得一提的是,对于 `Array` 这个 `struct` 的这种特殊行为,Apple 还准备了另一个函数 `unshare()` 给我们使用。`unshare()` 的作用是如果对象数组不是唯一参照,则复制一份,并将作用的参照指向新的地址(这样它就变成唯一参照,不会意外改变原来的别的同样的参照了);而如果这个参照已经是唯一参照了的话,就什么都不做。 ```swift var arr = [0,0,0] var newArr = arr //Breakpoint 1 arr.unshare() //Breakpoint 2 arr[0] = 1 arr //[1, 0, 0] newArr //[0, 0, 0] ``` 这个设计的意图是为了更安全地使用这个优化过的行为奇怪的数组结构体。关于 `unshare()` 的行为,我们也可以通过使用 LLDB 断点来观察内存地址的变化。参见下图: ![unshare array in swift](/assets/images/2014/swift_unshare_array.png) 另外一个要加以注意的是,`Array` 在 copy 时执行的不是深拷贝,所以 `Array` 中的参照类型在拷贝之后仍然会是参照。Array 中嵌套 Array 的情况亦是如此:对一个 Array 进行的 copy 只会将被拷贝的 `Array` 指向新的地址,而保持其中所有其他 `Array` 的引用。当然你可以为 `Array` (或者准确说是 Array)写一个递归的深拷贝扩展,但这是另外一个故事了。 ### 真 参照和值,Array和Dictionary背后的一些故事 > 2014 年 7 月 13 日更新 Apple 在 beta 3 里重写了 `Array`,它的行为简化了许多。首先 `copy` 和 `unshare` 两个方法被删掉了,而类似的行为现在以更合理的方式在幕后帮我们完成了。还是举上面的那个例子: ```swift var dic = [0:0, 1:0, 2:0] var newDic = dic //Check dic and newDic dic[0] = 1 dic //[0: 1, 1: 0, 2: 0] newDic //[0: 0, 1: 0, 2: 0] var arr = [0,0,0] var newArr = arr arr[0] = 1 //Check arr and newArr arr //[1, 0, 0] newArr //before beta3:[1, 0, 0], after beta3:[0, 0, 0] ``` `Dictionary` 当然还是 OK,但是对于 `Array` 中元素的改变,在 beta 3 中发生了变化。现在不再存在作为一个值类型但是却在赋值和改变时表现为参照类型的 `Array` 的特例,而是彻头彻尾表现出了值类型的特点。这个改变避免了原来需要小心翼翼地对 `Array` 进行 `copy` 或者 `unshare` 这样的操作,而 Apple 也承诺在性能上没有问题。文档中提到其实现在的行为和之前是一贯的,只不过对于数组的复制工作现在是在背后由 Apple 只在必要的时候才去做。所以可以猜测其实在背后 `Array` 和 `Dictionary` 的行为并不是像其他 struct 那样简单的在栈上分配,而是类似参照那样,通过栈上指向堆上位置的指针来实现的。而对于它的复制操作,也是在相对空间较为宽裕的堆上来完成的。当然,现在还无法(或者说很难)拿到最后的汇编码,所以这只是一个猜测而已。最后如果能够证实对错的话,我会再进行更新。 总之,beta 3 之后,原来飘忽不定难以捉摸(其实真正理解之后还是很稳定的,也很适合出笔试题)的 `Array` 现在彻底简单化了。基本只需要记住它的行为在表面上和其他的值类型完全无异,而性能方面的考量可以交给 Apple 来做。 ### Array vs Slice 因为 `Array` 类型实在太重要了,因此不得不再多说两句。查看 `Array` 在 Swift 中的定义,我们可以发现其实 `Array` 实现了两个很重要的接口 `MutableCollection` 和 `Sliceable`。第一个接口比较简单,为 `Array` 实现了下标等特性,通过 `Collection` 通用的一些概念,可以从数据结构中获取元素,比较简单。而第二个接口 `Sliceable` 实现了通过 Range 来取出部分数组,这里稍微有点特殊。 Swift 引入了在其他很多语言中很流行的用 `..` 和 `...` (beta3 中 `..` 被改成了 `..<`,虽说是为了更明确的意义,但是看起来会比较奇怪)来表示 Range 的概念。从一个数组里面取出一个子数组其实是蛮普遍的一个需求,但是如果你足够细心的话,可能会发现我们无法写这样的代码: ```swift var arr = [0,0,0] var partOfArr: Array = arr[0...1] //Could not find an overload for 'subscript' that accepts the supplied arguments ``` 你会得到一个编译错误,告诉你没有重载下标。在我们去掉我们强制加上的 `: Array` 类型设置之后,编译能通过了。这就告诉我们,我们使用 Rang 从 Array 中取出来的东西,并不是 `Array` 类型。那它到底是个什么东西?使用 REPL 可以很容易看到,在使用 Range 从 Array 里取出来的其实是一个 `Slice`,而不是一个 `Array`。 ```swift 1> var arr = [0,0,0] arr: Int[] = size=3 { [0] = 0 [1] = 0 [2] = 0 } 2> var slice = arr[0...1] slice: Slice = size=2 { [0] = 0 [1] = 0 } ``` So, what is a slice?查看 `Slice` 的定义,可以看到它几乎和 `Array` 一模一样,实现了同样的接口,拥有同样的成员,那么为什么不直接干脆给个爽快,而要新弄一个 `Slice` 呢?Apple gets crazy?当然不是..Slice的存在当然有其自己的价值和含义,而这和我们刚才提到的值和引用有一些关系。 So, why is a slice?让我们先尝试 play with it。接着上面的情况,运行下面的代码试试看: ```swift var arr : Array = [0,0,0] var slice = arr[0...1] arr[0] = 1 arr //[1, 0, 0] slice //[1, 0] slice[1] = 2 arr //[1, 2, 0] slice //[1, 2] ``` 我想你已经明白一些什么了吧?这里的 `slice` 和 `arr` 当然不可能是同一个引用(它们的类型都不一样),但是很有趣的是,通过 Range 拿到的 `Slice` 中的元素,是指向原来的 `Array` 的。这个特性就非常有趣了,我们可以对感兴趣的数组片段进行观察或者操作,并且它们的值和原来的数组是对应的同步的。 理所当然的,在对应着的 `Array` 或者 `Slice` 其中任意一个的内存指向发生变化时(比如添加或移除了元素,重新赋值等等),这种关系就会被打破。 对于 `Slice` 和 `Array`,其实是可以比较简单地转换的。因为 `Collection` 接口是实现了 `+` 重载的,于是我们可以简单地通过相加来生成一个 `Array` (如果我们愿意的话)。不过,要是真的有需要的话,使用 `Array` 的初始化方法会是比较好的选择: ```swift var arr : Array = [0,0,0] var slice = arr[0...1] var result1 : Array = [] + slice var result2 : Array = Array(slice) ``` 使用 Range 下标的方式,不仅可以取到这个 Range 内的 `Slice`,还可以对原来的数组进行批量"赋值": ```swift var arr : Array = [0,0,0] arr[0...1] = [1,1] arr //[1, 1, 0] ``` 细心的同学可能注意到了,这里我把“赋值”打上了双引号。实际上这里做的是替换,数组的内存已经发生了变化。因为 Swift 没有强制要求替换的时候 Range 的范围要和用来替换的 `Collection` 的元素个数一致,所以其实这里一定会涉及内存的分配和新的数组生成。我们可以看看下面的例子: ```swift var arr : Array = [0,0,0] var otherArr = arr arr[0...1] = [1,1] arr //[1, 1, 0] otherArr //[0, 0, 0] arr[0..1] = [1,1] arr //[1, 1, 1, 0] ``` 给一个数组进行 Range 赋值,背后其实调用了数组的 `replaceRange` 方法,将取到的 `Slice`,替换成了赋给它的 `Array` 或者 `Slice`。而只要 Range 有效,我们就可以很灵活地写出类似这样的所谓的插入方法: ```swift var arr : Array = [0,0,0] arr[1..1] = [1, 1] arr //[0, 1, 1, 0, 0] ``` 这里的 `1..1` 是一个起点为 1,长度为 0 的Range,于是它取到的是原来 `[0, 0, 0]` 中 index 为 1 的位置的一个空 `Slice`,将其替换为 `[1, 1]`。清楚明白。 既然都提到了这么多次 `Range`,还是需要说明一下这个 Swift 里很重要的概念(其实在 objc 里 `NSRange` 也很重要,只不过没有像 Swift 里这么普遍)。`Range` 结构体中有两个非常重要的值,`startIndex` 和 `endIndex`,它表示了这个 Range 的范围。而这个值永远是右开的,也就是说,它们会和 `x..y` 这样的表示中 `x` 和 `y` 分别相等。对于 `x < y` 的情况下的 Range,是存在数学上的表达意义的,比如 `2..1` 这样的 Range 表示从 2 开始往前数 1。但是在实际从 `Array` 或者 `Slice` 中取值时这种表达是没有意义,并且会抛出一个运行时的 EXC_BAD_INSTRUCTION 的,在使用的时候还要加以注意。 ### 颜文字很好,但是... 有了上面的一些基础,我们可以来谈谈 `String` 了。当说到我们可以在原生的 `String` 中使用 UniCode 字符时,全场一片欢呼。没错,以后我们可以把代码写成这样了! ```swift let π = 3.14159 let 你好 = "你好世界" let 🐶🐮 = "🐶🐮” ``` Cool...虽然我不认为有多少人会去把变量名弄成中文或者猫猫狗狗,但是毕竟字符串本身还是需要支持中文日文阿拉伯文甚至 emoji 的对吧。 另外一个很赞的是,Apple 把所有 `NSString` 的方法都“移植”到了 `String` 类型上,而且将 Cocoa 框架中所有涉及 `NSString` 的地方都换成了 `String`。这是一件很棒的事情,这意味着我们可以无缝在 Swift 上像原来写 objc 时候那样使用 `String`,而不必担心 `String` 和 `NSString` 之间类型转换等麻烦的问题。可能看过 session 或者细读了文档的同学会发现,新的 `String` 里,没有了原来的 `-length` 方法,取而代之,Apple 推荐我们使用 `countElements` 来获取字符串的长度。这是很 make sense 的一件事情,因为我们无法确定字符串中每个字符的字节长度,所以 Apple 为了帮助我们方便计算字符数,给了这个 O(N) 的方法。 这样的字符串带来了一个挺不方便的结果,那就是我们无法直接通过 `Int` 的下标来访问 `String` 中的字符。我们查看 Swift 中 `String` 的定义,可以看到它其实是实现了 `subscript (i: String.Index) -> Character { get }` 的(其 Range 访问也相应需要一个 `Range` 版本的泛型)。如果我们能知道字符对应的 `String.Index`,我们就可以写出方便的下标访问了。举个例子,如果有下面这样的两个 `String`。 ```swift var str = "1234" var imageStr = "🐶🐱🐭🐰" ``` 我们现在想要通过拿到上面那个 ASCII 字符串的某个数字所在的 `String.Index`,来获取下面对应位置的图标,比如 2 对应猫,应该如何做呢?一开始大概很容易想到这样的代码: ```swift var range = str.rangeOfString("2") //记得导入 Cocoa 或者 UIKit imageStr[range] //EXC_BAD_INSTRUCTION ``` 很不幸,EXC_BAD_INSTRUCTION,这表示 Swift 中有一个 Assertion 阻止了我们继续。其实 `String.Index` 和一般的 `Int` 之类的 index 不太一样,因为每一个 Index 代表的字节的长度是有差别的,所以它只能实现 `BidirectionalIndex`,而不能像其他的等长结构那样实现 `RandomAccessIndex` 接口(关于这两个接口分别是什么已经做了什么,留给大家自己研究下吧)。于是,在不同字符串之间的 Index 进行转换时,我们大概不得不使用一种笨办法,那就是计算步长和差值。对于我们的例子,我们会先算出在 `str` 中 2 与初始 Index 的距离,然后讲这个距离在 `imageStr` 中以 `imageStr` 的 String.Index 进行套用,算出适合其的第二个字符的 Range,然后进行 Range 的下标访问,如下: ```swift var range = str.rangeOfString("2") var aDistance: Int = distance(str.startIndex, range.startIndex) var imageStrStartIndex = advance(imageStr.startIndex, aDistance) var range2 = imageStrStartIndex..imageStrStartIndex.successor() var substring: String = imageStr[range2] ``` ![Happy Cat](/assets/images/2014/swift_substring-compressor.png) 大部分时候其实我们不会这么来映射字符串,不过对于 Swift 字符串的实现和与 NSString 的差异,还是值得研究一番的。 ### 幽灵一般的 Optional Swift 引入的最不一样的可能就是 Optional Value 了。在声明时,我们可以通过在类型后面加一个 `?` 来将变量声明为 Optional 的。如果不是 Optional 的变量,那么它就必须有值。而如果没有值的话,我们使用 Optional 并且将它设置为 `nil` 来表示没有值。 ```swift //num 不是一个 Int var num: Int? //num 没有值 num = nil //nil //num 有值 num = 3 //{Some 3} ``` Apple 在 Session 上告诉我们,Optinal Value 其实就是一个盒子,你盒子里可能装着实际的值,也可能什么都没装。 我们看到 Session 里或者文档里天天说 Optional Optional,但是我们在代码里基本一个 Optional 都没有看到,这是为什么呢?而且,上面代码中给 `num` 赋值为 3 的时候的那个输出为什么看起来有点奇怪?其实,在声明类型时的这个 `?` 仅仅只是 Apple 为了简化写法而提供的一个语法糖。实际上我们是有 Optional 类型的声明,就这里的 `num` 为例,最正规的写法应该是这样的: ```swift //真 Optional 声明和使用 var num: Optional num = Optional() num = Optional(3) ``` 没错,`num` 不是 `Int` 类型,它是一个 `Optional` 类型。到底什么是 `Optional` 呢,点进去看看: ```swift enum Optional : LogicValue, Reflectable { case None case Some(T) init() init(_ some: T) /// Allow use in a Boolean context. func getLogicValue() -> Bool /// Haskell's fmap, which was mis-named func map(f: (T) -> U) -> U? func getMirror() -> Mirror } ``` 你也许会大吃一惊。我们每天和 Swift 打交道用的 Optional 居然是一个泛型枚举 `enum`,而其实我们在使用这个枚举时,如果没有值,我们就规定这个枚举的是 `.None`,如果有,那么它就是 `Some(value)`(带值枚举这里不展开了,有不明白的话请看文档吧)。而这个枚举又恰好实现了 `LogicValue` 接口,这也就是为什么我们能使用 `if` 来对一个 Optinal 的值进行判断并进一步进行 unwrap 的依据。 ```swift var num: Optional = 3 if num { //因为有 LogicValue, //.None 时 getLogicValue() 返回 false //.Some 时返回 true var realInt = num! realInt //3 } ``` 既然 `var num: Int? = nil` 其实给 `num` 赋的值是一个枚举的话,那这个 `nil` 到底又是什么?它被赋值到哪里去了?一直注意的是,Swift 里的 nil 和 objc 里的 nil 完全不是一回事儿。objc 的 nil 是一个实实在在的指针,它指向一个空的对象。而这里的 nil 虽然代表空,但它只是一个语意上的概念,确是有实际的类型的,看看 Swift 的 `nil` 到底是什么吧: ```swift /// A null sentinel value. var nil: NilType { get } ``` `nil` 其实只是 `NilType` 的一个变量,而且这个变量是一个 getter。Swift 给了我们一个文档注释,告诉我们 `nil` 其实只是一个 null 的标记值。实际上我们在声明或者赋值一个 Optional 的变量时,`?` 语法糖做的事情就是声明一个 `Optional`,然后查看等号右边是不是 nil 这个标记值。如果不是,则使用 `init(_ some: T)` 用等号右边的类型 T 的值生成一个 `.Some` 枚举并赋值给这个 Optional 变量;如果是 nil,将其赋为 None 枚举。 所以说,Optional背后的故事,其实被这个小小的 `?` 隐藏了。 我想,Optional 讨论到这里就差不多了,还有三个小问题需要说明。 首先,`NilType` 这个类型非常特殊,它似乎是个 built in 的类型,我现在没有拿到关于它的任何资料。我本身逆向是个小白,现在看起来 Swift 的逆向难度也比较大,所以关于 `NilType` 的一些行为还是只能猜测。而关于 `nil` 这一 `NilType` 的类型的变量来说,猜测的话,它可能是 `Optional.None` 的一种类似多型表现,因为首先它确实是指向 0x0 的,并且与 Optional.None 的 content 的内容指向一致。但是具体细节还要等待挖掘或者公布了。 > 2014 年 7 月 13 日更新 > 从 beta3 开始 `nil` 是一个编译关键字了,`NilType` 则被从 Swift 中移除了。这个改变解决了上面提到的很多悬而未决的问题,比如对 nil 的多次封装以及如何实现自己的可 nil 的类等等。现在添加了一个叫做 `NilLiteralConvertible` 的接口来使某个类可以使用 nil 语法,而避免了原来的让人费解的隐式转换。但是现在还有一个问题,那就是 Optional 是实现了 `LogicValue` 接口的,这就是得像 `BOOL?` 这样的类型在使用的时候会一不小心就很危险。 其次,Apple 推荐我们在 unwrap 的时候使用一种所谓的隐式方法,即下面这种方式来 unwrap: ```swift var num: Int? = 3 if let n = num { //have a num } else { //no num } ``` 最后,这样隐式调用足够安全,性能上似乎应该也做优化(有点忘了..似乎说过),推荐在 unwrap 的时候尽可能写这样的推断,而减少直接进行 unwrap 这种行为。 最后一个问题是 Optional 的变量也可以是 Optinal。因为 Optional 就相当于一个黑盒子,可以知道盒子里有没有东西 (通过 LogicValue),也可以打开这个盒子 (unwrap) 来拿到里面的东西 (你要的类型的变量或者代表没有东西的 nil)。请注意,这里没有任何规则限制一个 Optional 的量不能再次被 Optional,比如下面这种情况是完全 OK 的: ```swift var str: String? = "Hi" //.Some("Hi") var anotherStr: String?? = str //.Some(.Some("Hi")) ``` 这其实是没有多少疑问的,很完美的两层 Optional,使用的时候也一层层解开就好。但是如果是 nil 的话,在这里就有点尴尬... ```swift var str: String? = nil var anotherStr: String?? = nil ``` 因为我们在 LLDB 里输出的时候,得到了两个 nil ![two nils](/assets/images/2014/lldb_optional-compressor.png) 如果说 `str` 其实是 `Optional.None`,输出是 nil 的话还可以理解,但是我们知道 (好吧,如果你认真读了上面的 Optional 的内容的话会知道),`anotherStr` 其实是 `Optional>.Some(Optional.None)`,这是其实一个有效的非空 `Optional`,至少第一层是。而如果放在 PlayGround 里,`anotherStr` 得到的输出又是正确的 `{nil}`。What hanppened? Another Apple bug? 答案是 No,这里不是 bug。为了方便观察,LLDB 会在输出的时候直接帮我们尽可能地做隐式的 unwrap,这也就导致了我们在 LLDB 中输出的值只剩了一个裸的 nil。如果想要看到 Optional 本身的值,可以在 Xcode 的 variable 观察窗口点右键,选中 `Show Raw values`,这样就能显示出 None 和 Some 了。或者我们可以直接使用 LLDB 的 `fr v -R` 命令来打印整个 raw 的值: ![LLDB-frv](/assets/images/2014/swift_print_raw-compressor.png) 可以清楚看到,`anotherStr` 是 `.Some` 包了一个 `.None`。 (这里有个自动 unwrap 的小疑问,就是写类似 `var anotherStr: String? = str` 这样的代码也能通过,应该是 `?` 语法在这里有个隐式解包,需要进一步确认) ### ? 那是什么??,! 原来如此!! 问号和叹号现在的用法都是原来 objc 中没有的概念。说起来简单也简单,但是背后也还是不少玄机。原来就已经存在的用法就不说了,这里把新用法从浅入深逐个总结一下吧。 首先是 `?`: * `?` 放在类型后面作为 Optional 类型的标记 这个用法上面已经说过,其实就是一个 `Optional` 的语法糖,自动将等号后面的内容 wrap 成 Optional。给个用例,不再多说: ```swift var num: Int? = nil //声明一个 Int 的 Optional,并将其设为啥都没有 var str: String? = "Hello" //声明一个 String 的 Optional,并给它一个字符串 ``` * `?` 放在某个 Optional 变量后面,表示对这个变量进行判断,并且隐式地 unwrap。比如说: ```swift foo?.somemethod() ``` 相比起一般的先判断再调用,类似这样的判断的好处是一旦判断为 `nil` 或者说是 `false`,语句便不再继续执行,而是直接返回一个 nil。上面的写法等价于 ```swift if let maybeFoo = foo { maybeFoo.somemethod() } ``` 这种写法更存在价值的地方在于可以链式调用,也就是所谓的 Optional Chaining,这样可以避免一大堆的条件分支,而使代码变得易读简洁。比如: ```swift if let upper = john.residence?.address?.buildingIdentifier()?.uppercaseString { println("John's uppercase building identifier is \(upper).") } ``` 注意最后 `buildingIdentifier` 后面的问号是在 `()` 之后的,这代表了这个 Optional 的判断对象是 `buildingIdentifier()` 的返回值。 * `?` 放在某个 optional 的 protocol 方法的括号前面,以表示询问是否可以对该方法调用 这中用法相当于以前 objc 中的 `-respondsToSelector:` 的判断,如果对象响应这个方法的话,则进行调用。例子: ```swift delegate?.questionViewControllerDidGetResult?(self, result) ``` 中的第二个问号。注意和上面在 `()` 后的问号不一样,这里是在 `()` 之前的,表示对方法的询问。 其实在 Swift 中,默认的 potocol 类型是没有 optional 的方法的,因为基于这个前提,可以对类型安全进行确保。但是 Cocoa 框架中的 protocol 还是有很多 optional 的方法,对于这些可选的接口方法,或者你想要声明一个带有可选方法的接口时,必须要在声明 `protocol` 时再其前面加上 `@objc` 关键字,并在可选方法前面加上 `@optional`: ```swift @objc protocol CounterDataSource { @optional func optionalMethod() -> Int func requiredMethod() -> Int @optional var optionalGetter: Int { get } } ``` 然后是 `!` 新用法的总结 * `!` 放在 Optional 变量的后面,表示强制的 unwrap 转换: ```swift foo!.somemethod() ``` 这将会使一个 `Optional` 的量被转换为 `T`。但是需要特别注意,如果这个 Optional 的量是 nil 的话,这种转换会在运行时让程序崩溃。所以在直接写 `!` 转换的时候一定要非常注意,只有在有必死决心和十足把握时才做 `!` 强转。如果待转换量有可能是 nil 的话,我们最好使用 `if let` 的语法来做一个判断和隐式转换,保证安全。 * `!` 放在类型后面,表示强制的隐式转换。 这种情况下和 `?` 放在类型后面的行为比较类似,都是一个类型声明的语法糖。`?` 声明的是 `Optional`,而 `!` 其实声明的是一个 `ImplicitlyUnwrappedOptional` 类型。首先需要明确的是,这个类型是一个 `struct`,其中关键部分是一个 `Optional` 的 value,和一组从这个 value 里取值的 getter 和 方法: ```swift struct ImplicitlyUnwrappedOptional : LogicValue, Reflectable { var value: T? //... static var None: T! { get } static func Some(value: T) -> T! //... } ``` 从外界来看,其实这和 `Optional` 的变量是类似的,有 `Some` 有 `None`。其实从本质上来说,`ImplicitlyUnwrappedOptional` 就是一个存储了 `Optional`,实现了 `Optional` 对外的方法特性的一个类型,唯一不同的是,`Optional` 需要我们手动进行进行 unwrap (不管是使用 `var!` 还是 `let if` 赋值,总要我们做点什么),而 `ImplicitlyUnwrappedOptional` 则会在使用的时候自动地去 unwrap,并对继续之后的操作调用,而不必去增加一次手动的显示/隐式操作。 为什么要这么设计呢?主要是基于 objc 的 Cocoa 框架的两点考虑和妥协。 首先是 objc 中是有指向空对象的指针的,就是我们所习惯的 `nil`。在 Swift 中,为了处理和 objc 的 nil 的兼容,我们需要一个可为空的量。而因为 Swift 的目的就是打造一个完全类型安全的语言,因此不仅对于 class,对于其他的类型结构我们也需要类型安全。于是很自然地,我们可以使用 Optional 的空来对 objc 做等效。因为 Cocoa 框架有大量的 API 都会返回 nil,因此我们在用 Swift 表达它们的时候,也需要换成对应的既可以表示存在,也可以表示不存在的 `Optional`。 那这样的话,不是直接用 `Optional` 就好了么?为什么要弄出一个 `ImplicitlyUnwrappedOptional` 呢?因为易用性。如果全部用 `Optional` 包装的话,在调用很多 API 时我们就都需要转来转去,十分麻烦。而对于 `ImplicitlyUnwrappedOptional` 因为编译器为我们进行了很多处理,使得我们在确信返回值或者要传递的值不是空的时候,可以很方便的不需要做任何转换,直接使用。但是对于那些 Cocoa 有可能返回 nil,我们本来就需要检查的方法,我们还是应该写 if 来进行转换和检查。 比如说,以下的写法就会在运行时导致一个 EXC_BAD_INSTRUCTION ```swift let formatter = NSDateFormatter() let now = formatter.dateFromString("not_valid") let soon = now.dateByAddingTimeInterval(5.0) // EXC_BAD_INSTRUCTION ``` 因为 `dateFromString` 返回的是一个 `NSDate!`,而我们的输入在原来会导致一个 `nil` 的返回,这里我们在使用 now 之前需要进行检查: ```swift let formatter = NSDateFormatter() let now = formatter.dateFromString("not_valid") if let realNow = now { realNow.dateByAddingTimeInterval(5.0) } else { println("Bad Date") } ``` 这和以前在 objc 时代做的事情差不多,或者,用更 Swift 的方式做 ```swift let formatter = NSDateFormatter() let now = formatter.dateFromString("not_valid") let soon = now?.dateByAddingTimeInterval(5.0) ``` ### 如何写出正确的 Swift 代码 现在距离 Swift 发布已经接近小一周了。很多开发者已经开始尝试用 Swift 写项目。但是不管是作为练习还是作为真正的工程,现在看来大家在写 Swift 时还是带了浓重的 objc 的影子。就如何写出带有 Swift 范儿的代码,在这里给出一点不成熟的小建议。 1. 理解 Swift 的类型组织结构。Swift 的基础组织非常漂亮,主要的基础类型大部分使用了 `struct` 来完成,然后在之上定义并且实现了各种接口,这样的设计模式其实是值得学习和借鉴的。当然,在实际操作中可能会有很大难度,因为接口比之前灵活许多,可以继承,可以放变量等等,因此在定义接口时如何保持接口的单一性和扩展性是一个不小的考验。 2. 善用泛型。很多时候 Swift 的 Generic 并不是显式的,类型推断帮助我们做了很多的事情,因此 Generic 这个概念可能被忽视的比较多。关于泛型这个强大的工具,因为原来 objc 中是没有的,而泛型的一个代表语言 C# 虽然平时有写,但很多时候只是当作类型安全的保证在用,我自己也没有太多心得。但是在日常开发中还是多思考和总结,相信会很有进步。 3. 尽快养成符合 Swift 的语法和习惯,比如 `if let`,比如对常量习惯性地用 `let` 而不要用 `var`,在上下文明确的时候省掉原来习惯写的 `self`,枚举只使用 `.`,合适地使用 `_` 这样的符号来增加可读性等等。既然写 Swift,就应该入乡随俗,尊重这门语言的规范,这样不管在之后和别人的讨论交流上,还是自我的长期发展上,都会很有帮助。 4. 安心等 Apple 进一步完善。现在 Swift 还处在相对很早期的阶段,很多东西虽然已经基本定型了,但是也有不少可塑性。编译器和调试器现在感觉还不太好用(当然,因为还在 beta,也不是说责怪什么),而且对于原来基于 objc 写的 Cocoa 框架还是有很多水土不服的地方。我个人来说,现在的水平使用 Swift 写还凑合 app 这样的级别应该问题不大,在这篇文章之后我暂时不会再进一步深挖 Swift,而是打算等待正式版出来之后再看情况使用。现在 Swift 仅在 `String` 上可以和 Cocoa 框架完美对接,而对于像 `Array` 这样的类型,虽然通过一些巧妙的方式完成了桥接,但是在实际使用上可能还是需要借助大量的 `NSArray`,在转换上略显麻烦。按照现在来看,Apple 应该至少会将 Cocoa 框架另外几个重要的类迅速适配 Swift 的语言习惯,如果能找到 一种很方便地使用 Cocoa 框架的方法的话,objc 程序员转型 Swift 就应该相对容易一些了。 洋洋洒洒不小心写了这么多(其实我还删了两节..因为写不动了),希望能对您学习和深入了解 Swift 有所帮助吧。因为很晚了,我没有仔细校对,文中肯定有不少错误(技术上和文字上的),欢迎您指出,我会尽快改正。 URL: https://onevcat.com/2014/06/my-opinion-about-swift/index.html.md Published At: 2014-06-03 12:05:44 +0900 # 关于 Swift 的一点初步看法 虽然四点半就起床去排队等入场,结果还是只能坐在了蛮后面的位置看着大屏幕参加了今年的 Keynote。其实今年 OS X 和 iOS 的更新亮点都不少,但是显然风头和光芒都让横空出世的 Swift 给抢走了。这部分内容因为不是 NDA,所以可以提前说一说。 Swift 是 Apple 自创的一门专门为 Cocoa 和 CocoaTouch 设计的语言,意在用来替代 objc。早上发布的时候有很多朋友说其实他们已经写了很久的 Swift,而且还给了一个[网站](http://swift-lang.org),在这里首先需要说明的是,这个网站的 *Swift parallel scripting language* 和 Apple 的 [Swift](https://developer.apple.com/swift/) 并不是一个东西,两者可以说毫无关系。Apple 还在自己的 Swift 介绍页面后面很友好地放上了 Swift parallel scripting language 的网站链接,以提示那些真的想搜另一个 Swift 却被 SEO 误导过来的可怜的孩子。 我个人来说,在把玩了 Swift 几个小时之后,深深地喜欢上了这门新的语言。这篇文章以一个初学者(其实现在大家都是初学者)的角度来对 Swift 做一个简单的介绍,因为现在大家其实是在同一个起跑线上,所以理解上可能会有很多不精确的地方,出错了也请大家轻喷指正! ## 什么是 Swift 很多人在看到 Swift 第一眼的感觉是,这丫是个脚本语言啊。因为在很多语法特性上 Swift 确实和一些脚本非常相似。但是首先需要明确的是,至少在 Apple 开发中,Swift 不是以一种脚本语言来运行的,所有的 Swift 代码都将被 LLVM 编译为 native code,以极高的效率运行。按照官方今天给出的 benchmark 数据,运行时比 Python 快 3.9 倍,比 objc 快 1.4 倍左右。我相信官方数据肯定是有些水分,但是即使这样,Swift 也给人带来很多遐想和期待。Swift 和原来的 objc 一样,是类型安全的语言,变量和方法都有明确的返回,并且变量在使用前需要进行初始化。而在语法方面,Swift 迁移到了业界公认的非常先进的语法体系,其中包含了闭包,多返回,泛型和大量的函数式编程的理念,函数也终于成为一等公民可以作为变量保存了(虽然具体实现和用法上来看和 js 那种传统意义的好像不太一样)。初步看下来语法上借鉴了很多 Ruby 的人性化的设计,但是借助于 Apple 自己手中 强大的 LLVM,性能上必须要甩开 Ruby 不止一两个量级。 另一方面,Swift 的代码又是可以 Interactive 来“解释”执行的。新的 Xcode 中加入了所谓的 Playground 来对开发者输入的 Swift 代码进行交互式的相应,开发者也可是使用 swift 的命令行工具来交互式地执行 swift 语句。细心的朋友可能注意到了,我在这里把“解释”两个字打上了双引号。这是因为即使在命令行中, Swift 其实也不是被解释执行的,而是在每个指令后进对从开始以来的 swift 代码行了一遍编译,然后执行的。这样的做法下依然可以让人“感到”是在做交互解释执行,这门语言的编译速度和优化水平,可见一斑。同时 Playground 还顺便记录了每条语句的执行时候的各种情况,叫做一组 timeline。可以使用 timeline 对代码的执行逐步检查,省去了断点 debug 的时间,也非常方便。 至于更详细的比如 Swift 的语法之类的,可以参见 Apple 在 iBooks 放出的 [The Swift Programming Language](https://itunes.apple.com/us/book/the-swift-programming-language/id881256329?mt=11),或者你是开发者的话,也可以看看 pre-release 的[参考文档](https://developer.apple.com/library/ios/welcome_to_swift) ## Cool,我可以现在就使用 Swift 么? Swift 作为 Apple 钦定的 objc 的继承者,作为 iOS/Mac 开发者的话,是觉得必须和值得学习和使用的。现在 Swift 可以和原来的 objc 或者 c 系的代码混用(注意,不同于 objc 和 c++ 或者 c 在同一个 .mm 文件中的混编,swift 文件不能和 objc 代码写在同一个文件中,你需要将两种代码分开)。编译出来的二进制文件是可以运行在 iOS 7 和 iOS 8 的设备上的(iOS 6 及之前的是不支持的)。虽然我没有尝试过,但是使用新的 clang 对 swift 进行编译的 app 二进制包,只要你的 target 是 iOS 7 及以上的话,应该现在就可以往 App Store 进行提交。 一个很好的消息是 Xcode 6 中应该是所有的文档都有 objc 和 swift 两种语言版本了,所以在文档支持上应该不是问题。而按照 Apple 开发者社区的一贯的跟进速度,有理由相信在不久的将来,Apple 很可能会果断 drop 掉 objc 的支持,而全面转向 swift。所以,关于标题里的这个问题的答案,我个人的建议是,尽快学习,尽快开始使用。如果你有一定的脚本语言的基础(Ruby 最好,Python 或者 JS 什么的也很不错),又比较了解 Cocoa 框架的思想的话,转型到新的语言应该完全不是问题。你会发现以前很多 objc 实现起来很郁闷的事情,在新语言下都易如反掌。我毫不忌讳地说,在 Apple 无数工程师和语言设计天才的努力下,Swift 吸收了众多语言的精华,应该是现在这个世界上最新(这不是废话么),也是最先进的一门编程语言(之一)了。而我认为,也正是 Apple 对这门语言有这样的自信,才会在这么一个可以说公司还在全盛的时候,不守陈规、如此大胆地进行语言的更换。因为 Apple 必定比你我都精于算计,切换语言带来的利益必须远大于弊端,才会值得冒如此大的风险。在这个意义上来说,今天的发布会就是程序开发业界的一枚重磅炸弹,也必将写入史册,而你我其实真的身在其中,变成了这段历史的见证者。 ## 如何开始? 很简单,虽然历年的 WWDC 都在 NDA 的控制之下使得我们无法讨论过多的内容,但是这次的 Swift 破天荒地是在 NDA 之外的内容。Apple 已经放出了足够多的资源让我们开始学习。首先是官方的 Swift 的[介绍页面](https://developer.apple.com/swift/),你可以了解一些 Swift 的基本特性和细节。然后就是从 iBooks 下载 [Swift 的书籍](https://itunes.apple.com/us/book/the-swift-programming-language/id881256329?mt=11)。你可以不必通读全书,而只需要快速浏览一下 35 页之前的 Tour 部分的内容,就可以开始将其运用到开发中了。因为不受 NDA 限制,所以 StackOverflow 的 [swift 标签](http://stackoverflow.com/questions/tagged/swift-language)和 [Google 上](https://www.google.com/#q=swift)应该会马上充斥满相关的问题和内容。及时跟进,相信和其他开发者一同从零开始学习和进步,你会很快上手并熟练使用 Swift 进行开发。 (因为真的,太好用了。你很难想象我在写一个漂亮的闭包或者嵌套函数或者多返回时,那种内心的激动和喜悦...) ## 总结 这次的 WWDC 可以说是 Apple 之前几年布局的一个汇总和爆发。从一开始的 Mac 整合电话和短信,以及无处不在的 Handoff,到后面的通知中心 widget 和系统 framework 的 extension,以及更甚的 Family Share 等等,可以说 Apple 通过自己对产业链的控制和生态圈的完善,让 iDevice 或者 Mac 的用户粘度得到了前所未有的加强。对一个人来说,可能一台苹果设备之后他会很容易购买第二台第三台;对于一家人来说,可能一个成员拥有苹果设备之后,其他人也会被宣传和便捷带动。这是一手妙招,也是 Apple 最近几年一直在做的趋势。 罗马其实不是一天建成的,在开发语言方面,Apple 其实也精心打造了很多年。在语言而言,之前完全没有这方面经验的苹果,毅然决然地选择离开 GCC 阵营,另起炉灶自己弄 Clang 和 LLVM 的布局,而终于在几年来对 objc 小修小补之后来了一次革命性的爆发。在日进万金的大好时候,抛弃一个成熟开发社区,而转向一种新的编程语言,做出这种决策,只能说这家公司的魄力让人折服和钦佩。另一方面,Apple 这么做的另一个理由应该是吸引更多的开发者加入到 Apple 开发阵营,因为相对于 objc 的语法和学习曲线,Swift 显然要容易很多,对于其他阵营的开发者,这也会是一个很好的入场机会。正应了这次 WWDC 的宣传语,Apple 已经为我们提供了更好的工具,我们有什么理由不继续我们的征途,实现我们的梦想呢? **Write the code. Change the world.** URL: https://onevcat.com/2014/05/jin-qi-sui-xiang-he-wwdc-de-ji-hua/index.html.md Published At: 2014-05-30 01:21:18 +0900 # 近期随想和 WWDC 的计划 最近的博文总是写技术,本来其实是打算将这里建设成技术成长与人文关怀并重的博客的,但是现在看来思考不足。在刚被每周七天每天 18 小时的魔鬼般的封闭开发连续虐待了三周之后,我基本达到了看一眼代码就想吐的地步。每天让我坚持下来的动力可能只剩 “过完这周就可以参加的 WWDC” 这一件事情了。于是觉得,现在是时候可以写一点技术无关的博文来舒缓舒缓心情了。 其实在封闭开发期间发生了不少事情,整理在一起看来,还是颇为值得思考的。 首先是经历了一件很不幸的事情,我的一个非常优秀的大学同学,也是家内从高中开始的持续了十年友情的闺蜜,因为一次意外事故遇难,比我们提前了不少和这个世界道别了。一直说生命是顽强的,而我们中的绝大多数也确实是从出生开始就学着去与命运抗争。只有不断砥砺磨练,才能活出绚丽光彩的人生,这样的信念一直激励着我。但是真正当前一天还活力无限、分享生活点滴的人,第二天却只有噩耗传来,与世长辞的时候,这般无情的事实才会告诉我们,生命之脆弱远远超乎想象。虽说天下并无不散的宴席,但是风云之间,意外事故的一瞬就能让至亲至爱的人永远分离。之前无论怎样的理想抱负,亦或是雄心壮志,也就在这倏尔之间戛然而止,便再无法高歌。 生命的易逝总会给人带来很多感慨,悲欢离合,阴晴圆缺,唯有惋惜,唯有叹息。 接下来一件事是锤子手机的发布。对于罗永浩先生的演说(或者说是讲课),我是第一次听。之前一直听说会是一场精彩的单口相声,全场听下来(因为还要干活,所以只能听不能看),也确实是一场精彩的单口相声。当年乔帮主做演讲或者发布也鲜有过一个人讲全场的情况,而这位罗老师尽显教师风范,直接霸占讲台接近三个小时,是能说佩服了。不管如何,在这个发布会上还是有不少亮点的,虽然并没有什么革命性的东西(用这个来要求一个刚起步没太多积累的企业也确实过为苛刻了),但是在一些细节上的打磨和小的创新还是着实让人感动的。当下中国所缺少的,其实正是这样静下心来,打造一款作品的工匠精神。锤子手机的这一点,深得我心。这是一个很不错的开始,我也很期待接下来的故事,不仅是锤子手机的故事,更是整个中国制造的故事,会怎样展开。 在演讲中罗先生为了说明手机系统和软件的重要性,引用了乔帮主对于日本电子设备为什么被美国大幅超越的解释,说是因为日本的软件行业水平不行。因为演讲中并没有对这句话进行出处的标注,乔帮主语录我又因为资质拙劣没有背完全,所以不知道一向亲日的帮主是不是真的说过这句话。本着对于没有出处的引用绝不相信的原则,我对这句话的真实程度持严重怀疑态度;但是,作为一个在日本工作了小两年的海漂的角度来看,我对这句话的内容双手赞成。不管是在自社工作中还是各种交流活动里,确实没有能够见到特别出彩的软件和技术。而日本开发者似乎都比较喜欢埋头苦干,不太擅长于向社区寻求帮助和进步,日益严重的孤岛效应和对与英文资料的心理抵触,使得有时候确实觉得日本的技术现状还真挺尴尬的。 抛开信息技术上的具体实现不说,日本的设计或者说工业设计其实还是世界领先的。不论是建筑业对于一砖一瓦的考究,还是对食物或者衣装从用料和做法上的审慎,其实都还是挺让人赞叹的。但是有一件事情其实很让人搞不懂:拥有如此强力的设计的日本,为什么 Web 网站都做的仿佛是上个世纪的样式呢?要么是 [Yahoo Japan](http://www.yahoo.co.jp) 这种让人抓狂的铺满链接毫无美感的版面设计,要么是 [Rakuten](http://www.rakuten.co.jp) 这种让人绝望的奇葩配色全是贴纸的无重点页面。而在手机应用和游戏界面设计上也是如此,似乎他们永远希望所有的信息在同一个屏幕上展示,而不去考究信息的重要程度和挖掘更好的用户体验。在当前的软件行业中,这样的行为显然是悖逆潮流,所以被美国超过也不足为奇了。现在看来,甚至有被中国超过的趋势(如果把像 360 和助手管家之类的东西从中国软件中刨除的话)。 其实这个问题的原因我想过不止一次,深层次来说是一个日本社会和中国社会的消费上的巨大区别的社会问题。在这边大叔大婶们一定都是消费主力,而小年轻因为工资低又要租房要腐败,基本每个月很难结余。所以更多的时候创造者会对更主要的目标客户做出妥协,而现状大概也就是妥协的产物了吧。 其实昨天和同事一起闲扯的时候有了个新的发现,从另一个角度看,他们也许也有他们的苦衷。比如你可以试试看用 WinXP 和 IE6 打开上面提到的两个网站,相信一个像素不差的完美排版和不逊于在高端先进浏览器上的效果,会给你带来意外的惊喜。 最后还是回归一下开发的话题吧。我想可能现在会关注我这个博客的朋友大部分都是 iOS 开发的爱好者或者从业者。一年一度,举世瞩目的 WWDC 将在下周进行。这次很幸运,被我抽到了一张门票,并且我顺利地办妥了各种手续,不出意外的话应该是可以成行,前往美国参加这次会议。可能一直看我的博客的朋友都知道,之前两年我都是通过一边对照 session 学习,一边结合自己的理解整理出一份笔记的方式来进行练习和巩固的。今年如果时间允许的话,应该也不会例外。但是和往常一样,因为有 NDA 的限制,我会先不将这些笔记公开进行发表,而只是整理在自己的仓库中。在 NDA 解除之前,也不会用来公开讨论和传播。所以有心的想在第一时间看到的朋友,到时候可以关注并找找我的 repo。 预先报告一下之后的行程吧,我将在后天晚上飞三藩市,然后先拜会一下在当地发财的同学们。之后会在周一的 KeyNote 上用[微博](http://weibo.com/onevcat/)的方式给大家带来一些现场的消息。因为 Apple 也会有直播,所以可能会更侧重于以我自己的视角来以一个普通开发者的身份体验 WWDC 这样一个盛会。接下来肯定是会挑一些自己感兴趣的或者在新系统中举足轻重的 session 和 lab 来参加,并且整理笔记,虽然这可能是后话。另外,可能大家会不太知道的是,因为这会是一个全球 Apple 开发者聚集的时间段,所以在 WWDC 举办的同时,也会有非常多的第三方组织的 event 或者 meeting。我也会挑选其中几个参加,并且有计划在这些活动上和像 [@mattt](https://twitter.com/mattt),[@rwenderlich](https://twitter.com/rwenderlich) 以及 [@danielboedewadt](https://twitter.com/danielboedewadt) 这样的顶级开发者~~进行一些技术探讨和交流~~见面要签名和合照的计划,希望能够顺利。 另外,这个博客现在采用了 [CollaMark](http://collamark.com/#/) 的还在开发中的笔记 API,你可以通过选中一段文字,然后点击弹出来的 C 的按钮来添加一段笔记。你可以设定这段笔记是只有你自己可见还是别人也能看。大家不妨可以尝试下作为一种新的和其他读者分享和交流的手段,我觉得很有意思。这是公司里一个中国同事 [@sunderls](http://weibo.com/sunderls) 业余时间做的项目,现在还在测试阶段,可能刷新会有一点点问题(如果没出来的话可能需要清页面缓存什么的),不过还是欢迎大家注册捧场 :) URL: https://onevcat.com/2014/05/kiwi-mock-stub-test/index.html.md Published At: 2014-05-09 11:48:33 +0900 # Kiwi 使用进阶 Mock, Stub, 参数捕获和异步测试 Kiwi 是 iOS 的一个行为驱动开发 (Behavior Driven Development, BDD) 的测试框架,我们在[上一篇入门介绍](http://onevcat.com/2014/02/ios-test-with-kiwi/)中简单了解了一些 iOS 中测试的概念以及 Kiwi 的基本用法。其实 Kiwi 的强大远不止如此,它不仅包含了基本的期望和断言,也集成了一些相对高级的测试方法。在本篇中我们将在之前的基础上,来看看 Kiwi 的这些相对高级的用法,包括模拟对象 (mock),桩程序 (stub),参数捕获和异步测试等内容。这些方法都是在测试中会经常用到的,用来减少我们测试的难度的手段,特别是在耦合复杂的情况下的测试以及对于 UI 事件的测试。 ## Stub 和 Mock 的基本概念 如果您曾经有过为代码编写测试的经验,您一定会知道其中不易。我们编写生产代码让它能够工作其实并不很难,项目中编码方面的工作难点往往在于框架搭建以及随着项目发展如何保持代码优雅可读可维护。而测试相比起业务代码的编写一般来说会更难一些,很多时候你会发现有些代码是“无法测试”的,因为代码之间存在较高的耦合程度,因此绕不开对于其他类的依赖,来对某个类单独测试其正确性。我们不能依赖于一个没有经过测试的类来对另一个需要测试的类进行测试,如果这么做了,我们便无法确定测试的结果是否正是按我们的需要得到的(不能排除测试成功,但是其实是因为未测试的依赖类恰好失败了而恰巧得到的正确结果的可能性)。 ### Stub 解决的方法之一是我们用一种最简单的语言来“描述”那些依赖类的行为,而避免对它们进行具体实现,这样就能最大限度地避免出错。比如我们有一个复杂的算法通过输入的温度和湿度来预测明天的天气,现在我们在存储类中暴露了一个方法,它接受输入的温度和湿度,通过之前复杂算法的计算后将结果写入到数据库中。相关的代码大概是下面这个样子,假设我们有个 `WeatherRecorder` 类来做这件事: ```objc //WeatherRecorder.m -(void) writeResultToDatabaseWithTemprature:(NSInteger)temprature humidity:(NSInteger)humidity { id result = [self.weatherForecaster resultWithTemprature:temprature humidity:humidity]; [self write:result]; } ``` (虽然这个例子设计得不太好,因为服务层架构不对,但是其实) 在实际项目中是可能会有不少类似的代码。对于这样的方法和相应的 `WeatherRecorder` 应该如何测试呢?这个方法依赖了 `weatherForecaster` 的计算方法,而我们这里关心的更多的是 write 这个方法的正确性 (算法的测试应该被分开写在对应的测试中),对于计算的细节和结果我们其实并不关心。但是这个方法本身和算法耦合在了一起,我们当然可以说直接给若干组输入,运行这个方法然后检测数据库中的结果是否与我们预期的一致,但是这其实做了假设,那就是:在测试中我们自己的计算结果和预报计算方法的结果是一致的。这个假设可能在一开始是成立的,但是你无法知道在之后的开发中这个算法会不会改变,会变成怎样。也许之后有修正模型出现,结果和现在大相径庭,这时就会出现 write 数据库的测试居然因为预报的算法变更而失败。这不仅使得测试涵盖了它不应该包括的内容,违背了测试的单一性,也凭添了不少麻烦。 一个完美的解决的方案是,我们人为地来指定计算的结果,然后测试数据库的写入操作。人为地让一个对象对某个方法返回我们事先规定好的值,这就叫做 `stub`。 在 Kiwi 中写一个 stub 非常简单,比如我们有一个 `Person` 类的实例,我们想要 stub 让它返回一个固定的名字,可以这么写: ```objc Person *person = [Person somePerson]; [person stub:@selector(name) andReturn:@“Tom”]; ``` 在这个 stub 下,如下测试将会通过,而不论 person 到底具体是谁: ```objc NSString *testName = [person name]; [ testName should] equal:@“Tom”]; ``` 另外,对于我们之前天气预报例子中的带有参数的方法,我们可以使用 Kiwi stub 的带参数版本来进行替换,比如: ```objc [weatherForecaster stub:@selector(resultWithTemprature:humidity:) andReturn:someResult withArguments:theValue(23),theValue(50)]; ``` 这时我们再给 `weatherForecaster` 发送参数为温度 `23` 和湿度 `50` 的消息时,方法会直接将 `someResult` 返回给我们,这样我们就可以不再依赖于天气预报算法的具体实现,也不用担心算法变更会破坏测试,而对数据库写入进行稳定的测试了。 对于 Kiwi 的 stub,需要注意的是它不是永久有效的,在每个 `it` block 的结尾 stub 都会被清空,超出范围的方法调用将不会被 stub 截取到。 ### Mock `mock` 是一个非常容易和 `stub` 混淆的概念。简单来说,我们可以将 `mock` 看做是一种更全面和更智能的 `stub`。 首先解释全面,我们需要明确,mock 其实就是一个对象,它是对现有类的行为一种模拟(或是对现有接口实现的模拟)。在 objc 的 OOP 中,类或者接口就是指导对象行为的蓝图,而 mock 则遵循这些蓝图并模拟它们的实例对象。从这方面来说,mock 与 stub 最大的区别在于 stub 只是简单的方法替换,而不涉及新的对象,被 stub 的对象可以是业务代码中真正的对象。而 mock 行为本身产生新的(不可能在业务代码中出现的)对象,并遵循类的定义相应某些方法。 其次是更智能。基础上来说,和 stub 很相似,我们可以为创造的 mock 定义在某种输入和方法调用下的输出,更进一步,我们还可以为 mock 设定期望 (准确来说,是我们一定会为 mock 设定期望,这也是 mock 最常见的用例)。即,我们可以为一个 mock 指定这样的期望:“这个 mock **应该收到以 X 为参数的 Y 方法**,并规定它的返回为 Z”。其中"应该收到以 X 为参数的 Y 方法"这个期望会在测试与其不符合时让你的测试失败,而“返回 Z” 这个描述行为更接近于一种 stub 的定义。XCTest 框架想要实现这样的测试例可以说要费九牛之力,但是这在 Kiwi 里却十分自然。 ![mock](/assets/images/2014/mock.jpg) 还是举上面的天气预报的例子。我们在 stub 时将 `weatherForecaster` 的方法替换处理了。细心的读者可能会有疑惑,问这个 `weatherForecaster` 是怎么来的。因为这个对象其实只是 `WeatherRecorder` 中一个属性,而且很有可能在测试时我们并不能拥有一个恰好合适的 `weatherForecaster`。`WeatherRecorder` 是不需要将 `weatherForecaster` 暴露在头文件中的,VC 是不需要知道它的实现细节的),而我们在上面的 stub 的前提是我们能在测试代码中拿到这个 `weatherForecaster`,很多时候只能修改代码将其暴露,但是这并不是好的实践,很多时候也并不现实。现在有了 mock 后,我们就可以自创一个虚拟的 `weatherForecaster`,并为其设定期望的调用来确保我们输入温度和湿度确实经过了计算然后存入了数据库中了。mock 所使用的期望和普通对象的调用期望类似: ```objc id weatherForecasterMock = [WeatherForecaster mock]; [[weatherForecasterMock should] receive:@selector(resultWithTemprature:humidity:) andReturn:someResult withArguments:theValue(23),theValue(50)]; ``` 然后,对于要测试的 `weatherRecorder` 实例,用 stub 将 -weatherForecaster 的返回换为我们的 mock: ```objc [weatherRecorder stub:@selector(weatherForecaster) andReturn:weatherForecasterMock]; ``` 这样一来,在 `-writeResultToDatabaseWithTemprature:humidity:` 中我们就可以使用一个 mock 的 `weatherForecaster` 来完成工作,并检验是否确实进行了预报了。类似的组合用法在 mock/stub 测试中是比较常见的,在本文最后的例子中我们会再次见到类似的用法。 ## 参数捕获 有时候我们会对 mock 对象的输入参数感兴趣,比如期望某个参数符合一定要求,但是对于 mock 而言一般我们是通过调用别的方法来验证 mock 是否被调用的,所以很可能无法拿到传给 mock 对象的参数。这种情况下我们就可以使用参数捕获来获取输入的参数。比如对于上面的 `weatherForecasterMock`,如果我们想捕获温度参数,可以在调用测试前使用 ```objc KWCaptureSpy *spy = [weatherForecasterMock captureArgument:@selector(resultWithTemprature:humidity:) atIndex:0]; ``` 来加一个参数捕获。这样,当我们在测试中使用 stub 将 `weatherForecaster` 替换为我们的 mock 后,再进行如下调用 ```objc [weatherRecorder writeResultToDatabaseWithTemprature:23 humidity:50] ``` 后,我们可以通过访问 `spy.argument` 来拿到实际输入 `resultWithTemprature:humidity:` 的第一个参数。 在这个例子中似乎不太有用,因为我们输入给 `-writeResultToDatabaseWithTemprature:humidity:` 的参数和 `-resultWithTemprature:humidity:` 的是一样的。但是在某些情况下确实会很有效果,我们会在之后看到一个实际的使用例。 ## 异步测试 异步测试是为了对后台线程的结果进行期望检验时所需要的,Kiwi 可以对某个对象的未来的状况书写期望,并进行检验。通过将要检验的对象加上 `expectFutureValue`,然后使用 `shouldEventually` 即可。就像这样: ```objc [[expectFutureValue(myObject) shouldEventually] beNonNil]; [[expectFutureValue(theValue(myBool)) shouldEventually] beYes]; ``` 比如在 REST 网络测试中,我们可能大部分情况下会选择用一组 mock 来替代服务器的返回进行验证,但是也不排除会有直接访问服务器进行测试的情况。在这种情况下我们就可以使用延时来进行异步测试。这里直接照抄一个官方 Wiki 的例子进行说明: ```objc context(@"Fetching service data", ^{ it(@"should receive data within one second", ^{ __block NSString *fetchedData = nil; [[LRResty client] get:@"http://www.example.com" withBlock:^(LRRestyResponse* r) { NSLog(@"That's it! %@", [r asString]); fetchedData = [r asString]; }]; [[expectFutureValue(fetchedData) shouldEventually] beNonNil]; }); }); ``` 这个测试保证了返回的 `LRRestyResponse` 对象可以转为一个字符串并且不是 `nil`。 其实没什么神奇的,就是生成了一个延时的验证,在一定时间间隔后再对观测的对象进行检查。这个时间间隔默认是 1 秒,如果你需要其他的时间间隔的话,可以使用 `shouldEventuallyBeforeTimingOutAfter` 版本: ## 一个例子:测试 ViewController 举个实际一点的例子吧,我们来看看平时觉得难以测试的 `UIViewController` 的部分,包括一个 `tableView` 和对应的 `dataSource` 和 `delegate` 的测试方法。我们使用了 objc.io 第一期中的 [Lighter View Controllers](http://www.objc.io/issue-1/lighter-view-controllers.html) 和 [Clean table view code](http://www.objc.io/issue-1/table-views.html) 中的代码来实现一个简单可测试的 VC 结构,然后使用 Kiwi 替换完成了 [Testing View Controllers](http://www.objc.io/issue-1/testing-view-controllers.html) 一文中的所有测试模块。这里篇幅有限,实现的具体细节就不在复述了,有兴趣的同学可以看看 objc.io 的这三篇文章,或者也可以在 [objc 中国](http://www.objccn.io) 上找到它们的译文:[更轻量的 View Controllers](http://objccn.io/issue-1-1/),[整洁的 Table View 代码](http://objccn.io/issue-1-2/)以及[测试 View Controllers](http://objccn.io/issue-1-3/)。 我们在这里结合 Kiwi 的方法对重写的测试部分进行一些说明。[objc.io 原来的项目](https://github.com/objcio/issue-1-lighter-view-controllers)使用的是 [OCMock](http://ocmock.org) 实现的解耦测试,而为了进行说明,我用 Kiwi 简单重写了测试部分的代码,这个项目也可以[在 Github 上找到](https://github.com/onevcat/PhotoData_Kiwi)。 对于 `ArchiveReading` 的测试都是 Kiwi 最基本的内容,在[上一篇文章中](http://onevcat.com/2014/02/ios-test-with-kiwi/)已经详细介绍过了;对于 `PhotoCell` 的测试形式上比较新颖,其实是一个对 xib 的测试,保证了 xib 的初始化和 outlet 连接的正确性,但是测试内容也比较基本。剩下的是对于 tableView 的 dataSource 和 viewController 的测试,我们来具体看看。 ### Data Source 的测试 首先是 `ArrayDataSourceSpec`,得益于将 array 的 dataSource 进行抽象和封装,我们可以单独对其进行测试。基本思路是我们希望在为一个 tableView 设置好数据源后,tableView 可以正确地从数据源获取组织 UI 所需要的信息,基本上来说,也就是能够得到“有多少行”以及“每行的 cell 是什么”这两个问题的答案。到这里,有写过 iOS 的开发者应该都明白我们要测试的是什么了。没错,就是 `-tableView:numberOfRowsInSection:` 以及 `-tableView:cellForRowAtIndexPath:` 这两个接口的实现。 测试用例关键代码如下: ```objc TableViewCellConfigureBlock block = ^(UITableViewCell *a, id b){ configuredCell = a; configuredObject = b; }; ArrayDataSource *dataSource = [[ArrayDataSource alloc] initWithItems:@[@"a", @"b"] cellIdentifier:@"foo" configureCellBlock:block]; id mockTableView = [UITableView mock]; UITableViewCell *cell = [[UITableViewCell alloc] init]; it(@"should be 2 items", ^{ NSInteger count = [dataSource tableView:mockTableView numberOfRowsInSection:0]; [[theValue(count) should] equal:theValue(2)]; }); __block id result = nil; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0]; it(@"should receive cell request", ^{ [[mockTableView should] receive:@selector(dequeueReusableCellWithIdentifier:forIndexPath:) andReturn:cell withArguments:@"foo",indexPath]; result = [dataSource tableView:mockTableView cellForRowAtIndexPath:indexPath]; }); ``` 为了简要说明,我改变了 repo 中的代码组织结构,不过意思是一样的。我们要测试的是 `ArrayDataSource` 类,因此我们生成一个实例对象。在测试中我们不希望测试依赖于 `UITableView`,因此我们 mock 了一个对象代替之。接下来向 dataSource 发送询问元素个数的方法,这里应该毫无疑问返回数组中的元素数量。接下来我们给 `mockTableView` 设定了一个期望,当将向这个 mock 的 tableView 请求 dequeu indexPath 为 (0,0) 的 cell 时,将直接返回我们预先生成的一个 cell,并进行接下来的处理。完成设定后,我们调用要测试的方法 `[dataSource tableView:mockTableView cellForRowAtIndexPath:indexPath]`。`dataSource` 在接到这个方法后,向 `mockTableView` 请求一个 cell(这个方法已经被 mock),接下来通过之前定义的 block 来对 cell 进行配置,最后返回并赋值给 `result`。于是,我们就得到了一个可以进行期望断言的 result,它应该和我们之前做的 cell 是同一个对象,并且经过了正确的配置。至此这个 dataSource 测试完毕。 您当然还可以扩展这个 dataSource 并且为其添加对应的测试,但是对于这两个 `required` 方法的测试已经揭示了测试 Data Source 的基本方法。 ### ViewController 的测试 ViewController 一般被认为是最难测试甚至不可测试的部分。而通过 objc.io 的抽离方式可以使 MVC 更加清晰,也让 ViewController 的代码简洁不少。保持良好的 MVC 结构,尽可能精简 ViewController,对其的测试还是有可能及有意义的。在 `PhotosViewControllerSpec` 里做了对 ViewConroller 的一个简单测试。我们模拟了 tableView 中对一个 cell 的点击,然后检查 `navigationController` 的 `push` 操作是否确实被调用,以及被 `push` 的对象是否是我们想要的下一个 ViewController。 要测试的是 `PhotosViewController` 的实例,因此我们生成一个。对于它的 `UINavigationController`,因为其没有在导航栈中,也这不是我们要测试的对象(保持测试的单一性),所以用一个 mock 对象来代替。然后为其设定 `-pushViewController:animated:` 需要被调用的期望。然后再用输入参数捕获将被 push 的对象抓出来,进行判断。关键部分代码如下: ```objc UINavigationController *mockNavController = [UINavigationController mock]; [photosViewController stub:@selector(navigationController) andReturn:mockNavController]; [[mockNavController should] receive:@selector(pushViewController:animated:)]; KWCaptureSpy *spy = [mockNavController captureArgument:@selector(pushViewController:animated:) atIndex:0]; [photosViewController tableView:photosViewController.tableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]]; id obj = spy.argument; PhotoViewController *vc = obj; [[vc should] beKindOfClass:[PhotoViewController class]]; [[vc.photo shouldNot] beNil]; ``` 在这里我们用 stub 替换了 `photosViewController` 的 `navigationController`,这个替换进去的 `UINavigationController` 的 mock 被期望响应 `-pushViewController:animated:`。于是在点击 tableView 的 cell 时,我们期望 push 一个新的 `PhotoViewController` 实例,这一点可以通过捕获 push 消息的参数来达成。大体的步骤和原理与之前天气预报的例子的最终版本很相似,在此就不再详细展开了。 关于 mock 还有一点需要补充的是,使用 `+mock` 方法生成的 mock 对象对于期望收到的方法是严格判定的,就是说它能且只能响应那些你添加了期望或者 stub 的方法。比如只为一个 mock 设定了 `should receive selector(a)` 这样的期望,那么对这个 mock 发送一个消息 b 的话,将会抛出异常 (当然,如果你没有向其发送消息 a 的话,测试会失败)。如果你的 mock 还需要相应其他方法的话,可以使用 `+nullMock` 方法来生成一个可以接受任意预定消息而不会抛出异常的空 mock。 ## 总结 花了两篇文章的篇幅介绍了 TDD 和 BDD,相信您已经有一定基本的了解了。在最近一年一来,测试的地位在 objc 社区中可以说是直线上升,不论是 Apple 官方的维护或者是开发者们的重视程度的提高。良好的代码习惯和良好的测试应该是相辅相成,良性循环的。 最后对几个常见问题做一些总结: #### 我应不应该测试,要怎么做 应该,即使在你的工作中没有要求。测试会让你的生活更美好轻松。你是愿意花 10 分钟完成对你代码的自动化测试,还是在 QA 找过来以后花一整天去找 bug,并且同时制造更多的 bug?即使你现在还完全不了解也不会编写测试,你也可以从最简单的 model 测试开始,抽离并封装逻辑部分,然后用 XCTest 做简单断言。先建立测试的概念,然后有意编写可测试代码,最终掌握测试方法。 #### 需要使用 TDD 么 建议使用。虽然看上去这有点疯狂,虽然一开始会有不适应,但是这确实是对思维检验的非常好的时机。在实际很爽地去一通乱写之前先做好整体设计,TDD 确实可以帮助提高项目结构和质量。开始的时候建议小粒度进行,可能开发效率会有一段低谷时期 (但是相较于代码质量的提高,这点付出还是很值得的),熟悉之后可以加大步伐,并且积累一套适合自己的测试风格,这时候你就会发现开发效率像坐火箭一般提升了。 #### 需要使用 BDD 么 可以考虑在有一定积累后使用。因为有些情况下 XCTest 确实略显苍白,有些测试实现起来也很繁芜。BDD 在一定程度上可以将测试的目的理得更清晰,当然,前提是你需要明确知道想测试的是什么,以及尽量保证测试的单一性和无耦合。另外虽然这两篇文章介绍的是 Kiwi,但是实际上我们在 objc 的 BDD 时还有不少其他选择,比如 [specta](https://github.com/specta/specta) 或者 [cedar](https://github.com/pivotal/cedar) 都是很好的框架。我个人喜欢 Kiwi 纯粹是因为和 Kiwi 作者和维护社区里的几位大大的个人关系,大家如果要实践 BDD,在选择的时候也可以进行一些对比,选择合适自己的。 ## 扩展阅读 * [Test-Driven iOS Development](http://www.amazon.com/Test-Driven-iOS-Development-Developers-Library/dp/0321774183) 我的 iOS TDD 入门书 * [Kiwi 的 Wiki](https://github.com/kiwi-bdd/Kiwi/wiki) 关于 Kiwi 你所需要知道的一切 * [Unit Testing - NSHipster](http://nshipster.com/unit-testing/) * [Test Driving iOS Development with Kiwi](https://itunes.apple.com/us/book/test-driving-ios-development/id502345143?mt=11) iBook的书,中国区不让卖书,所以可能需要非中国账号 (日本账号这本书只要 5 美金) URL: https://onevcat.com/2014/04/itc-special-characters/index.html.md Published At: 2014-04-28 00:40:39 +0900 # 苹果应用描述中不能使用特殊字符的对应方法 ### 该文章内容在iOS7中已经失效,请乖乖遵循苹果的规则写吧 虽然很早Apple就说过从5月1日开始就不再允许UDID以及没有对iPhone5优化的应用上架,但是这次iTunes Connect的对于描述字符的限制还是让很多开发者措手不及。毕竟事先完全没有和大家打过招呼,Apple想要统一应用市场的风格和体验的心态可以理解,但是在开发者难得还有一点自由发挥的应用描述的地方突然作出这样的限制,确实不太厚道。相关的新闻报道可以参看[这里](http://www.cnbeta.com/articles/234799.htm) 但是,难道我们真的没法使用更漂亮的描述了么?答案是,有办法!解决办法就一句话,**直接使用`字符值引用`来写iTC的描述就可以了~** 比如,想使用`★`这个字符,在描述中将`★`的地方都换成`★`就可以了。 一句废话,对于字符转换,当然也有在线将特殊字符转换为字符值引用的服务:[传送门](http://yasu.asuka.net/orkut/conv.html) 最后,祝大家五一快乐,假期好心情~ :) URL: https://onevcat.com/2014/03/common-background-practices/index.html.md Published At: 2014-03-22 01:07:50 +0900 # 常见的后台实践 ## 题外 [objc.io](http://www.objc.io) 是一个非常棒的iOS进阶学习的网站,上面有很多超赞的学习资源和例子。最近我和 [@方一雄](http://weibo.com/fangyixiong),[@answer-huang](http://weibo.com/u/1623064627) 和社区的另外几名小伙伴在主持做一个 objc.io 的译文整理汇总和后续翻译跟进的项目,我暂时略自我狂妄地把它叫做 `objc中国`([objccn.io](http://objccn.io)) 项目,希望它能给现在已经很红火的中国objc社区锦上添花。现在上面已经有一些文章,您可以时不时地访问我们的[首页](http://objccn.io)来查看新的动态。如果有兴趣,也可以考虑[加入我们](https://github.com/objccn/articles),来为中国objc社区的发展贡献一点力量。 对objc中国上的每一篇文章,我都会至少进行一个基本的校对。在整理和收集的过程中,我发现虽然不少文章有相对完整的译文,但其中也存在对原文的理解上有一定偏差的情况。可能是译者并没有原作者对某些问题的深入理解,可能是原文也做过一些修改调整而译文没有更新,也可能是因为翻译时时间上多有仓促,导致了它们并不足以在只是稍加修改后就能发表在主站点上。对于这样的译文,我的想法是为了保证文章质量,牺牲一些个人时间来重新进行翻译。一来可以保证文章质量和站点的水准,二来也算是一次自我学习和提高的过程。 题外话完毕。本文是 objc.io issue #2 的第二篇正文,这篇文章在该主题第一篇[并发编程:API 及挑战](http://objccn.io/issue-2-1/)的基础上深层次地讲了一些实践上的例子和技术,颇有难度。对多线程不熟悉的同学可以先参照看看第一篇,再来阅读本文。 --- 本文主要探讨一些常用后台任务的最佳实践。我们将会看看如何并发地使用 Core Data ,如何并行绘制 UI ,如何做异步网络请求等。最后我们将研究如何异步处理大型文件,以保持较低的内存占用。因为在异步编程中非常容易犯错误,所以,本文中的例子都将使用很简单的方式。因为使用简单的结构可以帮助我们看透代码,抓住问题本质。如果你最后把代码写成了复杂的嵌套回调的话,那么你很可能应该重新考虑自己当初的设计选择了。 ## 操作队列 (Operation Queues) 还是 GCD ? 目前在 iOS 和 OS X 中有两套先进的同步 API 可供我们使用:[操作队列][6]和 [GCD][7] 。其中 GCD 是基于 C 的底层的 API ,而操作队列则是 GCD 实现的 Objective-C API。关于我们可以使用的并行 API 的更加全面的总览,可以参见 [并发编程:API 及挑战][8]。 操作队列提供了在 GCD 中不那么容易复制的有用特性。其中最重要的一个就是可以取消在任务处理队列中的任务,在稍后的例子中我们会看到这个。而且操作队列在管理操作间的依赖关系方面也容易一些。另一面,GCD 给予你更多的控制权力以及操作队列中所不能使用的底层函数。详细介绍可以参考[底层并发 API][9] 这篇文章。 扩展阅读: * [StackOverflow: NSOperation vs. Grand Central Dispatch](http://stackoverflow.com/questions/10373331/nsoperation-vs-grand-central-dispatch) * [Blog: When to use NSOperation vs. GCD](http://eschatologist.net/blog/?p=232) ### 后台的 Core Data 在着手 Core Data 的并行处理之前,最好先打一些基础。我们强烈建议通读苹果的官方文档 [Concurrency with Core Data guide][10] 。这个文档中罗列了基本规则,比如绝对不要在线程间传递 managed objects等。这并不单是说你绝不应该在另一个线程中去更改某个其他线程的 managed object ,甚至是读取其中的属性都是不能做的。要想传递这样的对象,正确做法是通过传递它的 object ID ,然后从其他对应线程所绑定的 context 中去获取这个对象。 其实只要你遵循那些规则,并使用这篇文章里所描述的方法的话,处理 Core Data 的并行编程还是比较容易的。 Xcode 所提供的 Core Data 标准模版中,所设立的是运行在主线程中的一个存储调度 (persistent store coordinator)和一个托管对象上下文 (managed object context) 的方式。在很多情况下,这种模式可以运行良好。创建新的对象和修改已存在的对象开销都非常小,也都能在主线程中没有困难滴完成。然后,如果你想要做大量的处理,那么把它放到一个后台上下文来做会比较好。一个典型的应用场景是将大量数据导入到 Core Data 中。 我们的方式非常简单,并且可以被很好地描述: 1. 我们为导入工作单独创建一个操作 2. 我们创建一个 managed object context ,它和主 managed object context 使用同样的 persistent store coordinator 3. 一旦导入 context 保存了,我们就通知 主 managed object context 并且合并这些改变 在[示例app][11]中,我们要导入一大组柏林的交通数据。在导入的过程中,我们展示一个进度条,如果耗时太长,我们希望可以取消当前的导入操作。同时,我们显示一个随着数据加入可以自动更新的 table view 来展示目前可用的数据。示例用到的数据是采用的 Creative Commons license 公开的,你可以[在此下载][12]它们。这些数据遵守一个叫做 [General Transit Feed][13] 格式的交通数据公开标准。 我们创建一个 `NSOperation` 的子类,将其叫做 `ImportOperation`,我们通过重写 `main` 方法,用来处理所有的导入工作。这里我们使用 `NSPrivateQueueConcurrencyType` 来创建一个独立并拥有自己的私有 dispatch queue 的 managed object context,这个 context 需要管理自己的队列。在队列中的所有操作必须使用 `performBlock` 或者 `performBlockAndWait` 来进行触发。这点对于保证这些操作能在正确的线程上执行是相当重要的。 ```objc NSManagedObjectContext* context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSPrivateQueueConcurrencyType]; context.persistentStoreCoordinator = self.persistentStoreCoordinator; context.undoManager = nil; [self.context performBlockAndWait:^ { [self import]; }]; ``` 在这里我们重用了已经存在的 persistent store coordinator 。一般来说,初始化 managed object contexts 要么使用 `NSPrivateQueueConcurrencyType`,要么使用 `NSMainQueueConcurrencyType`。第三种并发类型 `NSConfinementConcurrencyType` 是为老旧代码准备的,我们不建议再使用它了。 在导入前,我们枚举文件中的各行,并对可以解析的每一行创建 managed object : ```objc [lines enumerateObjectsUsingBlock: ^(NSString* line, NSUInteger idx, BOOL* shouldStop) { NSArray* components = [line csvComponents]; if(components.count < 5) { NSLog(@"couldn't parse: %@", components); return; } [Stop importCSVComponents:components intoContext:context]; }]; ``` 在 view controller 中通过以下代码来开始操作: ```objc ImportOperation* operation = [[ImportOperation alloc] initWithStore:self.store fileName:fileName]; [self.operationQueue addOperation:operation]; ``` 至此为止,后台导入部分已经完成。接下来,我们要加入取消功能,这其实非常简单,只需要枚举的 block 中加一个判断就行了: ```objc if(self.isCancelled) { *shouldStop = YES; return; } ``` 最后为了支持进度条,我们在 operation 中创建一个叫做 `progressCallback` 的属性。需要注意的是,更新进度条必须在主线程中完成,否则会导致 UIKit 崩溃。 ```objc operation.progressCallback = ^(float progress) { [[NSOperationQueue mainQueue] addOperationWithBlock:^ { self.progressIndicator.progress = progress; }]; }; ``` 我们在枚举中来调用这个进度条更新的 block 的操作: ```objc self.progressCallback(idx / (float) count); ``` 然而,如果你执行示例代码的话,你会发现它运行逐渐变得很慢,取消操作也有迟滞。这是因为主操作队列中塞满了要更新进度条的 block 操作。一个简单的解决方法是降低更新的频度,比如只在每导入一百行时更新一次: ```objc NSInteger progressGranularity = lines.count / 100; if (idx % progressGranularity == 0) { self.progressCallback(idx / (float) count); } ``` ### 更新 Main Context 在 app 中的 table view 是由一个在主线程上获取了结果的 controller 所驱动的。在导入数据的过程中和导入数据完成后,我们要在 table view 中展示我们的结果。 在让一切运转起来之前之前,还有一件事情要做。现在在后台 context 中导入的数据还不能传送到主 context中,除非我们显式地让它这么去做。我们在 `Store` 类的设置 Core Data stack 的 `init` 方法中加入下面的代码: ```objc [[NSNotificationCenter defaultCenter] addObserverForName:NSManagedObjectContextDidSaveNotification object:nil queue:nil usingBlock:^(NSNotification* note) { NSManagedObjectContext *moc = self.mainManagedObjectContext; if (note.object != moc) [moc performBlock:^(){ [moc mergeChangesFromContextDidSaveNotification:note]; }]; }]; }]; ``` 如果 block 在主队列中被作为参数传递的话,那么这个 block 也会在主队列中被执行。如果现在你运行程序的话,你会注意到 table view 会在完成导入数据后刷新数据,但是这个行为会阻塞用户大概几秒钟。 要修正这个问题,我们需要做一些无论如何都应该做的事情:批量保存。在导入较大的数据时,我们需要定期保存,逐渐导入,否则内存很可能就会被耗光,性能一般也会更坏。而且,定期保存也可以分散主线程在更新 table view 时的工作压力。 合理的保存的次数可以通过试错得到。保存太频繁的话,可能会在 I/O 操作上花太多时间;保存次数太少的话,应用会变得无响应。在经过一些尝试后,我们设定每 250 次导入就保存一次。改进后,导入过程变得很平滑,它可以适时更新 table view,也没有阻塞主 context 太久。 ### 其他考虑 在导入操作时,我们将整个文件都读入到一个字符串中,然后将其分割成行。这种处理方式对于相对小的文件来说没有问题,但是对于大文件,最好采用惰性读取 (lazily read) 的方式逐行读入。本文最后的示例将使用输入流的方式来实现这个特性,在 [StackOverflow][14] 上 Dave DeLong 也提供了一段非常好的示例代码来说明这个问题。 在 app 第一次运行时,除开将大量数据导入 Core Data 这一选择以外,你也可以在你的 app bundle 中直接放一个 sqlite 文件,或者从一个可以动态生成数据的服务器下载。如果使用这些方式的话,可以节省不少在设备上的处理事件。 最后,最近对于 child contexts 有很多争议。我们的建议是不要在后台操作中使用它。如果你以主 context 的 child 的方式创建了一个后台 context 的话,保存这个后台 context 将[阻塞主线程][15]。而要是将主 context 作为后台 context 的 child 的话,实际上和与创建两个传统的独立 contexts 来说是没有区别的。因为你仍然需要手动将后台的改变合并回主 context 中去。 设置一个 persistent store coordinator 和两个独立的 contexts 被证明了是在后台处理 Core Data 的好方法。除非你有足够好的理由,否则在处理时你应该坚持使用这种方式。 扩展阅读: * [Core Data Programming Guide: Efficiently importing data](http://developer.apple.com/library/ios/#documentation/Cocoa/Conceptual/CoreData/Articles/cdImporting.html) * [Core Data Programming Guide: Concurrency with Core Data](http://developer.apple.com/library/ios/#documentation/Cocoa/Conceptual/CoreData/Articles/cdConcurrency.html#//apple_ref/doc/uid/TP40003385-SW1j) * [StackOverflow: Rules for working with Core Data](http://stackoverflow.com/questions/2138252/core-data-multi-thread-application/2138332#2138332) * [WWDC 2012 Video: Core Data Best Practices](https://developer.apple.com/videos/wwdc/2012/?id=214) * [Book: Core Data by Marcus Zarra](http://pragprog.com/book/mzcd/core-data) ## 后台 UI 代码 首先要强调:UIKit 只能在主线程上运行。而那部分不与 UIKit 直接相关,却会消耗大量时间的 UI 代码可以被移动到后台去处理,以避免其将主线程阻塞太久。但是在你将你的 UI 代码移到后台队列之前,你应该好好地测量哪一部分才是你代码中的瓶颈。这非常重要,否则你所做的优化根本是南辕北辙。 如果你找到了你能够隔离出的昂贵操作的话,可以将其放到操作队列中去: ```objc __weak id weakSelf = self; [self.operationQueue addOperationWithBlock:^{ NSNumber* result = findLargestMersennePrime(); [[NSOperationQueue mainQueue] addOperationWithBlock:^{ MyClass* strongSelf = weakSelf; strongSelf.textLabel.text = [result stringValue]; }]; }]; ``` 如你所见,这些代码其实一点也不直接明了。我们首先声明了一个 weak 引用来参照 self,否则会形成循环引用( block 持有了 self,私有的 `operationQueue` retain 了 block,而 self 又 retain 了 `operationQueue` )。为了避免在运行 block 时访问到已被释放的对象,在 block 中我们又需要将其转回 strong 引用。 编者注 这在 ARC 和 block 主导的编程范式中是解决 retain cycle 的一种常见也是最标准的方法。 ### 后台绘制 如果你确定 `drawRect:` 是你的应用的性能瓶颈,那么你可以将这些绘制代码放到后台去做。但是在你这样做之前,检查下看看是不是有其他方法来解决,比如、考虑使用 core animation layers 或者预先渲染图片而不去做 Core Graphics 绘制。可以看看 Florian 对在真机上图像性能测量的[帖子][16],或者可以看看来自 UIKit 工程师 Andy Matuschak 对个各种方式的权衡的[评论][17]。 如果你确实认为在后台执行绘制代码会是你的最好选择时再这么做。其实解决起来也很简单,把 `drawRect:` 中的代码放到一个后台操作中去做就可以了。然后将原本打算绘制的视图用一个 image view 来替换,等到操作执行完后再去更新。在绘制的方法中,使用 `UIGraphicsBeginImageContextWithOptions` 来取代 `UIGraphicsBeginImageContextWithOpertions` : ```objc UIGraphicsBeginImageContextWithOptions(size, NO, 0); // drawing code here UIImage *i = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return i; ``` 通过在第三个参数中传入 0 ,设备的主屏幕的 scale 将被自动传入,这将使图片在普通设备和 retina 屏幕上都有良好的表现。 如果你在 table view 或者是 collection view 的 cell 上做了自定义绘制的话,最好将塔门放入 operation 的子类中去。你可以将它们添加到后台操作队列,也可以在用户将 cell 滚动出边界时的 `didEndDisplayingCell` 委托方法中进行取消。这些技巧都在 2012 年的WWDC [Session 211 -- Building Concurrent User Interfaces on iOS][18]中有详细阐述。 除了在后台自己调度绘制代码,以也可以试试看使用 `CALayer` 的 `drawsAsynchronously` 属性。然而你需要精心衡量这样做的效果,因为有时候它能使绘制加速,有时候却适得其反。 ## 异步网络请求处理 你的所有网络请求都应该采取异步的方式完成。 然而,在 GCD 下,有时候你可能会看到这样的代码 ```objc // 警告:不要使用这些代码。 dispatch_async(backgroundQueue, ^{ NSData* contents = [NSData dataWithContentsOfURL:url] dispatch_async(dispatch_get_main_queue(), ^{ // 处理取到的日期 }); }); ``` 乍看起来没什么问题,但是这段代码却有致命缺陷。你没有办法去取消这个同步的网络请求。它将阻塞住线程直到它完成。如果请求一直没结果,那就只能干等到超时(比如 `dataWithContentsOfURL:` 的超时时间是 30 秒)。 如果队列是串行执行的话,它将一直被阻塞住。假如队列是并行执行的话,GCD 需要重开一个线程来补凑你阻塞住的线程。两种结果都不太妙,所以最好还是不要阻塞线程。 要解决上面的困境,我们可以使用 `NSURLConnection` 的异步方法,并且把所有操作转化为 operation 来执行。通过这种方法,我们可以从操作队列的强大功能和便利中获益良多:我们能轻易地控制并发操作的数量,添加依赖,以及取消操作。 然而,在这里还有一些事情值得注意: `NSURLConnection` 是通过 run loop 来发送事件的。因为时间发送不会花多少时间,因此最简单的是就只使用 main run loop 来做这个。然后,我们就可以用后台线程来处理输入的数据了。 另一种可能的方式是使用像 [AFNetworking](http://afnetworking.com) 这样的框架:建立一个独立的线程,为建立的线程设置自己的 run loop,然后在其中调度 URL 连接。但是并不推荐你自己去实现这些事情。 要处理URL 连接,我们重写自定义的 operation 子类中的 `start` 方法: ```objc - (void)start { NSURLRequest* request = [NSURLRequest requestWithURL:self.url]; self.isExecuting = YES; self.isFinished = NO; [[NSOperationQueue mainQueue] addOperationWithBlock:^ { self.connection = [NSURLConnectionconnectionWithRequest:request delegate:self]; }]; } ``` 由于重写的是 `start` 方法,所以我们需要自己要管理操作的 `isExecuting` 和 `isFinished` 状态。要取消一个操作,我们需要取消 connection ,并且设定合适的标记,这样操作队列才知道操作已经完成。 ```objc - (void)cancel { [super cancel]; [self.connection cancel]; self.isFinished = YES; self.isExecuting = NO; } ``` 当连接完成加载后,它向代理发送回调: ```objc - (void)connectionDidFinishLoading:(NSURLConnection *)connection { self.data = self.buffer; self.buffer = nil; self.isExecuting = NO; self.isFinished = YES; } ``` 就这么多了。完整的代码可以参见[GitHub上的示例工程][20]。 总结来说,我们建议要么你玩时间来把事情做对做好,要么就直接使用像 [AFNetworking][19] 这样的框架。其实 [AFNetworking][19] 还提供了不少好用的小工具,比如有个 `UIImageView` 的 category,来负责异步地从一个 URL 加载图片。在你的 table view 里使用的话,还能自动帮你处理取消加载操作,非常方便。 扩展阅读: * [Concurrency Programming Guide](http://developer.apple.com/library/ios/#documentation/General/Conceptual/ConcurrencyProgrammingGuide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40008091-CH1-SW1) * [NSOperation Class Reference: Concurrent vs. Non-Concurrent Operations](http://developer.apple.com/library/ios/#documentation/Cocoa/Reference/NSOperation_class/Reference/Reference.html%23http://developer.apple.com/library/ios/#documentation/Cocoa/Reference/NSOperation_class/Reference/Reference.html%23//apple_ref/doc/uid/TP40004591-RH2-SW15) * [Blog: synchronous vs. asynchronous NSURLConnection](http://www.cocoaintheshell.com/2011/04/nsurlconnection-synchronous-asynchronous/) * [GitHub: `SDWebImageDownloaderOperation.m`](https://github.com/rs/SDWebImage/blob/master/SDWebImage/SDWebImageDownloaderOperation.m) * [Blog: Progressive image download with ImageIO](http://www.cocoaintheshell.com/2011/05/progressive-images-download-imageio/) * [WWDC 2012 Session 211: Building Concurrent User Interfaces on iOS](https://developer.apple.com/videos/wwdc/2012/) ## 进阶:后台文件 I/O 在之前我们的后台 Core Data 示例中,我们将一整个文件加载到了内存中。这种方式对于较小的文件没有问题,但是受限于 iOS 设备的内存容量,对于大文件来说的话就不那么友好了。要解决这个问题,我们将构建一个类,它负责一行一行读取文件而不是一次将整个文件读入内存,另外要在后台队列处理文件,以保持应用相应用户的操作。 为了达到这个目的,我们使用能让我们异步处理文件的 `NSInputStream` 。根据[官方文档][21]的描述: > 如果你需总是需要从头到尾来读/写文件的话,streams 提供了一个简单的接口来异步完成这个操作 不管你是否使用 streams,大体上逐行读取一个文件的模式是这样的: 1. 建立一个中间缓冲层以提供,当没有找到换行符号的时候可以向其中添加数据 2. 从 stream 中读取一块数据 3. 对于这块数据中发现的每一个换行符,取中间缓冲层,向其中添加数据,直到(并包括)这个换行符,并将其输出 4. 将剩余的字节添加到中间缓冲层去 5. 回到 2,直到 stream 关闭 为了将其运用到实践中,我们又建立了一个[示例应用][22],里面有一个 `Reader` 类完成了这件事情,它的接口十分简单 ```objc @interface Reader : NSObject - (void)enumerateLines:(void (^)(NSString*))block completion:(void (^)())completion; - (id)initWithFileAtPath:(NSString*)path; @end ``` 注意,这个类不是 NSOperation 的子类。与 URL connections 类似,输入的 streams 通过 run loop 来传递它的事件。这里,我们仍然采用 main run loop 来分发事件,然后将数据处理过程派发至后台操作线程里去处理。 ```objc - (void)enumerateLines:(void (^)(NSString*))block completion:(void (^)())completion { if (self.queue == nil) { self.queue = [[NSOperationQueue alloc] init]; self.queue.maxConcurrentOperationCount = 1; } self.callback = block; self.completion = completion; self.inputStream = [NSInputStream inputStreamWithURL:self.fileURL]; self.inputStream.delegate = self; [self.inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; [self.inputStream open]; } ``` 现在,input stream 将(在主线程)向我们发送代理消息,然后我们可以在操作队列中加入一个 block 操作来执行处理了: ```objc - (void)stream:(NSStream*)stream handleEvent:(NSStreamEvent)eventCode { switch (eventCode) { ... case NSStreamEventHasBytesAvailable: { NSMutableData *buffer = [NSMutableData dataWithLength:4 * 1024]; NSUInteger length = [self.inputStream read:[buffer mutableBytes] maxLength:[buffer length]]; if (0 < length) { [buffer setLength:length]; __weak id weakSelf = self; [self.queue addOperationWithBlock:^{ [weakSelf processDataChunk:buffer]; }]; } break; } ... } } ``` 处理数据块的过程是先查看当前已缓冲的数据,并将新加入的数据附加上去。接下来它将按照换行符分解成小的部分,并处理每一行。 数据处理过程中会不断的从buffer中获取已读入的数据。然后把这些新读入的数据按行分开并存储。剩余的数据被再次存储到缓冲区中: ```objc - (void)processDataChunk:(NSMutableData *)buffer; { if (self.remainder != nil) { [self.remainder appendData:buffer]; } else { self.remainder = buffer; } [self.remainder obj_enumerateComponentsSeparatedBy:self.delimiter usingBlock:^(NSData* component, BOOL last) { if (!last) { [self emitLineWithData:component]; } else if (0 < [component length]) { self.remainder = [component mutableCopy]; } else { self.remainder = nil; } }]; } ``` 现在你运行示例应用的话,会发现它在响应事件时非常迅速,内存的开销也保持很低(在我们测试时,不论读入的文件有多大,堆所占用的内存量始终低于 800KB)。绝大部分时候,使用逐块读入的方式来处理大文件,是非常有用的技术。 延伸阅读: * [File System Programming Guide: Techniques for Reading and Writing Files Without File Coordinators](http://developer.apple.com/library/ios/#documentation/FileManagement/Conceptual/FileSystemProgrammingGUide/TechniquesforReadingandWritingCustomFiles/TechniquesforReadingandWritingCustomFiles.html) * [StackOverflow: How to read data from NSFileHandle line by line?](http://stackoverflow.com/questions/3707427/how-to-read-data-from-nsfilehandle-line-by-line) ## 总结 通过我们所列举的几个示例,我们展示了如何异步地在后台执行一些常见任务。在所有的解决方案中,我们尽力保持了代码的简单,这是因为在并发编程中,稍不留神就会捅出篓子来。 很多时候为了避免麻烦,你可能更愿意在主线程中完成你的工作,在你能这么做事,这确实让你的工作轻松不少,但是当你发现性能瓶颈时,你可以尝试尽可能用最简单的策略将那些繁重任务放到后台去做。 我们在上面例子中所展示的方法对于其他任务来说也是安全的选择。在主队列中接收事件或者数据,然后用后台操作队列来执行实际操作,然后回到主队列去传递结果,遵循这样的原则来编写尽量简单的并行代码,将是保证高效正确的不二法则。 --- [话题 #2 下的更多文章][11] [1]: http://objccn.io/issue-2 [6]: http://developer.apple.com/library/ios/#documentation/Cocoa/Reference/NSOperationQueue_class/Reference/Reference.html [7]: https://developer.apple.com/library/ios/#documentation/Performance/Reference/GCD_libdispatch_Ref/Reference/reference.html [8]: http://www.objc.io/issue-2-1/ [9]: http://www.objc.io/issue-2-3/ [10]: https://developer.apple.com/library/mac/#documentation/cocoa/conceptual/CoreData/Articles/cdConcurrency.html [11]: https://github.com/objcio/issue-2-background-core-data [12]: http://stg.daten.berlin.de/datensaetze/vbb-fahrplan-2013 [13]: https://developers.google.com/transit/gtfs/reference [14]: http://stackoverflow.com/questions/3707427/how-to-read-data-from-nsfilehandle-line-by-line/3711079#3711079 [15]: http://floriankugler.com/blog/2013/4/29/concurrent-core-data-stack-performance-shootout [16]: http://floriankugler.com/blog/2013/5/24/layer-trees-vs-flat-drawing-graphics-performance-across-ios-device-generations [17]: https://lobste.rs/s/ckm4uw/a_performance-minded_take_on_ios_design/comments/itdkfh [18]: https://developer.apple.com/videos/wwdc/2012/ [19]: http://afnetworking.com/ [20]: https://github.com/objcio/issue-2-background-networking [21]: http://developer.apple.com/library/ios/#documentation/FileManagement/Conceptual/FileSystemProgrammingGUide/TechniquesforReadingandWritingCustomFiles/TechniquesforReadingandWritingCustomFiles.html [22]: https://github.com/objcio/issue-2-background-file-io 原文 [Common Background Practices](http://www.objc.io/issue-2/common-background-practices.html) 译文 [iOS开发中一些常见的并行处理](http://blog.jobbole.com/52557/) URL: https://onevcat.com/2014/02/ios-test-with-kiwi/index.html.md Published At: 2014-02-14 01:06:19 +0900 # TDD的iOS开发初步以及Kiwi使用入门 测试驱动开发(Test Driven Development,以下简称TDD)是保证代码质量的不二法则,也是先进程序开发的共识。Apple一直致力于在iOS开发中集成更加方便和可用的测试,在Xcode 5中,新的IDE和SDK引入了XCTest来替代原来的SenTestingKit,并且取消了新建工程时的“包括单元测试”的可选项(同样待遇的还有使用ARC的可选项)。新工程将自动包含测试的target,并且相关框架也搭建完毕,可以说测试终于摆脱了iOS开发中“二等公民”的地位,现在已经变得和产品代码一样重要了。我相信每个工程师在完成自己的业务代码的同时,也有最基本的编写和维护相应的测试代码的义务,以保证自己的代码能够正确运行。更进一步,如果能够使用TDD来进行开发,不仅能保证代码运行的正确性,也有助于代码结构的安排和思考,有助于自身的不断提高。我在最开始进行开发时也曾对测试嗤之以鼻,但后来无数的惨痛教训让我明白那么多工程师痴迷于测试或者追求更完美的测试,是有其深刻含义的。如果您之前还没有开始为您的代码编写测试,我强烈建议,从今天开始,从现在开始(也许做不到的话,也请从下一个项目开始),编写测试,或者尝试一下TDD的开发方式。 而[Kiwi](https://github.com/allending/Kiwi)是一个iOS平台十分好用的行为驱动开发(Behavior Driven Development,以下简称BDD)的测试框架,有着非常漂亮的语法,可以写出结构性强,非常容易读懂的测试。因为国内现在有关Kiwi的介绍比较少,加上在测试这块很能很多工程师们并没有特别留意,水平层次可能相差会很远,因此在这一系列的两篇博文中,我将从头开始先简单地介绍一些TDD的概念和思想,然后从XCTest的最简单的例子开始,过渡到Kiwi的测试世界。在下一篇中我将继续深入介绍一些Kiwi的其他稍高一些的特性,以期更多的开发者能够接触并使用Kiwi这个优秀的测试框架。 ### 什么是TDD,为什么我们要TDD 测试驱动开发并不是一个很新鲜的概念了。软件开发工程师们(当然包括你我)最开始学习程序编写时,最喜欢干的事情就是编写一段代码,然后运行观察结果是否正确。如果不对就返回代码检查错误,或者是加入断点或者输出跟踪程序并找出错误,然后再次运行查看输出是否与预想一致。如果输出只是控制台的一个简单的数字或者字符那还好,但是如果输出必须在点击一系列按钮之后才能在屏幕上显示出来的东西呢?难道我们就只能一次一次地等待编译部署,启动程序然后操作UI,一直点到我们需要观察的地方么?这种行为无疑是对美好生命和绚丽青春的巨大浪费。于是有一些已经浪费了无数时间的资深工程师们突然发现,原来我们可以在代码中构建出一个类似的场景,然后在代码中调用我们之前想检查的代码,并将运行的结果与我们的设想结果在程序中进行比较,如果一致,则说明了我们的代码没有问题,是按照预期工作的。比如我们想要实现一个加法函数add,输入两个数字,输出它们相加后的结果。那么我们不妨设想我们真的拥有两个数,比如3和5,根据人人会的十以内的加法知识,我们知道答案是8.于是我们在相加后与预测的8进行比较,如果相等,则说明我们的函数实现至少对于这个例子是没有问题的,因此我们对“这个方法能正确工作”这一命题的信心就增加了。这个例子的伪码如下: ```c //Product Code add(float num1, float num 2) {...} //Test code let a = 3; let b = 5; let c = a + b; if (c == 8) { // Yeah, it works! } else { //Something wrong! } ``` 当测试足够全面和具有代表性的时候,我们便可以信心爆棚,拍着胸脯说,这段代码没问题。我们做出某些条件和假设,并以其为条件使用到被测试代码中,并比较预期的结果和实际运行的结果是否相等,这就是软件开发中测试的基本方式。 ![为什么我们要test](/assets/images/2014/kiwi-manga.png) 而TDD是一种相对于普通思维的方式来说,比较极端的一种做法。我们一般能想到的是先编写业务代码,也就是上面例子中的`add`方法,然后为其编写测试代码,用来验证产品方法是不是按照设计工作。而TDD的思想正好与之相反,在TDD的世界中,我们应该首先根据需求或者接口情况编写测试,然后再根据测试来编写业务代码,而这其实是违反传统软件开发中的先验认知的。但是我们可以举一个生活中类似的例子来说明TDD的必要性:有经验的砌砖师傅总是会先拉一条垂线,然后沿着线砌砖,因为有直线的保证,因此可以做到笔直整齐;而新入行的师傅往往二话不说直接开工,然后在一阶段完成后再用直尺垂线之类的工具进行测量和修补。TDD的好处不言自明,因为总是先测试,再编码,所以至少你的所有代码的public部分都应该含有必要的测试。另外,因为测试代码实际是要使用产品代码的,因此在编写产品代码前你将有一次深入思考和实践如何使用这些代码的机会,这对提高设计和可扩展性有很好的帮助,试想一下你测试都很难写的接口,别人(或者自己)用起来得多纠结。在测试的准绳下,你可以有目的有方向地编码;另外,因为有测试的保护,你可以放心对原有代码进行重构,而不必担心破坏逻辑。这些其实都指向了一个最终的目的:让我们快乐安心高效地工作。 在TDD原则的指导下,我们先编写测试代码。这时因为还没有对应的产品代码,所以测试代码肯定是无法通过的。在大多数测试系统中,我们使用红色来表示错误,因此一个测试的初始状态应该是红色的。接下来我们需要使用最小的代价(最少的代码)来让测试通过。通过的测试将被表示为安全的绿色,于是我们回到了绿色的状态。接下来我们可以添加一些测试例,来验证我们的产品代码的实现是否正确。如果不幸新的测试例让我们回到了红色状态,那我们就可以修改产品代码,使其回到绿色。如此反复直到各种边界和测试都进行完毕,此时我们便可以得到一个具有测试保证,鲁棒性超强的产品代码。在我们之后的开发中,因为你有这些测试的保证,你可以大胆重构这段代码或者与之相关的代码,最后只需要保证项目处于绿灯状态,你就可以保证代码没重构没有出现问题。 简单说来,TDD的基本步骤就是“红→绿→大胆重构”。 ### 使用XCTest来执行TDD Xcode 5中已经集成了XCTest的测试框架(之前版本是SenTestingKit和OCUnit),所谓测试框架,就是一组让“将测试集成到工程中”以及“编写和实践测试”变得简单的库。我们之后将通过实现一个栈数据结构的例子,来用XCTest初步实践一下TDD开发。在大家对TDD有一些直观认识之后,再转到Kiwi的介绍。如果您已经在使用XCTest或者其他的测试框架了的话,可以直接跳过本节。 首先我们用Xcode新建一个工程吧,选择模板为空项目,在`Product Name`中输入工程名字VVStack,当然您可以使用自己喜欢的名字。如果您使用过Xcode之前的版本的话,应该有留意到之前在这个界面是可以选择是否使用Unit Test的,但是现在这个选框已经被取消。 ![新建工程](/assets/images/2014/kiwi-1-1.png) 新建工程后,可以发现在工程中默认已经有一个叫做`VVStackTests`的target了,这就是我们测试时使用的target。测试部分的代码默认放到了{ProjectName}Tests的group中,现在这个group下有一个测试文件VVStackTests.m。我们的测试例不需要向别的类暴露接口,因此不需要.h文件。另外一般XCTest的测试文件都会以Tests来做文件名结尾。 ![Test文件和target](/assets/images/2014/kiwi-1-2.png) 运行测试的快捷键是`⌘U`(或者可以使用菜单的Product→Test),我们这时候直接对这个空工程进行测试,Xcode在编译项目后会使用你选择的设备或者模拟器运行测试代码。不出意外的话,这次测试将会失败,如图: ![失败的初始测试](/assets/images/2014/kiwi-1-3.png) `VVStackTests.m`是Xcode在新建工程时自动为我们添加的测试文件。因为这个文件并不长,所以我们可以将其内容全部抄录如下: ```objc #import @interface VVStackTests : XCTestCase @end @implementation VVStackTests - (void)setUp { [super setUp]; // Put setup code here. This method is called before the invocation of each test method in the class. } - (void)tearDown { // Put teardown code here. This method is called after the invocation of each test method in the class. [super tearDown]; } - (void)testExample { XCTFail(@"No implementation for \"%s\"", __PRETTY_FUNCTION__); } @end ``` 可以看到,`VVStackTests`是`XCTestCase`的子类,而`XCTestCase`正是XCTest测试框架中的测试用例类。XCTest在进行测试时将会寻找测试target中的所有`XCTestCase`子类,并运行其中以`test`开头的所有实例方法。在这里,默认实现的`-testExample`将被执行,而在这个方法里,Xcode默认写了一个`XCTFail`的断言,来强制这个测试失败,用以提醒我们测试还没有实现。所谓断言,就是判断输入的条件是否满足。如果不满足,则抛出错误并输出预先规定的字符串作为提示。在这个Fail的断言一定会失败,并提示没有实现该测试。另外,默认还有两个方法`-setUp`和`-tearDown`,正如它们的注释里所述,这两个方法会分别在每个测试开始和结束的时候被调用。我们现在正要开始编写我们的测试,所以先将原来的`-testExample`删除掉。现在再使用`⌘U`来进行测试,应该可以顺利通过了(因为我们已经没有任何测试了)。 接下来让我们想想要做什么吧。我们要实现一个简单的栈数据结构,那么当然会有一个类来代表这种数据结构,在这个工程中我打算就叫它`VVStack`。按照常规,我们可以新建一个Cocoa Touch类,继承NSObject并且开始实现了。但是别忘了,我们现在在TDD,我们需要先写测试!那么首先测试的目标是什么呢?没错,是测试这个`VVStack`类是否存在,以及是否能够初始化。有了这个目标,我们就可以动手开始编写测试了。在文件开头加上`#import "VVStack.h"`,然后在`VVStackTests.m`的`@end`前面加上如下代码: ```objc - (void)testStackExist { XCTAssertNotNil([VVStack class], @"VVStack class should exist."); } - (void)testStackObjectCanBeCreated { VVStack *stack = [VVStack new]; XCTAssertNotNil(stack, @"VVStack object can be created."); } ``` 嘛,当然是不可能通过测试的,而且甚至连编译都无法完成,因为我们现在根本没有一个叫做`VVStack`的类。最简单的让测试通过的方法就是在产品代码中添加`VVStack`类。新建一个Cocoa Touch的Objective-C class,取名VVStack,作为NSObject的子类。注意在添加的时候,应该只将其加入产品的target中: ![添加类的时候注意选择合适的target](/assets/images/2014/kiwi-1-4.png) 由于`VVStack`是NSObject的子类,所以上面的两个断言应该都能通过。这时候再运行测试,成功变绿。接下来我们开始考虑这个类的功能:栈的话肯定需要能够push,并且push后的栈顶元素应该就是刚才所push进去的元素。那么建立一个push方法的测试吧,在刚才添加的代码之下继续写: ```objc - (void)testPushANumberAndGetIt { VVStack *stack = [VVStack new]; [stack push:2.3]; double topNumber = [stack top]; XCTAssertEqual(topNumber, 2.3, @"VVStack should can be pushed and has that top value."); } ``` 因为我们还没有实现`-push:`和`-top`方法,所以测试毫无疑问地失败了(在ARC环境中直接无法编译)。为了使测试立即通过我们首先需要在`VVStack.h`中声明这两个方法,然后在.m的实现文件中进行实现。令测试通过的最简单的实现是一个空的push方法以及直接返回2.3这个数: ```objc //VVStack.h @interface VVStack : NSObject - (void)push:(double)num; - (double)top; @end //VVStack.m @implementation VVStack - (void)push:(double)num { } - (double)top { return 2.3; } @end ``` 再次运行测试,我们顺利回到了绿灯状态。也许你很快就会说,这算哪门子实现啊,如果再增加一组测试例,比如push一个4.6,然后检查top,不就失败了么?我们难道不应该直接实现一个真正的合理的实现么?对此的回答是,在实际开发中,我们肯定不会以这样的步伐来处理像例子中这样类似的简单问题,而是会直接跳过一些error-try的步骤,实现一个比较完整的方案。但是在更多的时候,我们所关心和需要实现的目标并不是这样容易。特别是在对TDD还不熟悉的时候,我们有必要放慢节奏和动作,将整个开发理念进行充分实践,这样才有可能在之后更复杂的案例中正确使用。于是我们发扬不怕繁杂,精益求精的精神,在刚才的测试例上增加一个测试,回到`VVStackTests.m`中,在刚才的测试方法中加上: ```objc - (void)testPushANumberAndGetIt { //... [stack push:4.6]; topNumber = [stack top]; XCTAssertEqual(topNumber, 4.6, @"Top value of VVStack should be the last num pushed into it"); } ``` 很好,这下子我们回到了红灯状态,这正是我们所期望的,现在是时候来考虑实现这个栈了。这个实现过于简单,也有非常多的思路,其中一种是使用一个`NSMutableArray`来存储数据,然后在`top`方法里返回最后加入的数据。修改`VVStack.m`,加入数组,更改实现: ```objc //VVStack.m @interface VVStack() @property (nonatomic, strong) NSMutableArray *numbers; @end @implementation VVStack - (id)init { if (self = [super init]) { _numbers = [NSMutableArray new]; } return self; } - (void)push:(double)num { [self.numbers addObject:@(num)]; } - (double)top { return [[self.numbers lastObject] doubleValue]; } @end ``` 测试通过,注意到在`-testStackObjectCanBeCreated`和`testPushANumberAndGetIt`两个测试中都生成了一个`VVStack`对象。在这个测试文件中基本每个测试都会需要初始化对象,因此我们可以考虑在测试文件中添加一个VVStack的实例成员,并将测试中的初始化代码移到`-setUp`中,并在`-tearDown`中释放。 接下来我们可以模仿继续实现`pop`等栈的方法。鉴于篇幅这里不再继续详细实现,大家可以自己动手试试看。记住先实现测试,然后再实现产品代码。一开始您可能会觉得这很无聊,效率低下,但是请记住这是起步练习不可缺少的一部分,而且在我们的例子中其实一切都是以“慢动作”在进行的。相信在经过实践和使用后,您将会逐渐掌握自己的节奏和重点测试。关于使用XCTest到这里为止的代码,可以在[github](https://github.com/onevcat/VVStack/tree/xctest)上找到。 ### Kiwi和BDD的测试思想 `XCTest`是基于OCUnit的传统测试框架,在书写性和可读性上都不太好。在测试用例太多的时候,由于各个测试方法是割裂的,想在某个很长的测试文件中找到特定的某个测试并搞明白这个测试是在做什么并不是很容易的事情。所有的测试都是由断言完成的,而很多时候断言的意义并不是特别的明确,对于项目交付或者新的开发人员加入时,往往要花上很大成本来进行理解或者转换。另外,每一个测试的描述都被写在断言之后,夹杂在代码之中,难以寻找。使用XCTest测试另外一个问题是难以进行[mock或者stub](http://www.mockobjects.com),而这在测试中是非常重要的一部分(关于mock测试的问题,我会在下一篇中继续深入)。 行为驱动开发(BDD)正是为了解决上述问题而生的,作为第二代敏捷方法,BDD提倡的是通过将测试语句转换为类似自然语言的描述,开发人员可以使用更符合大众语言的习惯来书写测试,这样不论在项目交接/交付,或者之后自己修改时,都可以顺利很多。如果说作为开发者的我们日常工作是写代码,那么BDD其实就是在讲故事。一个典型的BDD的测试用例包活完整的三段式上下文,测试大多可以翻译为`Given..When..Then`的格式,读起来轻松惬意。BDD在其他语言中也已经有一些框架,包括最早的Java的JBehave和赫赫有名的Ruby的[RSpec](http://rspec.info)和[Cucumber](http://cukes.info)。而在objc社区中BDD框架也正在欣欣向荣地发展,得益于objc的语法本来就非常接近自然语言,再加上[C语言宏的威力](http://onevcat.com/2014/01/black-magic-in-macro/),我们是有可能写出漂亮优美的测试的。在objc中,现在比较流行的BDD框架有[cedar](https://github.com/pivotal/cedar),[specta](https://github.com/specta/specta)和[Kiwi](https://github.com/allending/Kiwi)。其中个人比较喜欢Kiwi,使用Kiwi写出的测试看起来大概会是这个样子的: ```objc describe(@"Team", ^{ context(@"when newly created", ^{ it(@"should have a name", ^{ id team = [Team team]; [[team.name should] equal:@"Black Hawks"]; }); it(@"should have 11 players", ^{ id team = [Team team]; [[[team should] have:11] players]; }); }); }); ``` 我们很容易根据上下文将其提取为`Given..When..Then`的三段式自然语言 > Given a team, when newly created, it should have a name, and should have 11 players 很简单啊有木有!在这样的语法下,是不是写测试的兴趣都被激发出来了呢。关于Kiwi的进一步语法和使用,我们稍后详细展开。首先来看看如何在项目中添加Kiwi框架吧。 ### 在项目中添加Kiwi 最简单和最推荐的方法当然是[CocoaPods](http://cocoapods.org),如果您对CocoaPods还比较陌生的话,推荐您花时间先看一看这篇[CocoaPods的简介](http://blog.devtang.com/blog/2012/12/02/use-cocoapod-to-manage-ios-lib-dependency/)。Xcode 5和XCTest环境下,我们需要在Podfile中添加类似下面的条目(记得将`VVStackTests`换成您自己的项目的测试target的名字): ``` target :VVStackTests, :exclusive => true do pod 'Kiwi/XCTest' end ``` 之后`pod install`以后,打开生成的`xcworkspace`文件,Kiwi就已经处于可用状态了。另外,为了我们在新建测试的时候能省点事儿,可以在官方repo里下载并运行安装[Kiwi的Xcode Template](https://github.com/allending/Kiwi/tree/master/Xcode%20Templates)。如果您坚持不用CocoaPods,而想要自己进行配置Kiwi的话,可以参考[这篇wiki](https://github.com/allending/Kiwi/wiki/Setting-Up-Kiwi-2.x-without-CocoaPods)。 ### 行为描述(Specs)和期望(Expectations),Kiwi测试的基本结构 我们先来新建一个Kiwi测试吧。如果安装了Kiwi的Template的话,在新建文件中选择`Kiwi/Kiwi Spec`来建立一个Specs,取名为`SimpleString`,注意选择目标target为我们的测试target,模板将会在新建的文件名字后面加上Spec后缀。传统测试的文件名一般以Tests为后缀,表示这个文件中含有一组测试,而在Kiwi中,一个测试文件所包含的是一组对于行为的描述(Spec),因此习惯上使用需要测试的目标类来作为名字,并以Spec作为文件名后缀。在Xcode 5中建立测试时已经不会同时创建.h文件了,但是现在的模板中包含有对同名.h的引用,可以在创建后将其删去。如果您没有安装Kiwi的Template的话,可以直接创建一个普通的Objective-C test case class,然后将内容替换为下面这样: ```objc #import SPEC_BEGIN(SimpleStringSpec) describe(@"SimpleString", ^{ }); SPEC_END ``` 你可能会觉得这不是objc代码,甚至怀疑这些语法是否能够编译通过。其实`SPEC_BEGIN`和`SPEC_END`都是宏,它们定义了一个`KWSpec`的子类,并将其中的内容包装在一个函数中(有兴趣的朋友不妨点进去看看)。我们现在先添加一些描述和测试语句,并运行看看吧,将上面的代码的`SPEC_BEGIN`和`SPEC_END`之间的内容替换为: ```objc describe(@"SimpleString", ^{ context(@"when assigned to 'Hello world'", ^{ NSString *greeting = @"Hello world"; it(@"should exist", ^{ [[greeting shouldNot] beNil]; }); it(@"should equal to 'Hello world'", ^{ [[greeting should] equal:@"Hello world"]; }); }); }); ``` `describe`描述需要测试的对象内容,也即我们三段式中的`Given`,`context`描述测试上下文,也就是这个测试在`When`来进行,最后`it`中的是测试的本体,描述了这个测试应该满足的条件,三者共同构成了Kiwi测试中的行为描述。它们是可以nest的,也就是一个Spec文件中可以包含多个`describe`(虽然我们很少这么做,一个测试文件应该专注于测试一个类);一个`describe`可以包含多个`context`,来描述类在不同情景下的行为;一个`context`可以包含多个`it`的测试例。让我们运行一下这个测试,观察输出: ``` VVStack[36517:70b] + 'SimpleString, when assigned to 'Hello world', should exist' [PASSED] VVStack[36517:70b] + 'SimpleString, when assigned to 'Hello world', should equal to 'Hello world'' [PASSED] ``` 可以看到,这三个关键字的描述将在测试时被依次打印出来,形成一个完整的行为描述。除了这三个之外,Kiwi还有一些其他的行为描述关键字,其中比较重要的包括 * `beforeAll(aBlock)` - 当前scope内部的所有的其他block运行之前调用一次 * `afterAll(aBlock)` - 当前scope内部的所有的其他block运行之后调用一次 * `beforeEach(aBlock)` - 在scope内的每个it之前调用一次,对于`context`的配置代码应该写在这里 * `afterEach(aBlock)` - 在scope内的每个it之后调用一次,用于清理测试后的代码 * `specify(aBlock)` - 可以在里面直接书写不需要描述的测试 * `pending(aString, aBlock)` - 只打印一条log信息,不做测试。这个语句会给出一条警告,可以作为一开始集中书写行为描述时还未实现的测试的提示。 * `xit(aString, aBlock)` - 和`pending`一样,另一种写法。因为在真正实现时测试时只需要将x删掉就是`it`,但是pending语意更明确,因此还是推荐pending 可以看到,由于有`context`的存在,以及其可以嵌套的特性,测试的流程控制相比传统测试可以更加精确。我们更容易把before和after的作用区域限制在合适的地方。 实际的测试写在`it`里,是由一个一个的期望(Expectations)来进行描述的,期望相当于传统测试中的断言,要是运行的结果不能匹配期望,则测试失败。在Kiwi中期望都由`should`或者`shouldNot`开头,并紧接一个或多个判断的的链式调用,大部分常见的是be或者haveSomeCondition的形式。在我们上面的例子中我们使用了should not be nil和should equal两个期望来确保字符串赋值的行为正确。其他的期望语句非常丰富,并且都符合自然语言描述,所以并不需要太多介绍。在使用的时候不妨直接按照自己的想法来描述自己的期望,一般情况下在IDE的帮助下我们都能找到想要的结果。如果您想看看完整的期望语句的列表,可以参看文档的[这个页面](https://github.com/allending/Kiwi/wiki/Expectations)。另外,您还可以通过新建`KWMatcher`的子类,来简单地自定义自己和项目所需要的期望语句。从这一点来看,Kiwi可以说是一个非常灵活并具有可扩展性的测试框架。 到此为止的代码可以从[这里](https://github.com/onevcat/VVStack/tree/kiwi-start)找到。 ### Kiwi实际使用实例 最后我们来用Kiwi完整地实现VVStack类的测试和开发吧。首先重写刚才XCTest的相关测试:新建一个VVStackSpec作为Kiwi版的测试用例,然后把describe换成下面的代码: ```objc describe(@"VVStack", ^{ context(@"when created", ^{ __block VVStack *stack = nil; beforeEach(^{ stack = [VVStack new]; }); afterEach(^{ stack = nil; }); it(@"should have the class VVStack", ^{ [[[VVStack class] shouldNot] beNil]; }); it(@"should exist", ^{ [[stack shouldNot] beNil]; }); it(@"should be able to push and get top", ^{ [stack push:2.3]; [[theValue([stack top]) should] equal:theValue(2.3)]; [stack push:4.6]; [[theValue([stack top]) should] equal:4.6 withDelta:0.001]; }); }); }); ``` 看到这里的您看这段测试应该不成问题。需要注意的有两点:首先`stack`分别是在`beforeEach`和`afterEach`的block中的赋值的,因此我们需要在声明时在其前面加上`__block`标志。其次,期望描述的should或者shouldNot是作用在对象上的宏,因此对于标量,我们需要先将其转换为对象。Kiwi为我们提供了一个标量转对象的语法糖,叫做`theValue`,在做精确比较的时候我们可以直接使用例子中直接与2.3做比较这样的写法来进行对比。但是如果测试涉及到运算的话,由于浮点数精度问题,我们一般使用带有精度的比较期望来进行描述,即4.6例子中的`equal:withDelta:`(当然,这里只是为了demo,实际在这用和上面2.3一样的方法就好了)。 接下来我们再为这个context添加一个测试例,用来测试初始状况时栈是否为空。因为我们使用了一个Array来作为存储容器,根据我们之前用过的equal方法,我们很容易想到下面这样的测试代码 ```objc it(@"should equal contains 0 element", ^{ [[theValue([stack.numbers count]) should] equal:theValue(0)]; }); ``` 这段测试在逻辑上没有太大问题,但是有非常多值得改进的地方。首先如果我们需要将原来写在Extension里的`numbers`暴露到头文件中,这对于类的封装是一种破坏,对于这个,一种常见的做法是只暴露一个`-count`方法,让其返回`numbers`的元素个数,从而保证`numbers`的私有性。另外对于取值和转换,其实theValue的存在在一定程度上是破坏了测试可读性的,我们可以想办法改善一下,比如对于0的来说,我们有`beZero`这样的期望可以使用。简单改写以后,这个`VVStack.h`和这个测试可以变成这个样子: ```objc //VVStack.h //... - (NSUInteger)count; //... //VVStack.m //... - (NSUInteger)count { return [self.numbers count]; } //... it(@"should equal contains 0 element", ^{ [[theValue([stack count]) should] beZero]; }); ``` 更进一步地,对于一个collection来说,Kiwi有一些特殊处理,比如`have`和`haveCountOf`系列的期望。如果测试的对象实现了`-count`方法的话,我们就可以使用这一系列期望来写出更好的测试语句。比如上面的测试还可以进一步写成 ```objc it(@"should equal contains 0 element", ^{ [[stack should] haveCountOf:0]; }); ``` 在这种情况下,我们并没有显式地调用VVStack的`-count`方法,所以我们可以在头文件中将其删掉。但是我们需要保留这个方法的实现,因为测试时是需要这个方法的。如果测试对象不能响应count方法的话,如你所料,测试时会扔一个unrecognized selector的错。Kiwi的内部实现是一个大量依赖了一个个行为Matcher和objc的消息转发,对objcruntime特性比较熟悉,并想更深入的朋友不放可以看看Kiwi的源码,写得相当漂亮。 其实对于这个测试,我们还可以写出更漂亮的版本,像这样: ```objc it(@"should equal contains 0 element", ^{ [[stack should] beEmpty]; }); ``` 好了。关于空栈这个情景下的测试感觉差不多了。我们继续用TDD的思想来完善`VVStack`类吧。栈的话,我们当然需要能够`-pop`,也就是说在(Given)给定一个栈时,(When)当栈中有元素的时候,(Then)我们可以pop它,并且得到栈顶元素。我们新建一个context,然后按照这个思路书写行为描述(测试): ```objc context(@"when new created and pushed 4.6", ^{ __block VVStack *stack = nil; beforeEach(^{ stack = [VVStack new]; [stack push:4.6]; }); afterEach(^{ stack = nil; }); it(@"can be poped and the value equals 4.6", ^{ [[theValue([stack pop]) should] equal:theValue(4.6)]; }); it(@"should contains 0 element after pop", ^{ [stack pop]; [[stack should] beEmpty]; }); }); ``` 完成了测试书写后,我们开始按照设计填写产品代码。在VVStack.h中完成申明,并在.m中加入相应实现。 ```objc - (double)pop { double result = [self top]; [self.numbers removeLastObject]; return result; } ``` 很简单吧。而且因为有测试的保证,我们在提供像Stack这样的基础类时,就不需要等到或者在真实的环境中检测了。因为在被别人使用之前,我们自己的测试代码已经能够保证它的正确性了。`VVStack`剩余的最后一个小问题是,在栈是空的时候,我们执行pop操作时应该给出一个错误,用以提示空栈无法pop。虽然在objc中异常并不常见,但是在这个情景下是抛异常的好时机,也符合一般C语言对于出空栈的行为。我们可以在之前的“when created”上下文中加入一个期望: ```objc it(@"should raise a exception when pop", ^{ [[theBlock(^{ [stack pop]; }) should] raiseWithName:@"VVStackPopEmptyException"]; }); ``` 和`theValue`配合标量值类似,`theBlock`也是Kiwi中的一个转换语法,用来将一段程序转换为相应的matcher,使其可以被施加期望。这里我们期望空的Stack在被pop时抛出一个叫做"VVStackPopEmptyException"的异常。我们可以重构pop方法,在栈为空时给一个异常: ```objc - (double)pop { if ([self count] == 0) { [NSException raise:@"VVStackPopEmptyException" format:@"Can not pop an empty stack."]; } double result = [self top]; [self.numbers removeLastObject]; return result; } ``` ### 进一步的Kiwi VVStack的测试和实现就到这里吧,根据这套测试,您可以使用自己的实现来轻易地重构这个类,而不必担心破坏它的公共接口的行为。如果需要添加新的功能或者修正已有bug的时候,我们也可以通过添加或者修改相应的测试,来确保正确性。我将会在下一篇博文中继续介绍Kiwi,看看Kiwi在异步测试和mock/stub的使用和表现如何。Kiwi现在还在比较快速的发展中,官方repo的[wiki](https://github.com/allending/Kiwi/wiki)上有一些不错的资料和文档,可以参考。`VVStack`的项目代码可以在[这个repo](https://github.com/onevcat/VVStack)上找到,可以作为参考。 另外,Kiwi 不仅可以用来做简单的特性测试,它也包括了完整的 mock 和 stub 测试的功能。关于这部分内容我补充了一篇[文章](http://onevcat.com/2014/05/kiwi-mock-stub-test/)专门说明,有兴趣的同学不妨继续深入看看。 URL: https://onevcat.com/2014/01/black-magic-in-macro/index.html.md Published At: 2014-01-17 01:03:36 +0900 # 宏定义的黑魔法 - 宏菜鸟起飞手册 宏定义在C系开发中可以说占有举足轻重的作用。底层框架自不必说,为了编译优化和方便,以及跨平台能力,宏被大量使用,可以说底层开发离开define将寸步难行。而在更高层级进行开发时,我们会将更多的重心放在业务逻辑上,似乎对宏的使用和依赖并不多。但是使用宏定义的好处是不言自明的,在节省工作量的同时,代码可读性大大增加。如果想成为一个能写出漂亮优雅代码的开发者,宏定义绝对是必不可少的技能(虽然宏本身可能并不漂亮优雅XD)。但是因为宏定义对于很多人来说,并不像业务逻辑那样是每天会接触的东西。即使是能偶尔使用到一些宏,也更多的仅仅只停留在使用的层级,却并不会去探寻背后发生的事情。有一些开发者确实也有探寻的动力和意愿,但却在点开一个定义之后发现还有宏定义中还有其他无数定义,再加上满屏幕都是不同于平时的代码,既看不懂又不变色,于是乎心生烦恼,怒而回退。本文希望通过循序渐进的方式,通过几个例子来表述C系语言宏定义世界中的一些基本规则和技巧,从0开始,希望最后能让大家至少能看懂和还原一些相对复杂的宏。考虑到我自己现在objc使用的比较多,这个站点的读者应该也大多是使用objc的,所以有部分例子是选自objc,但是本文的大部分内容将是C系语言通用。 ### 入门 如果您完全不知道宏是什么的话,可以先来热个身。很多人在介绍宏的时候会说,宏嘛很简单,就是简单的查找替换嘛。嗯,只说对了的一半。C中的宏分为两类,对象宏(object-like macro)和函数宏(function-like macro)。对于对象宏来说确实相对简单,但却也不是那么简单的查找替换。对象宏一般用来定义一些常数,举个例子: ```c //This defines PI #define M_PI 3.14159265358979323846264338327950288 ``` `#define`关键字表明即将开始定义一个宏,紧接着的`M_PI`是宏的名字,空格之后的数字是内容。类似这样的`#define X A`的宏是比较简单的,在编译时编译器会在语义分析认定是宏后,将X替换为A,这个过程称为宏的展开。比如对于上面的`M_PI` ```c #define M_PI 3.14159265358979323846264338327950288 double r = 10.0; double circlePerimeter = 2 * M_PI * r; // => double circlePerimeter = 2 * 3.14159265358979323846264338327950288 * r; printf("Pi is %0.7f",M_PI); //Pi is 3.1415927 ``` 那么让我们开始看看另一类宏吧。函数宏顾名思义,就是行为类似函数,可以接受参数的宏。具体来说,在定义的时候,如果我们在宏名字后面跟上一对括号的话,这个宏就变成了函数宏。从最简单的例子开始,比如下面这个函数宏 ```c //A simple function-like macro #define SELF(x) x NSString *name = @"Macro Rookie"; NSLog(@"Hello %@",SELF(name)); // => NSLog(@"Hello %@",name); // => Hello Macro Rookie ``` 这个宏做的事情是,在编译时如果遇到`SELF`,并且后面带括号,并且括号中的参数个数与定义的相符,那么就将括号中的参数换到定义的内容里去,然后替换掉原来的内容。 具体到这段代码中,`SELF`接受了一个name,然后将整个SELF(name)用name替换掉。嗯..似乎很简单很没用,身经百战阅码无数的你一定会认为这个宏是写出来卖萌的。那么接受多个参数的宏肯定也不在话下了,例如这样的: ```c #define PLUS(x,y) x + y printf("%d",PLUS(3,2)); // => printf("%d",3 + 2); // => 5 ``` 相比对象宏来说,函数宏要复杂一些,但是看起来也相当简单吧?嗯,那么现在热身结束,让我们正式开启宏的大门吧。 ### 宏的世界,小有乾坤 因为宏展开其实是编辑器的预处理,因此它可以在更高层级上控制程序源码本身和编译流程。而正是这个特点,赋予了宏很强大的功能和灵活度。但是凡事都有两面性,在获取灵活的背后,是以需要大量时间投入以对各种边界情况进行考虑来作为代价的。可能这么说并不是很能让人理解,但是大部分宏(特别是函数宏)背后都有一些自己的故事,挖掘这些故事和设计的思想会是一件很有意思的事情。另外,我一直相信在实践中学习才是真正掌握知识的唯一途径,虽然可能正在看这篇博文的您可能最初并不是打算亲自动手写一些宏,但是这我们不妨开始动手从实际的书写和犯错中进行学习和挖掘,因为只有肌肉记忆和大脑记忆协同起来,才能说达到掌握的水准。可以说,写宏和用宏的过程,一定是在在犯错中学习和深入思考的过程,我们接下来要做的,就是重现这一系列过程从而提高进步。 第一个题目是,让我们一起来实现一个`MIN`宏吧:实现一个函数宏,给定两个数字输入,将其替换为较小的那个数。比如`MIN(1,2)`出来的值是1。嗯哼,simple enough?定义宏,写好名字,两个输入,然后换成比较取值。比较取值嘛,任何一本入门级别的C程序设计上都会有讲啊,于是我们可以很快写出我们的第一个版本: ```c //Version 1.0 #define MIN(A,B) A < B ? A : B ``` Try一下 ```c int a = MIN(1,2); // => int a = 1 < 2 ? 1 : 2; printf("%d",a); // => 1 ``` 输出正确,打包发布! ![潇洒走一回](/assets/images/2014/shipit.png) 但是在实际使用中,我们很快就遇到了这样的情况 ```c int a = 2 * MIN(3, 4); printf("%d",a); // => 4 ``` 看起来似乎不可思议,但是我们将宏展开就知道发生什么了 ```c int a = 2 * MIN(3, 4); // => int a = 2 * 3 < 4 ? 3 : 4; // => int a = 6 < 4 ? 3 : 4; // => int a = 4; ``` 嘛,写程序这个东西,bug出来了,原因知道了,事后大家就都是诸葛亮了。因为小于和比较符号的优先级是较低的,所以乘法先被运算了,修正非常简单嘛,加括号就好了。 ```c //Version 2.0 #define MIN(A,B) (A < B ? A : B) ``` 这次`2 * MIN(3, 4)`这样的式子就轻松愉快地拿下了。经过了这次修改,我们对自己的宏信心大增了...直到,某一天一个怒气冲冲的同事跑来摔键盘,然后给出了一个这样的例子: ```c int a = MIN(3, 4 < 5 ? 4 : 5); printf("%d",a); // => 4 ``` 简单的相比较三个数字并找到最小的一个而已,要怪就怪你没有提供三个数字比大小的宏,可怜的同事只好自己实现4和5的比较。在你开始着手解决这个问题的时候,你首先想到的也许是既然都是求最小值,那写成`MIN(3, MIN(4, 5))`是不是也可以。于是你就随手这样一改,发现结果变成了3,正是你想要的..接下来,开始怀疑之前自己是不是看错结果了,改回原样,一个4赫然出现在屏幕上。你终于意识到事情并不是你想像中那样简单,于是还是回到最原始直接的手段,展开宏。 ```c int a = MIN(3, 4 < 5 ? 4 : 5); // => int a = (3 < 4 < 5 ? 4 : 5 ? 3 : 4 < 5 ? 4 : 5); //希望你还记得运算符优先级 // => int a = ((3 < (4 < 5 ? 4 : 5) ? 3 : 4) < 5 ? 4 : 5); //为了您不太纠结,我给这个式子加上了括号 // => int a = ((3 < 4 ? 3 : 4) < 5 ? 4 : 5) // => int a = (3 < 5 ? 4 : 5) // => int a = 4 ``` 找到问题所在了,由于展开时连接符号和被展开式子中的运算符号优先级相同,导致了计算顺序发生了变化,实质上和我们的1.0版遇到的问题是差不多的,还是考虑不周。那么就再严格一点吧,3.0版! ```c //Version 3.0 #define MIN(A,B) ((A) < (B) ? (A) : (B)) ``` 至于为什么2.0版本中的`MIN(3, MIN(4, 5))`没有出问题,可以正确使用,这里作为练习,大家可以试着自己展开一下,来看看发生了什么。 经过两次悲剧,你现在对这个简单的宏充满了疑惑。于是你跑了无数的测试用例而且它们都通过了,我们似乎彻底解决了括号问题,你也认为从此这个宏就妥妥儿的哦了。不过如果你真的这么想,那你就图样图森破了。生活总是残酷的,该来的bug也一定是会来的。不出意外地,在一个雾霾阴沉的下午,我们又收到了一个出问题的例子。 ```c float a = 1.0f; float b = MIN(a++, 1.5f); printf("a=%f, b=%f",a,b); // => a=3.000000, b=2.000000 ``` 拿到这个出问题的例子你的第一反应可能和我一样,这TM的谁这么二货还在比较的时候搞++,这简直乱套了!但是这样的人就是会存在,这样的事就是会发生,你也不能说人家逻辑有错误。a是1,a++表示先使用a的值进行计算,然后再加1。那么其实这个式子想要计算的是取a和b的最小值,然后a等于a加1:所以正确的输出a为2,b为1才对!嘛,满眼都是泪,让我们这些久经摧残的程序员淡定地展开这个式子,来看看这次又发生了些什么吧: ```c float a = 1.0f; float b = MIN(a++, 1.5f); // => float b = ((a++) < (1.5f) ? (a++) : (1.5f)) ``` 其实只要展开一步就很明白了,在比较a++和1.5f的时候,先取1和1.5比较,然后a自增1。接下来条件比较得到真以后又触发了一次a++,此时a已经是2,于是b得到2,最后a再次自增后值为3。出错的根源就在于我们预想的是a++只执行一次,但是由于宏展开导致了a++被多执行了,改变了预想的逻辑。解决这个问题并不是一件很简单的事情,使用的方式也很巧妙。我们需要用到一个GNU C的赋值扩展,即使用`({...})`的形式。这种形式的语句可以类似很多脚本语言,在顺次执行之后,会将最后一次的表达式的赋值作为返回。举个简单的例子,下面的代码执行完毕后a的值为3,而且b和c只存在于大括号限定的代码域中 ```c int a = ({ int b = 1; int c = 2; b + c; }); // => a is 3 ``` 有了这个扩展,我们就能做到之前很多做不到的事情了。比如彻底解决`MIN`宏定义的问题,而也正是GNU C中`MIN`的标准写法 ```c //GNUC MIN #define MIN(A,B) ({ __typeof__(A) __a = (A); __typeof__(B) __b = (B); __a < __b ? __a : __b; }) ``` 这里定义了三个语句,分别以输入的类型申明了`__a`和`__b`,并使用输入为其赋值,接下来做一个简单的条件比较,得到`__a`和`__b`中的较小值,并使用赋值扩展将结果作为返回。这样的实现保证了不改变原来的逻辑,先进行一次赋值,也避免了括号优先级的问题,可以说是一个比较好的解决方案了。如果编译环境支持GNU C的这个扩展,那么毫无疑问我们应该采用这种方式来书写我们的`MIN`宏,如果不支持这个环境扩展,那我们只有人为地规定参数不带运算或者函数调用,以避免出错。 关于`MIN`我们讨论已经够多了,但是其实还存留一个悬疑的地方。如果在同一个scope内已经有`__a`或者`__b`的定义的话(虽然一般来说不会出现这种悲剧的命名,不过谁知道呢),这个宏可能出现问题。在申明后赋值将因为定义重复而无法被初始化,导致宏的行为不可预知。如果您有兴趣,不妨自己动手试试看结果会是什么。Apple在Clang中彻底解决了这个问题,我们把Xcode打开随便建一个新工程,在代码中输入`MIN(1,1)`,然后Cmd+点击即可找到clang中 `MIN`的写法。为了方便说明,我直接把相关的部分抄录如下: ```objc //CLANG MIN #define __NSX_PASTE__(A,B) A##B #define MIN(A,B) __NSMIN_IMPL__(A,B,__COUNTER__) #define __NSMIN_IMPL__(A,B,L) ({ __typeof__(A) __NSX_PASTE__(__a,L) = (A); __typeof__(B) __NSX_PASTE__(__b,L) = (B); (__NSX_PASTE__(__a,L) < __NSX_PASTE__(__b,L)) ? __NSX_PASTE__(__a,L) : __NSX_PASTE__(__b,L); }) ``` 似乎有点长,看起来也很吃力。我们先美化一下这宏,首先是最后那个`__NSMIN_IMPL__`内容实在是太长了。我们知道代码的话是可以插入换行而不影响含义的,宏是否也可以呢?答案是肯定的,只不过我们不能使用一个单一的回车来完成,而必须在回车前加上一个反斜杠`\`。改写一下,为其加上换行好看些: ```objc #define __NSX_PASTE__(A,B) A##B #define MIN(A,B) __NSMIN_IMPL__(A,B,__COUNTER__) #define __NSMIN_IMPL__(A,B,L) ({ __typeof__(A) __NSX_PASTE__(__a,L) = (A); \ __typeof__(B) __NSX_PASTE__(__b,L) = (B); \ (__NSX_PASTE__(__a,L) < __NSX_PASTE__(__b,L)) ? __NSX_PASTE__(__a,L) : __NSX_PASTE__(__b,L); \ }) ``` 但可以看出`MIN`一共由三个宏定义组合而成。第一个`__NSX_PASTE__`里出现的两个连着的井号`##`在宏中是一个特殊符号,它表示将两个参数连接起来这种运算。注意函数宏必须是有意义的运算,因此你不能直接写`AB`来连接两个参数,而需要写成例子中的`A##B`。宏中还有一切其他的自成一脉的运算符号,我们稍后还会介绍几个。接下来是我们调用的两个参数的`MIN`,它做的事是调用了另一个三个参数的宏`__NSMIN_IMPL__`,其中前两个参数就是我们的输入,而第三个`__COUNTER__`我们似乎不认识,也不知道其从何而来。其实`__COUNTER__`是一个预定义的宏,这个值在编译过程中将从0开始计数,每次被调用时加1。因为唯一性,所以很多时候被用来构造独立的变量名称。有了上面的基础,再来看最后的实现宏就很简单了。整体思路和前面的实现和之前的GNUC MIN是一样的,区别在于为变量名`__a`和`__b`添加了一个计数后缀,这样大大避免了变量名相同而导致问题的可能性(当然如果你执拗地把变量叫做__a9527并且出问题了的话,就只能说不作死就不会死了)。 花了好多功夫,我们终于把一个简单的`MIN`宏彻底搞清楚了。宏就是这样一类东西,简单的表面之下隐藏了很多玄机,可谓小有乾坤。作为练习大家可以自己尝试一下实现一个`SQUARE(A)`,给一个数字输入,输出它的平方的宏。虽然一般这个计算现在都是用inline来做了,但是通过和`MIN`类似的思路我们是可以很好地实现它的,动手试一试吧 :) ### Log,永恒的主题 Log人人爱,它为我们指明前进方向,它为我们抓虫提供帮助。在objc中,我们最多使用的log方法就是`NSLog`输出信息到控制台了,但是NSLog的标准输出可谓残废,有用信息完全不够,比如下面这段代码: ```objc NSArray *array = @[@"Hello", @"My", @"Macro"]; NSLog (@"The array is %@", array); ``` 打印到控制台里的结果是类似这样的 ``` 2014-01-20 11:22:11.835 TestProject[23061:70b] The array is ( Hello, My, Macro ) ``` 我们在输出的时候关心什么?除了结果以外,很多情况下我们会对这行log的所在的文件位置方法什么的会比较关心。在每次NSLog里都手动加上方法名字和位置信息什么的无疑是个笨办法,而如果一个工程里已经有很多`NSLog`的调用了,一个一个手动去改的话无疑也是噩梦。我们通过宏,可以很简单地完成对`NSLog`原生行为的改进,优雅,高效。只需要在预编译的pch文件中加上 ```objc //A better version of NSLog #define NSLog(format, ...) do { \ fprintf(stderr, "<%s : %d> %s\n", \ [[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], \ __LINE__, __func__); \ (NSLog)((format), ##__VA_ARGS__); \ fprintf(stderr, "-------\n"); \ } while (0) ``` 嘛,这是我们到现在为止见到的最长的一个宏了吧...没关系,一点一点来分析就好。首先是定义部分,第2行的`NSLog(format, ...)`。我们看到的是一个函数宏,但是它的参数比较奇怪,第二个参数是`...`,在宏定义(其实也包括函数定义)的时候,写为`...`的参数被叫做可变参数(variadic)。可变参数的个数不做限定。在这个宏定义中,除了第一个参数`format`将被单独处理外,接下来输入的参数将作为整体一并看待。回想一下NSLog的用法,我们在使用NSLog时,往往是先给一个format字符串作为第一个参数,然后根据定义的格式在后面的参数里跟上写要输出的变量之类的。这里第一个格式化字符串即对应宏里的`format`,后面的变量全部映射为`...`作为整体处理。 接下来宏的内容部分。上来就是一个下马威,我们遇到了一个do while语句...想想看你上次使用do while是什么时候吧?也许是C程序设计课的大作业?或者是某次早已被遗忘的算法面试上?总之虽然大家都是明白这个语句的,但是实际中可能用到它的机会少之又少。乍一看似乎这个do while什么都没做,因为while是0,所以do肯定只会被执行一次。那么它存在的意义是什么呢,我们是不是可以直接简化一下这个宏,把它给去掉,变成这个样子呢? ```objc //A wrong version of NSLog #define NSLog(format, ...) fprintf(stderr, "<%s : %d> %s\n", \ [[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], \ __LINE__, __func__); \ (NSLog)((format), ##__VA_ARGS__); \ fprintf(stderr, "-------\n"); ``` 答案当然是否定的,也许简单的测试里你没有遇到问题,但是在生产环境中这个宏显然悲剧了。考虑下面的常见情况 ```objc if (errorHappend) NSLog(@"Oops, error happened"); ``` 展开以后将会变成 ```objc if (errorHappend) fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__); (NSLog)((format), ##__VA_ARGS__); //I will expand this later fprintf(stderr, "-------\n"); ``` 注意..C系语言可不是靠缩进来控制代码块和逻辑关系的。所以说如果使用这个宏的人没有在条件判断后加大括号的话,你的宏就会一直调用真正的NSLog输出东西,这显然不是我们想要的逻辑。当然在这里还是需要重新批评一下认为if后的单条执行语句不加大括号也没问题的同学,这是陋习,无需理由,请改正。不论是不是一条语句,也不论是if后还是else后,都加上大括号,是对别人和自己的一种尊重。 好了知道我们的宏是如何失效的,也就知道了修改的方法。作为宏的开发者,应该力求使用者在最大限度的情况下也不会出错,于是我们想到直接用一对大括号把宏内容括起来,大概就万事大吉了?像这样: ```objc //Another wrong version of NSLog #define NSLog(format, ...) { fprintf(stderr, "<%s : %d> %s\n", \ [[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], \ __LINE__, __func__); \ (NSLog)((format), ##__VA_ARGS__); \ fprintf(stderr, "-------\n"); \ } ``` 展开刚才的那个式子,结果是 ```objc //I am sorry if you don't like { in the same like. But I am a fan of this style :P if (errorHappend) { fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__); (NSLog)((format), ##__VA_ARGS__); fprintf(stderr, "-------\n"); }; ``` 编译,执行,正确!因为用大括号标识代码块是不会嫌多的,所以这样一来的话我们的宏在不论if后面有没有大括号的情况下都能工作了!这么看来,前面例子中的do while果然是多余的?于是我们又可以愉快地发布了?如果你够细心的话,可能已经发现问题了,那就是上面最后的一个分号。虽然编译运行测试没什么问题,但是始终稍微有些刺眼有木有?没错,因为我们在写NSLog本身的时候,是将其当作一条语句来处理的,后面跟了一个分号,在宏展开后,这个分号就如同噩梦一般的多出来了。什么,你还没看出哪儿有问题?试试看展开这个例子吧: ```objc if (errorHappend) NSLog(@"Oops, error happened"); else //Yep, no error, I am happy~ :) ``` No! I am not haapy at all! 因为编译错误了!实际上这个宏展开以后变成了这个样子: ```objc if (errorHappend) { fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__); (NSLog)((format), ##__VA_ARGS__); fprintf(stderr, "-------\n"); }; else { //Yep, no error, I am happy~ :) } ``` 因为else前面多了一个分号,导致了编译错误,很恼火..要是写代码的人乖乖写大括号不就啥事儿没有了么?但是我们还是有巧妙的解决方法的,那就是上面的do while。把宏的代码块添加到do中,然后之后while(0),在行为上没有任何改变,但是可以巧妙地吃掉那个悲剧的分号,使用do while的版本展开以后是这个样子的 ```objc if (errorHappend) do { fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__); (NSLog)((format), ##__VA_ARGS__); fprintf(stderr, "-------\n"); } while (0); else { //Yep, no error, I am really happy~ :) } ``` 这个吃掉分号的方法被大量运用在代码块宏中,几乎已经成为了标准写法。而且while(0)的好处在于,在编译的时候,编译器基本都会为你做好优化,把这部分内容去掉,最终编译的结果不会因为这个do while而导致运行效率上的差异。在终于弄明白了这个奇怪的do while之后,我们终于可以继续深入到这个宏里面了。宏本体内容的第一行没有什么值得多说的`fprintf(stderr, "<%s : %d> %s\n",`,简单的格式化输出而已。注意我们使用了`\`将这个宏分成了好几行来写,实际在最后展开时会被合并到同一行内,我们在刚才`MIN`最后也用到了反斜杠,希望你还能记得。接下来一行我们填写这个格式输出中的三个token, ``` [[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__); ``` 这里用到了三个预定义宏,和刚才的`__COUNTER__`类似,预定义宏的行为是由编译器指定的。`__FILE__`返回当前文件的绝对路径,`__LINE__`返回展开该宏时在文件中的行数,`__func__`是改宏所在scope的函数名称。我们在做Log输出时如果带上这这三个参数,便可以加快解读Log,迅速定位。关于编译器预定义的Log以及它们的一些实现机制,感兴趣的同学可以移步到gcc文档的[PreDefine页面](http://gcc.gnu.org/onlinedocs/cpp/Predefined-Macros.html#Predefined-Macros)和clang的[Builtin Macro](http://clang.llvm.org/docs/LanguageExtensions.html#builtin-macros)进行查看。在这里我们将格式化输出的三个参数分别设定为文件名的最后一个部分(因为绝对路径太长很难看),行数,以及方法名称。 接下来是还原原始的NSLog,`(NSLog)((format), ##__VA_ARGS__);`中出现了另一个预定义的宏`__VA_ARGS__`(我们似乎已经找出规律了,前后双下杠的一般都是预定义)。`__VA_ARGS__`表示的是宏定义中的`...`中的所有剩余参数。我们之前说过可变参数将被统一处理,在这里展开的时候编译器会将`__VA_ARGS__`直接替换为输入中从第二个参数开始的剩余参数。另外一个悬疑点是在它前面出现了两个井号`##`。还记得我们上面在`MIN`中的两个井号么,在那里两个井号的意思是将前后两项合并,在这里做的事情比较类似,将前面的格式化字符串和后面的参数列表合并,这样我们就得到了一个完整的NSLog方法了。之后的几行相信大家自己看懂也没有问题了,最后输出一下试试看,大概看起来会是这样的。 ``` ------- -[AppDelegate application:didFinishLaunchingWithOptions:] 2014-01-20 16:44:25.480 TestProject[30466:70b] The array is ( Hello, My, Macro ) ------- ``` 带有文件,行号和方法的输出,并且用横杠隔开了(请原谅我没有质感的设计,也许我应该画一只牛,比如这样?),debug的时候也许会轻松一些吧 :) ![hello cowsay](/assets/images/2014/cowsay-lolcat.png) 这个Log有三个悬念点,首先是为什么我们要把format单独写出来,然后吧其他参数作为可变参数传递呢?如果我们不要那个format,而直接写成`NSLog(...)`会不会有问题?对于我们这里这个例子来说的话是没有变化的,但是我们需要记住的是`...`是可变参数列表,它可以代表一个、两个,或者是很多个参数,但同时它也能代表零个参数。如果我们在申明这个宏的时候没有指定format参数,而直接使用参数列表,那么在使用中不写参数的NSLog()也将被匹配到这个宏中,导致编译无法通过。如果你手边有Xcode,也可以看看Cocoa中真正的NSLog方法的实现,可以看到它也是接收一个格式参数和一个参数列表的形式,我们在宏里这么定义,正是为了其传入正确合适的参数,从而保证使用者可以按照原来的方式正确使用这个宏。 第二点是既然我们的可变参数可以接受任意个输入,那么在只有一个format输入,而可变参数个数为零的时候会发生什么呢?不妨展开看一看,记住`##`的作用是拼接前后,而现在`##`之后的可变参数是空: ``` NSLog(@"Hello"); => do { fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__); (NSLog)((@"Hello"), ); fprintf(stderr, "-------\n"); } while (0); ``` 中间的一行`(NSLog)(@"Hello", );`似乎是存在问题的,你一定会有疑惑,这种方式怎么可能编译通过呢?!原来大神们其实早已想到这个问题,并且进行了一点特殊的处理。这里有个特殊的规则,在`逗号`和`__VA_ARGS__`之间的双井号,除了拼接前后文本之外,还有一个功能,那就是如果后方文本为空,那么它会将前面一个逗号吃掉。这个特性当且仅当上面说的条件成立时才会生效,因此可以说是特例。加上这条规则后,我们就可以将刚才的式子展开为正确的`(NSLog)((@"Hello"));`了。 最后一个值得讨论的地方是`(NSLog)((format), ##__VA_ARGS__);`的括号使用。把看起来能去掉的括号去掉,写成`NSLog(format, ##__VA_ARGS__);`是否可以呢?在这里的话应该是没有什么大问题的,首先format不会被调用多次也不太存在误用的可能性(因为最后编译器会检查NSLog的输入是否正确)。另外你也不用担心展开以后式子里的NSLog会再次被自己展开,虽然展开式中NSLog也满足了我们的宏定义,但是宏的展开非常聪明,展开后会自身无限循环的情况,就不会再次被展开了。 作为一个您读到了这里的小奖励,附送三个debug输出rect,size和point的宏,希望您能用上(嗯..想想曾经有多少次你需要打印这些结构体的某个数字而被折磨致死,让它们玩儿蛋去吧!当然请先加油看懂它们吧) ``` #define NSLogRect(rect) NSLog(@"%s x:%.4f, y:%.4f, w:%.4f, h:%.4f", #rect, rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) #define NSLogSize(size) NSLog(@"%s w:%.4f, h:%.4f", #size, size.width, size.height) #define NSLogPoint(point) NSLog(@"%s x:%.4f, y:%.4f", #point, point.x, point.y) ``` ### 两个实际应用的例子 当然不是说上面介绍的宏实际中不能用。它们相对简单,但是里面坑不少,所以显得很有特点,非常适合作为入门用。而实际上在日常中很多我们常用的宏并没有那么多奇怪的问题,很多时候我们按照想法去实现,再稍微注意一下上述介绍的可能存在的共通问题,一个高质量的宏就可以诞生。如果能写出一些有意义价值的宏,小了从对你的代码的使用者来说,大了从整个社区整个世界和减少碳排放来说,你都做出了相当的贡献。我们通过几个实际的例子来看看,宏是如何改变我们的生活,和写代码的习惯的吧。 先来看看这两个宏 ```objc #define XCTAssertTrue(expression, format...) \ _XCTPrimitiveAssertTrue(expression, ## format) #define _XCTPrimitiveAssertTrue(expression, format...) \ ({ \ @try { \ BOOL _evaluatedExpression = !!(expression); \ if (!_evaluatedExpression) { \ _XCTRegisterFailure(_XCTFailureDescription(_XCTAssertion_True, 0, @#expression),format); \ } \ } \ @catch (id exception) { \ _XCTRegisterFailure(_XCTFailureDescription(_XCTAssertion_True, 1, @#expression, [exception reason]),format); \ }\ }) ``` 如果您常年做苹果开发,却没有见过或者完全不知道`XCTAssertTrue`是什么的话,强烈建议补习一下测试驱动开发的相关知识,我想应该会对您之后的道路很有帮助。如果你已经很熟悉这个命令了,那我们一起开始来看看幕后发生了什么。 有了上面的基础,相信您大体上应该可以自行解读这个宏了。`({...})`的语法和`##`都很熟悉了,这里有三个值得注意的地方,在这个宏的一开始,我们后面的的参数是`format...`,这其实也是可变参数的一种写法,和`...`与`__VA_ARGS__`配对类似,`{NAME}...`将于`{NAME}`配对使用。也就是说,在这里宏内容的`format`指代的其实就是定义的先对`expression`取了两次反?我不是科班出身,但是我还能依稀记得这在大学程序课上讲过,两次取反的操作可以确保结果是BOOL值,这在objc中还是比较重要的(关于objc中BOOL的讨论已经有很多,如果您还没能分清BOOL, bool和Boolean,可以参看[NSHisper的这篇文章](http://nshipster.com/bool/))。然后就是`@#expression`这个式子。我们接触过双井号`##`,而这里我们看到的操作符是单井号`#`,注意井号前面的`@`是objc的编译符号,不属于宏操作的对象。单个井号的作用是字符串化,简单来说就是将替换后在两头加上"",转为一个C字符串。这里使用@然后紧跟#expression,出来后就是一个内容是expression的内容的NSString。然后这个NSString再作为参数传递给`_XCTRegisterFailure`和`_XCTFailureDescription`等,继续进行展开,这些是后话。简单一瞥,我们大概就可以想象宏帮助我们省了多少事儿了,如果各位看官要是写个断言还要来个十多行的话,想象都会疯掉的吧。 另外一个例子,找了人民群众喜闻乐见的[ReactiveCocoa(RAC)](https://github.com/ReactiveCocoa/ReactiveCocoa)中的一个宏定义。对于RAC不熟悉或者没听过的朋友,可以简单地看看[Limboy的一系列相关博文](http://blog.leezhong.com)(搜索ReactiveCocoa),介绍的很棒。如果觉得“哇哦这个好酷我很想学”的话,不妨可以跟随raywenderlich上这个[系列的教程](http://www.raywenderlich.com/55384/ios-7-best-practices-part-1)做一些实践,里面简单地用到了RAC,但是都已经包含了RAC的基本用法了。RAC中有几个很重要的宏,它们是保证RAC简洁好用的基本,可以说要是没有这几个宏的话,是不会有人喜欢RAC的。其中`RACObserve`就是其中一个,它通过KVC来为对象的某个属性创建一个信号返回(如果你看不懂这句话,不要担心,这对你理解这个宏的写法和展开没有任何影响)。对于这个宏,我决定不再像上面那样展开和讲解,我会在最后把相关的宏都贴出来,大家不妨拿它练练手,看看能不能将其展开到代码的状态,并且明白其中都发生了些什么。如果你遇到什么问题或者在展开过程中有所心得,欢迎在评论里留言分享和交流 :) 好了,这篇文章已经够长了。希望在看过以后您在看到宏的时候不再发怵,而是可以很开心地说这个我会这个我会这个我也会。最终目标当然是写出漂亮高效简洁的宏,这不论对于提高生产力还是~~~震慑你的同事~~~提升自己实力都会很有帮助。 另外,在这里一定要宣传一下关注了很久的[@hangcom](http://weibo.com/hangcom) 吴航前辈的新书《iOS应用逆向工程》。很荣幸能够在发布之前得到前辈的允许拜读了整本书,可以说看的畅快淋漓。我之前并没有越狱开发的任何基础,也对相关领域知之甚少,在这样的前提下跟随书中的教程和例子进行探索的过程可以说是十分有趣。我也得以能够用不同的眼光和高度来审视这几年所从事的iOS开发行业,获益良多。可以说《iOS应用逆向工程》是我近期所愉快阅读到的很cool的一本好书。现在这本书还在预售中,但是距离1月28日的正式发售已经很近,有兴趣的同学可以前往[亚马逊](http://www.amazon.cn/gp/product/B00HQW9AA6/ref=s9_simh_gw_p14_d0_i6?pf_rd_m=A1AJ19PSB66TGU&pf_rd_s=center-2&pf_rd_r=1KY5VBPQDKMCCWC07ANV&pf_rd_t=101&pf_rd_p=108773272&pf_rd_i=899254051)或者[ChinaPub](http://product.china-pub.com/3769262)的相关页面预定,相信这本书将会是iOS技术人员非常棒的春节读物。 最后是我们说好的留给大家玩的练习,请加油展开 `RACObserve`,看看它到底做了什么。(我加了一点注释帮助大家稍微理解每个宏是做什么的,希望能有帮助。总之,加油!) ```objc //调用 RACSignal是类的名字 RACSignal *signal = RACObserve(self, currentLocation); //以下开始是宏定义 //rac_valuesForKeyPath:observer:是方法名 #define RACObserve(TARGET, KEYPATH) \ [(id)(TARGET) rac_valuesForKeyPath:@keypath(TARGET, KEYPATH) observer:self] #define keypath(...) \ metamacro_if_eq(1, metamacro_argcount(__VA_ARGS__))(keypath1(__VA_ARGS__))(keypath2(__VA_ARGS__)) //这个宏在取得keypath的同时在编译期间判断keypath是否存在,避免误写 //您可以先不用介意这里面的巫术.. #define keypath1(PATH) \ (((void)(NO && ((void)PATH, NO)), strchr(# PATH, '.') + 1)) #define keypath2(OBJ, PATH) \ (((void)(NO && ((void)OBJ.PATH, NO)), # PATH)) //A和B是否相等,若相等则展开为后面的第一项,否则展开为后面的第二项 //eg. metamacro_if_eq(0, 0)(true)(false) => true // metamacro_if_eq(0, 1)(true)(false) => false #define metamacro_if_eq(A, B) \ metamacro_concat(metamacro_if_eq, A)(B) #define metamacro_if_eq1(VALUE) metamacro_if_eq0(metamacro_dec(VALUE)) #define metamacro_if_eq0(VALUE) \ metamacro_concat(metamacro_if_eq0_, VALUE) #define metamacro_if_eq0_1(...) metamacro_expand_ #define metamacro_expand_(...) __VA_ARGS__ #define metamacro_argcount(...) \ metamacro_at(20, __VA_ARGS__, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1) #define metamacro_at(N, ...) \ metamacro_concat(metamacro_at, N)(__VA_ARGS__) #define metamacro_concat(A, B) \ metamacro_concat_(A, B) #define metamacro_concat_(A, B) A ## B #define metamacro_at2(_0, _1, ...) metamacro_head(__VA_ARGS__) #define metamacro_at20(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, ...) metamacro_head(__VA_ARGS__) #define metamacro_head(...) \ metamacro_head_(__VA_ARGS__, 0) #define metamacro_head_(FIRST, ...) FIRST #define metamacro_dec(VAL) \ metamacro_at(VAL, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19) ``` URL: https://onevcat.com/2013/12/code-vs-xib-vs-storyboard/index.html.md Published At: 2013-12-31 01:02:21 +0900 # 代码手写UI,xib和StoryBoard间的博弈,以及Interface Builder的一些小技巧 最近接触了几个刚入门的iOS学习者,他们之中存在一个普遍和困惑和疑问,就是应该如何制作UI界面。iOS应用是非常重视用户体验的,可以说绝大多数的应用成功与否与交互设计以及UI是否漂亮易用有着非常大的关系。而随着iOS开发发展至今,可以说在UI制作上大家逐渐分化为了三种主要流派:使用代码手写UI及布局;使用单个xib文件组织viewController或者view;使用StoryBoard来通过单个或很少的几个(关于这点稍后会进行展开)文件构建全部UI。应该使用哪种方式来制作UI已经是iOS开发中亘古不变的争论话题了,或许永远不会有一个统一的结论。但是首先需要知道的是三种方式各有优劣,所以也各有自己最适用的场合,而不会有完全的孰优孰劣。对于初学iOS开发来说,一时间其实是很难判定最适合自己的UI架构方式的。在这篇文章里我希望能够通过自己的经验给出一些意见,以期能帮助入门者来挑选最适合自己应用场景的方案。对于老鸟的话,也不妨对照自己平日的使用习惯和运用场景,看看有没有可以改进或变化的地方。最后,因为我本人现在最习惯和喜欢的是用Interface Builder(之后简称IB)及xib来做UI,所以文末附上了一些IB使用时候的小技巧,算是做个总结。 ### 代码手写UI 这种方法经常被学院派的极客或者依赖多人合作的大型项目大规模使用。Geek们喜欢用代码构建UI,是因为代码是键盘敲出来的,这样可以做到不开IB,手不离开键盘就完成工作,可以专注于编码环境,看起来很cool很高效,而且不到运行时大家都不知道会是什么样子,也显出了程序员这一职业的高大上及神秘气息(这个真的不是在黑..想想大家一起在设计师背后指点江山的场景吧)。大型多人合作项目使用代码构建UI,主要是看中纯代码在版本管理时的优势,检查追踪改动以及进行代码合并相对容易一些。 另外,代码UI可以说具有最好的代码重用性。如果你的目的是写一些可以高度重用的控件提供给其他开发者使用,那毫无疑问最好的选择应该是使用代码来完成UIView的子类。这样进一步的修改和其他开发者在使用时,都会方便不少。使用代码也是最为强大的,会有xib或者StoryBoard做不了的事情,但是使用代码最终一定能够完成所要的需求。 但是代码手写UI的劣势同时也是最明显的,主要就是一个字:慢。首先相比可视化的IB来说,完成一个并不太复杂的界面,你可能需要写上数百行的UI代码。不论是初始化一个Label,还是设定一个frame或者添加一个target-action,都需要写代码,这不仅在前期极为浪费时间,在之后维护时代码定位和寻找也会很痛苦。其次,因为你无法直观地看到你能得到的结果,所以你很可能需要不断地`Cmd+R`/`Cmd+.`来修改各个视图的位置大小。即使你用上了[Reveal](http://revealapp.com)或者[RestartLessOften](https://github.com/mikr/RestartLessOften)之类的工具,也还是无法特别方便地完成需要的布局。另外加上如果需要利用AutoLayout来进行尺寸适配的话,使用代码进行约束就更加头疼了。很多时候一个无法满足的约束的问题就够来回运行修改调试很长时间了。 ### Xibs 相对于代码,使用IB和xib文件来组织UI,可以省下大量代码和时间,从而得到更快的开发速度。如果你曾经受到过微软家Visual Basic或者其他Visual系的可视化界面的荼毒与残害,因此怀疑Interface Builder的纯正血统和工作能力,建议可以看看这些资料以纠正三观:[Jean-Marie Hullot的Interface Builder神话](http://www.programmer.com.cn/9234/)以及[西装革履的青涩乔帮主在NeXT时亲手用IB构建应用](http://www.youtube.com/watch?v=viLnOVBbcsE)(需要翻墙)。另外,不妨打开你的Mac上的Application文件夹中或者iPhone上Apple家的各种应用。你会惊奇地发现,IB远比你看到的要强大:小至计算器取色器这类小工具,大至iWork三件套,Aperture或Final Cut这样的专业级应用,无一不是使用IB来完成UI制作的。 其实IB和xib是从iOS SDK初次面世开始就是捆绑在开发者工具套装内的内容了,而到了Xcode 4之后更被直接集成到了Xcode中成为了IDE的一部分。xib设计的一大目的其实是为了良好的MVC:一般来说,单个的xib文件对应一个ViewController,而对于一些自定义的view,往往也会使用单个xib并从main bundle进行加载的方式来载入。IB帮助完成view的创建,布局和与file owner的关系映射等一些列工作。对于初学者来说,牢记xib的文件都是view的内容,有助于建立起较好的MVC的概念,从而在开发中避免或少走弯路。 xib文件之前一大被诟病的问题是文件内容过于复杂,可读性很差,即使只是简单打开没有编辑也有可能造成变化而导致合并和提交的苦难。在Xcode 5中Apple大幅简化了xib文件的格式,使其变得易读易维护。可以说现在对于xib文件在版本管理上其实和纯代码已经没有太大差异,只要仔细看过一遍xib的文件内容,自然能理解绝大部分,并很好地追踪并查找过往的修改记录了。 当然xib也不是完美的。最大的问题在于xib中的设置往往并非最终设置,在代码中你将有机会覆盖你在xib文件中进行的UI设计。在不同的地方对同一个属性进行设置,这在之后的维护中将会是噩梦般的存在。因为其实IB还是有所局限的,它没有逻辑判断,也很难在运行时进行配置,而反之使用代码确是无所不能的。在使用xib时,辅以部分代码来补充和完成功能几乎是不可避免的。关于这点在开发时应该予以高度重视,如果选择xib,那么要尽量将xib的工作和代码的工作隔离开来:能够使用xib完成的内容就统一使用xib来做,而不要说三个Label其中两个在xib设置了字体而另一个却在代码中完成。尽量仅保持必要的、较少的IBOutlet和IBAction会是一个好方法。 ### StoryBoard iOS5之后Apple提供了一种全新的方式来制作UI,那就是StoryBoard。简单理解来说,可以把StoryBoard看做是一组viewController对应的xib,以及它们之间的转换方式的集合。在StoryBoard中不仅可以看到每个ViewController的布局样式,也可以明确地知道各个ViewController之间的转换关系。相对于单个的xib,其代码需求更少,也由于集合了各个xib,使得对于界面的理解和修改的速度也得到了更大提升。减少代码量就是减少bug量,这也是程序开发中的真理之一。 在Xcode5之后,StoryBoard已经成为新建项目的默认配置,这也代表了Apple对开发者的建议和未来的方向。WWDC2013的各个Sample Code中也基本都使用了StoryBoard来进行演示。可以预见到,之后Apple必定会在这方面进行继续强化,而反之纯代码或者单个xib的方式很可能不会再得到增强。 如果不考虑iOS版本的支持(其实说实话现在已经很少还见到要从iOS4开始支持的app了吧),现在StoryBoard面临的最大问题就是多人协作。因为所有的UI都定义在一个文件中,因此很多开发者个人或企业的技术负责人认为StoryBoard是无法进行协作开发的,其实这更多的是一种对StoryBoard的陌生所造成的误解。虽然Apple并没有在WWDC明确提及,但是没有人规定整个项目只能有一个StoryBoard文件。一种可行的做法是将项目的不同部分分解成若干个StoryBoard,并安排开发人员对自己的部分进行负责。简单举例比如一个有4个tab功能相互独立的基于UITabBarViewController的应用,完全可以使用4个StoryBoard来分别代表4个tab,并在相互无干扰的情况下完成开发。这样一来就不会存在所谓的冲突问题了。StoryBoard的API是如此简单,现在的SDK中一共方法数量一只手就能数过来,所以具体方法在这里就不再罗嗦了。 StoryBoard的另外的挑战来源于ViewController的重用和自定义的view的处理。对于前者,在正确封装接口以及良好设计的基础上,其实StoryBoard的VC重用与代码的VC重用是没有本质区别的,在StoryBoard中添加封装良好需要重用的Scene即可解决。而对于后者,因为StoryBoard中已经不允许有单个view的存在,因此很多时候我们还是需要借助于单个的xib来自定义UI。这一点可以说是由于StoryBoard的设计思路所造成的,StoryBoard更强调的是一种层次结构,是在全局的视角上来组织UI设计和迁移。而对于单个的view,更多的会注重于重用和定制,而与整个项目的流程没有太大关系。相信抓住这一要点,就能很好地了解什么时候使用xib,什么时候使用StoryBoard。 关于StoryBoard最后要说的是,现在会有一些对于StoryBoard性能上的担忧。因为相对于单个xib来说,StoryBoard文件往往更大,加载速度也相应变慢。但是其实随着现在设备的更新换代,在iPhone4都难觅的今天,这点性能上的差距几乎可以忽略了。而再之后的设备,不论读取还是解析,只会越来越快。所以性能上的问题完全是没有担心的必要的。 ### 我的观点和选择 我入门的时候是使用xib的,因为那时候还没有StoryBoard,而我也不是喜欢代码的学院派Geek。到现在,三种方式我都有尝试过,并分别得到了一些可能还并不是特别深刻体会。对于现在的我来说,xib是我的奶酪,也是我在自己的一些项目里一直使用的方式,我可以在极短短时间内用xib架起一套包括自定义要素和良好部件重用性复杂UI。但是在我尝试了几次使用StoryBoard制作demo之后,我已经决定在之后的项目转向使用StoryBoard。一方面因为确实是未来方向(每次新工程删StoryBoard很讨厌..),现在的StoryBoard专有的preview功能,以及之后AutoLayout的进一步改进等都很值得期待;另一方面也觉得奶酪放一个地方太久了会不好,趁着iOS7的大变革,也更新一下自己的观念和方式,把奶酪换个地方摆摆,也许会对以后大有裨益。 对于初心者来说,我并不建议上手就直接使用代码来进行UI制作和布局,因为冗长的UI代码确实非常乏味无趣。尽快看到成品,至少尽快看到原型,是保持兴趣,继续深入和从事职业的有效动力。所以如果有可能有条件,在老鸟的指导下选择StoryBoard来进行快速构建(或者如果是单个人开发的话,可以不用考虑多个StoryBoard协作,就更容易),会是入门的好选择。而最新的教程和文档已经开始逐渐偏向StoryBoard,关于StoryBoard的问题在SO上关注度也会更高,这样在入门时会有更多的资料可以进行参考。 这并不是说不需要关心代码UI或者xib,因为使用StoryBoard的时候在只能使用代码以及自定义单个view时,还是不可避免地需要接触它们的。这里想给的一点建议就是,虽然你不依赖代码来进行UI制作,但是了解并掌握如何使用纯代码来从头构建UI还是非常必要的:包括从新建Window开始,到初始化ViewController,添加必要的view,设定它们的property,以及添加和处理它们的各种响应及responser链等内容。现在iOS开发入门非常容易,Xcode和xib/StoryBoard帮助开发者隐藏了太多的细节,但是很多时候如果你不明白underhood到底是些什么,为什么这些xib/StoryBoard会这样运作的话,经常会出现卡在一些很可笑的和初级的bug上找不着北,这其实会是对时间的巨大浪费,很不值得。 ### 一些IB小技巧 最后分享一些IB使用上的小技巧作为结束吧。其中很多方法也可以用在StoryBoard上,所以在向我自己之前xib使用者生涯致敬的同时,也算是一点小的备忘总结吧。 #### 同时添加多个outlet 在IB中,选中一个view并右键点击,将会出现灰色的HUD,可以在其上方便地拖拉或设定事件和outlet。你可以同时打开多个这样的面板来一次性添加所有outlet。右键点击面板,随便拖动一下面板,然后再打开另一个。你会发现前一个面板也留下来了,这样你就可以方便地进行拖拽设定了。 ![多个Outlet HUD](/assets/images/2013/IB-tip1.png) 当然,对于成组和行为类似的IBOutlet,应该直接使用IBOutletCollection来进行处理会更方便。 #### 可视化坐标距离 IB最烦人的问题就是对其。用代码的时候我们可以明确地指定x,y坐标,但是换到IB的时候我们更多的时候是靠拖拽UIView来布局。比如需要三个间隔相同的label,除了用强大的肉眼来估测距离是否相等以外,难道只能乖乖分别选中三个label,记下它们的坐标然后打开计算器来做加减法么? 显然不要那么笨,试试看选中一个label,然后按住option键并将鼠标移动到其他label上试试?你可以发现view之间的距离都以很容易理解的方式显示出来了。不仅是同层次的view,被选中view与其他层次的view之间的距离关系也可以同样显示。 ![显示View之间的距离](/assets/images/2013/IB-tip2.png) #### 在一组view层次中进行选择 对于一些复杂的view层级关系,我们往往直接在IB中选择会比较困难。比如view相互覆盖时,我们很难甚至不能在编辑视图中选中底层的view。这时候一般的做法是打开左侧的view层级面板,一层层展开然后选择自己需要的view。其实我们也有更简单的方法:按住`Cmd`和`Shift`,然后在需要选择的view上方按右键,就可以列出在点击位置上所有的view的列表。藉此就可以方便快速地选中想要的view了。 ![在编辑视图中选则底层view](/assets/images/2013/IB-tip3.png) #### 添加辅助线 这么高大上的技巧必须放在最后啊...在左边的层级列表中双击某个view,然后`Cmd+_`或者`Cmd+|`即可在选中的view上添加一条水平或者垂直中心的辅助线。当然这个辅助线是可以随意移动的。如果干过设计的同学肯定明白这个的意义了,在之后的对齐和设计变更的时候都有重要的参考价值。 ![为IB添加辅助线](/assets/images/2013/IB-tip4.png) URL: https://onevcat.com/2013/11/ios-iap-checklist/index.html.md Published At: 2013-11-18 01:01:16 +0900 # iOS内购实现及测试Check List 免费+应用内购买的模式已经被证明了是最有效的盈利模式,所以实现内购功能可能是很多开发者必做的工作和必备的技能了。但是鉴于内购这块坑不算少,另外因为sandbox测试所需要特定的配置也很多,所以对于经验不太多的开发者来说很容易就遇到各种问题,并且测试时出错Apple给出的也只有“Can not connect iTunes Store”或者"Invalid Product IDs"之类毫无价值的错误提示,并没有详细的错误说明,因此调试起来往往没有方向。有老前辈在[这里](http://troybrant.net/blog/2010/01/invalid-product-ids/)整理过一个相对完整的check list了,但是因为年代已经稍微久远,所以内容上和现在的情况已经有一些出入。趁着在最近两个项目里做内购这块遇到的新问题,顺便在此基础上总结整理了一份比较新的中文Check list,希望能帮到后来人。 如果您在实现和测试iOS应用内购的时候遇到问题,可以逐一对照下面所列出的条目,并逐一进行检查。相信可以排除大部分的错误。如果您遇到的问题不在这个列表范围内,欢迎在评论中指出,我会进行更新。 * 您是否在iOS Dev Center中打开了对应应用AppID的`In-App Purchases`功能?登陆iOS Dev Center的Certificates, Identifiers & Profiles下,在Identifiers中找到正在开发的App,In-App Purchase一项应当显示Enabled(如果使用Xcode5,可以直接在Xcode的Capabilities页面中打开In-App Purchases)。 * 您是否在iTunes Connect中注册了您的IAP项目,并将其设为Cleared for Sale? * 您的plist中的`Bundle identifier`的内容是否和您的AppID一致? * 您是否正确填写了Version(CFBundleVersion)和Build(CFBuildNumber)两个数字?两者缺一不可。 * 您用代码向Apple申请售卖物品列表时是否使用了完整的在iTC注册的Product ID?(使用在IAP管理中内购项目的Product ID一栏中的字符串) * 您是否在打开IAP以后重新生成过包含IAP许可的provisioning profile? * 你是否重新导入了新的包含IAP的provisioning profile?建议在Organizer中先删掉原来设备上的老的provisioning profile。 * 您是否在用包含IAP的provisioning profile在部署测试程序?在Xcode5中,建议使用General中的Team选项来自动管理。 * 您是否是在模拟器中测试IAP?虽然理论上说模拟器在某些情况下可以测试IAP,但是条件很多也不让人安心,因此您确实需要一台真机来做IAP测试。 * 您是在企业版发布中测试IAP么?因为企业版没有iTC进行内购项目管理,也无法发布AppStore应用,所以您在企业版的build中不能使用IAP。 * 您是否将设备上原来的app删除了,并重新进行了安装?记得在安装前做一下Clean和Clean Build Folder。 * 您是否在运行应用前将设备上实际的Apple ID登出了?建议在设置->iTunes Store和App Stroe中将使用中的Apple ID登出,以未登录状态进入应用进行测试。 * 你是否使用的是Test User?如果你还没有创建Test User,你需要到iTC中创建。 * 您使用的测试账号是否是美国区账号?虽然不是一定需要,但是鉴于其他地区的测试账号经常抽风,加上美国区账号一直很稳定,因此强烈建议使用美国区账号。正常情况下IAP不需要进行信用卡绑定和其他信息填写,如果你遇到了这种情况,可以试试删除这个测试账号再新建一个其他地区的。 * 您是否有新建账户进行测试?可能的话,可以使用新建测试账户试试看,因为某些特定情况下测试账户会被Apple锁定。 * 您的应用是否是被拒状态(Rejected)或自己拒绝(Developer Rejected)了?被拒绝状态的应用的话对应还未通过的内购项目也会一起被拒,因此您需要重新将IAP项目设为Cleared for Sale。 * 您的应用是否处于等待开发者发布(Pending Developer Release)状态?等待发布状态的IAP是无法测试的。 * 您的内购项目是否是最近才新建的,或者进行了更改?内购项目需要一段时间才能反应到所有服务器上,这个过程一般是一两小时,也可能再长一些达到若干小时。 * 您在iTC中Contracts, Tax, and Banking Information项目中是否有还没有设置或者过期了的项目?不完整的财务信息无法进行内购测试。 * 您是在越狱设备上进行内购测试么?越狱设备不能用于正常内购,您需要重装或者寻找一台没有越狱的设备。 * 您是否能正常连接到Apple的服务器,你可以访问[Apple开发者论坛关于IAP的板块](https://devforums.apple.com/community/ios/connected/purchase),如果苹果服务器正down掉,那里应该有热烈的讨论。 --- 如果您正在寻找一份手把手教你实现IAP的教程的话,这篇文章不是您的菜。关于IAP的实现和步骤,可以参考下面的教程: * 苹果的[官方IAP指南](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Introduction.html)和相应的[Technical Note](https://developer.apple.com/library/mac/technotes/tn2259/_index.html) * Ray Wenderlich的[iOS6 IAP教程](http://www.raywenderlich.com/23266/in-app-purchases-in-ios-6-tutorial-consumables-and-receipt-validation) * 一篇图文并茂的[中文教程](http://blog.csdn.net/xiaominghimi/article/details/6937097) * 直接使用大神们封好的Store有关的库,比如[mattt/CargoBay](https://github.com/mattt/CargoBay),[robotmedia/RMStore](https://github.com/robotmedia/RMStore)或者[MugunthKumar/MKStoreKit](https://github.com/MugunthKumar/MKStoreKit)。推荐前两个,因为MKStoreKit有一些恼人的小bug。 URL: https://onevcat.com/2013/10/vc-transition-in-ios7/index.html.md Published At: 2013-10-11 00:59:56 +0900 # WWDC 2013 Session笔记 - iOS7中的ViewController切换 这是我的WWDC2013系列笔记中的一篇,完整的笔记列表请参看[这篇总览](http://onevcat.com/2013/06/developer-should-know-about-ios7/)。本文仅作为个人记录使用,也欢迎在[许可协议](http://creativecommons.org/licenses/by-nc/3.0/deed.zh)范围内转载或使用,但是还烦请保留原文链接,谢谢您的理解合作。如果您觉得本站对您能有帮助,您可以使用[RSS](http://onevcat.com/atom.xml)或[邮件](http://eepurl.com/wNSkj)方式订阅本站,这样您将能在第一时间获取本站信息。 本文涉及到的WWDC2013 Session有 * Session 201 Building User Interfaces for iOS 7 * Session 218 Custom Transitions Using View Controllers * Session 226 Implementing Engaging UI on iOS 毫无疑问,ViewController(在本文中简写为VC)是使用MVC构建Cocoa或者CocoaTouch程序时最重要的一个类,我们的日常工作中一般来说最花费时间和精力的也是在为VC部分编写代码。苹果产品是注重用户体验的,而对细节进行琢磨也是苹果对于开发者一直以来的要求和希望。在用户体验中,VC之间的关系,比如不同VC之间迁移和转换动画效果一直是一个值得不断推敲的重点。在iOS7中,苹果给出了一套完整的VC制作之间迁移效果的方案,可以说是为现在这部分各种不同实现方案指出了一条推荐的统一道路。 ### iOS 7 SDK之前的VC切换解决方案 在深入iOS 7的VC切换效果的新API实现之前,先让我们回顾下现在的一般做法吧。这可以帮助理解为什么iOS7要对VC切换给出新的解决方案,如果您对iOS 5中引入的VC容器比较熟悉的话,可以跳过这节。 在iOS5和iOS6中,除了标准的Push,Tab和PresentModal之外,一般是使用ChildViewController的方式来完成VC之间切换的过渡效果。ChildViewController和自定义的Controller容器是iOS 5 SDK中加入的,可以用来生成自定义的VC容器,简单来说典型的一种用法类似这样: ```objc //ContainerVC.m [self addChildViewController:toVC]; [fromVC willMoveToParentViewController:nil]; [self.view addSubview:toVC.view]; __weak id weakSelf = self; [self transitionFromViewController:fromVC toViewController:toVC duration:0.3 options:UIViewAnimationOptionTransitionCrossDissolve animations:^{} completion:^(BOOL finished) { [fromVC.view removeFromSuperView]; [fromVC removeFromParentViewController]; [toVC didMoveToParentViewController:weakSelf]; }]; ``` 在自己对view进行管理的同时,可以使用transitionFromViewController:toViewController:...的Animation block中可以实现一些简单的切换效果。去年年初我写的[UIViewController的误用](http://onevcat.com/2012/02/uiviewcontroller/)一文中曾经指出类似`[viewController.view addSubview:someOtherViewController.view];`这样的代码的存在,一般就是误用VC。这个结论适用于非Controller容器,对于自定义的Controller容器来说,向当前view上添加其他VC的view是正确的做法(当然不能忘了也将VC本身通过`addChildViewController:`方法添加到容器中)。 VC容器的主要目的是解决将不同VC添加到同一个屏幕上的需求,以及可以提供一些简单的自定义切换效果。使用VC容器可以使view的关系正确,使添加的VC能够正确接收到例如屏幕旋转,viewDidLoad:等VC事件,进而进行正确相应。VC容器确实可以解决一部分问题,但是也应该看到,对于自定义切换效果来说,这样的解决还有很多不足。首先是代码高度耦合,VC切换部分的代码直接写在container中,难以分离重用;其次能够提供的切换效果比较有限,只能使用UIView动画来切换,管理起来也略显麻烦。iOS 7提供了一套新的自定义VC切换,就是针对这两个问题的。 ### iOS 7 自定义ViewController动画切换 #### 自定义动画切换的相关的主要API 在深入之前,我们先来看看新SDK中有关这部分内容的相关接口以及它们的关系和典型用法。这几个接口和类的名字都比较相似,但是还是能比较好的描述出各自的职能的,一开始的话可能比较迷惑,但是当自己动手实现一两个例子之后,它们之间的关系就会逐渐明晰起来。(相关的内容都定义在UIKit的[UIViewControllerTransitioning.h](https://gist.github.com/onevcat/6944809)中了) #### @protocol [UIViewControllerContextTransitioning](https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIViewControllerContextTransitioning_protocol/Reference/Reference.html) 这个接口用来提供切换上下文给开发者使用,包含了从哪个VC到哪个VC等各类信息,一般不需要开发者自己实现。具体来说,iOS7的自定义切换目的之一就是切换相关代码解耦,在进行VC切换时,做切换效果实现的时候必须要需要切换前后VC的一些信息,**系统在新加入的API的比较的地方都会提供一个实现了该接口的对象**,以供我们使用。 **对于切换的动画实现来说**(这里先介绍简单的动画,在后面我会再引入手势驱动的动画),这个接口中最重要的方法有: * -(UIView *)containerView; VC切换所发生的view容器,开发者应该将切出的view移除,将切入的view加入到该view容器中。 * -(UIViewController *)viewControllerForKey:(NSString *)key; 提供一个key,返回对应的VC。现在的SDK中key的选择只有UITransitionContextFromViewControllerKey和UITransitionContextToViewControllerKey两种,分别表示将要切出和切入的VC。 * -(CGRect)initialFrameForViewController:(UIViewController *)vc; 某个VC的初始位置,可以用来做动画的计算。 * -(CGRect)finalFrameForViewController:(UIViewController *)vc; 与上面的方法对应,得到切换结束时某个VC应在的frame。 * -(void)completeTransition:(BOOL)didComplete; 向这个context报告切换已经完成。 #### @protocol [UIViewControllerAnimatedTransitioning](https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIViewControllerAnimatedTransitioning_Protocol/Reference/Reference.html) 这个接口负责切换的具体内容,也即“切换中应该发生什么”。开发者在做自定义切换效果时大部分代码会是用来实现这个接口。它只有两个方法需要我们实现: * -(NSTimeInterval)transitionDuration:(id < UIViewControllerContextTransitioning >)transitionContext; 系统给出一个切换上下文,我们根据上下文环境返回这个切换所需要的花费时间(一般就返回动画的时间就好了,SDK会用这个时间来在百分比驱动的切换中进行帧的计算,后面再详细展开)。 * -(void)animateTransition:(id < UIViewControllerContextTransitioning >)transitionContext; 在进行切换的时候将调用该方法,我们对于切换时的UIView的设置和动画都在这个方法中完成。 #### @protocol [UIViewControllerTransitioningDelegate](https://developer.apple.com/library/ios/documentation/uikit/reference/UIViewControllerTransitioningDelegate_protocol/Reference/Reference.html) 这个接口的作用比较简单单一,在需要VC切换的时候系统会像实现了这个接口的对象询问是否需要使用自定义的切换效果。这个接口共有四个类似的方法: * -(id< UIViewControllerAnimatedTransitioning >)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source; * -(id< UIViewControllerAnimatedTransitioning >)animationControllerForDismissedController:(UIViewController *)dismissed; * -(id< UIViewControllerInteractiveTransitioning >)interactionControllerForPresentation:(id < UIViewControllerAnimatedTransitioning >)animator; * -(id< UIViewControllerInteractiveTransitioning >)interactionControllerForDismissal:(id < UIViewControllerAnimatedTransitioning >)animator; 前两个方法是针对动画切换的,我们需要分别在呈现VC和解散VC时,给出一个实现了UIViewControllerAnimatedTransitioning接口的对象(其中包含切换时长和如何切换)。后两个方法涉及交互式切换,之后再说。 ### Demo 还是那句话,一百行的讲解不如一个简单的小Demo,于是..it's demo time~ 整个demo的代码我放到了github的[这个页面](https://github.com/onevcat/VCTransitionDemo)上,有需要的朋友可以参照着看这篇文章。 我们打算做一个简单的自定义的modalViewController的切换效果。普通的present modal VC的效果大家都已经很熟悉了,这次我们先实现一个自定义的类似的modal present的效果,与普通效果不同的是,我们希望modalVC出现的时候不要那么乏味的就简单从底部出现,而是带有一个弹性效果(这里虽然是弹性,但是仅指使用UIView的模拟动画,而不设计iOS 7的另一个重要特性UIKit Dynamics。用UIKit Dynamics当然也许可以实现更逼真华丽的效果,但是已经超出本文的主题范畴了,因此不在这里展开了。关于UIKit Dynamics,可以参看我之前关于这个主题的[一篇介绍](http://onevcat.com/2013/06/uikit-dynamics-started/))。我们首先实现简单的ModalVC弹出吧..这段非常基础,就交待了一下背景,非初级人士请跳过代码段.. 先定义一个ModalVC,以及相应的protocal和delegate方法: ```objc //ModalViewController.h @class ModalViewController; @protocol ModalViewControllerDelegate -(void) modalViewControllerDidClickedDismissButton:(ModalViewController *)viewController; @end @interface ModalViewController : UIViewController @property (nonatomic, weak) id delegate; @end //ModalViewController.m - (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view. self.view.backgroundColor = [UIColor lightGrayColor]; UIButton *button = [UIButton buttonWithType:UIButtonTypeRoundedRect]; button.frame = CGRectMake(80.0, 210.0, 160.0, 40.0); [button setTitle:@"Dismiss me" forState:UIControlStateNormal]; [button addTarget:self action:@selector(buttonClicked:) forControlEvents:UIControlEventTouchUpInside]; [self.view addSubview:button]; } -(void) buttonClicked:(id)sender { if (self.delegate && [self.delegate respondsToSelector:@selector(modalViewControllerDidClickedDismissButton:)]) { [self.delegate modalViewControllerDidClickedDismissButton:self]; } } ``` 这个是很标准的modalViewController的实现方式了。需要多嘴一句的是,在实际使用中有的同学喜欢在-buttonClicked:中直接给self发送dismissViewController的相关方法。在现在的SDK中,如果当前的VC是被显示的话,这个消息会被直接转发到显示它的VC去。但是这并不是一个好的实现,违反了程序设计的哲学,也很容易掉到坑里,具体案例可以参看[这篇文章的评论](http://onevcat.com/2011/11/objc-block/)。 所以我们用标准的方式来呈现和解散这个VC: ```objc //MainViewController.m - (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view. UIButton *button = [UIButton buttonWithType:UIButtonTypeRoundedRect]; button.frame = CGRectMake(80.0, 210.0, 160.0, 40.0); [button setTitle:@"Click me" forState:UIControlStateNormal]; [button addTarget:self action:@selector(buttonClicked:) forControlEvents:UIControlEventTouchUpInside]; [self.view addSubview:button]; } -(void) buttonClicked:(id)sender { ModalViewController *mvc = [[ModalViewController alloc] init]; mvc.delegate = self; [self presentViewController:mvc animated:YES completion:nil]; } -(void)modalViewControllerDidClickedDismissButton:(ModalViewController *)viewController { [self dismissViewControllerAnimated:YES completion:nil]; } ``` 测试一下,没问题,然后我们可以开始实现自定义的切换效果了。首先我们需要一个实现了UIViewControllerAnimatedTransitioning的对象..嗯,新建一个类来实现吧,比如BouncePresentAnimation: ```objc //BouncePresentAnimation.h @interface BouncePresentAnimation : NSObject @end //BouncePresentAnimation.m - (NSTimeInterval)transitionDuration:(id )transitionContext { return 0.8f; } - (void)animateTransition:(id )transitionContext { // 1. Get controllers from transition context UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; // 2. Set init frame for toVC CGRect screenBounds = [[UIScreen mainScreen] bounds]; CGRect finalFrame = [transitionContext finalFrameForViewController:toVC]; toVC.view.frame = CGRectOffset(finalFrame, 0, screenBounds.size.height); // 3. Add toVC's view to containerView UIView *containerView = [transitionContext containerView]; [containerView addSubview:toVC.view]; // 4. Do animate now NSTimeInterval duration = [self transitionDuration:transitionContext]; [UIView animateWithDuration:duration delay:0.0 usingSpringWithDamping:0.6 initialSpringVelocity:0.0 options:UIViewAnimationOptionCurveLinear animations:^{ toVC.view.frame = finalFrame; } completion:^(BOOL finished) { // 5. Tell context that we completed. [transitionContext completeTransition:YES]; }]; } ``` 解释一下这个实现: 1. 我们首先需要得到参与切换的两个ViewController的信息,使用context的方法拿到它们的参照; 2. 对于要呈现的VC,我们希望它从屏幕下方出现,因此将初始位置设置到屏幕下边缘; 3. 将view添加到containerView中; 4. 开始动画。这里的动画时间长度和切换时间长度一致,都为0.8s。usingSpringWithDamping的UIView动画API是iOS7新加入的,描述了一个模拟弹簧动作的动画曲线,我们在这里只做使用,更多信息可以参看[相关文档](https://developer.apple.com/library/ios/documentation/uikit/reference/uiview_class/UIView/UIView.html#//apple_ref/occ/clm/UIView/animateWithDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion:);(顺便多说一句,iOS7中对UIView动画添加了一个很方便的Category,UIViewKeyframeAnimations。使用其中方法可以为UIView动画添加关键帧动画) 5. 在动画结束后我们必须向context报告VC切换完成,是否成功(在这里的动画切换中,没有失败的可能性,因此直接pass一个YES过去)。系统在接收到这个消息后,将对VC状态进行维护。 接下来我们实现一个UIViewControllerTransitioningDelegate,应该就能让它工作了。简单来说,一个比较好的地方是直接在MainViewController中实现这个接口。在MainVC中声明实现这个接口,然后加入或变更为如下代码: ```objc @interface MainViewController () @property (nonatomic, strong) BouncePresentAnimation *presentAnimation; @end @implementation MainViewController - (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil { self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; if (self) { // Custom initialization _presentAnimation = [BouncePresentAnimation new]; } return self; } -(void) buttonClicked:(id)sender { //... mvc.transitioningDelegate = self; //... } - (id )animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source { return self.presentAnimation; } ``` Believe or not, we have done. 跑一下,应该可以得到如下效果: ![BouncePresentAnimation的实际效果](/assets/images/2013/custom-vc-transition-1.gif) ### 手势驱动的百分比切换 iOS7引入了一种手势驱动的VC切换的方式(交互式切换)。如果你使用系统的各种应用,在navViewController里push了一个新的VC的话,返回时并不需要点击左上的Back按钮,而是通过从屏幕左侧划向右侧即可完成返回操作。而在这个操作过程中,我们甚至可以撤销我们的手势,以取消这次VC转移。在新版的Safari中,我们甚至可以用相同的手势来完成网页的后退功能(所以很大程度上来说屏幕底部的工具栏成为了摆设)。如果您还不知道或者没太留意过这个改动,不妨现在就拿手边的iOS7这辈试试看,手机浏览的朋友记得切回来哦 :) 我们这就动手在自己的VC切换中实现这个功能吧,首先我们需要在刚才的知识基础上补充一些东西: 首先是UIViewControllerContextTransitioning,刚才提到这个是系统提供的VC切换上下文,如果您深入看了它的头文件描述的话,应该会发现其中有三个关于InteractiveTransition的方法,正是用来处理交互式切换的。但是在初级的实际使用中我们其实可以不太理会它们,而是使用iOS 7 SDK已经给我们准备好的一个现成转为交互式切换而新加的类:UIPercentDrivenInteractiveTransition。 #### [UIPercentDrivenInteractiveTransition](https://developer.apple.com/library/ios/documentation/uikit/reference/UIPercentDrivenInteractiveTransition_class/Reference/Reference.html)是什么 这是一个实现了UIViewControllerInteractiveTransitioning接口的类,为我们预先实现和提供了一系列便利的方法,可以用一个百分比来控制交互式切换的过程。一般来说我们更多地会使用某些手势来完成交互式的转移(当然用的高级的话用其他的输入..比如声音,iBeacon距离或者甚至面部微笑来做输入驱动也无不可,毕竟想象无极限嘛..),这样使用这个类(一般是其子类)的话就会非常方便。我们在手势识别中只需要告诉这个类的实例当前的状态百分比如何,系统便根据这个百分比和我们之前设定的迁移方式为我们计算当前应该的UI渲染,十分方便。具体的几个重要方法: * -(void)updateInteractiveTransition:(CGFloat)percentComplete 更新百分比,一般通过手势识别的长度之类的来计算一个值,然后进行更新。之后的例子里会看到详细的用法 * -(void)cancelInteractiveTransition 报告交互取消,返回切换前的状态 * –(void)finishInteractiveTransition 报告交互完成,更新到切换后的状态 #### @protocol [UIViewControllerInteractiveTransitioning](https://developer.apple.com/library/ios/documentation/uikit/reference/UIViewControllerInteractiveTransitioning_protocol/Reference/Reference.html) 就如上面提到的,UIPercentDrivenInteractiveTransition只是实现了这个接口的一个类。为了实现交互式切换的功能,我们需要实现这个接口。因为大部分时候我们其实不需要自己来实现这个接口,因此在这篇入门中就不展开说明了,有兴趣的童鞋可以自行钻研。 还有就是上面提到过的UIViewControllerTransitioningDelegate中的返回Interactive实现对象的方法,我们同样会在交互式切换中用到它们。 ### 继续Demo Demo time again。在刚才demo的基础上,这次我们用一个向上划动的手势来吧之前呈现的ModalViewController给dismiss掉~当然是交互式的切换,可以半途取消的那种。 首先新建一个类,继承自UIPercentDrivenInteractiveTransition,这样我们可以省不少事儿。 ```objc //SwipeUpInteractiveTransition.h @interface SwipeUpInteractiveTransition : UIPercentDrivenInteractiveTransition @property (nonatomic, assign) BOOL interacting; - (void)wireToViewController:(UIViewController*)viewController; @end //SwipeUpInteractiveTransition.m @interface SwipeUpInteractiveTransition() @property (nonatomic, assign) BOOL shouldComplete; @property (nonatomic, strong) UIViewController *presentingVC; @end @implementation SwipeUpInteractiveTransition -(void)wireToViewController:(UIViewController *)viewController { self.presentingVC = viewController; [self prepareGestureRecognizerInView:viewController.view]; } - (void)prepareGestureRecognizerInView:(UIView*)view { UIPanGestureRecognizer *gesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleGesture:)]; [view addGestureRecognizer:gesture]; } -(CGFloat)completionSpeed { return 1 - self.percentComplete; } - (void)handleGesture:(UIPanGestureRecognizer *)gestureRecognizer { CGPoint translation = [gestureRecognizer translationInView:gestureRecognizer.view.superview]; switch (gestureRecognizer.state) { case UIGestureRecognizerStateBegan: // 1. Mark the interacting flag. Used when supplying it in delegate. self.interacting = YES; [self.presentingVC dismissViewControllerAnimated:YES completion:nil]; break; case UIGestureRecognizerStateChanged: { // 2. Calculate the percentage of guesture CGFloat fraction = translation.y / 400.0; //Limit it between 0 and 1 fraction = fminf(fmaxf(fraction, 0.0), 1.0); self.shouldComplete = (fraction > 0.5); [self updateInteractiveTransition:fraction]; break; } case UIGestureRecognizerStateEnded: case UIGestureRecognizerStateCancelled: { // 3. Gesture over. Check if the transition should happen or not self.interacting = NO; if (!self.shouldComplete || gestureRecognizer.state == UIGestureRecognizerStateCancelled) { [self cancelInteractiveTransition]; } else { [self finishInteractiveTransition]; } break; } default: break; } } @end ``` 有点长,但是做的事情还是比较简单的。 1. 我们设定了一个BOOL变量来表示是否处于切换过程中。这个布尔值将在监测到手势开始时被设置,我们之后会在调用返回这个InteractiveTransition的时候用到。 2. 计算百分比,我们设定了向下划动400像素或以上为100%,每次手势状态变化时根据当前手势位置计算新的百分比,结果被限制在0~1之间。然后更新InteractiveTransition的百分数。 3. 手势结束时,把正在切换的标设置回NO,然后进行判断。在2中我们设定了手势距离超过设定一半就认为应该结束手势,否则就应该返回原来状态。在这里使用其进行判断,已决定这次transition是否应该结束。 接下来我们需要添加一个向下移动的UIView动画,用来表现dismiss。这个十分简单,和BouncePresentAnimation很相似,写一个NormalDismissAnimation的实现了UIViewControllerAnimatedTransitioning接口的类就可以了,本文里略过不写了,感兴趣的童鞋可以自行查看源码。 最后调整MainViewController的内容,主要修改点有三个地方: ```objc //MainViewController.m @interface MainViewController () //... // 1. Add dismiss animation and transition controller @property (nonatomic, strong) NormalDismissAnimation *dismissAnimation; @property (nonatomic, strong) SwipeUpInteractiveTransition *transitionController; @end @implementation MainViewController - (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil { //... _dismissAnimation = [NormalDismissAnimation new]; _transitionController = [SwipeUpInteractiveTransition new]; //... } -(void) buttonClicked:(id)sender { //... // 2. Bind current VC to transition controller. [self.transitionController wireToViewController:mvc]; //... } // 3. Implement the methods to supply proper objects. -(id)animationControllerForDismissedController:(UIViewController *)dismissed { return self.dismissAnimation; } -(id)interactionControllerForDismissal:(id)animator { return self.transitionController.interacting ? self.transitionController : nil; } ``` 1. 在其中添加dismiss时候的动画和交互切换Controller 2. 在初始化modalVC的时候为交互切换的Controller绑定VC 3. 为UIViewControllerTransitioningDelegate实现dismiss时候的委托方法,包括返回对应的动画以及交互切换Controller 完成了,如果向下划动时,效果如下: ![交互驱动的VC转移](/assets/images/2013/custom-vc-transition-2.gif) ### 关于iOS 7中自定义VC切换的一些总结 demo中只展示了对于modalVC的present和dismiss的自定义切换效果,当然对与Navigation Controller的Push和Pop切换也是有相应的一套方法的。实现起来和dismiss十分类似,只不过对应UIViewControllerTransitioningDelegate的询问动画和交互的方法换到了UINavigationControllerDelegate中(为了区别push或者pop,看一下这个接口应该能马上知道)。另外一个很好的福利是,对于标准的navController的Pop操作,苹果已经替我们实现了手势驱动返回,我们不用再费心每个去实现一遍了,cheers~ 另外,可能你会觉得使用VC容器其提供的transition动画方法来进行VC切换就已经够好够方便了,为什么iOS7中还要引入一套自定义的方式呢。其实从根本来说它们所承担的是两类完全不同的任务:自定义VC容器可以提供自己定义的VC结构,并保证系统的各类方法和通知能够准确传递到合适的VC,它提供的transition方法虽然可以实现一些简单的UIView动画,但是难以重用,可以说是和containerVC完全耦合在一起的;而自定义切换并不改变VC的组织结构,只是负责提供view的效果,因为VC切换将动画部分、动画驱动部分都使用接口的方式给出,因此重用性非常优秀。在绝大多数情况下,精心编写的一套UIView动画是可以轻易地用在不同的VC中,甚至是不同的项目中的。 需要特别一提的是,Github上的[ColinEberhardt的VCTransitionsLibrary](https://github.com/ColinEberhardt/VCTransitionsLibrary)已经为我们提供了一系列的VC自定义切换动画效果,正是得益于iOS7中这一块的良好设计(虽然这几个接口的命名比较相似,在弄明白之前会有些confusing),因此这些效果使用起来非常方便,相信一般项目中是足够使用的了。而其他更复杂或者炫目的效果,亦可在其基础上进行扩展改进得到。可以说随着越来越多的应用转向iOS7,自定义VC切换将成为新的用户交互实现的基础和重要部分,对于今后会在其基础上会衍生出怎样让人眼前一亮的交互设计,不妨让我们拭目以待(或者自己努力去创造)。 URL: https://onevcat.com/2013/09/spring-list-like-ios7-message/index.html.md Published At: 2013-09-01 00:58:48 +0900 # WWDC 2013 Session笔记 - iOS7中弹簧式列表的制作 这是我的WWDC2013系列笔记中的一篇,完整的笔记列表请参看[这篇总览](http://onevcat.com/2013/06/developer-should-know-about-ios7/)。本文仅作为个人记录使用,也欢迎在[许可协议](http://creativecommons.org/licenses/by-nc/3.0/deed.zh)范围内转载或使用,但是还烦请保留原文链接,谢谢您的理解合作。如果您觉得本站对您能有帮助,您可以使用[RSS](http://onevcat.com/atom.xml)或[邮件](http://eepurl.com/wNSkj)方式订阅本站,这样您将能在第一时间获取本站信息。 本文涉及到的WWDC2013 Session有 * Session 206 Getting Started with UIKit Dynamics * Session 217 Exploring Scroll Views in iOS7 UIScrollView可以说是UIKit中最重要的类之一了,包括UITableView和UICollectionView等重要的数据容器类都是UIScrollView的子类。在历年的WWDC上,UIScrollView和相关的API都有专门的主题进行介绍,也可以看出这个类的使用和变化之快。今年也不例外,因为iOS7完全重新定义了UI,这使得UIScrollView里原来不太会使用的一些用法和实现的效果在新的系统中得到了很好的表现。另外,由于引入了UIKit Dynamics,我们还可以结合ScrollView做出一些以前不太可能或者需要花费很大力气来实现的效果,包括带有重力的swipe或者是类似新的信息app中的带有弹簧效果聊天泡泡等。如果您还不太了解iOS7中信息app的效果,这里有一张gif图可以帮您大概了解一下: ![iOS7中信息app的弹簧效果](/assets/images/2013/ios7-message-app-spring.gif) 这次笔记的内容主要就是实现一个这样的效果。为了避免重复造轮子,我对这个效果进行了一些简单的封装,并连同这篇笔记的demo一起扔在了Github上,有需要的童鞋可以[到这里](https://github.com/onevcat/VVSpringCollectionViewFlowLayout)自取。 iOS7的SDK中Apple最大的野心其实是想用SpriteKit来结束iOS平台游戏开发(至少是2D游戏开发)的乱战,统一游戏开发的方式并建立良性社区。而UIKit Dynamics,个人猜测Apple在花费力气为SpriteKit开发了物理引擎的同时,发现在UIKit中也可以使用,并能得到不错的效果,于是顺便革新了一下设计理念,在UI设计中引入了不少物理的概念。在iOS系统中,最为典型的应用是锁屏界面打开相机时中途放弃后的重力下坠+反弹的效果,另一个就是信息应用中的加入弹性的消息列表了。弹性列表在我自己上手试过以后觉得表现形式确实很生动,可以消除原来列表那种冷冰冰的感觉,是有可能在今后的设计中被大量使用的,因此决定学上一学。 首先我们需要知道要如何实现这样一种效果,我们会用到哪些东西。毋庸置疑,如果不使用UIKit Dynamics的话,自己从头开始来完成会是一件非常费力的事情,你可能需要实现一套位置计算和物理模拟来使效果看起来真实滑润。而UIKit Dynamics中已经给我们提供了现成的弹簧效果,可以用`UIAttachmentBehavior`进行实现。另外,在说到弹性效果的时候,我们其实是在描述一个列表中的各个cell之间的关系,对于传统的UITableView来说,描述UITableViewCell之间的关系是比较复杂的(因为Apple已经把绝大多数工作做了,包括计算cell位置和位移等。使用越简单,定制就会越麻烦在绝大多数情况下都是真理)。而UICollectionView则通过layout来完成cell之间位置关系的描述,给了开发者较大的空间来实现布局。另外,UIKit Dynamics为UICollectionView做了很多方便的Catagory,可以很容易地“指导”UICollectionView利用加入物理特性计算后的结果,在实现弹性效果的时候,UICollectionView是我们不二的选择。 如果您在阅读这篇笔记的时候遇到困难的话,建议您可以看看我之前的一些笔记,包括今年的[UIKit Dynamics的介绍](http://onevcat.com/2013/06/uikit-dynamics-started/)和去年的[UICollectionView介绍](http://onevcat.com/2012/06/introducing-collection-views/)。 话不多说,我们开工。首先准备一个UICollectionViewFlowLayout的子类(在这里叫做`VVSpringCollectionViewFlowLayout`),然后在ViewController中用这个layout实现一个简单的collectionView: ```objc //ViewController.m @interface ViewController () @property (nonatomic, strong) VVSpringCollectionViewFlowLayout *layout; @end static NSString *reuseId = @"collectionViewCellReuseId"; @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view, typically from a nib. self.layout = [[VVSpringCollectionViewFlowLayout alloc] init]; self.layout.itemSize = CGSizeMake(self.view.frame.size.width, 44); UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:self.view.frame collectionViewLayout:self.layout]; collectionView.backgroundColor = [UIColor clearColor]; [collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:reuseId]; collectionView.dataSource = self; [self.view insertSubview:collectionView atIndex:0]; } #pragma mark - UICollectionViewDataSource - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return 50; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:reuseId forIndexPath:indexPath]; //Just give a random color to the cell. See https://gist.github.com/kylefox/1689973 cell.contentView.backgroundColor = [UIColor randomColor]; return cell; } @end ``` 这部分没什么可以多说的,现在我们有一个标准的FlowLayout的UICollectionView了。通过使用UICollectionViewFlowLayout的子类来作为开始的layout,我们可以节省下所有的初始cell位置计算的代码,在上面代码的情况下,这个collectionView的表现和一个普通的tableView并没有太大不同。接下来我们着重来看看要如何实现弹性的layout。对于弹性效果,我们需要的是连接一个item和一个锚点间弹性连接的`UIAttachmentBehavior`,并能在滚动时设置新的锚点位置。我们在scroll的时候,只要使用UIKit Dynamics的计算结果,替代掉原来的位置更新计算(其实就是简单的scrollView的contentOffset的改变),就可以模拟出弹性的效果了。 首先在`-prepareLayout`中为cell添加`UIAttachmentBehavior`。 ```objc //VVSpringCollectionViewFlowLayout.m @interface VVSpringCollectionViewFlowLayout() @property (nonatomic, strong) UIDynamicAnimator *animator; @end @implementation VVSpringCollectionViewFlowLayout //... -(void)prepareLayout { [super prepareLayout]; if (!_animator) { _animator = [[UIDynamicAnimator alloc] initWithCollectionViewLayout:self]; CGSize contentSize = [self collectionViewContentSize]; NSArray *items = [super layoutAttributesForElementsInRect:CGRectMake(0, 0, contentSize.width, contentSize.height)]; for (UICollectionViewLayoutAttributes *item in items) { UIAttachmentBehavior *spring = [[UIAttachmentBehavior alloc] initWithItem:item attachedToAnchor:item.center]; spring.length = 0; spring.damping = 0.5; spring.frequency = 0.8; [_animator addBehavior:spring]; } } } @end ``` prepareLayout将在CollectionView进行排版的时候被调用。首先当然是call一下super的prepareLayout,你肯定不会想要全都要自己进行设置的。接下来,如果是第一次调用这个方法的话,先初始化一个UIDynamicAnimator实例,来负责之后的动画效果。iOS7 SDK中,UIDynamicAnimator类专门有一个针对UICollectionView的Category,以使UICollectionView能够轻易地利用UIKit Dynamics的结果。在`UIDynamicAnimator.h`中能够找到这个Category: ```objc @interface UIDynamicAnimator (UICollectionViewAdditions) // When you initialize a dynamic animator with this method, you should only associate collection view layout attributes with your behaviors. // The animator will employ thecollection view layout’s content size coordinate system. - (instancetype)initWithCollectionViewLayout:(UICollectionViewLayout*)layout; // The three convenience methods returning layout attributes (if associated to behaviors in the animator) if the animator was configured with collection view layout - (UICollectionViewLayoutAttributes*)layoutAttributesForCellAtIndexPath:(NSIndexPath*)indexPath; - (UICollectionViewLayoutAttributes*)layoutAttributesForSupplementaryViewOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath; - (UICollectionViewLayoutAttributes*)layoutAttributesForDecorationViewOfKind:(NSString*)decorationViewKind atIndexPath:(NSIndexPath *)indexPath; @end ``` 于是通过`-initWithCollectionViewLayout:`进行初始化后,这个UIDynamicAnimator实例便和我们的layout进行了绑定,之后这个layout对应的attributes都应该由绑定的UIDynamicAnimator的实例给出。就像下面这样: ```objc //VVSpringCollectionViewFlowLayout.m @implementation VVSpringCollectionViewFlowLayout //... -(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect { return [_animator itemsInRect:rect]; } -(UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath { return [_animator layoutAttributesForCellAtIndexPath:indexPath]; } @end ``` 让我们回到`-prepareLayout`方法中,在创建了UIDynamicAnimator实例后,我们对于这个layout中的每个attributes对应的点,都创建并添加一个添加一个`UIAttachmentBehavior`(在iOS7 SDK中,UICollectionViewLayoutAttributes已经实现了UIDynamicItem接口,可以直接参与UIKit Dynamic的计算中去)。创建时我们希望collectionView的每个cell就保持在原位,因此我们设定了锚点为当前attribute本身的center。 接下来我们考虑滑动时的弹性效果的实现。在系统的信息app中,我们可以看到弹性效果有两个特点: * 随着滑动的速度增大,初始的拉伸和压缩的幅度将变大 * 随着cell距离屏幕触摸位置越远,拉伸和压缩的幅度 对于考虑到这两方面的特点,我们所期望的滑动时的各cell锚点的变化应该是类似这样的: ![向上拖动时的锚点变化示意](/assets/images/2013/spring-list-ios7.png) 现在我们来实现这个锚点的变化。既然都是滑动,我们是不是可以考虑在UIScrollView的`–scrollViewDidScroll:`委托方法中来设定新的Behavior锚点值呢?理论上来说当然是可以的,但是如果这样的话我们大概就不得不面临着将刚才的layout实例设置为collectionView的delegate这样一个事实。但是我们都知道layout应该做的事情是给collectionView提供必要的布局信息,而不应该负责去处理它的委托事件。处理collectionView的回调更恰当地应该由处于collectionView的controller层级的类来完成,而不应该由一个给collectionView提供数据和信息的类来响应。在`UICollectionViewLayout`中,我们有一个叫做`-shouldInvalidateLayoutForBoundsChange:`的方法,每次layout的bounds发生变化的时候,collectionView都会询问这个方法是否需要为这个新的边界和更新layout。一般情况下只要layout没有根据边界不同而发生变化的话,这个方法直接不做处理地返回NO,表示保持现在的layout即可,而每次bounds改变时这个方法都会被调用的特点正好可以满足我们更新锚点的需求,因此我们可以在这里面完成锚点的更新。 ```objc //VVSpringCollectionViewFlowLayout.m @implementation VVSpringCollectionViewFlowLayout //... -(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds { UIScrollView *scrollView = self.collectionView; CGFloat scrollDelta = newBounds.origin.y - scrollView.bounds.origin.y; //Get the touch point CGPoint touchLocation = [scrollView.panGestureRecognizer locationInView:scrollView]; for (UIAttachmentBehavior *spring in _animator.behaviors) { CGPoint anchorPoint = spring.anchorPoint; CGFloat distanceFromTouch = fabsf(touchLocation.y - anchorPoint.y); CGFloat scrollResistance = distanceFromTouch / 500; UICollectionViewLayoutAttributes *item = [spring.items firstObject]; CGPoint center = item.center; //In case the added value bigger than the scrollDelta, which leads an unreasonable effect center.y += (scrollDelta > 0) ? MIN(scrollDelta, scrollDelta * scrollResistance) : MAX(scrollDelta, scrollDelta * scrollResistance); item.center = center; [_animator updateItemUsingCurrentState:item]; } return NO; } @end ``` 首先我们计算了这次scroll的距离`scrollDelta`,为了得到每个item与触摸点的之间的距离,我们当然还需要知道触摸点的坐标`touchLocation`。接下来,可以根据距离对每个锚点进行设置了:简单地计算了原来锚点与触摸点之间的距离`distanceFromTouch`,并由此计算一个系数。接下来,对于当前的item,我们获取其当前锚点位置,然后将其根据`scrollDelta`的数值和刚才计算的系数,重新设定锚点的位置。最后我们需要告诉UIDynamicAnimator我们已经完成了对冒点的更新,现在可以开始更新物理计算,并随时准备collectionView来取LayoutAttributes的数据了。 也许你还没有缓过神来?但是我们确实已经做完了,让我们来看看实际的效果吧: ![带有弹性效果的collecitonView](/assets/images/2013/spring-collection-view-over-ios7.gif) 当然,通过调节`damping`,`frequency`和`scrollResistance`的系数等参数,可以得到弹性不同的效果,比如更多的震荡或者更大的幅度等等。 这个layout实现起来非常简单,我顺便封装了一下放到了Github上,大家有需要的话可以[点击这里下载](https://github.com/onevcat/VVSpringCollectionViewFlowLayout)并直接使用。 URL: https://onevcat.com/2013/08/shader-tutorial-2/index.html.md Published At: 2013-08-31 00:57:34 +0900 # 猫都能学会的Unity3D Shader入门指南(二) ## 关于本系列 这是Unity3D Shader入门指南系列的第二篇,本系列面向的对象是新接触Shader开发的Unity3D使用者,因为我本身自己也是Shader初学者,因此可能会存在错误或者疏漏,如果您在Shader开发上有所心得,很欢迎并恳请您指出文中纰漏,我会尽快改正。在[之前的开篇](http://onevcat.com/2013/07/shader-tutorial-1/)中介绍了一些Shader的基本知识,包括ShaderLab的基本结构和语法,以及简单逐句地讲解了一个基本的shader。在具有这些基础知识后,阅读简单的shader应该不会有太大问题,在继续教程之前简单阅读一下Unity的[Surface Shader Example](http://docs.unity3d.com/Documentation/Components/SL-SurfaceShaderExamples.html),以检验您是否掌握了上一节的内容。如果您对阅读大部分示例Shader并没有太大问题,可以正确地指出Shader的结构,声明和使用的话,就说明您已经准备好继续阅读本节的内容了。 ## 法线贴图(Normal Mapping) 法线贴图是凸凹贴图(Bump mapping)的一种常见应用,简单说就是在不增加模型多边形数量的前提下,通过渲染暗部和亮部的不同颜色深度,来为原来的贴图和模型增加视觉细节和真实效果。简单原理是在普通的贴图的基础上,再另外提供一张对应原来贴图的,可以表示渲染浓淡的贴图。通过将这张附加的表示表面凸凹的贴图的因素于实际的原贴图进行运算后,可以得到新的细节更加丰富富有立体感的渲染效果。在本节中,我们将首先实现一个法线贴图的Shader,然后对Unity Shader的光照模型进行一些讨论,并实现一个自定义的光照模型。最后再通过更改shader模拟一个石头上的积雪效果,并对模型顶点进行一些修改使积雪效果看起来比较真实。在本节结束的时候,我们就会有一个比较强大的可以满足一些真实开发工作时可用的shader了,而且更重要的是,我们将会掌握它是如何被创造出来的。 关于法线贴图的效果图,可以对比看看下面。模型面数为500,左侧只使用了简单的Diffuse着色,右侧使用了法线贴图。比较两张图片不难发现,使用了法线贴图的石头在暗部和亮部都有着更好的表现。整体来说,凸凹感比Diffuse的结果增强许多,石头看起来更真实也更具有质感。 ![image](/assets/images/2013/shader-tutorial2-compare.jpg) 本节中需要用到的上面的素材可以[在这里下载](http://vdisk.weibo.com/s/y-NNpUsxhYhZI),其中包括上面的石块的模型,一张贴图以及对应的法线贴图。将下载的package导入到工程中,并新建一个material,使用简单的Diffuse的Shader(比如上一节我们实现的),再加上一个合适的平行光光源,就可以得到我们左图的效果。另外,本节以及以后都会涉及到一些Unity内建的Shader的内容,比如一些标准常用函数和常量定义等,相关内容可以在Unity的内建Shader中找到,内建Shader可以在[Unity下载页面](http://unity3d.com/unity/download/archive)的版本右侧找到。 接下来我们实现法线贴图。在实现之前,我们先简单地稍微多了解一些法线贴图的基本知识。大多数法线图一般都和下面的图类似,是一张以蓝紫色为主的图。这张法线图其实是一张RGB贴图,其中红,绿,蓝三个通道分别表示由高度图转换而来的该点的法线指向:Nx、Ny、Nz。在其中绝大部分点的法线都指向z方向,因此图更偏向于蓝色。在shader进行处理时,我们将光照与该点的法线值进行点积后即可得到在该光线下应有的明暗特性,再将其应用到原图上,即可反应在一定光照环境下物体的凹凸关系了。关于法向贴图的更多信息,可以参考[wiki上的相关条目](http://en.wikipedia.org/wiki/Normal_mapping)。 ![一张典型的法线图](/assets/images/2013/shader-tutorial2-normal.jpg) 回到正题,我们现在考虑的主要是Shader入门,而不是图像学的原理。再上一节我们写的Shader的基础上稍微做一些修改,就可以得到适应并完成法线贴图渲染的新Shader。新加入的部分进行了编号并在之后进行说明。 ```glsl Shader "Custom/Normal Mapping" { Properties { _MainTex ("Base (RGB)", 2D) = "white" {} //1 _Bump ("Bump", 2D) = "bump" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Lambert sampler2D _MainTex; //2 sampler2D _Bump; struct Input { float2 uv_MainTex; //3 float2 uv_Bump; }; void surf (Input IN, inout SurfaceOutput o) { half4 c = tex2D (_MainTex, IN.uv_MainTex); //4 o.Normal = UnpackNormal(tex2D(_Bump, IN.uv_Bump)); o.Albedo = c.rgb; o.Alpha = c.a; } ENDCG } FallBack "Diffuse" } ``` 1. 声明并加入一个显示名称为`Bump`的贴图,用于放置法线图 2. 为了能够在CG程序中使用这张贴图,必须加入一个sample,希望你还记得~ 3. 获取Bump的uv信息作为输入 4. 从法线图中提取法线信息,并将其赋予相应点的输出的Normal属性。`UnpackNormal`是定义在UnityCG.cginc文件中的方法,这个文件中包含了一系列常用的CG变量以及方法。`UnpackNormal`接受一个fixed4的输入,并将其转换为所对应的法线值(fixed3)。在解包得到这个值之后,将其赋给输出的Normal,就可以参与到光线运算中完成接下来的渲染工作了。 现在保存并且编译这个Shader,创建新的material并使用这个shader,将石头的材质贴图和法线图分别拖放到Base和Bump里,再将其应用到石头模型上,应该就可以看到右侧图的效果了。 ## 光照模型 在我们之前的看到的Shader中(其实也就上一节的基本diffuse和这里的normal mapping),都只使用了Lambert的光照模型(#pragma surface surf Lambert),这是一个很经典的漫反射模型,光强与入射光的方向和反射点处表面法向夹角的余弦成正比。关于Lambert和漫反射的一些详细的计算和推论,可以参看wiki([Lambert](http://en.wikipedia.org/wiki/Lambertian_reflectance),[漫反射](http://en.wikipedia.org/wiki/Diffuse_reflection))或者其他地方的介绍。一句话的简单解释就是一个点的反射光强是和该点的法线向量和入射光向量和强度和夹角有关系的,其结果就是这两个向量的点积。既然已经知道了光照计算的原理,我们先来看看如何实现一个自己的光照模型吧。 在刚才的Shader上进行如下修改。 * 首先将原来的`#pragma`行改为这样 ```glsl #pragma surface surf CustomDiffuse ``` * 然后在SubShader块中添加如下代码 ```glsl inline float4 LightingCustomDiffuse (SurfaceOutput s, fixed3 lightDir, fixed atten) { float difLight = max(0, dot (s.Normal, lightDir)); float4 col; col.rgb = s.Albedo * _LightColor0.rgb * (difLight * atten * 2); col.a = s.Alpha; return col; } ``` * 最后保存,回到Unity。Shader将编译,如果一切正常,你将不会看到新的shader和之前的在材质表现上有任何不同。但是事实上我们现在的shader已经与Unity内建的diffuse光照模型撇清了关系,而在使用我们自己设定的光照模型了。 喵的,这些代码都干了些什么!相信你一定会有这样的疑惑...没问题,没有疑惑的话那就不叫初学了,还是一行行讲来。首先正像我们上一篇所说,`#pragma`语句在这里声明了接下来的Shader的类型,计算调用的方法名,以及指定光照模型。在之前我们一直指定Lambert为光照模型,而现在我们将其换为了CustomDiffuse。 接下来添加的代码是计算光照的实现。shader中对于方法的名称有着比较严格的约定,想要创建一个光照模型,首先要做的是按照规则声明一个光照计算的函数名字,即`Lighting`。对于我们的光照模型CustomDiffuse,其计算函数的名称自然就是`LightingCustomDiffuse`了。光照模型的计算是在surf方法的表面颜色之后,根据输入的光照条件来对原来的颜色在这种光照下的表现进行计算,最后输出新的颜色值给渲染单元完成在屏幕的绘制。 也许你已经猜到了,我们之前用的Lambert光照模型是不是也有一个名字叫LightingLambert的光照计算函数呢?Bingo。在Unity的内建Shader中,有一个Lighting.cginc文件,里面就包含了LightingLambert的实现。也许你也注意到了,我们所实现的LightingCustomDiffuse的内容现在和Unity内建中的LightingLambert是完全一样的,这也就是使用新的shader的原来视觉上没有区别的原因,因为实现确实是完全一样的。 首先来看输入量,`SurfaceOutput s`这个就是经过表面计算函数surf处理后的输出,我们讲对其上的点根据光线进行处理,`fixed3 lightDir`是光线的方向,`fixed atten`表示光衰减的系数。在计算光照的代码中,我们先将输入的s的法线值(在Normal mapping中的话这个值已经是法线图中的对应量了)和输入光线进行点积(dot函数是CG中内置的数学函数,希望你还记得,可以[参考这里](http://http.developer.nvidia.com/CgTutorial/cg_tutorial_chapter05.html))。点积的结果在-1至1之间,这个值越大表示法线与光线间夹角越小,这个点也就应该越亮。之后使用max来将这个系数结果限制在0到1之间,是为了避免负数情况的存在而导致最终计算的颜色变为负数,输出一团黑,一般来说这是我们不愿意看到的。接下来我们将surf输出的颜色与光线的颜色`_LightColor0.rgb`(由Unity根据场景中的光源得到的,它在Lighting.cginc中有声明)进行乘积,然后再与刚才计算的光强系数和输入的衰减系数相乘,最后得到在这个光线下的颜色输出(关于difLight * atten * 2中为什么有个乘2,这是一个历史遗留问题,主要是为了进行一些光强补偿,可以参见[这里的讨论](http://forum.unity3d.com/threads/94711-Why-(atten-*-2)))。 在了解了基本实现方式之后,我们可以看看做一些修改玩玩儿。最简单的比如将这个Lambert模型改亮一些,比如换成Half Lambert模型。Half Lambert是由Valve创造的可以使物体在低光线条件下增亮的技术,最早被用于半条命(Half Life)中以避免在低光下物体的走形。简单说就是把光强系数先取一半,然后在加0.5,代码如下: ```glsl inline float4 LightingCustomDiffuse (SurfaceOutput s, fixed3 lightDir, fixed atten) { float difLight = dot (s.Normal, lightDir); float hLambert = difLight * 0.5 + 0.5; float4 col; col.rgb = s.Albedo * _LightColor0.rgb * (hLambert * atten * 2); col.a = s.Alpha; return col; } ``` 这样一来,原来光强0的点,现在对应的值变为了0.5,而原来是1的地方现在将保持为1。也就是说模型贴图的暗部被增强变亮了,而亮部基本保持和原来一样,防止过曝。使用Half Lambert前后的效果图如下,注意最右侧石头下方的阴影处细节更加明显了,而这一切都只是视觉效果的改变,不涉及任何贴图和模型的变化。 ![Half Lambert下发现贴图的表现](/assets/images/2013/shader-toturial-hl.jpg) ## 表面贴图的追加效果 OK,对于光线和自定义光照模型的讨论暂时到此为止,因为如果展开的话这将会一个庞大的图形学和经典光学的话题了。我们回到Shader,并且一起实现一些激动人心的效果吧。比如,在你的游戏场景中有一幕是雪地场景,而你希望做一些石头上白雪皑皑的覆盖效果,应该怎么办呢?难道让你可爱的3D设计师再去出一套覆雪的贴图然后使用新的贴图?当然不,不是不能,而是不该。因为新的贴图不仅会增大项目的资源包体积,更会增大之后修改和维护的难度,想想要是有好多石头需要实现同样的覆雪效果,或者是要随着游戏时间堆积的雪逐渐变多的话,你应该怎么办?难道让设计师再把所有的石头贴图都盖上雪,然后再按照雪的厚度出5套不同的贴图么?相信我,他们会疯的。 于是,我们考虑用Shader来完成这件工作吧!先考虑下我们需要什么,积雪效果的话,我们需要积雪等级(用来表示积雪量),雪的颜色,以及积雪的方向。基本思路和实现自定义光照模型类似,通过计算原图的点在世界坐标中的法线方向与积雪方向的点积,如果大于设定的积雪等级的阈值的话则表示这个方向与积雪方向是一致的,其上是可以积雪的,显示雪的颜色,否则使用原贴图的颜色。废话不再多说,上代码,在上面的Shader的基础上,更改Properties里的内容为 ```glsl Properties { _MainTex ("Base (RGB)", 2D) = "white" {} _Bump ("Bump", 2D) = "bump" {} _Snow ("Snow Level", Range(0,1) ) = 0 _SnowColor ("Snow Color", Color) = (1.0,1.0,1.0,1.0) _SnowDirection ("Snow Direction", Vector) = (0,1,0) } ``` 没有太多值得说的,唯一要提一下的是_SnowDirection设定的默认值为(0,1,0),这表示我们希望雪是垂直落下的。对应地,在CG程序中对这些变量进行声明: ```glsl sampler2D _MainTex; sampler2D _Bump; float _Snow; float4 _SnowColor; float4 _SnowDirection; ``` 接下来改变Input的内容: ```glsl struct Input { float2 uv_MainTex; float2 uv_Bump; float3 worldNormal; INTERNAL_DATA }; ``` 相对于上面的Shader输入来说,加入了一个`float3 worldNormal; INTERNAL_DATA`,如果SurfaceOutput中设定了Normal值的话,通过worldNormal可以获取当前点在世界中的法线值。详细的解说可以参见[Unity的Shader文档](http://docs.unity3d.com/Documentation/Components/SL-SurfaceShaders.html)。接下来可以改变surf函数,实装积雪效果了。 ```glsl void surf (Input IN, inout SurfaceOutput o) { half4 c = tex2D (_MainTex, IN.uv_MainTex); o.Normal = UnpackNormal(tex2D(_Bump, IN.uv_Bump)); if (dot(WorldNormalVector(IN, o.Normal), _SnowDirection.xyz) > lerp(1,-1,_Snow)) { o.Albedo = _SnowColor.rgb; } else { o.Albedo = c.rgb; } o.Alpha = c.a; } ``` 和上面相比,加入了一个if…else…的判断。首先看这个条件的不等式的左侧,我们对雪的方向和和输入点的世界法线方向进行点积。`WorldNormalVector`通过输入的点及这个点的法线值,来计算它在世界坐标中的方向;右侧的lerp函数相信只要对插值有概念的同学都不难理解:当_Snow取最小值0时,这个函数将返回1,而_Snow取最大值时,返回-1。这样我们就可以通过设定_Snow的值来控制积雪的阈值,要是积雪等级_Snow是0时,不等式左侧不可能大于右侧,因此完全没有积雪;相反要是_Snow取最大值1时,由于左侧必定大于-1,所以全模型积雪。而随着取中间值的变化,积雪的情况便会有所不同。 应用这个Shader,并且适当地调节一下积雪等级和颜色,可以得到如下右边的效果。 ![添加了积雪效果的Shader](/assets/images/2013/shader-tutorial2-snow.jpg) ## 更改顶点模型 到现在位置,我们还仅指是在原贴图上进行操作,不管是用法线图使模型看起来凸凹有致,还是加上积雪,所有的计算和颜色的输出都只是“障眼法”,并没有对模型有任何实质的改动。但是对于积雪效果来说,实际上积雪是附加到石头上面,而不应当简单替换掉原来的颜色。但是具体实施起来,最简单的办法还是直接替换颜色,但是我们可以稍微变更一下模型,使原来的模型在积雪的方向稍微变大一些,这样来达到一种雪是附加到石头上的效果。 我们继续修改之前的Shader,首先我们需要告诉surface shadow我们要改变模型的顶点。首先将#param行改为 `#pragma surface surf CustomDiffuse vertex:vert` 这告诉Shader我们想要改变模型顶点,并且我们会写一个叫做`vert`的函数来改变顶点。接下来我们再添加一个参数,在Properties中声明一个`_SnowDepth`变量,表示积雪的厚度,当然我们也需要在CG段中进行声明: ```glsl //In Properties{…} _SnowDepth ("Snow Depth", Range(0,0.3)) = 0.1 //In CG declare float _SnowDepth; ``` 接下来实现vert方法,和之前积雪的运算其实比较类似,判断点积大小来决定是否需要扩大模型以及确定模型扩大的方向。在CG段中加入以下vert方法 ```glsl void vert (inout appdata_full v) { float4 sn = mul(transpose(_Object2World) , _SnowDirection); if(dot(v.normal, sn.xyz) >= lerp(1,-1, (_Snow * 2) / 3)) { v.vertex.xyz += (sn.xyz + v.normal) * _SnowDepth * _Snow; } } ``` 和surf的原理差不多,系统会输入一个当前的顶点的值,我们根据需要计算并填上新的值作为返回即可。上面第一行中使用`transpose`方法输出原矩阵的转置矩阵,在这里_Object2World是Unity ShaderLab的内建值,它表示将当前模型转换到世界坐标中的矩阵,将其与积雪方向做矩阵乘积得到积雪方向在物体的世界空间中的投影(把积雪方向转换到世界坐标中)。之后我们计算了这个世界坐标中实际的积雪方向和当前点的法线值的点积,并将结果与使用积雪等级的2/3进行比较lerp后的阈值比较。这样,当前点如果和积雪方向一致,并且积雪较为完整的话,将改变该点的模型顶点高度。 加入模型更改前后的效果对比如下图,加入模型调整的右图表现要更为丰满真实。 ![image](/assets/images/2013/shader-tutorial2-snow-vert.jpg) 这节就到这里吧。本节中实现的Shader可以[在这里找到完整版本](https://gist.github.com/onevcat/6396814)进行参考,希望大家周末愉快~ URL: https://onevcat.com/2013/08/ios7-background-multitask/index.html.md Published At: 2013-08-17 00:56:02 +0900 # WWDC 2013 Session笔记 - iOS7中的多任务 这是我的WWDC2013系列笔记中的一篇,完整的笔记列表请参看[这篇总览](http://onevcat.com/2013/06/developer-should-know-about-ios7/)。本文仅作为个人记录使用,也欢迎在[许可协议](http://creativecommons.org/licenses/by-nc/3.0/deed.zh)范围内转载或使用,但是还烦请保留原文链接,谢谢您的理解合作。如果您觉得本站对您能有帮助,您可以使用[RSS](http://onevcat.com/atom.xml)或[邮件](http://eepurl.com/wNSkj)方式订阅本站,这样您将能在第一时间获取本站信息。 本文涉及到的WWDC2013 Session有 * Session 204 What's New with Multitasking * Session 705 What’s New in Foundation Networking ## iOS7以前的Multitasking iOS的多任务是在iOS4的时候被引入的,在此之前iOS的app都是按下Home键就被干掉了。iOS4虽然引入了后台和多任务,但是实际上是伪多任务,一般的app后台并不能执行自己的代码,只有少数几类服务在通过注册后可以真正在后台运行,并且在提交到AppStore的时候也会被严格审核是否有越权行为,这种限制主要是出于对于设备的续航和安全两方面进行的考虑。之后经过iOS5和6的逐渐发展,后台能运行的服务的种类虽然出现了增加,但是iOS后台的本质并没有变化。在iOS7之前,系统所接受的应用多任务可以大致分为几种: * 后台完成某些花费时间的特定任务 * 后台播放音乐等 * 位置服务 * IP电话(VoIP) * Newsstand 在WWDC 2013的keynote上,iOS7的后台多任务改进被专门拿出来向开发者进行了介绍,到底iOS7里多任务方面有什么新的特性可以利用,如何使用呢?简单来说,iOS7在后台特性方面有很大改进,不仅改变了以往的一些后台任务处理方式,还加入了全新的后台模式,本文将针对iOS7中新的后台特性进行一些学习和记录。大体来说,iOS7后台的变化在于以下四点: * 改变了后台任务的运行方式 * 增加了后台获取(Background Fetch) * 增加了推送唤醒(静默推送,Silent Remote Notifications) * 增加了后台传输(Background Transfer Service) ## iOS7的多任务 ### 后台任务 首先看看后台任务的变化,先说这方面的改变,而不是直接介绍新的API,是因为这个改变很典型地代表了iOS7在后台任务管理和能耗控制上的大体思路。从上古时期开始(其实也就4.0),UIApplication提供了`-beginBackgroundTaskWithExpirationHandler:`方法来使app在被切到后台后仍然能保持运行一段时间,app可以用这个方法来确保一些很重很慢的工作可以在急不可耐的用户将你的应用扔到后台后还能完成,比如编码视频,上传下载某些重要文件或者是完成某些数据库操作等,虽然时间不长,但在大多数情况下勉强够用。如果你之前没有使用过这个API的话,它使用起来大概是长这个样子的: ```objc - (void) doUpdate dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self beginBackgroundUpdateTask]; NSURLResponse * response = nil; NSError * error = nil; NSData * responseData = [NSURLConnection sendSynchronousRequest: request returningResponse: &response error: &error]; // Do something with the result [self endBackgroundUpdateTask]; }); } - (void) beginBackgroundUpdateTask { self.backgroundUpdateTask = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:^{ [self endBackgroundUpdateTask]; }]; } - (void) endBackgroundUpdateTask { [[UIApplication sharedApplication] endBackgroundTask: self.backgroundUpdateTask]; self.backgroundUpdateTask = UIBackgroundTaskInvalid; } ``` 在`beginBackgroundTaskWithExpirationHandler:`里写一个超时处理(系统只给app分配了一定时间来进行后台任务,超时之前会调用这个block),然后进行开始进行后台任务处理,在任务结束或者过期的时候call一下`endBackgroundTask:`使之与begin方法配对(否则你的app在后台任务超时的时候会被杀掉)。同时,你可以使用UIApplication实例的backgroundTimeRemaining属性来获取剩余的后台执行时间。 具体的执行时间来说,在iOS6和之前的系统中,系统在用户退出应用后,如果应用正在执行后台任务的话,系统会保持活跃状态直到后台任务完成或者是超时以后,才会进入真正的低功耗休眠状态。 ![iOS6之前的后台任务处理](/assets/images/2013/ios-multitask-ios6.png) 而在iOS7中,后台任务的处理方式发生了改变。系统将在用户锁屏后尽快让设备进入休眠状态,以节省电力,这时后台任务是被暂停的。之后在设备在特定时间进行系统应用的操作被唤醒(比如检查邮件或者接到来电等)时,之前暂停的后台任务将一起进行。就是说,系统不会专门为第三方的应用保持设备处于活动状态。如下图示 ![iOS7的后台任务处理](/assets/images/2013/ios-multitask-ios7.png) 这个变化在不减少应用的后台任务时间长度的情况下,给设备带来了更多的休眠时间,从而延长了续航。对于开发者来说,这个改变更多的是系统层级的变化,对于非网络传输的任务来说,保持原来的用法即可,新系统将会按照新的唤醒方式进行处理;而对于原来在后台做网络传输的应用来说,苹果建议在iOS7中使用`NSURLSession`,创建后台的session并进行网络传输,这样可以很容易地利用更好的后台传输API,而不必受限于原来的时长,关于这个具体的我们一会儿再说。 ### 后台获取(Background Fetch) 现在的应用无法在后台获取信息,比如社交类应用,用户一定需要在打开应用之后才能进行网络连接,获取新的消息条目,然后才能将新内容呈现给用户。说实话这个体验并不是很好,用户在打开应用后必定会有一段时间的等待,每次皆是如此。iOS7中新加入的后台获取就是用来解决这个不足的:后台获取干的事情就是在用户打开应用之前就使app有机会执行代码来获取数据,刷新UI。这样在用户打开应用的时候,最新的内容将已然呈现在用户眼前,而省去了所有的加载过程。想想看,没有加载的网络体验的世界,会是怎样一种感觉。这已经不是smooth,而是真的amazing了。 那具体应该怎么做呢?一步一步来: #### 启用后台获取 首先是修改应用的Info.plist,在`UIBackgroundModes`中加入fetch,即可告诉系统应用需要后台获取的权限。另外一种更简单的方式,得益于Xcode5的Capabilities特性(参见可以参见我之前的一篇[WWDC2013笔记 Xcode5和ObjC新特性](http://onevcat.com/2013/06/new-in-xcode5-and-objc/)),现在甚至都不需要去手动修改Info.plist来进行添加了,打开Capabilities页面下的Background Modes选项,并勾选Background fetch选项即可(如下图)。 ![在Capabilities中开启Background Modes](/assets/images/2013/ios7-multitask-background-fetch.png) 笔者写这篇文章的时候iOS7还没有上市,也没有相关的审核资料,所以不知道如果只是在这里打开了fetch选项,但却没有实现的话,应用会不会无法通过审核。但是依照苹果一贯的做法来看,如果声明了需要某项后台权限,但是结果却没有相关实现的话,被拒掉的可能性还是比较大的。因此大家尽量不要拿上线产品进行实验,而应当是在demo项目里研究明白以后再到上线产品中进行实装。 #### 设定获取间隔 对应用的UIApplication实例设置获取间隔,一般在应用启动的时候调用以下代码即可: ```objc [[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum]; ``` 如果不对最小后台获取间隔进行设定的话,系统将使用默认值`UIApplicationBackgroundFetchIntervalNever`,也就是永远不进行后台获取。当然,`-setMinimumBackgroundFetchInterval:`方法接受的是NSTimeInterval,因此你也可以手动指定一个以秒为单位的最小获取间隔。需要注意的是,我们都已经知道iOS是一个非常霸道为我独尊的系统,因此自然也不可能让一介区区第三方应用来控制系统行为。这里所指定的时间间隔只是代表了“在上一次获取或者关闭应用之后,在这一段时间内一定不会去做后台获取”,而真正具体到什么时候会进行后台获取,那~~~完全是要看系统娘的心情的~~~我们是无从得知的。系统将根据你的设定,选择比如接收邮件的时候顺便为你的应用获取一下,或者也有可能专门为你的应用唤醒一下设备。作为开发者,我们应该做的是为用户的电池考虑,尽可能地选择合适自己应用的后台获取间隔。设置为UIApplicationBackgroundFetchIntervalMinimum的话,系统会尽可能多尽可能快地为你的应用进行后台获取,但是比如对于一个天气应用,可能对实时的数据并不会那么关心,就完全不必设置为UIApplicationBackgroundFetchIntervalMinimum,也许1小时会是一个更好的选择。新的Mac OSX 10.9上已经出现了功耗监测,用于让用户确定什么应用是能耗大户,有理由相信同样的东西也可能出现在iOS上。如果不想让用户因为你的应用是耗电大户而怒删的话,从现在开始注意一下应用的能耗还是蛮有必要的(做绿色环保低碳的iOS app,从今天开始~)。 #### 实现后台获取代码并通知系统 在完成了前两步后,只需要在AppDelegate里实现`-application:performFetchWithCompletionHandler:`就行了。系统将会在执行fetch的时候调用这个方法,然后开发者需要做的是在这个方法里完成获取的工作,然后刷新UI,并通知系统获取结束,以便系统尽快回到休眠状态。获取数据这是应用相关的内容,在此不做赘述,应用在前台能完成的工作在这里都能做,唯一的限制是系统不会给你很长时间来做fetch,一般会小于一分钟,而且fetch在绝大多数情况下将和别的应用共用网络连接。这些时间对于fetch一些简单数据来说是足够的了,比如微博的新条目(大图除外),接下来一小时的天气情况等。如果涉及到较大文件的传输的话,用后台获取的API就不合适了,而应该使用另一个新的文件传输的API,我们稍后再说。类似前面提到的后台任务完成时必须通知系统一样,在在获取完成后,也必须通知系统获取完成,方法是调用`-application:performFetchWithCompletionHandler:`的handler。这个CompletionHandler接收一个`UIBackgroundFetchResult`作为参数,可供选择的结果有`UIBackgroundFetchResultNewData`,`UIBackgroundFetchResultNoData`,`UIBackgroundFetchResultFailed`三种,分别表示获取到了新数据(此时系统将对现在的UI状态截图并更新App Switcher中你的应用的截屏),没有新数据,以及获取失败。写一个简单的例子吧: ```objc //File: YourAppDelegate.m -(void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler { UINavigationController *navigationController = (UINavigationController*)self.window.rootViewController; id fetchViewController = navigationController.topViewController; if ([fetchViewController respondsToSelector:@selector(fetchDataResult:)]) { [fetchViewController fetchDataResult:^(NSError *error, NSArray *results){ if (!error) { if (results.count != 0) { //Update UI with results. //Tell system all done. completionHandler(UIBackgroundFetchResultNewData); } else { completionHandler(UIBackgroundFetchResultNoData); } } else { completionHandler(UIBackgroundFetchResultFailed); } }]; } else { completionHandler(UIBackgroundFetchResultFailed); } } ``` 当然,实际情况中会比这要复杂得多,用户当前的ViewController是否合适做获取,获取后的数据如何处理都需要考虑。另外要说明的是上面的代码在获取成功后直接在appDelegate里更新UI,这只是为了能在同一处进行说明,但却是不正确的结构。比较好的做法是将获取和更新UI的业务逻辑都放到fetchViewController里,然后向其发送获取消息的时候将completionHandler作为参数传入,并在fetchViewController里完成获取结束的报告。 另一个比较神奇的地方是系统将追踪用户的使用习惯,并根据对每个应用的使用时刻给一个合理的fetch时间。比如系统将记录你在每天早上9点上班的电车上,中午12点半吃饭时,以及22点睡觉前会刷一下微博,只要这个习惯持续个三四天,系统便会将应用的后台获取时刻调节为9点,12点和22点前一点。这样在每次你打开应用都直接有最新内容的同时,也节省了电量和流量。 #### 后台获取的调试 既然是系统决定的fetch,那我们要如何测试写的代码呢?难道是将应用退到后台,然后安心等待系统进行后台获取么?当然不是...Xcode5为我们提供了两种方法来测试后台获取的代码。一种是从后台获取中启动应用,另一种是当应用在后台时模拟一次后台推送。 对于前者,我们可以新建一个Scheme来专门调试从后台启动。点击Xcode5的Product->Scheme->Edit Scheme(或者直接使用快捷键`⌘<`)。在编辑Scheme的窗口中点Duplicate Scheme按钮复制一个当前方案,然后在新Scheme的option中将Background Fetch打上勾。从这个Scheme来运行应用的时候,应用将不会直接启动切入前台,而是调用后台获取部分代码并更新UI,这样再点击图标进入应用时,你应该可以看到最新的数据和更新好的UI了。 ![更改Scheme的选项为从后台获取事件中启动](/assets/images/2013/ios7-back-fetch-scheme.png) 另一种是当应用在后台时,模拟一次后台获取。这个比较简单,在app调试运行时,点击Xcode5的Debug菜单中的Simulate Background Fetch,即可模拟完成一次获取调用。 ### 推送唤醒(Remote Notifications) 远程推送(Remote Push Notifications)可以说是增加用户留存率的不二法则,在iOS6和之前,推送的类型是很单一的,无非就是显示标题内容,指定声音等。用户通过解锁进入你的应用后,appDelegate中通过推送打开应用的回调将被调用,然后你再获取数据,进行显示。这和没有后台获取时的打开应用后再获取数据刷新的问题是一样的。在iOS7中这个行为发生了一些改变,我们有机会使设备在接收到远端推送后让系统唤醒设备和我们的后台应用,并先执行一段代码来准备数据和UI,然后再提示用户有推送。这时用户如果解锁设备进入应用后将不会再有任何加载过程,新的内容将直接得到呈现。 实装的方法和刚才的后台获取比较类似,还是一步步来: #### 启用推送唤醒 和上面的后台获取类似,更改Info.plist,在`UIBackgroundModes`下加入`remote-notification`即可开启,当然同样的更简单直接的办法是使用Capabilities。 #### 更改推送的payload 在iOS7中,如果想要使用推送来唤醒应用运行代码的话,需要在payload中加入`content-available`,并设置为1。 ```javascript aps { content-available: 1 alert: {...} } ```  #### 实现推送唤醒代码并通知系统 最后在appDelegate中实现`-application:didReceiveRemoteNotification:fetchCompletionHandle:`。这部分内容和上面的后台获取部分完全一样,在此不再重复。 #### 一些限制和应用的例子 因为一旦推送成功,用户的设备将被唤醒,因此这类推送不可能不受到限制。Apple将限制此类推送的频率,当频率超过一定限制后,带有content-available标志的推送将会被阻塞,以保证用户设备不被频繁唤醒。按照Apple的说法,这个频率在一小时内个位数次的推送的话不会有太大问题。 Apple给出了几个典型的应用情景,比如一个电视节目类的应用,当用户标记某些剧目为喜爱时,当这些剧有更新时,可以给用户发送静默的唤醒推送通知,客户端在接到通知后检查更新并开始后台下载(注意后台下载的部分绝对不应该在推送回调中做,而是应该使用新的后台传输服务,后面详细介绍)。下载完成后发送一个本地推送告知用户新的内容已经准备完毕。这样在用户注意到推送并打开应用的时候,所有必要的内容已经下载完毕,UI也将切换至用户喜爱的剧目,用户只需要点击播放即可开始真正使用应用,这绝对是无比顺畅和优秀的体验。另一种应用情景是文件同步类,比如用户标记了一些文件为需要随时同步,这样用户在其他设备或网页服务上更改了这些文件时,可以发送静默推送然后使用后台传输来保持这些文件随时是最新。 如果您是一路看下来的话,不难发现其实后台获取和静默推送在很多方面是很类似的,特别是实现和处理的方式,但是它们适用的情景是完全不同的。后台获取更多地使用在泛数据模式下,也即用户对特定数据并不是很关心,数据应该被更新的时间也不是很确定,典型的有社交类应用和天气类应用;而静默推送或者是推送唤醒更多地应该是用户感兴趣的内容发生更新时被使用,比如消息类应用和内容型服务等。根据不同的应用情景,选择合适的后台策略(或者混合使用两者),以带给用户绝佳体验,这是Apple所期望iOS7开发者做到的。 ### 后台传输(Background Transfer Service) iOS6和之前,iOS应用在大块数据的下载这一块限制是比较多的:只有应用在前台时能保持下载(用户按Home键切到后台或者是等到设备自动休眠都可能中止下载),在后台只有很短的最多十分钟时间可以保持网络连接。如果想要完成一个较大数据的下载,用户将不得不打开你的app并且基本无所事事。很多这种时候,用户会想要是在下载的时候能切到别的应用刷刷微博或者玩玩游戏,然后再切回来的就已经下载完成了的话,该有多好。iOS7中,这可以实现了。iOS7引入了后台传输的相关方式,用来保证应用退出后数据下载或者上传能继续进行。这种传输是由iOS系统进行管理的,没有时间限制,也不要求应用运行在前台。 想要实现后台传输,就必须使用iOS7的新的网络连接的类,NSURLSession。这是iOS7中引入用以替代陈旧的NSURLConnection的类,著名的AFNetworking甚至不惜从底层开始完全重写以适配iOS7和NSURLSession(参见[这里](https://github.com/AFNetworking/AFNetworking/wiki/AFNetworking-2.0-Migration-Guide)),NSURLSession的重要性可见一斑。在这里我主要只介绍NSURLSession在后台传输中的一些使用,关于这个类的其他用法和对原有NSURLConnection的加强,只做稍微带过而不展开,有兴趣深入挖掘和使用的童鞋可以参看Apple的文档(或者更简单的方式是使用AFNetworking来处理网络相关内容,而不是直接和CFNetwork框架打交道)。 #### 步骤和例子 后台传输的的实现也十分简单,简单说分为三个步骤:创建后台传输用的NSURLSession对象;向这个对象中加入对应的传输的NSURLSessionTask,并开始传输;在实现appDelegate里实现`-application:handleEventsForBackgroundURLSession:completionHandler:`方法,以刷新UI及通知系统传输结束。接下来结合代码来看一看实际的用法吧~ 首先我们需要一个用于后台下载的session: ```objc - (NSURLSession *)backgroundSession { //Use dispatch_once_t to create only one background session. If you want more than one session, do with different identifier static NSURLSession *session = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfiguration:@"com.yourcompany.appId.BackgroundSession"]; session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil]; }); return session; } ``` 这里创建并配置了NSURLSession,将其指定为后台session并设定delegate。 接下来向其中加入对应的传输用的NSURLSessionTask,并启动下载。 ```objc //@property (nonatomic) NSURLSession *session; //@property (nonatomic) NSURLSessionDownloadTask *downloadTask; - (NSURLSession *)backgroundSession { //... } - (void) beginDownload { NSURL *downloadURL = [NSURL URLWithString:DownloadURLString]; NSURLRequest *request = [NSURLRequest requestWithURL:downloadURL]; self.session = [self backgroundSession]; self.downloadTask = [self.session downloadTaskWithRequest:request]; [self.downloadTask resume]; } ``` 最后一步是在appDelegate中实现`-application:handleEventsForBackgroundURLSession:completionHandler:` ```objc //AppDelegate.m - (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler { //Check if all transfers are done, and update UI //Then tell system background transfer over, so it can take new snapshot to show in App Switcher completionHandler(); //You can also pop up a local notification to remind the user //... } ``` NSURLSession和对应的NSURLSessionTask有以下重要的delegate方法可以使用: ```objc - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location; - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error; ``` 一旦后台传输的状态发生变化(包括正常结束和失败)的时候,应用将被唤醒并运行appDelegate中的回调,接下来NSURLSessionTask的委托方法将在后台被调用。虽然上面的例子中直接在appDelegate中call了completionHandler,但是实际上更好的选择是在appDelegate中暂时持有completionHandler,然后在NSURLSessionTask的delegate方法中检查是否确实完成了传输并更新UI后,再调用completionHandler。另外,你的应用到现在为止只是在后台运行,想要提醒用户传输完成的话,也许你还需要在这个时候发送一个本地推送(记住在这个时候你的应用是可以执行代码的,虽然是在后台),这样用户可以注意到你的应用的变化并回到应用,并开始已经准备好数据和界面。 #### 一些限制 首先,后台传输只会通过wifi来进行,用户大概也不会开心蜂窝数据的流量被后台流量用掉。后台下载的时间与以前的关闭应用后X分钟的模式不一样,而是为了节省电力变为离散式的下载,并与其他后台任务并发(比如接收邮件等)。另外还需要注意的是,对于下载后的内容不要忘记写到应用的目录下(一般来说这种可以重复获得的内容应该放到cache目录下),否则如果由于应用完全退出的情况导致没有保存到可再次访问的路径的话,那可就白做工了。 后台传输非常适合用于文件,照片或者追加游戏内容关卡等的下载,如果配合后台获取或者静默推送的话,相信可以完全很多很有趣,并且以前被限制而无法实现的功能。 URL: https://onevcat.com/2013/07/shader-tutorial-1/index.html.md Published At: 2013-07-23 00:54:38 +0900 # 猫都能学会的Unity3D Shader入门指南(一) ## 动机 自己使用Unity3D也有一段时间了,但是很多时候是流于表面,更多地是把这个引擎简单地用作脚本控制,而对更深入一些的层次几乎没有了解。虽然说Unity引擎设计的初衷就是创建简单的不需要开发者操心的谁都能用的3D引擎,但是只是肤浅的使用,可能是无法达到随心所欲的境地的,因此,这种状况必须改变!从哪里开始呢,貌似有句话叫做会写Shader的都是高手,于是,想大概看看从Shader开始能不能使自己到达的层次能再深入一些吧,再于是,有了这个系列(希望我能坚持写完它,虽然应该会拖个半年左右)。 Unity3D的所有渲染工作都离不开着色器(Shader),如果你和我一样最近开始对Shader编程比较感兴趣的话,可能你和我有着同样的困惑:如何开始?Unity3D提供了一些Shader的手册和文档(比如[这里](http://docs.unity3d.com/Documentation/Manual/Shaders.html),[这里](http://docs.unity3d.com/Documentation/Components/Built-inShaderGuide.html)和[这里](http://docs.unity3d.com/Documentation/Components/SL-Reference.html)),但是一来内容比较分散,二来学习阶梯稍微陡峭了些。这对于像我这样之前完全没有接触过有关内容的新人来说是相当不友好的。国内外虽然也有一些Shader的介绍和心得,但是也同样存在内容分散的问题,很多教程前一章就只介绍了基本概念,接下来马上就搬出一个超复杂的例子,对于很多基本的用法并没有解释。也许对于Shader熟练使用的开发者来说是没有问题,但是我相信像我这样的入门者也并不在少数。在多方寻觅无果后,我觉得有必要写一份教程,来以一个入门者的角度介绍一些Shader开发的基本步骤。其实与其说是教程,倒不如说是一份自我总结,希望能够帮到有需要的人。 所以,本“教程”的对象是 * 总的来说是新接触Shader开发的人:也许你知道什么是Shader,也会使用别人的Shader,但是仅限于知道一些基本的内建Shader名字,从来没有打开它们查看其源码。 * 想要更多了解Shader和有需求要进行Shader开发的开发者,但是之前并没有Shader开发的经验。 当然,因为我本身在Shader开发方面也是一个不折不扣的大菜鸟,本文很多内容也只是在自己的理解加上一些可能不太靠谱的求证和总结。本文中的示例应该会有更好的方式来实现,因此您是高手并且恰巧路过的话,如果有好的方式来实现某些内容,恳请您不吝留下评论,我会对本文进行不断更新和维护。 ## 一些基本概念 ### Shader和Material 如果是进行3D游戏开发的话,想必您对着两个词不会陌生。Shader(着色器)实际上就是一小段程序,它负责将输入的Mesh(网格)以指定的方式和输入的贴图或者颜色等组合作用,然后输出。绘图单元可以依据这个输出来将图像绘制到屏幕上。输入的贴图或者颜色等,加上对应的Shader,以及对Shader的特定的参数设置,将这些内容(Shader及输入参数)打包存储在一起,得到的就是一个Material(材质)。之后,我们便可以将材质赋予合适的renderer(渲染器)来进行渲染(输出)了。 所以说Shader并没有什么特别神奇的,它只是一段规定好输入(颜色,贴图等)和输出(渲染器能够读懂的点和颜色的对应关系)的程序。而Shader开发者要做的就是根据输入,进行计算变换,产生输出而已。 Shader大体上可以分为两类,简单来说 * 表面着色器(Surface Shader) - 为你做了大部分的工作,只需要简单的技巧即可实现很多不错的效果。类比卡片机,上手以后不太需要很多努力就能拍出不错的效果。 * 片段着色器(Fragment Shader) - 可以做的事情更多,但是也比较难写。使用片段着色器的主要目的是可以在比较低的层级上进行更复杂(或者针对目标设备更高效)的开发。 因为是入门文章,所以之后的介绍将主要集中在表面着色器上。 ### Shader程序的基本结构 因为着色器代码可以说专用性非常强,因此人为地规定了它的基本结构。一个普通的着色器的结构应该是这样的: ![一段Shader程序的结构](/assets/images/2013/shader-structure.png) 首先是一些属性定义,用来指定这段代码将有哪些输入。接下来是一个或者多个的子着色器,在实际运行中,哪一个子着色器被使用是由运行的平台所决定的。子着色器是代码的主体,每一个子着色器中包含一个或者多个的Pass。在计算着色时,平台先选择最优先可以使用的着色器,然后依次运行其中的Pass,然后得到输出的结果。最后指定一个回滚,用来处理所有Subshader都不能运行的情况(比如目标设备实在太老,所有Subshader中都有其不支持的特性)。 需要提前说明的是,在实际进行表面着色器的开发时,我们将直接在Subshader这个层次上写代码,系统将把我们的代码编译成若干个合适的Pass。废话到此为止,下面让我们真正实际进入Shader的世界吧。 ## Hello Shader 百行文档不如一个实例,下面给出一段简单的Shader代码,然后根据代码来验证下上面说到的结构和阐述一些基本的Shader语法。因为本文是针对Unity3D来写Shader的,所以也使用Unity3D来演示吧。首先,新建一个Shader,可以在Project面板中找到,Create,选择Shader,然后将其命名为`Diffuse Texture`: ![在Unity3D中新建一个Shader](/assets/images/2013/shader-create-in-unity.png) 随便用个文本编辑器打开刚才新建的Shader: ```glsl Shader "Custom/Diffuse Texture" { Properties { _MainTex ("Base (RGB)", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Lambert sampler2D _MainTex; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutput o) { half4 c = tex2D (_MainTex, IN.uv_MainTex); o.Albedo = c.rgb; o.Alpha = c.a; } ENDCG } FallBack "Diffuse" } ``` 如果您之前没怎么看过Shader代码的话,估计细节上会看不太懂。但是有了上面基本结构的介绍,您应该可以识别出这个Shader的构成,比如一个Properties部分,一个SubShader,以及一个FallBack。另外,第一行只是这个Shader的声明并为其指定了一个名字,比如我们的实例Shader,你可以在材质面板选择Shader时在对应的位置找到这个Shader。 ![在Unity3D中找到刚才新建的Shader](/assets/images/2013/shader-select.png) **接下来我们讲逐句讲解这个Shader,以期明了每一个语句的意义。** ### 属性 在`Properties{}`中定义着色器属性,在这里定义的属性将被作为输入提供给所有的子着色器。每一条属性的定义的语法是这样的: `_Name("Display Name", type) = defaultValue[{options}]` * _Name - 属性的名字,简单说就是变量名,在之后整个Shader代码中将使用这个名字来获取该属性的内容 * Display Name - 这个字符串将显示在Unity的材质编辑器中作为Shader的使用者可读的内容 * type - 这个属性的类型,可能的type所表示的内容有以下几种: * Color - 一种颜色,由RGBA(红绿蓝和透明度)四个量来定义; * 2D - 一张2的阶数大小(256,512之类)的贴图。这张贴图将在采样后被转为对应基于模型UV的每个像素的颜色,最终被显示出来; * Rect - 一个非2阶数大小的贴图; * Cube - 即Cube map texture(立方体纹理),简单说就是6张有联系的2D贴图的组合,主要用来做反射效果(比如天空盒和动态反射),也会被转换为对应点的采样; * Range(min, max) - 一个介于最小值和最大值之间的浮点数,一般用来当作调整Shader某些特性的参数(比如透明度渲染的截止值可以是从0至1的值等); * Float - 任意一个浮点数; * Vector - 一个四维数; * defaultValue 定义了这个属性的默认值,通过输入一个符合格式的默认值来指定对应属性的初始值(某些效果可能需要某些特定的参数值来达到需要的效果,虽然这些值可以在之后在进行调整,但是如果默认就指定为想要的值的话就省去了一个个调整的时间,方便很多)。 * Color - 以0~1定义的rgba颜色,比如(1,1,1,1); * 2D/Rect/Cube - 对于贴图来说,默认值可以为一个代表默认tint颜色的字符串,可以是空字符串或者"white","black","gray","bump"中的一个 * Float,Range - 某个指定的浮点数 * Vector - 一个4维数,写为 (x,y,z,w) * 另外还有一个{option},它只对2D,Rect或者Cube贴图有关,在写输入时我们最少要在贴图之后写一对什么都不含的空白的{},当我们需要打开特定选项时可以把其写在这对花括号内。如果需要同时打开多个选项,可以使用空白分隔。可能的选择有ObjectLinear, EyeLinear, SphereMap, CubeReflect, CubeNormal中的一个,这些都是OpenGL中TexGen的模式,具体的留到后面有机会再说。 所以,一组属性的申明看起来也许会是这个样子的 ```glsl //Define a color with a default value of semi-transparent blue _MainColor ("Main Color", Color) = (0,0,1,0.5) //Define a texture with a default of white _Texture ("Texture", 2D) = "white" {} ``` 现在看懂上面那段Shader(以及其他所有Shader)的Properties部分应该不会有任何问题了。接下来就是SubShader部分了。 ### Tags 表面着色器可以被若干的标签(tags)所修饰,而硬件将通过判定这些标签来决定什么时候调用该着色器。比如我们的例子中SubShader的第一句 `Tags { "RenderType"="Opaque" }` 告诉了系统应该在渲染非透明物体时调用我们。Unity定义了一些列这样的渲染过程,与RenderType是Opaque相对应的显而易见的是`"RenderType" = "Transparent"`,表示渲染含有透明效果的物体时调用。在这里Tags其实暗示了你的Shader输出的是什么,如果输出中都是非透明物体,那写在Opaque里;如果想渲染透明或者半透明的像素,那应该写在Transparent中。 另外比较有用的标签还有`"IgnoreProjector"="True"`(不被[Projectors](http://docs.unity3d.com/Documentation/Components/class-Projector.html)影响),`"ForceNoShadowCasting"="True"`(从不产生阴影)以及`"Queue"="xxx"`(指定渲染顺序队列)。这里想要着重说一下的是Queue这个标签,如果你使用Unity做过一些透明和不透明物体的混合的话,很可能已经遇到过不透明物体无法呈现在透明物体之后的情况。这种情况很可能是由于Shader的渲染顺序不正确导致的。Queue指定了物体的渲染顺序,预定义的Queue有: * Background - 最早被调用的渲染,用来渲染天空盒或者背景 * Geometry - 这是默认值,用来渲染非透明物体(普通情况下,场景中的绝大多数物体应该是非透明的) * AlphaTest - 用来渲染经过[Alpha Test](http://docs.unity3d.com/Documentation/Components/SL-AlphaTest.html)的像素,单独为AlphaTest设定一个Queue是出于对效率的考虑 * Transparent - 以从后往前的顺序渲染透明物体 * Overlay - 用来渲染叠加的效果,是渲染的最后阶段(比如镜头光晕等特效) 这些预定义的值本质上是一组定义整数,Background = 1000, Geometry = 2000, AlphaTest = 2450, Transparent = 3000,最后Overlay = 4000。在我们实际设置Queue值时,不仅能使用上面的几个预定义值,我们也可以指定自己的Queue值,写成类似这样:`"Queue"="Transparent+100"`,表示一个在Transparent之后100的Queue上进行调用。通过调整Queue值,我们可以确保某些物体一定在另一些物体之前或者之后渲染,这个技巧有时候很有用处。 ### LOD LOD很简单,它是Level of Detail的缩写,在这里例子里我们指定了其为200(其实这是Unity的内建Diffuse着色器的设定值)。这个数值决定了我们能用什么样的Shader。在Unity的Quality Settings中我们可以设定允许的最大LOD,当设定的LOD小于SubShader所指定的LOD时,这个SubShader将不可用。Unity内建Shader定义了一组LOD的数值,我们在实现自己的Shader的时候可以将其作为参考来设定自己的LOD数值,这样在之后调整根据设备图形性能来调整画质时可以进行比较精确的控制。 * VertexLit及其系列 = 100 * Decal, Reflective VertexLit = 150 * Diffuse = 200 * Diffuse Detail, Reflective Bumped Unlit, Reflective Bumped VertexLit = 250 * Bumped, Specular = 300 * Bumped Specular = 400 * Parallax = 500 * Parallax Specular = 600 ### Shader本体 前面杂项说完了,终于可以开始看看最主要的部分了,也就是将输入转变为输出的代码部分。为了方便看,请容许我把上面的SubShader的主题部分抄写一遍 ```glsl CGPROGRAM #pragma surface surf Lambert sampler2D _MainTex; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutput o) { half4 c = tex2D (_MainTex, IN.uv_MainTex); o.Albedo = c.rgb; o.Alpha = c.a; } ENDCG ``` 还是逐行来看,首先是CGPROGRAM。这是一个开始标记,表明从这里开始是一段CG程序(我们在写Unity的Shader时用的是Cg/HLSL语言)。最后一行的ENDCG与CGPROGRAM是对应的,表明CG程序到此结束。 接下来是是一个编译指令:`#pragma surface surf Lambert`,它声明了我们要写一个表面Shader,并指定了光照模型。它的写法是这样的 `#pragma surface surfaceFunction lightModel [optionalparams]` * surface - 声明的是一个表面着色器 * surfaceFunction - 着色器代码的方法的名字 * lightModel - 使用的光照模型。 所以在我们的例子中,我们声明了一个表面着色器,实际的代码在surf函数中(在下面能找到该函数),使用Lambert(也就是普通的diffuse)作为光照模型。 接下来一句`sampler2D _MainTex;`,sampler2D是个啥?其实在CG中,sampler2D就是和texture所绑定的一个数据容器接口。等等..这个说法还是太复杂了,简单理解的话,所谓加载以后的texture(贴图)说白了不过是一块内存存储的,使用了RGB(也许还有A)通道,且每个通道8bits的数据。而具体地想知道像素与坐标的对应关系,以及获取这些数据,我们总不能一次一次去自己计算内存地址或者偏移,因此可以通过sampler2D来对贴图进行操作。更简单地理解,sampler2D就是GLSL中的2D贴图的类型,相应的,还有sampler1D,sampler3D,samplerCube等等格式。 解释通了sampler2D是什么之后,还需要解释下为什么在这里需要一句对`_MainTex`的声明,之前我们不是已经在`Properties`里声明过它是贴图了么。答案是我们用来实例的这个shader其实是由两个相对独立的块组成的,外层的属性声明,回滚等等是Unity可以直接使用和编译的ShaderLab;而现在我们是在`CGPROGRAM...ENDCG`这样一个代码块中,这是一段CG程序。对于这段CG程序,要想访问在`Properties`中所定义的变量的话,**必须使用和之前变量相同的名字进行声明**。于是其实`sampler2D _MainTex;`做的事情就是再次声明并链接了_MainTex,使得接下来的CG程序能够使用这个变量。 终于可以继续了。接下来是一个struct结构体。相信大家对于结构体已经很熟悉了,我们先跳过之,直接看下面的的surf函数。上面的#pragma段已经指出了我们的着色器代码的方法的名字叫做surf,那没跑儿了,就是这段代码是我们的着色器的工作核心。我们已经说过不止一次,着色器就是给定了输入,然后给出输出进行着色的代码。CG规定了声明为表面着色器的方法(就是我们这里的surf)的参数类型和名字,因此我们没有权利决定surf的输入输出参数的类型,只能按照规定写。这个规定就是第一个参数是一个Input结构,第二个参数是一个inout的SurfaceOutput结构。 它们分别是什么呢?Input其实是需要我们去定义的结构,这给我们提供了一个机会,可以把所需要参与计算的数据都放到这个Input结构中,传入surf函数使用;SurfaceOutput是已经定义好了里面类型输出结构,但是一开始的时候内容暂时是空白的,我们需要向里面填写输出,这样就可以完成着色了。先仔细看看INPUT吧,现在可以跳回来看上面定义的INPUT结构体了: ```glsl struct Input { float2 uv_MainTex; }; ``` 作为输入的结构体必须命名为Input,这个结构体中定义了一个float2的变量…你没看错我也没打错,就是float2,表示浮点数的float后面紧跟一个数字2,这又是什么意思呢?其实没什么魔法,float和vec都可以在之后加入一个2到4的数字,来表示被打包在一起的2到4个同类型数。比如下面的这些定义: ``` //Define a 2d vector variable vec2 coordinate; //Define a color variable float4 color; //Multiply out a color float3 multipliedColor = color.rgb * coordinate.x; ``` 在访问这些值时,我们即可以只使用名称来获得整组值,也可以使用下标的方式(比如.xyzw,.rgba或它们的部分比如.x等等)来获得某个值。在这个例子里,我们声明了一个叫做`uv_MainTex`的包含两个浮点数的变量。 如果你对3D开发稍有耳闻的话,一定不会对uv这两个字母感到陌生。UV mapping的作用是将一个2D贴图上的点按照一定规则映射到3D模型上,是3D渲染中最常见的一种顶点处理手段。在CG程序中,我们有这样的约定,在一个贴图变量(在我们例子中是`_MainTex`)之前加上uv两个字母,就代表提取它的uv值(其实就是两个代表贴图上点的二维坐标 )。我们之后就可以在surf程序中直接通过访问uv_MainTex来取得这张贴图当前需要计算的点的坐标值了。 如果你坚持看到这里了,那要恭喜你,因为离最后成功读完一个Shader只有一步之遥。我们回到surf函数,它的两有参数,第一个是Input,我们已经明白了:在计算输出时Shader会多次调用surf函数,每次给入一个贴图上的点坐标,来计算输出。第二个参数是一个可写的SurfaceOutput,SurfaceOutput是预定义的输出结构,我们的surf函数的目标就是根据输入把这个输出结构填上。SurfaceOutput结构体的定义如下 ```glsl struct SurfaceOutput { half3 Albedo; //像素的颜色 half3 Normal; //像素的法向值 half3 Emission; //像素的发散颜色 half Specular; //像素的镜面高光 half Gloss; //像素的发光强度 half Alpha; //像素的透明度 }; ``` 这里的half和我们常见float与double类似,都表示浮点数,只不过精度不一样。也许你很熟悉单精度浮点数(float或者single)和双精度浮点数(double),这里的half指的是半精度浮点数,精度最低,运算性能相对比高精度浮点数高一些,因此被大量使用。 在例子中,我们做的事情非常简单: ```glsl half4 c = tex2D (_MainTex, IN.uv_MainTex); o.Albedo = c.rgb; o.Alpha = c.a; ``` 这里用到了一个`tex2d`函数,这是CG程序中用来在一张贴图中对一个点进行采样的方法,返回一个float4。这里对_MainTex在输入点上进行了采样,并将其颜色的rbg值赋予了输出的像素颜色,将a值赋予透明度。于是,着色器就明白了应当怎样工作:即找到贴图上对应的uv点,直接使用颜色信息来进行着色,over。 ## 接下来... 我想现在你已经能读懂一些最简单的Shader了,接下来我推荐的是参考Unity的[Surface Shader Examples](http://docs.unity3d.com/Documentation/Components/SL-SurfaceShaderExamples.html)多接触一些各种各样的基本Shader。在这篇教程的基础上,配合一些google的工作,完全看懂这个shader示例页面应该不成问题。如果能做到无压力看懂,那说明你已经有良好的基础可以前进到Shader的更深的层次了(也许等不到我的下一篇教程就可以自己开始动手写些效果了);如果暂时还是有困难,那也没有关系,Shader学习绝对是一个渐进的过程,因为有很多约定和常用技巧,多积累和实践自然会进步并掌握。 在接下来的教程里,打算通过介绍一些实际例子以及从基础开始实际逐步动手实现一个复杂一点的例子,让我们能看到shader在真正使用中的威力。我希望能尽快写完这个系列,但是无奈时间确实有限,所以我也不知道什么时候能出炉...写好的时候我会更改这段内容并指向新的文章。您要是担心错过的话,也可以使用[邮件订阅](http://eepurl.com/wNSkj)或者[订阅本站的rss](http://onevcat.com/atom.xml)(虽然Google Reader已经关了- -)。 URL: https://onevcat.com/2013/07/what-i-did-recently/index.html.md Published At: 2013-07-21 00:53:10 +0900 # 近期做的两三事 夏日炎炎,无心睡眠。 虽然已经有一段时间没有更新博客了,但是我确实是一直在努力干活儿的。这一个月以来大部分视线都在WWDC上,也写了几篇博文介绍个人觉得iOS7中需要深入挖掘和研究的API。但是因为NDA加上现在人在国外的缘故,还是不太好肆无忌惮地发出来。等到iOS7和Xcode5的NDA结束的时候(大概是9月中旬吧),我会一并把写的WWDC2013的笔记发出来,到时候还要请大家多多捧场。 另外在工作之外,也自己做了一些小项目,基本都是一些个人兴趣所致。虽然不值一提,但是还是想写下来主要作为记录。另外如果恰好能帮助到两三个同仁的话,那是最好不过。 ### 一个Xcode插件,VVDocumenter 虽然ObjC代码因为其可读性极强,而不太需要时常查阅文档,但是其实对于大多数人(包括我自己)来说,可能为方法或变量取一个好名字并不是那么简单的事情。这时候可能就需要文档或者注释来帮助之后的开发者(包括大家自己)尽快熟悉和方便修改。但是用Xcode写文档是一件让人很头疼的事情,没有像VS之类的成熟IDE的方便的方法,一直以来都是依靠像Snippet这样的东西,然后自己辛苦填写大量已有的内容。 之前看到一个用[Ruby+系统服务来生成注释的方案](http://blog.chukong-inc.com/index.php/2012/05/16/xcode4_fast_doxygen/),但是每次要自己去选还要按快捷键,总觉得是很麻烦的事情。借鉴其他平台IDE一般都是采用三个斜杠(`///`)来生成文档注释的方法,所以也为Xcode写了一个类似的。用法很简单,在要写文档的代码上面连打三个斜杠,就能自动提取参数等生成规范的Javadoc格式文档注释。**VVDocumenter**整个项目MIT开源,并且扔在github上了,有兴趣的童鞋可以[在这里](https://github.com/onevcat/VVDocumenter-Xcode)找到,欢迎大家加星fork以及给我发pull request来改善这个插件。 ![VVDocumenter演示](https://raw.github.com/onevcat/VVDocumenter-Xcode/master/ScreenShot.gif) ### 一个Unity插件,UniRate 做了一个叫**UniRate**的Unity插件,可以完全解决Unity移动端游戏请求用户评价的需求。对于一款应用/游戏来说,一般都会在你使用若干次/天之后弹一个邀请你评价的窗口,你可以选择是否到AppStore/Android Market进行评价或者稍后提醒。分别在iOS或者Android中实现这样的功能可以说是小菜一碟,但是Unity里现在暂时没有很好的方案。很可能你会需要花不少时间来实现一个类似功能,又或者要是你对native plugin这方面不太熟悉的话,可能就比较头疼了。 现在可以用UniRate来解决,添加的方法很简单,导入资源包,将里面的UniRateManager拖拽到scene中,就可以了..是的..没有第三步,这时候你已经有一个会监测用户使用并在安装3天并且使用10次后弹出一个提示评价的框,用户可以选择评价并跳转到相应页面了。如果你想做更多细节的调整和控制,可以参看这里的[用户手册](https://github.com/onevcat/UniRate/wiki/UniRate-Manual)和[在线文档](http://unirate.onevcat.com/reference/class_uni_rate.html)。 ![UniRate](/assets/images/2013/UniRate.jpg) 如果你感兴趣并且希望支持一下的话,UniRate现在可以在Unity Asset Store购买,[传送门在这里](https://www.assetstore.unity3d.com/#/content/10116)。 ### Oculus VR Rift 如果你不知道Oculus的话,这里有张我的近照可以帮助你了解。 ![我的Oculus Rift](/assets/images/2013/oculus-me.png) 其实就是一个虚拟现实用的眼镜,可以直接在眼前塞满屏幕的设备。之前也有索尼之类的厂家出过类似的眼镜,但是Oculus最大的特点是全屏无黑边,可以说提供了和以往完全不同的沉浸式游戏体验。难能可贵的是,在此同时还能做到价格厚道(坊间传闻今后希望能做到本体免费)。 回到主题,自从体验过Oculus VR Rift以后,我就相信这会是游戏的未来和方向。于是之前就下了订单预定了开发者版本,今天总算是到货。Oculus对于我来说最大的优点是支持Unity3D,所以自己可以用它来做一些好玩儿的东西,算是门槛比较低。相信之后会有一段时间来学习适配Oculus的Unity开发,并且每天沉浸在创造自娱自乐的虚拟现实之中,希望这段时光能成为自己之后美好的回忆。我在之后也会找机会在博客里分享一些关于Unity和Oculus集成,以及开发Oculus适配的游戏的一些经验和方法。 **如果有可能的话,真希望自己能够做一款好玩的Oculus的游戏,或者找到一个做Oculus游戏的企业,去创造这个未来,改变世界。** ### XUPorter更新 [XUPorter](https://github.com/onevcat/XUPorter)最早是写出来自己用的。因为每次从Unity build工程出来的时候,在Xcode里把各种依赖库拖来拖去简直是一件泯灭人性的事情。两年多前刚开始Unity的时候没有post build script这种东西,于是每次都要花上五到十分钟来配置Xcode的工程,时间一长就直接忘了需要依赖哪些文件和框架才能编译通过。后来有个post build脚本,但是每次写起来也很麻烦。XUPorter利用Unity3.5新加入的`PostProcessBuild`来根据配置修改Xcode工程文件,具体的介绍可以[看这里](http://onevcat.com/2012/12/xuporter/)。之前就是往Github上一扔而已,很高兴的是,有一些项目开始使用XUPorter做管理了,也有热心人在Github上帮助维护这个项目。于是最近对其进行了一些更新,添加了第三方库的添加等一些功能。 如果有需要的朋友可以了解一下并使用,可以节省不少时间。如果觉得好,也欢迎帮助推荐和支持,让更多人知道并受益。最简单的方法就是在[项目的Github页面](https://github.com/onevcat/XUPorter)加个星星~ :) URL: https://onevcat.com/2013/06/sprite-kit-start/index.html.md Published At: 2013-06-16 00:51:18 +0900 # WWDC 2013 Session笔记 - SpriteKit快速入门和新时代iOS游戏开发指南 这是我的WWDC2013系列笔记中的一篇,完整的笔记列表请参看[这篇总览](http://onevcat.com/2013/06/developer-should-know-about-ios7/)。本文仅作为个人记录使用,也欢迎在[许可协议](http://creativecommons.org/licenses/by-nc/3.0/deed.zh)范围内转载或使用,但是还烦请保留原文链接,谢谢您的理解合作。如果您觉得本站对您能有帮助,您可以使用[RSS](http://onevcat.com/atom.xml)或[邮件](http://eepurl.com/wNSkj)方式订阅本站,这样您将能在第一时间获取本站信息。 本文涉及到的WWDC2013 Session有 * Session 502 Introduction to Sprite Kit * Session 503 Designing Games with Sprite Kit SpriteKit的加入绝对是iOS 7/OSX 10.9的SDK最大的亮点。从此以后官方SDK也可以方便地进行游戏制作了。 如果你在看这篇帖子,那我估计你应该稍微知道一些iOS平台上2D游戏开发的东西,比如cocos2d,那很好,因为SpriteKit的很多概念其实和cocos2d非常类似,你应该能很快掌握。如果上面这张图你看着眼熟,或者自己动手实践过,那更好,因为这篇文章的内容就是通过使用SpriteKit来一步一步带你重新实践一遍这个经典教程。如果你既不知道cocos2d,更没有使用游戏引擎开发iOS游戏的经验,只是想一窥游戏开发的天地,那现在,SpriteKit将是一个非常好的入口,因为是iOS SDK自带的框架,因此思想和用法上和现有的其他框架是统一的,这极大地降低了学习的难度和门槛。 ### 什么是SpriteKit 首先要知道什么是`Sprite`。Sprite的中文译名就是精灵,在游戏开发中,精灵指的是以图像方式呈现在屏幕上的一个图像。这个图像也许可以移动,用户可以与其交互,也有可能仅只是游戏的一个静止的背景图。塔防游戏中敌方源源不断涌来的每个小兵都是一个精灵,我方防御塔发出的炮弹也是精灵。可以说精灵构成了游戏的绝大部分主体视觉内容,而一个2D引擎的主要工作,就是高效地组织,管理和渲染这些精灵。SpriteKit是在iOS7 SDK中Apple新加入的一个2D游戏引擎框架,在SpriteKit出现之前,iOS开发平台上已经出现了像cocos2d这样的比较成熟的2D引擎解决方案。SpriteKit展现出的是Apple将Xcode和iOS/Mac SDK打造成游戏引擎的野心,但是同时也确实与IDE有着更好的集成,减少了开发者的工作。 ### Hello SpriteKit 废话不多说,本文直接上实例教程来说明SpriteKit的基本用法。 好吧,我要做的是将非常风靡流行妇孺皆知的[raywenderlich的经典cocos2d教程](http://www.raywenderlich.com/25736/how-to-make-a-simple-iphone-game-with-cocos2d-2-x-tutorial)使用全新的SpriteKit重新实现一遍。重做这个demo的主要原因是cocos2d的这个入门实在是太经典了,包括了精灵管理,交互检测,声音播放和场景切换等等方面的内容,麻雀虽小,却五脏俱全。这个小demo讲的是一个无畏的英雄抵御外敌侵略的故事,英雄在画面左侧,敌人源源不断从右侧涌来,通过点击屏幕发射飞镖来消灭敌人,阻止它们越过屏幕左侧边缘。在示例中用到的素材,可以从[这里下载](/assets/images/2013/ResourcePackSpriteKit.zip)。另外为了方便大家,整个工程示例我也放在了github上,[传送门在此](https://github.com/onevcat/SpriteKitSimpleGame)。 ### 配置工程 首先当然是建立工程,Xcode5提供了SpriteKit模板,使用该模板建立新工程,名字就叫做SpriteKitSimpleGame好了。 ![新建一个SpriteKit工程](/assets/images/2013/spritekit-create.png) 因为我们需要一个横屏游戏,所以在新建工程后,在工程设定的General标签中,把Depoyment Info中Device Orientation中的Portrait勾去掉,使应用只在横屏下运行。另外,为了使之后的工作轻松一些,我们可以选择在初始的view显示完成,尺寸通过rotation计算完毕之后再添加新的Scene,这样得到的Scene的尺寸将是宽480(或者568)高320的size。如果在appear之前就使用bounds.size添加的话,将得到宽320 高480(568)的size,会很麻烦。将ViewController.m中的`-viewDidLoad:`方法全部替换成下面的`-viewDidAppear:`。 ```objc - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; // Configure the view. SKView * skView = (SKView *)self.view; skView.showsFPS = YES; skView.showsNodeCount = YES; // Create and configure the scene. SKScene * scene = [MyScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [skView presentScene:scene]; } ``` 然后编译运行,应如果一切正常,该显示类似于下面的画面,每点击画面时,会出现一架不停旋转的飞机。 ![SpriteKit正常运行](/assets/images/2013/sprite-kit-begin-screen.png) ### 加入精灵 SpriteKit是基于场景(Scene)来组织的,每个SKView(专门用来呈现SpriteKit的View)中可以渲染和管理一个SKScene,每个Scene中可以装载多个精灵(或者其他Node,之后会详细说明),并管理它们的行为。 现在让我们在这个Scene里加一个精灵吧,先从我们的英雄开始。首先要做的是把刚才下载的素材导入到工程中。我们这次用资源目录(Asset Catalog)来管理资源吧。点击工程中的`Images.xcassets`,以打开Asset Catalog。将下载解压后Art文件夹中的图片都拖入到打开的资源目录中,资源目录会自动根据文件的命名规则识别图片,1x的图片将用于iPhone4和iPad3之前的非retina设备,2x的图片将用于retina设备。当然,如果你对设备性能有信心的话,也可以把1x的图片删除掉,这样在非retina设备中也将使用2x的高清图(画面上的大小自然会帮你缩小成2x的一半),以获取更好的视觉效果。做完这一步后,工程的资源目录会是这个样子的: ![将图片素材导入工程中](/assets/images/2013/spritekit-import-images.png) 开始coding吧~默认的SpriteKit模板做的事情就是在ViewController的self.view(这个view是一个SKView,可以到storyboard文件中确认一下)中加入并显示了一个SKScene的子类实例MyScene。正如在做app开发时我们主要代码量会集中在ViewController一样,在用SpriteKit进行游戏开发时,因为所有游戏逻辑和精灵管理都会在Scene中完成,我们的代码量会集中在SKScene中。在MyScene.m中,把原来的`-initWithSize`替换成这样: ```objc -(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { /* Setup your scene here */ //1 Set background color for this scene self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0]; //2 Create a new sprite SKSpriteNode *player = [SKSpriteNode spriteNodeWithImageNamed:@"player"]; //3 Set it's position to the center right edge of screen player.position = CGPointMake(player.size.width/2, size.height/2); //4 Add it to current scene [self addChild:player]; } return self; } ``` 1. 因为默认工程的Scene背景偏黑,而我们的主角和怪物也都是黑色的,所以先设定为白色。SKColor只是一个define定义而已,在iOS平台下被定义为UIColor,在Mac下被定义为NSColor。在SpriteKit开发时,尽量使用SK开头的对应的UI类可以统一代码而减少跨iOS和Mac平台的成本。类似的定义还有SKView,它在iOS下是UIView的子类,在Mac下是NSView的子类。 2. 在SpriteKit中初始化一个精灵很简单,直接用`SKSpriteNode`的`+spriteNodeWithImageNamed:`,指定图片名就行。实际上一个SKSpriteNode中包含了贴图(SKTexture对象),颜色,尺寸等等参数,这个简便方法为我们读取图片,生成SKTexture,并设定精灵尺寸和图片大小一致。在实际使用中,绝大多数情况这个简便方法就足够了。 3. 设定精灵的位置。SpriteKit中的坐标系和其他OpenGL游戏坐标系是一致的,屏幕左下角为(0,0)。不过需要注意的是不论是横屏还是竖屏游戏,view的尺寸都是按照竖屏进行计算的,即对于iPhone来说在这里传入的sizewidth是320,height是480或者568,而不会因为横屏而发生交换。因此在开发时,请千万不要使用绝对数值来进行位置设定及计算(否则你会死的很难看啊很难看)。 4. 把player加入到当前scene中,addChild接受SKNode对象(SKSprite是SKNode的子类),关于SKNode稍后再说。 运行游戏,yes~主角出现在屏幕上了。 ![在屏幕左侧添加了一个精灵](/assets/images/2013/sprite-kit-add-player.png) ### 源源不断涌来的怪物大军 没有怪物的陪衬,主角再潇洒也是寂寞。添加怪物精灵的方法和之前添加主角没什么两样,生成精灵,设定位置,加到scene中。区别在于怪物是会移动的 & 怪物是每隔一段时间就会出现一个的。 在MyScene.m中,加入一个方法`-addMonster` ```objc - (void) addMonster { SKSpriteNode *monster = [SKSpriteNode spriteNodeWithImageNamed:@"monster"]; //1 Determine where to spawn the monster along the Y axis CGSize winSize = self.size; int minY = monster.size.height / 2; int maxY = winSize.height - monster.size.height/2; int rangeY = maxY - minY; int actualY = (arc4random() % rangeY) + minY; //2 Create the monster slightly off-screen along the right edge, // and along a random position along the Y axis as calculated above monster.position = CGPointMake(winSize.width + monster.size.width/2, actualY); [self addChild:monster]; //3 Determine speed of the monster int minDuration = 2.0; int maxDuration = 4.0; int rangeDuration = maxDuration - minDuration; int actualDuration = (arc4random() % rangeDuration) + minDuration; //4 Create the actions. Move monster sprite across the screen and remove it from scene after finished. SKAction *actionMove = [SKAction moveTo:CGPointMake(-monster.size.width/2, actualY) duration:actualDuration]; SKAction *actionMoveDone = [SKAction runBlock:^{ [monster removeFromParent]; }]; [monster runAction:[SKAction sequence:@[actionMove,actionMoveDone]]]; } ``` 1. 计算怪物的出生点(移动开始位置)的Y值。怪物从右侧屏幕外随机的高度处进入屏幕,为了保证怪物图像都在屏幕范围内,需要指定最小和最大Y值。然后从这个范围内随机一个Y值作为出生点。 2. 设定出生点恰好在屏幕右侧外面,然后添加怪物精灵。 3. 怪物要是匀速过来的话太死板了,加一点随机量,这样怪物有快有慢不会显得单调 4. 建立SKAction。SKAction可以操作SKNode,完成诸如精灵移动,旋转,消失等等。这里声明了两个SKAction,`actionMove`负责将精灵在`actualDuration`的时间间隔内移动到结束点(直线横穿屏幕);`actionMoveDone`负责将精灵移出场景,其实是run一段接受到的block代码。`runAction`方法可以让精灵执行某个操作,而在这里我们要做的是先将精灵移动到结束点,当移动结束后,移除精灵。我们需要的是一个顺序执行,这里sequence:可以让我们顺序执行多个action。 然后尝试在上面的`-initWithSize:`里调用这个方法看看结果 ```objc -(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { //... [self addChild:player]; [self addMonster]; } return self; } ``` ![在游戏中加入会动的敌人](/assets/images/2013/spritekit-add-moving-monster.png) Cool,我们的游戏有个能动的图像。知道么,游戏的本质是什么?就是一堆能动的图像! 只有一个怪物的话,英雄大大还是很寂寞,所以我们说好了会有源源不断的怪物..在`-initWithSize:`的4之后加入以下代码 ```objc //... //5 Repeat add monster to the scene every 1 second. SKAction *actionAddMonster = [SKAction runBlock:^{ [self addMonster]; }]; SKAction *actionWaitNextMonster = [SKAction waitForDuration:1]; [self runAction:[SKAction repeatActionForever:[SKAction sequence:@[actionAddMonster,actionWaitNextMonster]]]]; //... ``` 这里声明了一个SKAction的序列,run一个block,然后等待1秒。用这个动作序列用`-repeatActionForever:`生成一个无限重复的动作,然后让scene执行。这样就可以实现每秒调用一次`-addMonster`来向场景中不断添加敌人了。如果你对Cocoa(Touch)开发比较熟悉的话,可能会说,为什么不用一个NSTimer来做同样的事情,而要写这样的SKAction呢?能不能用NSTimer来达到同样的目的?答案是在对场景或者精灵等SpriteKit对象进行类似操作时,尽量不要用NSTimer。因为NSTimer将不受SpriteKit的影响和管理,使用SKAction可以不加入其它任何代码就获取如下好处: * 自动暂停和继续,当设定一个SKNode的`paused`属性为YES时,这个SKNode和它管理的子node的action都会自动被暂停。这里详细说明一下SKNode的概念:SKNode是SpriteKit中要素的基本组织方式,它代表了SKView中的一种游戏资源的组织方式。我们现在接触到的SKScene和SKSprite都是SKNode的子类,而一个SKNode可以有很多的子Node,从而构成一个SKNode的树。在我们的例子中,MyScene直接加在SKView中作为最root的node存在,而英雄或者敌人的精灵都作为Scene这个node的子node被添加进来。SKAction和node上的各种属性的的作用范围是当前这个node和它的所有子node,在这里我们如果设定MySecnen这个node(也就是self)的`paused`属性被设为YES的话,所有的Action都会被暂停,包括这个每隔一秒调用一次的action,而如果你用NSTimer的话,恭喜,你必须自行维护它的状态。 * 当SKAction依附的结点被从结点树中拿掉的时候,这个action会自动结束并停止,这是符合一般逻辑的。 编译,运行,一切如我们所预期的那样,每个一秒有一个怪物从右侧进入,并以不同的速度穿过屏幕。 ![添加了源源不断滚滚而来的敌人大军](/assets/images/2013/sprtekit-monsters.gif) ### 奥特曼打小怪兽是天经地义的 有了英雄,有了怪兽,就差一个“打”了。我们打算做的是在用户点击屏幕某个位置时,就由英雄所在的位置向点击位置发射一枚固定速度的飞镖。然后这每飞镖要是命中怪物的话,就把怪物从屏幕中移除。 先来实现发射飞镖吧。检测点击,然后让一个精灵朝向点击的方向以某个速度移动,有很多种SKAction可以实现,但是为了尽量保持简单,我们使用上面曾经使用过的`moveTo:duration:`吧。在发射之前,我们先要来做一点基本的数学运算,希望你还能记得相似三角形之类的概念。我们的飞镖是由英雄发出的,然后经过手指点击的点,两点决定一条直线。简单说我们需要求解出这条直线和屏幕右侧边缘外的交点,以此来确定飞镖的最终目的。一旦我们得到了这个终点,就可以控制飞镖moveTo到这个终点,从而模拟出发射飞镖的action了。如图所示,很简单的几何学,关于具体的计算就不再讲解了,要是算不出来的话,请考虑call你的中学数学老师并负荆请罪以示诚意。 ![通过点击计算飞镖终止位置](/assets/images/2013/spritekit-math.png) 然后开始写代码吧,还记得我们之前点击会出现一个飞机的精灵么,找到相应的地方,MyScene.m里的`-touchesBegan:withEvent:`:,用下面的代码替换掉原来的。 ```objc -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { /* Called when a touch begins */ for (UITouch *touch in touches) { //1 Set up initial location of projectile CGSize winSize = self.size; SKSpriteNode *projectile = [SKSpriteNode spriteNodeWithImageNamed:@"projectile.png"]; projectile.position = CGPointMake(projectile.size.width/2, winSize.height/2); //2 Get the touch location tn the scene and calculate offset CGPoint location = [touch locationInNode:self]; CGPoint offset = CGPointMake(location.x - projectile.position.x, location.y - projectile.position.y); // Bail out if you are shooting down or backwards if (offset.x <= 0) return; // Ok to add now - we've double checked position [self addChild:projectile]; int realX = winSize.width + (projectile.size.width/2); float ratio = (float) offset.y / (float) offset.x; int realY = (realX * ratio) + projectile.position.y; CGPoint realDest = CGPointMake(realX, realY); //3 Determine the length of how far you're shooting int offRealX = realX - projectile.position.x; int offRealY = realY - projectile.position.y; float length = sqrtf((offRealX*offRealX)+(offRealY*offRealY)); float velocity = self.size.width/1; // projectile speed. float realMoveDuration = length/velocity; //4 Move projectile to actual endpoint [projectile runAction:[SKAction moveTo:realDest duration:realMoveDuration] completion:^{ [projectile removeFromParent]; }]; } } ``` 1. 为飞镖设定初始位置。 2. 将点击的位置转换为node的坐标系的坐标,并计算点击位置和飞镖位置的偏移量。如果点击位置在飞镖初始位置的后方,则直接返回 3. 根据相似三角形计算屏幕右侧外的结束位置。 4. 移动飞镖,并在移动结束后将飞镖从场景中移除。注意在移动怪物的时候我们用了两个action(actionMove和actionMoveDone来做移动+移除),这里只使用了一个action并用带completion block移除精灵。这里对飞镖的这种做法是比较简明常见高效的,之前的做法只是为了说明action的`sequence:`的用法。 运行看看现在的游戏吧,我们有英雄有怪物还有打怪物的小飞镖,好像气氛上已经开始有趣了! ![加入飞镖之后,游戏开始变得有趣了](/assets/images/2013/spritekit-add-projectile.gif) ### 飞镖击中的检测 但是一个严重的问题是,现在的飞镖就算接触到了怪物也是直穿而过,完全就是空气一般的存在。为什么?因为我们还没有写任何检测飞镖和怪物的接触的代码(废话)。我们想要做的是在飞镖和怪物接触到的时候,将它们都移出场景,这样看起来就像是飞镖打中了怪物,并且把怪物消灭了。 基本思路是在每隔一个小的时间间隔,就扫描一遍场景中现存的飞镖和怪物。这里就需要提到SpriteKit中最基本的每一帧的周期概念。 ![SpriteKit的更新周期](/assets/images/2013/spritekit-update_loop.png) 在iOS传统的view的系统中,view的内容被渲染一次后就将一直等待,直到需要渲染的内容发生改变(比如用户发生交互,view的迁移等)的时候,才进行下一次渲染。这主要是因为传统的view大多工作在静态环境下,并没有需要频繁改变的需求。而对于SpriteKit来说,其本身就是用来制作大多数时候是动态的游戏的,为了保证动画的流畅和场景的持续更新,在SpriteKit中view将会循环不断地重绘。 动画和渲染的进程是和SKScene对象绑定的,只有当场景被呈现时,这些渲染以及其中的action才会被执行。SKScene实例中,一个循环按执行顺序包括 * 每一帧开始时,SKScene的`-update:`方法将被调用,参数是从开始时到调用时所经过的时间。在该方法中,我们应该实现一些游戏逻辑,包括AI,精灵行为等等,另外也可以在该方法中更新node的属性或者让node执行action * 在update执行完毕后,SKScene将会开始执行所有的action。因为action是可以由开发者设定的(还记得runBlock:么),因此在这一个阶段我们也是可以执行自己的代码的。 * 在当前帧的action结束之后,SKScene的`-didEvaluateActions`将被调用,我们可以在这个方法里对结点做最后的调整或者限制,之后将进入物理引擎的计算阶段。 * 然后SKScene将会开始物理计算,如果在结点上添加了SKPhysicsBody的话,那么这个结点将会具有物理特性,并参与到这个阶段的计算。根据物理计算的结果,SpriteKit将会决定结点新的状态。 * 然后`-didSimulatePhysics`会被调用,这类似之前的`-didEvaluateActions`。这里是我们开发者能参与的最后的地方,是我们改变结点的最后机会。 * 一帧的最后是渲染流程,根据之前设定和计算的结果对整个呈现的场景进行绘制。完成之后,SpriteKit将开始新的一帧。 在了解了一些SpriteKit的基础概念后,回到我们的demo。检测场景上每个怪物和飞镖的状态,如果它们相撞就移除,这是对精灵的计算的和操作,我们可以将其放到`-update:`方法中来处理。在此之前,我们需要保存一下添加到场景中的怪物和飞镖,在MyScene.m的@implementation之前加入下面的声明: ```objc @interface MyScene() @property (nonatomic, strong) NSMutableArray *monsters; @property (nonatomic, strong) NSMutableArray *projectiles; @end ``` 然后在`-initWithSize:`中配置场景之前,初始化这两个数组: ```objc -(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { /* Setup your scene here */ self.monsters = [NSMutableArray array]; self.projectiles = [NSMutableArray array]; //... } return self; } ``` 在将怪物或者飞镖加入场景中的同时,分别将它们加入到数组中, ```objc -(void) addMonster { //... [self.monsters addObject:monster]; } ``` ```objc -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { for (UITouch *touch in touches) { //... [self.projectiles addObject:projectile]; } } ``` 同时,在将它们移除场景时,将它们移出所在数组,分别在`[monster removeFromParent]`和`[projectile removeFromParent]`后加入`[self.monsters removeObject:monster]`和`[self.projectiles removeObject:projectile]`。接下来终于可以在`-update:`中检测并移除了: ```objc -(void)update:(CFTimeInterval)currentTime { /* Called before each frame is rendered */ NSMutableArray *projectilesToDelete = [[NSMutableArray alloc] init]; for (SKSpriteNode *projectile in self.projectiles) { NSMutableArray *monstersToDelete = [[NSMutableArray alloc] init]; for (SKSpriteNode *monster in self.monsters) { if (CGRectIntersectsRect(projectile.frame, monster.frame)) { [monstersToDelete addObject:monster]; } } for (SKSpriteNode *monster in monstersToDelete) { [self.monsters removeObject:monster]; [monster removeFromParent]; } if (monstersToDelete.count > 0) { [projectilesToDelete addObject:projectile]; } } for (SKSpriteNode *projectile in projectilesToDelete) { [self.projectiles removeObject:projectile]; [projectile removeFromParent]; } } ``` 代码比较简单,不多解释了。直接运行看结果 ![发射飞镖,消灭敌人!](/assets/images/2013/spritekit-hit.gif) ### 播放声音 音效绝对是游戏的一个重要环节,还记得一开始下载的那个资源文件压缩包么?里面除了Art文件夹外还有个Sounds文件夹,我们把Sounds加入工程里,整个文件夹拖到工程导航里面,然后勾上“Copy item”。 我们想在发射飞镖时播出一个音效,对于音效的播放是十分简单的,SpriteKit为我们提供了一个action,用来播放单个音效。因为每次的音效是相同的,所以只需要在一开始加载一次action,之后就一直使用这个action,以提高效率。先在MyScene.m的@interface中加入 ```objc @property (nonatomic, strong) SKAction *projectileSoundEffectAction; ``` 然后在`-initWithSize:`一开始的地方加入 ```objc self.projectileSoundEffectAction = [SKAction playSoundFileNamed:@"pew-pew-lei.caf" waitForCompletion:NO]; ``` 最后,修改发射飞镖的action,使播放音效的action和移动精灵的action同时执行。将`-touchesBegan:withEvent:`最后runAction的部分改为 ```objc //... //4 Move projectile to actual endpoint and play the throw sound effect SKAction *moveAction = [SKAction moveTo:realDest duration:realMoveDuration]; SKAction *projectileCastAction = [SKAction group:@[moveAction,self.projectileSoundEffectAction]]; [projectile runAction:projectileCastAction completion:^{ [projectile removeFromParent]; [self.projectiles removeObject:projectile]; }]; //... ``` 之前我们介绍了用`-sequence:`连接不同的action,使它们顺序串行执行。在这里我们用了另一个方便的方法,`-group:`可以范围一个新的action,这个action将并行同时开始执行传入的所有action。在这里我们在飞镖开始移动的同时,播放了一个pew-pew-lei的音效(音效效果请下载demo试听,或者自行脑补…)。 游戏中音效一般来说至少会有效果音(SE)和背景音(BGM)两种,SE可以用SpriteKit的action来解决,而BGM就要惨一些,至少写这篇教程的时候,SpriteKit还没有一个BGM的专门的对应方案(如果之后新加了的话我会更新本教程)。所以现在我们使用传统的播放较长背景音乐的方法来实现背景音,那就是用`AVAudioPlayer`。在@interface MyScene()中加入一个bgmPlayer的声明,然后在`-initWithSize:`中加载背景音并一直播放。 ```objc @interface MyScene() //... @property (nonatomic, strong) AVAudioPlayer *bgmPlayer; //... @end @implementation MyScene -(id)initWithSize:(CGSize)size { //... NSString *bgmPath = [[NSBundle mainBundle] pathForResource:@"background-music-aac" ofType:@"caf"]; self.bgmPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:[NSURL fileURLWithPath:bgmPath] error:NULL]; self.bgmPlayer.numberOfLoops = -1; [self.bgmPlayer play]; //... } ``` AVAudioPlayer用来播放背景音乐相当的合适,唯一的问题是有可能你想在暂停的时候停止这个背景音乐的播放。因为使用的是SpriteKit以外的框架,而并非action,因此BGM的播放不会随着设置Scene为暂停或者移除这个Scene而停止。想要停止播放,必须手动显式地调用`[self.bgmPlayer stop]`,可以说是比较麻烦,不过有时候你不并不想在暂停或者场景切换的时候中断背景音乐的话,这反倒是一个好的选择。 ### 结果计算和场景切换 到现在为止,整个关卡作为一个demo来说已经比较完善了。最后,我们可以为这个关卡设定一些条件,毕竟不是每个人都喜欢一直无意义地消灭怪物直到手机没电。我们设定规则,当打死30个怪物后切换到新的场景,以成功结束战斗的结果;另外,要是有任何一个怪物到达了屏幕左侧边缘,则本场战斗失败。另外我们在显示结果的场景中还需要一个交互按钮,以便我们重新开始一轮游戏。 首先是检测被打死的怪物数,在MyScene里添加一个`monstersDestroyed`,然后在打中怪物时使这个值+1,并在随后判断如果消灭怪物数量大于等于30,则切换场景(暂时没有实现,现在留了两个TODO,一会儿我们再实装场景切换) ```objc @interface MyScene() //... @property (nonatomic, assign) int monstersDestroyed; //... @end -(void)update:(CFTimeInterval)currentTime { //... for (SKSpriteNode *monster in monstersToDelete) { [self.monsters removeObject:monster]; [monster removeFromParent]; self.monstersDestroyed++; if (self.monstersDestroyed >= 30) { //TODO: Show a win scene } } //... ``` 另外,在怪物到达屏幕边缘的时候也触发场景的切换: ```objc - (void) addMonster { //... SKAction *actionMoveDone = [SKAction runBlock:^{ [monster removeFromParent]; [self.monsters removeObject:monster]; //TODO: Show a lose scene }]; //... } ``` 接下来就是制作新的表示结果的场景了。新建一个SKScene的子类很简单,和平时我们新建Cocoa或者CocoaTouch的类没有什么区别。菜单中File->New->File...,选择Objective-C class,然后将新建的文件取名为ResultScene,父类填写为SKScene,并在新建的时候选择合适的Target即可。在新建的ResultScene.m的@implementation中加入如下代码: ```objc -(instancetype)initWithSize:(CGSize)size won:(BOOL)won { if (self = [super initWithSize:size]) { self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0]; //1 Add a result label to the middle of screen SKLabelNode *resultLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"]; resultLabel.text = won ? @"You win!" : @"You lose"; resultLabel.fontSize = 30; resultLabel.fontColor = [SKColor blackColor]; resultLabel.position = CGPointMake(CGRectGetMidX(self.frame), CGRectGetMidY(self.frame)); [self addChild:resultLabel]; //2 Add a retry label below the result label SKLabelNode *retryLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"]; retryLabel.text = @"Try again"; retryLabel.fontSize = 20; retryLabel.fontColor = [SKColor blueColor]; retryLabel.position = CGPointMake(resultLabel.position.x, resultLabel.position.y * 0.8); //3 Give a name for this node, it will help up to find the node later. retryLabel.name = @"retryLabel"; [self addChild:retryLabel]; } return self; } ``` 我们在ResultScene中自定义了一个含有结果的初始化方法初始化,之后我们将使用这个方法来初始化ResultScene。在这个init方法中我们做了以下这些事: 1. 根据输入添加了一个SKLabelNode来显示游戏的结果。SKLabelNode也是SKNode的子类,可以用来方便地显示不同字体、颜色或者样式的文字标签。 2. 在结果标签的下方加入了一个重开一盘的标签 3. 我们为这个node进行了命名,通过对node命名,我们可以在之后方便地拿到这个node的参照,而不必新建一个变量来持有它。在实际运用中,这个命名即可以用来存储一个唯一的名字,来帮助我们之后找到特定的node(使用`-childNodeWithName:`),也可以一堆特性类似的node共用一个名字,这样可以方便枚举(使用`-enumerateChildNodesWithName:usingBlock:`方法)。不过这次的demo中,我们只是简单地用字符串比较来确定node,稍后会看到具体的用法。 最后不要忘了这个方法名写到.h文件中去,这样我们才能在游戏场景中调用到。 回到游戏场景,在MyScene.m的加入对ResultScene.h的引用,然后在实现中加入一个切换场景的方法 ```objc #import "ResultScene.h" //... -(void) changeToResultSceneWithWon:(BOOL)won { [self.bgmPlayer stop]; self.bgmPlayer = nil; ResultScene *rs = [[ResultScene alloc] initWithSize:self.size won:won]; SKTransition *reveal = [SKTransition revealWithDirection:SKTransitionDirectionUp duration:1.0]; [self.scene.view presentScene:rs transition:reveal]; } ``` `SKTransition`是专门用来做不同的Scene之前切换的类,这个类为我们提供了很多“廉价”的场景切换效果(相信我,你如果想要自己实现它们的话会颇费一番功夫)。在这里我们建立了一个将当前场景上推的切换效果,来显示新的ResultScene。另外注意我们在这里停止了BGM的播放。之后,将刚才留下来的两个TODO的地方,分别替换为以相应参数对这个方法的调用。 最后,我们想要在ResultScene中点击Retry标签时,重开一盘游戏。在ResultScene.m中加入代码 ```objc -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { for (UITouch *touch in touches) { CGPoint touchLocation = [touch locationInNode:self]; SKNode *node = [self nodeAtPoint:touchLocation]; if ([node.name isEqualToString:@"retryLabel"]) { [self changeToGameScene]; } } } -(void) changeToGameScene { MyScene *ms = [MyScene sceneWithSize:self.size]; SKTransition *reveal = [SKTransition revealWithDirection:SKTransitionDirectionDown duration:1.0]; [self.scene.view presentScene:ms transition:reveal]; } ``` 运行游戏,消灭足够多的敌人(或者漏过一个敌人),应该能够可能到场景切换和结果显示。然后点击再来一次的话,将重新开始新的游戏。 ![结束时显示结果场景](/assets/images/2013/spritekit-result.png) ### 关于Sprite的一些个人补充 至此,整个Demo的主体部分结束。接下来对于当前的SpriteKit(iOS SDK beta1)说一些我个人的看法和理解。如果之后这部分内容有巨大变化的话,我会尽量更新。首先是性能问题,如果有在iOS平台下使用cocos2d开发的经验的话,很容易看出来SpriteKit在很多地方借鉴了cocos2d。作为SDK内置的框架来说,又有cocos2d的开源实现进行参考,效率方面超越cocos2d应该是理所当然的。在现有的一系列benchmark上来看,实际上SpriteKit在图形渲染方面也有着很不错的表现。另外,在编写过程中,也有不少技巧可以使用,以进一步进行优化,比如在内存中保持住常用的action,预先加载资源,使用Atlas等等。在进行比较全面和完整的优化后,SpriteKit的表现应该是可以期待的。 使用SpriteKit一个很明显的优点在于,SKView其实是基于UIKit的UIView的一套实现,而其中的所有SKNode对象都UIResponder的子类,并且实现了NSCoding等接口。也就是说,其实在SpriteKit中是可以很容易地使用其他的非游戏Cocoa/CocoaTouch框架的。比如可以使用UIKit或者Cocoa来简单地制作UI,然后只在需要每帧演算的时候使用SpriteKit,藉此来达到快速开发的目的。这点上cocos2d是无法与之比拟的。另外,因为SKSprite同时兼顾了iOS和Mac两者,因此在我们进行开发时如果能稍加注意,理论上可以比较容易地完成iOS和Mac的跨平台。 由于SKNode是UIResponder的子类,因此在真正制作游戏的时候,对于相应用户点击等操作我们是不必(也不应该)像demo中一样全部放在Scene点击事件中,而是应该尽量封装游戏中用到的node,并在node中处理用户的点击,并且委托到Scene中进行处理,可能逻辑上会更加清晰。关于用户交互事件的处理,另外一个需要注意的地方在于,使用UIResponder监测的用户交互事件和SKScene的事件循环是相互独立的。如果像我们的demo中那样直接处理用户点击并和SpriteKit交互的话,我们并不能确定这个执行时机在SKScene循环中的状态。比如点击的相关代码也许会在`-update`后执行,也可能在`-didSimulatePhysics`后被调用,这引入了执行顺序的不确定性。对于上面的这个简单的demo来说这没有什么太大关系,但是在对于时间敏感的游戏逻辑或者带有物理模拟的游戏中,也许时序会很关键。由于点击事件的时序和精灵动画和物理等的时序不确定,有可能造成奇怪的问题。对此现在暂时的解决方法是仅在点击事件中设置一个标志位记录点击状态,然后在接下来的`-update:`中进行检测并处理(苹果给出的官方SpriteKit的“Adventure”是这样处理的),以此来保证时序的正确性。代价是点击事件会延迟一帧才会被处理,虽然在绝大多数情况下并不是什么问题,但是其实这点上并不优雅,至少在现在的beta版中,算不上优雅。 URL: https://onevcat.com/2013/06/uikit-dynamics-started/index.html.md Published At: 2013-06-15 00:50:00 +0900 # WWDC 2013 Session笔记 - UIKit Dynamics入门 这是我的WWDC2013系列笔记中的一篇,完整的笔记列表请参看[这篇总览](http://onevcat.com/2013/06/developer-should-know-about-ios7/)。本文仅作为个人记录使用,也欢迎在[许可协议](http://creativecommons.org/licenses/by-nc/3.0/deed.zh)范围内转载或使用,但是还烦请保留原文链接,谢谢您的理解合作。如果您觉得本站对您能有帮助,您可以使用[RSS](http://onevcat.com/atom.xml)或[邮件](http://eepurl.com/wNSkj)方式订阅本站,这样您将能在第一时间获取本站信息。 本文涉及到的WWDC2013 Session有 * Session 206 Getting Started with UIKit Dynamics * Session 221 Advanced Techniques with UIKit Dynamics ### 什么是UIKit动力学(UIKit Dynamics) 其实就是UIKit的一套动画和交互体系。我们现在进行UI动画基本都是使用CoreAnimation或者UIView animations。而UIKit动力学最大的特点是将现实世界动力驱动的动画引入了UIKit,比如重力,铰链连接,碰撞,悬挂等效果。一言蔽之,即是,将2D物理引擎引入了人UIKit。需要注意,UIKit动力学的引入,并不是以替代CA或者UIView动画为目的的,在绝大多数情况下CA或者UIView动画仍然是最优方案,只有在需要引入逼真的交互设计的时候,才需要使用UIKit动力学它是作为现有交互设计和实现的一种补充而存在的。 目的当然是更加自然和炫目的UI动画效果,比如模拟现实的拖拽和弹性效果,放在以前如果单用iOS SDK的动画实现起来还是相当困难的,而在UIKit Dynamics的帮助下,复杂的动画效果可能也只需要很短的代码(基本100行以内...其实现在用UIView animation想实现一个不太复杂的动画所要的代码行数都不止这个数了吧)。总之,便利多多,配合UI交互设计,以前很多不敢想和不敢写(至少不敢自己写)的效果实现起来会非常方便,也相信在iOS7的时代各色使用UIKit动力学的应用的在动画效果肯定会上升一个档次。 ### 那么,应该怎么做呢 #### UIKit动力学实现的结构 为了实现动力UI,需要注册一套UI行为的体系,之后UI便会按照预先的设定进行运动了。我们应该了解的新的基本概念有如下四个: * UIDynamicItem:用来描述一个力学物体的状态,其实就是实现了UIDynamicItem委托的对象,或者抽象为有面积有旋转的质点; * UIDynamicBehavior:动力行为的描述,用来指定UIDynamicItem应该如何运动,即定义适用的物理规则。一般我们使用这个类的子类对象来对一组UIDynamicItem应该遵守的行为规则进行描述; * UIDynamicAnimator;动画的播放者,动力行为(UIDynamicBehavior)的容器,添加到容器内的行为将发挥作用; * ReferenceView:等同于力学参考系,如果你的初中物理不是语文老师教的话,我想你知道这是啥..只有当想要添加力学的UIView是ReferenceView的子view时,动力UI才发生作用。 光说不练假把式,来做点简单的demo吧。比如为一个view添加重力行为: ```objc - (void)viewDidLoad { [super viewDidLoad]; UIView *aView = [[UIView alloc] initWithFrame:CGRectMake(100, 50, 100, 100)]; aView.backgroundColor = [UIColor lightGrayColor]; [self.view addSubview:aView]; UIDynamicAnimator* animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view]; UIGravityBehavior* gravityBeahvior = [[UIGravityBehavior alloc] initWithItems:@[aView]]; [animator addBehavior:gravityBeahvior]; self.animator = animator; } ``` 代码很简单, 1. 以现在ViewController的view为参照系(ReferenceView),来初始化一个UIDynamicAnimator。 2. 然后分配并初始化一个动力行为,这里是UIGravityBehavior,将需要进行物理模拟的UIDynamicItem传入。`UIGravityBehavior`的`initWithItems:`接受的参数为包含id的数组,另外`UIGravityBehavior`实例还有一个`addItem:`方法接受单个的id。就是说,实现了UIDynamicItem委托的对象,都可以看作是被力学特性影响的,进而参与到计算中。UIDynamicItem委托需要我们实现`bounds`,`center`和`transform`三个属性,在UIKit Dynamics计算新的位置时,需要向Behavior内的item询问这些参数,以进行正确计算。iOS7中,UIView和UICollectionViewLayoutAttributes已经默认实现了这个接口,所以这里我们直接把需要模拟重力的UIView添加到UIGravityBehavior里就行了。 3. 把配置好的UIGravityBehavior添加到animator中。 4. strong持有一下animator,避免当前scope结束被ARC释放掉(后果当然就是UIView在哪儿傻站着不掉) 运行结果,view开始受重力影响了: ![重力作用下的UIview](/assets/images/2013/uikit-dynamics-gravity.gif) #### 碰撞,我要碰撞 没有碰撞的话,物理引擎就没有任何意义了。和重力行为类似,碰撞也有一个`UIDynamicBehavior`子类来描述碰撞行为,即`UICollisionBehavior`。在上面的demo中加上几句: ```objc - (void)viewDidLoad { [super viewDidLoad]; UIView *aView = [[UIView alloc] initWithFrame:CGRectMake(100, 50, 100, 100)]; aView.backgroundColor = [UIColor lightGrayColor]; [self.view addSubview:aView]; UIDynamicAnimator* animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view]; UIGravityBehavior* gravityBeahvior = [[UIGravityBehavior alloc] initWithItems:@[aView]]; [animator addBehavior:gravityBeahvior]; UICollisionBehavior* collisionBehavior = [[UICollisionBehavior alloc] initWithItems:@[aView]]; collisionBehavior.translatesReferenceBoundsIntoBoundary = YES; [animator addBehavior:collisionBehavior]; collisionBehavior.collisionDelegate = self; self.animator = animator; } ``` 也许聪明的你已经看到了,还是一样的,创建新的行为规则(UICollisionBehavior),然后加到animator中…唯一区别的地方是碰撞需要设定碰撞边界范围translatesReferenceBoundsIntoBoundary将整个参照view(也就是self.view)的边框作为碰撞边界(另外你还可以使用setTranslatesReferenceBoundsIntoBoundaryWithInsets:这样的方法来设定某一个区域作为碰撞边界,更复杂的边界可以使用addBoundaryWithIdentifier:forPath:来添加UIBezierPath,或者addBoundaryWithIdentifier:fromPoint:toPoint:来添加一条线段为边界,详细地还请查阅文档);另外碰撞是有回调的,可以在self中实现`UICollisionBehaviorDelegate`。 最后,只是直直地掉下来的话未免太无聊了,加个角度吧: ```objc aView.transform = CGAffineTransformRotate(aView.transform, 45); ``` 结果是这样的,帅死了…这在以前只用iOS SDK的话,够写上很长时间了吧.. ![碰撞和重力同时作用的动力UI](/assets/images/2013/uikit-dynamics-collider.gif) 碰撞的delegate可以帮助我们了解碰撞的具体情况,包括哪个item和哪个item开始发生碰撞,碰撞接触点是什么,是否和边界碰撞,和哪个边界碰撞了等信息。这些回调方法保持了Apple一向的命名原则,所以通俗易懂。需要多说一句的是回调方法中对于ReferenceView的Bounds做边界的情况,BoundaryIdentifier将会是nil,自行添加的其他边界的话,ID自然是添加时指定的ID了。 * – collisionBehavior:beganContactForItem:withBoundaryIdentifier:atPoint: * – collisionBehavior:beganContactForItem:withItem:atPoint: * – collisionBehavior:endedContactForItem:withBoundaryIdentifier: * – collisionBehavior:endedContactForItem:withItem: #### 其他能实现的效果 除了重力和碰撞,iOS SDK还预先帮我们实现了一些其他的有用的物理行为,它们包括 * UIAttachmentBehavior 描述一个view和一个锚相连接的情况,也可以描述view和view之间的连接。attachment描述的是两个点之间的连接情况,可以通过设置来模拟无形变或者弹性形变的情况(再次希望你还记得这些概念,简单说就是木棒连接和弹簧连接两个物体)。当然,在多个物体间设定多个;UIAttachmentBehavior,就可以模拟多物体连接了..有了这些,似乎可以做个老鹰捉小鸡的游戏了- -… * UISnapBehavior 将UIView通过动画吸附到某个点上。初始化的时候设定一下UISnapBehavior的initWithItem:snapToPoint:就行,因为API非常简单,视觉效果也很棒,估计它是今后非游戏app里会被最常用的效果之一了; * UIPushBehavior 可以为一个UIView施加一个力的作用,这个力可以是持续的,也可以只是一个冲量。当然我们可以指定力的大小,方向和作用点等等信息。 * UIDynamicItemBehavior 其实是一个辅助的行为,用来在item层级设定一些参数,比如item的摩擦,阻力,角阻力,弹性密度和可允许的旋转等等 UIDynamicItemBehavior有一组系统定义的默认值, * allowsRotation YES * density 1.0 * elasticity 0.0 * friction 0.0 * resistance 0.0 所有的UIDynamicBehavior都是可以独立作用的,同时作用时也遵守力的合成。也就是说,组合使用行为可以达到一些较复杂的效果。举个例子,希望模拟一个drag物体然后drop后下落的过程,可以用如下代码: ```objc - (void)viewDidLoad { [super viewDidLoad]; UIDynamicAnimator* animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view]; UICollisionBehavior* collisionBehavior = [[UICollisionBehavior alloc] initWithItems:@[self.square1]]; collisionBehavior.translatesReferenceBoundsIntoBoundary = YES; [animator addBehavior:collisionBehavior]; UIGravityBehavior *g = [[UIGravityBehavior alloc] initWithItems:@[self.square1]]; [animator addBehavior:g]; self.animator = animator; } -(IBAction)handleAttachmentGesture:(UIPanGestureRecognizer*)gesture { if (gesture.state == UIGestureRecognizerStateBegan){ CGPoint squareCenterPoint = CGPointMake(self.square1.center.x, self.square1.center.y - 100.0); CGPoint attachmentPoint = CGPointMake(-25.0, -25.0); UIAttachmentBehavior* attachmentBehavior = [[UIAttachmentBehavior alloc] initWithItem:self.square1 point:attachmentPoint attachedToAnchor:squareCenterPoint]; self.attachmentBehavior = attachmentBehavior; [self.animator addBehavior:attachmentBehavior]; } else if ( gesture.state == UIGestureRecognizerStateChanged) { [self.attachmentBehavior setAnchorPoint:[gesture locationInView:self.view]]; } else if (gesture.state == UIGestureRecognizerStateEnded) { [self.animator removeBehavior:self.attachmentBehavior]; } } ``` viewDidiLoad时先在现在环境中加入了重力,然后监测到pan时附加一个UIAttachmentBehavior,并在pan位置更新更新其锚点,此时UIAttachmentBehavior和UIGravityBehavior将同时作用(想象成一根木棒连着手指处和view)。在手势结束时将这个UIAttachmentBehavior移除,view将在重力作用下下落。整个过程如下图: ![Drag & Drop](/assets/images/2013/uikit-dynamics-dragdrop.gif) ### UIKit力学的物理学分析 既然是力学,那显然各种单位是很重要的。在现实世界中,理想情况下物体的运动符合牛顿第二运动定理,在国际单位制中,力的单位是牛顿(N),距离单位是米(m),时间单位是秒(s),质量单位是千克(kg)。根据地球妈妈的心情,我们生活在这样一套体制中:重力加速度约为9.8m/s^2 ,加速度的单位是m/s^2 ,速度单位是m/s,牛顿其实是kg·m/s^2 ,即1牛顿是让质量为1千克的物体产生1米每二次方秒的加速度所需要的力。 以上是帮助您回忆初中知识,而现在这一套体系在UIKit里又怎么样呢?这其实是每一个物理引擎都要讨论和明白的事情,UIKit的单位体制里由于m这个东西太过夸张,因此用等量化的点(point,之后简写为p)来代替。具体是这样的:UI重力加速度定义为1000p/s^2 ,这样的定义有两方面的考虑,一时为了简化,好记,确实1000比9.8来的只观好看,二是也算符合人们的直感:一个UIView从y=0开始自由落体落到屏幕底部所需的时间,在3.5寸屏幕上为0.98秒,4寸屏幕上为1.07秒,1秒左右的自由落体的视觉效果对人眼来说是很舒服能进行判断的。 那么UIView的质量又如何定义呢,这也是很重要的,并涉及到力作用和加速度下UIView的表现。苹果又给出了自己的“UIKit牛顿第二定律”,定义了1单位作用力相当于将一个100px100p的默认密度的UIView以100p/s^2 的加速度移动。这里需要注意默认密度这个假设,因为在UIDynamicItem的委托中并没有实现任何密度相关的定义,而是通过UIDynamicItemBehavior来附加指定的。默认情况下,密度值为1,这就相当于质量是10000单位的UIView在1单位的作用力下可以达到1/10的UI重力加速度。 这样类比之后的结论是,如果将1单位的UI力学中的力等同于1牛顿的话: * 1000单位的UI质量,与现实世界中1kg的质量相当,即一个点等同一克; * 屏幕的100像素的长度,约和现实世界中0.99米相当(完全可以看为1米) * UI力学中的默认密度,约和现实世界的0.1kg/m^2 相当 可以说UIKit为我们构建了一套适应iOS屏幕的相当优雅的力学系统,不仅让人过目不忘,在实际的物理情景和用户体验中也近乎完美。在开发中,我们可以参照这些关系寻找现实中的例子,然后将其带入UIKit的力学系统中,以得到良好的模拟效果。 ### UIKit动力学自定义 除了SDK预先定义好的行为以外,我们还可以自己定义想要的行为。这种定义可以发生在两个层级上,一种是将官方的行为打包,以简化实现。另一种是完全定义新的计算规则。 对于第一种,其实考虑一下上面的重力+边界碰撞,或者drag & drop行为,其实都是两个甚至多个行为的叠加。要是每次都这样设定一次的话,不是很辛苦么,还容易遗忘出错。于是一种好的方式是将它们打包封装一下。具体地,如下步骤: 1. 继承一下UIDynamicBehavior(在这里UIDynamicBehavior类似一个抽象类,并没有具体实现什么行为) 2. 在子类中实现一个类似其他内置行为初始化方法`initWithItems:`,用以添加物体和想要打包的规则。当然你如果喜欢用其他方式也行..只不过和自带的行为保持API统一对大家都有好处..添加item的话就用默认规则的initWithItems:就行,对于规则UIDynamicBehavior提供了一个addChildBehavior:的方法,来将其他规则加入到当前规则里 3. 没有第三步了,使用就行了。 一个例子,打包了碰撞和重力两种行为,定义之后使用时就只需要写一次了。当然这只是最简单的例子和运用,当行为复杂以后,这样的使用方法是不可避免的,否则管理起来会让人有想死的心。另外,将手势等交互的方式也集成之中,进一步封装调用细节会是不错的实践。 ```objc //GravityWithCollisionBehavior.h @interface GravityWithCollisionBehavior : UIDynamicBehavior -(instancetype) initWithItems:(NSArray *)items; @end //GravityWithCollisionBehavior.m @implementation GravityWithCollisionBehavior -(instancetype) initWithItems:(NSArray *)items { if (self = [super init]) { UIGravityBehavior *gb = [[UIGravityBehavior alloc] initWithItems:items]; UICollisionBehavior *cb = [[UICollisionBehavior alloc] initWithItems:items]; cb.translatesReferenceBoundsIntoBoundary = YES; [self addChildBehavior:gb]; [self addChildBehavior:cb]; } return self; } @end ``` 另一种比较高级一点,需要对计算完全定义。在默认的行为或者它们组合不能满足禽兽般的产品经理/设计师的需求是,亲爱的骚年..开始自己写吧..其实说简单也简单,UIDynamicBehavior里提供了一个`@property(nonatomic, copy) void (^action)(void)`,animator将在每次animation step(就是需要计算动画时)调用这个block。就是说,你可以通过设定这个block来实现自己的行为。基本思路就是在这个block中向所有item询问它们当前的center和transform状态,然后开始计算,然后把计算后的相应值再赋予item,从而改变在屏幕上的位置,大小,角度等。 ### UIKit动力学的性能分析和限制 使用物理引擎不是没有代价的的,特别是在碰撞检测这块,是要耗费一定CPU资源的。但是以测试的情况来看,如果只是UI层面上的碰撞检测还是没有什么问题的,我自己实测iPhone4上同时进行数十个碰撞计算完全没有掉帧的情况。因此如果只是把其用在UI特效上,应该不用太在意资源的耗费。但是如果同时有成百上千的碰撞需要处理的情况,可能会出现卡顿吧。 对于UIDynamicItem来说,当它们被添加到动画系统后,我们只能通过动画系统来改变位置,而外部的对于UIDynamicItem的center,transform等设定是被忽略的(其实这也是大多数2D引擎的实现策略,算不上限制)。 主要的限制是在当计算迭代无法得到有效解的时候,动画将无法正确呈现。这对于绝大多数物理引擎都是一样的。迭代不能收敛时整个物理系统处于不确定的状态,比如初始时就设定了碰撞物体位于边界内部,或者在狭小空间内放入了过多的非弹性碰撞物体等。另外,这个引擎仅仅只是用来呈现UI效果,它并没有保证物理上的精确度,因此如果要用它来做UI以外的事情,有可能是无法得到很好的结果的。 ### 总结 总之就是一套全新的UI交互的视觉体验和效果,但是并非处处适用。在合适的地方使用可以增加体验,但是也会有其他方式更适合的情况。所以拉上你的设计师好基友去开拓新的大陆吧… URL: https://onevcat.com/2013/06/new-in-xcode5-and-objc/index.html.md Published At: 2013-06-13 00:48:32 +0900 # WWDC 2013 Session笔记 - Xcode5和ObjC新特性 这是我的WWDC2013系列笔记中的一篇,完整的笔记列表请参看[这篇总览](http://onevcat.com/2013/06/developer-should-know-about-ios7/)。本文仅作为个人记录使用,也欢迎在[许可协议](http://creativecommons.org/licenses/by-nc/3.0/deed.zh)范围内转载或使用,但是还烦请保留原文链接,谢谢您的理解合作。如果您觉得本站对您能有帮助,您可以使用[RSS](http://onevcat.com/atom.xml)或[邮件](http://eepurl.com/wNSkj)方式订阅本站,这样您将能在第一时间获取本站信息。 本文涉及到的WWDC2013 Session有 * Session 400 What's New in Xcode 5 * Session 401 Xcode Core Concepts * Session 407 Debugging with Xcode * Session 404 Advances in Objective-C 等Tools模块下的内容 随着iOS7 SDK的beta放出,以及Xcode 5 DP版本的到来,很多为iOS7开发应用的方式已经逐渐浮现。可以豪不夸张地讲,由于iOS7的UI发生了重大变革,此次的升级不同于以往,我们将会迎来iOS开发诞生以来最剧烈的变动,如何拥抱变化,快速适应新的世界和平台,值得每个Cocoa和CocoaTouch开发者研究。工欲善其事,必先利其器。想做iOS7的开发,就必须切换到Xcode5和新的ObjC体系(包括新引入的语法和编译器),在这里我简要地对新添加或重大变化的功能做一个小结。 ## 说说新的Xcode Xcode4刚出的时候存在茫茫多似乎无穷无尽的bug(如果是一路走来的同仁可能对此还记忆犹新),好消息是这次Xcode5 DP版本似乎相当稳定,如果你遇到了开启新Xcode就报错强退的话,多半原因是因为你在使用为Xcode4制作的插件,不同版本的Xcode是共用同一个文件夹下的插件的,请将`~/Library/Application Support/Developer/Shared/Xcode/Plug-ins`目录下的内容清理一下,应该就能顺利进入Xcode5了。 Xcode 5现在使用了ARC,取代了原来的垃圾回收(Garbage collection)机制,因此不论从启动速度和使用速度上来说都比之前快了不少。现在大部分的AppStore提交应用也都使用了ARC,新SDK中加入的系统框架也全都是ARC的了。另外,在Xcode5中新建工程也不再提供是否使用ARC的选项(虽然也还是可以在Build Setting中关掉)。如果你还在使用手动内存管理的话,现在是时候抛弃release什么的了,如果你还在迷茫应该应该怎么使用ARC,可以参看一下去年这个时候我发的一篇[ARC的教程文章](http://onevcat.com/2012/06/arc-hand-by-hand/)。 ### 界面变化 ![Xcode5减小了顶栏宽度](/assets/images/2013/xcode5-header.png) 首先值得称赞的是顶部工具栏的变化,新版中贯彻了精简的原则,将顶栏砍掉了30%左右的宽度,对于小屏幕来说绝对是福音。另外,在外观上界面也向平面和简洁的方向迈进了一大步,可算是对iOS7的遥相呼应吧。 ### 更易用的版本管理 ![image](/assets/images/2013/xcode5-sourcecontrol.png) 虽然在Xcode 4里就集成了版本管理的内容,但是一直被藏的很深,很多时候开发者不得不打开Organizer才能找到对应操作的地方。与之相比,Xcode5为版本管理留出了专门的一个`Source Control`菜单,从此以后妈妈再也不用担心我找不到git放哪儿了。集成的版本管理可以方便地完成大部分初级功能,包括Check Out,Pull,Commit,Push,Merge等,特别是在建立仓库和检出仓库时十分方便。但是在遇到稍微复杂的git操作时还是感到力不从心(比如rebase或摘樱桃的时候),这点上毕竟Xcode并不是一个版本管理app,而最基本的几个操作在日常工作中也算能快速地应付绝大部分情况(在不将工程文件添加到版本管理的情况下)。 值得称赞的是在编辑代码的时候,可以直接对某一行进行blame了,在该行点击右键选Show Blame for Line,就能看到最后改动的人的信息。另外,Version Editor(View->Version Editor)也除了之前就有的版本对比之外,还新加了Blame和Log两种视图。在对代码历史追溯这块,Xcode5现在已经做的足够好了. 结论是,虽然有所进步,但是Xcode的内置版本管理仍然不堪大任,命令行或者一个专业的git管理工具还是必要的。 ### 方便的工程配置 与版本管理的强化相比较,工程配置方面也进行了很多加强,简化了之前开发者的需要做的一些配置工作。首先是在Build Setting的General里,加入了Team的设置,只要填写对应的Apple ID和应用Bundle ID,Xcode就将自动去寻找对应的Provisioning Profile,并使用合适的Provisioning来进行应用打包。因为有了自动配置和将集成的版本管理放到了菜单栏中,Organizer的地位被大大削弱了。至少我现在在Organizer中没有找到本机的证书管理和Provisioning Profile管理的地方,唯一开Organizer的理由大概就是应用打包发布时了。想想从远古时代的Application Loader一步一步走到现在,Xcode可以说在简化流程,帮助开发者快速发布应用方面做了很大努力。 另一个重要改进是在Build选项中加入了`Capabilities`标签,如下图 ![Xcode5的Capabilities](/assets/images/2013/xcode5-capabilities.png) 想想看以前为app配置iCloud要花的步骤吧:到Apple Developer里找到应用的ID,打开对应的app的iCloud功能,生成对应的Provisioning文件,回到Xcode创建一个Entitlements文件,定义Key-Value Store,Ubiquity Containers和Keychain Groups,然后你才能开始为应用创建UIDocument并且继续开发。哦天啊…作为学习来说做一次还能接受,但是如果每次开发应用都要来一遍这个过程,只能用枯燥乏味四个字来形容了。于是,正如你所看到的,现在你需要做的是,点一下iCloud的开关,然后…开始编程吧~轻松惬意。同样的方法也适用于Apple提供的其他服务,包括打开和配置GameCenter,Passbook,IAP,Maps,Keychain,后台模式和Data Protection,当然还有iOS7新加入的Inter-app Audio。这些小开关做的事情都很简单,但确实十分贴心。 ### 资源管理,Asset Catalog和Image Slicing 资源目录(Asset Catalog)和图像切片(Image Slicing)是Xcode5新加入的功能。资源目录可以方便开发者管理工程中使用的图片素材,利用开发中的命名规则(比如高清图的@2x,图标的Icon,Splash的Default等),来筛选和分类图片。建立一个资源目录十分简单,如果是老版本导入的工程,在工程设置中图标或者splash图的设置中点击`Use Asset Catalog`,Xcode将建立新的资源目录;如果是直接使用Xcode 5建立的工程的话,那么资源目录应该已经默认躺在工程中了。 ![添加一个Asset Catalog](/assets/images/2013/xcode5-asset-catalog.png) 添加资源目录后,在工程中会新加一个.xcassets后缀的目录用以整理和存放图片,该文件夹中存放了图片和对应的json文件来保存图片信息。为了能够使用资源目录的特性,以及更好的前向兼容性,建议将所有的图片资源都加入资源目录中:在工程中选择.xcassets文件,然后在资源目录中点击加号即可添加图片。另外,直接从工程外的Finder中将图片拖动到Xcode的资源目录界面中,也将把拖进来的图片拷贝并添加到资源目录中。对的,不再会有讨厌的弹窗出来,问你要拷贝还是要引用了。 ![在Asset Catalog中添加图片](/assets/images/2013/xcode5-add-ac.png) Asset Catalog的意义在于为工程中的图片提供了一个存储信息的地方,不仅可以描述资源对应的设备,资源的版本和更新信息等,更重要的在于可以为Image Slicing服务。所谓Image Slicing,相当于一个可视化的`resizableImageWithCapInsets:resizingMode:`,可以用于指定在图片缩放时用来填充的像素。在资源目录中选择要slicing的图片,点击图片界面右下方的Show Slicing按钮,在想要设定切片的图片上点击`Start Slicing`,将出现左中右(或者上中下)三条可以拖动的指示线,通过拖动它们来设定实际的缩放范围。 ![设定Image Slicing](/assets/images/2013/xcode5-slicing.png) 在左侧线(或者上方线)和中间线之间的像素将在缩放时被填充,在中间线和右侧线(或者下方线)之间的像素将被隐藏。比如上面的例子,实际运行中如果对这张图片进行拉伸的话,会是下面的样子: ![拉升Image Slicing后的图片](/assets/images/2013/xcode5-slicing-image.png) Image Slicing可以帮助开发者用可视化的方式完成resizable image,之后通过拖拖线就可以完成sliced image,而不必再写代码,也不用再一次次尝试输入的insets合不合适了。slicing可缩放的图片大量用于UI中可以节省打包的占用空间,而在Xcode 5中引入和加强图片资源管理的目的,很大一部分是为了配合SpriteKit将游戏引擎加入到SDK中,并将Xcode逐渐打造为一个全面的IDE工具。 ### 新的调试和辅助功能 这应该是Xcode5最值得称赞的改进了,在调试中现在在编辑框内鼠标悬浮在变量名上,Xcode将会根据类型进行猜测,并输出最合适的结果以帮助观察。就像这样: ![鼠标悬浮就可以出现变量结果](/assets/images/2013/xcode5-debug-mouseover.png) 以前版本的Xcode虽然也有鼠标悬浮提示,但是想从中找到想要的value确实还是比较麻烦的事情,很多时候我们不得不参考下面Variables View的值或者直接p或者po它们,现在如果只是需要知道变量情况的话,在断到代码后一路用鼠标跟着代码走一遍,就差不多了然于胸了。如果你认为鼠标悬停只能打打字符串或者数字的话你就错了,数组,字典什么的也不在话下,更过分的是设计图像的也能很好地显示,只需要点击预览按钮,就像这样: ![直接悬停显示图片](/assets/images/2013/xcode5-debug-image.png) Xcode5集成了一个Debug面板,用来实现一个简单的Profiler,可以在调试时直接看到应用的CPU消耗,内存使用等情况(其他的还有iCloud情况,功耗和图形性能等)。在Debug运行时Cmd+6即可切换到该Debug界面。监测的内容简单明了,CPU使用用来检查是否有高占用或者尖峰(特别是主线程中),内存检测用来检查内存使用和释放的情况是否符合预期。 ![Debug的Profiler面板](/assets/images/2013/xcode5-debug-profiler.png) 如果养成开发过程的调试中就一直打开这个Profiler面板的话(至少我从之后会坚持这个做法了),相信是有助于在开发过程中就迅速的监测到潜在的问题,并迅速解决的。当然,对于明显的问题可以在Debug面板中发现后立即寻找对应代码解决,但是如果比较复杂的问题,想要知道详细情况的话,还是要使用Instruments,在Debug面板中提供了一个“Profile In Instruments”按钮,可以快速跳转到Instruments。 最后,Xcode在注释式文档方面也有进步,现在如下格式的注释将在Xcode中直接被检测到并集成进代码提示中了: ```objc /** * Setup a recorder for a specified file path. After setting it, you can use the other control method to control the shared recorder. * * @param talkingPath An NSString indicates in which path the recording should be created * @returns YES if recorder setup correctly, NO if there is an error */ - (BOOL)recordWithFilePath:(NSString *)talkingPath; ``` 得到的结果是这样的 ![Xcode对代码注释的解析](/assets/images/2013/xcode5-comment-doc.png) 以及Quick Help中会有详细信息 ![在Quick Help中显示详细文档](/assets/images/2013/xcode5-quickhelp.png) Xcode现在可以识别Javadoc格式(类似于上面例子)的注释文档,可用的标识符除了上面的`@param`和`@return`外,还有例如`@see`,`@discussion`等,关于Javadoc的更多格式规则,可以参考[Wiki](http://en.wikipedia.org/wiki/Javadoc)。 ## 关于Objective-C,Modules和Autolinking OC自从Apple接手后,一直在不断改进。随着移动开发带来的OC开发者井喷式增加,客观上也要求Apple需要提供各种良好特性来支持这样一个庞大的开发者社区。iOS4时代的GCD,iOS5时代的ARC,iOS6时代的各种简化,每年我们都能看到OC在成为一种先进语言上的努力。基于SmallTalk和runtime,本身是C的超集,如此“根正苗红”的一门语言,在今年也迎来的新的变化。 今年OC的最大变化就是加入了Modules和Autolinking。 ### 什么是Modules呢 在了解Modules之前我们需要先了解一下OC的import机制。`#import `,我相信每个开发者都写过这样的代码,用来引用其他的头文件。熟悉C或者C++的童鞋可能会知道,在C和C++里是没有#import的,只有#include(虽然GCC现在为C和C++做了特殊处理使得import可以被编译),用来包含头文件。#include做的事情其实就是简单的复制粘贴,将目标.h文件中的内容一字不落地拷贝到当前文件中,并替换掉这句include,而#import实质上做的事情和#include是一样的,只不过OC为了避免重复引用可能带来的编译错误(这种情况在引用关系复杂的时候很可能发生,比如B和C都引用了A,D又同时引用了B和C,这样A中定义的东西就在D中被定义了两次,重复了),而加入了#import,从而保证每个头文件只会被引用一次。 > 如果想深究,import的实现是通过#ifndef一个标志进行判断,然后在引入后#define这个标志,来避免重复引用的 实质上import也还是拷贝粘贴,这样就带来一个问题:当引用关系很复杂,或者一个头文件被非常多的实现文件引用时,编译时引用所占的代码量就会大幅上升(因为被引用的头文件在各个地方都被copy了一遍)。为了解决这个问题,C系语言引入了预编译头文件(PreCompiled Header),将公用的头文件放入预编译头文件中预先进行编译,然后在真正编译工程时再将预先编译好的产物加入到所有待编译的Source中去,来加快编译速度。比如iOS开发中Supporting Files组内的.pch文件就是一个预编译头文件,默认情况下,它引用了UIKit和Foundation两个头文件--这是在iOS开发中基本每个实现文件都会用到的东西。 于是理论上说,想要提高编译速度,可以把所有头文件引用都放到pch中。但是这样面临的问题是在工程中随处可用本来不应该能访问的东西,而编译器也无法准确给出错误或者警告,无形中增加了出错的可能性。 于是Modules诞生了。Modules相当于将框架进行了封装,然后加入在实际编译之时加入了一个用来存放已编译添加过的Modules列表。如果在编译的文件中引用到某个Modules的话,将首先在这个列表内查找,找到的话说明已经被加载过则直接使用已有的,如果没有找到,则把引用的头文件编译后加入到这个表中。这样被引用到的Modules只会被编译一次,但是在开发时又不会被意外使用到,从而同时解决了编译时间和引用泛滥两方面的问题。 稍微追根问底,Modules是什么?其实无非是对框架进行了如下封装,拿UIKit为例: ```objc framework module UIKit { umbrella header "UIKit.h" module * {export *} link framework "UIKit" } ``` 这个Module定义了首要头文件(UIKit.h),需要导出的子modules(所有),以及需要link的框架名称(UIKit)。需要指出的是,现在Module还不支持第三方的框架,所以只有SDK内置的框架能够从这个特性中受益。另外,在C++的源代码中,Modules也是被禁用的。 ### 好了,说了那么多,这玩意儿怎么用呢 关于普通开发者使用的这个新特性的方法,Apple在LLVM5.0(也就是Xcode5带的最新的编译器前端中)引入了一个新的编译符号`@import`,使用@符号将告诉编译器去使用Modules的引用形式,从而获取好处,比如想引用MessageUI,可以写成 ```objc @import MessageUI; ``` 在使用上,这将等价于以前的`#import `,但是将使用Modules的特性。如果只想使用某个特性的.h文件,比如`#import `,对应写作 ```objc @import MessageUI.MFMailComposeViewController; ``` 当然,如果对于以前的工程,想要使用新的Modules特性,如果要把所有头文件都这样一个一个改成`@import`的话,会是很大的一个工作量。Apple自然也考虑到了这一点,于是对于原来的代码,只要使用的是iOS7或者MacOS10.9的SDK,在Build Settings中将Enable Modules(C and Objective-C)打开,然后保持原来的`#import`写法就行了。是的,不需要任何代码上的改变,编译器会在编译的时候自动地把可能的地方换成Modules的写法去编译的。 Autolinking是Modules的附赠小惊喜,因为在module定义的时候指定来link framework,所以在编译module时LLVM会将所涉及到的框架自动帮你写到link里去,不再需要到编译设置里去添加了。 URL: https://onevcat.com/2013/06/developer-should-know-about-ios7/index.html.md Published At: 2013-06-11 00:45:02 +0900 # 开发者所需要知道的iOS7 SDK新特性 春风又绿加州岸,物是人非又一年。WWDC 2013 keynote落下帷幕,新的iOS开发旅程也由此开启。在iOS7界面重大变革的背后,开发者们需要知道的又有哪些呢。同去年一样,我会先简单纵览地介绍iOS7中我个人认为开发者需要着重关注和学习的内容,之后再陆续对自己感兴趣章节进行探索。计划继承类似[WWDC2012的笔记](http://onevcat.com/2012/06/%E5%BC%80%E5%8F%91%E8%80%85%E6%89%80%E9%9C%80%E8%A6%81%E7%9F%A5%E9%81%93%E7%9A%84ios6-sdk%E6%96%B0%E7%89%B9%E6%80%A7/)的形式,希望对国内开发者有所帮助。 相关笔记整理如下: * 总览 [开发者所需要知道的iOS7 SDK新特性](http://onevcat.com/2013/06/developer-should-know-about-ios7/) * 工具 [WWDC2013笔记 Xcode5和ObjC新特性](http://onevcat.com/2013/06/new-in-xcode5-and-objc/) * UIKit动力学 [WWDC2013笔记 UIKit力学模型入门](http://onevcat.com/2013/06/uikit-dynamics-started/) * SpriteKit入门 [WWDC2013笔记 SpriteKit快速入门和新时代iOS游戏开发指南](http://onevcat.com/2013/06/sprite-kit-start/) * 后台应用运行和多任务新特性 [WWDC2013笔记 iOS7中的多任务](http://onevcat.com/2013/08/ios7-background-multitask/) * iOS7中弹簧式列表的制作 [WWDC 2013 Session笔记 - iOS7中弹簧式列表的制作](http://onevcat.com/2013/09/spring-list-like-ios7-message) * iOS7中自定义ViewController切换效果 [WWDC 2013 Session笔记 - iOS7中的ViewController切换](http://onevcat.com/2013/10/vc-transition-in-ios7/) --- ### UI相关 #### 全新UI设计 iOS7最大的变化莫过于UI设计,也许你会说UI设计“这是设计师大大们应该关注的事情,不关开发者的事,我们只需要替换图片就行了”。那你就错了。UI的变化必然带来使用习惯和方式的转变,如何运用iOS7的UI,如何是自己的应用更切合新的系统,都是需要考虑的事情。另外值得注意的是,使用iOS7 SDK(现在只有Xcode5预览版提供)打包的应用在iOS7上运行时将会自动使用iOS7的新界面,所以原有应用可能需要对新界面进行重大调整。具体的iOS7中所使用的UI元素的人际交互界面文档,可以从[这里](https://developer.apple.com/library/prerelease/ios/design/index.html#//apple_ref/doc/uid/TP40013289)找到(应该是需要开发者账号才能看)。 简单总结来说,以现在上手体验看来新的UI变化改进有如下几点: * 状态栏,导航栏和应用实际展示内容不再界限:系统自带的应用都不再区分状态栏和navigation bar,而是用统一的颜色力求简洁。这也算是一种趋势。 * BarItem的按钮全部文字化:这点做的相当坚决,所有的导航和工具条按钮都取消了拟物化,原来的文字(比如“Edit”,“Done”之类)改为了简单的文字,原来的图标(比如新建或者删除)也做了简化。 * 程序打开加入了动画:从主界面到图标所在位置的一个放大,同时显示应用的载入界面。 自己实验了几个现有的AppStore应用在iOS7上的运行情况: * [Pomodoro Do](https://itunes.apple.com/app/id533469911?mt=8): 这是我自己开发的应用,运行正常,但是因为不是iOS7 SDK打包,所以在UI上使用了之前系统的,问题是导航栏Tint颜色丢失,导致很难看,需要尽快更新。 * Facebook:因为使用了图片自定义导航栏,而没有直接使用系统提供的材质,所以没什么问题。 * 面包旅行:直接Crash,无法打开,原因未知。 这次UI大改可以说是一次对敏捷开发的检验,原来的应用(特别是拟物化用得比较重的应用)虽然也能运行,但是很多UI自定义的地方需要更改不说,还容易让用户产生一种“来到了另一个世界”的感觉,同时可以看到也有部分应用无法运行。而对于苹果的封闭系统和只升不降的特性,开发者以及其应用必须要尽快适应这个新系统,这对于迭代快速,还在继续维护的应用来说会是一个机会。相信谁先能适应新的UI,谁就将在iOS7上占到先机。 #### UIKit的力学模型(UIKit Dynamics) 这个专题的相关笔记 > UIKit动力学 [WWDC2013笔记 UIKit力学模型入门](http://onevcat.com/2013/06/uikit-dynamics-started/) http://onevcat.com/2013/06/uikit-dynamics-started/ 新增了`UIDynamicItem`委托,用来为UIView制定力学模型行为,当然其他任何对象都能通过实现这组接口来定义动力学行为,只不过在UIKit中可能应用最多。所谓动力学行为,是指将现实世界的我们常见的力学行为或者特性引入到UI中,比如重力等。通过实现UIDynamicItem,UIKit现在支持如下行为: * UIAttachmentBehavior 连接两个实现了UIDynamicItem的物体(以下简称动力物体),一个物体移动时,另一个跟随移动 * UICollisionBehavior 指定边界,使两个动力物体可以进行碰撞 * UIGravityBehavior 顾名思义,为动力物体增加重力模拟 * UIPushBehavior 为动力物体施加持续的力 * UISnapBehavior 为动力物体指定一个附着点,想象一下类似挂一幅画在图钉上的感觉 如果有开发游戏的童鞋可能会觉得这些很多都是做游戏时候的需求,一种box2d之类的2D物理引擎的既视感跃然而出。没错的亲,动态UI,加上之后要介绍的Sprite Kit,极大的扩展了使用UIKit进行游戏开发的可能性。另外要注意UIDynamicItem不仅适用于UIKit,任何对象都可以实现接口来获得动态物体的一些特性,所以说用来做一些3D的或者其他奇怪有趣的事情也不是没有可能。如果觉得Cocos2D+box2d这样的组合使用起来不方便的话,现在动态UIKit+SpriteKit给出了新的选择。 ### 游戏方面 这个专题的相关笔记 > SpriteKit入门 [WWDC2013笔记 SpriteKit快速入门和新时代iOS游戏开发指南](http://onevcat.com/2013/06/sprite-kit-start/) http://onevcat.com/2013/06/sprite-kit-start/ iOS7 SDK极大加强了直接使用iOS SDK制作和分发游戏的体验,最主要的是引入了专门的游戏制作框架。 #### Sprite Kit Framework 这是个人认为iOS7 SDK最大的亮点,也是最重要的部分,iOS SDK终于有自己的精灵系统了。Sprite Kit Framework使用硬件加速的动画系统来表现2D和2.5D的游戏,它提供了制作游戏所需要的大部分的工具,包括图像渲染,动画系统,声音播放以及图像模拟的物理引擎。可以说这个框架是iOS SDK自带了一个较完备的2D游戏引擎,力图让开发者专注于更高层的实现和内容。和大多数游戏引擎一样,Sprite Kit内的内容都按照场景(Scene)来分开组织,一个场景可以包括贴图对象,视频,形状,粒子效果甚至是CoreImage滤镜等等。相对于现有的2D引擎来说,由于Sprite Kit是在系统层级进行的优化,渲染时间等都由框架决定,因此应该会有比较高的效率。 另外,Xcode还提供了创建粒子系统和贴图Atlas的工具。使用Xcode来管理粒子效果和贴图atlas,可以迅速在Sprite Kit中反应出来。 #### Game Controller Framework 为Made-for-iPhone/iPod/iPad (MFi) game controller设计的硬件的对应的框架,可以让用户用来连接和控制专门的游戏硬件。参考WWDC 2013开场视频中开始的赛车演示。现在想到的是,也许这货不仅可以用于游戏…或者苹果之后会扩展其应用,因为使用普及率很高的iPhone作为物联网的入口,似乎会是很有前途的事情。 #### GameCenter改进 GameCenter一直是苹果的败笔...虽然每年都在改进,但是一直没看到大的起色。今年也不例外,都是些小改动,不提也罢。 ### 多任务强化 这个专题的相关笔记 > 后台应用运行和多任务新特性 [WWDC2013笔记 iOS7中的多任务](http://onevcat.com/2013/08/ios7-background-multitask/) http://onevcat.com/2013/08/ios7-background-multitask/ * 经常需要下载新内容的应用现在可以通过设置`UIBackgroundModes`为`fetch`来实现后台下载内容了,需要在AppDelegate里实现`setMinimumBackgroundFetchInterval:`以及`application:performFetchWithCompletionHandler: `来处理完成的下载,这个为后台运行代码提供了又一种选择。不过考虑到Apple如果继续严格审核的话,可能只有杂志报刊类应用能够取得这个权限吧。另外需要注意开发者仅只能指定一个最小间隔,最后下没下估计就得看系统娘的心情了。 * 同样是后台下载,以前只能推送提醒用户进入应用下载,现在可以接到推送并在后台下载。UIBackgroundModes设为remote-notification,并实现`application:didReceiveRemoteNotification:fetchCompletionHandler:` 为后台下载,开发者必须使用一个新的类`NSURLSession`,其实就是在NSURLConnection上加了个后台处理,使用类似,API十分简单,不再赘述。 ### AirDrop 这个是iOS7的重头新功能,用户可以用它来分享照片,文档,链接,或者其他数据给附近的设备。但是不需要特别的实现,被集成在了标准的UIActivityViewController里,并没有单独的大块API提供。数据的话,可以通过实现UIActivityItemSource接口后进行发送。大概苹果也不愿意看到超出他们控制的文件分享功能吧,毕竟这和iOS设计的初衷不一样。如果你不使用UIActivityViewController的话,可能是无法在应用里实装AirDrop功能了。 另外,结合自定义的应用文件类型,可以容易地实现在后台接收到特定文件后使用自己的应用打开,也算是增加用户留存和回访的一个办法。但是这样做现在看来比较讨厌的是会有将所有文件都注册为可以打开的应用(比如Evernote或者Dropbox之类),导致接收到AirDrop发送的内容的时候会弹出很长一排选项,体验较差,只能说希望Apple以后能改进吧 ### 地图 Apple在继续在地图应用上的探索,MapKit的改进也乏善可陈。我一直相信地图类应用的瓶颈一定在于数据,但是对于数据源的建立并不是一年两年能够完成的。Google在这一块凭借自己的搜索引擎有着得天独厚的优势,苹果还差的很远很远。看看有哪些新东西吧: * MKMapCamera,可以将一个MKMapCamera对象添加到地图上,在指明位置,角度和方向后将呈现3D的样子…大概可以想象成一个数字版的Google街景.. * MKDirections 获取Apple提供的基于方向的路径,然后可以用来将路径绘制在自己的应用中。这可能对一些小的地图服务提供商产生冲击,但是还是那句话,地图是一个数据的世界,在拥有完备数据之前,Apple不是Google的对手。这个状况至少会持续好几年(也有可能是永远)。 * MKGeodesicPolyline 创建一个随地球曲率的线,并附加到地图上,完成一些视觉效果。 * MKMapSnapshotter 使用其拍摄基于地图的照片,也许各类签到类应用会用到 * 改变了overlay物件的渲染方式 ### Inter-App Audio 应用间的音频 AudioUnit框架中加入了在同一台设备不同应用之间发送MIDI指令和传送音频的能力。比如在一个应用中使用AudioUnit录音,然后在另一个应用中打开以处理等。在音源应用中声明一个AURemoteIO实例来标为Inter-App可用,在目标应用中使用新的发现接口来发现并获取音频。 想法很好,也算是在应用内共享迈出了一步,不过我对现在使用AudioUnit这样的低层级框架的应用数量表示不乐观。也许今后会有一些为更高层级设计的共享API提供给开发者使用。毕竟要从AudioUnit开始处理音频对于大多数开发者来说并不是一件很容易的事情。 ### 点对点连接 Peer-to-Peer Connectivity 可以看成是AirDrop不能直接使用的补偿,代价是需要自己实现。MultipeerConnectivity框架可以用来发现和连接附近的设备,并传输数据,而这一切并不需要有网络连接。可以看到Apple逐渐在文件共享方面一步步放开限制,但是当然所有这些都还是被限制在sandbox里的。 ### Store Kit Framework Store Kit在内购方面采用了新的订单系统,这将可以实现对订单的本机验证。这是一次对应内购破解和有可能验证失败导致内购失败的更新,苹果希望藉此减少内购的实现流程,减少出错,同时遏制内购破解泛滥。前者可能没有问题,但是后者的话,因为objc的动态特性,决定了只要有越狱存在,内购破解也是早晚的事情。不过这一点确实方便了没有能力架设验证服务器的小开发者,这方面来说还是很好的。 ### 最后 当然还有一些其他小改动,包括MessageUI里添加了附件按钮,Xcode开始支持模块了等等。完整的iOS7新特性列表可以在[这里](https://developer.apple.com/library/prerelease/ios/releasenotes/General/WhatsNewIniOS/Articles/iOS7.html#//apple_ref/doc/uid/TP40013162-SW1)找到(暂时应该也需要开发者账号)。最后一个好消息是,苹果放慢了废弃API的速度,这个版本并没有特别重要的API被标为Deprecated,Cheers。 URL: https://onevcat.com/2013/05/talk-about-warning/index.html.md Published At: 2013-05-24 00:43:19 +0900 # 谈谈Objective-C的警告 > 一个有节操的程序员会在乎自己的代码的警告,就像在乎饭碗边上有只死蟑螂那样。 > ——@onevcat ### 重视编译警告 现在编译器有时候会很吵,而编译器给出的警告对开发者来说是很有用的信息。警告不会阻止继续编译和链接,也不会导致程序不能运行,但是很多时候编译器会先你一步发现问题所在,对于Objective-C来说特别如此。[Clang](http://clang.llvm.org/)不仅对于明显的错误能够提出警告(比如某方法或者接口未实现),也能对很多潜在可能的问题做出提示(比如方法已经废弃或者有问题的转换),而这些问题在很多时候都可能成为潜在的致命错误,必须加以重视。 像Ruby或者PHP这样的动态语言没有所谓的编译警告,而C#或者Java这类语言的警告很多都是不得不照顾的废弃方法什么的,很多开发者已经习惯于忽略警告进行开发。OC由于现在由苹果负责维护,Clang的LLVM也同时是苹果在做,可以说从语言到编译器到SDK全局都在掌握之中,因此做OC开发时的警告往往比其他语言的警告更有参考价值。打开尽可能多的警告提示,并且在程序开发中尽量避免生成警告,对于构建一个健壮高效的程序来说,是必须的。 ### 在Xcode中开启额外警告提示 Xcode的工程模板已经为我们设置开启了一些默认和常用的警告提示,这些默认设置为了兼容一些上年头的项目,并没有打开很多,仅是指对最危险和最常见的部分进行了警告。这对于一个新项目来说这是不够用的(至少对我来说是不够用的),在无数前辈大牛的教导下,首先要做的事情就是打开尽可能多的警告提示。 最简单的方法是通过UI来打开警告。在Xcode中,Build Setting选项里为我们预留了一些打开警告的开关,找到并直接勾选相应的选项就可以打开警告。大部分时间里选项本身已经足够能描述警告的作用和产生警告的时机,如果不是很明白的话,在右侧的Quick Help面板里有更详细的说明。对于OC开发来说特有的警告都在`Apple LLVM compiler 4.2 - Warnings - Objective C`一栏中,不管您是不是决定打开它们,都是值得花时间看一看加以了解的,因为它们都是写OC程序时最应该避免的情况。另外几个`Apple LLVM compiler 4.2 - Warnings - …`(All languages和C++)也包含了大量的选项,以方便控制警告产生。 ![Xcode设置中的警告选项](/assets/images/2013/xcode-warning.png) 当然在UI里一个一个点击激活警告虽然简单,但每次都这样来一回是一种一点也不有趣的做法,特别是在你已经了解它们的内容并决定打开它们的时候。在编译选项中加入合适的flag能够打开或者关闭警告:在Build Setting中的Other C Flags里添加形似`-W...`的编译标识。你可以在其中填写任意多的`-W...`以开关某些警告,比如,填写为`-Wall -Wno-unused-variable`即可打开“全部”警告(其实并不是全部,只是一大部分严重警告而已),但是不启用“未使用变量”的警告。使用`-W...`的形式,而不是在UI上勾选的一大好处是,在编译器版本更新时,新加入的警告如果包含在`-Wall`中的话,不需要对工程做任何修改,新的警告即可以生效。这样立即可以察觉到同一个工程由于编译器版本更新时可能带来的隐患。另外一个更重要的原因是..Xcode的UI并没有提供所有的警告 =_=||.. 刚才提到的,需要注意的是,`-Wall`的名字虽然是all,但是这真的只是一个迷惑人的词语,实际上`-Wall`涵盖的仅只是所有警告中的一个子集。在[StackExchange](http://programmers.stackexchange.com/questions/122608/clang-warning-flags-for-objective-c-development/124574#124574)上有一个在Google工作的Clang开发者进行的回答,其中解释了有一些重要的警告组: * -Wall 并**不是**所有警告。这一个警告组开启的是编译器开发者对于“你所写的代码中有问题”这一命题有着很高的自信的那些警告。要是在这一组设定下你的代码出现了警告,那基本上就是你的代码真的存在严重问题了。但是同时,并不是说打开Wall就万事大吉了,因为Wall所针对的仅仅只是经典代码库中的为数不多的问题,因此有一些致命的警告并不能被其捕捉到。但是不论如何,因为Wall的警告提供的都是可信度和优先级很高的警告,所以为所有项目(至少是所有新项目)打开这组警告,应该成为一种良好的习惯。 * -Wextra 如其所名,`-Wextra`组提供“额外的”警告。这个组和`-Wall`组几乎一样有用,但是有些情况下对于代码相对过于严苛。一个很常见的例子是,`-Wextra`中包含了`-Wsign-compare`,这个警告标识会开启比较时候对signed和unsigned的类型检查,当比较符两边一边是signed一边是unsigned时,产生警告。其实很多代码并没有特别在意这样的比较,而且绝大多数时候,比较signed和unsigned也是没有太大问题的(当然不排除会有致命错误出现的情况)。需要注意,`-Wextra`和`-Wall`是相互独立的两个警告组,虽然里面打开的警告标识有个别是重复的,但是两组并没有包含的关系。想要同时使用的话必须在Other C Flags中都加上 * -Weverything 这个是真正的所有警告。但是一般开发者不会选择使用这个标识,因为它包含了那些还正在开发中的可能尚存bug的警告提示。这个标识一般是编译器开发者用来调试时使用的,如果你想在自己的项目里开启的话,警告一定会爆棚导致你想开始撞墙.. ![-Wall和-Wextra下0警告的工程,在-Weverything下的表现,可以用惨不忍睹来形容](/assets/images/2013/weverything.png) 关于某个组开启了哪些警告的说明,在GCC的手册中有[一个参考](http://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html)。虽然苹果现在用的都是LLVM了,但是这部分内容应该是继承了GCC的设定。 ### 控制警告,局部加入或关闭 Clang提供了我们自己加入警告或者暂时关闭警告的办法。 强制加入一个警告: ```objc //Generate a warning #pragma message "Warning 1" //Another way to generate a warning #warning "Warning 2" ``` 两种强制警告的方法在视觉效果上结果是一样的,但是警告类型略有不同,一个是`-W#pragma-messages`,另一个是`-W#warnings`。对于第二种写法,把warning换成error,可以强制使编译失败。比如在发布一些需要API Key之类的类库时,可以使用这个方法来提示别的开发者别忘了输入必要的信息。 ```objc //Generate an error to fail the build. #error "Something wrong" ``` 对于关闭某个警告,如果需要全局关闭的话,直接在Other C Flags里写`-Wno-...`就行了,比如`-Wextra -Wno-sign-compare`就是一个常见的组合。如果相对某几个文件开启或禁用警告,在Build Phases的Compile Source相应的文件中加入对应的编译标识即可。如果只是想在某几行关闭某个警告的话,可以通过临时改变诊断编译标记来抑制指定类型的警告,具体如下: ```objc #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wunused-variable" int a; #pragma clang diagnostic pop ``` 如果a之后没有被使用,也不会出未使用变量的警告了。对于想要抑制的警告类型的标识名,可以在build产生该警告后的build log中看到。Xcode中的话,快捷键Cmd+7然后点击最近的build log中,进入详细信息中就能看到了。 ![警告的详细信息,可以找到标识符](/assets/images/2013/warning-detail.png) ### 我应该开启哪些警告提示 个人喜好(代码洁癖)不同,会有不同的需求。我的建议是对于所有项目,特别是新开的项目,首先开启`-Wall`和`-Wextra`,然后在此基础上构建项目并且避免一切警告。如果在开发过程中遇到了某些确实无法解决或者确信自己的做法是正确的话(其实这种情况,你的做法一般即使不是错误的,也会是不那么正确的),可以有选择性地关闭某些警告。一般来说,关闭的警告项目不应该超过一只手能数出来的数字,否则一定哪儿出问题了.. ### 是否要让警告等于错误 一种很常见的做法和代码洁癖是将警告标识为错误,从而中断编译过程。这让开发者不得不去修复这些警告,从而保持代码干净整洁。在Xcode中,可以通过勾选相应的Treat Warnings as Errors来开启,或者加入`-Werror`标识。我个人来说不喜欢使用这个设定,因为它总是打断开发流程。很多时候并不可能把代码全写完再编译调试,相反地,我更喜欢写一点就编译运行一下看看结果,这样在中间debug编译的时候会出现警告也不足为奇。另外,如果做TDD开发时,也可能会有大量正常的警告出现,如果有警告就不让编译的话,开发效率可能会打折扣。一个比较好的做法是只在Release Build时将警告视为错误,因为Xcode中是可以为Debug和Release分别指定标识的,所以这很容易做到。 另外也可以只把某些警告当作错误,`-Werror=...`即可,同样地,也可以在`-Werror`被激活时使用`-Wno-error=...`来使某些警告不成为错误。结合使用这些编译标识可以达到很好的控制。 URL: https://onevcat.com/2013/04/using-blending-in-ios/index.html.md Published At: 2013-04-29 00:41:33 +0900 # iOS中使用blend改变图片颜色 最近对`Core Animation`和`Core Graphics`的内容东西比较感兴趣,自己之前也在这块相对薄弱,趁此机会也想补习一下这块的内容,所以之后几篇可能都会是对CA和CG学习的记录的文章。 在应用里一个很常见的需求是主题变换:同样的图标,同样的素材,但是需要按照用户喜爱变为不同的颜色。在iOS5和6的SDK里部分标准控件引入了`tintColor`,来满足个性化界面的需求,但是Apple在这方面还远远做的不够。一是现在用默认控件根本难以做出界面优秀的应用,二是`tintColor`所覆盖的并不够全面,在很多情况下开发者都无法使用其来完成个性化定义。解决办法是什么?最简单当然是拜托设计师大大出图,想要蓝色主题?那好,开PS盖个蓝色图层出一套蓝色的UI;又想加粉色UI,那好,再出一套粉色的图然后导入Xcode。代码上的话根据颜色需求使用image-blue或者image-pink这样的名字来加载图片。 如果有一丁点重构的意识,就会知道这不是一个很好的解决方案。工程中存在大量的冗余和重复(就算你要狡辩这些图片颜色不同不算重复,你也会在内心里知道这种狡辩是多么无力),这是非常致命的。想象一下如果你有10套主题界面,先不论应用的体积会膨胀到多少,光是想做一点修改就会痛苦万分,比如希望改一下某个按钮的形状,很好,设计师大大请重复地修改10遍,并出10套UI,然后一系列的重命名,文件移动和导入…一场灾难。 当然有其他办法,因为说白了就是tint不同的颜色到图片上而已,如果我们能实现改变UIImage的颜色,那我们就只需要一套UI,然后用代码来改变UI的颜色就可以了,生活有木有一下光明起来呀。嗯,让我们先从一张图片开始吧~下面是一张带有alpha通道的图片,原始颜色是纯的灰色(当然什么颜色都可以,只不过我这个人现在暂时比较喜欢灰色而已)。 ![要处理的原图](/assets/images/2013/blend_origin.png) 我们将用blending给这张图片加上另一个纯色作为tint,并保持原来的alpha通道。用Core Graphics来做的话,大概的想法很直接: 1. 创建一个上下文用以画新的图片 2. 将新的tintColor设置为填充颜色 3. 将原图片画在创建的上下文中,并用新的填充色着色(注意保持alpha通道) 4. 从当前上下文中取得图片并返回 最麻烦的部分可能就是保持alpha通道了。[UIImage的文档](https://developer.apple.com/library/ios/#documentation/UIKit/Reference/UIImage_Class/Reference/Reference.html)中提供了使用blend绘图的方法`drawInRect:blendMode:alpha:`,`rect`和`alpha`都没什么问题,但是`blendMode`是个啥玩意儿啊…继续看文档,关于[`CGBlendMode`的文档](https://developer.apple.com/library/ios/#documentation/GraphicsImaging/Reference/CGContext/Reference/reference.html#//apple_ref/doc/c_ref/CGBlendMode),里面有一大堆看不懂的枚举值,比如这样: ``` kCGBlendModeDestinationOver R = S*(1 - Da) + D Available in iOS 2.0 and later. Declared in CGContext.h. ``` 完全不懂..直接看之后的Discussion部分: >The blend mode constants introduced in OS X v10.5 represent the Porter-Duff blend modes. The symbols in the equations for these blend modes are: R is the premultiplied result S is the source color, and includes alpha D is the destination color, and includes alpha Ra, Sa, and Da are the alpha components of R, S, and D 原来如此,R表示结果,S表示包含alpha的原色,D表示包含alpha的目标色,Ra,Sa和Da分别是三个的alpha。明白了这些以后,就可以开始寻找我们所需要的blend模式了。相信你可以和我一样,很快找到这个模式: ``` kCGBlendModeDestinationIn R = D*Sa Available in iOS 2.0 and later. Declared in CGContext.h. ``` 结果 = 目标色和原色透明度的加成,看起来正式所需要的。啦啦啦,还等什么呢,开始动手实现看看对不对吧~ 为了以后使用方便,当然是祭出Category,先创建一个UIImage的类别: ```objc // UIImage+Tint.h #import @interface UIImage (Tint) - (UIImage *) imageWithTintColor:(UIColor *)tintColor; @end ``` 暂时先这样,当然我们也可以创建一个类方法直接完成从bundle读取图片然后加tintColor,但是很多时候并不如上面一个实例方法方便(比如想要从非bundle的地方获取图片),这个问题之后再说。那么就按照之前设想的步骤来实现吧: ```objc // UIImage+Tint.m #import "UIImage+Tint.h" @implementation UIImage (Tint) - (UIImage *) imageWithTintColor:(UIColor *)tintColor { //We want to keep alpha, set opaque to NO; Use 0.0f for scale to use the scale factor of the device’s main screen. UIGraphicsBeginImageContextWithOptions(self.size, NO, 0.0f); [tintColor setFill]; CGRect bounds = CGRectMake(0, 0, self.size.width, self.size.height); UIRectFill(bounds); //Draw the tinted image in context [self drawInRect:bounds blendMode:kCGBlendModeDestinationIn alpha:1.0f]; UIImage *tintedImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return tintedImage; } @end ``` 简单明了,没有任何难点。测试之:`[[UIImage imageNamed:@"image"] imageWithTintColor:[UIColor orangeColor]];`,得到的结果为: ![使用kCGBlendModeDestinationIn模式的结果](/assets/images/2013/blend_1.png) 嗯...怎么说呢,虽然tintColor的颜色是变了,但是总觉得怪怪的。仔细对比一下就会发现,原来灰色图里星星和周围的灰度渐变到了橙色的图里好像都消失了:星星整个变成了橙色,周围的一圈漂亮的光晕也没有了,这是神马情况啊…这种图能交差的话那算见鬼了,会被设计和产品打死的吧。对于无渐变的纯色图的图来说直接用上面的方法是没问题的,但是现在除了Metro的大色块以外哪里无灰度渐变的设计啊…检查一下使用的blend,`R = D * Sa`,恍然大悟,我们虽然保留了原色的透明度,但是却把它的所有的灰度信息弄丢了。怎么办?继续刨`CGBlendMode`的文档吧,那么多blend模式应该总有我们需要的。功夫不负有心人,`kCGBlendModeOverlay`一副嗷嗷待选的样子: ``` kCGBlendModeOverlay Either multiplies or screens the source image samples with the background image samples, depending on the background color. The result is to overlay the existing image samples while preserving the highlights and shadows of the background. The background color mixes with the source image to reflect the lightness or darkness of the background. Available in iOS 2.0 and later. Declared in CGContext.h. ``` kCGBlendModeOverlay可以保持背景色的明暗,也就是灰度信息,听起来正是我们需要的。加入到声明中,并且添加相应的实现( 顺便重构一下原来的代码 :) ): ```objc // UIImage+Tint.h #import @interface UIImage (Tint) - (UIImage *) imageWithTintColor:(UIColor *)tintColor; - (UIImage *) imageWithGradientTintColor:(UIColor *)tintColor; @end ``` ```objc // UIImage+Tint.m #import "UIImage+Tint.h" @implementation UIImage (Tint) - (UIImage *) imageWithTintColor:(UIColor *)tintColor { return [self imageWithTintColor:tintColor blendMode:kCGBlendModeDestinationIn]; } - (UIImage *) imageWithGradientTintColor:(UIColor *)tintColor { return [self imageWithTintColor:tintColor blendMode:kCGBlendModeOverlay]; } - (UIImage *) imageWithTintColor:(UIColor *)tintColor blendMode:(CGBlendMode)blendMode { //We want to keep alpha, set opaque to NO; Use 0.0f for scale to use the scale factor of the device’s main screen. UIGraphicsBeginImageContextWithOptions(self.size, NO, 0.0f); [tintColor setFill]; CGRect bounds = CGRectMake(0, 0, self.size.width, self.size.height); UIRectFill(bounds); //Draw the tinted image in context [self drawInRect:bounds blendMode:blendMode alpha:1.0f]; UIImage *tintedImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return tintedImage; } @end ``` 完成,测试之…好吧,好尴尬,虽然颜色和周围的光这次对了,但是透明度又没了啊魂淡..一点不奇怪啊,因为`kCGBlendModeOverlay`本来就没承诺给你保留原图的透明度的说。 ![使用kCGBlendModeOverlay模式的结果](/assets/images/2013/blend_2.png) 那么..既然我们用`kCGBlendModeOverlay`能保留灰度信息,用`kCGBlendModeDestinationIn`能保留透明度信息,那就两个blendMode都用不就完事儿了么~尝试之,如果在blend绘图时不是`kCGBlendModeDestinationIn`模式的话,则再用`kCGBlendModeDestinationIn`画一次: ```objc // UIImage+Tint.m #import "UIImage+Tint.h" @implementation UIImage (Tint) - (UIImage *) imageWithTintColor:(UIColor *)tintColor { return [self imageWithTintColor:tintColor blendMode:kCGBlendModeDestinationIn]; } - (UIImage *) imageWithGradientTintColor:(UIColor *)tintColor { return [self imageWithTintColor:tintColor blendMode:kCGBlendModeOverlay]; } - (UIImage *) imageWithTintColor:(UIColor *)tintColor blendMode:(CGBlendMode)blendMode { //We want to keep alpha, set opaque to NO; Use 0.0f for scale to use the scale factor of the device’s main screen. UIGraphicsBeginImageContextWithOptions(self.size, NO, 0.0f); [tintColor setFill]; CGRect bounds = CGRectMake(0, 0, self.size.width, self.size.height); UIRectFill(bounds); //Draw the tinted image in context [self drawInRect:bounds blendMode:blendMode alpha:1.0f]; if (blendMode != kCGBlendModeDestinationIn) { [self drawInRect:bounds blendMode:kCGBlendModeDestinationIn alpha:1.0f]; } UIImage *tintedImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return tintedImage; } @end ``` 结果如下: ![使用kCGBlendModeOverlay和kCGBlendModeDestinationIn模式的结果](/assets/images/2013/blend_3.png) 已经很完美了,这样的话只要在代码里设定一下颜色,我们就能够很轻易地使用同样一套UI,将其blend为需要的颜色,来实现素材的重用了。唯一需要注意的是,因为每次使用`UIImage+Tint`的方法绘图时,都使用了CG的绘制方法,这就意味着每次调用都会是用到CPU的Offscreen drawing,大量使用的话可能导致性能的问题(主要对于iPhone 3GS或之前的设备,可能同时处理大量这样的绘制调用的能力会有不足)。关于CA和CG的性能的问题,打算在之后用一篇文章来介绍一下。对于这里的`UIImage+Tint`的实现,可以写一套缓存的机制,来确保大量重复的元素只在load的时候blend一次,之后将其缓存在内存中以快速读取。当然这是一个权衡的问题,在时间和空间中做出正确的平衡和选择,也正是程序设计的乐趣所在。 这篇文章中作为示例的工程和UIImage+Tint可以在[Github](https://github.com/onevcat/VVImageTint)上找到,您可以随意玩弄..我相信也会是个来研究每种blend的特性的好机会~ URL: https://onevcat.com/2013/04/ios-interview/index.html.md Published At: 2013-04-13 00:39:32 +0900 # 上级向的十个iOS面试问题 不管对于招聘和应聘来说,面试都是很重要的一个环节,特别对于开发者来说,面试中的技术问题环节不仅是企业对应聘者技能和积累的考察,也是一个开发者自我检验的好机会。对于iOS和Mac开发来说,因为本事还算比较新,企业对于这方面的开发者需求也比较大,所以面试时的要求可能并不是很高,一般能知道一些Cocoa和OC的基本知识也就认为可以了。但是对于一个希望拥有技术力基础的企业的iOS或者Mac开发来说,两到三个顶尖的熟练技术人员,带领一些还较为初级的开发者,共同完成项目应该是比较常见的构成。 Cocoa特别是CocoaTouch的开发,上手可以说十分容易,但是背后隐藏的细节和原理却很丰富。一方面对于基础不够熟练和清晰(比如从一个AppDelegate开始用代码构建ViewController,或者清晰地说明栈和堆之类的概念),另一方面对于更进阶的开发知之甚少(比如多线程、网络异步处理或者Core开头的各种框架等等)。这些内容十分重要,但是可能现在一般的iOS开发者或多或少都在这些问题上存在薄弱。在这里我整理了一份面向于较高层级的iOS开发者的面试题目的问题清单,列出了十个应聘Leader级别的高级Cocoa/CocoaTouch开发工程师所应该掌握和理解的技术。这份列表没有提供标准答案,因为这些问题本身就没有标准答案。随每个人对这些内容的认识的不同和理解的差异,可以有不一样的答案。但是最基本地,如果面对的是一名资深的Cocoa开发者,至少期望能得到的答案都是“接触过”,并且能结合自己的经验说个七七八八,达到互相能明白意图和方法的地步。能够在其中两三个领域有不错的见解和具体的阐述的话,那是更好。这种对于知识覆盖面和深度的考察很能真实反映出开发者的技术水平。如果清单里的很大部分内容都是完全没接触过和没听过的话,那可能距离资深Cocoa开发这样一个阶段还尚有距离了。 那么,面试开始。 1. 你使用过Objective-C的运行时编程(Runtime Programming)么?如果使用过,你用它做了什么?你还能记得你所使用的相关的头文件或者某些方法的名称吗? 2. 你实现过多线程的Core Data么?NSPersistentStoreCoordinator,NSManagedObjectContext和NSManagedObject中的哪些需要在线程中创建或者传递?你是用什么样的策略来实现的? 3. Core开头的系列的内容。是否使用过CoreAnimation和CoreGraphics。UI框架和CA,CG框架的联系是什么?分别用CA和CG做过些什么动画或者图像上的内容。(有需要的话还可以涉及Quartz的一些内容) 4. 是否使用过CoreText或者CoreImage等?如果使用过,请谈谈你使用CoreText或者CoreImage的体验。 5. NSNotification和KVO的区别和用法是什么?什么时候应该使用通知,什么时候应该使用KVO,它们的实现上有什么区别吗?如果用protocol和delegate(或者delegate的Array)来实现类似的功能可能吗?如果可能,会有什么潜在的问题?如果不能,为什么?(虽然protocol和delegate这种东西面试已经面烂了...) 6. 你用过NSOperationQueue么?如果用过或者了解的话,你为什么要使用NSOperationQueue,实现了什么?请描述它和GCD的区别和类似的地方(提示:可以从两者的实现机制和适用范围来描述)。 7. 既然提到GCD,那么问一下在使用GCD以及block时要注意些什么?它们两是一回事儿么?block在ARC中和传统的MRC中的行为和用法有没有什么区别,需要注意些什么? 8. 您是否做过异步的网络处理和通讯方面的工作?如果有,能具体介绍一些实现策略么? 9. 对于Objective-C,你认为它最大的优点和最大的不足是什么?对于不足之处,现在有没有可用的方法绕过这些不足来实现需求。如果可以的话,你有没有考虑或者实践过重新实现OC的一些功能,如果有,具体会如何做? 10. 你实现过一个框架或者库以供别人使用么?如果有,请谈一谈构建框架或者库时候的经验;如果没有,请设想和设计框架的public的API,并指出大概需要如何做、需要注意一些什么方面,来使别人容易地使用你的框架。 以上10个问题对于初级或者刚接触iOS的开发者来说,肯定是过于难了。想要答出全部问题,可能需要至少两到三年的Cocoa/CocoaTouch开发经验。而如果想要有所见地的回答,可能需要更长的时间和经验。这些问题对于技术的积累会是一个很好的考察,因为如果没有对这些问题中涉及的内容有过实际使用和体会的话,是很难较完整和全面回答这些问题的。同时,因为这些问题并不像ABCD的客观题有标准答案,表现的是应聘者的理解,所以提问者也必须具备必要的材料或者知识,以应对可能的讨论。 在为团队寻求高级别的开发工程师或者Leader类的职位时,这些问题的回答会是对应聘者技术深度和广度的一个有效的考察。同样地,如果你的团队在Cocoa/CocoaTouch上比较偏重,但是技术团队的No.1的工程师却不能很好地回答这些问题的话,可能也会是需要检讨技术层的一个信号。 URL: https://onevcat.com/2013/04/our-money-app/index.html.md Published At: 2013-04-06 00:37:13 +0900 # 两个人一起记账吧~ Our Money ## 已下架 Our Money是一款能够协助多人在云端记账的iOS应用,可以帮助您简单地记录和整理日常开销,您可以邀请您的朋友和家人与您一起记账,免去每日汇报总结之苦。 * [App Store地址](https://itunes.apple.com/cn/app/our-money/id562520527?ls=1&mt=8) * [Our Money app的首页](http://ourmoney.onevcat.com) 大概但凡从按月领生活费开始花钱以来,都会兴起记账的念头,至于是否能够坚持,就各凭本事了。说到自己,则是多次付诸行动,然后不了了之。从一开始记在小本本上自己用计算器加加减减,到建个Excel文档自动求和,再到手机上的记账应用,时代在进步,咱的手段也在进步,却总还觉得没有找到最合适的工具。尤其是用手机记账以来,有的软件,每次对非得给一笔开销定义出两层的分类,让我头疼不已,家庭小帐非得整成个公司帐簿,改动标签也颇为麻烦;有的软件,记录条目倒是简单,但其他诸如统计等功能却也一起被简化了。不过,最让我郁闷的是,记账总成为我一个人的事情,谁让是用我的手机在记呢。 现在,终于等到了一款操作简单但是功能齐全,尤其是,**可以多人共同记账的应用**。这款叫做Our Money 的应用,最大的亮点当然就在于“Our”。它可以实现多人一起记账,只要人手一个应用,就可以和家人一起记录家庭开销,和朋友一起整理出游费用,不同的帐本可以选择和不同的人分享,每个人都能参与,条目更新实时同步,再不用一个人负责所有的帐目。 好啦,废话不多说,让我们一起来体验一下这个软件吧。[下载应用](https://itunes.apple.com/cn/app/our-money/id562520527?ls=1&mt=8)并打开,用邮箱注册用户,就可以开始记账啦。请记住你的邮箱是你邀请别人或者别人邀请你共同记账的标识哦~ ![OurMoney的注册登陆界面](/assets/images/2013/1-ourmoney-login.png) Our Money的主界面相当简洁,最上方列出列表名称,收入(预算)、支出、结余也一目了然,条目的时间、分类、备注都一目了然。那么其他其他内容被藏在哪里呢?左边一拉,当前列表的按月总计;右边一拉,列表编辑,数据统计,就是这么简单~ ![按月份统计收入和开销](/assets/images/2013/2-ourmoney-month.png) ![按项目和用户的统计](/assets/images/2013/3-ourmoney-stat.png) 首先我们新建一个列表, 在右边的界面下拉一下,就可以新建自己的列表了。选中的列表下方能够修改列表名称或者删除,中间的邀请就是重头戏啦,输入希望一同记账的朋友的邮箱,他就可以收到邀请并加入你的列表。当邀请了朋友或家人加入列表后,列表信息中就会显示多人同为列表用户。当然,在记账时随时可以邀请新的用户加入。 ![邀请别人加入特定列表一起记账](/assets/images/2013/4-ourmoney-invite.png) 选定刚才新建的列表,回到主界面,随便记下一点东西,在同一列表中的用户将通过推送(如果允许的话)收到您更改了列表的消息。而对方打开应用时,马上就可以同步地看到您所记录的信息,这便于双方更迅速地各自完成记账,免去了回家后苦苦思索或者汇总的麻烦,确实十分方便。 ![家人或朋友记账后,立即可以收到系统提醒](/assets/images/2013/5-ourmoney-push.png) 记错了,找不到修改的地方怎么办?点一下,记录被选中,下面就出现了编辑或者删除的选项,还可以分享条目到社交网络,秀一下收到的礼物什么的哦~ 在消费和记账时难免会出现没有网络的尴尬时候,这时候Our Money还能正常工作么?当然,Our Money具有完善的离线模式处理,没有网络时照常使用,当之后连上网络的时候会自动为您完成所有同步,完全不用自己操心。 ![Our Money方便的离线模式](/assets/images/2013/6-ourmoney-offline.png) 总的来说Our Money是一款功能强大但又简单高效的记账软件,其云端记账和共同记账的理念很符合当今多人记账的需求。从今天开始就和家人朋友用Our Money一起记账吧~ 您可以从[App Store中下载Our Money](https://itunes.apple.com/cn/app/our-money/id562520527?ls=1&mt=8),还可以进一步通过应用内的赠送系统将您的记账和心得分享给家人朋友。 URL: https://onevcat.com/2013/04/half-year-in-japan/index.html.md Published At: 2013-04-01 00:35:58 +0900 # 赴日半年的一些杂感 来日本已经足足有半年了,在这半年里见识了许多,也经历了许多。学生生涯的结束和职场生涯的开始,在这样的转变中积极投入到新的生活中去,大概也算是自己努力的一种方式。今天到公司很早,有机会整理一下这半年的一些体会和感想吧。 ## 关于日本 其实日本对于中国和中国人来说,一直是个又爱又恨的国家。爱大抵是因为日本既有着无数的中国文化元素输入,同时又有着一大堆类似ACG的输出。前者拉近了中国与日本的距离,后者让世界有了解日本的窗口。而恨,基本都来源于七十多年前的那场战争。中国人的这种仇恨其实也并非与生俱来,而日本人也确实很难理解这种仇恨,我想这大抵和两个国家国民所受到的教育和舆论的导向不无关系。说到教育和舆论,中国的洗脑教育和言论管制估计在全球知名大国中是无出其右的。包括我在内,从小接受的就是长期而持续的仇恨教育灌输,所有能接触到的历史书籍中也都是宣扬两国民族仇恨的,我想这对于国人于日本的理解上造成了很大偏差。加上当代中国走了一些弯路,导致普遍性的国民信仰丢失和是非观的扭曲,导致了这种本不该存在的误解又进一步加深。 相反地,在日本不管是电视新闻还是报纸,我都极少见到有针对中国的宣传。其实基本上电视新闻都很少会报道日本国外的消息。经常见到的都是本地哪个居民楼发生了火灾,或者谁家走失的猫狗被发现并寻找失主这样的消息。而唯一有的政治节目的形式一般是一大堆人坐成个圆桌讨论的形式,即使这样还是会请来不同方面的人,更像是一种讨论。比如之前说到钓鱼岛的问题,人员构成是两个主持+两个日本政界+两个中国人+一个美国人这样的组合,一群人都站在自己的利益角度吵得不亦乐乎。这在国内现在的请“砖家”出来唠叨教导大众的媒体模式下,应该是不可能出现的。 但是同时,日本国民对于政治的不关心远远超出了我的想象,但是却正是一个这样对政治不关心的国家,却有着整个亚洲最民主的制度,这是一个很奇怪的现象。选举前几乎每天在车站都会有议员拿个喇叭宣扬自己党派和个人的理念思想,但路人匆匆都无人理睬(我想如果有人停下来和他辩论的话他也许会很开心);到现在选举已经尘埃落定后也每周会有不同的政治家到处演说。在中国,就算在北京,你也绝不可能看到国家财政部或者人事部的部长在做街头演说,也没有可能直面总书记或者国家主席,但是这些事情我却都在日本经历了,而且是作为一个外国人在不经意间就都经历了。中日两国在政治上的差距,还很大很大,而中国想要走的民主道路(希望如此),也还很长很长。 其他的来说,印象最深刻的大抵就是和传说中一样的日本人的礼貌和以“耻文化”为基础的道德理念。虽然是在礼仪之邦长大的孩子,但是却是在这里感受到了更多的礼仪。服务行业就不用多说了,就算是普通生活中也会有很多的讲究。有时候真的不得不感慨是环境造就人的行为,在一个所有人都很互相尊重(至少是表面上互相尊重)的环境下,你也不得不学会去尊重别人。同样的,当人们都互相信任的时候,你也不由地变得愿意信任别人,这是一件让人感觉很好的事情。 ![随便一个书店里关于三国的架子,可能在中国都看不到这么多的有关书籍](/assets/images/2013/bookstore.jpg) 另外就是日本真的是一个很喜欢读书的国家,这一点虽然不让我吃惊,但是当走在街上很容易就看到很多书店的时候还是有些赞叹的。在电子书籍和信息时代的今天,实体书可能更多的已经成为一种符号了,至少在快餐文化的中国是如此。实体书在日本的畅销,一方面是因为地铁和文库本的贡献,另一方面大概是因为日本本身文化封闭的特性吧(之后会展开说这点)。 ## 关于工作 ![面白法人Kayac的门牌](/assets/images/2013/kayac.jpg) 工作上并没有什么特别值得称道的地方,本来也是作为漫漫人生中修行的一站来到这里的,所以说更多的还是希望用心体会这边的工作的精髓,而并不是去刻意地达成某些目标。虽然日本是一个游戏制作的传统强国,但是可以感受到在当今欧美大作不断频现和日本游戏固步自封的双重作用下,日本的游戏产业正在逐渐没落。虽然在社交领域有DeNA或者GREE,在手游上有去年风光满满的Puzzle And Dragon,但是给人的印象就是这些大抵都是only for Japan的东西。日本游戏界可以说看不到和外界交流的意愿,现在的日本游戏越来越难走向世界,世界的优秀游戏也越来越难进入日本市场,这大抵还是和当今主流文化是英语文化圈的欧美文化但是日本从业人员整体英语水平并不够有一定关系吧。 除此之外,工作上还是很开心的。Kayac是一个很不错的公司,至少到现在我很享受在这里的工作。虽然加班是多了点,但是和最地狱的那段时间来说简直就是天堂(所以说趁着年轻过一些苦逼的日子是很有好处的,之后都会觉得比起以前不算什么)。不仅可以穿拖鞋汗衫进出公司,更可以每天面朝大海或者富士山写代码,这点比较惬意。 不过工作上需要特别提的,最好的地方是,可以和其他国家(不单单日本的,还有法国越南印尼什么)的程序员一起工作,这对我来说是一种非常奇妙的体验。以前很多时候认为是非常正常的事情,以及非常正常的写法,有时候却在不同文化背景下会发生了一些奇妙的变化。会发现原来每个国家的coder写出来的东西真的是会带有coder个人的文化背景和思考方法的,这是以前完全没有想到的事情。比如日本的程序员写出来的东西总是很工整,每个类的格式甚至是申明变量的顺序都很规范,但是往往却在很多地方写的很啰嗦复杂。在你完全了解他的结构之前,读这样的代码很是痛苦,无尽的跳转和条件经常让人崩溃,有时候甚至不得不佩服在如此复杂的代码下居然没有出错。而法国人的代码却完全不一样,写的结构那个飘逸那个散,还时不时带上几句法语注释,虽说配合Google Translate可以猜个大概,但还是让人哭笑不得。 Kayac的话据说有全日本最好的Perl程序员(或者说之一),但是很可惜我并不会也不想以Perl作为自己的开发语言,所以说基本没有交流,算是比较可惜。这边的话也有一些还算厉害的OC程序员和iOS开发者,有时候可以在网上看到一些他们的技术博客,也算不错。和其中一个在Kayac待了几年的大大玩的比较好,他居然还送了一本他写的OC的入门书给我,虽然说内容太基础对我没什么用处,但是这份情谊还是很珍贵的。 技术力上的话,Kayac或者是大部分日本企业(猜测)并不是具有很强的技术能力。不管是在选用框架和编码能力上大部分员工都还很入门的感觉。不过这大抵是因为重视的方面不同,我们可能更看重个人能力和解决问题的速度质量,但是他们更多的是喜欢在范式和规则之下完成任务。这样一来,制定规则的人,或者说是项目的负责人的业务能力也就直接导致了项目的质量和进度。不过正如@钟亮所说,很多时候跳出技术的层面来看这些就会豁然开朗,无非就是遵循的规则和追求目标的手段的不一致,绝大部分最终的产品不会有太多人在意其中的技术细节。 不过不管怎么样,技术强力还是很有好处的,一开始和同事互相不太认识的时候经常各种“被教导”和“被使唤”,后来逐渐实力被认可以后就转变成了总是“被请教”和“被提建议”。日本社会和日本人心态确实是会从骨子里尊敬强者,所以说想要立足以及赢得他们的尊重,只能迫使自己变得更强。 每天很快就能搞定自己的任务,但是这边整个公司或者国家的氛围就是要加班到很晚,所以自己也不好到点走人。于是就有了以前不敢想象的大把时间用于学习和提高。闲着没事儿会琢磨学一些新的语言,或者是想办法将现有知识更深入,也会有时间经常关心一些业界的最新动态,这些都会很有帮助,也希望它们最终能成为自己人生的积淀。 同时也在向日本人学习。不得不说一下现在在做的项目的Leader,是一个很有趣的人。年轻时候干的是潜水员,负责挖沉船探宝那种,后来体力逐渐跟不上,也考虑到相对危险,转行当了程序员。半路出家但是水平还不赖,更难能可贵的是一把年纪了却每天也还坚持学技术。从git到进阶C#再到模型和贴图入门什么的,我入职半年间,他案头的书都换了三四本。这种精神很让人佩服,也应当成为学习的榜样。 ## 关于生活 关于这一点,嘛,至少可以不用待在北京吸毒气。在北京的时候因为空气的问题,经常咳嗽不舒服,每次沙尘的时候也完全不能出门。那时候雾霾还不叫雾霾,但是劣质空气不需要命名大家也心知肚明。一年中能见到蓝天的日子也屈指可数。别的不说,这边至少天蓝蓝,水蓝蓝,空气清新,多年的咳嗽到这边完全没有复发,这就比一切都强了。 ![东京彩虹桥](/assets/images/2013/tokyo-bridge.jpg) 另外不需要因为户口什么的各种看派出所的脸色。印象里在日本我所得到的微笑和尊重比在国内加起来都多——不管是来自服务行业、政府部门还是平时接触的日本人。有时候仔细想想确实,纳税人辛辛苦苦创造的价值,却很大一部分得不到有效的利用。而去和自己供养的人打交道的时候,还要遭遇种种不便和蔑视。愤愤不平倒是没有,但心却拔凉拔凉。深知自己并不是二代,靠自己改变不了现状,剩下的选择就只有逃离(XD 当然没这么夸张的凄凉,只不过用脚投票也是现在的流行趋势是不)。 暂时就写这么多吧,之后的生活,再慢慢体会。顺便送上一副京都的红叶。顺便提一句,本文照片都是自己拍摄的,版权所有。因此如果想要借作他用的话,还请麻烦知会一声,如果合适,会考虑给您高清版本的图片。 ![京都御所红叶](/assets/images/2013/kouyou.jpg) URL: https://onevcat.com/2013/03/mgtwitterengine/index.html.md Published At: 2013-03-24 00:34:48 +0900 # MGTwitterEngine中Twitter API 1.1的使用 在iOS5中使用Twitter framework或者在iOS6中使用Social framework来完成Twitter的集成是非常简单和轻松的,但是如果应用要针对iOS5之前的系统版本,那么就不能使用iOS提供的框架了。一个比较常见也是使用最广泛的选择是[MGTwitterEngine](https://github.com/mattgemmell/MGTwitterEngine),比如[PomodoroDo](http://www.onevcat.com/showcase/pomodoro_do/)选择使用的就是该框架。 但是今天在对PomodoroDo作更新的时候,发现Twitter的分享无法使用了,在查阅Twitter文档说明之后,发现这是Twitter采用了新版API的原因。默认状况下MGTwitterEngine采用的是v1版的API,并且使用XML的版本进行请求,而在1.1中,将[只有JSON方式的API可以使用](https://dev.twitter.com/docs/api/1.1/overview#JSON_support_only)。v1.0版本的API已经于2013年3月5日被完全废弃,因此想要继续使用MGTwitterEngine来适配iOS5之前的Twitter集成需求,就需要将MGTwitterEngine的请求改为JSON方式。MGTwitterEngine也考虑到了这一点,但是因为时间比较古老了,MGTwitterEngine使用了YAJL来作为JSON的Wrapper,因此还需要将YAJL集成进来。下午的时候尝试了一会儿,成功地让MGTwitterEngine用上了1.1的Twitter API,为了以防之后别人或是自己可能遇到同样的问题,将更新的方法在此留底备忘。 1. 导入YAJL Framework * YAJL的OC实现,从[该地址下载该框架](https://github.com/gabriel/yajl-objc/download)。(2013年3月24日的最新版本为YAJL 0.3.1 for iOS) * 解压下载得到的zip,将解压后的YAJLiOS.framework加入项目工程 * 在Xcode的Build Setting里在Other Linker Flags中添加-ObjC和-all_load标记 2. 加入MGTwitterEngine的JSON相关代码 * 从[MGTwitterEngine的页面](https://github.com/mattgemmell/MGTwitterEngine)down下该项目。当然如果有新版或者有别的branch可以用的话更省事儿,但是鉴于MGTwitterEngine现在的活跃度来说估计可能性不大,所以还是乖乖自己更新吧。 * 解开下载的zip,用Xcode打开MGTwitterEngine.xcodeproj工程文件,将其中Twitter YAJL Parsers组下的所有文件copy到自己的项目中。 3. YAJL头文件集成 * 接下来是C和OC接口头文件的导入,从下面下载YAJL库:[https://github.com/thinglabs/yajl-objc](https://github.com/thinglabs/yajl-objc) * 在下载得到的文件夹中,寻找并将以下h文件拷贝到自己的工程中: * yajl_common.h * yajl_gen.h * yajl_parse.h * NSObject+YAJL.h * YAJL.h * YAJLDocument.h * YAJLGen.h * YAJLParser.h 4. 最后是在MGTwitterEngine设定为使用v1.1 API以及JSON方式请求 在MGTwitterEngine.m中,将对应代码修改为以下: ```objc #define USE_LIBXML 0 #define TWITTER_DOMAIN @"api.twitter.com/1.1" ``` 在MGbTwitader.h,启用YAJL ```objc #define define YAJL_AVAILABLE 1 ``` 本文参考: [MGTwitterEngine issues 107](https://github.com/mattgemmell/MGTwitterEngine/issues/107) [http://damienh.org/2009/06/20/setting-up-mgtwitterengine-with-yajl-106-for-iphone-development/](http://damienh.org/2009/06/20/setting-up-mgtwitterengine-with-yajl-106-for-iphone-development/) URL: https://onevcat.com/2013/02/xcode-plugin/index.html.md Published At: 2013-02-02 00:32:39 +0900 # Xcode 4 插件制作入门 本文欢迎转载,但烦请保留此行出处信息:[https://onevcat.com/2013/02/xcode-plugin/](https://onevcat.com/2013/02/xcode-plugin/) ## 2014.5.4更新 对于 Xcode 5,本文有些地方显得过时了。Xcode 5 现在已经全面转向了 ARC,因此在插件初始化设置方面其实有所改变。另外由于一大批优秀插件的带动(可以参看文章底部链接),很多大神们逐渐加入了插件开发的行列,因此,一个简单的 Template 就显得很必要了。在 Github 上的[这个 repo](https://github.com/kattrali/Xcode5-Plugin-Template) 里,包含了一个 Xcode 5 的插件的 Template 工程,省去了每次从头开始建立插件工程的麻烦,大家可以直接下载使用。 另外值得一提的是,在 Xcode 5 中, Apple 为了防止过期插件导致的在 Xcode 升级后 IDE 的崩溃,添加了一个 UUID 的检查机制。只有包含声明了适配 UUID,才能够被 Xcode 正确加载。上面那个项目中也包含了这方面的更详细的说明,可以参考。 文中其他关于插件开发的思想和常用方法在新的 Xcode 中依然是奏效的。 --- 本文将介绍创建一个Xcode4插件所需要的基本步骤以及一些常用的方法。请注意为Xcode创建插件并没有任何的官方支持,因此本文所描述的方法和提供的信息可能会随Apple在Xcode上做的变化而失效。另外,由于创建插件会使用到私有API,因此Xcode插件也不可能被提交到Mac App Store上进行出售。 本文内容是基于Xcode 4.6(4H127)完成的,但是应该可以适用于任意的Xcode 4.X版本。VVPlugInDemo的工程文件我放到了github上,有需要的话您可以从[这里下载](https://github.com/onevcat/VVPluginDemo)并作为参考和起始来使用。 ## 综述 Xcode本身作为一个IDE来说已经可以算上优秀,但是依然会有很多缺失的功能,另外在开发中针对自己的开发需求,创建一些便利的IDE插件,必定将大为加快开发速度。由于苹果官方并不对Xcode插件提供任何技术和文档支持,因此对于大部分开发者来说可能难于上手。虽然没有官方支持,但是在Xcode中开发并使用插件是可能的,并且也是被默许的。在Xcode启动的时候,Xcode将会寻找位于~/Library/Application Support/Developer/Shared/Xcode/Plug-ins文件夹中的后缀名为.xcplugin的bundle作为插件进行加载(运行其中的可执行文件),这就可以令我们光明正大合法合理地将我们的代码注入(虽然这个词有点不好听)Xcode,并得到运行。因此,想要创建Xcode插件,**我们需要创建Bundle工程并将编译的bundle放到上面所说的插件目录中去**,这就是Xcode插件的原理。 需要特别说明的是,因为Xcode会在启动时加载你的插件,这样就相当于你的代码有机会注入Xcode。只要你的插件加载成功,那么它将和Xcode共用一个进程,也就是说当你的代码crash的时候,Xcode也会随之crash。同样的情况也可能在Xcode版本更新的时候,由于兼容性问题而出现(因为插件可能使用私有API,Apple没有义务去维护这些API的可用性)。在出现这种情况的时候,可以直接删除插件目录下的导致问题的xcplugin文件即可。 ## 你的第一个插件 我将通过制作一个简单的demo插件来说明一般Xcode插件的编写方法,这个插件将在Xcode的Edit菜单中加入一个叫做“What is selected”的项目,当你点击这个菜单命令的时候,将弹出一个警告框,提示你现在在编辑器中所选中的内容。我相信这个例子能包含绝大部分在插件创建中所必须的步骤和一些有用的方法。由于我自己也只是个半吊子开发者,水平十分有限,因此错误和不当之处还恳请大家轻喷多原谅,并帮助我改正。那么开始.. ### 创建Bundle工程 ![image][5] 创建工程,OSX,Framework & Library,选择Bundle,点击Next。 [5]: /assets/images/2013/xcode-plugin-1.png ![image][6] [6]: /assets/images/2013/xcode-plugin-2.png 在Project信息页面中,填入插件名字,在这个例子里,就叫做DemoPlugin,Framework使用默认的Cocoa就行。另外一定记住将Use Automatic Reference Counting前的勾去掉,由于插件只能使用GC来进行内存管理,因此不需要使用ARC。 ### 工程设置 插件工程有别于一般工程,需要进行一些特别的设置,以确保能正确编译插件bundle。 ![image][7] [7]: /assets/images/2013/xcode-plugin-3.png 首先,在编辑工程的Info.plist文件(直接编辑plist文件或者是修改TARGETS下对应target的Info都行),加入以下三个布尔值: ``` XCGCReady = YES XCPluginHasUI = NO XC4Compatible = YES ``` 这将告诉编译器工程已经使用了GC,没有另外的UI并且是Xcode4适配的,否则你的插件将不会被加载。接下来,对Bundle Setting进行一些设置: ![image][8] [8]: /assets/images/2013/xcode-plugin-4.png * Installation Build Products Location 设置为 ${HOME} * Product的根目录 * Installation Directory 设置为 * /Library/Application Support/Developer/Shared/Xcode/Plug-ins * 这里指定了插件安装的位置,这样build之后就会将插件直接扔到Plug-ins的目录了。当然不嫌麻烦的话也可以每次自己复制粘贴过去。注意这里不是绝对路径,而是基于上面的${HOME}的路径。 * Deployment Location 设置为 YES * 告诉Xcode不要用设置里的build location,而是用Installation Directory来确定build后放哪儿 * Wrapper extension 设置为 xcplugin * 把产品后缀名改为xcplugin,否则Xcode不会加载插件 如一开始说的那样,Xcode会在每次启动的时候搜索插件目录并进行加载,做如上设置的目的是每次build之后你只需要重新启动Xcode就能看到重新编译后的插件的效果,而避免了自己再去寻找Product然后copy&paste的步骤。 另外,还需要自己在User-Defined里添加一个键值对: ![image][9] [9]: /assets/images/2013/xcode-plugin-5.png * GCC_ENABLE_OBJC_GC 设置为 supported 至此所有配置工作完成,接下来终于可以开始实现插件了~ ### Hello World 新建一个类,取名叫做VVPluginDemo(当然只要不重,随便什么名字都是可以的),继承自NSObject(做iOS开发的童鞋请不要忘记现在是写Xcode插件,您需要通过OS X的Cocoa里的Objective-C class模版,而不要用Cocoa Touch的模版..)。打开VVPluginDemo.m,加入以下代码: ```objc +(void)pluginDidLoad:(NSBundle *)plugin { NSLog(@"Hello World"); } ``` Build(对于OS X 10.8的SDK可能会有提示GC已经废弃的警告,不用管,Xcode本身是GC的,ARC的插件是无法load的),打开控制台(Control+空格 输入console),重新启动Xcode。应该能控制台中看到我们的插件的输出: ![image][10] [10]: /assets/images/2013/xcode-plugin-6.png 太好了。有句话叫做,写出一个Hello World,就说明你已经掌握了一半…那么,剩下的一半内容,将对开发插件时可能面临的问题和一些常用的手段进行介绍。 ### 创建插件单例,监听事件 继续我们的插件,还记得我们的目的么?在Xcode的Edit菜单中加入一个叫做“What is selected”的项目,当你点击这个菜单命令的时候,将弹出一个警告框,提示你现在在编辑器中所选中的内容。一般来说,我们希望插件能够在整个Xcode的生命周期中都存在(不要忘记其实用来写Cocoa的Xcode本身也是一个Cocoa程序)。最好的办法就是在+pluginDidLoad:中初始化单例,如下: ```objc + (void) pluginDidLoad: (NSBundle*) plugin { [self shared]; } +(id) shared { static dispatch_once_t once; static id instance = nil; dispatch_once(&once, ^{ instance = [[self alloc] init]; }); return instance; } ``` 这样,以后我们在别的类中,就可以简单地通过[VVPluginDemo shared]来访问到插件的实例了。 在init中,加入一个程序启动完成的事件监听,并在程序完成启动后,在菜单栏的Edit中添加我们所需要的菜单项,这个操作最好是在Xcode完全启动以后再进行,以避免一些潜在的危险和冲突。另外,由于想要在按下按钮时显示编辑器中显示的内容,我们可能需要监听NSTextViewDidChangeSelectionNotification事件(WTF,你为什么会知道要监听什么。别着急,后面会再说,先做demo先做demo) ```objc - (id)init { if (self = [super init]) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidFinishLaunching:) name:NSApplicationDidFinishLaunchingNotification object:nil]; } return self; } - (void) applicationDidFinishLaunching: (NSNotification*) noti { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(selectionDidChange:) name:NSTextViewDidChangeSelectionNotification object:nil]; NSMenuItem *editMenuItem = [[NSApp mainMenu] itemWithTitle:@"Edit"]; if (editMenuItem) { [[editMenuItem submenu] addItem:[NSMenuItem separatorItem]]; NSMenuItem *newMenuItem = [[NSMenuItem alloc] initWithTitle:@"What is selected" action:@selector(showSelected:) keyEquivalent:@""]; [newMenuItem setTarget:self]; [newMenuItem setKeyEquivalentModifierMask: NSAlternateKeyMask]; [[editMenuItem submenu] addItem:newMenuItem]; [newMenuItem release]; } } -(void) selectionDidChange:(NSNotification *)noti { //Nothing now. Just in case of crash. } -(void) showSelected:(NSNotification *)noti { //Nothing now. Just in case of crash. } ``` 现在build,重启Xcode,如果一切顺利的话,你应该能看到菜单栏上的变化了: ![image][11] [11]: /assets/images/2013/xcode-plugin-8.png ### 完成Demo插件 剩下的事情就很简单了,在接收到TextView的ChangeSelection通知后把现在选中的文本更新一下,在点击按钮时显示一个含有储存文字的对话框就行了。Let's do it~ 首先在.m文件中加上property声明(个人习惯,喜欢用ivar也可以)。在#import和@implementation之间加上: ```objc @interface VVPluginDemo() @property (nonatomic,copy) NSString *selectedText; @end ``` 得益于新的属性自动绑定,synthesis已经不需要写了(对此还不太了解的童鞋可以参看我的[这篇博文](http://www.onevcat.com/2012/06/modern-objective-c/))。然后完成- selectionDidChange:和-showSelected:如下: ```objc -(void) selectionDidChange:(NSNotification *)noti { if ([[noti object] isKindOfClass:[NSTextView class]]) { NSTextView* textView = (NSTextView *)[noti object]; NSArray* selectedRanges = [textView selectedRanges]; if (selectedRanges.count==0) { return; } NSRange selectedRange = [[selectedRanges objectAtIndex:0] rangeValue]; NSString* text = textView.textStorage.string; self.selectedText = [text substringWithRange:selectedRange]; } //Hello, welcom to OneV's Den } -(void) showSelected:(NSNotification *)noti { NSAlert *alert = [[[NSAlert alloc] init] autorelease]; [alert setMessageText: self.selectedText]; [alert runModal]; } ``` Build,重启Xcode,随便选中一段文本,然后点击Edit中的What is selected。OY~完成~ ![image][13] [13]: /assets/images/2013/xcode-plugin-7.png 至此,您应该已经掌握了基本的Xcode插件制作方法了。接下来的就是根据您的需求实践了~但是在此之前,还有一些重要的技巧和常用方法可能您会有兴趣。 ## 开发插件时有用的技巧 由于没有文档指导插件开发,调试也只能用打log的方式,因此会十分艰难。掌握一些常用的技巧和方法,将会很有帮助。 ### I Need All Notifications! 一种很好的方法是监听需要的消息,并针对消息作出反应。就像demo里的NSTextViewDidChangeSelectionNotification。对于熟悉iOS或者Mac开发的童鞋来说,应该在日常开发里也接触过很多类型的Notification了,而因为插件开发没有文档,因此我们需要自己去寻找想要监听和接收的Notification。[NSNotificationCenter文档][14]中,关于加入Observer的方法-addObserver:selector:name:object:,当给name参数赋值nil时,将可以监听到所有的notification: [14]: https://developer.apple.com/library/mac/#documentation/Cocoa/Reference/Foundation/Classes/NSNotificationCenter_Class/Reference/Reference.html > notificationName: The name of the notification for which to register the observer; that is, only notifications with this name are delivered to the observer. If you pass nil, the notification center doesn’t use a notification’s name to decide whether to deliver it to the observer. 因此可以用它来监测所有的Notification,并从中找到自己所需要的来进行处理: ```objc -(id)init { if (self = [super init]) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(notificationListener:) name:nil object:nil]; } return self; } -(void)notificationListener:(NSNotification *)noti { NSLog(@" Notification: %@", [noti name]); } ``` 编译重启后在控制台得到的输出: ![image][15] [15]: /assets/images/2013/xcode-plugin-9.png 当然如果只是打印名字的话可能帮助不大,也许你需要从noti的object或者userinfo中获得更多的信息。按条件打印,配合控制台的搜索进行寻找会是一个不错的方法。 ### Hack私有API 用OC的动态特性可以做很多事,比如在运行时替换掉某个Xcode的方法。记住Xcode本身也是Cocoa程序,本质上和我们用Xcode所开发的程序没有太大区别。因此如果可以知道Xcode在进行某些操作时候的方法的话,就可以将该方法与我们自己实现的方法进行运行时调换,从而改为执行我们自己的方法。这便是运行时的Method Swizzling(或者叫Monkey patch,管他呢),这在smalltalk类语言中是一种很有趣和方便的做法,关于这方面更详细的,我以前写过一篇关于[OC运行时特性的文章][16]。当时提到的method swizzling方法并没有对交换的函数进行检查等工作,通用性也比较差。现在针对OC已经有比较成熟的一套方法交换机制了,其中比较有名的有[rentzsch的jrswizzle][17]以及[OC社区的MethodSwizzling实现][18]。 [16]: http://www.onevcat.com/2012/04/objective-c-runtime/ [17]: https://github.com/rentzsch/jrswizzle [18]: http://cocoadev.com/wiki/MethodSwizzling 有了方法交换的办法,接下来需要寻找要交换的方法。Xcode所使用的所有库都包含在Xcode.app/Contents/的Frameworks,SharedFrameworks和OtherFrameworks三个文件夹下。其中和Xcode关系最为直接以及最为重要的是Frameworks中的IDEKit和IDEFoundation,以及SharedFrameworks中的DVTKit和DVTFoundation四个。其中DVT前缀表示Developer Toolkit,IDE和IDEFoundation中的类基本是DVT中类的子类。这四个framework将是我们在开发改变Xcode默认行为的Xcode插件时最主要要打交道的。另外如果想对IB进行注入,可能还需要用到Frameworks下的IBAutolayoutFoundation(待确定)。关于这些framework中的私有API,可以使用[class-dump][19]很简单地将头文件提取出来。当然,也有人为懒人们完成了这个工作,[probablycorey的xcode-class-dump][20]中有绝大部分类的头文件。 [19]: http://stevenygard.com/projects/class-dump/ [20]: https://github.com/probablycorey/xcode-class-dump 作为Demo,我们将简单地完成一个方法交换:在补全代码时,我们简单地输出一句log。 #### MethodSwizzle 为了交换方法,可以直接用现成的MethodSwizzle实现。MethodSwizzle可以在[这里][21]找到。将.h和.m导入插件工程即可~ [21]: https://gist.github.com/4696790 #### 寻找对应API 通过搜索,补全代码的功能定义在DVKit中的DVTTextCompletionController类,其中有一个方法为- (BOOL)acceptCurrentCompletion,猜测返回的布尔值是否接受当前的补全结果。由于这些都是私有API,因此需要在我们的工程中自己进行声明。在新建文件中的C and C++中选Header File,为工程加入一个Header文件,并加入一下代码: ```objc @interface DVTTextCompletionController : NSObject - (BOOL)acceptCurrentCompletion; @end ``` 然后需要将DVKit.framework添加到工程中,在/Applications/Xcode.app/Contents/SharedFrameworks中找到DVTKit.framework,拷贝到任意正常能访问到的目录下,然后在插件工程的Build Phases中加入framework。嗯?你说找不到DVTKit.framework?亲,私有框架当然找不到,点击Add Other...然后去刚才copy出来的地方去找吧.. ![image][22] [22]: /assets/images/2013/xcode-plugin-10.png 最后便是加入方法交换了~新建一个DVTTextCompletionController的Category,命名为PluginDemo ![image][23] [23]: /assets/images/2013/xcode-plugin-13.png import之前定义的header和MethodSwizzle.h,在DVTTextCompletionController+PluginDemo.m中加入下面实现: ```objc + (void)load { MethodSwizzle(self, @selector(acceptCurrentCompletion), @selector(swizzledAcceptCurrentCompletion)); } - (BOOL)swizzledAcceptCurrentCompletion { NSLog(@"acceptCurrentCompletion is called by %@", self); return [self swizzledAcceptCurrentCompletion]; } ``` +load方法在每个NSObject类或子类被调用时都会被执行,可以用来在runtime配置当前类。这里交换了DVTTextCompletionController的acceptCurrentCompletion方法和我们自己实现的swizzledAcceptCurrentCompletion方法。在swizzledAcceptCurrentCompletion中,先打印了一句log,输出相应该方法的实例。接下来似乎是调用了自己,但是实际上swizzledAcceptCurrentCompletion的方法已经和原来的acceptCurrentCompletion交换,因此这里实际调用的将是原来的方法。那么这段代码所做的就是将Xcode想调用原来的acceptCurrentCompletion的行为,改变成了先打印一个log,之后再进行原来的acceptCurrentCompletion调用。 编译,重启Xcode,打开一个工程随便输入点东西,让补全出现。控制台中的输出符合我们的预期: ![image][24] [24]: /assets/images/2013/xcode-plugin-12.png 太棒了,有了对私有API的注入,能做的事情大为扩展了。 ### 研究Xcode的View Hierarchy 另外一种常见的插件行为是修改某些界面。再一次说明,Xcode是一个标准Cocoa程序,一切都是那么熟悉(如果你为Cocoa或者CocoaTouch开发的话,应该是很熟悉)。拿到整个App的Window,然后依次递归打印subview。stackoverflow上有[一个UIView的版本](http://stackoverflow.com/questions/2715534/where-does-a-uialertview-live-while-not-dismissed/2715772#2715772),稍微改变一下就可以得到一个NSView版本。新建一个NSView的Dumping Category,加入如下实现: ```objc -(void)dumpWithIndent:(NSString *)indent { NSString *class = NSStringFromClass([self class]); NSString *info = @""; if ([self respondsToSelector:@selector(title)]) { NSString *title = [self performSelector:@selector(title)]; if (title != nil && [title length] > 0) { info = [info stringByAppendingFormat:@" title=%@", title]; } } if ([self respondsToSelector:@selector(stringValue)]) { NSString *string = [self performSelector:@selector(stringValue)]; if (string != nil && [string length] > 0) { info = [info stringByAppendingFormat:@" stringValue=%@", string]; } } NSString *tooltip = [self toolTip]; if (tooltip != nil && [tooltip length] > 0) { info = [info stringByAppendingFormat:@" tooltip=%@", tooltip]; } NSLog(@"%@%@%@", indent, class, info); if ([[self subviews] count] > 0) { NSString *subIndent = [NSString stringWithFormat:@"%@%@", indent, ([indent length]/2)%2==0 ? @"| " : @": "]; for (NSView *subview in [self subviews]) { [subview dumpWithIndent:subIndent]; } } } ``` 在合适的时候(比如点击某个按钮时),调用下面一句代码,便可以打印当前Xcode的结构,非常方便。这对了解Xcode的构成和如何搭建一个如Xcode般复杂的程序很有帮助~ [[[NSApp mainWindow] contentView] dumpWithIndent:@""]; 在结果控制台中的输出结果类似这样: ![image][26] [26]: /assets/images/2013/xcode-plugin-14.png 根据自己需要去去相应的view吧~然后配合方法交换,基本可以做到尽情做想做的事情了。 ## 最后的小bonus /Applications/Xcode.app/Contents/Frameworks/IDEKit.framework/Versions/A/Resources中有不少Xcode界面用的图片,pdf,png和tiff格式都有,想要自定义run,stop按钮或者想要让断点标记从蓝色块变成机器猫头像什么的…应该是可能的~ /Applications/Xcode.app/Contents/PlugIns目录里有很多Xcode自带的“官方版”外挂插件,显然通过class-dump和注入的方法,你可以为Xcode的插件写插件...嗯~比如改变debugger的行为或者让plist编辑器更聪明,就是这样的。 希望Apple能提供为Xcode编写插件的支持,所有东西都需要摸索虽然很有趣,但是也比较花时间。 另外,github等代码托管网站上有不少大神们写的插件,都开源放出。这些必须是学习插件编写的最优秀的教材和参考: * [mneorr / Alcatraz](https://github.com/mneorr/Alcatraz) Xcode的包管理插件,管理其他插件的插件 * [onevcat / VVDocumenter-Xcode](https://github.com/onevcat/VVDocumenter-Xcode) 帮助快速写文档注释的插件,自动提取参数返回值等 * [omz / ColorSense-for-Xcode](https://github.com/omz/ColorSense-for-Xcode) 在UIColor/NSColor上显示出对应的颜色 * [omz / Dash-Plugin-for-Xcode](https://github.com/omz/Dash-Plugin-for-Xcode) 在Xcode中集成Dash,方便看文档 * [omz / MiniXcode](https://github.com/omz/MiniXcode) 隐藏Xcode臃肿的工具栏,获得更大的可视空间 * [ksuther / KSImageNamed-Xcode](https://github.com/ksuther/KSImageNamed-Xcode) 输入imageNamed的时候自动补完图片名称 * [JugglerShu / XVim](https://github.com/JugglerShu/XVim) 将Xcode编辑器改造成Vim * [davekeck / Xcode-4-Fixins](https://github.com/davekeck/Xcode-4-Fixins) 修正一些Xcode的bugs(应该已经没有太大用了) * [0xced / CLITool-InfoPlist](https://github.com/0xced/CLITool-InfoPlist) 方便修改Info.plist为CLI目标的插件 * [questbeat / Lin](https://github.com/questbeat/Lin) 为NSLocalizedString显示补全 * [stefanceriu / SCXcodeMiniMap](https://github.com/stefanceriu/SCXcodeMiniMap) 在侧边显示代码小地图 好了,就到这里吧。VVPlugInDemo的工程文件我放到了github上,有需要的话您可以从[这里下载][35]并作为参考和起始来使用。谢谢您看完这么长的文。正如一开始所说的,我自己水平十分有限,因此错误和不当之处还恳请大家轻喷多原谅,并帮助我改正,再次谢谢~ [35]: https://github.com/onevcat/VVPluginDemo URL: https://onevcat.com/2013/01/do_not_pause_me/index.html.md Published At: 2013-01-26 00:23:34 +0900 # Unity3D中暂停时的动画及粒子效果实现 暂停是游戏中经常出现的功能,而Unity3D中对于暂停的处理并不是很理想。一般的做法是将`Time.timeScale`设置为0。Unity的文档中对于这种情况有以下描述; > The scale at which the time is passing. This can be used for slow motion effects….When timeScale is set to zero the game is basically paused … timeScale表示游戏中时间流逝快慢的尺度。文档中明确表示,这个参数是用来做慢动作效果的。对于将timeScale设置为0的情况,仅只有一个补充说明。在实际使用中,通过设置timeScale来实现慢动作特效,是一种相当简洁且不带任何毒副作用的方法,但是当将timeScale设置为0来实现暂停时,由于时间不再流逝,所有和时间有关的功能痘将停止,有些时候这正是我们想要的,因为毕竟是暂停。但是副作用也随之而来,在暂停时各种动画和粒子效果都将无法播放(因为是时间相关的),FixedUpdate也将不再被调用。 **换句话说,最大的影响是,在timeScale=0的暂停情况下,你将无法实现暂停菜单的动画以及各种漂亮的点击效果。** 但是并非真的没办法,关于timeScale的文档下就有提示: > Except for realtimeSinceStartup, timeScale affects all the time and delta time measuring variables of the Time class. 因为 `realtimeSinceStartup` 和 `timeScale` 无关,因此也就成了解决在暂停下的动画和粒子效果的救命稻草。对于Unity动画,在每一帧,根据实际时间寻找相应帧并采样显示的方法来模拟动画: ```csharp AnimationState _currState = animation[clipName]; bool isPlaying = true; float _progressTime = 0F; float _timeAtLastFrame = 0F; float _timeAtCurrentFrame = 0F; bool _inReversePlaying = false; float _deltaTime = 0F; animation.Play(clipName); _timeAtLastFrame = Time.realtimeSinceStartup; while (isPlaying) { _timeAtCurrentFrame = Time.realtimeSinceStartup; _deltaTime = _timeAtCurrentFrame - _timeAtLastFrame; _timeAtLastFrame = _timeAtCurrentFrame; _progressTime += _deltaTime; _currState.normalizedTime = _inReversePlaying ? 1.0f - (_progressTime / _currState.length) : _progressTime / _currState.length; animation.Sample(); //…repeat or over by wrap mode } ``` 对于粒子效果,同样进行计时,并通过粒子系统的Simulate方法来模拟对应时间的粒子状态来完成效果,比如对于Legacy粒子,使Emitter在`timeScale=0`暂停时继续有效发射并显示效果: ```csharp _deltaTime = Time.realtimeSinceStartup - _timeAtLastFrame; _timeAtLastFrame = Time.realtimeSinceStartup; if (Time.timeScale == 0 ){ _emitter.Simulate(_deltaTime); _emitter.emit = true; } ``` 核心的代码基本都在上面了,可以根据这个思路完成实现。[完整的代码和示例工程](https://github.com/onevcat/UnpauseMe)我放到了github上,有需要的朋友可以去查看,也欢迎大家指正。 URL: https://onevcat.com/2012/12/xuporter/index.html.md Published At: 2012-12-18 00:17:55 +0900 # Unity编译至Xcode工程后自动添加文件和库的方法 废话之前 [XUPorter项目Github链接][3] [3]: https://github.com/onevcat/XUPorter ### 为什么想要自动添加 由于Unity是全平台的游戏开发环境,在开发中针对特定平台的特定功能时,很难避免根据对象平台的不同而引入不同的依赖。包括源码,需要的库和框架等。在使用各种插件后这种情况愈发严重:比如想加入内购功能,StroreKit.framework必不可少,而且也需要相应的处理代码。按照一般的Unity插件开发流程,在完成.cs的接口声明和Unity侧的调用实现后,最重要的当然是在iOS native侧完成实现。而在以前,包括依赖库和所有源码文件,都只有在Unity生成Xcode工程之后,再手动添加。如果工程小依赖少的话花不了太多时间,但是如果项目很大,很可能折腾一次就要十来分钟,严重影响了工作效率,必须加以解决。 ### 怎么办 Unity开发团队也意识到了这个问题,在Unity编译的最后加入了一个脚本调用的命令,会自动搜索Editor文件夹下的PostprocessBuildPlayer,并进行调用,在该文件中可以自己加入脚本来向Xcode中添加库和文件。关于PostprocessBuildPlayer的详细信息,可以参看[官方文档][5],关于向Xcode中添加文件或库,gonzoua的[xcs][6]也许是不错的选择。但是似乎xcs只能针对Xcode3来添加,在Xcode4中,主工程文件的结构发生了改变,导致xcs失效,而这个项目也迟迟没有更新(也许有时间我会考虑接手继续这个项目,但肯定不是现在...)。因此不得不打其他主意。 [5]: http://docs.unity3d.com/Documentation/Manual/BuildPlayerPipeline.html [6]: https://github.com/gonzoua/xcs 在Unity3.5中,加入了一个很棒的标签——[[PostProcessBuild]][7],被该标签标注的函数将自动在build player后被调用,这为大家提供了一个不需要用脚本和命令行就能添加或修改编译得到的工程的绝好的入口。darktable用python实现了一个Xcode4工程文件读写的接口[Mod PBXProj][8],但是对于Unity来说,更需要的是C#的实现。Cariola完成了[一部分实现][9],但是存在一些错误和不太好用的地方,代码也很乱。我在其基础上进行了一些改进和整理。但是因为变动的还是比较大,很难merge回去,所以决定自己开一个项目来继续推进这个项目。 [7]: http://docs.unity3d.com/Documentation/ScriptReference/PostProcessBuildAttribute.html [8]: https://bitbucket.org/darktable/mod-pbxproj/overview [9]: https://github.com/dcariola/XCodeEditor-for-Unity ### XUPorter 我把它叫做XUPorter,a dependency porter from Unity to Xcode。XUPorter可以读取Xcode工程文件并进行解析(再次感谢darktable的工作),之后在Unity工程的Assets目录下寻找所有的.projmods文件,并根据文件内容向工程中添加文件或库。 #### 使用方法 将Github项目中的所有文件copy到Unity工程文件夹下的/Assets/Editor目录中,XUPorter使用一个[改良版的MiniJSON][10]来进行。如果你的项目中已经在使用这个MiniJSON了的话,可以直接将XUPorter文件夹下的MiniJSON文件夹删掉;如果不一样的话,你可以选择其中一个重构一下或者加上命名空间来解决类名冲突。接下来,Mods文件夹下是示例文件以及需要导入Xcode的文件。在看完以后你需要把Mods文件夹下的所有.projmods文件以及Mods/iOS文件夹下的内容删除或者替换为你所需要的内容。 [10]: https://github.com/prime31/UIToolkit/blob/master/Assets/Plugins/MiniJSON.cs 在[这里][11]提供了.unitypackege格式文件的下载,你也可以选择下载打包好的文件并导入你的工程,之后的步骤和上面一样。 [11]: http://d.pr/f/HAzc .projmods文件是一个JSON格式的配置patch文件,定义了要如何设置Xcode工程。举个基本的例子,比如KKKeychain.projmods: ```json { "group": "KKKeychain", "libs": [], "frameworks": ["Security.framework"], "headerpaths": [], "files": [], "folders": ["iOS/KKKeychain/"], "linker_flags": [], "excludes": ["^.*.meta$", "^.*.mdown$", "^.*.pdf$"] } ``` 各参数定义如下: * group:所有由该projmods添加的文件和文件夹所属的Xcode中的group名称 * libs:在Xcode Build Phases中需要添加的动态链接库的名称,比如libz.dylib * frameworks:在Xcode Build Phases中需要添加的框架的名称,比如Security.framework * headerpaths:Xcode中编译设置中的Header Search Paths路径 * files:加入工程的文件名 * folders:加入工程的文件夹,其中所有的文件和文件夹都将被加入工程中 * linker_flags:添加到工程linker flag中的链接配置,比如-ObjC * excludes:忽略的文件的正则表达式,匹配的文件将不会被加入工程中 更多的例子可以参看Mods文件夹中的其他projmods文件。所有的定义路径都是基于当前projmods文件位置的相对路径。 最后,在完成projmods后,Unity会在编译完成后,调用XCodePostProcess的OnPostProcessBuild来对编译得到的Xcode工程进行修改。 之后进一步要做的是为MiniJSON添加一个namespace,这样可以避免不必要的冲突。另外如果您有什么好的想法,也欢迎fork这个项目并给我pull request。项目的github链接请[猛击这里](https://github.com/onevcat/XUPorter)。 URL: https://onevcat.com/2012/11/memory-in-unity3d/index.html.md Published At: 2012-11-16 00:16:54 +0900 # Unity 3D中的内存管理 本文欢迎转载,但烦请保留此行出处信息:[https://www.onevcat.com/2012/11/memory-in-unity3d/](https://www.onevcat.com/2012/11/memory-in-unity3d/) Unity3D在内存占用上一直被人诟病,特别是对于面向移动设备的游戏开发,动辄内存占用飙上一两百兆,导致内存资源耗尽,从而被系统强退造成极差的体验。类似这种情况并不少见,但是绝大部分都是可以避免的。虽然理论上Unity的内存管理系统应当为开发者分忧解难,让大家投身到更有意义的事情中去,但是对于Unity对内存的管理方式,官方文档中并没有太多的说明,基本需要依靠自己摸索。最近在接手的项目中存在严重的内存问题,在参照文档和Unity Answer众多猜测和证实之后,稍微总结了下Unity中的内存的分配和管理的基本方式,在此共享。 虽然Unity标榜自己的内存使用全都是“Managed Memory”,但是事实上你必须正确地使用内存,以保证回收机制正确运行。如果没有做应当做的事情,那么场景和代码很有可能造成很多非必要内存的占用,这也是很多Unity开发者抱怨内存占用太大的原因。接下来我会介绍Unity使用内存的种类,以及相应每个种类的优化和使用的技巧。遵循使用原则,可以让非必要资源尽快得到释放,从而降低内存占用。 --- ### Unity中的内存种类 实际上Unity游戏使用的内存一共有三种:程序代码、托管堆(Managed Heap)以及本机堆(Native Heap)。 程序代码包括了所有的Unity引擎,使用的库,以及你所写的所有的游戏代码。在编译后,得到的运行文件将会被加载到设备中执行,并占用一定内存。这部分内存实际上是没有办法去“管理”的,它们将在内存中从一开始到最后一直存在。一个空的Unity默认场景,什么代码都不放,在iOS设备上占用内存应该在17MB左右,而加上一些自己的代码很容易就飙到20MB左右。想要减少这部分内存的使用,能做的就是减少使用的库,稍后再说。 托管堆是被Mono使用的一部分内存。[Mono](http://www.mono-project.com/Main_Page)项目一个开源的.net框架的一种实现,对于Unity开发,其实充当了基本类库的角色。托管堆用来存放类的实例(比如用new生成的列表,实例中的各种声明的变量等)。“托管”的意思是Mono“应该”自动地改变堆的大小来适应你所需要的内存,并且定时地使用垃圾回收(Garbage Collect)来释放已经不需要的内存。关键在于,有时候你会忘记清除对已经不需要再使用的内存的引用,从而导致Mono认为这块内存一直有用,而无法回收。 最后,本机堆是Unity引擎进行申请和操作的地方,比如贴图,音效,关卡数据等。Unity使用了自己的一套内存管理机制来使这块内存具有和托管堆类似的功能。基本理念是,如果在这个关卡里需要某个资源,那么在需要时就加载,之后在没有任何引用时进行卸载。听起来很美好也和托管堆一样,但是由于Unity有一套自动加载和卸载资源的机制,让两者变得差别很大。自动加载资源可以为开发者省不少事儿,但是同时也意味着开发者失去了手动管理所有加载资源的权力,这非常容易导致大量的内存占用(贴图什么的你懂的),也是Unity给人留下“吃内存”印象的罪魁祸首。 --- ### 优化程序代码的内存占用 这部分的优化相对简单,因为能做的事情并不多:主要就是减少打包时的引用库,改一改build设置即可。对于一个新项目来说不会有太大问题,但是如果是已经存在的项目,可能改变会导致原来所需要的库的缺失(虽说一般来说这种可能性不大),因此有可能无法做到最优。 ![](http://www.onevcat.com/wp-content/uploads/2012/11/unity-setting.png) 当使用Unity开发时,默认的Mono包含库可以说大部分用不上,在Player Setting(Edit->Project Setting->;Player或者Shift+Ctrl(Command)+B里的Player Setting按钮)面板里,将最下方的Optimization栏目中“Api Compatibility Level”选为.NET 2.0 Subset,表示你只会使用到部分的.NET 2.0 Subset,不需要Unity将全部.NET的Api包含进去。接下来的“Stripping Level”表示从build的库中剥离的力度,每一个剥离选项都将从打包好的库中去掉一部分内容。你需要保证你的代码没有用到这部分被剥离的功能,选为“Use micro mscorlib”的话将使用最小的库(一般来说也没啥问题,不行的话可以试试之前的两个)。库剥离可以极大地降低打包后的程序的尺寸以及程序代码的内存占用,唯一的缺点是这个功能只支持Pro版的Unity。 这部分优化的力度需要根据代码所用到的.NET的功能来进行调整,有可能不能使用Subset或者最大的剥离力度。如果超出了限度,很可能会在需要该功能时因为找不到相应的库而crash掉(iOS的话很可能在Xcode编译时就报错了)。比较好地解决方案是仍然用最强的剥离,并辅以较小的第三方的类库来完成所需功能。一个最常见问题是最大剥离时Sysytem.Xml是不被Subset和micro支持的,如果只是为了xml,完全可以导入一个轻量级的xml库来解决依赖(Unity官方推荐[这个](http://unity3d.com/support/documentation/Images/manual/Mono.Xml.zip))。 关于每个设定对应支持的库的详细列表,可以在[这里](http://docs.unity3d.com/Documentation/ScriptReference/MonoCompatibility.html)找到。关于每个剥离级别到底做了什么,[Unity的文档](http://unity3d.com/support/documentation/Manual/iphone-playerSizeOptimization.html)也有说明。实际上,在游戏开发中绝大多数被剥离的功能使用不上的,因此不管如何,库剥离的优化方法都值得一试。 --- ### 托管堆优化 Unity有一篇不错的关于[托管堆代码如何写比较好](http://unity3d.com/support/documentation/Manual/Understanding%20Automatic%20Memory%20Management.html)的说明,在此基础上我个人有一些补充。 首先需要明确,托管堆中存储的是你在你的代码中申请的内存(不论是用js,C#还是Boo写的)。一般来说,无非是new或者Instantiate两种生成object的方法(事实上Instantiate中也是调用了new)。在接收到alloc请求后,托管堆在其上为要新生成的对象实例以及其实例变量分配内存,如果可用空间不足,则向系统申请更多空间。 当你使用完一个实例对象之后,通常来说在脚本中就不会再有对该对象的引用了(这包括将变量设置为null或其他引用,超出了变量的作用域,或者对Unity对象发送Destory())。在每隔一段时间,Mono的垃圾回收机制将检测内存,将没有再被引用的内存释放回收。总的来说,你要做的就是在尽可能早的时间将不需要的引用去除掉,这样回收机制才能正确地把不需要的内存清理出来。但是需要注意在内存清理时有可能造成游戏的短时间卡顿,这将会很影响游戏体验,因此如果有大量的内存回收工作要进行的话,需要尽量选择合适的时间。 如果在你的游戏里,有特别多的类似实例,并需要对它们经常发送Destroy()的话,游戏性能上会相当难看。比如小熊推金币中的金币实例,按理说每枚金币落下台子后都需要对其Destory(),然后新的金币进入台子时又需要Instantiate,这对性能是极大的浪费。一种通常的做法是在不需要时,不摧毁这个GameObject,而只是隐藏它,并将其放入一个重用数组中。之后需要时,再从重用数组中找到可用的实例并显示。这将极大地改善游戏的性能,相应的代价是消耗部分内存,一般来说这是可以接受的。关于对象重用,可以参考[Unity关于内存方面的文档中Reusable Object Pools部分](http://docs.unity3d.com/Documentation/Manual/UnderstandingAutomaticMemoryManagement.html),或者Prime31有一个是用Linq来建立重用池的视频教程(Youtube,需要翻墙,[上半部分](http://www.youtube.com/watch?v=IX041ZvgQKE),[下半部分](http://www.youtube.com/watch?v=d9078u8ft58))。 如果不是必要,应该在游戏进行的过程中尽量减少对GameObject的Instantiate()和Destroy()调用,因为对计算资源会有很大消耗。在便携设备上短时间大量生成和摧毁物体的话,很容易造成瞬时卡顿。如果内存没有问题的话,尽量选择先将他们收集起来,然后在合适的时候(比如按暂停键或者是关卡切换),将它们批量地销毁并且回收内存。Mono的内存回收会在后台自动进行,系统会选择合适的时间进行垃圾回收。在合适的时候,也可以手动地调用System.GC.Collect()来建议系统进行一次垃圾回收。要注意的是这里的调用真的仅仅只是建议,可能系统会在一段时间后在进行回收,也可能完全不理会这条请求,不过在大部分时间里,这个调用还是靠谱的。 --- ### 本机堆的优化 当你加载完成一个Unity的scene的时候,scene中的所有用到的asset(包括Hierarchy中所有GameObject上以及脚本中赋值了的的材质,贴图,动画,声音等素材),都会被自动加载(这正是Unity的智能之处)。也就是说,当关卡呈现在用户面前的时候,所有Unity编辑器能认识的本关卡的资源都已经被预先加入内存了,这样在本关卡中,用户将有良好的体验,不论是更换贴图,声音,还是播放动画时,都不会有额外的加载,这样的代价是内存占用将变多。Unity最初的设计目的还是面向台式机,几乎无限的内存和虚拟内存使得这样的占用似乎不是问题,但是这样的内存策略在之后移动平台的兴起和大量移动设备游戏的制作中出现了弊端,因为移动设备能使用的资源始终非常有限。因此在面向移动设备游戏的制作时,尽量减少在Hierarchy对资源的直接引用,而是使用Resource.Load的方法,在需要的时候从硬盘中读取资源,在使用后用Resource.UnloadAsset()和Resources.UnloadUnusedAssets()尽快将其卸载掉。总之,这里是一个处理时间和占用内存空间的trade off,如何达到最好的效果没有标准答案,需要自己权衡。 在关卡结束的时候,这个关卡中所使用的所有资源将会被卸载掉(除非被标记了DontDestroyOnLoad)的资源。注意不仅是DontDestroyOnLoad的资源本身,其相关的所有资源在关卡切换时都不会被卸载。DontDestroyOnLoad一般被用来在关卡之间保存一些玩家的状态,比如分数,级别等偏向文本的信息。如果DontDestroyOnLoad了一个包含很多资源(比如大量贴图或者声音等大内存占用的东西)的话,这部分资源在场景切换时无法卸载,将一直占用内存,这种情况应该尽量避免。 另外一种需要注意的情况是脚本中对资源的引用。大部分脚本将在场景转换时随之失效并被回收,但是,在场景之间被保持的脚本不在此列(通常情况是被附着在DontDestroyOnLoad的GameObject上了)。而这些脚本很可能含有对其他物体的Component或者资源的引用,这样相关的资源就都得不到释放,这绝对是不想要的情况。另外,static的单例(singleton)在场景切换时也不会被摧毁,同样地,如果这种单例含有大量的对资源的引用,也会成为大问题。因此,尽量减少代码的耦合和对其他脚本的依赖是十分有必要的。如果确实无法避免这种情况,那应当手动地对这些不再使用的引用对象调用Destroy()或者将其设置为null。这样在垃圾回收的时候,这些内存将被认为已经无用而被回收。 需要注意的是,Unity在一个场景开始时,根据场景构成和引用关系所自动读取的资源,只有在读取一个新的场景或者reset当前场景时,才会得到清理。因此这部分内存占用是不可避免的。在小内存环境中,这部分初始内存的占用十分重要,因为它决定了你的关卡是否能够被正常加载。因此在计算资源充足或是关卡开始之后还有机会进行加载时,尽量减少Hierarchy中的引用,变为手动用Resource.Load,将大大减少内存占用。在Resource.UnloadAsset()和Resources.UnloadUnusedAssets()时,只有那些真正没有任何引用指向的资源会被回收,因此请确保在资源不再使用时,将所有对该资源的引用设置为null或者Destroy。同样需要注意,这两个Unload方法仅仅对Resource.Load拿到的资源有效,而不能回收任何场景开始时自动加载的资源。与此类似的还有AssetBundle的Load和Unload方法,灵活使用这些手动自愿加载和卸载的方法,是优化Unity内存占用的不二法则~ URL: https://onevcat.com/2012/10/perl-json-utf/index.html.md Published At: 2012-10-29 00:08:28 +0900 # Perl中JSON的解析和utf-8乱码的解决 最近在做一个带有网络通讯和同步功能的app,需要自己写一些后台的东西。因为是半路入门,所以从事开发以来就没有做过后台相关的工作,属于绝对的小白菜鸟。而因为公司在入职前给新员工提过学习Perl的要求,所以还算是稍微看过一些。这次的后台也直接就用Perl来写了。 ### 基本使用 和app的通讯,很大程度上依赖了JSON,一来是熟悉,二来是iOS现在解析JSON也十分方便。iOS客户端的话JSON的解析和生成都是没什么问题的:iOS5中加入了[NSJSONSerialization](http://developer.apple.com/library/ios/#documentation/Foundation/Reference/NSJSONSerialization_Class/Reference/Reference.html)类来提供相关功能,如果希望支持更早的系统版本的话,相关的开源代码也有很多,也简单易用,比如[SBJson](http://stig.github.com/json-framework/)或者[JSONKit](https://github.com/johnezang/JSONKit)。同样,在Perl里也有不少类似的JSON处理的模块,最有名最早的应该是[JSON](http://search.cpan.org/~makamaka/JSON-2.53/lib/JSON.pm)模块了,同时也简单易用,应该可以满足大部分情况下的需求了。 使用也很简单,安装完模块后,use之后使用encode_json命令即可将perl的array或者dic转换为标准的JSON字符串了: ```perl use JSON qw/encode_json decode_json/; my $data = [ { 'name' => 'Ken', 'age' => 19 }, { 'name' => 'Ken', 'age' => 25 } ]; my $json_out = encode_json($data); ``` 得到的字符串为 ```json [{"name":"Ken","age":19},{"name":"Ken","age":25}] ``` 相对应地,解析也很容易 ```perl my $array = decode_json($json_out); ``` 得到的$array是含有两个字典的数组的ref。 ### UTF-8乱码解决 在数据中含有UTF-8字符的时候需要稍微注意,如果直接按照上面的方法将会出现乱码。JSON模块的encode_json和decode_json自身是支持UTF8编码的,但是perl为了简洁高效,默认是认为程序是非UTF8的,因此在程序开头处需要申明需要UTF8支持。另外,如果需要用到JSON编码的功能(即encode_json)的话,还需要加入Encode模块的支持。总之,在程序开始处加入以下: ```perl use utf8; use Encode; ``` 另外,如果使用非UTF8进行编码的内容的话,最好先使用Encode的from_to命令转换成UTF8,之后再进行JSON编码。比如使用GBK编码的简体字(一般来自比较早的Windows的文件等会偶尔变成非UTF8编码),先进性如下转换: ```perl use JSON; use Encode 'from_to'; # 假设$json是GBK编码的 my $json = '{"test" : "我是GBK编码的哦"}'; from_to($json, 'GBK', 'UTF-8'); my $data = decode_json($json); ``` 其他的,如果追求更高的JSON转换性能的话,可以试试看[JSON::XS](http://search.cpan.org/~mlehmann/JSON-XS-2.33/XS.pm)之类的附加模块~ URL: https://onevcat.com/2012/09/autoayout/index.html.md Published At: 2012-09-20 00:01:31 +0900 # WWDC 2012 Session笔记——202, 228, 232 AutoLayout(自动布局)入门 这是博主的WWDC2012笔记系列中的一篇,完整的笔记列表可以参看[这里](http://onevcat.com/2012/06/%E5%BC%80%E5%8F%91%E8%80%85%E6%89%80%E9%9C%80%E8%A6%81%E7%9F%A5%E9%81%93%E7%9A%84ios6-sdk%E6%96%B0%E7%89%B9%E6%80%A7/)。如果您是首次来到本站,也许您会有兴趣通过[RSS](http://onevcat.com/atom.xml),或者通过页面左侧的邮件订阅的方式订阅本站。 AutoLayout在去年的WWDC上被引入Cocoa,而在今年的WWDC上,Apple不惜花费了三个Session的前所未见的篇幅来详细地向开发者讲解AutoLayout在iOS上的应用,是由起原因的:iPhone5的屏幕将变为4寸,开发者即将面临为不同尺寸屏幕进行应用适配的工作。Android平台开发中最令人诟病的适配工作的厄运现在似乎也将降临在iOS开发者的头上。基于这样的情况,Apple大力推广使用AutoLayout的方法来进行UI布局,以一举消除适配的烦恼。AutoLayout将是自Interface Builder和StoryBoard之后UI制作上又一次重要的变化,也必然是之后iOS开发的趋势,因此这个专题很值得学习。 ## AutoLayout是什么? 使用一句Apple的官方定义的话 > AutoLayout是一种基于约束的,描述性的布局系统。 > Auto Layout Is a Constraint-Based, Descriptive Layout System. 关键词: * 基于约束 - 和以往定义frame的位置和尺寸不同,AutoLayout的位置确定是以所谓相对位置的约束来定义的,比如_x坐标为superView的中心,y坐标为屏幕底部上方10像素_等 * 描述性 - 约束的定义和各个view的关系使用接近自然语言或者可视化语言(稍后会提到)的方法来进行描述 * 布局系统 - 即字面意思,用来负责界面的各个元素的位置。 总而言之,AutoLayout为开发者提供了一种不同于传统对于UI元素位置指定的布局方法。以前,不论是在IB里拖放,还是在代码中写,每个UIView都会有自己的frame属性,来定义其在当前视图中的位置和尺寸。使用AutoLayout的话,就变为了使用约束条件来定义view的位置和尺寸。这样的**最大好处是一举解决了不同分辨率和屏幕尺寸下view的适配问题,另外也简化了旋转时view的位置的定义**,原来在底部之上10像素居中的view,不论在旋转屏幕或是更换设备(iPad或者iPhone5或者以后可能出现的mini iPad)的时候,始终还在底部之上10像素居中的位置,不会发生变化。 总结 > 使用约束条件来描述布局,view的frame会依据这些约束来进行计算 > Describe the layout with constraints, and frames are calculated automatically. * * * ## AutoLayout和Autoresizing Mask的区别 Autoresizing Mask是我们的老朋友了…如果你以前一直是代码写UI的话,你肯定写过UIViewAutoresizingFlexibleWidth之类的枚举;如果你以前用IB比较多的话,一定注意到过每个view的size inspector中都有一个红色线条的Autoresizing的指示器和相应的动画缩放的示意图,这就是Autoresizing Mask。在iOS6之前,关于屏幕旋转的适配和iPhone,iPad屏幕的自动适配,基本都是由Autoresizing Mask来完成的。但是随着大家对iOS app的要求越来越高,以及已经以及今后可能出现的多种屏幕和分辨率的设备来说,Autoresizing Mask显得有些落伍和迟钝了。AutoLayout可以完成所有原来Autoresizing Mask能完成的工作,同时还能够胜任一些原来无法完成的任务,其中包括: * AutoLayout可以指定任意两个view的相对位置,而不需要像Autoresizing Mask那样需要两个view在直系的view hierarchy中。 * AutoLayout不必须指定相等关系的约束,它可以指定非相等约束(大于或者小于等);而Autoresizing Mask所能做的布局只能是相等条件的。 * AutoLayout可以指定约束的优先级,计算frame时将优先按照满足优先级高的条件进行计算。 总结 > Autoresizing Mask是AutoLayout的子集,任何可以用Autoresizing Mask完成的工作都可以用AutoLayout完成。AutoLayout还具备一些Autoresizing Mask不具备的优良特性,以帮助我们更方便地构建界面。 * * * ## AutoLayout基本使用方法 ### Interface Builder 最简单的使用方法是在IB中直接拖。在IB中任意一个view的File inspector下面,都有Use Autolayout的选择框(没有的同学可以考虑升级一下Xcode了=。=),钩上,然后按照平常那样拖控件就可以了。拖动控件后在左边的view hierarchy栏中会出现Constraints一向,其中就是所有的约束条件。 ![](http://ww3.sinaimg.cn/mw690/83bbf18dgw1dwxkfbus7qj.jpg) 选中某个约束条件后,在右边的Attributes inspector中可以更改约束的条件,距离值和优先度等: ![](http://ww2.sinaimg.cn/mw690/83bbf18dgw1dwxklmxul8j.jpg) 对于没有自动添加的约束,可以在IB中手动添加。选择需要添加约束的view,点击菜单的Edit->Pin里的需要的选项,或者是点击IB主视图右下角的![](http://ww3.sinaimg.cn/mw690/83bbf18dgw1dwxkrarjvmj.jpg)按钮,即可添加格外的约束条件。 可视化的添加不仅很方便直观,而且基本不会出错,是优先推荐的添加约束的方式。但是有时候只靠IB是无法完成某些约束的添加的(比如跨view hierarchy的约束),有时候IB添加的约束不能满足要求,这时就需要使用约束的API进行补充。 ### 手动使用API添加约束 #### 创建 iOS6中新加入了一个类:NSLayoutConstraint,一个形如这样的约束 * item1.attribute = multiplier ⨉ item2.attribute + constant 对应的代码为 ```objc [NSLayoutConstraint constraintWithItem:button attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:superview attribute:NSLayoutAttributeBottom multiplier:1.0 constant:-padding] ``` 这对应的约束是“button的底部(y) = superview的底部 -10”。 #### 添加 在创建约束之后,需要将其添加到作用的view上。UIView(当然NSView也一样)加入了一个新的实例方法: * -(void)addConstraint:(NSLayoutConstraint *)constraint; 用来将约束添加到view。在添加时唯一要注意的是添加的目标view要遵循以下规则: * 对于两个同层级view之间的约束关系,添加到他们的父view上 ![](http://ww1.sinaimg.cn/mw690/83bbf18dgw1dx3236wmnnj.jpg) * 对于两个不同层级view之间的约束关系,添加到他们最近的共同父view上 ![](http://ww1.sinaimg.cn/mw690/83bbf18dgw1dx3237dsbxj.jpg) * 对于有层次关系的两个view之间的约束关系,添加到层次较高的父view上 ![](http://ww4.sinaimg.cn/mw690/83bbf18dgw1dx32384ardj.jpg) #### 刷新 可以通过-setNeedsUpdateConstraints和-layoutIfNeeded两个方法来刷新约束的改变,使UIView重新布局。这和CoreGraphic的-setNeedsDisplay一套东西是一样的~ ### Visual Format Language 可视格式语言 UIKit团队这次相当有爱,估计他们自己也觉得新加约束的API名字太长了,因此他们发明了一种新的方式来描述约束条件,十分有趣。这种语言是对视觉描述的一种抽象,大概过程看起来是这样的: accept按钮在cancel按钮右侧默认间距处 ![](http://ww2.sinaimg.cn/mw690/83bbf18dgw1dx32c2yth4j.jpg) ![](http://ww4.sinaimg.cn/mw690/83bbf18dgw1dx32c3win2j.jpg) ![](http://ww3.sinaimg.cn/mw690/83bbf18dgw1dx32c47ab9j.jpg) 最后使用VFL(Visual Format Language)描述变成这样: ```objc [NSLayoutConstraint constraintsWithVisualFormat:@"[cancelButton]-[acceptButton]" options:0 metrics:nil views:viewsDictionary]; ``` 其中viewsDictionary是绑定了view的名字和对象的字典,对于这个例子可以用以下方法得到对应的字典: ```objc UIButton *cancelButton = ... UIButton *acceptButton = ... viewsDictionary = NSDictionaryOfVariableBindings(cancelButton,acceptButton); ``` 生成的字典为 `{ acceptButton = ""; cancelButton = ""; }` 当然,不嫌累的话自己手写也未尝不可。现在字典啊数组啊写法相对简化了很多了,因此也不复杂。关于Objective-C的新语法,可以参考我之前的一篇WWDC 2012笔记:[WWDC 2012 Session笔记——405 Modern Objective-C](http://www.onevcat.com/2012/06/modern-objective-c/)。 在view名字后面添加括号以及连接处的数字可以赋予表达式更多意义,以下进行一些举例: * [cancelButton(72)]-12-[acceptButton(50)] * 取消按钮宽72point,accept按钮宽50point,它们之间间距12point * [wideView(>=60@700)] * wideView宽度大于等于60point,该约束条件优先级为700(优先级最大值为1000,优先级越高的约束越先被满足) * V:[redBox][yellowBox(==redBox)] * 竖直布局,先是一个redBox,其下方紧接一个宽度等于redBox宽度的yellowBox * H:|-[Find]-[FindNext]-[FindField(>=20)]-| * 水平布局,Find距离父view左边缘默认间隔宽度,之后是FindNext距离Find间隔默认宽度;再之后是宽度不小于20的FindField,它和FindNext以及父view右边缘的间距都是默认宽度。(竖线'|‘ 表示superview的边缘) * * * ## 容易出现的错误 因为涉及约束问题,因此约束模型下的所有可能出现的问题这里都会出现,具体来说包括两种: * Ambiguous Layout 布局不能确定 * Unsatisfiable Constraints 无法满足约束 布局不能确定指的是给出的约束条件无法唯一确定一种布局,也即约束条件不足,无法得到唯一的布局结果。这种情况一般添加一些必要的约束或者调整优先级可以解决。无法满足约束的问题来源是有约束条件互相冲突,因此无法同时满足,需要删掉一些约束。两种错误在出现时均会导致布局的不稳定和错误,Ambiguous可以被容忍并且选择一种可行布局呈现在UI上,Unsatisfiable的话会无法得到UI布局并报错。 对于不能确定的布局,可以通过调试时暂停程序,在debugger中输入 * po [[UIWindow keyWindow] _autolayoutTrace] 来检查是否存在Ambiguous Layout以及存在的位置,来帮助添加条件。另外还有一些检查方法,来查看view的约束和约束状态: * [view constraintsAffectingLayoutForOrientation/Axis: NSLayoutConstraintOrientationHorizontal/Vertical] * [view hasAmbiguousLayout] * [view exerciseAmbiguityInLayout] > 2013年9月1日作者更新:在iOS7和Xcode5中,IB在添加和检查Autolayout约束方面有了长足的进步。现在使用IB可以比较容易地完成复杂约束,而得益于新的IB的约束检查机制,我们也很少再会遇到遗漏或者多余约束情况的出现(有问题的约束条件将直接在IB中得到错误或者警告)。但是对于确实很奇葩的约束条件有可能使用IB无法达成,这时候还是有可能需要代码补充的。 * * * ## 布局动画 动画是UI体验的重要部分,更改布局以后的动画也非常关键。说到动画,Core Animation又立功了..自从CA出现以后,所有的动画效果都非常cheap,在auto layout中情况也和collection view里一样,很简单(可以参考[WWDC 2012 Session笔记——219 Advanced Collection Views and Building Custom Layouts](http://www.onevcat.com/2012/08/advanced-collection-view/)),只需要把layoutIfNeeded放到animation block中即可~ ```objc [UIView animateWithDuration:0.5 animations:^{ [view layoutIfNeeded]; }]; ``` 如果对block不熟悉的话,可以看看我很早时候写的一篇[block的文章](http://blog.onevcat.com/2011/11/objc-block/)。 URL: https://onevcat.com/2012/08/advanced-collection-view/index.html.md Published At: 2012-08-28 23:55:59 +0900 # WWDC 2012 Session笔记——219 Advanced Collection Views and Building Custom Layouts 这是博主的WWDC2012笔记系列中的一篇,完整的笔记列表可以参看[这里](http://onevcat.com/2012/06/%E5%BC%80%E5%8F%91%E8%80%85%E6%89%80%E9%9C%80%E8%A6%81%E7%9F%A5%E9%81%93%E7%9A%84ios6-sdk%E6%96%B0%E7%89%B9%E6%80%A7/)。如果您是首次来到本站,也许您会有兴趣通过[RSS](http://onevcat.com/atom.xml),或者通过页面左侧的邮件订阅的方式订阅本站。 在上一篇[UICollectionView的入门介绍](http://www.onevcat.com/2012/06/introducing-collection-views/)中,大概地对iOS6新加入的强大的UICollectionView进行了一些说明。在这篇博文中,将结合WWDC2012 Session219:Advanced Collection View的内容,对Collection View进行一个深入的使用探讨,并给出一个自定义的Demo。 ## UICollectionView的结构回顾 首先回顾一下Collection View的构成,我们能看到的有三个部分: * Cells * Supplementary Views 追加视图 (类似Header或者Footer) * Decoration Views 装饰视图 (用作背景展示) 而在表面下,由两个方面对UICollectionView进行支持。其中之一和tableView一样,即提供数据的UICollectionViewDataSource以及处理用户交互的UICollectionViewDelegate。另一方面,对于cell的样式和组织方式,由于collectionView比tableView要复杂得多,因此没有按照类似于tableView的style的方式来定义,而是专门使用了一个类来对collectionView的布局和行为进行描述,这就是UICollectionViewLayout。 这次的笔记将把重点放在UICollectionViewLayout上,因为这不仅是collectionView和tableView的最重要求的区别,也是整个UICollectionView的精髓所在。 如果对UICollectionView的基本构成要素和使用方法还不清楚的话,可以移步到我之前的一篇笔记:[Session笔记——205 Introducing Collection Views](http://www.onevcat.com/2012/06/introducing-collection-views/)中进行一些了解。 * * * ## UICollectionViewLayoutAttributes UICollectionViewLayoutAttributes是一个非常重要的类,先来看看property列表: * @property (nonatomic) CGRect frame * @property (nonatomic) CGPoint center * @property (nonatomic) CGSize size * @property (nonatomic) CATransform3D transform3D * @property (nonatomic) CGFloat alpha * @property (nonatomic) NSInteger zIndex * @property (nonatomic, getter=isHidden) BOOL hidden 可以看到,UICollectionViewLayoutAttributes的实例中包含了诸如边框,中心点,大小,形状,透明度,层次关系和是否隐藏等信息。和DataSource的行为十分类似,当UICollectionView在获取布局时将针对每一个indexPath的部件(包括cell,追加视图和装饰视图),向其上的UICollectionViewLayout实例询问该部件的布局信息(在这个层面上说的话,实现一个UICollectionViewLayout的时候,其实很像是zap一个delegate,之后的例子中会很明显地看出),这个布局信息,就以UICollectionViewLayoutAttributes的实例的方式给出。 * * * ## 自定义的UICollectionViewLayout UICollectionViewLayout的功能为向UICollectionView提供布局信息,不仅包括cell的布局信息,也包括追加视图和装饰视图的布局信息。实现一个自定义layout的常规做法是继承UICollectionViewLayout类,然后重载下列方法: * -(CGSize)collectionViewContentSize * 返回collectionView的内容的尺寸 * -(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect * 返回rect中的所有的元素的布局属性 * 返回的是包含UICollectionViewLayoutAttributes的NSArray * UICollectionViewLayoutAttributes可以是cell,追加视图或装饰视图的信息,通过不同的UICollectionViewLayoutAttributes初始化方法可以得到不同类型的UICollectionViewLayoutAttributes: * layoutAttributesForCellWithIndexPath: * layoutAttributesForSupplementaryViewOfKind:withIndexPath: * layoutAttributesForDecorationViewOfKind:withIndexPath: * -(UICollectionViewLayoutAttributes _)layoutAttributesForItemAtIndexPath:(NSIndexPath _)indexPath * 返回对应于indexPath的位置的cell的布局属性 * -(UICollectionViewLayoutAttributes _)layoutAttributesForSupplementaryViewOfKind:(NSString _)kind atIndexPath:(NSIndexPath *)indexPath * 返回对应于indexPath的位置的追加视图的布局属性,如果没有追加视图可不重载 * -(UICollectionViewLayoutAttributes * )layoutAttributesForDecorationViewOfKind:(NSString_)decorationViewKind atIndexPath:(NSIndexPath _)indexPath * 返回对应于indexPath的位置的装饰视图的布局属性,如果没有装饰视图可不重载 * -(BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds * 当边界发生改变时,是否应该刷新布局。如果YES则在边界变化(一般是scroll到其他地方)时,将重新计算需要的布局信息。 另外需要了解的是,在初始化一个UICollectionViewLayout实例后,会有一系列准备方法被自动调用,以保证layout实例的正确。 首先,-(void)prepareLayout将被调用,默认下该方法什么没做,但是在自己的子类实现中,一般在该方法中设定一些必要的layout的结构和初始需要的参数等。 之后,-(CGSize) collectionViewContentSize将被调用,以确定collection应该占据的尺寸。注意这里的尺寸不是指可视部分的尺寸,而应该是所有内容所占的尺寸。collectionView的本质是一个scrollView,因此需要这个尺寸来配置滚动行为。 接下来-(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect被调用,这个没什么值得多说的。初始的layout的外观将由该方法返回的UICollectionViewLayoutAttributes来决定。 另外,在需要更新layout时,需要给当前layout发送 -invalidateLayout,该消息会立即返回,并且预约在下一个loop的时候刷新当前layout,这一点和UIView的setNeedsLayout方法十分类似。在-invalidateLayout后的下一个collectionView的刷新loop中,又会从prepareLayout开始,依次再调用-collectionViewContentSize和-layoutAttributesForElementsInRect来生成更新后的布局。 * * * ## Demo 说了那么多,其实还是Demo最能解决问题。Apple官方给了一个flow layout和一个circle layout的例子,都很经典,需要的同学可以从[这里下载](http://www.onevcat.com/wp-content/uploads/2012/08/advanced-collection-view-demo.zip)。 ### LineLayout——对于个别UICollectionViewLayoutAttributes的调整 先看LineLayout,它继承了UICollectionViewFlowLayout这个Apple提供的基本的布局。它主要实现了单行布局,自动对齐到网格以及当前网格cell放大三个特性。如图: [![](http://www.onevcat.com/wp-content/uploads/2012/08/QQ20120828-1-e1346145550225.png "collection-view-line-layout")](http://www.onevcat.com/wp-content/uploads/2012/08/QQ20120828-1-e1346145550225.png) 先看LineLayout的init方法: ```objc -(id)init { self = [super init]; if (self) { self.itemSize = CGSizeMake(ITEM_SIZE, ITEM_SIZE); self.scrollDirection = UICollectionViewScrollDirectionHorizontal; self.sectionInset = UIEdgeInsetsMake(200, 0.0, 200, 0.0); self.minimumLineSpacing = 50.0; } return self; } ``` self.sectionInset = UIEdgeInsetsMake(200, 0.0, 200, 0.0); 确定了缩进,此处为上方和下方各缩进200个point。由于cell的size已经定义了为200x200,因此屏幕上在缩进后就只有一排item的空间了。 self.minimumLineSpacing = 50.0; 这个定义了每个item在水平方向上的最小间距。 UICollectionViewFlowLayout是Apple为我们准备的开袋即食的现成布局,因此之前提到的几个必须重载的方法中需要我们操心的很少,即使完全不重载它们,现在也可以得到一个不错的线状一行的gridview了。而我们的LineLayout通过重载父类方法后,可以实现一些新特性,比如这里的动对齐到网格以及当前网格cell放大。 自动对齐到网格 ```objc - (CGPoint)targetContentOffsetForProposedContentOffset: (CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity { //proposedContentOffset是没有对齐到网格时本来应该停下的位置 CGFloat offsetAdjustment = MAXFLOAT; CGFloat horizontalCenter = proposedContentOffset.x + (CGRectGetWidth(self.collectionView.bounds) / 2.0); CGRect targetRect = CGRectMake(proposedContentOffset.x, 0.0, self.collectionView.bounds.size.width, self.collectionView.bounds.size.height); NSArray* array = [super layoutAttributesForElementsInRect:targetRect]; //对当前屏幕中的UICollectionViewLayoutAttributes逐个与屏幕中心进行比较,找出最接近中心的一个 for (UICollectionViewLayoutAttributes* layoutAttributes in array) { CGFloat itemHorizontalCenter = layoutAttributes.center.x; if (ABS(itemHorizontalCenter - horizontalCenter) < ABS(offsetAdjustment)) { offsetAdjustment = itemHorizontalCenter - horizontalCenter; } } return CGPointMake(proposedContentOffset.x + offsetAdjustment, proposedContentOffset.y); } ``` 当前item放大 ```objc -(NSArray *)layoutAttributesForElementsInRect:(CGRect)rect { NSArray *array = [super layoutAttributesForElementsInRect:rect]; CGRect visibleRect; visibleRect.origin = self.collectionView.contentOffset; visibleRect.size = self.collectionView.bounds.size; for (UICollectionViewLayoutAttributes* attributes in array) { if (CGRectIntersectsRect(attributes.frame, rect)) { CGFloat distance = CGRectGetMidX(visibleRect) - attributes.center.x; CGFloat normalizedDistance = distance / ACTIVE_DISTANCE; if (ABS(distance) < ACTIVE_DISTANCE) { CGFloat zoom = 1 + ZOOM_FACTOR*(1 - ABS(normalizedDistance)); attributes.transform3D = CATransform3DMakeScale(zoom, zoom, 1.0); attributes.zIndex = 1; } } } return array; } ``` 对于个别UICollectionViewLayoutAttributes进行调整,以达到满足设计需求是UICollectionView使用中的一种思路。在根据位置提供不同layout属性的时候,需要记得让-shouldInvalidateLayoutForBoundsChange:返回YES,这样当边界改变的时候,-invalidateLayout会自动被发送,才能让layout得到刷新。 ### CircleLayout——完全自定义的Layout,添加删除item,以及手势识别 CircleLayout的例子稍微复杂一些,cell分布在圆周上,点击cell的话会将其从collectionView中移出,点击空白处会加入一个cell,加入和移出都有动画效果。 这放在以前的话估计够写一阵子了,而得益于UICollectionView,基本只需要100来行代码就可以搞定这一切,非常cheap。通过CircleLayout的实现,可以完整地看到自定义的layout的编写流程,非常具有学习和借鉴的意义。 ![CircleLayout](http://www.onevcat.com/wp-content/uploads/2012/06/QQ20120630-5.png) 首先,布局准备中定义了一些之后计算所需要用到的参数。 ```objc -(void)prepareLayout { //和init相似,必须call super的prepareLayout以保证初始化正确 [super prepareLayout]; CGSize size = self.collectionView.frame.size; _cellCount = [[self collectionView] numberOfItemsInSection:0]; _center = CGPointMake(size.width / 2.0, size.height / 2.0); _radius = MIN(size.width, size.height) / 2.5; } ``` 其实对于一个size不变的collectionView来说,除了_cellCount之外的中心和半径的定义也可以扔到init里去做,但是显然在prepareLayout里做的话具有更大的灵活性。因为每次重新给出layout时都会调用prepareLayout,这样在以后如果有collectionView大小变化的需求时也可以自动适应变化。 然后,按照UICollectionViewLayout子类的要求,重载了所需要的方法: ```objc //整个collectionView的内容大小就是collectionView的大小(没有滚动) -(CGSize)collectionViewContentSize { return [self collectionView].frame.size; } //通过所在的indexPath确定位置。 - (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)path { UICollectionViewLayoutAttributes* attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:path]; //生成空白的attributes对象,其中只记录了类型是cell以及对应的位置是indexPath //配置attributes到圆周上 attributes.size = CGSizeMake(ITEM_SIZE, ITEM_SIZE); attributes.center = CGPointMake(_center.x + _radius * cosf(2 * path.item * M_PI / _cellCount), _center.y + _radius * sinf(2 * path.item * M_PI / _cellCount)); return attributes; } //用来在一开始给出一套UICollectionViewLayoutAttributes -(NSArray*)layoutAttributesForElementsInRect:(CGRect)rect { NSMutableArray* attributes = [NSMutableArray array]; for (NSInteger i=0 ; i < self.cellCount; i++) { //这里利用了-layoutAttributesForItemAtIndexPath:来获取attributes NSIndexPath* indexPath = [NSIndexPath indexPathForItem:i inSection:0]; [attributes addObject:[self layoutAttributesForItemAtIndexPath:indexPath]]; } return attributes; } ``` 现在已经得到了一个circle layout。为了实现cell的添加和删除,需要为collectionView加上手势识别,这个很简单,在ViewController中: ```objc UITapGestureRecognizer* tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapGesture:)]; [self.collectionView addGestureRecognizer:tapRecognizer]; ``` 对应的处理方法handleTapGesture:为 ```objc - (void)handleTapGesture:(UITapGestureRecognizer *)sender { if (sender.state == UIGestureRecognizerStateEnded) { CGPoint initialPinchPoint = [sender locationInView:self.collectionView]; NSIndexPath* tappedCellPath = [self.collectionView indexPathForItemAtPoint:initialPinchPoint]; //获取点击处的cell的indexPath if (tappedCellPath!=nil) { //点击处没有cell self.cellCount = self.cellCount - 1; [self.collectionView performBatchUpdates:^{ [self.collectionView deleteItemsAtIndexPaths:[NSArray arrayWithObject:tappedCellPath]]; } completion:nil]; } else { self.cellCount = self.cellCount + 1; [self.collectionView performBatchUpdates:^{ [self.collectionView insertItemsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForItem:0 inSection:0]]]; } completion:nil]; } } } ``` performBatchUpdates:completion: 再次展示了block的强大的一面..这个方法可以用来对collectionView中的元素进行批量的插入,删除,移动等操作,同时将触发collectionView所对应的layout的对应的动画。相应的动画由layout中的下列四个方法来定义: * initialLayoutAttributesForAppearingItemAtIndexPath: * initialLayoutAttributesForAppearingDecorationElementOfKind:atIndexPath: * finalLayoutAttributesForDisappearingItemAtIndexPath: * finalLayoutAttributesForDisappearingDecorationElementOfKind:atIndexPath: > 更正:正式版中API发生了变化(而且不止一次变化 > initialLayoutAttributesForInsertedItemAtIndexPath:在正式版中已经被废除。现在在insert或者delete之前,prepareForCollectionViewUpdates:会被调用,可以使用这个方法来完成添加/删除的布局。关于更多这方面的内容以及新的示例demo,可以参看[这篇博文](http://markpospesel.wordpress.com/2012/10/25/fixing-circlelayout/)(需要翻墙)。新的示例demo在Github上也有,[链接](https://github.com/mpospese/CircleLayout) 在CircleLayout中,实现了cell的动画。 ```objc //插入前,cell在圆心位置,全透明 - (UICollectionViewLayoutAttributes *)initialLayoutAttributesForInsertedItemAtIndexPath:(NSIndexPath *)itemIndexPath { UICollectionViewLayoutAttributes* attributes = [self layoutAttributesForItemAtIndexPath:itemIndexPath]; attributes.alpha = 0.0; attributes.center = CGPointMake(_center.x, _center.y); return attributes; } //删除时,cell在圆心位置,全透明,且只有原来的1/10大 - (UICollectionViewLayoutAttributes *)finalLayoutAttributesForDeletedItemAtIndexPath:(NSIndexPath *)itemIndexPath { UICollectionViewLayoutAttributes* attributes = [self layoutAttributesForItemAtIndexPath:itemIndexPath]; attributes.alpha = 0.0; attributes.center = CGPointMake(_center.x, _center.y); attributes.transform3D = CATransform3DMakeScale(0.1, 0.1, 1.0); return attributes; } ``` 在插入或删除时,将分别以插入前和删除后的attributes和普通状态下的attributes为基准,进行UIView的动画过渡。而这一切并没有很多代码要写,几乎是free的,感谢苹果… * * * ## 布局之间的切换 有时候可能需要不同的布局,Apple也提供了方便的布局间切换的方法。直接更改collectionView的collectionViewLayout属性可以立即切换布局。而如果通过setCollectionViewLayout:animated:,则可以在切换布局的同时,使用动画来过渡。对于每一个cell,都将有对应的UIView动画进行对应,又是一个接近free的特性。 对于我自己来说,UICollectionView可能是我转向iOS 6 SDK的最具有吸引力的特性之一,因为UIKit团队的努力和CoreAnimation的成熟,使得创建一个漂亮优雅的UI变的越来越简单了。可以断言说UICollectionView在今后的iOS开发中,一定会成为和UITableView一样的强大和最常用的类之一。在iOS 6还未正式上市前,先对其特性进行一些学习,以期尽快能使用新特性来简化开发流程,可以说是非常值得的。 URL: https://onevcat.com/2012/08/not-a-studen/index.html.md Published At: 2012-08-10 23:53:51 +0900 # 学生时代的终焉 距离研究生毕业,已经过去一个月了。在毕业季的离愁和从学生身份的转变的怅惘渐渐淡去时,大概是时候对我的整个的大学生涯做一个小结了。很多事情的记忆已经在时间的冲蚀中变得模糊了,但是也有一些事情比其他的琐事更深地印刻在了记忆之中,也许简单的梳理和回忆,无法把这七年刻画的细致入微,但是作为轮廓的勾勒和回顾,却已然绰绰有余了。(写完之后终于发现又被写成标准流水账了,这个从小学开始的写作文的毛病在不写博客两个月之后再次复发了哎…) ### 悲剧的开始 大学本科加上研究生,七年时间,说长不长,说短却也不短。在经历了中学时代的辉煌之后,我终于还是在大学里找到了自己真正的位置。如果大学不在清华,如果不在清华里可谓最变态的电子系,如果不在清华里可谓最变态的电子系中最变态的班的话,也许我的大学生活会完全不一样吧。 这里有在央视热门节目露过脸的高考状元, 这里有“百度一下”能检出几万条结果的全省第一, 这里有奥数好几块金牌的超级达人, 这里有中学带了五个社团还能考到第一的变态, 这里有之后叱咤清华被老师们认可为几十年难遇的特奖得主和学生会主席。 当然,这里还有我,一个基本是以倒数一二的成绩分到这个班的可怜的差生。虽然说来之前已经被打过预防针,但是我真的不知道,这七年,会这么开始。 当每个人都在叫嚷着自己不会啊考试要挂了啊的时候,我还暗自得意过自己貌似这些题都还算能做一做。最后结果出来发现自己在为自己的80分沾沾自喜的时候,周围基本都是一片95+,那种"你是一个傻逼"的打击对于那时的我来说着实不轻。对于从一个教育相对落后的地区出来的学生来说,这可能是很正常很普遍的现象,但是从遥遥领先到远远落后,这样的落差,一时间确实难以接受。不过几次下来,麻木之后,也就对自己所处的位置心安理得了。当时为自己找到的借口是这些东西别人高中里都学过或者接触过,而自己高中时一不搞竞赛二也从来没自己私下刻苦学习过,所以一开始起点就落后很多了。学习这个东西,就像F1赛车一样,发车的时候落后,之后想赶上的话,要付出的代价可要大得多,所以得过且过了… ### 一点点改变 还好之后不久便觉得这样的想法实在很可恶…大二的挂科犹如当头棒喝。本科有整整四年的时间,这比初中的三年和高中的三年都要多,而且这四年时间中真正属于自己的时间很多很多。想要努力学习的话,不一定能在这里出类拔萃,但是有所斩获却是毋庸置疑。有句话很好,上帝给每个人都基本公平地发了时间这种万能货币,而一个人,想要怎么样的生活,想要成为怎样的人,与他把时间这种货币用来换了什么有莫大关系。大一和大二的公共课程和电子通讯方向的学习让我感到十分疲惫,我感到的是时间的浪费。而这时正好有机会在专业上进行一个细分,可以选择继续电子或者转为微电子方向。 其实这个时候的境遇和我高一结束后文理分班和校区迁移那时候很像。高一的时候的状况真是糟糕透顶,每天上学放学路上疲惫不堪,加上进入高中时相似的心理落差(当然没有从高中到大学差距这么大),让我几乎无法专于学习了。当时也正好遇上了分班和校址迁移,让我有机会得到喘息,从而有了一个新的开始。我当然希望这次也能有同样的效果,于是毫不犹豫的选择了转到新的专业去。 事实证明了这是一个明智的选择。我也许真的是那种喜欢去适应,喜欢去改变的人吧。有时候奶酪被拿走了,总会喜欢去寻找更新鲜的奶酪,也许是自己潜意识中的那只嗅嗅,在不断指引着我吧(笑)。总之,在微电的这段时光还是很快乐的。在这里虽然在绩点上也没什么了不起的突破,但是却在感情上找到了归宿。其实这么说来,到现在为止,我在自己人生的每个阶段,都很好的完成了我的任务:小学初中是快乐地生活成长,高中考到一个很好的大学,大学时找到很好的伴侣。虽然种种不顺,但是看起来却是不折不扣的成功呐… ### 继续努力 在好不容易真正习惯了清华的生活的时候,本科也快毕业了。靠着本科后两年拉回来的绩点,在本科最后踩着线随大流保了研。那时候真的没有想过继续深造和工作哪个好,也不太明白读研意味着什么。只是盲目地从众,而等我真正明白的时候,硕士都已经快毕业了。 其实硕士期间我是很幸运的,因为遇到了一位真的非常非常非常开明的导师。对比起很多其他同学的导师,我的导师几乎具备了一切优点:发钱多,派活少,不push,除了不太请我们吃饭以外,已经和忘年交的朋友差不多了。所以在硕士阶段,属于自己的时间也有很多,也正是以此为条件,我有机会仔细思考我真正想要的和喜欢的东西是什么。 首先,肯定不是研究。一看论文就犯困,一做试验就想逃,这些特质决定了必然不会是一个好的科研人员。我一直认为很多科学研究是毫无意义并且对这个世界是不会有任何改变的(特别是在中国,当然这句话肯定是错的,不过这就是我的想法)。国内的科研环境,就我所看到的号称中国最好的大学之一来说,也满满充斥着拉关系跑经费,报批各种各样的项目,面临无穷无尽的审计,大家真正忙的一切,都和科研本身没什么关系,而最后往往就靠几个真正还不那么讨厌科研的学生的寒碜的所谓“成果”来应付课题最终检查。在这方面,我完全没有入门,也并不是太了解真正的科研的感觉应该是怎么样的。但是在这里,我体会到的是一种低效和浑噩,从真心里,我不喜欢这样的生活。 为了尽量不在科研上花过多的时间,我选了一个非常奇葩的研究方向,做着前人从未做过的试验。因为课题很新,和研究组里所有人的课题都基本没有交集,导师也对新的方法表示闻所未闻。于是我几乎失去了所有的来自研究组的指导和支持,独自一人在黑暗中摸索。但是好处是,我做的试验没有其他人做过,因此我的结果也就没有人能够给出权威的评判,因为在这个领域其实我就是权威。那种感觉,真心不错。 但是这样做的目的,其实是解放自己的时间。不再被无数的试验束缚的同时,我开始尝试走向高效,去做一些自己喜欢做的事情。其实,每个人在青春的时候都应该有那么一段奋斗的历史,这样才不至于在老去后回首时发现一片苍白。拥有狂热的兴趣爱好也罢,全身心地投入某件事情也罢,都会在十几年甚至几十年后成为一段非常美好的回忆。努力过做过,在这个世界上留下一些什么东西,能够时不时被人想起,有时候,存在感和被认同感,还是十分重要的。 ### 新的开始 找工作的那段时间还是相当郁闷的。虽说好歹算是名校毕业,但是一样四处碰壁。首先我很个性的做了一份比较非主流的简历,这直接导致了所有的正统企业都把我拒之门外(其实应该是我把他们拒之门外吧,233);接着,投出去的一些简历直接没有了回音,估计是没见过清华的学生去投他们,觉得是在调戏?但是我真的没有乱投简历啊,给了简历的企业都是我真的想去的地方啊;最后,给了笔试的企业的各种笔试基本都没通过,各种请你写出XX算法,写你妹啊我木有学过啊有木有..而且在我做了这些项目以后我就觉得算法什么的就是扯淡啊有木有,你招的是码农啊,又不是计算机科学家,你要的那些算法google一下不就完事儿了。 于是,我好像是被所有的中国企业抛弃了,或者好听一点的话,是中国这些企业都和自己相性不符吧,真的,没有任何一家中国企业愿意给我offer,无奈最后只能去日本了。 然后,顺利的毕业了。不过毕业前的那段日子还是相当难熬的,每天白天在实验室待一整天,做试验整理数据攒论文不亦乐乎,晚上到兼职的地方作项目有时候deadline前忙到夜里两三点的时候也有,周末两天为了之后的工作还要到北语上课。如此高强度的无休生活如果可能一个月两个月的话还好,再长的话可能真的要崩溃掉。幸运的是我没感到什么太大压力就撑下来了,可能以后遇到什么时间上的压力的话,想想这段狗一般的经历就能平静许多了吧… 离开清华的一个月里,都在北语混迹。每天过着标准的学生生活,上课,食堂,宿舍。只不过上课由模电课、数电课、工艺课变成了日语课、日语课、日语课,食堂由麻辣烫、煎鸡饭、铁板烧变成了超市鸡、超市鸡、超市鸡,宿舍从逛论坛、打魔兽、侃大山变成了写代码、写代码、写代码。不过还好这种生活也就还有两周就结束了,再之后是回家,好好待上一个月。这应该是我最后一个这么长的假期了吧,之后的新的生活,应该会很忙碌。 大学的生活,很值得回忆。不管以后怎样NB的我们,可能永远都忘不了这段SB的日子。如果用一句话总结这七年,那不妨抄一句游戏台词:虽有遗憾,却无后悔。 流水账结束。今后,祝自己天天开心,愿自己继续加油。 URL: https://onevcat.com/2012/07/pomodoro-do/index.html.md Published At: 2012-07-25 23:50:56 +0900 # Pomodoro Do - 拖延症患者的福音 ## 已下架 由于完全是自己完成的应用啦,所以详细介绍就写的偏向广告一点吧~欢迎大家购买使用,并给我提意见哦~我会不断完善这款app的。 * App Store地址:[https://itunes.apple.com/app/id791903475?ls=1&mt=8](https://itunes.apple.com/app/id791903475?ls=1&mt=8) * Pomodoro Do官方主页:[http://pomo.onevcat.com/](http://pomo.onevcat.com/) * i果儿评测:[Pomodoro Do——拖延症什么的,我才不怕呢](http://www.iguor.com/4050.html) * PunApp:[用一顆番茄來改變你的人生 – Pomodoro Do 評測](http://punapp.com/review/article/7437) ## 什么是Pomodoro Do 一款新鲜上架的番茄工作法辅助应用,功能上十分齐全,从自定义时间到历史统计和推送都很完整。这款应用加入了成就系统和箴言系统的创新,让用户自然地养成使用番茄工作法的习惯,从而提高效率。有拖延症和想提高效率的读者可以试试看这款应用。 拖延症是现在颇为流行的一个说法,人们在习惯网络带来的便利同时也容易被网络分散太多的时间,相信大家多少都有点拖延症患者的感觉,每天工作开始总要那么一些时间浏览一下网页、收收邮件才能进入学习或者工作状态,或者工作到一半不知不觉地就开始刷刷微博,时间不知不觉就过去了,但是手上的事儿却远没做完。这款应用对于那些希望告别拖延症的用户来说正是瞌睡送来的枕头,只要您有一点决心,就可以显著改善拖延症的状况。 我们先来简单了解一下番茄工作法:所谓番茄工作法,就是设定一个任务,将番茄时间设为25分钟,专注工作,中途不允许做任何与该任务无关的事,直到番茄时钟响起,短暂休息一下(5分钟)完成一个番茄时段,每4个番茄时段有一个长休息。通过番茄工作法可以有效提升集中力和注意力。不太了解的朋友对于番茄工作法的详细介绍可以[百度一下](http://baike.baidu.com/view/5259318.htm),E文好的朋友可以直接看看[番茄工作法的官方网站](http://www.pomodorotechnique.com/)哦。 ## 详细介绍< ### 主界面 应用打开后就直接是主界面。主界面十分简洁美观,深灰色的主题体现了稳重大方。上方为番茄计时框,下方为今天的历史记录,计时框里的小喇叭可以快速开关声音。 ![](http://i.minus.com/jbbVzDlANOqkXZ_e.jpg) ### 新建 一个番茄任务。点击右上角的“+”号,应用切换到了任务设定的界面,可以设定任务时长,任务名称,选择向社交网络共享,文本框里会随机出现励志箴言,也可以自己进行编辑。设置完成以后,一个番茄时间就开始计时了。 ![](http://i.minus.com/j3bzTZSKFOR1h_e.jpg) ### 推送提醒 将手机放在一边专注于预设的任务,待一个番茄时间结束时,PomodoroDo会将手机从睡眠中摇醒,用推送信息告诉你,你完成了一个番茄。 ![](http://i.minus.com/jAcQCqnYvQEYA_e.jpg) ### 打断和箴言 在做一个番茄任务的时候,有事情打扰,我们点击右上角的“X”,可以根据具体情况选择暂停或者打断。如果可以很快回到番茄任务来,选择暂停,有15秒的时间处理问题,如果是费时的紧急事件,就只能选择打断,放弃这个任务了。未能完成的任务也会出现在主界面的历史记录里。回顾一天的番茄任务完成情况,会对这一天的工作情况有个直观的了解,随附的励志箴言也让人充满斗志。 ![](http://i.minus.com/j9tugJa3rhgue_e.jpg) ### 统计功能 回到主界面,我们试着按一下左上角的按钮,出现了菜单界面。其中的任务和历史分别根据任务内容和日期对您做过的番茄任务进行统计。这是这款番茄工作法应用的一大亮点,可以方便用户适时对自己一段时间以来的任务情况做一个总结和调整。 ![](http://i.minus.com/jqwcnJvMJZ3XW_e.jpg) ### 成就系统 我们看到,菜单中还有成就系统,在时间管理应用中添加成就系统无疑提高了应用的趣味性,在努力实现新成就的同时,您又向高效管理自己的时间迈近了一步。 ![](http://i.minus.com/jbtP7LC9fQbimQ_e.jpg) ### 灵活设置 设定菜单中可以设置默认番茄时间、音效开关和社交网络管理。另外比较值得注意的是可以选择箴言的语言,目前该应用支持中文、英文和日文。对于正在学习英语和日语的用户来说,换换箴言的语言类型会有意外的收获呢。 ![](http://i.minus.com/jbu2R995HUwR0_e.jpg) ## 总结 AppStore上也有不少番茄工作法的应用。相比起来,Pomodoro Do的话,在使用习惯上可能更适合大家一些,另外功能上也非常齐全。另外相比起来,这款番茄计时器的界面简洁细腻一些,操作流畅,界面可滑动切换和操作,是一款精致的应用。利用番茄工作法,减少对时间的焦虑,使用户快速进入注意力高度集中的状态,利用适当的工作-休息周期,提高一天的工作和学习效率。最后抄一下AppStore中自己总结的应用特点: 1. 不拘泥于番茄工作法推荐的25分钟-5分钟,灵活的番茄和休息时间,根据个人特点指定效率计划; 2. 加入后台提醒,开始番茄后立即开始工作。达到预定时间后将提醒您进行下一阶段; 3. 社交网络分享,自我激励,让朋友帮助您提高效率,让相关人士了解您的工作进展和专注; 4. 每个番茄都对应一句箴言,内心平静方可成就大业; 5. 丰富的成就系统,照目标提高效率,帮助您建立良好习惯; 6. 完整的番茄记录,帮助您总结和回顾您的效率情况。 对于有提高自己效率和减少时间浪费的读者,可以推荐尝试一下~简单的任务设计和使用方法,在iPhone上实践番茄工作法,就能快速提高自己的工作和学习效率。 --- * App Store地址:[https://itunes.apple.com/app/id791903475?ls=1&mt=8](https://itunes.apple.com/app/id791903475?ls=1&mt=8) * Pomodoro Do官方主页:[http://pomo.onevcat.com/](http://pomo.onevcat.com/) * i果儿评测:[Pomodoro Do——拖延症什么的,我才不怕呢](http://www.iguor.com/4050.html) * PunApp:[用一顆番茄來改變你的人生 – Pomodoro Do 評測](http://punapp.com/review/article/7437) URL: https://onevcat.com/2012/06/introducing-collection-views/index.html.md Published At: 2012-06-30 23:41:15 +0900 # WWDC 2012 Session笔记——205 Introducing Collection Views 这是博主的WWDC2012笔记系列中的一篇,完整的笔记列表可以参看[这里](http://onevcat.com/2012/06/%E5%BC%80%E5%8F%91%E8%80%85%E6%89%80%E9%9C%80%E8%A6%81%E7%9F%A5%E9%81%93%E7%9A%84ios6-sdk%E6%96%B0%E7%89%B9%E6%80%A7/)。如果您是首次来到本站,也许您会有兴趣通过[RSS](http://onevcat.com/atom.xml),或者通过页面左侧的邮件订阅的方式订阅本站。 在之前的[iOS6 SDK新特性前瞻](http://www.onevcat.com/2012/06/%E5%BC%80%E5%8F%91%E8%80%85%E6%89%80%E9%9C%80%E8%A6%81%E7%9F%A5%E9%81%93%E7%9A%84ios6-sdk%E6%96%B0%E7%89%B9%E6%80%A7/)中我曾经提到过UICollectionView,当时只把CollectionView当作是一个现在已有的开源GridView,仔细研究了下WWDC2012相关的Session后发现并不是那么简单。Apple这次真的给广大开发者带来了一个非常powerful的view,其强大程度可以说远超UITableView。接下来的这篇笔记将对应Session 205,作为使用UICollectionView的入门,之后还将完成一篇关于深入使用UICollectionView以及相应的Layout的笔记。 废话到此,正式开始。 ### 什么是UICollectionView UICollectionView是一种新的数据展示方式,简单来说可以把他理解成多列的UITableView(请一定注意这是UICollectionView的最最简单的形式)。如果你用过iBooks的话,可能你还对书架布局有一定印象:一个虚拟书架上放着你下载和购买的各类图书,整齐排列。其实这就是一个UICollectionView的表现形式,或者iPad的iOS6中的原生时钟应用中的各个时钟,也是UICollectionView的最简单的一个布局,如图: ![iOS6 iPad版时钟应用](http://www.onevcat.com/wp-content/uploads/2012/06/QQ20120630-1.png) 最简单的UICollectionView就是一个GridView,可以以多列的方式将数据进行展示。标准的UICollectionView包含三个部分,它们都是UIView的子类: * Cells 用于展示内容的主体,对于不同的cell可以指定不同尺寸和不同的内容,这个稍后再说 * Supplementary Views 追加视图 如果你对UITableView比较熟悉的话,可以理解为每个Section的Header或者Footer,用来标记每个section的view * Decoration Views 装饰视图 这是每个section的背景,比如iBooks中的书架就是这个 ![](http://www.onevcat.com/wp-content/uploads/2012/06/QQ20120630-2.png) ![](http://www.onevcat.com/wp-content/uploads/2012/06/QQ20120630-3.png) 不管一个UICollectionView的布局如何变化,这三个部件都是存在的。再次说明,复杂的UICollectionView绝不止上面的几幅图,关于较复杂的布局和相应的特性,我会在本文稍后和[下一篇笔记](http://www.onevcat.com/2012/08/advanced-collection-view/")中进行一些深入。 * * * ### 实现一个简单的UICollectionView 先从最简单的开始,UITableView是iOS开发中的非常非常非常重要的一个类,相信如果你是开发者的话应该是对这个类非常熟悉了。实现一个UICollectionView和实现一个UITableView基本没有什么大区别,它们都同样是datasource和delegate设计模式的:datasource为view提供数据源,告诉view要显示些什么东西以及如何显示它们,delegate提供一些样式的小细节以及用户交互的相应。因此在本节里会大量对比collection view和table view来进行说明,如果您还不太熟悉table view的话,也是个对照着复习的好机会。 #### UICollectionViewDataSource * section的数量 -numberOfSectionsInCollection: * 某个section里有多少个item -collectionView:numberOfItemsInSection: * 对于某个位置应该显示什么样的cell -collectionView:cellForItemAtIndexPath: 实现以上三个委托方法,基本上就可以保证CollectionView工作正常了。当然,还有提供Supplementary View的方法 * collectionView:viewForSupplementaryElementOfKind:atIndexPath: 对于Decoration Views,提供方法并不在UICollectionViewDataSource中,而是直接UICollectionViewLayout类中的(因为它仅仅是视图相关,而与数据无关),放到稍后再说。 #### 关于重用 为了得到高效的View,对于cell的重用是必须的,避免了不断生成和销毁对象的操作,这与在UITableView中的情况是一致的。但值得注意的时,在UICollectionView中,不仅cell可以重用,Supplementary View和Decoration View也是可以并且应当被重用的。在iOS5中,Apple对UITableView的重用做了简化,以往要写类似这样的代码: ```objc UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"MY_CELL_ID"]; if (!cell) { //如果没有可重用的cell,那么生成一个 cell = [[UITableViewCell alloc] init]; } //配置cell,blablabla return cell ``` 而如果我们在TableView向数据源请求数据之前使用`-registerNib:forCellReuseIdentifier:`方法为@“MY_CELL_ID"注册过nib的话,就可以省下每次判断并初始化cell的代码,要是在重用队列里没有可用的cell的话,runtime将自动帮我们生成并初始化一个可用的cell。 这个特性很受欢迎,因此在UICollectionView中Apple继承使用了这个特性,并且把其进行了一些扩展。使用以下方法进行注册: * -registerClass:forCellWithReuseIdentifier: * -registerClass:forSupplementaryViewOfKind:withReuseIdentifier: * -registerNib:forCellWithReuseIdentifier: * -registerNib:forSupplementaryViewOfKind:withReuseIdentifier: 相比UITableView有两个主要变化:一是加入了对某个Class的注册,这样即使不用提供nib而是用代码生成的view也可以被接受为cell了;二是不仅只是cell,Supplementary View也可以用注册的方法绑定初始化了。在对collection view的重用ID注册后,就可以像UITableView那样简单的写cell配置了: ```objc - (UICollectionView*)collectionView:(UICollectionView*)cv cellForItemAtIndexPath:(NSIndexPath*)indexPath { MyCell *cell = [cv dequeueReusableCellWithReuseIdentifier:@”MY_CELL_ID”]; // Configure the cell's content cell.imageView.image = ... return cell; } ``` 需要吐槽的是,对collection view,取重用队列的方法的名字和UITableView里面不一样了,在Identifier前面多加了Reuse五个字母,语义上要比以前清晰,命名规则也比以前严谨了..不知道Apple会不会为了追求完美而把UITableView中的命名不那么好的方法deprecate掉。 #### UICollectionViewDelegate 数据无关的view的外形啊,用户交互啊什么的,由UICollectionViewDelegate来负责: * cell的高亮 * cell的选中状态 * 可以支持长按后的菜单 关于用户交互,UICollectionView也做了改进。每个cell现在有独立的高亮事件和选中事件的delegate,用户点击cell的时候,现在会按照以下流程向delegate进行询问: 1. -collectionView:shouldHighlightItemAtIndexPath: 是否应该高亮? 2. -collectionView:didHighlightItemAtIndexPath: 如果1回答为是,那么高亮 3. -collectionView:shouldSelectItemAtIndexPath: 无论1结果如何,都询问是否可以被选中? 4. -collectionView:didUnhighlightItemAtIndexPath: 如果1回答为是,那么现在取消高亮 5. -collectionView:didSelectItemAtIndexPath: 如果3回答为是,那么选中cell 状态控制要比以前灵活一些,对应的高亮和选中状态分别由highlighted和selected两个属性表示。 #### 关于Cell 相对于UITableViewCell来说,UICollectionViewCell没有这么多花头。首先UICollectionViewCell不存在各式各样的默认的style,这主要是由于展示对象的性质决定的,因为UICollectionView所用来展示的对象相比UITableView来说要来得灵活,大部分情况下更偏向于图像而非文字,因此需求将会千奇百怪。因此SDK提供给我们的默认的UICollectionViewCell结构上相对比较简单,由下至上: * 首先是cell本身作为容器view * 然后是一个大小自动适应整个cell的backgroundView,用作cell平时的背景 * 再其上是selectedBackgroundView,是cell被选中时的背景 * 最后是一个contentView,自定义内容应被加在这个view上 这次Apple给我们带来的好康是被选中cell的自动变化,所有的cell中的子view,也包括contentView中的子view,在当cell被选中时,会自动去查找view是否有被选中状态下的改变。比如在contentView里加了一个normal和selected指定了不同图片的imageView,那么选中这个cell的同时这张图片也会从normal变成selected,而不需要额外的任何代码。 #### UICollectionViewLayout 终于到UICollectionView的精髓了…这也是UICollectionView和UITableView最大的不同。UICollectionViewLayout可以说是UICollectionView的大脑和中枢,它负责了将各个cell、Supplementary View和Decoration Views进行组织,为它们设定各自的属性,包括但不限于: * 位置 * 尺寸 * 透明度 * 层级关系 * 形状 * 等等等等… * Layout决定了UICollectionView是如何显示在界面上的。在展示之前,一般需要生成合适的UICollectionViewLayout子类对象,并将其赋予CollectionView的collectionViewLayout属性。关于详细的自定义UICollectionViewLayout和一些细节,我将写在之后一篇笔记中。 Apple为我们提供了一个最简单可能也是最常用的默认layout对象,UICollectionViewFlowLayout。Flow Layout简单说是一个直线对齐的layout,最常见的Grid View形式即为一种Flow Layout配置。上面的照片架界面就是一个典型的Flow Layout。 * 首先一个重要的属性是itemSize,它定义了每一个item的大小。通过设定itemSize可以全局地改变所有cell的尺寸,如果想要对某个cell制定尺寸,可以使用-collectionView:layout:sizeForItemAtIndexPath:方法。 * 间隔 可以指定item之间的间隔和每一行之间的间隔,和size类似,有全局属性,也可以对每一个item和每一个section做出设定: * @property (CGSize) minimumInteritemSpacing * @property (CGSize) minimumLineSpacing * -collectionView:layout:minimumInteritemSpacingForSectionAtIndex: * -collectionView:layout:minimumLineSpacingForSectionAtIndex: * 滚动方向 由属性scrollDirection确定scroll view的方向,将影响Flow Layout的基本方向和由header及footer确定的section之间的宽度 * UICollectionViewScrollDirectionVertical * UICollectionViewScrollDirectionHorizontal * Header和Footer尺寸 同样地分为全局和部分。需要注意根据滚动方向不同,header和footer的高和宽中只有一个会起作用。垂直滚动时section间宽度为该尺寸的高,而水平滚动时为宽度起作用,如图。 * @property (CGSize) headerReferenceSize * @property (CGSize) footerReferenceSize * -collectionView:layout:referenceSizeForHeaderInSection: * -collectionView:layout:referenceSizeForFooterInSection: * 缩进 * @property UIEdgeInsets sectionInset; * -collectionView:layout:insetForSectionAtIndex: #### 总结 一个UICollectionView的实现包括两个必要部分:UICollectionViewDataSource和UICollectionViewLayout,和一个交互部分:UICollectionViewDelegate。而Apple给出的UICollectionViewFlowLayout已经是一个很强力的layout方案了。 * * * ### 几个自定义的Layout 但是光是UICollectionViewFlowLayout的话,显然是不够用的,而且如果单单是这样的话,就和现有的开源各类Grid View没有区别了…UICollectionView的强大之处,就在于各种layout的自定义实现,以及它们之间的切换。先看几个相当exiciting的例子吧~ 比如,堆叠布局: ![](http://www.onevcat.com/wp-content/uploads/2012/06/QQ20120630-4.png) 圆形布局: ![](http://www.onevcat.com/wp-content/uploads/2012/06/QQ20120630-5-300x290.png) 和Cover Flow布局: ![](http://www.onevcat.com/wp-content/uploads/2012/06/QQ20120630-6.png) 所有这些布局都采用了同样的数据源和委托方法,因此完全实现了model和view的解耦。但是如果仅这样,那开源社区也已经有很多相应的解决方案了。Apple的强大和开源社区不能比拟的地方在于对SDK的全局掌控,CollectionView提供了非常简单的API可以令开发者只需要一次简单调用,就可以使用CoreAnimation在不同的layout之间进行动画切换,这种切换必定将大幅增加用户体验,代价只是几十行代码就能完成的布局实现,以及简单的一句API调用,不得不说现在所有的开源代码与之相比,都是相形见拙了…不得不佩服和感谢UIKit团队的努力。 关于上面几种自定义Layout和实现细节,和其他高级CollectionView应用,将在[下一篇笔记](http://www.onevcat.com/2012/08/advanced-collection-view/ "WWDC 2012 Session笔记——219 Advanced Collection Views and Building Custom Layouts")中进行详细说明~ URL: https://onevcat.com/2012/06/modern-objective-c/index.html.md Published At: 2012-06-24 23:39:08 +0900 # WWDC 2012 Session笔记——405 Modern Objective-C 这是博主的WWDC2012笔记系列中的一篇,完整的笔记列表可以参看[这里](http://onevcat.com/2012/06/%E5%BC%80%E5%8F%91%E8%80%85%E6%89%80%E9%9C%80%E8%A6%81%E7%9F%A5%E9%81%93%E7%9A%84ios6-sdk%E6%96%B0%E7%89%B9%E6%80%A7/)。如果您是首次来到本站,也许您会有兴趣通过[RSS](http://onevcat.com/atom.xml),或者通过页面左侧的邮件订阅的方式订阅本站。 2007年的时候,Objective-C在TIOBE编程语言排名里还排在可怜的第45位,而随着移动互联网的迅速发展和iPhone,iPad等iOS设备的广阔市场前景,Objective-C也迅速崛起,走进了开发者的视野。在最近的TIOBE排名中,Objective-C达到了惊人的第4名,可以说已经成为当今世界上一门非常重要的编程语言。 而Objective-C现在主要是由Apple在负责维护了。一直以来Apple为了适应开发的发展需要,不断在完善OC以及相应的cocoa库,2.0中引入的property,随着iOS4引入的block,以及去年引入的ARC,都受到了绝大部分开发者的欢迎。几乎每年都有重大特性的加入,这不是每种语言都能做到的,更况且这些特性都为大家带来了众多的便利。 今年WWDC也不例外,OC和LLVM将得到重大的改进。本文将对这些改进进行一个简单整理和评述。 ### 方法顺序 如果有以下代码: ```objc @interface SongPlayer : NSObject - (void)playSong:(Song *)song; @end @implementation SongPlayer - (void)playSong:(Song *)song { NSError *error; [self startAudio:&error]; //... } - (void)startAudio:(NSError **)error { //... } @end ``` 在早一些的编译环境中,上面的代码会在[self startAudio:&error]处出现一个实例方法未找到的警告。由于编译顺序,编译器无法得知在-playSong:方法之后还有一个-startAudio:,因此给出警告。以前的解决方案有两种:要么将-startAudio:的实现移到-playSong:的上方,要么在类别中声明-startAudio:(顺便说一句..把-startAudio:直接拿到.h文件中是完全错误的做法,因为这个方法不应该是public的)。前者破坏.m文件的结构打乱了方法排列的顺序,导致以后维护麻烦;后者要写额外的不必要代码,使.m文件变长。其实两种方法都不是很好的解决方案。 现在不需要再头疼这个问题了,LLVM中加入了新特性,现在直接使用上面的代码,不需要做额外处理也可以避免警告了。新编译器改变了以往顺序编译的行为,改为先对方法申明进行扫描,然后在对方法具体实现进行编译。这样,在同一实现文件中,无论方法写在哪里,编译器都可以在对方法实现进行编译前知道所有方法的名称,从而避免了警告。 * * * ### 枚举改进 从Xcode4.4开始,有更好的枚举的写法了: ```objc typedef enum NSNumberFormatterStyle : NSUInteger { NSNumberFormatterNoStyle, NSNumberFormatterDecimalStyle, NSNumberFormatterCurrencyStyle, NSNumberFormatterPercentStyle, NSNumberFormatterScientificStyle, NSNumberFormatterSpellOutStyle } NSNumberFormatterStyle; ``` 在列出枚举列表的同时绑定了枚举类型为NSUInteger,相比起以前的直接枚举和先枚举再绑定类型好处是方便编译器给出更准确的警告。个人觉得对于一般开发者用处并不是特别大,因为往往并不会涉及到很复杂的枚举,用以前的枚举申明方法也不至于就搞混。所以习惯用哪种枚举方式还是接着用就好了..不过如果有条件或者还没有形成自己的习惯或者要开新工程的话,还是尝试一下这种新方法比较好,因为相对来说要严格一些。 * * * ### 属性自动绑定 人人都爱用property,这是毋庸置疑的。但是写property的时候一般都要对应写实例变量和相应的synthesis,这实在是一件让人高兴不起来的事情。Apple之前做了一些努力,至少把必须写实例变量的要求去掉了。在synthesis中等号后面的值即为实力变量名。**现在Apple更进一步,给我们带来了非常好的消息:以后不用写synthesis了!**Xcode 4.4之后,synthesis现在会对应property自动生成。 默认行为下,对于属性foo,编译器会自动在实现文件中为开发者补全synthesis,就好像你写了@synthesis foo = _foo;一样。默认的实例变量以下划线开始,然后接属性名。如果自己有写synthesis的话,将以开发者自己写的synthesis为准,比如只写了@synthesis foo;那么实例变量名就是foo。如果没有synthesis,而自己又实现了-foo以及-setFoo:的话,该property将不会对应实例变量。而如果只实现了getter或者setter中的一个的话,另外的方法会自动帮助生成(即使没有写synthesis,当然readonly的property另说)。 对于写了@dynamic的实现,所有的对应的synthesis都将不生效(即使没有写synthesis,这是runtime的必然..),可以理解为写了dynamic的话setter和getter就一定是运行时确定的。 总结一下,新的属性绑定规则如下: * 除非开发者在实现文件中提供getter或setter,否则将自动生成 * 除非开发者同时提供getter和setter,否则将自动生成实例变量 * 只要写了synthesis,无论有没有跟实例变量名,都将生成实例变量 * dynamic优先级高于synthesis * * * ### 简写 OC的语法一直被认为比较麻烦,绝大多数的消息发送都带有很长的函数名。其实这是一把双刃剑,好的方面,它使得代码相当容易阅读,因为几乎所有的方法都是以完整的英语进行描述的,而且如果遵守命名规则的话,参数类型和方法作用也一清二楚,但是不好的方面,它使得coding的时候要多不少不必要的键盘敲击,降低了开发效率。Apple意识到了这一点,在新的LLVM中引入了一系列列规则来简化OC。经过简化后,以降低部分可读性为代价,换来了开发时候稍微快速一些,可以说比较符合现在短开发周期的需要。简化后的OC代码的样子向Perl或者Python这样的快速开发语言靠近了一步,至于实际用起来好不好使,就还是仁智各异了…至少我个人对于某些简写不是特别喜欢..大概是因为看到简写的代码还没有形成直觉,总要反应一会儿才能知道这是啥… #### NSNumber 所有的[NSNumber numberWith…:]方法都可以简写了: * `[NSNumber numberWithChar:‘X’]` 简写为 `@‘X’`; * `[NSNumber numberWithInt:12345]` 简写为 `@12345` * `[NSNumber numberWithUnsignedLong:12345ul]` 简写为 `@12345ul` * `[NSNumber numberWithLongLong:12345ll]` 简写为 `@12345ll` * `[NSNumber numberWithFloat:123.45f]` 简写为 `@123.45f` * `[NSNumber numberWithDouble:123.45]` 简写为 `@123.45` * `[NSNumber numberWithBool:YES]` 简写为 `@YES` 嗯…方便很多啊~以前最讨厌的就是数字放Array里还要封装成NSNumber了…现在的话直接用@开头接数字,可以简化不少。 #### NSArray 部分NSArray方法得到了简化: * `[NSArray array]` 简写为 `@[]` * `[NSArray arrayWithObject:a]` 简写为 `@[ a ]` * `[NSArray arrayWithObjects:a, b, c, nil]` 简写为 `@[ a, b, c ]` 可以理解为@符号就表示NS对象(和NSString的@号一样),然后接了一个在很多其他语言中常见的方括号[]来表示数组。实际上在我们使用简写时,编译器会将其自动翻译补全为我们常见的代码。比如对于@[ a, b, c ],实际编译时的代码是 ```objc // compiler generates: id objects[] = { a, b, c }; NSUInteger count = sizeof(objects)/ sizeof(id); array = [NSArray arrayWithObjects:objects count:count]; ``` 需要特别注意,要是a,b,c中有nil的话,在生成NSArray时会抛出异常,而不是像[NSArray arrayWithObjects:a, b, c, nil]那样形成一个不完整的NSArray。其实这是很好的特性,避免了难以查找的bug的存在。 #### NSDictionary 既然数组都简化了,字典也没跑儿,还是和Perl啊Python啊Ruby啊很相似,意料之中的写法: * `[NSDictionary dictionary]` 简写为 `@{}` * `[NSDictionary dictionaryWithObject:o1 forKey:k1]` 简写为 `@{ k1 : o1 }` * `[NSDictionary dictionaryWithObjectsAndKeys:o1, k1, o2, k2, o3, k3, nil]` 简写为 `@{ k1 : o1, k2 : o2, k3 : o3 }` 和数组类似,当写下@{ k1 : o1, k2 : o2, k3 : o3 }时,实际的代码会是 ```objc // compiler generates: id objects[] = { o1, o2, o3 }; id keys[] = { k1, k2, k3 }; NSUInteger count = sizeof(objects) / sizeof(id); dict = [NSDictionary dictionaryWithObjects:objects forKeys:keys count:count]; ``` #### Mutable版本和静态版本 上面所生成的版本都是不可变的,想得到可变版本的话,可以对其发送-mutableCopy消息以生成一份可变的拷贝。比如 ```objc NSMutableArray *mutablePlanets = [@[ @"Mercury", @"Venus", @"Earth", @"Mars", @"Jupiter", @"Saturn", @"Uranus", @"Neptune" ] mutableCopy]; ``` 另外,对于标记为static的数组(没有static的字典..哈希和排序是在编译时完成的而且cocoa框架的key也不是常数),不能使用简写为其赋值(其实原来的传统写法也不行)。解决方法是在类方法+ (void)initialize中对static进行赋值,比如: ```objc static NSArray *thePlanets; + (void)initialize { if (self == [MyClass class]) { thePlanets = @[ @"Mercury", @"Venus", @"Earth", @"Mars", @"Jupiter", @"Saturn", @"Uranus", @"Neptune" ]; } } ``` #### 下标 其实使用这些简写的一大目的是可以使用下标来访问元素: * `[_array objectAtIndex:idx]` 简写为 `_array[idx]`; * `[_array replaceObjectAtIndex:idx withObject:newObj]` 简写为 `_array[idx] = newObj` * `[_dic objectForKey:key]` 简写为 `_dic[key]` * `[_dic setObject:object forKey:key]` 简写为 `_dic[key] = newObject` 很方便,但是一定需要注意,对于字典用的也是方括号[],而不是想象中的花括号{}。估计是想避免和代码块的花括号发生冲突吧…简写的实际工作原理其实真的就只是简单的对应的方法的简写,没有什么惊喜。 但是还是有惊喜的..那就是使用类似的一套方法,可以做到对于我们自己的类,也可以使用下标来访问。而为了达到这样的目的,我们需要实现以下方法, 对于类似数组的结构: ```objc - (elementType)objectAtIndexedSubscript:(indexType)idx; - (void)setObject:(elementType)object atIndexedSubscript:(indexType)idx; ``` 对于类似字典的结构: ```objc - (elementType)objectForKeyedSubscript:(keyType)key; - (void)setObject:(elementType)object forKeyedSubscript:(keyType)key; ``` * * * ### 固定桥接 对于ARC来说,最让人迷惑和容易出错的地方大概就是桥接的概念。由于历史原因,CF对象和NSObject对象的转换一直存在一些微妙的关系,而在引入ARC之后,这些关系变得复杂起来:主要是要明确到底应该是由CF还是由NSObject来负责内存管理的问题(关于ARC和更详细的说明,可以参看我之前写的一篇[ARC入门教程](http://www.onevcat.com/2012/06/arc-hand-by-hand/ "手把手教你ARC——ARC入门和使用"))。 在Xcode4.4之后,之前区分到底谁拥有对象的工作可以模糊化了。在代码块区间加上CF_IMPLICIT_BRIDGING_ENABLED和CF_IMPLICIT_BRIDGING_DISABLED,在之前的桥接转换就都可以简单地写作CF和NS之间的强制转换,而不再需要加上__bridging的关键字了。谁来管这块内存呢?交给系统去头疼吧~ * * * Objective-C确实是一门正在高速变化的语言。一方面,它的动态特性和small talk的烙印深深不去,另一方面,它又正积极朝着各种简单语言的语法方向靠近。各类的自动化处理虽然有些让人不放心,但是事实证明了它们工作良好,而且也确实为开发者节省了时间。尽快努力去拥抱新的变化吧~ URL: https://onevcat.com/2012/06/what-is-new-in-cocoa-touch/index.html.md Published At: 2012-06-20 23:37:08 +0900 # WWDC 2012 Session笔记——200 What is new in Cocoa Touch 这是博主的WWDC2012笔记系列中的一篇,完整的笔记列表可以参看[这里](http://onevcat.com/2012/06/%E5%BC%80%E5%8F%91%E8%80%85%E6%89%80%E9%9C%80%E8%A6%81%E7%9F%A5%E9%81%93%E7%9A%84ios6-sdk%E6%96%B0%E7%89%B9%E6%80%A7/)。如果您是首次来到本站,也许您会有兴趣通过[RSS](http://onevcat.com/atom.xml),或者通过页面下方的邮件订阅的方式订阅本站。 之前写过[一篇iOS6 SDK新内容的总览](http://www.onevcat.com/2012/06/%e5%bc%80%e5%8f%91%e8%80%85%e6%89%80%e9%9c%80%e8%a6%81%e7%9f%a5%e9%81%93%e7%9a%84ios6-sdk%e6%96%b0%e7%89%b9%e6%80%a7/),从这篇开始,将对WWDC 2012的我个人比较感兴趣的Session进行一些笔记,和之后的笔记一起应该可以形成一个比较完整的WWDC 2012 Session部分的个人记录。 因为WWDC的内容可谓众多,我自觉不太可能看完所有Session(其实也没有这个必要..),所以对于内容覆盖上可能有所欠缺。另外我本身也只是一个iOS开发初学者加业余爱好者,因此很多地方也都不明白,不理解,因此难免有各种不足。这些笔记的最大作用是给自己做一些留底,同时帮助理解Session的内容。欢迎高手善意地指出我的错误和不足..谢谢! 所有的WWDC 2012 Session的视频和讲义可以在[这里](https://developer.apple.com/videos/wwdc/2012/)找到,如果想看或者下载的话可能需要一个野生开发者账号(就是不用交99美金那种)。iOS6 Beta和Xcode4.5预览版现在已经提供开发者下载(需要家养开发者的账号,就在iOS Resource栏里),当然网上随便搜索一下不是开发者肯定也能下载到,不过如果你不太懂的话还是不建议尝试iOS6 Beta,有时间限制麻烦不说,而且可能存在各种bug,Xcode4.5预览版同理.. 作为WWDC 2012 Session部分的真正的开场环节,Session200可以说是iOS开发者必听必看的。这个Session介绍了关于Cocoa Touch的新内容,可以说是对整个iOS6 SDK的概览。 我也将这个Session作为之后可能会写的一系列的Session笔记的第一章,我觉得用Session 200作为一个开始,是再适合不过的了~ * * * ### 更多的外观自定义 从iOS5开始,Apple就逐渐致力于标准控件的可自定义化,基本包括颜色,图片等的替换。对于标准控件的行为,Apple一向控制的还是比较严格的。而开发者在做app时,最好还是遵守Apple的人机交互手册来确定控件的功能,否则可能遇到意想不到的麻烦… iOS6中Apple继续扩展了一些控件的可定义性。对于不是特别追求UI的开发团队或者实力有限的个人开发者来说这会是一个不错的消息,使用现有的资源和新加的API,可以快速开发出界面还不错的应用。 #### UIPopoverBackgroundView UIPopoverBackgroundView是iOS5引入的,可以为popover自定义背景。iOS6中新加入了询问是否以默认方式显示的方法: `+ (BOOL)wantsDefaultContentAppearance;` 返回NO的话,将以新的立体方式显示popover。 具体关于UIPopoverBackgroundView的用法,可以参考[文档](http://developer.apple.com/library/ios/#documentation/UIKit/Reference/UIPopoverBackgroundView_class/Reference/Reference.html) #### UIStepper UIStepper也是iOS5引入的新控件,在iOS5中Apple为标准控件自定义做出了相当大的努力(可以参看WWDC2011的相关内容),而对于新出生的UIStepper却没有相应的API。在iOS6里终于加上了..可以说是预料之中的。 `@property (nonatomic,retain) UIColor *tintColor;` 这个属性定义颜色。 ![定义了tint之后的stepper](http://www.onevcat.com/wp-content/uploads/2012/06/QQ20120620-2.png) ```objc - (void)setBackgroundImage:(UIImage*)image forState:(UIControlState)state; - (void)setDividerImage:(UIImage*)image forLeftSegmentState:(UIControlState)left rightSegmentState:(UIControlState)right; - (void)setIncrementImage:(UIImage *)image forState:(UIControlState)state; - (void)setDecrementImage:(UIImage *)image forState:(UIControlState)state; ``` 可以定义背景图片、分隔图片和增减按钮的图片,都很简单明了,似乎没什么好说的。 #### UISwitch 同样地,现在有一系列属性可以自定义了。 ```objc @property (nonatomic, retain) UIColor *tintColor; @property (nonatomic, retain) UIColor *thumbTintColor; @property (nonatomic, retain) UIImage *onImage; @property (nonatomic, retain) UIImage *offImage; ``` 其中thumbTintColor指的是开关的圆形滑钮的颜色。另外对于on和off时候可以自定义图片,那么很大程度上其实开关控件已经可以完全自定义,基本不再需要自己再去实现一次了.. ![](http://www.onevcat.com/wp-content/uploads/2012/06/QQ20120620-3.png) #### UINavigationBar & UITabBar 加入了阴影图片的自定义: `@property (nonatomic,retain) UIImage *shadowImage;` 这个不太清楚,没有自己实际试过。以后有机会做个小demo看看可以… #### UIBarButtonItem 现在提供设置背景图片的API: ```objc (void)setBackgroundImage:(UIImage *)bgImage forState:(UIControlState)state style:(UIBarButtonItemStyle)style barMetrics:(UIBarMetrics)barMetrics; ``` 这个非常有用…以前在自定义UINavigationBar的时候,对于BarButtonItem的背景图片的处理非常复杂,通常需要和designer进行很多配合,以保证对于不同宽度的按钮背景图都可以匹配。现在直接提供一个UIImage就OK了..初步目测是用resizableImageWithCapInsets:做的实现..很赞,可以偷不少懒~ * * * ### UIImage的API变化 随着各类Retina设备的出现,对于图片的处理方面之前的API有点力不从心..反应最大的就是图片在不同设备上的适配问题。对于iPhone4之前,是普通图片。对于iPhone4和4S,由于Retina的原因,需要将图片宽高均乘2,并命名为@2x。对于遵循这样原则的图片,cocoa touch将会自动进行适配,将4个pixel映射到1个point上去,以保证图片不被拉伸以及比例的适配。对于iPhone开发,相关的文档是比较全面的,但是对于iPad就没那么好运了。Apple对于iPad开发的支持显然做的不如对iPhone那样好,所以很多iPad开发者在对图片进行处理的时候往往不知所措——特别是在retina的new iPad出现以后,更为严重。而这次UIImage的最大变化在于自己可以对scale进行指定了~这样虽然在coding的时候变麻烦了一点,但是图片的Pixel to Point对应关系可以自己控制了,在做适配的时候可以省心不少。具体相关几个API如下: ```objc + (UIImage *)imageWithData:(NSData *)data scale:(CGFloat)scale; - (id)initWithData:(NSData *)data scale:(CGFloat)scale; + (UIImage *)imageWithCIImage:(CIImage *)ciImage scale:(CGFloat)scale orientation:(UIImageOrientation)orientation; - (id)initWithCIImage:(CIImage *)ciImage scale:(CGFloat)scale orientation:(UIImageOrientation)orientation; ``` 指定scale=2之后即可对retina屏幕适配,相对来说还是比较简单的。 * * * ### UITableView的改动 UITableView就不多介绍了,基础中的基础…在iOS5引入StoryBoard之后,由StoryBoard生成的UITableViewController中对cell进行操作时所有的cell的alloc语句都可以不写,可以为cell绑定nib等,都简化了UITableView的使用。在iOS6中,对cell的复用有一些新的方法: `- (void)registerClass:(Class)cellClass forCellReuseIdentifier:(NSString *)identifier;` 将一个类注册为某个重用ID。 `- (id)dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath;` 将指定indexPath的cell重用(若不能重用则返回nil,在StoryBoard会自动生成一个新的cell)。 另外,对UITableView的Header、Footer和Section分隔添加了一系列的property以帮助自定义,并且加入了关于Header和Footer的delegate方法。可以说对于TableView的控制更强大了… * * * ### UIRefreshControl 这个是新加的东西,Apple的抄袭之作,官方版的下拉刷新。下拉刷新自出现的第一分钟起,就成为了人民群众喜闻乐见的手势,对于这种得到大众认可的手势,Apple是一定不会放过的。 相对与现在已有的开源下拉刷新来说,功能上还不那么强大,可自定义的内容不多,而且需要iOS6以后的系统,因此短期内还难以形成主流。但是相比开源代码,减去了拖源码加库之类的麻烦,并且和系统整合很好,再加上Apple的维护,相信未来是有机会成为主流的。现在来说的话,也就只是一种实现的选择而已。 * * * ### UICollectionView 这个是iOS的UIKit的重头戏..一定意义上可以把UICollectionView理解成多列的UITableView。开源社区有很多类似的实现,基本被称作GridView,我个人比较喜欢的实现有[AQGridView](https://github.com/AlanQuatermain/AQGridView)和[GMGridView](https://github.com/gmoledina/GMGridView).开源实现基本上都是采用了和UITableView类似的方法,继承自UIScrollView,来进行多列排列。功能上来说相对都比较简单.. 而UICollectionView可以说是非常强大..强大到基本和UITableView一样了..至少使用起来和UITableView一样,用惯了UITableView的童鞋甚至可以不用看文档就能上手。一样的DataSource和Delegate,不同之处在于多了一个Layout对象对其进行排列的设定,这个稍后再讲。我们先来看Datasource和Delegate的API ```objc //DataSource -numberOfSectionsInCollectionView: -collectionView:numberOfItemsInSection: -collectionView:cellForItemAtIndexPath: //Delegate -collectionView:shouldHighlightItemAtIndexPath: -collectionView:shouldSelectItemAtIndexPath: -collectionView:didSelectItemAtIndexPath: ``` 没什么值得说的,除了名字以外,和UITableView的DataSource和Delegate没有任何不同。值得一提的是对应的UICollectionViewCell和UITableViewCell略有不同,UICollectionViewCell没有所谓的默认style,cell的子view自下而上有Background View、Selected Background View和一个Content View。开发者将自定义内容扔到Content View里即可。 需要认真看看的是Layout对象,它控制了整个UICollectionView中每个Section甚至Section中的每个cell的位置和关系。Apple提供了几种不错的Layout,足以取代现在常用的几个开源库,其中包括了像Linkedin和Pinterest的视图。可以说Apple对于利用AppStore这个平台,向第三方开发者进行学习的能力是超强的。 关于UICollectionView,在之后有两个session专门进行了讨论,我应该也会着重看一看相关内容,之后再进行补充了~ * * * ### UIViewController **这个绝对是重磅消息~**一直以来我们会在viewDidUnload方法中做一些清空outlet或者移除observer的事情。在viewDidUnload中清理observer其实并不是很安全,因此在iOS5中Apple引入了viewWillUnload,建议开发者们在viewWillUnload的时候就移除observer。而对于出现内存警告时,某些不用的view将被清理,这时候将自动意外执行viewWillUnload和viewDidUnload,很可能造成莫名其妙的crash,而这种内存警告造成的问题又因为其随机性难以debug。 于是Apple这次做了一个惊人的决定,直接在**iOS6里把viewWillUnload和viewDidUnload标注为了Deprecated**,并且不再再会调用他们。绝大部分开发者其实是对iOS3.0以来就伴随我们的viewDidUnload是有深深的感情的,但是现在需要和这个方法说再见了。对于使用iOS6 SDK的app来说不应该再去实现这两个方法,而之前在这两个方法中所做的工作需要重新考虑其更合适的位置:比如在viewWillDisappear中去移除observer,在dealloc中将outlet置为nil等。 * * * ### 状态恢复 在之前的一篇iOS6 SDK的简述中已经说过这个特性。简单讲就是对每个view现在都多了一个属性: `@property (nonatomic, copy) NSString *restorationIdentifier;` 通过在用户点击Home键时的一系列delegate里对现有的view进行编码存储后,在下一次打开文件时进行解码恢复。更多的详细内容之后也会有session进行详细说明,待更新。 * * * ### 总结 其他的很多新特性,包括社交网络,GameCenter和PassKit等也会在之后逐渐深入WWDC 2012 Session的时候进行笔记.. 作为开篇,就这样吧。 URL: https://onevcat.com/2012/06/developer-should-know-about-ios6/index.html.md Published At: 2012-06-11 23:35:11 +0900 # 开发者所需要知道的iOS6 SDK新特性 iOS6 beta和相应的SDK已经放出了,WWDC2012要进入session环节了。iOS6无疑是这届WWDC的重点,在keynote上面对消费者展示了很多新鲜的特性,而之后的seesion对于开发者来说应该是更为重要。这里先大概把iOS6里新增的开发者可能用到的特性做个简单的整理。之后我也会挑一些自己感兴趣的session做一些整理和翻译工作,也算是对自己的一种锻炼吧~相关的笔记整理如下: [Session 200 What's New in Cocoa Touch](http://www.onevcat.com/2012/06/what-is-new-in-cocoa-touch/)  Cocoa Touch新特性一览 [Session 405 Modern Objective-C](http://www.onevcat.com/2012/06/modern-objective-c/ "WWDC 2012 Session笔记——405 Modern Objective-C") 先进Objective-C [Session 205 Introducing Collection Views](http://www.onevcat.com/2012/06/introducing-collection-views/ "WWDC 2012 Session笔记——205 Introducing Collection Views") Collection View入门 [Session 219 Advanced Collection Views and Building Custom Layouts](http://www.onevcat.com/2012/08/advanced-collection-view/ "WWDC 2012 Session笔记——219 Advanced Collection Views and Building Custom Layouts") 高级Collection View和自定义布局 [Session 202,228,232 AutoLayout使用](http://www.onevcat.com/2012/09/autoayout/ "WWDC 2012 Session笔记——202, 228, 232 AutoLayout(自动布局)入门") * * * ### 地图 iOS6抛弃了一直用的google map,而使用了自家的地图服务。相应地,MapKit框架也自然变成和Apple自家的地图服务绑定了。随之而来的好处是因为都是自家的内容,所以整合和开放会更进一步,第三方app现在有机会和地图应用进行交互了。也就是说,不使用自身搭载地图信息的app现在可以打开地图应用,并且显示一些感兴趣的路线和地点,这对于路线规划和记录类的应用来说这是个好消息~ * * * ### 深度社交网络集成 iOS5的时候深度集成了Twitter,而Apple似乎从中尝到了不少甜头。现在Apple深度集成了Facebook和Sina Weibo。是的你没看错..新浪微博现在被深度集成了。对于开发这来说,特别是中国开发者来说确实是个好消息,因为如果只是想发条信息的话,不再需要进行繁琐的API申请,不再需要接受新浪恶心的应用审核,也不再需要忍受新浪程序员写出来的错误百出的SDK了。使用新的Social.framework可以很简单的从系统中拿到认证然后向社交网络发送消息,这对app的推广来说是很好的补充。 另外,Apple提供了一类新的ViewController:UIActivityViewController来询问用户的社交行为,可以看做这是Apple为统一界面和用户体验做的努力,但是估计除了Apple自家的应用意外可能很少有人会用默认界面吧..毕竟冒了会和自己的UI风格不符的危险… * * * ### Passbook和PassKit Passbook是iOS6自带的新应用,可以用来存储一些优惠券啊电影票啊登机牌啊什么的。也许Passbook这个新应用不是很被大家看好,但是我坚持认为这会是一个很有前景的方向。这是又一次使用数字系统来取代物理实体的尝试,而且从Passbook里我看到了Apple以后在NFC领域发展的空间。因为iPhone的设备很容易统一,因此也许会由Apple首先制定NFC的新游戏标准也为可知,如果成真那电子钱包和电子支付将会变成一大桶金呐… 扯远了,PassKit是新加入的,可以说是配合或者呼应Passbook存在的框架。开发者可以使用PassKit生成和读取包含一些类似优惠券电影票之类信息的特殊格式的文件,然后以加密签名的方式发送给用户。然后在使用时,出示这些凭证即可按照类似物理凭证的方式进行使用。这给了类似电影院和餐馆这样的地方很多机会,可以利用PassKit进行售票系统或者优惠系统的开发,来引入更方便的购票体系,争取更多的客户。当然,现在还只能是当做物理凭证的补充来使用,我始终相信当iPhone里加入NFC模块以后,Passbook将摇身一变,而你的iPhone便理所当然的成了电子钱包。 * * * ### Game Center 这个iOS4引入的东东一直不是很好用,iOS6里Apple终于对这个体系进行了一些升级。简单说就是完善了一些功能,主要是联机对战匹配的东西,不过我依然不看好…想当时写小熊对战的时候曾经想使用GameCenter的匹配系统来写,结果各种匹配和网络的悲剧,导致白白浪费了一个月时间。而像水果忍者这类的游戏,使用了GameCenter的对战系统,但是也面临经常性的掉线之类的问题,可以说游戏体验是大打折扣的。虽然iOS6里新加了一些特性,但是整个机制和基本没有改变,因此我依旧不看好Game Center的表现(或者说是在中国的表现,如果什么时候Apple能在中国架GameCenter的服务器的话也许会有改善)。 不过值得注意的是,Mountain Lion里也加入了GameCenter。也就是说,我们在以后可能可以用iOS设备和Mac通过GameCenter进行联机对战,或者甚至是直接用Mac和Mac进行联机对战。这对于没有自己服务器/自己不会写服务器后端/没有精力维护的个人开发者提供了很好的思路。使用GameCenter做一些简单的网络游戏并不是很难,而因为GameCenter的特性,这个成本也将会非常低。这也许会是以后的一个不错的方向~ * * * ### 提醒 自带的提醒应用得到了加强,Apple终于开放了向Reminder里添加东西和从中读取的API(Event Kit框架),以及一套标准的用户界面。这个没太多好说的,To-Do类应用已经在AppStore泛滥成灾,无非是提供了一个反向向系统添加list的功能,但是专业To-Do类应用的其他功能相信Apple现在不会以后也不想去替代。 * * * ### 新的IAP IAP(应用内购买)现在能直接从iTunes Store购买音乐了。这配合iTunes Match什么的用很不错,但是和天朝用户无关…首先是iTunes Store在天朝不开,其次是要是我朝用户什么时候具有买正版音乐的意识的话,我们这些开发者可能就要笑惨了。 * * * ### Collection Views 不得不说Apple很无耻(或者说很聪明)。"会抄袭的艺术家是好的艺术家,会剽窃的艺术家是优秀的艺术家"这句话再次得到了诠释。基本新的UICollectionView实现了[PSCollectionView](https://github.com/ptshih/PSCollectionView)的功能,简单说就是类似Pinterest那样的"瀑布流"的展示方式。当然UICollectionView更灵活一些,可以根据要求变化排列的方式。嗯..Apple还很贴心地提供了相应的VC:UICollectionViewController。 可能这一套UI展现方式在iPhone上不太好用,但是在iPad上会很不错。不少照片展示之类的app可以用到.但是其实如果只是瀑布流的话估计短时间内大家还是会用开源代码,毕竟only for iOS6的话或多或少会减少用户的.. * * * ### UI状态保存 Apple希望用户关闭app,然后下一次打开时能保持关闭时的界面状态。对于支持后台且不被kill掉的app来说是天然的。但是如果不支持后台运行或者用户自己kill掉进程的话,就没那么简单了。现在的做法是从rootViewController开始把所有的VC归档后存成NSData,然后下次启动的时候做检查如果需要恢复的话就解压出来。 每次都要在appDelegate写这些代码的话,既繁杂又不优雅,于是Apple在iOS6里帮开发者做了这件脏活累活,还不错~其实机理应该没变,就是把这些代码放到app启动里去做了.. * * * ### 隐私控制 自从之前Apple被爆隐私门以后,就对这个比较重视了。现在除了位置信息以外,联系人、日历、提醒和照片的访问也强制需求用户的允许了。对普通开发者影响不大,因为如果确实需要的话用户一定会理解,但是可能对于360之流的流氓公司会造成冲击吧,对此只要呵呵就好了..= =? * * * ### 其他一些值得一提的改动 * 整个UIView都支持NSAttributedString的格式化字符串了。特别是UITextView和UITextField~(再次抄袭开源社区,Apple你又赢了) * UIImage现在多了一个新方法,可以在生成UIImage对象时指定scale。为retina iPad开发的童鞋们解脱了.. * NSUUID,用这个类现在可以很方便的创建一个uuid了.注意这个是uuid,不要和udid弄混了…Apple承诺的udid解决方案貌似还没出现..~~~现在要拿udid的话还是用[OpenUDID](https://github.com/ylechelle/OpenUDID)吧~~~~OpenUDID已死,udid暂时无解,请乖乖使用广告vendor id;或者将一个uuid存入keychain可以在大多数情况下替代udid(onevcat与2013.09.01更新) * * * 按照以往WWDC的惯例,之后几天的开发者Session会对这些变化以及之前就存在在iOS里的一些issues和tips做解释和交流。在session公布之后我会挑选一些自己感兴趣并且可能比较实用的部分再进行整理~尽情期待~ URL: https://onevcat.com/2012/06/euro2012/index.html.md Published At: 2012-06-10 23:32:58 +0900 # EURO 2012 又是一届欧洲杯。 那一年,当各色时装铺满球场,当克林斯曼哭得像个小孩的时候,我4岁。那时的我,懵懂中认识了足球。 那一年,当小劳德鲁普挑起大梁,在绿茵上奔走书写丹麦童话的时候,我6岁。那时的我,刚懂得什么是足球。 那一年,当巴乔在玫瑰碗忧郁叹息,当塔法雷尔仰天长啸的时候,我8岁。那时的我,已经可以和小伙伴踢踢小场。 那一年,当高卢雄鸡昂首怒掏巴西,我和老爸做在电视前的地上一起喝酒看球的时候,我12岁。那时的我,已经是小学队里最出色的门将,奇拉维特和巴特兹是我的偶像。 那一年,当特雷泽盖打进金球绝杀意大利,我独自在电视前感叹人生如球场瞬息多变的时候,我14岁。那时的我,刚刚开始学会思考和冷静。 那一年,当小组赛第一场开始我就预言希腊夺冠,同学家人纷纷不信,而最终却拜服的时候,我18岁。那时的我,籍以生存的,是理性和惯性,更多的是一种纯粹和执着。 那一年,黄健翔高喊意大利万岁格罗索无敌。 那一年,和我几乎同龄的伊涅斯塔、席尔瓦,托雷斯和法布雷加斯的名字不过刚刚进入人们的视野,西班牙的黄金一代终于成型。 那一年,C罗梅西罗本集体哑火,身价越高状态越差的怪圈笼罩了所有的球星。 可以说,足球,陪伴我长大。关于足球的这些记忆,可能永远也无法抹去了。 而现在,在同样这块七千平米的草地上,又会演绎出什么样的故事呢…? URL: https://onevcat.com/2012/06/arc-hand-by-hand/index.html.md Published At: 2012-06-04 23:30:00 +0900 # 手把手教你ARC——iOS/Mac开发ARC入门和使用 ![Revolution of Objective-c](/assets/images/2012/arctitle.png) 本文部分实例取自iOS 5 Toturail一书中关于ARC的[教程和公开内容](http://www.raywenderlich.com/5677/beginning-arc-in-ios-5-part-1),仅用于技术交流和讨论。请不要将本文的部分或全部内容用于商用,谢谢合作。 欢迎转载本文,但是转载请注明本文出处:[http://www.onevcat.com/2012/06/arc-hand-by-hand/][3] [3]: http://www.onevcat.com/2012/06/arc-hand-by-hand/ 本文适合人群:对iOS开发有一定基础,熟悉iOS开发中内存管理的Reference Counting机制,对ARC机制有听闻很向往但是一直由于种种原因没有使用的童鞋。本文将从ARC机理入手对这个解放广大iOS开发者的伟大机制进行一个剖析,并逐步引导你开始使用ARC。一旦习惯ARC,你一定会被它的简洁高效所征服。 ## 写在开头 虽然距离WWDC2011和iOS 5已经快一年时间,但是很多开发者并没有利用新方法来提高自己的水平,这点在ARC的使用上非常明显(特别是国内,基本很少见到同行转向ARC)。我曾经询问过一些同行为什么不转向使用ARC,很多人的回答是担心内存管理不受自己控制..其实我个人认为这是对于ARC机制了解不足从而不自信,所导致的对新事物的恐惧。而作为最需要“追赶时髦”的职业,这样的心态将相当不利。谨以此文希望能清楚表述ARC的机理和用法,也希望能够成为现在中文入门教学缺失的补充。 * * * ## 什么是ARC Automatic Reference Counting,自动引用计数,即ARC,可以说是WWDC2011和iOS5所引入的最大的变革和最激动人心的变化。ARC是新的LLVM 3.0编译器的一项特性,使用ARC,可以说一举解决了广大iOS开发者所憎恨的手动内存管理的麻烦。 在工程中使用ARC非常简单:只需要像往常那样编写代码,只不过永远不写`retain`,`release`和`autorelease`三个关键字就好~这是ARC的基本原则。当ARC开启时,编译器将自动在代码合适的地方插入`retain`, `release`和`autorelease`,而作为开发者,完全不需要担心编译器会做错(除非开发者自己错用ARC了)。好了,ARC相当简单吧~到此为止,本教程结束。 等等…也许还有其他问题,最严重的问题是“我怎么确定让ARC来管理不会出问题?”或者“用ARC会让程序性能下降吧”。对于ARC不能正处理内存管理的质疑自从ARC出生以来就一直存在,而现在越来越多的代码转向ARC并取得了很好的效果,这证明了ARC是一套有效的简化开发复杂程度的机制,另外通过研究ARC的原理,可以知道使用ARC甚至能提高程序的效率。在接下来将详细解释ARC的运行机理并且提供了一个step-by-step的教程,将非ARC的程序转换为ARC。 * * * ## ARC工作原理 手动内存管理的机理大家应该已经非常清楚了,简单来说,只要遵循以下三点就可以在手动内存管理中避免绝大部分的麻烦: > 如果需要持有一个对象,那么对其发送retain 如果之后不再使用该对象,那么需要对其发送release(或者autorealse) 每一次对retain,alloc或者new的调用,需要对应一次release或autorealse调用 初学者可能仅仅只是知道这些规则,但是在实际使用时难免犯错。但是当开发者经常使用手动引用计数 Manual Referecen Counting(MRC)的话,这些规则将逐渐变为本能。你会发现少一个`release`的代码怎么看怎么别扭,从而减少或者杜绝内存管理的错误。可以说MRC的规则非常简单,但是同时也非常容易出错。往往很小的错误就将引起crash或者OOM之类的严重问题。 在MRC的年代里,为了避免不小心忘写`release`,Xcode提供了一个很实用的小工具来帮助可能存在的代码问题(Xcode3里默认快捷键Shift+A?不记得了),可以指出潜在的内存泄露或者过多释放。而ARC在此基础上更进一步:ARC是Objective-C编译器的特性,而不是运行时特性或者垃圾回收机制,ARC所做的只不过是在代码编译时为你自动在合适的位置插入`release`或`autorelease`,就如同之前MRC时你所做的那样。因此,至少在效率上ARC机制是不会比MRC弱的,而因为可以在最合适的地方完成引用计数的维护,以及部分优化,使用ARC甚至能比MRC取得更高的运行效率。 ### ARC机制 学习ARC很简单,在MRC时代你需要自己`retain`一个想要保持的对象,而现在不需要了。现在唯一要做的是用一个指针指向这个对象,只要指针没有被置空,对象就会一直保持在堆上。当将指针指向新值时,原来的对象会被`release`一次。这对实例变量,synthesize的变量或者局部变量都是适用的。比如 ```objc NSString *firstName = self.textField.text; ``` `firstName`现在指向NSString对象,这时这个对象(`textField`的内容字符串)将被hold住。比如用字符串@“OneV"作为例子(虽然实际上不应该用字符串举例子,因为字符串的retainCount规则其实和普通的对象不一样,大家就把它当作一个普通的对象来看吧…),这个时候`firstName`持有了@"OneV"。 ![一个strong指针](/assets/images/2012/arcpic1.png) 当然,一个对象可以拥有不止一个的持有者(这个类似MRC中的retainCount>1的情况)。在这个例子中显然`self.textField.text`也是@“OneV",那么现在有两个指针指向对象@"OneV”(被持有两次,retainCount=2,其实对NSString对象说retainCount是有问题的,不过anyway~就这个意思而已.)。 ![两个strong指向同一个对象](/assets/images/2012/arcpic2.png) 过了一会儿,也许用户在`textField`里输入了其他的东西,那么`self.textField.text`指针显然现在指向了别的字符串,比如@“onevcat",但是这时候原来的对象已然是存在的,因为还有一个指针`firstName`持有它。现在指针的指向关系是这样的: ![其中一个strong指向了另一个对象](/assets/images/2012/arcpic3.png) 只有当`firstName`也被设定了新的值,或者是超出了作用范围的空间(比如它是局部变量但是这个方法执行完了或者它是实例变量但是这个实例被销毁了),那么此时`firstName`也不再持有@“OneV",此时不再有指针指向@"OneV",在ARC下这种状况发生后对象@"OneV"即被销毁,内存释放。 ![没有strong指向@"OneV",内存释放](/assets/images/2012/arcpic4.png) 类似于`firstName`和`self.textField.text`这样的指针使用关键字`strong`进行标志,它意味着只要该指针指向某个对象,那么这个对象就不会被销毁。反过来说,ARC的一个基本规则即是,**只要某个对象被任一`strong`指针指向,那么它将不会被销毁。如果对象没有被任何strong指针指向,那么就将被销毁**。在默认情况下,所有的实例变量和局部变量都是`strong`类型的。可以说`strong`类型的指针在行为上和MRC时代`retain`的property是比较相似的。 既然有`strong`,那肯定有`weak`咯~`weak`类型的指针也可以指向对象,但是并不会持有该对象。比如: ```objc __weak NSString *weakName = self.textField.text ``` 得到的指向关系是: ![一个strong和一个weak指向同一个对象](/assets/images/2012/arcpic5.png) 这里声明了一个`weak`的指针`weakName`,它并不持有@“onevcat"。如果`self.textField.text`的内容发生改变的话,根据之前提到的**"只要某个对象被任一strong指针指向,那么它将不会被销毁。如果对象没有被任何strong指针指向,那么就将被销毁”**原则,此时指向@“onevcat"的指针中没有`strong`类型的指针,@"onevcat"将被销毁。同时,在ARC机制作用下,所有指向这个对象的`weak`指针将被置为`nil`。这个特性相当有用,相信无数的开发者都曾经被指针指向已释放对象所造成的EXC_BAD_ACCESS困扰过,使用ARC以后,不论是`strong`还是`weak`类型的指针,都不再会指向一个dealloced的对象,从**根源上解决了意外释放导致的crash**。 ![strong指向另外对象,内存释放,weak自动置nil](/assets/images/2012/arcpic6.png) 不过在大部分情况下,`weak`类型的指针可能并不会很常用。比较常见的用法是在两个对象间存在包含关系时:对象1有一个`strong`指针指向对象2,并持有它,而对象2中只有一个`weak`指针指回对象1,从而避免了循环持有。一个常见的例子就是oc中常见的delegate设计模式,viewController中有一个`strong`指针指向它所负责管理的UITableView,而UITableView中的`dataSource`和`delegate`指针都是指向viewController的`weak`指针。可以说,`weak`指针的行为和MRC时代的`assign`有一些相似点,但是考虑到`weak`指针更聪明些(会自动指向nil),因此还是有所不同的。细节的东西我们稍后再说。 ![一个典型的delegate设计模式](/assets/images/2012/arcpic7.png) 注意类似下面的代码似乎是没有什么意义的: ``` __weak NSString *str = [[NSString alloc] initWithFormat:…]; NSLog(@"%@",str); //输出是"(null)" ``` 由于`str`是`weak`,它不会持有alloc出来的`NSString`对象,因此这个对象由于没有有效的`strong`指针指向,所以在生成的同时就被销毁了。如果我们在Xcode中写了上面的代码,我们应该会得到一个警告,因为无论何时这种情况似乎都是不太可能出现的。你可以把**weak换成**strong来消除警告,或者直接前面什么都不写,因为ARC中默认的指针类型就是`strong`。 property也可以用`strong`或`weak`来标记,简单地把原来写`retain`和`assign`的地方替换成`strong`或者`weak`就可以了。 ```objc @property (nonatomic, strong) NSString *firstName; @property (nonatomic, weak) id delegate; ``` ARC可以为开发者节省很多代码,使用ARC以后再也不需要关心什么时候`retain`,什么时候`release`,但是这并不意味你可以不思考内存管理,你可能需要经常性地问自己这个问题:谁持有这个对象? 比如下面的代码,假设`array`是一个`NSMutableArray`并且里面至少有一个对象: ```objc id obj = [array objectAtIndex:0];  [array removeObjectAtIndex:0];  NSLog(@"%@",obj); ``` 在MRC时代这几行代码应该就挂掉了,因为`array`中0号对象被remove以后就被立即销毁了,因此obj指向了一个dealloced的对象,因此在NSLog的时候将出现EXC_BAD_ACCESS。而在ARC中由于obj是`strong`的,因此它持有了`array`中的首个对象,`array`不再是该对象的唯一持有者。即使我们从`array`中将obj移除了,它也依然被别的指针持有,因此不会被销毁。 ### 一点提醒 ARC也有一些缺点,对于初学者来说,可能仅只能将ARC用在objective-c对象上(也即继承自NSObject的对象),但是如果涉及到较为底层的东西,比如Core Foundation中的malloc()或者free()等,ARC就鞭长莫及了,这时候还是需要自己手动进行内存管理。在之后我们会看到一些这方面的例子。另外为了确保ARC能正确的工作,有些语法规则也会因为ARC而变得稍微严格一些。 ARC确实可以在适当的地方为代码添加`retain`或者`release`,但是这并不意味着你可以完全忘记内存管理,因为你必须在合适的地方把`strong`指针手动设置到nil,否则app很可能会oom。简单说还是那句话,你必须时刻清醒谁持有了哪些对象,而这些持有者在什么时候应该变为指向`nil`。 ARC必然是Objective-C以及Apple开发的趋势,今后也会有越来越多的项目采用ARC(甚至不排除MRC在未来某个版本被弃用的可能),Apple也一直鼓励开发者开始使用ARC,因为它确实可以简化代码并增强其稳定性。可以这么说,使用ARC之后,由于内存问题造成的crash基本就是过去式了(OOM除外 :P) 我们正处于由MRC向ARC转变的节点上,因此可能有时候我们需要在ARC和MRC的代码间来回切换和适配。Apple也想到了这一点,因此为开发这提供了一些ARC和非ARC代码混编的机制,这些也将在之后的例子中列出。另外ARC甚至可以用在C++的代码中,而通过遵守一些代码规则,iOS 4里也可以使用ARC(虽然我个人认为在现在iOS 6都呼之欲出的年代已经基本没有需要为iOS 4做适配的必要了)、 总之,聪明的开发者总会尝试尽可能的自动化流程,已减轻自己的工作负担,而ARC恰恰就为我们提供了这样的好处:自动帮我们完成了很多以前需要手动完成的工作,因此对我来说,转向ARC是一件不需要考虑的事情。 * * * ## 具体操作 说了这么多,终于可以实践一下了。在决定使用ARC后,很多开发者面临的首要问题是不知如何下手。因为可能手上的项目已经用MRC写了一部分,不想麻烦做转变;或者因为新项目里用ARC时遇到了奇怪的问题,从而放弃ARC退回MRC。这都是常见的问题,而在下面,将通过一个demo引导大家彻底转向ARC的世界。 ### Demo ![Demo](/assets/images/2012/arcpic8.png) 例子很简单,这是一个查找歌手的应用,包含一个简单的UITableView和一个搜索框,当用户在搜索框搜索时,调用[MusicBrainz][20]的API完成名字搜索和匹配。MusicBrainz是一个开放的音乐信息平台,它提供了一个免费的XML网页服务,如果对MusicBrainz比较有兴趣的话,可以到它的官网逛一逛。 [20]: http://musicbrainz.org/ > AppDelegate.h/m 这是整个app的delegate,没什么特殊的,每个iOS/Mac程序在main函数以后的入口,由此进入app的生命周期。在这里加载了最初的viewController并将其放到Window中展示出来。另外appDelegate还负责处理程序开始退出等系统委托的事件 > MainViewController.h/m/xib 这个demo最主要的ViewController,含有一个TableView和一个搜索条。 SoundEffect.h/m 简单的播放声音的类,在MusicBrainz搜索完毕时播放一个音效。 main.m 程序入口,所有c程序都从main函数开始执行 > AFHTTPRequestOperation.h/m 这是有名的网络框架AFNetworking的一部分,用来帮助等简单地处理web服务请求。这里只包含了这一个类而没有将全部的AFNetworking包括进来,因为我们只用了这一个类。完整的框架代码可以在github的相关页面上找到[https://github.com/gowalla/AFNetworking][22] [22]: https://github.com/gowalla/AFNetworking > SVProgresHUD.h/m/bundle 是一个常用的进度条指示,当搜索的时候出现以提示用户正在搜索请稍后。bundle是资源包,里面包含了几张该类用到的图片,打进bundle包的目的一方面是为了资源容易管理,另一方面也是主要方面时为了不和其他资源发生冲突(Xcode中资源名字是资源的唯一标识,同名字的资源只能出现一次,而放到bundle包里可以避免这个潜在的问题)。SVProgresHUD可以在这里找到[https://github.com/samvermette/SVProgressHUD][23] [23]: https://github.com/samvermette/SVProgressHUD 快速过一遍这个应用吧:`MainViewController`是`UIViewController`的子类,对应的xib文件定义了对应的`UITableView`和`UISearchBar`。`TableView中`显示`searchResult`数组中的内容。当用户搜索时,用AFHTTPRequestOperation发一个HTTP请求,当从MusicBrainz得到回应后将结果放入`searchResult`数组中并用`tableView`显示,当返回结果是空时在`tableView`中显示没找到。主要的逻辑都在MainViewController.m中的`-searchBarSearchButtonClicked:`方法中,生成了用于查询的URL,根据MusicBrainz的需求替换了请求的header,并且完成了返回逻辑,然后在主线程中刷新UI。整个程序还是比较简单的~ ### MRC到ARC的自动转换 回到正题,我们讨论的是ARC,关于REST API和XML解析的技术细节就暂时先忽略吧..整个程序都是用MRC来进行内存管理的,首先来让我们把这个demo转成ARC吧。基本上转换为ARC意味着把所有的`retain`,`release`和`autorelease`关键字去掉,在之前我们明确几件事情: * Xcode提供了一个ARC自动转换工具,可以帮助你将源码转为ARC * 当然你也可以自己动手完成ARC转换 * 同时你也可以指定对于某些你不想转换的代码禁用ARC,这对于很多庞大复杂的还没有转至ARC的第三方库帮助很大,因为不是你写的代码你想动手修改的话代码超级容易mess… 对于我们的demo,为了说明问题,这三种策略我们都将采用,注意这仅仅只是为了展示如何转换。实际操作中不需要这么麻烦,而且今后的绝大部分情况应该是从工程建立开始就是ARC的。 ![选择LLVM compiler 3.0](/assets/images/2012/arcpic9.png) 首先,ARC是LLVM3.0编译器的特性,而老的工程特别是Xcode3时代的工程的默认编译器很可能是GCC或者LLVM-GCC,因此第一步就是确认编译器是否正确。**在Project设置面板,选择target,在Build Settings中将Compiler for C/C++/Objective-C选为Apple LLVM compiler 3.0或以上。**为了确保之后转换的顺利,在这里我个人建议最好把Treat Warnings as Errors和 Run Static Analyzer都打开,确保在改变编译器后代码依旧没有警告或者内存问题(虽然静态分析可能不太能保证这一点,但是聊胜于无)。好了~clean(`Shift+Cmd+K`)以后Bulid一下试试看,经过修改后的demo工程没有任何警告和错误,这是很好的开始。(对于存在警告的代码,这里是很好的修复的时机..请在转换前确保原来的代码没有内存问题)。 ![打开ARC](/assets/images/2012/arcpic10.png) 接下来就是完成从MRC到ARC的伟大转换了。还是在Build Settings页面,把Objective-C Automatic Reference Counting改成YES(如果找不到的话请看一看搜索栏前面的小标签是不是调成All了..这个选项在Basic里是不出现的),这样我们的工程就将在所有源代码中启用ARC了。然后…试着编译一下看看,嗯..无数的错误。 ![请耐心聆听编译器的倾诉,因为很多时候它是你唯一的伙伴](/assets/images/2012/arcpic11.png) 这是很正常的,因为ARC里不允许出现retain,release之类的,而MRC的代码这些是肯定会有的东西。我们可以手动一个一个对应地去修复这些错误,但是这很麻烦。Xcode为我们提供了一个自动转换工具,可以帮助重写源代码,简单来说就是去掉多余的语句并且重写一些property关键字。 ![使用Xcode自带的转换ARC工具](/assets/images/2012/arcpic12.png) ![选择要转换的文件](/assets/images/2012/arcpic13.png) 这个小工具是Edit->Refactor下的Convert to Objective-C ARC,点击后会让我们选择要转换哪几个文件,在这里为了说明除了自动转换外的方法,我们不全部转换,而只是选取其中几个转换(`MainViewController.m`和`AFHTTPRequestOperation.m`不做转换,之后我们再手动将这两个转为ARC)。注意到这个对话框上有个警告标志告诉我们target已经是ARC了,这是由于之前我们在Build Settings里已经设置了启用ARC,其实直接在这里做转换后Xcode会自动帮我们开启ARC。点击检查后,Xcode告诉我们一个不幸的消息,不能转换,需要修复ARC readiness issues..后面还告诉我们要看到所有的所谓的ARC readiness issues,可以到设置的General里把Continue building after errors勾上…What the f**k…好吧~先乖乖听从Xcode的建议"Cmd+,“然后Continue building after errors打勾然后再build。 ![乖乖听话,去把勾打上](/assets/images/2012/arcpic14.png) 问题依旧,不过在issue面板里应该可以看到所有出问题的代码了。在我们的例子里,问题出在SoundEffect.m里: ```objc NSURL *fileURL = [[NSBundle mainBundle] URLForResource:filename withExtension:nil]; if (fileURL != nil) { SystemSoundID theSoundID; OSStatus error = AudioServicesCreateSystemSoundID((CFURLRef)fileURL, &theSoundID); if (error == kAudioServicesNoError) { soundID = theSoundID; } } ``` 这里代码尝试把一个`NSURL`指针强制转换为一个`CFURLRef`指针。这里涉及到一些Core Services特别是Core Foundation(CF)的东西,AudioServicesCreateSystemSoundID()函数接受CFURLRef为参数,这是一个CF的概念,但是我们在较高的抽象层级上所建立的是`NSURL`对象。在Cocoa框架中,有很多顶层对象对底层的抽象,而在使用中我们往往可以不加区别地对这两种对象进行同样的对待,这类对象即为可以"自由桥接"的对象(toll-free bridged)。NSURL和CFURLRef就是一对好基友好例子,在这里其实`CFURLRef`和`NSURL`是可以进行替换的。 通常来说为了代码在底层级上的正确,在iOS开发中对基于C的API的调用所传入的参数一般都是CF对象,而Objective-C的API调用都是传入NSObject对象。因此在采用自由桥接来调用C API的时候就需要进行转换。但是在使用ARC编译的时候,因为内存管理的原因,编译器需要知道对这些桥接对象要实行什么样的操作。如果一个NSURL对象替代了CFURLRef,那么在作用区域外,应该由谁来决定内存释放和对象销毁呢?为了解决这个问题,引入了**bridge,**bridge_transfer和__bridge_retained三个关键字。关于选取哪个关键字做转换,需要由实际的代码行为来决定。如果对于自由桥接机制感兴趣,大家可以自己找找的相关内容,比如[适用类型][36]、[内部机制][37]和[一个简介][38]~之后我也会对这个问题做进一步说明 [36]: http://developer.apple.com/library/mac/#documentation/CoreFoundation/Conceptual/CFDesignConcepts/Articles/tollFreeBridgedTypes.html#//apple_ref/doc/uid/20002401-767858 [37]: http://www.mikeash.com/pyblog/friday-qa-2010-01-22-toll-free-bridging-internals.html [38]: http://ridiculousfish.com/blog/posts/bridge.html 回到demo,我们现在在上面的代码中(CFURLRef)前加上`__bridge`进行转换。然后再运行ARC转换工具,这时候检查应该没有其他问题了,那么让我们进行转换吧~当然在真正转换之前会有一个预览界面,在这里我们最好检查一下转换是不是都按照预想进行了..要是出现大面积错误又没有备份或者出现各种意外的话就可以哭了… 前后变化的话比较简单,基本就是去掉不需要的代码和改变property的类型而已,其实有信心的话不太需要每次都看,但是如果是第一次执行ARC转换的操作的话,我还是建议稍微看一下变化,这样能对ARC有个直观上的了解。检查一遍,应该没什么问题了..需要注意的是main.m里关于autoreleasepool的变化以及所有dealloc调用里的[super dealloc]的删除,它们同样是MRC到ARC的主要变化.. 好了~转换完成以后我们再build看看..应该会有一些警告。对于原来`retain`的property,比较保险的做法是转为`strong`,在LLVM3.0中自动转换是这样做的,但是在3.1中property默认并不是`strong`,这样在使用property赋值时存在警告,我们在property声明里加上`strong`就好了~然后就是SVProgressHUD.m里可能存在问题,这是由于原作者把`release`的代码和其他代码写在一行了.导致自动转换时只删掉了部分,而留下了部分不应该存在的代码,删掉对变量的空的调用就好了.. ### 自动转换之后的故事 然后再编译,没有任何错误和警告了,好棒~等等…我们刚才没有对MainViewController和AFHTTPRequestOperation进行处理吧,那么这两个文件里应该还存在`release`之类的东西吧..?看一看这两个文件,果然有各种`release`,但是为什么能编译通过呢?!明明刚才在自动转换前他们还有N多错的嘛…答案很简单,在自动转换的时候因为我们没有勾选这两个文件,因此编译器在自动转换过后为这两个文件标记了"不使用ARC编译"。可以看到在target的Building Phases下,MainViewController.m和AFHTTPRequestOperation.m两个文件后面被加上了`-fno-objc-arc`的编译标记,被加上该标记的文件将不使用ARC规则进行编译。(相对地,如果你想强制对某几个文件启用ARC的话,可以为其加上`-fobjc-arc`标记) ![强制不是用ARC](/assets/images/2012/arcpic15.png) 提供这样的编译标记的原因是显而易见的,因为总是有一部分的第三方代码并没有转换为ARC(可能是由于维护者犯懒或者已经终止维护),所以对于这部分代码,为了迅速完成转换,最好是使用-fno-objc-arc标记来禁止在这些源码上使用ARC。 为了方便查找,再此列出一些在转换时可能出现的问题,当然在我们使用ARC时也需要注意避免代码中出现这些问题: * “Cast … requires a bridged cast” ***这是我们在demo中遇到的问题,不再赘述*** * Receiver type ‘X’ for instance message is a forward declaration ***这往往是引用的问题。ARC要求完整的前向引用,也就是说在MRC时代可能只需要在.h中申明@class就可以,但是在ARC中如果调用某个子类中未覆盖的父类中的方法的话,必须对父类.h引用,否则无法编译。*** * Switch case is in protected scope ***现在switch语句必须加上{}了,ARC需要知道局部变量的作用域,加上{}后switch语法更加严格,否则遇到没有break的分支的话内存管理会出现问题。*** * A name is referenced outside the NSAutoreleasePool scope that it was declared in... ***这是由于写了自己的autoreleasepool,而在转换时在原来的pool中申明的变量在新的@autoreleasepool中作用域将被局限。解决方法是把变量申明拿到pool的申请之前。*** * ARC forbids Objective-C objects in structs or unions ***可以说ARC所引入的最严格的限制是不能在C结构体中放OC对象了..因此类似下面这样的代码是不可用的*** ```objc typedef struct { UIImage *selectedImage; UIImage *disabledImage; } ButtonImages; ``` 这个问题只有乖乖想办法了..改变原来的结构什么的.. ### 手动转换 刚才做了对demo的大部分转换,还剩下了MainViewController和AFHTTPRequestOperation是MRC。但是由于使用了`-fno-objc-arc`,因此现在编译和运行都没有问题了。下面我们看看如何手动把MainViewController转为ARC,这也有助于进一步理解ARC的规则。 首先,我们需要转变一下观念…对于MainViewController.h,在.h中申明了两个实例变量: ```objc @interface MainViewController : UIViewController { NSOperationQueue *queue; NSMutableString *currentStringValue; } ``` 我们不妨仔细考虑一下,为什么在interface里出现了实例变量的申明?通常来说,实例变量只是在类的实例中被使用,而你所写的类的使用者并没有太多必要了解你的类中有哪些实例变量。而对于绝大部分的实例变量,应该都是`protected`或者`private`的,对它们的操作只应该用`setter`和`getter`,而这正是property所要做的工作。可以说,**将实例变量写在头文件中是一种遗留的陋习**。更好的写实例变量名字的地方应当与类实现关系更为密切,为了隐藏细节,我们应该考虑将它们写在@implementation里。好消息是,在LLVM3.0中,不论是否开启ARC,编译器是支持将实例变量写到实现文件中的。甚至如果没有特殊需要又用了property,我们都不应该写无意义的实例变量申明,因为在@synthesize中进行绑定时,我们就可以设置变量名字了,这样写的话可以让代码更加简洁。 在这里我们对着两个实例变量不需要property(外部成员不应当能访问到它们),因此我们把申明移到.m里中。修改后的.h是这样的,十分简洁一看就懂~ ```objc #import @interface MainViewController : UIViewController @property (nonatomic, retain) IBOutlet UITableView *tableView;   @property (nonatomic, retain) IBOutlet UISearchBar *searchBar;  @end ``` 然后.m的开头变成这样: ```objc @implementation MainViewController { NSOperationQueue *queue;  NSMutableString *currentStringValue;  } ``` 这样的写法让代码相当灵活,而且不得不承认.m确实是这些实例变量的应该在的地方…build一下,没问题..当然对于SoundEffect类也可以做相似的操作,这会让使用你的类的人很开心,因为.h越简单越好..P.S.另外一个好处可以减少.h里的引用,减少编译时间(虽然不明显=。=) 然后就可以在MainViewController里启用ARC了,方法很简单,删掉Build Phases里相关文件的-fno-objc-arc标记就可以了~然后..然后当然是一大堆错误啦。我们来手动一个个改吧,虽然谈不上乐趣,但是成功以后也会很有成就~(如果你不幸在启用ARC后build还是成功了,恭喜你遇到了Xcode的bug,请Cmd+Q然后重新打开Xcode把=_=) #### dealloc 红色最密集的地方是`dealloc`,因为每一行都是`release`。由于在这里`dealloc`并没有做除了`release`和`super dealloc`之外的任何事情,因此简单地把整个方法删掉就好了。当然,在对象被销毁时,`dealloc`还是会被调用的,因此我们在需要对非ARC管理的内存进行管理和必要的逻辑操作的时候,还是应该保留`dealloc`的,当然这涉及到CF以及以下层的东西:比如对于`retain`的CF对象要`CFRelease()`,对于`malloc()`到堆上的东西要`free()`掉,对于添加的`observer`可以在这里remove,schedule的timer在这里`invalidate`等等~`[super dealloc]`这个消息也不再需要发了,ARC会自动帮你搞定。 另外,在MRC时代一个常做的事情是在`dealloc`里把指向自己的delegate设成nil(否则就等着EXC_BAD_ACCESS吧 :P),而现在一般delegate都是`weak`的,因此在self被销毁后这个指针自动被置成`nil`了,你不用再为之担心,好棒啊.. #### 去掉各种release和autorelease 这个很直接,没有任何问题。去掉就行了~不再多说 #### 讨论一下Property 在MainViewController.m里的类扩展中定义了两个property: ```objc @interface MainViewController () @property (nonatomic, retain) NSMutableArray *searchResults; @property (nonatomic, retain) SoundEffect *soundEffect;  @end ``` 申明的类型是`retain`,关于`retain`,`assign`和`copy`的讨论已经烂大街了,在此不再讨论。在MRC的年代使用property可以帮助我们使用dot notation的时候简化对象的`retain`和`copy`,而在ARC时代,这就显得比较多余了。在我看来,使用property和点方法来调用setter和getter是不必要的。property只在将需要的数据在.h中暴露给其他类时才需要,而在本类中,只需要用实例变量就可以。(更新,现在笔者在这点上已经不纠结了,随意就好,自己明白就行。但是也许还是用点方法会好一些,至少可以分清楚到底是操作了实例变量还是调用了setter和getter)。因此我们可以移去searchResults和soundEffect的@property和@synthesize,并将起移到实例变量申明中: ```objc #import "plementation MainViewController {  NSOperationQueue *queue;  NSMutableString *currentStringValue; NSMutableArray *searchResults; SoundEffect *soundEffect;  } ``` 相应地,我们需要将对应的`self.searchResult`和`self.soundEffect`的self.都去去掉。在这里需要注意的是,虽然我们去掉了soundEffect的property和synthesize,但是我们依然有一个lazy loading的方法`-(SoundEffect *)soundEffect`,神奇之处在于(可能你以前也不知道),点方法并不需要@property关键字的支持,虽然大部分时间是这么用的..(property只是对setter或者getter的申明,而点方法是对其的调用,在这个例子的实现中我们事实上实现了-soundEffect这个getter方法,所以点方法在等号右边的getter调用是没有问题的)。为了避免误解,建议把self.soundEffect的getter调用改写成[self soundEffect]。 然后我们看看.h里的property~里面有两个`retain`的IBOutlet。`retain`关键字在ARC中是依旧可用的,它在ARC中所扮演的角色和`strong`完全一样。为了避免迷惑,最好在需要的时候将其写为strong,那样更符合ARC的规则。对于这两个property,我们将其申明为`weak`(事实上,如果没有特别意外,除了最顶层的IBOutlet意外,自己写的outlet都应该是`weak`)。通过加载xib得到的用户界面,在其从xib文件加载时,就已经是view hierarchy的一部分了,而view hierarchy中的指向都是strong的。因此outlet所指向的UI对象不应当再被hold一次了。将这些outlet写为weak的最显而易见的好处是你就不用再viewDidUnload方法中再将这些outlet设为nil了(否则就算view被摧毁了,但是由于这些UI对象还在被outlet指针指向而无法释放,代码简洁了很多啊..)。 在我们的demo中将IBOutlet的property改为`weak`并且删掉viewDidUnload中关于这两个IBOutlet的内容~ 总结一下新加入的property的关键字类型: * strong 和原来的retain比较相似,strong的property将对应__strong的指针,它将持有所指向的对象 * weak 不持有所指向的对象,而且当所指对象销毁时能将自己置为nil,基本所有的outlet都应该用weak * unsafe_unretained 这就是原来的assign。当需要支持iOS4时需要用到这个关键字 * copy 和原来基本一样..copy一个对象并且为其创建一个strong指针 * assign 对于对象来说应该永远不用assign了,实在需要的话应该用unsafe_unretained代替(基本找不到这种时候,大部分assign应该都被weak替代)。但是对于基本类型比如int,float,BOOL这样的东西,还是要用assign。 特别地,对于`NSString`对象,在MRC时代很多人喜欢用copy,而ARC时代一般喜欢用strong…(我也不懂为什么..求指教) #### 自由桥接的细节 MainViewController现在剩下的问题都是桥接转换问题了~有关桥接的部分有三处: * (NSString *)CFURLCreateStringByAddingPercentEscapes(…):CFStringRef至NSString * * (CFStringRef)text:NSString *至CFStringRef * (CFStringRef)@“!_‘();:@&=+$,/?%#[]":NSString _至CFStringRef 编译器对前两个进行了报错,最后一个是常量转换不涉及内存管理。 关于toll-free bridged,如果不进行细究,`NSString`和`CFStringRef`是一样的东西,新建一个`CFStringRef`可以这么做: ```objc CFStringRef s1 = [[NSString alloc] initWithFormat:@"Hello, %@!",name]; ``` 然后,这里`alloc`了而s1是一个CF指针,要释放的话,需要这样: ```objc CFRelease(s1); ``` 相似地可以用`CFStringRef`来转成一个`NSString`对象(MRC): ```objc CFStringRef s2 = CFStringCreateWithCString(kCFAllocatorDefault,bytes, kCFStringEncodingMacRoman);  NSString *s3 = (NSString *)s2; // release the object when you're done [s3 release]; ``` 在ARC中,编译器需要知道这些指针应该由谁来负责释放,如果把一个`NSObject`看做是CF对象的话,那么ARC就不再负责它的释放工作(记住ARC是only for NSObject的)。对于不需要改变持有者的对象,直接用简单的**bridge就可以了,比如之前在SoundEffect.m做的转换。在这里对于(CFStringRef)text这个转换,ARC已经负责了text这个NSObject的内存管理,因此这里我们需要一个简单的**bridge。而对于`CFURLCreateStringByAddingPercentEscapes`方法,方法中的create暗示了这个方法将形成一个新的对象,如果我们不需要`NSString`转换,那么为了避免内存的问题,我们需要使用`CFRelease`来释放它。而这里我们需要一个`NSString`,因此我们需要告诉编译器接手它的内存管理工作。这里我们使用**bridge_transfer关键字,将内存管理权由CF object移交给NSObject(或者说ARC)。如果这里我们只用**bridge的话,内存管理的负责人没有改变,那么这里就会出现一个内存泄露。另外有时候会看到`CFBridgingRelease()`,这其实就是transfer cast的内联写法..是一样的东西。总之,需要记住的原则是,当在涉及CF层的东西时,如果函数名中有含有Create, Copy, 或者Retain之一,就表示返回的对象的retainCount+1了,对于这样的对象,最安全的做法是将其放在`CFBridgingRelease()`里,来平衡`retain`和`release`。 还有一种bridge方式,`__bridge_retained`。顾名思义,这种转换将在转换时将retainCount加1。和`CFBridgingRelease()`相似,也有一个内联方法`CFBridgingRetain()`来负责和`CFRelease()`进行平衡。 需要注意的是,并非所有的CF对象都是自由桥接的,比如Core Graphics中的所有对象都不是自由桥接的(如`CGImage`和`UIImage`,`CGColor`和`UIColor`)。另外也不是只有自由桥接对象才能用bridge来桥接,一个很好的特例是void _(指向任意对象的指针,类似id),对于void _和任意对象的转换,一般使用`_bridge`。(这在将ARC运用在Cocos2D中很有用) #### 终于搞定了 至此整个工程都ARC了~对于AFHTTPRequestOperation这样的不支持ARC的第三方代码,我们的选择一般都是就不使用ARC了(或者等开源社区的大大们更新ARC适配版本)。可以预见,在近期会有越来越多的代码转向ARC,但是也一定会有大量的代码暂时或者永远保持MRC等个,所以对于这些代码就不用太纠结了~ * * * ## 写在最后 写了那么多,希望你现在能对ARC有个比较全面的了解和认识了。ARC肯定是以后的趋势,也确实能让代码量大大降低,减少了很多无意义的重复工作,还提高了app的稳定性。但是凡事还是纸上得来终觉浅,希望作为开发者的你,在下一个工程中去尝试用用ARC~相信你会和我一样,马上爱上这种make life easier的方式的~ URL: https://onevcat.com/2012/05/tsinghua-photos/index.html.md Published At: 2012-05-16 23:27:51 +0900 # 水清木华 七年时光,匆匆飞逝。入学之日还历历在目,离别之时却已悄然而来。我希望自己能挥一挥衣袖,不带走这里的一片云彩,但却留下自己青春的回忆。在这个偌大的园子里,有我的欢笑,有我的泪水,有我的努力。我相信所有清华学子在离别母校时,必定是依依不舍。但是,孩子总有离家之日,外面的舞台也必会更加精彩。 在毕业之际,载着园园骑车逛了一圈校园,再次好好地看了看这个生活了七年的地方,于是有了下面这一组照片。 清华园这三个字让多少人魂牵梦萦,而在这个园子里待了七年之后,我终将告别这里,踏上新的征途 ![](/assets/images/2012/tsinghua-1.jpg) 图书馆老馆。虽然由于年代的问题,不如科技图书馆和人文图书馆那般受欢迎,但是却不可能丢失古朴严谨的氛围,大通桌依然是最受欢迎的~ ![](/assets/images/2012/tsinghua-2.jpg) 清华八斋之一,明斋。取自“大学之道,在明明德” ![](/assets/images/2012/tsinghua-3.jpg) 清华八斋之一,新斋。取自“在新民” ![](/assets/images/2012/tsinghua-4.jpg) 工字厅,也许在校外不出名,但是这确实是白宫之于美国,唐宁10号之于英国,中南海之于中国的地方,是清华的校长办公室;不过令人遗憾的是继承了中国政府部门的一贯传统,闲人免进 ![](/assets/images/2012/tsinghua-5.jpg) 从二教外看大礼堂,这里算是清华老一代建筑中最为中心的地域了吧 ![](/assets/images/2012/tsinghua-6.jpg) 清华日晷,行胜于言,此诺必守一生。 ![](/assets/images/2012/tsinghua-7.jpg) 日晷和大礼堂,都是清华早期标志性建筑 ![](/assets/images/2012/tsinghua-8.jpg) 西区体育场观礼台,观礼台侧的字母依稀可见。鄙人才识浅薄,求问高人刻字内容.. ![](/assets/images/2012/tsinghua-9.jpg) 清华八斋之一,善斋。取自“在止于至善” ![](/assets/images/2012/tsinghua-10.jpg) 一圈,一圈,记录的是时光荏苒和历史沧桑。日晷见证了清华一步一步走到如今的所有时光。 ![](/assets/images/2012/tsinghua-11.jpg) 大礼堂门外雕花,不由感叹现今的新建筑已经失去了当时的那一份精雕细琢.. ![](/assets/images/2012/tsinghua-12.jpg) 大礼堂正门的门雕,圆形图案的精致的雕花守候了一代有一代的清华人 ![](/assets/images/2012/tsinghua-13.jpg) 同方部,取自《礼记》“儒有合志同方,营道同术,并立则乐,相下不厌”,是清华最早的一批建筑之一,现为校友会之所在 ![](/assets/images/2012/tsinghua-14.jpg) 二校门外东侧热能系系馆..一直觉得这个系馆位置相当黄金.. ![](/assets/images/2012/tsinghua-15.jpg) 逸夫馆书架,TP类的部分馆藏.. ![](/assets/images/2012/tsinghua-16.jpg) 纯粹觉得好看... ![](/assets/images/2012/tsinghua-17.jpg) 逸夫馆前的喷泉,终于换了新的..不容易呢 ![](/assets/images/2012/tsinghua-18.jpg) 西阶梯教室,优雅的小楼,随没有什么特点,但是给人感觉却很亲切。 ![](/assets/images/2012/tsinghua-19.jpg) 重新修葺的科学馆的门牌,很遗憾没有见到过从前的科学馆 ![](/assets/images/2012/tsinghua-20.jpg) 二教永远是我的伤心地..不提也罢 ![](/assets/images/2012/tsinghua-21.jpg) 远观大礼堂,夏季和秋初礼堂前的草坪茵茵正胜,富有层次的绿色给校园填满了活力 ![](/assets/images/2012/tsinghua-22.jpg) 新清华学堂和人文图书馆等百年时的新一代标志性建筑都采用了棱锥形状,算是一大特色 ![](/assets/images/2012/tsinghua-23.jpg) 新清华学堂外的台阶 ![](/assets/images/2012/tsinghua-24.jpg) 伫立的主楼,坚实的建筑风格和气势让人起敬,但是单看外表却很难让人相信这是五十年前清华人的杰作 ![](/assets/images/2012/tsinghua-25.jpg) URL: https://onevcat.com/2012/04/objective-c-runtime/index.html.md Published At: 2012-04-22 23:18:44 +0900 # 深入Objective-C的动态特性 Objective-C具有相当多的动态特性,基本的,也是经常被提到和用到的有动态类型(Dynamic typing),动态绑定(Dynamic binding)和动态加载(Dynamic loading)。 这些动态特性都是在Cocoa程序开发时非常常用的语言特性,而在这之后,OC在底层也提供了相当丰富的运行时的特性,比如枚举类属性方法、获取方法实现等等。虽然在平常的Cocoa开发中这些较底层的运行特性基本用不着,但是在某些情况下如果你知道这些特性并合理加以运用的话,往往能事半功倍~ ### 动态特性基础 1、动态类型 即运行时再决定对象的类型。这类动态特性在日常应用中非常常见,简单说就是id类型。id类型即通用的对象类,任何对象都可以被id指针所指,而在实际使用中,往往使用introspection来确定该对象的实际所属类: ``` id obj = someInstance; if ([obj isKindOfClass:someClass]) { someClass *classSpecifiedInstance = (someClass *)obj; // Do Something to classSpecifiedInstance which now is an instance of someClass //... } ``` `-isMemberOfClass:` 是 `NSObject` 的方法,用以确定某个 `NSObject` 对象是否是某个类的成员。与之相似的为 `-isKindOfClass:`,可以用以确定某个对象是否是某个类或其子类的成员。这两个方法为典型的introspection方法。在确定对象为某类成员后,可以安全地进行强制转换,继续之后的工作。 2、动态绑定 基于动态类型,在某个实例对象被确定后,其类型便被确定了。该对象对应的属性和响应的消息也被完全确定,这就是动态绑定。在继续之前,需要明确Objective-C中消息的概念。由于OC的动态特性,在OC中其实很少提及“函数”的概念,传统的函数一般在编译时就已经把参数信息和函数实现打包到编译后的源码中了,而在OC中最常使用的是消息机制。调用一个实例的方法,所做的是向该实例的指针发送消息,实例在收到消息后,从自身的实现中寻找响应这条消息的方法。 动态绑定所做的,即是在实例所属类确定后,将某些属性和相应的方法绑定到实例上。这里所指的属性和方法当然包括了原来没有在类中实现的,而是在运行时才需要的新加入的实现。在Cocoa层,我们一般向一个NSObject对象发送-respondsToSelector:或者-instancesRespondToSelector:等来确定对象是否可以对某个SEL做出响应,而在OC消息转发机制被触发之前,对应的类的+resolveClassMethod:和+resolveInstanceMethod:将会被调用,在此时有机会动态地向类或者实例添加新的方法,也即类的实现是可以动态绑定的。一个例子: ``` void dynamicMethodIMP(id self, SEL _cmd) { // implementation .... } //该方法在OC消息转发生效前被调用 + (BOOL) resolveInstanceMethod:(SEL)aSEL { if (aSEL == @selector(resolveThisMethodDynamically)) { //向[self class]中新加入返回为void的实现,SEL名字为aSEL,实现的具体内容为dynamicMethodIMP class_addMethod([self class], aSEL, (IMP) dynamicMethodIMP, “v@:”); return YES; } return [super resolveInstanceMethod:aSel]; } ``` 当然也可以在任意需要的地方调用`class_addMethod`或者`method_setImplementation`(前者添加实现,后者替换实现),来完成动态绑定的需求。 3、动态加载 根据需求加载所需要的资源,这点很容易理解,对于iOS开发来说,基本就是根据不同的机型做适配。最经典的例子就是在Retina设备上加载@2x的图片,而在老一些的普通屏设备上加载原图。随着Retina iPad的推出,和之后可能的Retina Mac的出现,这个特性相信会被越来越多地使用。 ### 深入运行时特性 基本的动态特性在常规的Cocoa开发中非常常用,特别是动态类型和动态绑定。由于Cocoa程序大量地使用Protocol-Delegate的设计模式,因此绝大部分的delegate指针类型必须是id,以满足运行时delegate的动态替换(在Java里这个设计模式被叫做Strategy?不是很懂Java,求纠正)。而Objective-C还有一些高级或者说更底层的运行时特性,在一般的Cocoa开发中较为少见,基本被运用与编写OC和其他语言的接口上。但是如果有所了解并使用得当的话,在Cocoa开发中往往可以轻易解决一些棘手问题。 这类运行时特性大多由`/usr/lib/libobjc.A.dylib`这个动态库提供,里面包括了对于类、实例成员、成员方法和消息发送的很多API,包括获取类实例变量列表,替换类中的方法,为类成员添加变量,动态改变方法实现等,十分强大。完整的API列表和手册可以在这里找到。虽然文档开头表明是对于Mac OS X Objective-C 2.0适用,但是由于这些是OC的底层方法,因此对于iOS开发来说也是完全相同的。 一个简单的例子,比如在开发Universal应用或者游戏时,如果使用IB构建了大量的自定义的UI,那么在由iPhone版转向iPad版的过程中所面临的一个重要问题就是如何从不同的nib中加载界面。在iOS5之前,所有的`UIViewController`在使用默认的界面加载时(`init`或者`initWithNibName:bundle:`),都会走`-loadNibNamed:owner:options:`。而因为我们无法拿到`-loadNibNamed:owner:options`的实现,因此对其重载是比较困难而且存在风险的。因此在做iPad版本的nib时,一个简单的办法是将所有的nib的命名方式统一,然后使用自己实现的新的类似`-loadNibNamed:owner:options`的方法将原方法替换掉,同时保证非iPad的设备还走原来的`loadNibNamed:owner:options`方法。使用OC运行时特性可以较简单地完成这一任务。 代码如下,在程序运行时调用`+swizze`,交换自己实现的`loadPadNibNamed:owner:options`和系统的`loadNibNamed:owner:options`,之后所有的`loadNibNamed:owner:options`消息都将会发为`loadPadNibNamed:owner:options`,由自己的代码进行处理。 ``` +(BOOL)swizze { Method oldMethod = class_getInstanceMethod(self, @selector(loadNibNamed:owner:options:)); if (!oldMethod) { return NO; } Method newMethod = class_getInstanceMethod(self, @selector(loadPadNibNamed:owner:options:)); if (!newMethod) { return NO; } method_exchangeImplementations(oldMethod, newMethod); return YES; } ``` `loadPadNibNamed:owner:options`的实现如下,注意在其中的`loadPadNibNamed:owner:options`由于之前已经进行了交换,因此实际会发送为系统的`loadNibNamed:owner:options`。以此完成了对不同资源的加载。 ``` -(NSArray *)loadPadNibNamed:(NSString *)name owner:(id)owner options:(NSDictionary *)options { NSString *newName = [name stringByReplacingOccurrencesOfString:@"@pad" withString:@""]; newName = [newName stringByAppendingFormat:@"@pad"]; //判断是否存在 NSFileManager *fm = [NSFileManager defaultManager]; NSString* filepath = [[NSBundle mainBundle] pathForResource:newName ofType:@”nib”]; //这里调用的loadPadNibNamed:owner:options:实际为为交换后的方法,即loadNibNamed:owner:options: if ([fm fileExistsAtPath:filepath]) { return [self loadPadNibNamed:newName owner:owner options:options]; } else { return [self loadPadNibNamed:name owner:owner options:options]; } } ``` 当然这只是一个简单的例子,而且这个功能也可以通过别的方法来实现。比如添加UIViewController的类别来重载init,但是这样的重载会比较危险,因为你UIViewController的实现你无法完全知道,因此可能会由于重载导致某些本来应有的init代码没有覆盖,从而出现不可预测的错误。当然在上面这个例子中重载VC的init的话没有什么问题(因为对于VC,init的方法会被自动转发为loadNibNamed:owner:options,因此init方法并没有做其他更复杂的事情,因此只要初始化VC时使用的都是init的话就没有问题)。但是并不能保证这样的重载对于其他类也是可行的。因此对于实现未知的系统方法,使用上面的运行时交换方法会是一个不错的选择~ URL: https://onevcat.com/2012/03/appcode/index.html.md Published At: 2012-03-24 23:10:06 +0900 # AppCode,Objective-C IDE的另一选择 ###Xcode or AppCode 近年来随着iOS设备和Mac发展,Objective-C(以下简写为OC)进步神速,但是这个世界上并没有多少OC的IDE。要说集成了Mac和iOS SDK的OC开发套件,最为常用和普及的一定是Apple自家的Xcode了。真心说来Xcode是一个很棒的IDE,它具备了作为一个优秀IDE所应该拥有的一切要素。其他的OC IDE环境从来不是主流,但是其中却也不乏优秀者,[JetBrains的AppCode](http://www.jetbrains.com/objc/)便是佼佼者之一。 说到JetBrains可能最为人熟知的是它旗下的另一款Java IDE——[IntelliJ IDEA](http://www.jetbrains.com/idea/)。而JetBrains也还同时有PHP,Python,Ruby等语言的专用IDE,可以说JetBrains就是以IDE为主要产品的公司。作为一家专业的IDE解决方案提供商,它的产品自然也能符合绝大多数用户的需求。而AppCode是JetBrains为Mac和iOS下app开发所推出的IDE产品。如果你想要更effective和elegant的coding,那确实应该尝试AppCode;或者只是单单看腻了Xcode,也可以尝尝鲜~ ###列举AppCode的几个好用特性 ####代码补全 这是最最基本的特性,我想也是一个合格的IDE及编辑器应该完成的最基本的功能。AppCode的代码补全不仅限于类、方法或者变量名字这样的基本自动完成,它还具备了根据上下文推测的能力,并且推测算法十分可靠。 甚至如果你写了一个从未出现过的变量或者方法,AppCode都会询问你是否想要添加这个方法。开发者将有机会避免一切可能的无意义的来回跳转,而专注于有效代码的编写。 ####快速跳转 Xcode的最大确定之一就是难以定位文件和类。想找一个文件的话,基本上不可能完全用键盘实现。而如果遵循效率至上的原则的话,手指离开键盘就意味着效率下降。Sublime Text提供了一种很优秀的寻找和跳转的方法,而AppCode中也有类似的导航方式(我不确定是谁先提出的)。配合类似微博的特定符号,可以完成从文件到类乃至到方法和符号的快速跳转,避免了所有可能的鼠标操作。 ####代码分析和修改意见 虽然Xcode也有代码分析的功能(Shift+Cmd+B in Xcode 4),但是大部分情况下是会望了用的,而且Xcode的分析基本只能找到内存上的潜在问题,随着ARC的逐渐普及,相信内存上的issue会在开发过程中越来越少。AppCode的代码分析是实时进行的,在代码完成之前,你就可以看到存在的问题。分析和监测的问题包括且不限于代码内存管理、从未调用的方法、不可到达的代码段等。 关于警告或错误代码的修改可以说是AppCode的强项,自动帮助添加release/autorelease,优化头文件引用(去掉多余头文件以及自动添加需要的头文件),自动帮助完成强制转换等。 代码分析和修正共有超过60种监视的错误,遵循AppCode的建议可以保证代码的整洁。 ####代码格式修正 每个人都有自己喜欢和习惯的代码格式,比如{}的位置,缩进和隔行的形式等等。阅读符合自己风格的代码时,往往效率能有大幅提升。AppCode提供了高度可自定制的代码风格模版,并可以很简单地将其套用到任何代码上。这样,不论写代码时多么没有注意格式,最后产生的代码都是完全符合风格的漂亮优雅的代码。这不仅可以为自己之后的维护和修改打下基础,也能在团队合作中快速将自己的代码的风格改为和团队统一。这也是我个人最喜欢的AppCode的一个功能。 ####iOS环境 既然是for OC的IDE,那基本上绝大部分时间都是在为iOS或者Mac开发而工作了。AppCode虽然不是Apple的亲儿子,但是不管是设备调试还是模拟器运行也都是没有问题的。而且AppCode也集成了GDB和LLDB,其Debug工具的界面总体上说比Xcode更灵活。另外,单元测试和文档功能也深度集成到了AppCode中,可以随时方便地运行和调用。 ####插件 插件这种东西,为一个应用提供了无限的可能(关于插件这种东西的登峰造极的应用,可以参考VIM或者魔兽世界)。可以说使用插件或者自己编写插件来使用,完全可以将AppCode二次开发为一个完全符合自己需求和习惯的IDE。Xcode虽然也提供插件功能,但是Xcode的插件开发相当繁琐,而且成功的Xcode插件也基本不存在与这个世界之上。而AppCode现在已经有50+的插件存在于插件仓库中,已经可以满足大部分开发者的需求了(比如存在把编辑器VIM化的强力插件)。 ###AppCode的不足 金无足赤,AppCode也有一些不足之处。比如需要依赖Xcode,没有集成nib编辑器,在打开nib文件时会自动去开Xcode,Instrument工具也要调用Xcode等。但是这并不妨碍AppCode成为一款伟大的IDE,在通过一段时间的对AppCode的使用后,我已经成为了AppCode的忠实拥趸~这款IDE对于开发效率的提高和开发心情的调节可谓是相当成功。 URL: https://onevcat.com/2012/03/opencv-build-and-config/index.html.md Published At: 2012-03-03 23:06:23 +0900 # OpenCV 在 iOS 开发环境下的编译和配置 转载本文请保留以下原作者信息: 原作:OneV's Den [http://www.onevcat.com/2012/03/opencv-build-and-config/](http://www.onevcat.com/2012/03/opencv-build-and-config/) ## 2014.5.3 更新 现在一般都直接使用方便的 CocoaPods 来进行依赖管理了,特别是对于像 OpenCV 这样关系复杂的类库来说尤为如此。可以访问 [CocoaPods 的页面](http://cocoapods.org)并搜索 OpenCV 找到相关的 pod 信息就可以进行简单的导入了。如果您还不会或者没有开始使用 CocoaPods 的话,现在正是时候学习并实践了! --- 最近在写一个自己的app,用到一些图像识别和处理的东西。研究后发现用OpenCV是最为方便省事的,但是为iOS开发环境编译和配置OpenCV的库还是需要费点功夫,网上资料也并不是很全,而且有不少已经过期。在此进行一些总结,算是留底,也希望能对其他人有所帮助。 OpenCV (Open Source Computer Vision Library) 是跨平台的开源项目,由一系列C函数和少量C++类构成,提供了图像处理和计算机视觉方面很多通用的算法。在开发有关图像识别和处理的app的时候,OpenCV提供了一系列易用轻量的API,而且遵循BSD License。 ## OpenCV For iOS一键编译 OpenCV用在iOS上,一般是以静态库的方式提供服务的,因此需要先将源码进行编译。如果你想省事,这里有一个我预先编译好的库,可以直接使用(OpenCV版本为2.3,虽然文件名字有part1,但是只有这一个包,开袋即食),如果需要最新版本的OpenCV,可以选择自行编译。 先从OpenCV的repository下载最新的OpenCV ``` svn co https://code.ros.org/svn/opencv/trunk ``` 这里包含了源码和所有范例教程等,有1G多,小水管需谨慎。如果只想下载源码的话,可以从这里check out ``` svn co https://code.ros.org/svn/opencv/trunk/opencv ``` 如果之前有check out过,那么用svn update进行更新即可拿到最新版的源码,或者到[sourceforge进行下载](http://sourceforge.net/projects/opencvlibrary/)。 由于darwin没有内置CMake,因此在编译前需要下载并安装CMake,在CMake的官网可以找到下载。 Eugene Khvedchenya写了一个超级棒的脚本,可以在这里找到下载,或者这里有一个本地的副本(不再更新)。将下载的脚本放到trunk目录中,运行 ``` sh BuildOpenCV.sh opencv/ opencv_ios_build ``` 数分钟后即可在opencv_ios_build目录下找到头文件和编译好的静态库。 如果是从官方库签出的OpenCV并且不怕麻烦的话,也可以使用官方的脚本完成编译,具体可以参看下载的`/opencv/ios/readme.txt`文件。 ## OpenCV的库配置 和其他静态库的配置基本一致,以Xcode4为例。 * 将编译好的opencv文件夹拖入工程中,记得勾选Copy items into destination group’s folder (if needed) ![](http://www.onevcat.com/wp-content/uploads/2012/03/Xcode-1.jpg) * 在Build Settings的Header Search Paths和Library Search Paths中填入相应的头文件位置和库文件位置,并将Always Search User Paths勾为Yes ![](http://www.onevcat.com/wp-content/uploads/2012/03/Xcode-2.jpg) ![](http://www.onevcat.com/wp-content/uploads/2012/03/Xcode-3.jpg) * 在Build Phases中的Link Binary Libraries中添加用到的库文件即可 ## 编译脚本 编译OpenCV的脚本如下,请不要直接复制粘贴该脚本,可能某些符号会在字符转换过程中出现问题。可以访问这里下载该脚本的最新版本,或者[点击这里](http://www.onevcat.com/wp-content/uploads/2012/03/BuildOpenCV.sh_.zip)取得脚本的副本。 URL: https://onevcat.com/2012/02/iosversion/index.html.md Published At: 2012-02-25 22:54:42 +0900 # 符合iOS系统兼容性需求的方法 转载本文请保留以下原作者信息: 原作:OneV's Den https://onevcat.com/2012/02/iosversion/ ## 兼容性,开发者之殇 兼容性问题是全世界所有开发这面临的最头疼的问题之一,这句话不会有开发者会反驳。往昔的Windows Vista的升级死掉一批应用的惨状历历在目,而如今火热的移动互联网平台上类似的问题也层出不穷。Android的开源导致产商繁多,不同的设备不仅硬件配置有差异,系统有差异,就连屏幕比例也有差异。想要开发出一款真正全Android平台都完美运行的app简直是不可能的.. iOS好很多,因为iPhone和iTouch的分辨率完全一致(就算retina也有Apple帮你打理好了),因此需要关注的往往只有系统版本的差别。**在使用到某些新系统才有的特性,或者被废弃的方法时,都需要对运行环境做出判定。** ## 何时需要照顾兼容性 没有人可以记住所有系统新加入的类或者属性方法,也没有人可以开发出一款能针对全系统的还amazing的应用。 **在iOS的话,首先要做的就是为自己所开发的app选择合适的最低版本**,在XCode中的对应app的target属性中,设置iOS Deployment Target属性,这对应着你的app将会运行在的最低的系统版本。如果你设成iOS 4.3的话,那你就不需要考虑代码对4.3之前的系统的兼容性了——因为低于iOS 4.3的设备是无法通过正规渠道安装你的应用的。 但是你仍然要考虑在此之后的系统的兼容性。随着Apple不断升级系统,有些功能或者特性在新系统版本上会有很容易很高效的实现方法,有些旧的API被废弃或者删除。如果没有对这些可能存在的兼容性问题做出处理的话,小则程序效率低下,之后难以维护,大则直接crash。对于这些可能存在的问题,开发者一定要明确自己所调用的API是否存在于所用的框架中。某个类或者方法所支持的系统版本可以在官方文档中查到,所有的条目都会有类似这样的说明 ``` Availability Available in iOS 5.0 and later. ``` 或者 ``` Availability Available in iOS 2.1 and later. Deprecated in iOS 5.0. ``` 从Availability即可得知版本兼容的信息。 如果iOS Deployment Target要小于系统要求,则需要另写处理代码,否则所使用的特性在老版本的系统上将无法表现。 另外,Apple也不过是一家公司,Apple的开发人员也不过是牛一点的程序员,iOS本身就有可能有某些bug,导致在某些版本上存在瑕疵。开发人员会很care,并尽快修正,但是很多时候用户并不care,或者由于种种原因一直不升级系统。这时候便需要检测系统,去避开这些bug,以增加用户体验了(比如3.0时的UITableView插入行时可能crash)。 ## 符合兼容性需求的代码 ### 判定系统版本,按运行时的版本号运行代码 这不一定是最有效的方法,但这是最直接的方法。对系统版本的判定可以参考我的另一片文章: [UIViewController的误用](/2012/02/uiviewcontroller/)。 顺便在这边也把比较的方法贴出来: ```objc // System Versioning Preprocessor Macros #define SYSTEM_VERSION_EQUAL_TO(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] == NSOrderedSame) #define SYSTEM_VERSION_GREATER_THAN(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] == NSOrderedDescending) #define SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] != NSOrderedAscending) #define SYSTEM_VERSION_LESS_THAN(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] == NSOrderedAscending) #define SYSTEM_VERSION_LESS_THAN_OR_EQUAL_TO(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] != NSOrderedDescending) ``` 使用 ···objc if (SYSTEM_VERSION_LESS_THAN(@“5.0”)) { //系统版本不小于5.0...Do something } ··· 需要注意的是,`[@“5.0” compare:@“5” options:NSNumericSearch]`这样的输入将会返回一个NSOrderedDescending,而不是期望中的NSOrderedSame,因此在输入的时候需要特别小心。另外这个方法的局限还在于对于4.1.3这样的系统版本号比较无力,想要使用的话也许需要自己进行转换(也许去掉第二个小数点再进行比较是个可行的方案)。 ### 对于类,可以检测其是否存在 如果要使用的新特性是个类,而不是某个特定的方法的话,可以用NSClassFromString来将字符串转为类,如果runtime没有这个类的存在,该方法将返回nil。比如对于iOS 5,在位置解析方面,Apple使用了CoreLocation中的新类CLGeocoder来替代原来的MKReverseGeocoder,要判定使用哪个类,可以用下面的代码: ```objc if(NSClassFromString(@"CLGeocoder")) { //CLGeocoder存在,可以使用 } else{ //只能使用MKReverseGeocoder } ``` 这个方法应该是绝对靠谱的,但是只有类能够使用。而系统更新往往带来的不止类,也有很多新的方法和属性这样的东西,这个方式就无能为力了。 ### 向特定的类或对象询问是否拥有某特性 对于某些类活期成员,他们本身有类似询问支不支持某种特性这样的方法,比如大家所熟知的UIImagePickerController里检测可用媒体类型的availableMediaTypesForSourceType:方法,以及CMMotionManager里检测三轴陀螺是否可用的方法gyroAvailable。但是有些类询问方法的类和成员所要使用的特性基本基本都是和设备硬件有关的。 另外当然也可以用respondsToSelector:方法来检测是否对某个方法响应。但是**这是非常危险的做法**,我本人不推荐使用。因为iOS系统里存在很多私有API,而Apple的审查机制对于私有API调用的格杀勿论是业界公知的。而respondsToSelector:如果想要检测的方法在老版本的系统中是作为私有API形式存在的话,你将得到错误的结果,从而去执行私有API,那就完了。这种应用往往是被拒了都不知道自己在哪儿使用了私有API… URL: https://onevcat.com/2012/02/uiviewcontroller/index.html.md Published At: 2012-02-02 22:50:53 +0900 # UIViewController的误用 转载本文请保留以下原作者信息: 原作:OneV [https://onevcat.com/2012/02/uiviewcontroller/](https://onevcat.com/2012/02/uiviewcontroller/) ## 什么是UIViewController的误用 UIViewController是iOS开发中最常见也最重要的部件之一,可以说绝大多数的app都用到了UIViewController来管理页面的view。它是MVC的核心结构和桥梁构成,可以说UIViewController是绝大多数开发者所花时间最多的部分了。 但是正是这样一个重要的类却经常被误用,从而导致app的不稳定,莫名奇妙的bug甚至无法通过appstore的审查。最常见和最可怕的误用在于在一个UIViewController里加入本来不应该由它管理的其他UIViewController,也即违反了Apple在开发者文档中关于UIViewController的描述: > Each custom view controller object you create is responsible for managing all of the views in a single view hierarchy. In iPhone applications, the views in a view hierarchy traditionally cover the entire screen, but in iPad applications they may cover only a portion of the screen. The one-to-one correspondence between a view controller and the views in its view hierarchy is the key design consideration. You should not use multiple custom view controllers to manage different portions of the same view hierarchy. Similarly, you should not use a single custom view controller object to manage multiple screens worth of content. 一个ViewController应该且只应该管理一个view hierarchy,而通常来说一个完整的view hierarchy指的是整整占满的一个屏幕。而很多app满屏中会有各个区域分管不同的功能,一些开发者喜欢直接新建一个ViewController和一套相应的View来完成所要的功能(比如我自己=_=)。虽然十分方便,但是却面临很多风险.. 一般来说,只要你的代码中含有类似这样的语句,那你一定是误用UIViewController了 ```objc viewController.view.bounds = CGRectMake(50, 50, 100, 200); [viewController.view addSubview:someOtherViewController.view]; ``` 这样的代码可能导致莫名的bug,也会令接手的开发者无所适从。 ### 小题大做吧,这样用会有什么问题呢 一个很麻烦的问题是,这将会导致你的app在不同的iOS版本上有不同的表现。在iOS5之前,能够对viewController进行管理的类有UINavigationController,UITabBarController和iPad专有的UISplitViewController。而在iOS5中加入了可自定义的ViewControllers的容器。由于新的SDK的处理机制,iOS4前通过addSubview加到当前controller的view上的view的呈现,将不会触发被加入view hierarchy的view的controller的viewWillAppear:方法。而且,新加入的viewController也不会接收到诸如didReceiveMemoryWarning等委托方法,而且也不能响应所有的旋转事件!而iOS5中由于所谓的custom container VC的出现,上述方法又能够运行良好,这导致了同样代码在不同终端产生不同的行为,为之后的维护和进一步开发埋下了隐患。另外,用这样的方法所添加的viewController显然违背了Apple的本意,它的parentViewController,interfaceOrientation显然都是错误的,有时候甚至会出现触摸事件无法响应等严重问题。 ### 好吧,那我们要怎么办 如果你已经在一个app里这样误用了大量的viewController,那可能的办法也许是尽力去自行处理各种非正常的状况,比如在addSubview之后手动调用加入的vc的viewWillAppear:,以及在收到didReceiveMemoryWarning后顺次调用子VC的didReceiveMemoryWarning(显然都是很蛋疼的做法啊)。但是需要注意的是iOS5中这些方法的调用似乎是没有问题的(至少我测试是这样),因此需要对不同版本系统进行分别处理。可以用UIDevice的方法确定运行环境的系统版本: ```objc // System Versioning Preprocessor Macros #define SYSTEM_VERSION_EQUAL_TO(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] == NSOrderedSame) #define SYSTEM_VERSION_GREATER_THAN(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] == NSOrderedDescending) #define SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] != NSOrderedAscending) #define SYSTEM_VERSION_LESS_THAN(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] == NSOrderedAscending) #define SYSTEM_VERSION_LESS_THAN_OR_EQUAL_TO(v) ([[[UIDevice currentDevice] systemVersion] compare:v options:NSNumericSearch] != NSOrderedDescending) ``` 在合适的时机判定判定系统版本,手动调用对应方法: ```objc if (SYSTEM_VERSION_LESS_THAN(@“5.0”)) { //viewWillAppear或didReceiveMemoryWarning或其他 } ``` 显然,这样的代码既非优雅亦难维护,而且随着iOS版本的更新,谁也不知道这段代码之后会不会有什么问题,无形中增加了开发成本。 ## 真正的解决之道 当然是严格遵守Apple提供的设计规范,每个VC管理一个view hierarchy。在设计的时候,永远记住你的view和view controller都需要重用,而不恰当的使用view controller会导致重用性大打折扣。而通用的view有时也需要一个类似controller的东西来管理它的subview的行为,或者做出某些相应,这个时候我们不妨想一想一些Apple写的经典的view是如何实现的,比如UITableView和UIPickerView,依靠protocol的各种方法进行配置。 作为自定义的view的controller应当是直接继承自NSObject的类,而不应该是UIViewController。一个UIViewController可以包含若干个这样的controller来控制一个view中的不同部分的功能实现,而对于对应的自定义view是代码写的还是nib出来的就无所谓了。当然,如果是新接触iOS开发的话,我个人不建议使用Interface Builder,除非你确实清楚IB到底在背后为你做了什么。在当你完全清楚之后,IB确实能极大提升开发效率(特别是在Xcode4以后),但是如果你的对IB和view加载连接的概念如同毛线团的话,IB的使用只会让你以及让你的同事茫然失措。 在iOS5中提供了所谓的container of View Controllers,有兴趣的童鞋可以参看WWDC 2011的[Session 102 – Implementing UIViewController Containment](http://developer.apple.com/videos/wwdc/2011/)(需要一个野生开发者账号) ## 一些资料 作为iOS开发者,Apple的关于UIViewController的文档以及开发者的一些讨论是必读的,简单整理如下: * [View Controller Programming Guide for iOS](http://developer.apple.com/library/ios/#featuredarticles/ViewControllerPGforiPhoneOS/Introduction/Introduction.html#//apple_ref/doc/uid/TP40007457-CH1-SW1) * [关于误用UIViewController而造成的私有API调用的讨论](https://devforums.apple.com/message/310806#310806) * [stack overflow上关于误用view controller的讨论](http://stackoverflow.com/questions/5691226/am-i-abusing-uiviewcontroller-subclassing/5691708#comment-6507338) URL: https://onevcat.com/2012/01/caocao/index.html.md Published At: 2012-01-21 22:28:27 +0900 # 再看曹操 最近在看《苍天航路》,一部以曹操为主角正剧三国志漫画。我向来是对曹操这个人物有所感慨,并且有所敬畏的,在他六十六载的人生中,有着太多的波澜壮阔和起起伏伏。而在后世,人们对这样一个伟人的人生的争论似乎从未停止。三国演义引导了尊刘贬曹中国基调,而日本更倾向于把曹操解读为苍天的霸者。这很大程度上反应了两国国民的心态差别:一个同情弱小,一个崇尚强大。在那个战火纷飞,英杰辈出的年代,孟德的谋略的高度是毋庸置疑的,而对于天下来说,他也是当时唯一一个敢于背负恶名而生存的人,只此一点,就比其他在那乱世之中只顾保全名节的所谓的“高士”高出许多。 曹操真正发家是从兖州收服青州兵开始的,但是在那之前,他早已成名:五色棒,讨黄巾,伐董卓。麾下更是猛将如云,谋士如雨。在兖州蛰伏的曹操需要的只是一支真正的自己的军队。而黄巾余党的出现,正给了他这个机会。之后接汉帝,屯良田,四方征讨,在短短五年之内横扫徐冀并幽四州,灭吕布袁绍,把刘备打到失魂落魄。在乱世之中,任何的机会稍纵即逝,而主宰人物的小小的失策便会对之后数十甚至数百年的历史走势发生惊天影响。曹操正抓住了这样的机会..试想要是没有脱离伐董联军,没有来到兖州这片发源地,没有听取朝中智士谋,设奇伏,日夜会战,没有最终整编难以驾驭的青州兵为己所用,那也就不会有曹操后来的一切。与其说是历史成就了曹操,不如说是曹操成就了历史。 击溃四世三公不可一世的袁绍后,曹操站在了历史的至高点。这时候北方肃清,中原几已一统,虽然这一切功绩都是以“王师”的名义取得的,但是世人皆知此为曹操所为,也不会有人将中原视为汉室领土。名望倾国,霸权独立的曹操,此时做出了最明智的选择——不称帝,不上位。而今我们只能猜测曹操不愿称帝的缘由,是因为顾虑时机未到,还是不眷虚名?但是可以知道的是,在刚刚击败强敌,问鼎权力巅峰的时候,他却异常冷静——没有几个人能在如此大的成功和如此强的霸权面前把持自若的。 曹操之所以是曹操,是因为你很难在历史上找到一个和他相似的人。一直以来曹操是叛逆的代表,而这个世界的改变,也正是这些“叛逆”的人的功劳。Think Different带给人们的感动正在于它表达了人们所无法表达的对于这个世界的叛逆先驱的敬意,他们不遵循蹈矩,而是以自己的见解来开创新的世界。古代中国从百家争鸣开始其实一直有很好的多元化传统,但是随着历史长河流向下游,河面愈宽容纳愈多的同时,也难免限制了支流的壮大,甚是可惜..回到曹操,没有人会怀疑曹操是强大的支流,看看现世加给曹操的头衔便知:军事家,政治家,谋略家,诗人。包容百家,通晓谋略,乱世奸雄,治世能臣,三曹之首。中国历史,乃至世界历史上也再没有第二个人在一生中取得如此多的成就了。曹操就是曹操,你永远不会认错。 历史永远是由胜利者书写的,而深入人心的三国演义小说又是由三国志这样的历史改编的。那么,今天深入人心的曹操的形象,也是由胜利者灌输到民众心中的。胜利者的老祖宗的顶头上司便是曹操,因此曹操的形象并非很坏,非君子,亦非小人。其实如此更好,要是最终三国以魏统一而收局的话,曹操大抵也就会变成李渊那样的人物,而三国这段故事很可能永远不会被后人所津津乐道了吧... >寒风摧,隔牖厉 > >廊檐流角血气。 > >狼烟起,连天蔽 > >帆影丛生浪迹。 > >携天子,讨僭逆。 > >海内归一可期。 > >犹不知樯橹灰飞周郎妙计,湮灭在长江滔滔里,命兮,恨兮。 苍天霸者的梦,断在赤壁。在这里,他遭遇了一生的劲敌——孙权。 曹操一生,其实并没有什么敌人,因为绝大多数的敌人在真正能对曹操构成威胁之前就已经被荡平。而吴地的孙家历经三代,亦是英杰聚集,天灵地宝之所。赤壁之战,只是曹孙争霸的起始,也亦这段历史的转折。曹操在生命旅程中,始终没有能真正打败这个敌人,而自己最终却也打不败时间这个最大的敌人... 呜呼孟德~ URL: https://onevcat.com/2012/01/testflight/index.html.md Published At: 2012-01-17 22:17:25 +0900 # TestFlight——完美的iOS app测试方案 转载本文请保留以下原作者信息: 原作:onevcat [http://www.onevcat.com/2012/01/testflight/](http://www.onevcat.com/2012/01/testflight/) ## 2014.5.3补充 TestFlight 现在已经修成正果,被 Apple 高价收购。虽然很遗憾不能再支持 Android 版本,但是有理由相信在 Apple 旗下的 TestFlight 将被深度整合进 Apple 开发的生态体系,并且承担更加重要的作用。不妨期待一下今年的 WWDC 上 Apple 在 CI 方面的进一步动作,预测应该会有 OSX Server 和 TestFlight 的协作方面内容。对于 CI 方面的一些其他介绍,可以参看 objc 中国的[这篇帖子](http://objccn.io/issue-6-5/)。 ## 2013.3.31补充 在整理以前写的内容,想不到还有机会再对这篇帖子进行一些更新。当时写这篇帖子的时候,app内部测试以及对应的crash报告类的服务相对很少,而且并不成熟。TestFlight算是在这一领域的先行者,而随着app市场的不断膨胀,相应的类似服务也逐渐增多,比较常用的有: 崩溃报告类: * [Crittercism](https://www.crittercism.com/) 个人用了一段时间,表现很稳定,但是版本更新时设置比较麻烦 * [Crashlytics](https://www.crashlytics.com/) 相当优雅方便,最近被Twitter收购。十分推荐 用户行为统计类: * [Flurry](http://www.flurry.com/) 这个太有名了,不多说了 * [Countly](http://count.ly/) 好处是轻量开源,数据可以自己掌控 但是在“发布前”测试分发这个环节上,基本还没有出现能与TestFlight相匹敌的服务出现,因此如果有这方面的测试需求的话,TF依然是开发人员的首选。 当然,这一年多来,TF也进步了很多。从整个队伍建立和开发者添加开始,到桌面客户端的出现以及打包上传的简化,可以说TF也逐渐向着一个更成熟易用的方向发展。本文虽然写的时间比较早,但是整个TF的基本流程并没有发生变化,依然可以作为入门的参考。 ## 前言 iOS开发的测试一直是令人头疼的问题。app开发的短周期和高效率的要求注定了一款app,特别是小公司的app,不会留给开发人员很多测试的时间。而在测试时往往又遇到crash报告提交困难,测试人员与开发人员沟通不便等等问题,极大延缓了测试进度。TestFlight即是为了解决iOS开发时测试的种种困难而生的服务,使用TestFlight可以十分便利地完成版本部署,测试用户Log提交,收集Carsh Log和收集用户反馈等工作,而这一切居然连一个iDP账号都不需要! ## 基本使用 ### 注册 TestFlight界面友好,文档齐全,开发者在使用上不会遇到很多问题。到[TestFlight官网][4]注册账号即可开始使用。 [4]: https://testflightapp.com/ ![注册账号](http://www.onevcat.com/wp-content/uploads/2012/01/TestFlight-»-iOS-beta-testing-on-the-fly-2.jpg) 注册时记得勾选I am a developer,之后便可以以开发者身份管理开发和测试团队,提交测试版本和查看报告等,若没有勾选则是以测试者身份注册。若在注册时没有选上,之后在帐号设置中也可以进行更改。 ![勾选开发者](http://www.onevcat.com/wp-content/uploads/2012/01/signdetail.jpg) ### 确认 注册完成以后会在注册邮箱中收到确认邮件。使用你的iDevice用邮件内的帐号登陆,并且完成设备注册,加入TestFlight的描述文件。关于设备注册和可能遇到的问题,可以参看[这篇帖子][9]。 [9]: http://liucheng.easymorse.com/?tag=testflight ### 创建团队 登陆TestFlight后在自己的Dashboard可以新建一个团队。团队包括了开发者、测试者和相应的测试版本。创建团队后可以通过选择团队来查看团队的信息等情况。 ![建立团队](http://www.onevcat.com/wp-content/uploads/2012/01/addteam1.jpg) ### 添加测试者 在团队管理界面可以为团队添加成员。填写受邀者的邮件和简单的说明,一封包含注册链接的邮件将被发送到指定邮箱。受邀者通过类似的注册和确认流程即可加入团队,参与共同开发和测试。 ![添加测试者](http://www.onevcat.com/wp-content/uploads/2012/01/addteammate.jpg) ### 上传测试版本 上传的版本必须是包含签名的ipa,成功上传版本后即可选择给团队内的成员发邮件或推送邀请他们进行新版本的安装和测试。之后在版本管理中即可看到关于该版本的测试信息。该部分具体内容参看本文最后。 ### 收集测试信息 在build界面中选择需要查看的版本的对应按钮即可看到收集到的测试信息,包括一般的session信息,设备使用TFLog进行的输出(需要TestFlight SDK),crash报告,是否通过了预先设定的检查点,测试人员的安装情况等信息。 结合SDK来使用,一切测试机仿佛都变成了你自己的终端,所有的Log和设备的状态尽在掌握,而这样的便利仅仅需要点击下鼠标和写几行代码,这便是TestFlight的强大之处。 ![收集信息](http://www.onevcat.com/wp-content/uploads/2012/01/tfinfo-1024x484.jpg) ## TestFlight SDK使用 ### 下载 不使用TestFlight的SDK的话,可以说就连这个强大的平台的一成功力都发挥不出来。点击[这里][16]从官方网站下载SDK,[官方文档][17]提供了关于SDK的很全面的说明,在[支持页面][18]也能找到很多有用的信息。 [16]: https://d3fqheiq7nlyrx.cloudfront.net/sdk-downloads/TestFlightSDK0.8.2.zip [17]: https://testflightapp.com/sdk/doc/0.8.2/ [18]: http://support.testflightapp.com/ 之后将以Xcode4为例,简介SDK的使用,更多信息可以参考TestFlight官网。 ### 配置 * 将头文件加入工程:File->Add Files to * 找到包含SDK的文件夹 * 勾选"Copy items into destination folder (if needed)" * 选择"Create groups for any added folders" * 勾上想要使用TestFlight SDK的Target * 验证libTestFlight.a是否被加到link部件中 * 在Project Navigation里选中工程文件 * 选中想要应用SDK的Target * 选择Build Phase栏 * 打开Link Binary With Libraries Phase * 如果libTestFlight.a不在栏内,从Project Navigation里将其拖到栏内 * 开始使用 * 在需要用到TestFlight SDK的文件中引入头文件:_#import “TestFlight.h”_,方便起见,您也可以在工程的预编译文件中的_#ifdef __OBJC___块中引入 * 获取团队token:在[这个页面][19]中对应的团队下选取TeamInfo,获取团队的token。 * 在AppDelegate中启动TestFlight ```objc –(BOOL)application:(UIApplication *_)application didFinishLaunchingWithOptions:(NSDictionary  _*)launchOptions { // start of your application:didFinishLaunchingWithOptions // … [TestFlight takeOff:@“团队Token”]; // The rest of your application:didFinishLaunchingWithOptions method // … } ``` 为了能得到有用的crash log(挂载过的),必须在生成ipa的时候不剥离.dSYM文件。在Project Navigation里选中工程文件,选中需要使用TestFlight SDK的Target,在Building Setting的Deployment块下,将以下三项设为NO [19]: http://testflightapp.com/dashboard/team/ * Deployment Post Processing * Strip Debug Symbols During Copy * Strip Linked Product ### 检查点 开发者可以在代码的任意位置设置检查点,当测试者通过检查点时,session里将会对此记录。比如测试者通过了某个关卡,或者提交了某个分数,或者向数据库加入了某条信息等。通过验证检查点,一方面可以检测代码是否正确,另一方面也可以作为游戏的平衡性调整和测试,用来检测用户的普遍水平。 ![加入检查点](http://www.onevcat.com/wp-content/uploads/2012/01/checkpoints.jpg) ```objc [TestFlight passCheckpoint:@“CHECKPOINT_NAME”]; ``` ### 检查点问题 配合检查点可以向测试者提出问题,比如“是否正确地通过了演示界面?”或者“分数榜的提交正常吗?”这样的问题。在build management下可以找到Question选项,为检查点添加问题。问题的回答分为多选,是/否以及任意回答,如果选择多选的话,还需要指出问题的可能的选项。 当测试者通过问题所对应的检查点时,一个modalViewController形式的问题和选项列表会出现供测试者选择。开发者可以在build的Question选项卡中看到反馈。 ### 反馈 TestFlight提供了一个默认的反馈界面,测试者可以填写他们想写的任何内容并将这个反馈发送给你。调用一个反馈: ```objc –(IBAction)launchFeedback { [TestFlight openFeedbackView]; } ``` 一般来说可以在主界面或者最常见的界面上设置一个“反馈”按钮,这样测试者可以很方便地将他们的感受和意见发送给你。 ### 远程Log 太棒了…配合TestFlight,现在开发者可以拿到远程客户端的Log。使用**TFLog**代替NSLog即可,任何TFLog的输出将被上传到TestFlight的服务器。如果需要详细一些的输出信息,可以用内建的**参数**的方式输出信息,比如: ```objc #define NSLog(__FORMAT__, ...) TFLog((@"%s [Line %d] " __FORMAT__), __PRETTY_FUNCTION__, __LINE__, ##__VA_ARGS__) ``` 将会得到类似这样的输出 ```objc -[HTFCheckpointsController showYesNoQuestion:] [Line 45] Pressed YES/NO ``` 所有的TFLog都不会阻塞主线程,所有的TFLog都将完成以下三种Logger工作 * TestFlight logger * Apple System Log logger * STDERR logger 配合以前介绍过的NSLogger(参见[这篇文章][22]),将Log发挥到极致,让你永远掌控自己的代码吧~ [22]: http://www.onevcat.com/2011/12/nslogger/ Log将会在客户端进入后台或者被退出的时候上传到服务器,如果没有看到应该有的Log的话,很有可能是还在上传中。视Log文件大小而定,一般这个过程需要若干分钟。当然,巨量上几M甚至10+M的Log可能会被TestFlight拒绝哦..毕竟没有那么多存储空间.. 当然,客户端必须有可用的网络环境这个功能才会启用。得到的Log会存储在Session下。 ![Session页面](http://www.onevcat.com/wp-content/uploads/2012/01/Log.jpg) ## 生成和上传测试版本 ### 打包ipa ..做过部署的童鞋对这个应该很熟了,官方也有一个详细的guide,总之照着做就没错了 * [XCode3如何生成ipa][25] * [Xcode4如何生成ipa][26] [25]: http://support.testflightapp.com/kb/tutorials/how-to-create-an-ipa-xcode-3 [26]: http://support.testflightapp.com/kb/tutorials/how-to-create-an-ipa-xcode-4 ### 上传测试版本 打包好ipa后就到版本上传界面,把做好的ipa拖过去就万事大吉了。 ![上传ipa包](http://www.onevcat.com/wp-content/uploads/2012/01/ipa.jpg) 最后一步是邀请团队内的测试者进行测试。把你想邀请的测试者打上勾然后OK,包含链接的邀请邮件将会发到他们的邮箱。然后~等待测试结果和大家的反馈,并且根据反馈完善app吧~ ## 写在最后 TestFlight是一个很棒的工具,而且关键,它现在还是免费的~ 虽然有趋势以后将会收费,但是这套方案确实是方便易用..希望多支持吧~ URL: https://onevcat.com/2012/01/ahrp2012/index.html.md Published At: 2012-01-11 22:14:04 +0900 # AHRP 2013 内部推荐机会 #### 2013秋季项目内推已经结束。如果打算参加的话,可以关注9月份陆续开始的宣讲会和AHRP官方网站的一些信息。谢谢大家对AHRP项目和我的blog的关注~ #### ~~~AHRP新一年的秋季项目即将开始,2012春季项目中博主内推的童鞋中有2人最终拿到了offer,而2013秋季依然我有机会作为内定者为大家进行内推。如果有对该项目感兴趣的童鞋欢迎给我邮件或留言咨询最新情况。此次内推5月26日就将截止,AHRP秋季项目将在6月初和被内推者先行联系,现在申请可以先人一步,将对您求职路上占据主动有很大帮助!~~~ ~~~——OneV 2012.5.14~~~ ~~~####2012春季项目内推已经结束,项目已经正式启动。如打算参加的话,可以关注近期在北京各高校开展的宣讲会或在AHRP官网申请(推荐现场投递简历,官网会可能比较慢导致错过企业招聘)。感谢大家对OneV's Den和AHRP的关注~!如果还有什么问题,欢迎给我留言或邮件。    ——OneV 2012.2.20~~~ ## 我和AHRP 我今年通过AHRP找到了日本的工作,今年晚些时候将赴日加入Kayac公司开始职场生涯。一路走来,我觉得能和AHRP工作组的人员成为朋友是很幸运的事情。而现在,作为AHRP的内定者,我也有机会向AHRP进行**内部推荐**,让更多的朋友接触到这个项目,了解这个项目,有机会去倾听AHRP工作人员们对职场的独特见解,最终达成自己的梦想。 ## 什么是AHRP AHRP是Asian Human Resource Project的简称,是日本人力资源公司株式会社TRANSCEND旗下的校园招聘项目,负责在中国选拔优秀的大学毕业生赴日工作。选拔合格者将直接与企业签订正式的日本劳动合同并作为正社员加入公司,享有与当地毕业生相同的薪水福利、培训机会及晋升空间(小科普:日本劳动合同分为正社员和合同制,正社员是众生雇佣,而合同制是有年限的)。 AHRP项目自成立以来,已成功举办了四届校园招聘:AHRP2008、AHRP2009、AHRP2010及AHRP2011。共有超过180位来自北大、清华、复旦、南开等著名高校的同学通过本项目成功进入伊藤忠商社、软银集团、乐天株式会社、RECRUIT、日产汽车等优秀日企,走上国际舞台,实现海外就职。 参加AHRP项目,您也许可以获得 * 国际化的视野,国际化的职业背景 * 在有潜力行业中的无限发展可能 * 掌握多一门外语 * 崭新的异国生活 * 实现梦想的机会 ## AHRP2012 AHRP2012面向2012年的应届毕业生,也欢迎工作了一至两年但还是充满激情的年轻人。AHRP2012的秋季部分已经结束,27家日本企业与78位中国应届毕业生完成了签约。这些企业包括DeNASoftBank(软银集团)GREE等知名企业。2012秋季项目中共有27个企业参加,即将来到的春季项目也将有20个左右的企业,具体的名单将会在二月中下旬确定,届时也请不要错过。 ## 为什么要参加AHRP 不论是因为想要找一份国际化的工作,让自己在职场开始的时候就能站在相对高点,拥有开阔的眼界,还是因为对现实不满,对已经签约的工作还有忧虑,我认为都可以尝试参加AHRP项目。以我自己的经历来看,AHRP的面试和绝大多数国内企业的面试是截然不同的。在AHRP,你将会被一步一步引导看清你真正所需要的未来,而面试官也将会在了解你的情况之后,凭借他们丰富的人力资源经验为你指出之后可能的道路。在这里,面试官和应聘者的界限似乎不这么明显,大家更像是朋友一样和下午茶聊天。在和AHRP工作人员的谈天中,你往往能对职场和今后的职场道路产生新的理解和认识。不论如何,我认为只要你有心到日本工作,愿意尝试挑战和全新的道路,你都可以参与到这个项目里来。 有一句话我很欣赏:一个人从来不会后悔做过了什么,而只会后悔没做过什么。其实很多时候您需要的仅仅是一次尝试的勇气,就足以让您的人生变得精彩。 ## 听起来很有趣,如何参加? 如果您能看到这里,那也许您不会介意再看看我的前面两篇与AHRP有关的博文了..它们实在我接触AHRP的过程中我的真实感受。 * 近期求职总结-AHRP和DeNA面试 * 尘埃落定,下一站Kayac AHRP项目组的官方网站上也有大量的信息,包括AHRP介绍,历年情况和详细的FAQ。 特别说明几点: * 参加AHRP全程不收取任何费用,被录取后的日语培训费用也全部由AHRP承担 * 首次去日本的机票貌似也是AHRP负责,到日本后会提供无息贷款帮助渡过前几个月 * 专业不限,由于公司数量不少,基本各专业都有机会找到合适自己的企业。相对来说的话,IT方面的招收会多一些,机会也会大一些。 * 日语无要求,内定(签约)后会有免费的日语培训,以确保大家能在日本生活。 * 到日本也会有语言学习时期和相应的技能培训,并且会有前辈帮助熟悉环境。 如果您确实对AHRP项目有兴趣,可以考虑给我留言(评论时请注明邮箱,不用担心,您的邮件地址除了我没人能看到)或者是直接给我发邮件(onevcat at gmail.com)。我在对您的情况进行基本确认后会将您的信息作为内部推荐提交给AHRP项目组的工作人员。您将可以在第一时间收到关于AHRP2012春季项目的最新情报,以及优先于春季招聘档期和AHRP面试接触的机会,以提高您被成功录用的可能性。 我十分期待着能与您成为朋友,能成为在异国他乡的土地上能够彼此照应的同伴! URL: https://onevcat.com/2011/12/vvbordertimer/index.html.md Published At: 2011-12-24 22:04:43 +0900 # VVBorderTimer GitHub 链接: [https://github.com/onevcat/VVBorderTimerView](https://github.com/onevcat/VVBorderTimerView) ### 是什么 * **VVBorderTimer**是UIView的子类 * 它为UIView提供使用边界进行倒计时的效果 * 边框角落的半径和线宽在运行时可调 * 倒计时是有颜色渐变效果 ### What’s this * **VVBorderTimer** is a subclass of UIView. * It provides an counting down effect using the view’s border. * The radius of round corners and line width are configurable in runtime. * There is also a color transition effect during the counting. ### 怎么用 * 将VVBorderTimerView.h和VVBorderTimerView.m导入您的工程。请根据您的情况选择使用ARC版本或非ARC版本 * 分配并初始化一个VVBorderTimerView. 设置其背景颜色 ``` VVBorderTimerView *btv = [[VVBorderTimerView alloc] initWithFrame:CGRectMake(20, 20, 280, 280)]; //为计时器设置背景颜色 btv.backgroundColor = [UIColor clearColor]; ``` 3. 配置计时器属性,如: 颜色(可选), 总时间和delegate. ``` //上边界为绿色 UIColor *color0 = [UIColor greenColor]; //右边黄色 UIColor *color1 = [UIColor yellowColor]; //下边橙色 UIColor *color2 = [UIColor colorWithRed:1.0 green:140.0/255.0 blue:16.0/255.0 alpha:1.0]; //左边红色 UIColor *color3 = [UIColor redColor]; //为计时器指定颜色. 不同颜色将在转角处发生渐变. //如果您没有指定颜色,或者指定其为nil(btv.colors = nil),所有边将默认使用黑色 btv.colors = [NSArray arrayWithObjects:color0,color1,color2,color3,nil]; //为计时器设定总时间 btv.totalTime = 10; //为计时器设定delegate btv.delegate = self; ``` 4. 实现计时器的delegate ``` //转角半径(0 代表矩形) -(float) cornerRadius:(VVBorderTimerView *)requestor { return 30; } //计时器线宽 -(float) lineWidth:(VVBorderTimerView *)requestor { return 10; } //当计时器停止时,该方法被调用 -(void) timerViewDidFinishedTiming:(VVBorderTimerView *)aTimerView { //do something } ``` 5. 将计时器加入您的viewController的view,并使用 -(void)start 开始计时 ``` [self.view addSubview:btv]; [btv start]; ``` 如果您使用的是非ARC,请不要忘记在将计时器加入view结构后释放它。可能您需要保留一个指向该计时器的指针,以便在即使结束后将其移除 在GitHub网站的这个页面上有一个简单的demo供您参考,如果您感兴趣,可以关注或者分支该项目。祝您好运~ ### How to use 1. Import VVBorderTimerView.h and VVBorderTimerView.m to your project. Select either ARC version or non-ARC version for your situation. 2. Alloc and init a VVBorderTimerView. Set its background color. ``` VVBorderTimerView *btv = [[VVBorderTimerView alloc] initWithFrame:CGRectMake(20, 20, 280, 280)]; //Specify a background color for the timer btv.backgroundColor = [UIColor clearColor]; ``` 3. Set the properties for the timer: colors(optional), totalTime and delegate. ``` //Top border will be green UIColor *color0 = [UIColor greenColor]; //Right border yellow UIColor *color1 = [UIColor yellowColor]; //Buttom border orange UIColor *color2 = [UIColor colorWithRed:1.0 green:140.0/255.0 blue:16.0/255.0 alpha:1.0]; //Left border red UIColor *color3 = [UIColor redColor]; //Set the colors for the timer. Color transition will be occured in the corner. //If your DID NOT specify the colors or specify it to nil(btv.colors = nil), default black color for all edge will be used. btv.colors = [NSArray arrayWithObjects:color0,color1,color2,color3,nil]; //Set the total time the timer should count in second btv.totalTime = 10; //Set the delegate for the timer btv.delegate = self; ``` 4. Implement the timer’s delegate ``` //Corner radius for a timer(0 means rectangle) -(float) cornerRadius:(VVBorderTimerView *)requestor { return 30; } //Line width for a timer -(float) lineWidth:(VVBorderTimerView *)requestor { return 10; } //When the timer stopped, this method will be called -(void) timerViewDidFinishedTiming:(VVBorderTimerView *)aTimerView { //do something } ``` 5. Add it to your viewController’s view and then start the timer using -(void)start ``` [self.view addSubview:btv]; [btv start]; ``` DO NOT forget to release the timer after it is added to the view’s hierarchy if you use non-ARC. You may want to keey a pointer to the timer, so you can remove it from superview when it stops. You can also find a demo in the github page here. You can watch and fork it if you are intrested in it. Enjoy! ### Lisence VVBorderTimer is Copyright © 2011 Wei Wang(onevcat), All Rights Reserved, All Wrongs Revenged. Released under the New BSD Licence. * https://github.com/onevcat/VVBorderTimerView/ BSD license follows (http://www.opensource.org/licenses/bsd-license.php) Copyright (c) 2011 Wei Wang(onevcat) All Rights Reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBU -TORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. URL: https://onevcat.com/2011/12/password/index.html.md Published At: 2011-12-22 00:49:48 +0900 # 凑热闹,谈密码,Challenge-Response密码验证 [CSDN的密码事件](http://news.csdn.net/a/20111221/309505.html)闹得沸沸扬扬,600万用户数据的泄露应该是中国互联网历史上最严重的帐号信息泄露事件。让人不可思议的是,2009年4月之前的用户密码居然是以明文存储。使用明文存储密码本身就是一件相当扯淡的事情,而当这种事情发生在以程序员为主要客户的大型网站上,真是让人哭笑不得。 之后又陆续爆出人人、多玩以及各种知名网站的账户信息泄露的消息,虽然还未确知真伪,但也很是让人揪心。而“不能明文保存密码”这一个初级中的初级的错误之所以会在中国这篇神奇的土地上一次又一次的出现,我认为是与中国的网络审查制度和相关法制不全造成的(其实就算法令全了我估计在特权之下也只是一纸空文)。负责网络审查的大大们往往一个电话过来指明要XXX的密码,而在天朝这样的地方如果你拿不出这个密码的话,基本就被扣上了政治不合作和给领导难看的大帽子,很多时候程序员被逼无奈只能明文存储。而像这次CSDN这样的由于系统升级而遗留下的历史问题,可以相信溯其根源也或多或少会与这样的特权有关吧... 扯远了扯远了...作为有良知的程序员,绝不能用明文存储密码,而作为简单的密码存放验证解决方案,MD5的Challenge-Response验证方法可以满足绝大部分非机密级别的应用了。使用MD5存储密码在验证中是很常见的做法,虽然[王小云](http://baike.baidu.com/view/350813.htm#2)教授提出了有效的MD5强无碰撞算法,但是作为民用级别的网站认证,简单的散列MD5的方便快速简单易用的特性还是相当吸引人。为了防止截取MD5伪造身份完成认证,再加入一个Challenge-Response机制,客户端请求验证时,由服务器随机一个串给客户端进行挑战,客户端使用密码的散列值与从服务器取得的串组合得到新的散列值,将此散列值提交给服务器生成的散列值进行应答验证,若两个散列一致则通过,否则失效。如果希望能够得到更加安全的散列算法,可以选择SHA-256,SHA-384或者SHA-512等还未被攻破的散列(仅限民用..我猜政府军事部门不太可能用散列这种悲剧又简单的摘要算法)。对于iPhone SDK来说,常用的散列算法都在头文件中均有说明。而libcommonCrypto.dylib和Security.framework中也都提供了相当多的安全方法,涉及网络应用的app开发的话,不论是作为自身修养的提升还是对自己的代码负责,都应当对安全问题有基本的认识和思索... URL: https://onevcat.com/2011/12/uiimage/index.html.md Published At: 2011-12-21 00:46:41 +0900 # 带边框的UIImage缩放 一个带边框的UIImage如果使用常规的缩放,边框部分将被按照缩放比例拉伸或压缩,有些时候这并不是我们所期望的..比如这个边框是根据图片大小变化的外框。比如下面的类似按钮的不明物体图片:主体为渐变蓝色,边框为外圈白色,灰色底板为背景。 ![](http://iphonedevelopertips.com/wp-content/uploads/2011/12/blueButton.gif) 常见的按钮添加和背景设置如下: ```objc UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(80, 130, 160, 44)]; [button setTitle:@”Test Button” forState:UIControlStateNormal]; // Image with without cap insets UIImage *buttonImage = [UIImage imageNamed:@”blueButton”]; [button addTarget:self action:@selector(buttonPressed:) forControlEvents: UIControlEventTouchUpInside]; [button setBackgroundImage:buttonImage forState:UIControlStateNormal]; [[self view] addSubview:button]; ``` 所得到的按钮会相当悲剧… ![](http://iphonedevelopertips.com/wp-content/uploads/2011/12/button0.gif) 边框,特别是左右边框由于按钮frame过大被惨烈拉伸… iOS5中提供了一个新的UIImage方法,[resizableImageWithCapInsets:](http://developer.apple.com/library/IOs/#documentation/UIKit/Reference/UIImage_Class/Reference/Reference.html),可以将图片转换为以某一偏移值为偏移的可伸缩图像(偏移值内的图像将不被拉伸或压缩)。 用法引述如下: ```objc resizableImageWithCapInsets: Creates and returns a new image object with the specified cap insets. - (UIImage *)resizableImageWithCapInsets:(UIEdgeInsets)capInsets Parameters capInsets The values to use for the cap insets. Return Value A new image object with the specified cap insets. Discussion You use this method to add cap insets to an image or to change the existing cap insets of an image. In both cases, you get back a new image and the original image remains untouched. During scaling or resizing of the image, areas covered by a cap are not scaled or resized. Instead, the pixel area not covered by the cap in each direction is tiled, left-to-right and top-to-bottom, to resize the image. This technique is often used to create variable-width buttons, which retain the same rounded corners but whose center region grows or shrinks as needed. For best performance, use a tiled area that is a 1x1 pixel area in size. ``` 输入参数为一个capInsets结构体: ```objc //Defines inset distances for views. typedef struct { CGFloat top, left, bottom, right; } UIEdgeInsets; ``` 分别表示上左下右四个方向的偏移量。于是把上面按钮的UIImage改为如下形式: ```objc // Image with cap insets UIImage *buttonImage = [[UIImage imageNamed:@”blueButton”] resizableImageWithCapInsets:UIEdgeInsetsMake(0, 16, 0, 16)]; ``` 可以得到如下按钮: ![](http://iphonedevelopertips.com/wp-content/uploads/2011/12/button1.gif) 问题得到解决。 但是值得注意的是该方法需要至少iOS5的运行环境,因此对于需要开发支持iOS5之前的App来说是不可行的。替代方案是[stretchableImageWithLeftCapWidth:topCapHeight:](http://developer.apple.com/library/IOs/#documentation/UIKit/Reference/UIImage_Class/DeprecationAppendix/AppendixADeprecatedAPI.html#//apple_ref/occ/instm/UIImage/stretchableImageWithLeftCapWidth:topCapHeight:),但是在iOS5中,这已经是被Deprecated的方法了,而且该方法只能以1px作为重复铺满拉伸区域,无法做到类似渐变等图片效果,是存在一定局限的。 URL: https://onevcat.com/2011/12/skyrim/index.html.md Published At: 2011-12-11 00:36:43 +0900 # 直到我膝盖中了一箭... 这句话就这么火了。其实通宵等了一夜天际,但是之后却一直没有大块时间,所以至今主线还没有玩完,今年之内估计也是没有机会完结了。在2011年各种组织评选年度游戏之前,我想我得为老滚5写点什么。 今年是大作频出的年份,传送门2,巫师2,刺客信条:启示录,英雄无敌6,新的COD...当然,还有让人等了五年的上古卷轴5。一个人的一生并没有多少个五年可以等,而这个世界上也没有多少游戏值得人们等待五年。 恰恰上古卷轴就是这样的一款游戏,优点的评说似乎都很相似,无非就是超高的自由度,开放的引擎,官方支持的MOD,以及还不赖的画面表现效果。单独的某一点都不足以使上古卷轴取得成功:自由度再高不可能超越GTA,引擎再开放也不如Minecraft,官方支持的MOD一抓一大把,画面效果远逊于巫师2。但是,可怕的是Bethesda可以把这一切要素集中到一款游戏中,这注定了上古卷轴自从它诞生之时起就会是一款基因优秀的游戏。 五年前湮没上市的时候,没有人会想到五年之后还有无数的Moder沉迷于斯,也没有人会想到当初4G的游戏如今居然滚到了30G的硬盘空间。在时间的流逝、岁月的积淀过后,那些当年光鲜华丽的画面早已是不堪入目,无数的创新玩法也早已被各家产商用烂,但是这个游戏留给我们了一笔宝贵的财富,那就是MOD,以及开发游戏,开放MOD的开发模式。 厂商最宝贵的资源是什么?不是产品,不是用户,更不是资金。苹果和安卓告诉我们,厂商最宝贵的资源其实是良好的平台和对平台生态系统的维护。提供一个基础,聚集一批用户和开发者,让开发者在官方基础上自行拓展功能,拉动消费者群体,最终形成良性的厂商-开发者-消费者的模式,赢取稳定的用户和开发者忠诚度。而在像上古卷轴这样的大型游戏中,MOD的出现和官方提供的MOD开发平台,恰是遵循了这样的理念。各种MOD达人以炫耀技术或者只是单纯地对游戏的喜爱而在Bethesda的平台上对游戏进行二次开发,无数的玩家因此得益玩到更多的内容,制作商获得更大的用户群体,又有更多的个人制作者投身到更多更好的MOD制作... 嗯..一切都很美好,直到我膝盖中了一箭。这个循环里其实有一链并不靠谱,与标准的良性生态系统稍有区别,那就是开发者的利益问题。现在开发者只能够得到精神上的满足,而精神满足永远不能成为长久的动力,这也是为什么绝大部分的MOD都只停留在换贴图的高度,因为不会有强力开发者将绝大部分时间和经历投入到这项没有实际收益的事业中去。 如果Bethesda想在现在日趋激烈的游戏行业做大做强,而不是停留原地等待其他更具优势的厂商复制甚至改良这种模式的话,也许效仿成功者建立一个开发者能够受益的平台会是一个很好的选择。 URL: https://onevcat.com/2011/12/nslogger/index.html.md Published At: 2011-12-08 00:35:22 +0900 # Log的艺术,顺带赞NSLogger 写代码易,调程序难。不论是多么资深的程序员,都不可能在毛线球一般的代码中弄清到底发生了什么,特别是当在程序在N多个线程中来回跳转和涉及到难以理解的内存操作的时候,我们不可避免地需要log的帮助来整理思路,确认到底发生了什么。而这时候,输出log的好坏和时机,往往决定了花在调试上的时间。 其实某种程度上来说,log是一门艺术,而从输出log上往往也能判断程序员的水平。新手往往都很可爱,NSLog(@"Hello World")会是不变的模式。不得不承认这样的输出如果在恰当的时候也能一击致命,但是它所能带给我们的信息量实在太少了。水平再高一点的程序员大概会在关键事件的时候在保证代码通用性的前提下使用诸如`__func__`之类的东西监测程序流程。高级程序员在log方面就会显得大巧若拙,也许会把整个代码流的行为都进行log,而不漏掉任何一个细节,包括所有的内部状态、各种事件、异常等,完全受掌控的代码和预期中的程序行为是他们成功的关键。而传说中的大牛级人物可能把更多的注意力放在线程,网络等时序上可能出错的地方,无谓的内存释放或是某个超时的网络请求都不会遗漏。 其实在面对纷繁芜杂的输出需求的时候,Cocoa环境默认的NSLog基本就是个废品:它不能给输出分门别类,不能按照需求输出除了字符的格式,也不能兼顾线程区别对待。而在高水平log的要求中,这些都应当是提供给开发者最基本、最便利的输出方式。 于是NSLogger出现了,在Florent Pillet的打造下,一个开源强力的输出工具给了log这一古老的工作崭新的生命。标签输出,优先级查找,直接输出图像,多线程标记,时序控制,甚至是通过网络log到别人的终端或者是从别人的终端程序中记录log。在这里,只有想不到没有做不到,堪称是史上最为强大的logger,而且最重要的是他是BSD License的。在我看来,任何apple开发者都不应该错过他。 URL: https://onevcat.com/2011/12/neltharion/index.html.md Published At: 2011-12-06 00:33:41 +0900 # 别了,耐萨里奥 耐萨里奥,艾泽拉斯最强大的生物之一,受到泰坦祝福的大地守护者,黑龙领袖。他一直是睿智、高贵、沉着和强大力量的象征。在燃烧军团第一次入侵时,他率领了五色巨龙军团协助暗夜精灵抗击恶魔。也正是那时,他提议五大龙族领袖将力量注入巨龙之魂中,以抵抗军团。而不幸的是,在无数恶魔的萦绕下,在无尽的战斗中,在古老邪神的诱惑下,他癫狂了——他的身体一块块裂开,赤红的火焰从身体的裂缝中喷涌出来,他调转龙头,赶走了其他四色巨龙领袖。正是从此刻开始,他有了一个新的名字,死亡之翼。 11月30日凌晨3时,WOW美服开放4.3暮光审判,四小时后,死亡之翼首次倒下;截止12月1日午11时,283家各色公会已经击杀死亡之翼;12月5日,爆出游戏漏洞,可以利用随机团队副本bug从一个BOSS身上反复获取装备,死亡之翼被高端公会刷烂。 从不可一世的强大,到人见人欺的寂寥,这一切在暴雪的导演下,只需要一周不到的时间。艾泽拉斯的凡人们以不可思议的速度改变着他们的世界:存在于这个星球上上万年的生物被轻而易举地消灭,他们也和这个世界的创造者建立了沟通与联系。这个世界已经没有能够阻挡他们的力量了,无数的传奇和知名形象一个接一个地轰塌。在暴雪的世界里,似乎形象的塑造不需要成本,而他们的终途不过是凡人们通向更高的基石...这一切,是不是来的太快,也太突然? 别了,耐萨里奥,是你见证了WOW的由盛转衰,是你见证了我离开艾泽拉斯这个世界的前前后后。你的那一份孤狂与高傲无人能懂,因为还没有人能够站在你那凛冽的高度俯视众生。也许你会有无数的继任者,但是那与你已经毫无关系。希望这是你的终结,希望你能得到安息。 别了,耐萨里奥,你需要记住,你不需要祭文,也没有人会为你哀伤... URL: https://onevcat.com/2011/12/debug-2/index.html.md Published At: 2011-12-03 00:11:55 +0900 # Objective-C中的Debug表达式 有程序的地方就有bug,有bug的地方就需要debug。对于程序员来说,coding的过程便是制造bug和解决bug。Objective定义了不少表达式来协助debug的流程,将这些表达式用在NSLog中,可以简化部分工作,快速定义到debug的部分。 比如以下代码: ```objc -(id) initWithPlayer:(VVPlayer *)aPlayer seatsNum:(int)seatsNum { if (self = [super init]) { NSLog(@”\n Function: %s\n Pretty function: %s\n Line: %d\n File: %s\n Object: %@”,__func__, __PRETTY_FUNCTION__, __LINE__, __FILE__, aPlayer); } … } ``` 输出结果为: `__func__`, `__PRETTY_FUNCTION__`, `__LINE__`, `__FILE__`等都是系统预留的定义词,简单易用。 另外有一些Core Foundation的方法可以从CFString的层级拿到一些有用的字符串,包括且不限于selector,class,protocol等,参考下面的代码: ``` -(id) initWithPlayer:(VVPlayer *)aPlayer seatsNum:(int)seatsNum { if (self = [super init]) { NSLog(@”Current selector: %@”, NSStringFromSelector(_cmd)); NSLog(@”Object class: %@”, NSStringFromClass([self class])); NSLog(@”Filename: %@”, [[NSString stringWithUTF8String:__FILE__] lastPathComponent]); } … } ``` 输出结果为: 拿到了相关的字符串,其实这并不仅在调试中有用,在进行selector的传递时也很好用~ URL: https://onevcat.com/2011/11/nsurl/index.html.md Published At: 2011-11-30 00:06:37 +0900 # 关于 NSURL 的解析和编码 NSURL毫无疑问是常用类,有时候我们需要对一个url进行分析整理,当然是可以按照RFC 1808的定义去自己分析,但是万能的Apple大大已经在SDK里扔了不少方法来帮助解析一个url了…方便又快捷呐~比如下面的输入: ```objc NSURL *url = [NSURL URLWithString: @"http://www.onevcat.com/2011/11/debug/;param?p=307#more-307"]; NSLog(@“Scheme: %@”, [url scheme]); NSLog(@“Host: %@”, [url host]); NSLog(@“Port: %@”, [url port]); NSLog(@“Path: %@”, [url path]); NSLog(@“Relative path: %@”, [url relativePath]); NSLog(@“Path components as array: %@”, [url pathComponents]); NSLog(@“Parameter string: %@”, [url parameterString]); NSLog(@“Query: %@”, [url query]); NSLog(@“Fragment: %@”, [url fragment]); ``` 将得到以下输出: ![](http://www.onevcat.com/wp-content/uploads/2011/11/url_result.png) 没什么值得多说的~相当方便就能得到所要结果的方法~ 另外,在由`NSString`生成`NSURL`对象时,有可能会出现`NSString`中包含百分号各类括号冒号等对于url来说的非法字符如果直接进行转换的话将得到nil。在对于复杂url进行转换前,可以先试试对待转换的NSString发送 `stringByAddingPercentEscapesUsingEncoding:` 将其转换为合法的url字符串(其实目的就是保证非法字符用UTF8编码..) 比如这样: ```objc NSString *fixedStr = [reqStr stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; ``` URL: https://onevcat.com/2011/11/debug/index.html.md Published At: 2011-11-26 23:58:57 +0900 # Xcode4.2的debug小技巧 [GNU Debugger(gdb)](https://en.wikipedia.org/wiki/Gdb)一直是UNIX下最为流行的调试器,而在Cocoa框架中也一直被作为默认的调试工具。在gcc都被LLVM取代了的如今,gdb还是作为默认调试器,更可见其优秀特性。 最近在调试过程中发现了一些小窍门或者说是小技巧,不敢独飨。也许调试在大多数人看来不过是切断点,run程序,断住,然后开始分析。很多时候我们需要在gdb中一行行敲命令去控制gdb的运行,而如果我们右击代码段左侧的断点标记,可以发现一个很有趣的菜单,那就是Edit Breakponit。 然后你会发现,原来断点和调试器如此强大。不同的action可以解放你的双手完成无脑傻瓜调试,比如自动在断点处输入调试命令,按照需求输出log,为断点次数计数,执行shell命令,捕捉断点时的屏幕状态等,更甚至于可以运行AppleScript。熟悉AppleScript的朋友便知道这意味着什么,你甚至可以在gdb上外挂上自己的另外的程序,这是无数的可能。这种方式让我联想到用lua和python这样的脚本语言制作的各类插件,而AppleScript一向是被低估的语言(很大程度上是由于平台局限)。但是显然现在AppleScript可以用来作为一个gdb的插件语言,保不准明天它就会成为iOS设备上的标准插件语言,如果Apple有这样的气魄、决心和能力让AppleScript可以运行在iOS设备的话,对于所有的Cocoa开发者来说,都将是一场激动人心的革命(好吧因为系统的封闭性被打破,这也许对用户会是灾难..)~ 总之,断点设置不错,配合AppleScript的话很好很强大,花上5分钟研究研究断点设置能实现的功能,一定能提高gdb的使用效率~ URL: https://onevcat.com/2011/11/kayac/index.html.md Published At: 2011-11-26 00:02:48 +0900 # 尘埃落定,下一站Kayac **AHRP2012秋季项目已经结束,春季项目博主可以以内定者的身份进行内推,具体可以参看[这篇帖子](/2012/01/ahrp2012/)。** 从10月初开始到现在,一个多月的求职尘埃落定,没有意外的话我明年将在日本镰仓(Kamakura)的Kayac公司开始自己的职业生涯。回顾这一个多月来的心情沉浮,颇有收获:从一开始的国内公司连连被拒,到之后几近看到出路,到最后寻找到Kayac,一路过来似乎有些宿命的感觉,而在一次又一次的笔试面试谈话中,确实渐渐看清了今后的道路和心中的诉求。这一个多月里,思考了很多人生的问题,整个人成熟了不少。 Kayac有个日文名字叫做“面白法人”,中文里是“有趣的公司”的意思。一个公司能取出这样的名字,足以证明这是一个富有想象力的有趣的地方。这其实本身就和我略有玩世不恭的个性有几分呼应,在之后和公司CEO柳泽先生和CTO贝畑先生聊过后觉得他们的气场确实很吸引我,这正是我所寻觅的一份工作。而好像恰好我在面试中所表现的技能和性格也很合他们的意,所以双方似乎并没有太多的犹豫和纠结,很开心地就把这件事情定下来了。 在国外生活对我这个土包子来说已经是很大的挑战了,现在还加上是在国外开始自己的职业生涯,那将会是一个莫大的挑战了。一开始当然会是新奇感占据上风,但是随着时间的积淀,各种未知的因素自然会浮现出来。虽然在面试时信心满满地表示适应新的生活完全不是问题,但是到底需要多久才能随心无拘,自己也并不知道。还好我想来不是一个依赖环境的人,也常常喜欢独立,所以也许现在的担心都是多余的吧~ 到今天为止,求职算是告一段落,近期的重点将转到学术实验和语言学习上了。 从这里开始,职业生涯的序章已经拉开,希望我能将之后的篇章谱写的精彩有趣。 URL: https://onevcat.com/2011/11/rest/index.html.md Published At: 2011-11-20 23:57:19 +0900 # 难得的休假 海南是个不错的地方,十多年前来就很不错,现在的话貌似也还行。难得能在这种忙得死人的时候能拿出三天时间跑到海南放松一下…刚做完一个会议报告就被大家围着提问,十分钟的演讲,提问居然持续了十五分钟,这不是明摆着坑我么...看来之前的准备还是过于浅显了。下次有这样的会议报告的话一定努力做得让大家看不懂才行。 讲完以后现在可以开始休息两天了~这半年来被各种杂务缠身,不过貌似现在已经习惯这样的忙碌生活了,突然有机会放松下来心里居然会有一些不安。而幸好,海南正好是一个能够让人轻松下来的地方,只要躲开白天的下饺子,选择半夜三点到海边吹风,在静谧的海滩上倾心聆听。深夜里视觉的丧失,能换来听觉的敏锐,由耳及心,去感受那一份独特的宽广和优雅,这样的体会在能看到喧闹的海浪的时候是永远体会不到的。 URL: https://onevcat.com/2011/11/objc-block/index.html.md Published At: 2011-11-09 23:55:12 +0900 # Objective-C中的Block 技术是需要沉淀的。接触iOS开发也有大半年时间了,从一开始的纯白到现在自我感觉略懂一点,其实进步是明显的。无数牛人表示技术博是完成菜鸟到高手蜕变的途径之一,虽然这个博客并非是为技术而生,但是也许作为工科背景下的我来说,每天都写文艺的东西显然并不现实。于是就有了这个集子:能工巧匠集。用这篇开篇, 写一些在开发过程中的积累和感悟,大部分应该是Objectiv-C和XCode的内容,包括基本语法特性和小技巧,或者自己喜欢的一些开源代码的用法分析等等。也许以后会扩展到Unity3D或者UDK的一些3D引擎的心得,当然也有可能会有别的一些自己认为值得分享的东西。这个集子的目的,一来是记录自己一步一步成长的脚印,二来也算是为新来者铺一条直一点的道路。集子里的东西仅仅是自己的心得体会,高手路过请一笑置之..感恩。 iOS SDK 4.0开始,Apple引入了block这一特性,而自从block特性诞生之日起,似乎它就受到了Apple特殊的照顾和青睐。字面上说,block就是一个代码块,但是它的神奇之处在于在内联(inline)执行的时候(这和C++很像)还可以传递参数。同时block本身也可以被作为参数在方法和函数间传递,这就给予了block无限的可能。 在日常的coding里绝大时间里开发者会是各种block的使用者,但是当你需要构建一些比较基础的,提供给别人用的类的时候,使用block会给别人的使用带来很多便利。当然如果你已经厌烦了一直使用delegate模式来编程的话,偶尔转转写一些block,不仅可以锻炼思维,也能让你写的代码看起来高端洋气一些,而且因为代码跳转变少,所以可读性也会增加。 先来看一个简单的block吧: ```objc // Defining a block variable BOOL (^isInputEven)(int) = ^(int input) { if (input % 2 == 0) { return YES; } else { return NO; } }; ``` 以上定义了一个block变量,block本身就是一个程序段,因此有返回值有输入参数,这里这个block返回的类型为BOOL。天赋异秉的OC用了同样不走寻常路的"{% raw %} ^{% endraw %}"符号来表示block定义的开始(就像用减号和加号来定义方法一样),block的名称紧跟在{% raw %} ^{% endraw %}符号之后,这里是isInputEven(也即以后使用inline方式调用该block时所需要的名称)。这段block接受一个int型的参数,而在等号后面的int input是对这个传入int参数的说明:在该block内,将使用input这个名字来指代传入的int参数。一开始看block的定义和写法时可能会比较痛苦,但是请谨记它只是把我们常见的方法实现换了一种写法而已,请以习惯OC中括号发送消息的速度和决心,尽快习惯block的写法吧! 调用这个block的方法就非常简单和直观了,类似调用c函数的方式即可: ```objc // Call similar to a C function call int x = -101; NSLog(@"%d %@ number", x, isInputEven(x) ? @"is an even" : @"is not an even"); ``` 不出意外的话输出为**-101 is not an even number** 以上的用法没有什么特别之处,只不过是类似内联函数罢了。但是block的神奇之处在于block外的变量可以无缝地直接在block内部使用,比如这样: ```objc float price = 1.99; float (^finalPrice)(int) = ^(int quantity) { // Notice local variable price is // accessible in the block return quantity * price; }; int orderQuantity = 10; NSLog(@"Ordering %d units, final price is: $%2.2f", orderQuantity, finalPrice(orderQuantity)); ``` 输出为**Ordering 10 units, final price is: $19.90** 相当开心啊,block外的`price`成功地在block内部也能使用了,这意味着内联函数可以使用处于同一scope里的局部变量。但是需要注意的是,你不能在block内部改变本地变量的值,比如在{% raw %} ^{% endraw %}{}里写`price = 0.99`这样的语句的话,你亲爱的compiler一定是会叫的。而更需要注意的是`price`这样的局部变量的变化是不会体现在block里的!比如接着上面的代码,继续写: ```objc price = .99; NSLog(@"Ordering %d units, final price is: $%2.2f", orderQuantity, finalPrice(orderQuantity)); ``` 输出还是**Ordering 10 units, final price is: $19.90,**这就比较忧伤了,可以理解为在block内的`price`是readonly的,只在定义block时能够被赋值(补充说明,实际上是因为`price`是value type,block内的`price`是在申明block时复制了一份到block内,block外面的`price`无论怎么变化都和block内的`price`无关了。如果是reference type的话,外部的变化实际上是会影响block内的)。 但是如果确实需要传递给block变量值的话,可以考虑下面两种方法: 1、将局部变量声明为`__block`,表示外部变化将会在block内进行同样操作,比如: ```objc // Use the __block storage modifier to allow changes to 'price' __block float price = 1.99; float (^finalPrice)(int) = ^(int quantity) { return quantity * price; }; int orderQuantity = 10; price = .99; NSLog(@"With block storage modifier - Ordering %d units, final price is: $%2.2f", orderQuantity, finalPrice(orderQuantity)); ``` 此时输出为**With block storage modifier – Ordering 10 units, final price is: $9.90** 2、使用实例变量——这个比较没什么好说的,实例内的变量横行于整个实例内..可谓霸道无敌...=_= block外的对象和基本数据一样,也可以作为block的参数。而让人开心的是,block将自动retain传递进来的参数,而不需担心在block执行之前局部对象变量已经被释放的问题。这里就不深究这个问题了,只要严格遵循Apple的thread safe来写,block的内存管理并不存在问题。(更新,ARC的引入再次简化了这个问题,完全不用担心内存管理的问题了) 由于block的灵活的机制,导致iOS SDK 4.0开始,Apple大力提倡在各种地方应用block机制。最典型的当属UIView的动画了:在4.0前写一个UIView的Animation大概是这样的: ```objc [UIView beginAnimations:@"ToggleSiblings"context:nil]; [UIView setAnimationTransition:UIViewAnimationTransitionCurlUp forView:self.view cache:YES]; [UIViewsetAnimationDuration:1.0f]; // Make your changes [UIView commitAnimations]; ``` 在一个不知名的小角落里的begin/commit两行代码间写下需要进行的动作,然后静待发生。而4.0后这样的方法直接被discouraged了(虽然还没Deprecated),取而代之的正是block: ```objc [UIView animateWithDuration:5.0f animations:^{ view.opacity = 0.5f; }]; ``` 简单明了,一切就这么发生了.. 可能有人会觉得block的语法很奇怪,不像是OOP的风格,诚然直接使用的block看起来破坏了OOP的结构,也让实例的内存管理出现了某些“看上去奇怪”的现象。但是通过`typedef`的方法,可以将block进行简单包装,让它的行为更靠近对象一些: ```objc typedef double (^unary_operation_t)(double op); ``` 定义了一个接受一个double型作为变量,类型为unary_operation_t的block,之后在使用前用类似C的语法声明一个unary_operation_t类型的"实例",并且定义内容后便可以直接使用这个block了~ ```objc unary_operation_t square; square = ^(double operand) { return operand * operand; } ``` 啰嗦一句的还是内存管理的问题,block始终不是对象,而block的内存管理自然也是和普通对象不一样。系统会为block在堆上分配内存,而当把block当做对象进行处理时(比如将其压入一个NSMutableArray),我们需要获取它的一份copy(比如[square copy]),并且在Array retain了这个block后将其释放([square autorelease]是不错的选择)。而对于block本身和调用该block的实例,则可以放心:SDK会将调用block的实例自动retain,直至block执行完毕后再对实例release,因此不会出现block执行到一半,实例就被dealloc这样的尴尬的局面。 在ARC的时代,这些都是废话了。打开ARC,然后瞎用就可以了。ARC解决了block的最让开发者头疼的最大的也是唯一的问题,内存管理。关于block的内存管理方面,有一个很好玩的小quiz,可以做做玩~[传送门](http://blog.parse.com/2013/02/05/objective-c-blocks-quiz/) iOS SDK 4.0以后,随着block的加入很多特性也随之添加或者发生了升级。Apple所推荐的block使用范围包括以下几个方面: * 枚举——通过block获取枚举对象或控制枚举进程 * View动画——简单明了的方式规定动画 * 排序——在block内写排序算法 * 通知——当某事件发生后执行block内的代码 * 错误处理——当错误发生时执行block代码 * 完成处理——当方法执行完毕后执行block代码 * GCD多线程——多线程控制,关于这个以后有机会再写… 仔细研读4.0的SDK的话,会发现很多常用类中都加入了不少带block作为参数的方法,改变固有思维习惯,适应并尽可能利用block给程序上带来的便捷,无疑是提高效率和使代码优雅的很好的途径~ URL: https://onevcat.com/2011/10/seeking-job/index.html.md Published At: 2011-10-31 23:52:17 +0900 # 近期求职总结-AHRP和DeNA面试 **AHRP2012秋季项目已经结束,春季项目博主可以以内定者的身份进行内推,具体可以参看[这篇帖子](/2012/01/ahrp2012/)。** 在求职路上已经走了一段时间了,一直不太顺利,趁现在有时间有心情稍微整理一下近期在求职中的感受,也算做一个阶段总结吧。 其实一开始目标就比较明确了,不太想继续在做硕士期间的方向走下去,而是想做一些移动互联开发相关的事情,同时也看好智能电视和近距传输这样有可能在近期再一次改变人类生活的东西。所以简历大都扔到了IT公司。投出去的简历无非三种结果:第一是毫无回音的,比如支付宝;第二是通知笔试但是被我拒了,比如盛大;最后确实比较诱人的企业,比如网易腾讯之流,但是参加完笔试就毫无音讯——作为一个学IC的,要去和软院的孩子们比默写最短路径之类的无聊算法实在有些强人所难,以及无趣。其实真正全心投入了的恐怕只有AHRP项目过来的DeNA的面试了吧。 AHRP项目很赞,最初其实只是在C楼紫超下楼拐角看到个易拉宝而已,结果很巧的发现了DeNA。可能在移动互联这个行业也干了大半年了,每天算是耳濡目染,对比如Gameloft,DeNA或者Mixi这样的公司的名字已经有天然的感觉了吧。而且能到国外工作生活一段时间对自己人生来说也是一件很有意义的事情,就决定参加了。AHRP在清华的宣讲会场面很火爆,我也很幸运地抢到了最后一把椅子,而宣讲会上武田先生和小畑先生的表现也都很让人印象深刻。和中国企业的招聘会的不同之处在会后收简历的时候就已经体现出来了:一共有大概三到四个人在收简历,而每个学生交简历的时候AHRP的工作人员都会和学生简单确认求职意向、专业情况,并且尝试了解一些学生最初的想法并且在简历上圈点。DeNA这次在中国的招聘来得很早,因此我被提醒要保持手机通讯通畅,以便及时联系。 其实当天晚上就收到了AHRP的一面通知,第二天就开始了AHRP和DeNA的面试旅程。在网上也翻找过一些AHRP的面经,基本上这个项目的面试或者笔试会是一个漫长的过程,但是由于面试性质和国内的传统意义上的面试不太一样,所以也还算轻松。比较标准的流程大概是6面,有部分企业的话还会要求一次笔试。前三次面试是AHRP组织的,确认你的专业情况和去日本的动机,基本上就是作为初筛,把觉得值得向企业推荐的人推荐给企业进行面试,鉴于中国毕业生现在的海投简历的习惯,这相当于减小了企业组织面试的压力。后三面属于填报的企业进行的面试,主要考察是否和公司文化匹配以及基本的职业技能。而笔试常常在企业一面前后作为企业筛选的一道关卡吧。 一面很幸运遇到了小畑先生,和大多数面试官不同,小畑先生似乎天生就有一种亲和力,和他的对话令人非常开心,并且能很快的放松下来。这大概就是个人魅力的所在吧,也难怪小畑先生会被誉为日本人力资源界的神话。问题都很随和简单,整个过程就是在聊天,主要是了解你为什么想去日本。其实这个问题对于每个中国学生来说可能答案都会不一样,会来关注AHRP项目的学生基本上都会对海外工作有种朦胧的向往,期待着能在异国找到理想的生活和不错的薪资。但是若要真正的让大家说出自己的想法的时候,理由往往会变得冠冕堂皇起来,比如开阔眼界、创新精神这样的回答。我想大概所有AHRP的面试官都对这样的回答不会陌生,甚至估计耳朵都听出茧子了吧..但是无疑这会是最保守最靠谱的答案。但是除此之外,可能真的需要思考为什么自己会来参加AHRP,为什么自己会想要到日本工作?Listen to your heart,让你的心来作答,也许是最好的选择。如果你只是有种朦胧的向往,却拨不开那层迷雾,看不到自己真实的想法,那日本的忙碌的生活和高强度的工作也许并不是想像中的那样美好。 由于DeNA招人很急,AHRP的二面貌似被省略了,取而代之的是拿到了一面通过的通知,同时被告知需要提交一份技术报告和参加笔试。技术报告很开心的完成了,毕竟大半年的兼职让我确实有不少可以能拿得出手的项目,而这些项目对于一个应届生来说绝对是难能可贵的财富。笔试比较有趣,看起来是DeNA组织的,大概是是一份给日本本土员工的智力测验+性格测验题。有技巧的算术计算,图形规律,按指令变换图形和按图形推断指令,总之都是很传统的智商测验的题目,这点我很有优势——因为小时候自己做过相当多的智商训练题目,而那些题目大多要比这次笔试的难很多。我个人觉得这种笔试题的方向很好,因为它测试的是人的逻辑思维和观察能力,而这两者大概是完成工作所最需要的素质(当然在中国这样人际关系复杂的地方可能还不够),而我很讨厌那种考一个迷宫算法或者排序算法的无聊的默写代码题,在这样一个时代默写算法已经解决不了任何问题了,但是中国很多公司还是乐此不疲,真是悲哀.. 很顺利接到三面通知,AHRP的三面貌似不太刷人,大概就是确认下你的意向是否有变,同时稍微考察下你对要应聘的公司的了解吧。三面我遇到了中国的面试官,大概为了效率,一个面试官同时面了四个人。我好像一直不太适应这种一对多的面试,发挥其实不太好,回来还是忐忑了一阵子。不过也许确实三面不刷人,也很开心的接到了通过的通知。 企业一面,在DeNA的话是一个简单的技术面,有一个日本的人事和一个中国的技术做面试官。会对你之前提交的技术报告做一个简单的了解。我本来会很有优势,因为我的技术报告不需要我过多的介绍,拿出一台iOS设备来run一下自己的成果就好了。但是突发状况让我很郁闷,前一天的bug还没有修正直接导致了app打不开...还好之前就说过这还是在测试开发中的版本,然后很不介意的打了圆场说技术二面还有机会的话再给你们展示,然后blablabla答了一些问题。大概是花了多长时间做出来,有多少人的团队完成的项目,在团队中担任什么角色等等。除了项目的话也还问了些关于之前实习的情况。总之如果确实有很好的项目经历的话,如实介绍就好了~ 当晚收到企业二面的通知——技术二面。面试官比之前的一面多了一个日本的技术人员,其实就是对你的项目进行深入的了解。没什么说的,同样的错误不可能犯第二次,而且他们也都到App Store下载装上了,于是一边介绍一边玩,时间哗哗的就过去了。我估计他们一整天坐那面试也很烦闷了,现在难得有个机会还能正经地玩会儿游戏,都很开心的样子。于是我知道技术面肯定是OK了.. 在网上看到的面经一般公司就两面,然后第三面看看人没问题就签了。所以收到企业三面的通知看到是人事面的时候还是担心了一下的。而事实证明了我在面对人事面试的时候准备和经验也确实还不够充分。人事面基本是以了解你性格特点为目的的,大概问题就是很经典的你觉得成功的事情啦,遇到的挫折啦之类的。其实和面试官聊得不是很开心吧,我想是因为时间选的不好,感觉下午四点半面试官已经蛮累了。加上貌似翻译上面出了一点小问题,导致了他提问的问题被误解了,最后我的回答和他的问题对不上。虽然后来我发现了,补正了几句,但是不好的印象已经留下了,可能也于事无补了吧~ 人事面出来就大概知道可能死在了这最后一坎上,一开始还有一点侥幸心理,希望能够靠技术面的表现能有一个argue的机会,不过最后证明我在这点上的想法还是过于天真了。当天晚上没有能够等到通过的通知,基本上还是难过到了后半夜才有睡着——毕竟是一个为之付出了不少心血的项目,却最后在本不该失败的地方跌到了,失去了这样一个机会,自己都觉得惋惜。 不过生活总是还要继续,得之我幸,失之我命~一扇门关闭了,你才能够走到另一扇门中去发现那里的世界。正如我在简历上写的,平淡对待得失,冷眼看尽繁华,现在的我,其实离那样理想的境界更近了一步。 URL: https://onevcat.com/2011/10/xcode4/index.html.md Published At: 2011-10-27 23:48:33 +0900 # Xcode4.2,想说爱你不容易 随着iOS5,最终还是在一个项目结束之前就被迫换到XCode4.2了。XCode4初出的时候就有无数先辈惨死在无尽的bug和极度不适中,而我选择了在一段时间的4.1和3.2.6共存的过渡期后再完全转到新版本下继续工作,现在看来是非常明智的。 GCC在4.2彻底再见了,同样标着4.2,但是想在XCode4.2上弄个GCC4.2的编译器还真是费力。还好LLVM还不错,就是可怜了那些写了N多GCC only的stand-alone的苦逼程序员了。LLVM最终还是暴露了了Apple想要脱离GNU的目标,不过新的编译环境给人的感受确实很棒。得益于LLVM,coding的时候实时能看到代码错误,ARC的引入彻底让人可以忘掉繁琐又无聊的GC策略,从而可以专注于内容实现。这在以前的IDE中是难以想象的。 功能的整合与集成也很方便,Interface Builder终于内嵌了,IBOutlet和IBAction前面也加上了小圆圈,这样一来很不容易再出现忘了拉线这样尴尬的失误了。不知道什么时候能把Instrument也整合到XCode里来,那样生活会更美好=。= 当然也有让人不满的地方,或者说是我还不懂的地方。为什么新工程的默认设置是禁用C和Objective-C的异常捕捉呢...拖别人的代码的时候基本都会有try..catch,于是每次都要去手动改..满讨厌,不知道是不是有什么新的异常处理的机制?另外iOS5的SDK还是有些问题,segment的property设置不再调用change方法把我坑了一个下午,ASI的一个消息也会莫名中断...完美的iOS5 SDK和相应的开源库毕竟还需要时间。嗯...还有一点就是我的机子跑XCode4真的好慢! 最后抱怨下...习惯了写release以后现在在ARC的光芒下写release就被打红叉,每次都苦笑..看来习惯它还真的是需要一定时间呢~ URL: https://onevcat.com/2011/10/pandaria/index.html.md Published At: 2011-10-22 23:46:18 +0900 # 潘达利亚,你好 因为一直忙着找工作,最近对各种消息新闻的关注很少。早上起来难得有时间看看新闻,发现BlizzCon2011最终还是没有让人失望,一年一部的WOW资料片又被推上了前台。潘达利亚之雾(Mists of Pandaria)的背后,熊猫人最终还是被BLZ作为填充新作空白期的大菜呈递了出来。虽说整个WOW主线和熊猫人其实没有什么关系,但是作为War3的重要英雄之一,熊猫人的出现倒也还算是合情合理吧。 一如既往的,新的种族,新的职业,新的大陆,新的副本,新的系统,一切看起来都很美好。但是...仔细发觉一下就会发现有不少问题。小宠物对战系统很是让人无语,我能吐槽这整就一个宠物小精灵么?螳螂人蘑菇人XX人也不禁让人看到了不少"中国玄幻类"免费网游的影子。而对于天赋的修改也让人依稀看到了Diablo的方式——莫非这就是BLZ的终极目标星际魔兽大菠萝3in1的重要一步? CTM毫无疑问是失败的,因为副本模式的变化导致了玩家黏合力的下降,随之而来的是批量AFK的浪潮。而MOP中却没有对这个关键因素做出修改。4.3也还没来,现在谁也不知道休闲模式的效果如何,但是由于所谓的休闲模式出产实在鸡肋,估计也难以成功。倘若MOP完全沿用现在的模式的话,其失败已然是可以预见的了。 或许这正是BLZ想要的吧,因为没有谁能有精力同时投身两个大型RPG,买一年WOW送终身Diablo也许能说明一些问题——想要把死忠都赶到新的世界中去。七年风雨,七年辉煌之后,WOW的时代终要过去了,而网络游戏新的篇章却正要开启... URL: https://onevcat.com/2011/10/zhaopin/index.html.md Published At: 2011-10-15 23:43:39 +0900 # 求职季 又到了一年一度的求职季~招人者基本年年不变,求职者换了一批又一批。年复一年,却也正见证了这个社会的新陈代谢。简历已经扔出去一些了,但是事实上特别满意的或者特别想要拿到的职位并不多..也许是因为这么多年书读出来,已然迷失了自己,我现在很难安静下来询问自己的内心:你想做什么,你能做什么?往往一思考到这个问题,我就选择逃避..我会告诉自己,我想要的很简单,安静恬逸的小生活而已——虽然我知道这不是真的,这只是我多年前的梦想罢了... 也许人都会经历这样的过程吧,从满是理想主义的充满幻想到被现实主义约束的看清事实,最后又看透这个世界,返璞归真。前两个阶段是都能达到的,但是第三阶段看似是需要一些“修为”了的。也不知这样的境界我是否能够看到.. 不过现在最要紧的好像是找到份自己还算喜欢的工作吧~ 今天有两场笔试,上午10点一场,我本来以为剩下的时间不够写完的。但是貌似由于来参加的人太多,导致考场爆满,没参加网申的人现在被陆续赶出去了~于是笔试华丽的推迟了几分钟... 祝自己求职顺利,一帆风顺吧,加油! URL: https://onevcat.com/2011/10/iphone/index.html.md Published At: 2011-10-07 23:41:23 +0900 # 乔帮主,一路走好 一个时代的终结,意味着另一个时代的开始。一个巨匠的陨落,代表着一颗新星的诞生。1642年伽利略带着“追求科学需要特殊的勇气”,以自己的溘然辞世宣告了旧时代的终焉。一年之后,牛顿呱呱坠地,预告了新的物理学时代的开幕。当世之时,乔布斯所代表的便是对旧秩序的挑战以及对完美创新的不懈追求。 当我们一遍一遍地被IBM和Wintel联盟强奸的时候,乔布斯犹如救世主一般一次又一次降临。1984对蓝色巨人的挑战,1998的强势回归,唯美的工业设计追求一直给予消费者无尽的享受。我相信没有其他人能设计出iMac的半透明一体机箱,也没有人能设计出双面玻璃金属圈天线,或者是哪怕玻璃旋转楼梯这样的产品。正如Apple官网所述,一位杰出的,了不起的人物告别了世界。 而几乎是同时,iPhone4终于迎来了自己的接任者。与其把iPhone4s解读为iPhone Second,不如把它认为是iPhone4 Steve——这是乔布斯留给世界的最后的礼物...iOS5的新鲜特性,iMessage向传统电信运营商强有力的挑战,Siri俨然让人看到了iRobot的希望。创新依旧,美丽依旧,对传统权威的挑战也依旧。 但是,它确实缺少了一些东西..缺少了背后的一位伟人的关注。 乔帮主,一路走好...你没有离去,我们将铭记你的教诲, >Stay hungry. Stay Foolish 你的设计和思想将在另一个世界继续发光。因为你的离去,一定是因为上帝也想用iPhone了... URL: https://onevcat.com/2011/10/mai-ke-ji-man-sai/index.html.md Published At: 2011-10-03 23:38:59 +0900 # 麦克基满塞 出游总是会看到新的东西,总是会给人带来惊喜,总是能令你开阔眼界。我素来知道中国的山寨事业一直兴旺发达,但是今天在长沙汽车站看到的这家店确实震惊了...名字霸气就不说了,以前类似的店 面也就记得有个“麦肯基”,如今连德克士也不能幸免了~关键在于,这家店的位置好的出奇.就紧靠汽车站大门..走过路过完全没有可能错过啊...嗯,当然 边上也还是有传统的车站餐馆K记,不过悲剧的是正牌K记被挤到了一条街之外。 嗯...这家店边上嘛,貌似附送了一家传说中的阿迪王...长沙是个蛮囧的地方..至少汽车站感觉是这样的 =_=~祝我旅途愉快吧..吼吼 URL: https://onevcat.com/2011/09/luan-shi-zhi-qiu/index.html.md Published At: 2011-09-22 23:37:52 +0900 # 乱世之秋 这是和平的年代,没有世界大战,也没有美苏争霸。这是混乱的时代,到处政治动乱,左右都是纷争。利比亚战乱尚未平息,也门又掀乱局。利益分配的戏码今年唱罢明年再演,丝毫没有新意..只是中国在这场利益分配的游戏中貌似永远拿不到自己的那份蛋糕,真不知是可歌可泣还是可悲可叹。 这个秋天来得似乎比以往都早..北京三场秋雨下过,暑气便散得无影无踪,出门一件单衣俨然已经过于薄弱了。身边的一切 显得井井有条,学习、实验、测试、代码,早出晚归,披星戴月,虽然过得不算轻松,但却也还是愉快幸福。诚然我在这个表面的治世下活得还算潇洒,比起各种不幸来,我确实是幸运的。但是有时候总是在想,自己期望的到底是什么,我真正想要的生活到底是什么。呵呵,可能所有我这年纪或者比我还年轻一些的的同龄人都在考虑这样的问题吧。我也许会说我想要的不是那种平淡一生、安安静静的生活,我不满,我需要闯荡,也需要激情。但是心里却会有莫名的惧怕,惧怕失败的可能,也惧怕激情以后那无尽的繁杂。退却的心理便占据了上风,做事的时候只能蹑手蹑脚..但是转念想想,即使失败,又能失去什么呢?确实,我没有什么可以失去的东西,而这正是我最轻松最可能放手一搏的时机呐。人就是矛盾的生物吧,也许什么时候我不再思前想后了,什么时候心中有一份执着的追求了,也许我就能找到我的答案了吧。 可能我需要的是一个乱世?唔,一个技术的乱世,也许就够了吧。笑。 URL: https://onevcat.com/2011/09/windows-slatezheng-zhuang-dai-fa/index.html.md Published At: 2011-09-14 23:36:37 +0900 # Windows Slate,整装待发 决定稍微晚睡一两小时,看看传说中的Win8到底是什么德行。接近发布的最后时刻,一直神秘的Win8终于决定了真正的名字——Slate...唔,确实总是不能叫做WinPad的吧... Metro,Start页面,支持arm,内置应用商店...其实这一切都透出了一股浓浓的山寨味。 Macintosh被华丽剽窃的悲剧已经上演过一次,而如今微软又想故伎重演,估计这次苹果再笨怎么也不会重蹈当年的覆辙了吧~ 其实个人并不是很看好这款Windows,Ribbon的滥用也许能在平板设备上能有所建树,但是在传统PC上却略显臃肿和无趣了。微软如果不能巩固住其在PC系统领域的霸主地位,而又想急于进入已经被iOS和Android充分瓜分的移动市场,最终的结果会不会是两边不讨好呢...怀疑中? 坐看微软是否能把两强争霸的局面硬生生拖入三足鼎立吧.. 毕竟多元化才能真正激发这个行业的创新,才能继续推动IT的发展,才能最终使人类受益吧。 不写了,直播开始了..最后...希望WinSlate起航顺风,一路走好...还有..希望WinXP在国内的占有率能掉下来..XD... URL: https://onevcat.com/2011/09/shi-nian/index.html.md Published At: 2011-09-11 23:34:15 +0900 # 十年 倏忽之间,十年已逝。于一个历史事件而言,十年时间也许还不足以给出公允的评判;但于生者或逝者而言,却已然是应当忘怀的时候了。纵然一个国家的政府有再大的过错,但是国家的人民永远是无辜的。在这样的时刻,惟有为逝者哀悼,为生者祈福。 过去的十年,两次经济危机,两次局部战争,美国的霸主地位依然不可动摇。这十年间,美国更多地把精力集中在了恐怖主义身上,中国这一曾经的假想敌也更多地被作为“反恐合作伙伴”而受到欢迎。不得不说这是中国的极大的发展机会,可惜的是,这十年于中国政府来说可能是大发展,但是于中国人民来说,可能却是大倒退。每个月可怖的CPI数据,一团混乱的税收体制,各种不安全的社会因素,拜金主义下的道德沦丧,触目惊心的贪腐及权钱交易,超高物价和超低收入之间的矛盾。一切的一切似乎都在暗示,中国的运转存在问题。但是庞大的行政机构导致中央与地方的行政令完全不统一,地方权力逐渐膨胀,中央约束日渐微势,纵有胡温这样的开明领袖依然难以扭转中国当今局势。人大被架空,各种所谓的“行政调节”层出不穷,层削盘剥,民众哀怨,已经是当今的社会现实。 明年是世界政坛领导人大换血的一年,中共换届,美俄大选,台府港府皆要换人,可有得热闹。明年之后中国要如何走,新的政府能否抓住国内主要矛盾,能否有有效的运转国家机器的手段,来保证她的国民的幸福,只能拭目以待了... 希望明天会更好。 URL: https://onevcat.com/2011/09/ban-jia/index.html.md Published At: 2011-09-06 23:32:54 +0900 # 搬家 最早开博于新浪,大概已经是六七年前的事情了吧。 之后本科入学,拜悲剧的cernet网络所赐,新浪博客直接无法登陆。偶然看到搜狐就在学校边上,小尝试一下发现速度奇快,于是毫不犹豫地搬家。 然后是稳定的写博期,然后人开始变懒就不怎么写东西了,再然后...再然后就没有了... 最近总被问“你玩微博么”,其实在twitter诞生之初的07年我就有帐号了..不幸的是周围没人和我有相同的爱好,加上智能手机不流行以及跨国短信贵死人等诸多因素,导致现在的twiiter上只有一个follower。而社交网络这种东西要是没人和你玩的话,估计自己连自己玩蛋的兴趣都不会有,于是后来加上伟大的党的伟大封杀,就彻底对微博没了胃口。不过就算这样我还是会很礼貌的把自己的新浪微博腾讯微博搜狐微博网易微博凤凰微博一股脑的告诉他,然后加一句:上面什么都没有哦,亲! 其实心底里还是觉得标准博客才是真正能写出东西的地方,而微博更多地还是掺杂有一些被追捧性质的信息时事交流的类快餐文化平台。真的很难想象在极度口语化的时代,微博那短短几十个字能够承载起文化的传承和积淀,而信息传播这一主要功能却又无时无刻不在“有关部门”的监管之下,似乎也难有作为。于是乎造成了中国微博看似繁荣,实则尴尬的局面。 重新开始写博,一方面是希望今后的生活能有些许积淀,能为以后的回忆留下可以追溯的足迹;另一方面也算机缘所致,恰好有这么个拾回以前习惯的机会。总之,何乐不为呢~?