「啪哒」是一款简单直观的孕妈助手小工具,帮助您轻松记录胎动和宫缩情况。
无广告、无弹窗,默默守护无打扰,让妈妈与孩子的交流更顺畅。
还有watchOS版本,便捷程度再上一个台阶:
P.S. 感谢夫人和孩子在怀孕过程中对上述情景的亲身验证,喂养和换尿片等记录功能已经在筹备,争取有机会继续折腾他们 :p
如果你还有其他建议和需求,欢迎联系:onetap@foxmail.com
]]>举个例子,假设我们正在开发一个文本编辑器,它除了支持纯文字的内容之外,还提供了 HTML 编辑和预览 PDF 文件的功能。
为了能复用文件处理逻辑的代码,我们统一使用结构体 Document
来表示我们的文件,并且通过 format
属性来区分文件的类型:
1 | struct Document { |
尽管这样做避免了很多重复代码,而且枚举的使用也很好地区分了不同数据,但是当我们要针对特定文件类型进行操作时,这样的写法就会带来不小的歧义问题。
比如,我们要针对纯文本文件实现一个打开编辑器的 API,它会假设传进来的参数类型都是纯文本的:
1 | func openTextEditor(for document: Document) { |
这个方法在传入 HTML 文件时可能不会有什么大问题,但如果传进来了一个 PDF 文件就可能让我们的 App 崩溃了。
在针对特定格式的文件实现逻辑时,我们会不断遇到这样的问题。再打个比方,我们现在要实现一个 HTML 的编辑器了:
1 | func openHTMLEditor(for document: Document) { |
那我们来尝试解决一下这个问题。首先想到的可能是用 switch
对传入的参数的 format
进行判断,然后再调用对应的方法。这种方式对纯文本和 HTML 文件很友好(因为我们在上面实现过它们的打开编辑器 API),然而对于 PDF 文件来说,我们可能就需要抛一个错误或者运行某条断言了:
1 | func openEditor(for document: Document) { |
上述方案依然有问题,因为它还是要求开发者自己去跟踪某种类型的文件和特定代码分支的关系,而且只有运行时才能发现某些分支里的潜在问题。
所以即便这个结构体看起来还比较优雅,但在实际使用时还是能感觉到不妥。
其中一种解决这个问题的方式是,把 Document
从一个实际的类型改为一个协议:
1 | protocol Document { |
这样改了之后,我们就可以针对不同类型的文件实现单独的类型:
1 | struct TextDocument: Document { |
这种改法的优点在于,它让我们具备了同时对通用类型和特定类型进行操作的能力:
1 | // 这个方法用于保存文件(不论类型),所以它的参数可以是任何实现了 Document 协议的对象: |
现在编译器就能帮我们检查方法调用的参数是否符合要求了,也就是说,我们最终把运行时对文件类型的判断提前到了编译时。这已经前进了一大步!
然而,这种做法也降低了我们的代码复用程度,因为我们现在是用协议来表示文件的,所以与文件相关的所有方法都需要我们在每一个具体的类型里写一遍。这甚至会波及我们未来可能支持的更多文件类型。
如果能找到一种方法能在编译时进行类型检查,同时还能保障我们的代码复用,那就太棒了。其实我们前面写过的其中一行代码里就给了我们提示:
1 | let text = String(decoding: document.data, as: UTF8.self) |
在进行 Data
和 String
的转换时,我们把期望的字符串编码格式传了过去,但我们传递的不是值,而是这个类型本身的引用。我们再深挖一层,在 Swift 标准库里对 UTF8
的声明是这样的:
1 | enum Unicode { |
实际上,
UTF8
枚举里有一个私有的 case,这是为了向后兼容 Swift3 而存在的。
这就是所谓的幽灵类型,即把类型当作一个标记来使用,而不会用它来声明对象。不过上面那些枚举里没有定义任何公开的 case,所以它们根本就不能被用来声明对象。
这对我们的文本类型困境有什么帮助呢?回到最初的结构体的实现方式,这次我们把 format
属性去掉,改成泛型:
1 | struct Document<Format> { |
类似 Unicode
那样,我们会定义一个 DocumentFormat
枚举,用它来充当命名空间的作用,然后分别定义三个没有 case 的枚举来表示文件类型:
1 | enum DocumentFormat { |
到目前为止,我们都没有用到协议。Format
只是用来充当一个运行时标记,任何类型都能约束我们的 Document
。接下来,我们就能把之前针对特定文件类型所写的 API 改成这样:
1 | func openTextEditor(for document: Document<DocumentFormat.Text>) { |
当然了,不指定任何约束也是可以的,比如之前用于保存文件的方法就可以这样写:
1 | func save<F>(_ document: Document<F>) { |
我们还可以进一步给不同类型定义一个别名,就像 UTF8
那样:
1 | typealias TextDocument = Document<DocumentFormat.Text> |
幽灵类型在我们需要给特定文件类型写扩展的时候也很好用,比如我们要给纯文本文件加一个设置字体的方法:
1 | extension Document where Format == DocumentFormat.Text { |
而且,因为幽灵类型只是普通的类型,所以我们还能让它们遵循其他的协议。比如在想要实现打印功能的时候,我们可以让 DocumentFormat
遵循 Printable
协议,在这个基础下再实现我们自己的代码。
尽管幽灵类型看起来不太像 Swift 本身的语法,然而,虽然 Swift 不像其他纯函数式语言(比如 Haskell)那样把幽灵类型当作语言里的一等公民来对待,但这种模式已经在标准库和其他苹果平台的 SDK 里被广泛使用了。
比方说 Foundation
里的 Measurement
API 用就幽灵类型来确保类型安全:
1 | let meters = Measurement<UnitLength>(value: 5, unit: .meters) |
这样就严格区分了两种计量单位,避免了开发者在一个需要长度单位的地方用了角度,就像我们在上文中区分文件类型那样。
幽灵类型是一种能让我们更好利用类型机制来区分变量的神奇技术。虽然幽灵类型会让代码看起来更冗长,而且泛型的使用看起来也更复杂,但是它却能帮我们把运行时才能发现的问题提前到了编译期间,让编译器能发挥出更大的作用。
不过,就像我们之前认为 Document
结构体看起来很美好那样,幽灵类型如果用错了地方,也可能是杀鸡用牛刀。也许本来很简单的流程,会被幽灵类型搞得很复杂。
到头来,选择合适趁手的工具才是最重要的。
]]>给平平无奇的日历加点内容,让简简单单的生活充满趣味!
将手边的电子设备利用起来,妈妈再也不用担心我没有台历,不记得日子了!
【功能】
【反馈】
使用过程中有疑问?对见闻历有新鲜的灵感或想法?
随时投送到这个邮箱:onetap@foxmail.com
好好喝水给你答案:
这么多讲究吗?不用记,好好喝水来帮你!
== 功能特点 ==
== 个性定制 ==
== 不断进步 ==
== 系统支持 ==
想要在正式发布前体验到新功能,或者参与到新功能的构思中来吗?
通过 App 设置页内的入口找到我们,也可以通过邮件直接与我们取得联系:onetap@foxmail.com
我们一直期待听到你对好好喝水的反馈和想法!
]]>本文只研究了原生 XPC 通讯的部分,关于集成到 Electron 里还有哪些坑会在下一篇文章里讲讲
选型的过程不是这次要讨论的重点,就当作我们经过一番挣扎然后选择了原生的 XPC 实现吧:)
XPC 是苹果官方提供的一种进程间通讯的手段,是一种苹果特有的 IPC 技术。
在 NSHipster 的一篇文章里,作者说 XPC 是官方 SDK 内跨进程通讯的最优解决方案(2014)。从 2011 年被提出的时候,XPC 就持续在“体制内”发光发热,比如 macOS 的沙盒、iOS 的 Remote View Controller 和两个平台上都有的应用扩展(App Extensions)里都用到了 XPC 的技术。
对于开发者来说,使用 XPC 技术我们就能做到像这样的事情:
看完是不是已经迫不及待了呢?别着急,在使用这个强大工具前,我们还需要了解两个关键技术。
launchd
负责管理 macOS 上的守护进程,在构建 XPC 方案的过程中,我们会用它来配置一个我们自己的守护进程。
这个守护进程会一直潜伏在系统里(只占用非常少的资源),当我们的应用需要它的时候就可以被随时唤醒。
更多 launchd
的信息和用法可以在它的man 页面找到。
字面意思是“给任务加上祝福”,任何应用都不能跟一个没有被系统祝福的任务愉快地玩耍。
这是一组协助开发者安全地安装守护进程的 API,长这个样子:
1 | Boolean SMJobBless(CFStringRef domain, CFStringRef executableLabel, AuthorizationRef auth, CFErrorRef *outError); |
苹果似乎也认为这组 API 的用法只可意会不可言传,所以在SMJobBless 的方法说明里写了很多,还给出了一个很完整的示例工程并通过一个 Python 脚本把安装守护进程的前置条件给配置好了。
脚本这个动作,虽然让整个流程变得更加完善,但却将原本只要几句命令就能解决的事情复杂化了,少了一些苹果味。
写了这么多,其实都还在 Prerequisites 阶段打转转。接下来才要正式开始跨应用通讯的实现!
不过在此之前,我们还是先把上文题外话里提到的前置条件准备好,让后面的过程更顺畅一些。
通过 launchd
安装守护进程是个需要很高安全性的动作,所以应用签名是必不可少的。而对于一个跨应用通讯的系统来说,安全性主要涉及到两个部分:
在这篇文章中,通讯的接收方不负责 XPC 应用的安装,所以它只要管好自己的签名就够了
这里我们就要用上前面提到的 Python 脚本里的一句关键命令:
1 | codesign -d -r - /path/to/file.app |
虽然官方 Demo 里的这个脚本还做了许多其他的检验来确保信息的完整和正确,但对于我们这样成熟的(嘿嘿)开发者来说,当然要直接薅最珍贵的羊毛啦。
把这个命令的路径参数改为我们已经签好名的应用,会得到像这样子的输出:
1 | Executable=/path/to/file.app |
其中,designated =>
后面的部分(例子里是从 “anchor” 开始,我们自己签名的话开头可能是“identifier”,这个顺序并不要紧)就是我们需要的“签名需求”(Code Signing Requirement)。
把签名需求放到我们自己的 XPC 应用的 Info.plist 里,如此一来这个 XPC 应用就只能被拥有这个签名的应用启动了:
1 | <key>SMAuthorizedClients</key> |
这里的 value 是数组格式的,意味着如果想允许多个 App 启动这个 XPC 应用的话,就需要把这些 App 的签名需求都写上。
同理,还要取到 XPC 应用的签名需求并配到我们客户端的 Info.plist 里:
1 | <key>SMPrivilegedExecutables</key> |
注意这个 “dict” 里的 “key” 要填的是我们的 XPC 应用的 label,不过因为 label 通常会定成跟 Bundle Identifier 一致,所以写上它的 Bundle Identifier 也就可以了。
上一段啰嗦了一下是因为 label 其实可以跟 Bundle Identifier 不同的,但这会给开发的过程带来许多麻烦,所以建议还是统一。这个 label 具体是什么鬼会在下一个小节里讲到。
首先来添加一个 Target 并选择 XPC Service,让 Xcode 帮我们生成一些默认代码:
然后为我们的 XPC 应用再创建一个 plist,这个文件会在 XPC 应用被安装的时候自动拷贝到 /Library/LaunchDaemons 目录下,这是统一存放守护进程配置文件的地方。
为了与默认的 Info.plist 区分开来,在文件的名字里加上个 “Launchd”,文件内容是这样的:
1 |
|
看,前面埋的坑—— Label 出现了!这是系统用来唯一标识守护进程的值,下面 MachServices 中的 key 是我们 XPC 应用的 Bundle Identifier。在建立连接的时候,系统就会根据这张配置表去寻找正确的 XPC 应用。
完成后我们的目录结构是这样的:(例子来自官方的 EvenBetterAuthorizationSample)
如图,官方例子中还给 plist 加上了项目名前缀,但名字不重要,重要的是别忘了把签名需求写对。
最后,因为我们要用到的产物是 .xpc 包里的二进制文件,所以必须把这两个 plist 也打进二进制文件里去,这就要在 Build Settings 的 Other Linker Flags 里配置一下:
配置内容如下,把最后的路径改成自己的 plist 就可以了(这也是为什么前面说文件名不重要):
1 | -sectcreate __TEXT __info_plist HelperTool/HelperTool-Info.plist |
完成了这些配置后打出来的包会是一个完整的 .xpc 文件了,但我们需要的只是它里面的二进制文件。在用上它之前,让我们把安装 XPC 应用的代码写好,这里的代码是在官方例子的基础上改的,个人感觉比例子里的更易懂一些:
1 | // 1 |
AuthorizationCreate(NULL, NULL, 0, &authRef)
kSMDomainSystemLaunchd
表示我们要使用 launchd 服务(这也是目前仅有的可选项),第二个参数是我们之前设置的 XPC 应用的 label当执行到上面的逻辑时,我们从两个角度来看看会发生什么:
通过 SMJobBless 安装的 XPC 应用会存在 /Library/PrivilegedHelperTools 下面,一旦授权完成过一次,后续只要配置文件和这里的二进制文件还对得上就不会再弹授权框了
为了让系统方便地找到 XPC 应用,要把它的二进制文件放到应用的 /Contents/Library/LaunchServices 路径下,我们可以在客户端的 Build Phases 里面加一个步骤来做这件事:
千万记得这里要放的是 .xpc 包里的二进制文件,在 xxx.xpc/Contents/MacOS 目录下
OK,万事具备,接下来我们真的要写代码了。
首先我们来实现 XPC 应用的连接监听逻辑,在创建 Target 之后的 .m 文件里已经有连接处理的模版和丰富的注释了:
1 | self.listener = [[NSXPCListener alloc] initWithMachServiceName:@"这里改成上面设置的 Label"]; |
需要特别说明的一点是,XPC 连接建立起来之后,连接发起方就能获取到上面的逻辑里的 exportedObject
,而再上一行的 exportedInterface
是声明这个对象在这次 XPC 通讯中会遵循的协议。
换句话说,连接的发起方会把连接上的 XPC 应用直接当作一个对象来操作。这个对象的消息传递是异步的,所以在调用的时候要小心避免卡主线程。
因为协议需要连接双方自行约定统一,所以上面
HelperToolProtocol
的定义建议放到一个公共的文件里,让我们的应用项目和 XPC 应用项目都能访问到
XPC 应用这边先说这么多,大多数情况下模版代码就够了,只需要自己定义一下 exportedInterface
就能实现例如心跳机制这样的功能。
接下来实现客户端发起连接的逻辑,我们直接参考官方例子里的代码:
1 | - (void)connectToHelperTool |
invalidationHandler
里置空,在其他地方通过 [connection invalidate]
来实现断连resume
来建立连接,调用后 XPC 应用那边才会收到 -[listener:shouldAcceptNewConnection:]
回调Done!如果前面的一系列配置都正确的话,这个方法就能搭起客户端与 XPC 应用之间连接桥梁了!
除了与 XPC 应用建立连接之外,NSXPCConnection 还提供了另一组 API 用于直接跟其他客户端建立连接:
1 | - (instancetype)initWithListenerEndpoint:(NSXPCListenerEndpoint *)endpoint; |
一次完整的连接建立流程是这样的:
在上个小节中我们完成了第一步,而第四步跟第一步其实挺像的,所以第二三步就是我们现在要处理的了。
之前我们声明了一个空的 HelperToolProtocol
,现在就给它加一些内容,向外界提供对象读写的能力:
1 | @protocol HelperToolProtocol |
因为
exportedObject
的消息传递是异步的,所以在需要返回值的时候要改用回调的方式实现。
然后在 XPC 应用里声明一个成员变量并实现上面的两个方法就完成了:
1 | @property (strong, nonatomic) NSXPCListenerEndpoint *endpoint; |
接下来回到客户端的代码里(现在还没实现客户端 B,所以这里讲的都是客户端 A):
1 | // 1 |
exportedObject
,因为方法返回的是实现了这个协议的对象,所以协议的匹配很关键YES
,所以这里肯定会成功,实际使用的过程中可能要加上安全性的处理监听器有了,就差监听到连接后的回调了:
1 | // 1 |
exportedInterface
之外,还要设置 remoteObjectInterface
,因为这是一条双向通讯的连接,所以要让其他客户端知道我们期望它们能遵循什么协议好的,流程走完一半了,第三四步需要在客户端 B 里面实现:
1 | // 1 |
NSXPCConnection
,这样在调用 resume
之后对方会收到 -[listener:shouldAcceptNewConnection:]
的回调exportedInterface
和 exportedObject
设置好,之后的代码就跟其他地方看到的差不多了这一段总结了我在实现过程中踩的坑,也许我们的情况不太一样,但希望能给大家一个排查的思路
涉及到多端通讯的逻辑调试起来比较绕,错误通常会发生在以下两个部分:
handler
的结果都打印一下,一般都会带有比较明确的错误域和错误码下面是我碰过的一些错误和处理方式:
安装 XPC 应用时在客户端内找不到 XPC 应用的二进制文件,检查一下二进制包是不是放到了正确的路径下,格式是否正确(记得要取 .xpc 后缀的文件里的二进制文件)。
签名匹配不上。大概率是 Info.plist 里配置的签名需求不正确,回头看看 前置准备 那个小节,检查内容是否跟 codesign -d -r - /path/to/app
和 codesign -d -r - /path/to/xpc
的一致。
出自 FoundationErrors.h - NSXPCConnectionInterrupted
连接被打断(interrupted),约等于 connection.interruptionHandler 被触发了。
如果发生在连接建立的过程中,那意味着它发现连接已经被占用了,多见于调试过程中重启了其中一端,但是另一端没有把连接释放掉。
在正常运行的过程中发生的话,可能是系统 XPC 服务发现我们的连接长时间没有使用而挂起了它,这种情况一般不需要处理,系统会在我们下次使用这条连接的时候自动帮我们处理好。
出自 FoundationErrors.h - NSXPCConnectionInvalid
同样分两种情况,一连接就出事的话,可能是 XPC 应用没有安装成功,排查方式是看 plist 和二进制文件有没有出现在它们该出现的路径里。
另一种情况,可能是客户端因为沙盒的原因而无法建立这条连接,控制台日志里会看到类似 deny mach-lookup 的信息,可以选择把 App Sandbox 关上(会没法上 Mac App Store 但不影响其他渠道的分发),真要打开沙盒的话有两条可以尝试的路径:
XPC 是 macOS 跨应用通讯中不得不面对的一种方案,可能出于各种原因最终的选择并不是它,但它确实是目前最简单可靠的实现了。
尽管我在网上已经查了非常多的资料,也还是在动手的过程中频频踩坑。写下这篇长文也是希望能把这条路尽可能填平,只是这个文章长度就有些一发不可收拾了😅。
信了苹果教之后,每次有什么更新,我最期待的都是隐藏在大功能下的小细节,不知道有多少人跟我一样?
首先要说的是 UI 中最常见的列表:在萌新们刚开始学习 iOS 开发的时候,列表的实现也许就是其中一个劝退点。虽然 UIKit 的 API 已经做了比较友好的封装,但在这个前提下,开发者还必须要了解 UITableViewDelegate、UITableViewDataSource、Cell 与列表的关系,如果想要构造一个高性能的列表,还需要了解 Cell 的重用等等等等。
那么在 SwiftUI 里构造一个列表的操作是怎样的呢?我们分三步走:
假设我们就构造一个最基础的列表项布局好了,一种 UIKit 直接就支持的显示模式:图片 + 标题 + 详情描述。
经过前面几篇文章的训练,我们对这种类型的 UI 构造应该熟门熟路了:
1 | HStack() { |
完成这一步之后,预览会是这个样子的:
OK,学习完前面三篇文章之后,这里并没有什么新鲜的知识。下一步是准备数据。
这一步跟直接用 Swift 来实现没有太多区别,我就直接用 WWDC 视频里的数据结构了:
1 | import Foundation |
这是我照着手敲的…如果有人知道哪里能弄来 WWDC 视频里的 Demo 项目源文件,请务必告诉我!
到这一步为止还没什么不同,唯一比较不常见的可能是下面的 testData
,这是方便我们调试用的假数据。
为了让 SwiftUI 的 List
能正常使用这个数据结构,我们还需要进行一点点改造:
1 | import SwiftUI |
List
能使用的数据结构必须遵循 SwiftUI 里新加入的 Identifiable
协议,它要求这个数据结构里必须有一个符合 Hashable
协议的变量 id
,我们这里用到的 id
是 UUID
类型的,本身已经满足这个要求,所以这里就不需要做更多修改了。
现在我们把 UI 和数据都准备好了,接下来就让它们组合起来,显示成一个列表!
对于 List
来说,列表项的默认布局就是水平方向的,所以我们可以直接 Cmd + 鼠标左键点击列表项 UI 里的 HStack
,然后选择“Convert to List…”:
也许是 Xcode 的版本问题,在我这里显示的是“Embed in List”,但是最后的效果也是把选中了的
HStack
替换成了List
得益于 List
构造方法里的数组类型参数,这段 UI 代码看起来就像是一个 for…in 的语句一样!回头看看预览,一个像模像样的列表已经出来了:
接下来我们把准备好的数据对接上去:
1 | struct ListView: View { |
List
的参数缓存我们真实的数组,然后把内部的硬编码数据换成传进来的数据对比导入数据前后的界面,我们可以发现:默认情况下 Cell 的高度为 44pt,但是当塞进去的内容(比如图片)需要用到的高度大于这个值时,Cell 会自动调整自己的高度以适应内容的大小,并填充合适的间距来美化我们的列表项。这些小细节全都是免费的!
预览里的图片当然是要我们事先导入的,因为跟 SwiftUI 没什么关系,所以这里也就跳过了这一步;而且我也懒得找图片来代替它们了,所以截图里看到的还是之前默认图的样子…
孤零零的一个列表可能还不够有意思,一般来说列表是罗列概要数据用的,为了看到更完整的信息,我们通常会在用户点击列表项的时候跳转到一个详情界面,这就涉及到了界面导航的概念。
在原来的开发过程里,这时候我们就会实现一个 NavigationViewController
去把我们列表的视图控制器包裹起来,然后通过 push & pop 这样的操作来实现界面的切换。
一起来看看 SwiftUI 是怎么做的:
1 | NavigationView { // 1 |
NavigationView
把原本 body
里的全部内容包裹起来,就像我们原来用 NavigationViewController
把 UIViewController
包起来一个道理NavigationView
里的最外层子视图(在这里就是 List
)加一个修饰器来配置导航栏的标题;注意我们这里传的是一个 View
,也就是说它不仅限于显示文字,还可以是各种各样遵循 View
协议的视图完成这两步就可以在原有的视图之上显示一个导航栏了,不过还不够,我们要给列表项加上点击事件以便跳转到详情界面:
1 | List(rooms) { |
这里就只有一步:把原先用于布局列表项的代码用一个 NavigationButton
包起来,同时通过参数来指定这个导航事件的目的地。如例子的代码所示,实现的效果是点击列表项之后跳转到一个新的界面,界面中会居中显示房间的名字:
在加上 NavigationButton
之后,细心的童鞋们可能已经发现我们列表项的变化了:每一项的尾部都显示了一个小尖角,这是 iOS 里用于表示列表可以“点击查看更多”的常见元素了。
OK,用 Live Mode 运行一下预览,可以看到 SwiftUI 已经把转场过程中的标题动画处理好了,界面跳转、手势返回,全都丝般顺滑。
两个典型的常用组件我们已经看完了,接下来我们要实现一个更复杂一些的界面。往常要实现这么一个界面,虽说没有太多的智力活,但体力活是肯定少不了的。
一图胜千言,先来看看效果图:
这种界面在用户注册的过程中也是挺常见的,借用一个前端的概念——它叫做“表单(Form)”。
接下来的代码里,有些类需要在 Xcode11 beta 4 版本上才能使用(其实我只知道 Beta 1 用不了,但具体哪个版本变化的就不清楚了),建议大家先更新一下 Xcode,不然就只能在文章里过过眼瘾啦。虽说 WWDC 展示的时候那个讲师明明就已经用上了,果然发布会要用特供版本在哪里都是惯例啊。
顺带一提,这种变化除了更新软件之后上手试试之外,还能在哪里看到呢?答案就是:官方文档!
在网页上找到你想看的 API 之后,把图示右上角的 “API Changes” 打开,选择要比较的版本(对于 Beta 版的软件,一般不会给出每个版本之间的变化,所以如上所述我也不知道 Form
这位兄弟是什么时候加进来的),然后就能在 “SDKs” 下面看到特定版本的需求是什么时候加进来的了。上面的截图就表示 macOS App 在 Xcode beta 4 版本上是无法使用 Form
的,因为它在 beta 5 才被正式加进来。
让我们用前三篇文章加上上面两个小节的知识,来判断一下要怎么实现它。
navigationBarTitle
可以搞定的事情UITableView
UITableViewCell
,而最后一行起到的是按钮的作用,可以真的用按钮去实现,也可以用文字加上列表项点击事件的方式来做看看上面的需求,每一点都不难,可它们胜在多啊!如果用 UIKit 去实现,光是把界面做出来就已经要花不少代码,更别说界面背后的数据逻辑了。
于是 SwiftUI 应运而生,这种简单元素组合而成的复杂页面正是它的强项!
我们直接看最终的界面代码,不过你也不用看得太仔细,我们会在下面把它一点点拆开来讲:
1 | struct FormView : View { |
尽管有前几篇文章的知识作为铺垫,但这段代码里还是有不少的新面孔啊。
UserInfo
的类型定义是这样的:
1 | struct UserInfo { |
它其实就是一个普通的结构体,用来表示这位用户的个人信息。
在上一篇文章里我们讲过,如果要把一个完整的自定义对象绑定到视图上,那么这个对象的类型就需要遵循 BindableObject
协议。但在这个例子里,我们只是将对象的属性绑定到了视图上,我们只在意这些属性而不是对象这个整体,所以可以直接通过 @State
来进行对象局部的绑定。
如前面的文档截图里所述:Form
是一个用于组合一系列数据入口的视图,比如应用的设置页面,或者是例子里的用户信息录入界面。
话说回来,这么多新玩具,在开发过程中怎么知道有没有可以对症下药的呢?其实它们都整整齐齐码在了原来我们用于挑选 xib 或者 storyboard 组件的地方,我们可以用 Cmd + L 把这个界面弄出来:
顾名思义,这是一个用于分区的控件,它也可以被用在 List
里面,就组成了所谓的 “SectionList”,例如通讯录里按首字母分区的样式。
例子中的 Section
用于给 Form
里的不同数据进行分类,并提供统一样式的 Header,它显示的内容也可以通过构造方法里的 header
参数来进行修改。
这是目前为止碰到的最复杂的组件了,它有点类似于 UIKit 里的 PickerView
,不过它是更广义的“选择器”,在样式上也会有更多的变化可供选择,比如例子里的 SegmentedPickerStyle
(有些样式是平台相关的,在使用的时候要留意编译器的提醒)。
其实只要明白 $
变量的含义,这个构造方法也很好理解,就是把选择器里选中的内容与 userInfo
对象里的 race
属性绑定起来。
这个写法本质上跟往 List
的构造方法里传一个数组的意思差不多,这样拆开来可以让列表型视图的内容样式更丰富,感兴趣的童鞋们可以详细看看这个视频,这里就不展开讲了。
其中的 Race
是一个枚举类型,它的实现也很有意思:
1 | enum Race : CaseIterable, Hashable, Identifiable { // 1 |
例子里的 Race.allCase
是遵循 CaseIterable
协议带给我们的一个福利,意思是“枚举所有可能的值”,于是我们就可以把它用在需要数组类型参数的地方了。
而 Hashable
和 Identifiable
是为了让这个枚举的对象可以被作为唯一标识符来使用。之所以这样做,是因为 Picker
的列表项必须要设置 tag
以区分用户的选择,于是例子中就直接用了枚举对象本身来作为选项的 tag
。
这个控件看名字就该知道是干嘛用的了,正如 Swift 4.2 之后的布尔值有了一个叫 toggle
的语法糖,这个控件就是用来标识开关状态的,也就是原来的 UISwitch
。
直译过来应该叫“步进器”。对于重视用户体验的苹果来说,这样看上去简单,但要正确实现逻辑还需要费点心思的小玩意儿,当然是选择封装成一个系统组件!相信看了前面那些五花八门的组件之后,这个 Stepper
的用法也是不需要再多加解释了。
好了,这短短不到40行的代码(好吧,算上那个结构体和枚举也有60行左右了),就已经实现了我们这一小节开头截图里的那种表单效果。不仅如此,SwiftUI 还默默为我们做了大量的优化工作,我可以拍胸脯说这些代码在效率上也会是远超 UIKit 版本的。
四篇文章下来,这个系列的 SwiftUI 教程也算是告一段落了。显然这仅仅是一个入门教程,更多有意思的新元素还在后面等着你。
文章里面的例子都是通过 Xcode 的新功能 Canvas 预览和截图出来的,目前它也只有个 iPhone 的外框样式,这难免有点限制了我们的想象,所以我必须要在最后提醒大家一句:
用 SwiftUI 构建的界面天生就是跨全苹果平台的!
也就是说,之前例子里的所有代码放到 macOS 和 watchOS 上都是适用的!(当然要除掉跟 UIKit 混合的那部分)而且所有界面的样式都会根据平台的不同而自动调整,几乎不需要开发者的介入就可以做到全平台适配!
写到这的时候我居然感觉挺兴奋的,就像是回到了刚刚接触 Xcode 和在 iOS 5 上做开发的那个年代。那个时候 Android 已经有好些机型要适配,而我只要把应用在我手里的 iPhone 上跑顺就八九不离十了:)
相比起前两篇实操文,这篇文章可能会比较干,请大家看文章之前先访问一下饮水机。
学习过斯坦福公开课的小伙伴们应该对下面这张图片很有印象了:
这是 iOS 自出道以来就非常推崇的,可谓是“官方建议”的数据流模型,也就是大家都熟知的 MVC 模式。
从 GUI 开始兴起以来,基于职责分离的思想,工程师们慢慢把管理用户界面的 View 和管理用户数据的 Model 给区分了开来;而从 Smalltalk 的某个版本开始,为了进一步降低图形应用程序的管理难度,设计出了 MVC 模式。MVC 的出现主要是为了解决这两个问题:
解决这两个问题也是大多数为现代图形界面应用程序而诞生的设计模式们的目标,比如 MVVM、MVP。
因为有着一段不长不短的 React Native 开发经历(目前还在做着),所以从这个角度看过去,比较成熟的方案是 Redux + React Redux。
Redux 是专为 JavaScript 软件打造的一个可预测状态容器。听起来很厉害的样子,其实主要是做了三个事情:
这样做了之后,我们就可以确定这个数据源是可以真实反应我们的应用状态的,所以叫做“可预测状态容器(Predictable State Container)”。
然后 React Redux 就好理解了,它的任务是建立一套机制,让上述的状态一一绑定到视图上,实现一条双向更新的通道。
Redux 和 React Redux 都是 Redux 官方出品,所以质量还是比较有保证的。下面这两篇文章应该可以给大家技术选型的时候提供一些支持:
Motivation · Redux
Why Use React Redux? · React Redux
如果你觉得 SwiftUI 在构造界面时用到的声明式语法跟 JSX 的相似度很高的话,那在介绍完它的数据绑定逻辑之后,你肯定会再一次把它拿来跟 JavaScript 做比较了。
SwiftUI 中引入了一个关键字 @State
来作为数据绑定的标识。当一个被绑定的数据被改变时,相关联的视图会重新计算它自己的 body
内容;反过来,当视图主动去改变绑定在数据上的属性时,这个数据也会随之变化。这种双向绑定的机制就像 JSX + Redux + React Redux 的组合拳,只不过 SwiftUI 自己就把这些事情给做了。
但是,凭什么 SwiftUI 用几个关键字就实现了别人整整两个开源库的功能?其实这得益于 Swift 5.1 的新功能——属性包装(Property Wrappers)。
在 2019 年3月份的时候,Swift 核心团队里的成员已经透露出了一个作用类似于 lazy
关键字的新功能,那个时候它被称为“属性代理”(Property Delegates)。
举个例子,延迟初始化可谓是编程里的一种美德,在 Swift 的世界里,除了直接用 lazy
,我们也可以用一个私有属性加上一个作为访问器的 Computed Property 来实现:
1 | private var _lazyProp: Prop? |
如今,Property Wrappers 为我们提供了第三条路可走,不仅如此,它还承诺会为开发者们提供了一种实现类似 lazy
关键字用法的途径。
在 SwiftUI 的功能提议 SE-0258 里可以看到,Property Wrappers 的主要目的就是为了避免开发者重复写出上面那种固定模式的代码。既然这种写法是比较固定的,那么就应该定义出一种机制,来把各种固定写法定义成一个个的工具库。
还是用 lazy
的例子,怎么用 Property Wrappers 来实现一个同样的功能呢?比如实现一个作用相同的 @Lazy
属性?
官方给出的解决方案是这样的:
1 | @propertyWrapper |
如此一来,下面这种变量声明
1 | var foo = 123 |
就会被展开成这样:
1 | private var _foo: Lazy<Int> = Lazy<Int>(wrappedValue: 123) // 1 |
Lazy
的 init
方法来进行初始化一个私有变量,它的类型是 Lazy<Int>
wrappedValue
里提供的真正的逻辑实现不仅如此,既然 @Lazy
是一个 enum
,那它本身就可以定义五花八门的公开方法,而每一个被 @propertyWrapper
标记的类型都可以通过定义一个 projectedValue
属性来实现一些骚操作:
1 | @propertyWrapper |
声明了 projectedValue
之后 ,我们就自动获得了一个带 $
符号的分身用来访问我们 projectedValue
的 getter,从而调用到里面的方法:
1 | var foo = 123 |
上面那句声明变量的语句,会展开成这样:
1 | private var _foo: Lazy<Int> = Lazy<Int>(wrappedValue: 123) |
其实给
Lazy
添加extension
也可以达到类似的目的,不过这时候的方法调用就要通过_foo
来进行,而不是$foo
了
SwiftUI 的数据流模型是基于下面两点原则来构建的:
我们展开来看:
在多数情况下,我们的视图是需要根据某些状态来动态变化显示样式的,比如对于 Switch
来说,改变它的 on
属性可以让它显示当前的开关状态。
对于这种情况,on
属性就应该作为 Switch
的依赖而存在,否则这个控件除了长得好看就一无是处了。所以在 SwiftUI 里,属性会被描述为视图的依赖,这意味着我们的注意力可以从属性和视图的关联里抽身出来,集中在建立更好的用户体验上。
同一组视图里的数据都是来自于同一个数据源的(甚至整个应用的数据都来自于同一个数据源,Redux 就是这么做的)。
对于开发者来说,数据源的不唯一意味着视图状态的不唯一。可以想象,位于同一视图层级的两个视图要共用某些参数时,数据来源的不唯一会为编程带来多大的麻烦。
SwiftUI 对这种情况的处理是,让父视图作为子视图的唯一数据源:
做过前端 UI 开发的童鞋们应该很熟悉这套操作了,这就是把 State 上提成 Props 嘛,目的是让子视图尽可能的简单,最好的情况下子视图本身应该是无状态的。
于是我们可以得出,基于这两个原则来实现的数据流模型已经完全不同于我们以往的理解,我们需要重新定义我们所认识的视图:视图要体现的是一个个独立的状态,而不是一系列连续的事件 。
说了这么多,我们来实际改造一段代码试试。
假设我们要实现一个播放器的播放按钮,需求是它要能反应播放状态:
我们通过给按钮设置不同的图片来区分这两个状态,按照之前的知识,我们能轻松写下这样的代码:
1 | struct PlayerView: View { |
等等,我们这个 PlayerControl
是 struct
类型的,不能这样直接改变属性的值:
其中一个安抚编译器的方法是,用一个临时变量来替代 self
,我们顺便把布尔值取反的操作也简化一下:
1 | struct PlayerView: View { |
好了,这样一改,那句临时变量赋值语句就成为了夜空中最亮的星,怎么看怎么别扭…
那接下来就轮到 SwiftUI 里定义的 Property Wrappers 出场了,这段代码可以改写成这样:
1 | struct PlayerView: View { |
似乎…也没太大变化啊,代码量也不见少,只不过是省掉了临时变量了?
这是因为,这样的写法还是根据我们的惯常思维来走,回忆一下上面讲到的 SwiftUI 数据模型原则:
例子里的写法貌似符合了“单一数据源”,但是却把“以依赖的形式访问数据”晾在了一边。我们来进一步改造这个例子:
1 | struct PlayerView: View { |
@State
标记的变量会自动生成一个以 $
作为前缀的新变量,这个新变量本质上是一个 Computed Value,实现了双向绑定的机制,也就是说当 PlayButton
内部改变了这个变量之后,PlayerView
里的 isPlaying
也会发生相同的变化@Binding
,我们就告诉了编译器这个变量是从外部传入的可以被绑定的参数,相当于 React 里的 Props
声明isPlaying
作为 Text
的依赖来使用,对于 PlayButton
来说,变量的声明和使用都是在一个结构体里面完成的,这就意味着这个视图与 PlayerView
是解偶的
@Binding
具有以下两种特性:
- 在不持有变量的前提下进行变量的读写
- 可以从
@State
变量中推导出来
现在回忆一下,在 SwiftUI 之前我们是怎么实现类似逻辑的?在不知不觉中,我们已经舍弃了 ViewController,让视图直接成为了数据的载体。甚至可以说,在 SwiftUI 里,视图就是为数据服务的。
@State
标记的属性一旦变化,会引起依赖它的视图、这个视图的父视图和它的同级视图一起做必要的变化。为什么要强调必要的?因为相较于繁重的渲染工作来说,对声明式语法描述出来的数据结构进行比较并不消耗什么性能,SwiftUI 会在重新渲染前对视图状态进行比较,尽可能地去避免无谓的绘制,所以不需要担心性能的问题。
类似于React.PureComponent
提供的逻辑。
基于这套数据模型实现出来的数据流可以用下面这张图片来表示:
要知道,Action 不只可以来自于用户交互,它还可以来自我们自己实现的触发器、消息推送等等,而不管来源是什么,我们实现的逻辑都可以理解并做出同样的处理。
这样的数据流模式确保了数据的流动永远是单向的,而 State 在这里充当了视图变化的唯一数据源,让视图的更新是可预测和易懂的。
当然,
@State
也有它的局限性,比如它无法正确处理我们自己定义的对象类型的属性变化,所以我们还需要 BindableObject 协议来从旁辅助,这里就不继续展开了。
了解了视图基础和数据流模型,相信大家都已经看到了 SwiftUI 的魅力,余下的细节就需要各位开发者在实际应用中发掘了。下一讲就让我们来把这个魅力继续扩大,一起来实际看看 SwiftUI 还给我们的开发带来了哪些好处。
Text
组件,并通过 Stack
系列的组件对内容进行了一些简单的布局。在这篇文章里,我们会认识一个全新的图片组件,并且会尝试利用这两篇文章的知识,结合 MapKit 框架,来实现一个简单的地点详情界面。写完第一篇文章之后,本职的开发任务突然进入了紧张的预发布阶段,搞得早就写好的第二篇文章拖了这么久才完成润色和发布,看来“全网最早”要丢了…
首先把一张地标图片放到 Assets.xcassets 里去,我在百度找了张广州塔的照片:
然后,我们要为新的图片视图创建一个新的类,就放在上一篇文章的 ContentView.swift 旁边好了。新建文件的时候,选择 SwiftUI View:
取个名字叫 CircleImage,因为我们将要在这里把广州塔裁剪成一个圆。新建的代码内容跟上一章看到的一样,我们把 Text
改成 Image
,然后把图片的名字传进去,直接就可以通过预览在画布上看到我们的图片了:
接下来我们在代码里给它加上一个圆形的裁剪。原来的做法有很多了,最快速的做法应该是操作 clipsToBounds
和 cornerRadius
,给图片加上长度等于一半宽高的圆角,这还得要求图片是正方形的才能达到满意的显示效果。
而在 SwiftUI 里,这就是一句话的事情:
.clipShape()
给图片加了个裁剪的形状,其中 Circle
类型是一个用来当作遮罩的图形,你也可以给它加上填充色或者描边来单独使用,类似于以往通过 CALayer
去实现的效果。
但这也太大了,我们的屏幕装不下,我们可以再加两行代码,把图片缩放到一个合适的大小:
讲道理,这里设置的宽高应该是一样的,毕竟是个圆嘛…但是我懒得重新截图了,各位童鞋自己调整一下数值就可以了
为了让图片本身在不同背景下都能凸显出来,我们再给它加个描边,这要通过 overlay()
方法去实现;也许再加个阴影吧,用到的是 shadow()
方法;最后出来的效果是这样的:
是不是醒目多啦?
每当做完一个新视图,我就想对比一下用老方法实现同样的效果有什么不同…
不知道大家发现了没有,我们在 SwiftUI 里用到的视图全部都是 struct
,这意味着它们跟我们原本熟悉的 UIKit 是两套不同的机制,那难道以前开发的视图就完全用不上了吗?
答案是可以的。
要在 SwiftUI 里使用 UIView 的子类,只需要把它用一个遵循 UIViewRepresentable
协议的 SwiftUI 视图包裹起来即可。
这里举的是 UIKit 的例子,但同样也适用于 AppKit 和 WatchKit。
我们再来创建一个新的 SwiftUI View 来做我们的地图界面,但这一次,我们要改一下内容视图的协议:
1 | import SwiftUI |
然后你会发现 Xcode 在 UIViewRepresentable
这里报错了,因为这个协议下有两个必须实现的方法:
makeUIView(context:)
用来创建我们的 MKMapView
updateUIView(_:context:)
用来进行视图的配置,并响应后续可能的变化那下面我们就来实现一下。再加新代码之前,可以把已经用不上的 body
部分先删掉了。
对于 makeUIView(context:)
,只需要声明它返回的是 MKMapView
然后直接通过构造方法返回一个空对象就可以了:
1 | func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView { |
updateUIView(_:context:)
要做的事情就比较多了,我们一步步说:
1 | func updateUIView(_ uiView: MKMapView, context: UIViewRepresentableContext<MapView>) { |
赶紧预览一下看看效果吧!你会发现画布上空白一片…
那是因为预览默认是静态模式的,它只能完整渲染 SwiftUI 的视图。因为我们现在用到了 UIView 的子类,所以要把预览切换到实时模式(右下角红框里的按钮):
emmmm…塔呢?这定位看起来也不是很准啊。
先看看预期实现的效果图:
然后花一点时间思考一下怎么弄,我们这两篇文章的知识是完全足够了的。
好啦!公布答案!
我们会在上一篇文章实现的 ContentView
里直接进行组装,下面来看看分解动作:
1 | struct ContentView: View { |
VStack
把所有内容包裹起来,默认情况下 VStack
的内容布局是居中的,所以我们不需要做修改MapView
我们要做两个修改edgesIgnoringSafeArea
可以让我们的视图把系统预留给刘海和状态栏的区域也用掉,这样看起来会更自然一点height
的视图,它的宽度会默认占满所有父视图里的可用空间offset
移动的,所以为了保持与底部文字的间距,特意加上了一个负的 padding
来抵消掉位移导致的差距VStack
整体是在竖直方向上居中的,所以加上一个 Spacer
把整体有内容的部分顶到最上面(其实也可以通过 HStack
的 alignment
来实现,不过那样代码就没有现在的简单优雅了)最终成果:
如果你发现照着实现出来之后,中间圆形部分特别大的话,别担心,你是对的!
因为文章前面的CircleImage
确实是为了展示而特意设置得比较大的,所以调整一下里面的宽高即可。
到这里大家应该对 SwiftUI 的使用比较上手了,但目前为止涉及到的组件还比较少,SwiftUI 光是各种强大的组件就已经够玩很久了。不过我打算在第四篇文章里再集中讲各种有意思的组件使用方式,因为下一篇文章我们要先解决数据来源的问题。
既然我们的视图组件已经是通过声明式的写法来构建的了,那我们的数据是不是也该换一种方式绑定到视图上呢?在 JS 上我们可以用 react-redux 这样的数据绑定手段,那 SwiftUI 是不是该搭配 RxSwift 来使用了?
这些问题都将在下一篇文章里为大家解答!
SwiftUI | Creating and Combining Views
SwiftUI Essentials - WWDC 2019 - Videos - Apple Developer
可能是全网最早的 SwiftUI 中文教程?
这篇文章来源于苹果官方的教程,相当于是我自己学习过程的一个记录。这个系列教程会跟着官方教程构造一个新的项目,还会加入一些 WWDC 的东西作为补充,可能偶尔会有一些自由发挥的部分。(不过我这里是做不出官方教程那种酷炫的动画了…)
目前(2019.6.5)要体验到这个新东西,需要用到 Xcode 11 beta,而如果要体验新的预览机制和对画布上的预览进行操作,还需要把系统更新到 macOS Catalina 10.15 beta, 看来这个系列的新功能提供了系统层面的支持。
话说回来,下了 iPadOS 之后用 iTunes 死活升不上去,报错说 macOS 有软件要更新,但总更新失败,最后下载 Xcode 11 beta 让它跑完 install components 那一步就可以成功升级 iPad 了…告诉我不是一个人…
让我们先用新鲜热辣的 Xcode 11 beta 创建一个项目吧!
前面的步骤身为 iOS 开发的同学们应该很熟悉了,这里要创建的是 Xcode project -> Single View App(playground 我还没试过,也许也被“强化”过了?),然后在取名字的那一步稍作停留,瞧瞧我在打勾区发现了什么:Use SwiftUI!当然选择钩上它!
完事之后我们就可以看到熟悉又带点陌生的编辑器界面了。点开默认提供给我们的 ContentView.swift,可以看到里面已经写好了两个 struct
:
先打个岔,看看编辑区域右边新加入的 Minimap 界面,这虽然是一个在代码编辑器中比较常见的功能,但苹果一出手,还是给改进了一番:
苹果的每次大更新,着重宣传的主要变化,在我都尝试一遍之后就没什么感觉了,反而是这些小地方特别打动我
言归正传,看回代码。
这两个是 SwiftUI 默认提供的结构体,其中遵循 View
协议的那个定义了我们的界面内容和布局,而遵循 PreviewProvider
协议的那个则负责处理这个界面的预览。
那什么是预览?我们可以先把旁边的所谓预览窗口跑起来,点击右上角的 Resume(这个版本的 Xcode 默认会显示下图这个界面,没有的话,也可以在 Editor 里面打开它):
这时,Xcode 会把我们的项目运行起来,就跟平时点 Run 跑到模拟器上一样。有所不同的是,这次的模拟器直接显示成了 Xcode 的一个子界面,我们甚至可以直接操作这个模拟器里的视图,就像操作 xib 和 storyboard 一样!
仔细看,点选了界面上的文案之后,左侧编辑器里相应的视图代码也被高亮了起来,是不是有一种根据按钮事件找 IBAction 代码的感觉?
尝试修改一下代码里 Text
中的内容,会发现模拟器里的显示也实时更新了!对这个更加强大的模拟器,苹果给它起了个名字叫 画布(Canvas)。
那既然 画布 有着跟 Storyboard 相似的体验,那是不是意味着我们也可以直接改动界面上的元素?答案是肯定的,所有功能都隐藏在 Command + 左键点击里:
点击后会出现一个内容丰富的弹出框,这些操作会根据点击的视图不同而不同。
我们可以通过 Inspect… 来修改视图的一些基本元素:
图上应该能看出来,这个弹出框是可以滚动的,它已经可以取代原来我们常用的 Attributes Inspector。实际上,如果你在这个时候点开右侧边栏,会发现 Attributes Inspector 的内容跟这里是完全一致的
我们来把它的字体改为 Large Title,可以看到代码部分也跟着界面一起改变了:
按照这个规律,我们通过手写代码来改个字体颜色试试:
这种链式调用的语法是不是跟用 OC 实现的 AutoLayout 开源库 Masonry 很像呢?苹果把这些方法叫做 *修饰器(modifiers)*,它们会在旧视图的基础上构造一个新视图返回出来,这使得上述的链式调用成为可能。
如果我们再从刚才的 Inspect… 里把字体颜色改为 inherited,就会发现 Xcode 把我们刚加上的
.color(.red)
又给删掉了,这波操作让写代码有意思了不少啊。
在前面的内容里,我们通过 SwiftUI 来描述了我们想要的视图样式,但这只是单一的视图。当视图多起来的时候,我们可以通过 stacks 把视图在竖直方向、水平方向或从前往后组合起来。
注意力继续回到我们的新朋友画布上,这次我们加快一点速度,先对刚刚的文本进行 Embed in VStack 的操作:
然后通过 Command + shift + L 调出视图库界面:
从里面拖一个 Text
到编辑器里(对,没错,就是编辑器,它会自动变成代码),放到我们之前操作的 Text
之下。现在我们的代码应该变成这个样子了:
从视图库拖组件出来这一步,我们有两种选择:一种是拖到代码里,另一种是拖到 Canvas 上。大家可以尝试一下拖到界面上会有什么样的效果。
稍后我们再回过头来看这个 VStack
是什么。现在让我们继续快进,加入两个没见过的新组件 HStack
和 Spacer
,通过给 VStack
加上参数来进行布局,还要再通过修饰器美化一下界面,最后 ContentView
的内容应该是这个样子的:
1 | struct ContentView : View { |
相应的,我们的界面也成了这样:
刚才的代码里,起到容器作用的是 VStack
和 HStack
,顾名思义,它们分别是竖直方向上和水平方向上的层叠视图(Vertical & Horizontal),用法跟我们早就认识的 StackView
相同。
到目前为止,有过前端开发经验的同学们应该能发现,这不就是 JSX 的语法吗?
我们知道,在实现一个新界面的时候,通常包含着“用基本组件就能实现”的常规部分和“要把奇技淫巧发挥到极致”的出彩部分。SwiftUI 的出现就是为了简化常规 UI 的开发过程,让开发者能够把精力都放在激动人心的部分。
—— 摘自 WWDC
为了达到这个目的,一个首要的改变就是:把命令式的视图逻辑转变为声明式的。这样做的好处在于:
这样转变之后,整个视图层级的代码看起来就清晰了许多(对比一下用单纯的 Swift 来实现会有多少代码),然而这种转变的背后其实都是我们熟悉的 Swift 语法。举个例子,VStack
本身就是一个 View
,它的实现是这样的:
1 | public struct VStack<Content: View> : View { |
其中,alignment
和 spacing
是两个布局用的属性,我们在之前的例子里就设置了 VStack(alignment: .leading)
,这可以让内部的元素左对齐;而 content
属性则是一个闭包,它将返回另外一个视图,里面包含了所有要显示在 VStack
里的子视图。
VStack
和 HStack
在 SwiftUI 里被用到时候,其实就是调用了它的构造方法,因为前两个参数要么是有默认值的,要么是可选的,所以只需要关注最后一个闭包参数;而这个闭包参数作为参数列表里的最后一员,可以写作结尾闭包的样子,于是就有了我们上面例子中的写法。
剩下的 Spacer()
和 .padding()
就只不过是 SwiftUI 提供给我们的又一个常规组件和修饰器而已了。
写到这里,仅仅涵盖了官方教程第一章里的前3部分,外加一点来自 WWDC 视频里的内容。我还会继续补充,努力把整个教程都覆盖掉。
不过这就足以让我们看到它的好玩之处了,至少写了一段时间 React Native 的我,看到这似曾相识的语法,着实感觉欣喜。苹果在为开发者打造工具上下的功夫,恐怕在历史所有科技型企业上也是数一数二的了。
SwiftUI | Creating and Combining Views
SwiftUI Essentials - WWDC 2019 - Videos - Apple Developer
这是 macOS 开发系列的第四篇文章 —— NSTextField。
最常见的控件之一,却不一定是你最熟悉的控件之一。
将近半年没发文了,这段时间过得真是充实得过分,以至于完全没有时间好好整理一下手边可以写的内容。最近好不容易有点时间可以把存货整理整理,发现当时写的好多东西都已经过时了!赶紧收拾干净先发一篇上来,不然指不定哪天连整个主题都没用了…
咱们直接进入正题!
一个完整的 TextField 是由两个类组成的:NSTextFieldCell,干了绝大多数脏活累活的一个类,和 NSTextField,作为 NSTextFieldCell 的容器而存在。所有 NSTextFieldCell 里的方法在 NSTextField 里面都有对应的存在(有点像 UIView 对 Layer 的封装)。
对于绝大多数情况来说,我们直接用 NSTextField 就足够了(那这篇文章不久没什么作用了吗?!)。如果你想要对自己的输入框有更多的掌控权,那可能还需要了解一个叫 NSControl 的家伙。
正如 iOS 里的 UIControl,NSControl 是一个抽象类,必须通过子类继承来使用。但是跟比较纯粹的 UIControl 不同,NSControl 除了支持 Target/Action 机制和一些常见的属性设置之外,还加上了支持文字编辑的一系列代理方法。
举个两组最常用的例子:
这么多方法里面,比较好用的当属 did
系列方法了:
这三个方法虽然已经在官方文档里被标记为 “macOS 10.0-10.14 Deprecated”了,但它们仍然在勤勤恳恳地工作着。鉴于 macOS 的 10.14 还没出来(2018.8),我们但用无妨。2019年4月再看,系统版本已经到10.14以上了,是时候考虑正式换成下面的方法了。
上面三个方法的链接都是 Objective-C 版本的,被 Deprecated 的也是这个版本的方法。在 Swift 版的文档里,在 NSControlTextEditingDelegate 里已经加入这三个方法
的 Beta 版了(2019.4 Beta 标识已经去掉了):controlTextDidBeginEditing(_:)、 controlTextDidChange(_:)、 controlTextDidEndEditing(_:)。方法签名看起来是一样的,估计新方法的正式版出来之后也可以无缝迁移。
顾名思义,它们代表了输入过程中的三个状态,不过有一点要注意的是:每次出现 didEnd 并不一定会有一个对应的 didBegin。因为 didBegin 表示的是用户开始输入的状态,也就是说,单单是光标在控件上面闪烁着是不算数的,一定要用户敲下第一个字符的时候才会回调 controlTextDidBeginEditing(_:)
。
而相对的,didEnd 表示结束编辑,只要用户选中输入框之后点击了输入框以外的地方,都会被算作“结束”,即使他从头到尾都没有输入过一个字。
这三个方法传入的参数都是 Notification
类型,说明它们其实都是系统通知的回调方法,只要实现了这个方法,系统就会自动帮你注册这三个消息的监听器。
虽然参数是个
Notification
,但它会把触发消息的输入框作为 object 属性一起传进来,可以做的事情就相当多了。
这个代理是专门为了编辑操作而设计的,除了还在 Beta 版的三个 did 系列方法外(2019.4 Beta 标识已经去掉了),还有分工明确的另外7个方法,一共10个。这部分在现在的项目里还没怎么接触,就只是把文档搬过来方便大家参考。
验证:
格式化文本:
文本编辑响应:
自动补全:
按键事件响应:
成员方法 Beta:
没有错!到这里就结束了!(因为实在是没什么存货…)希望这篇文章能起到入门和索引的作用。
有了这些内容,应该大概能知道怎么去控制输入框的内容,也可以避免一些简单的坑了。
如果没人注意到标题里的“(1)”的话,我就在这里打住了…不然我可能会把打造一个真实情景下使用的输入框的过程讲一讲。
]]>看上面这段话就觉得内容挺多的吧…所以专门把 Extension 界的当红选手 —— Today 小组件单独放在这一篇文章里面讲,作为这个 iOS Extension 入门系列的收尾~
让我们马上进入正题!
展示在 Today 界面(手机主页最左屏)里的应用扩展统称为“小组件”(Widget)。小组件存在的目的是向用户快速展示当下最重要的信息,并提供一些简易的任务处理功能,比如“把任务标记为完成”之类的。
官方建议: Today 小组件负责的任务最好在单次操作内就能完成,如果你发现这个任务需要多个步骤,那 Today 小组件也许不是最适合的扩展点。具体扩展点参见 官方扩展点列表 或者这个系列文章的第一篇(又一篇 iOS Extension 入门(1/3)— 基础 & 分享扩展)里的翻译版。
iOS 和 macOS 平台上都有 Today 小组件,在开发过程中需要注意的地方是相同的:
从交互上来说,务必避免在小组件里放滚动列表,因为用户很难区分小组件内部的滚动和整个小组件列表的滚动
在 iOS 上:小组件不允许键盘输入,所以一切针对小组件的设置都应该在载体应用内完成。以“股市”为例,用户可以直接在小组件上切换显示的单位,但是股票列表的编辑需要在载体应用里进行。
在 macOS 上:载体应用可以不做任何功能,小组件可以提供一个配置入口。还是以“股市”为例,小组件里可以直接搜索、添加和删除特定股票。
就像创建分享扩展那样,首先要在项目配置里添加一个 Target(复习系列文章第一篇),如果想要共享数据的话,还需要配置一下 Capabilities -> App Groups(复习系列文章第二篇)。
Xcode 依旧贴心地为我们创建了一个目录,随便点开看看,可以发现 Info.plist 里关于 NSExtension
的内容有所不同,其中的 NSExtensionActivationRule
字段已经没有了,因为 Today 小组件的开关是用户自己选择操作的,不需要我们开发者去判断。
为了实现最好的效果,建议使用 AutoLayout 去做界面的布局。
Today 小组件的宽度是固定的,高度上有延伸的空间以显示更多的内容。Xcode 创建的 IB 模版里已经用上了 AutoLayout,并用上了标准的四周间隔,我们可以通过 widgetMarginInsetsForProposedMarginInsets:
方法来获取到这些间隔以便计算。
模版里的 VC 已经实现了
NSWidgetProviding
协议,上述方法就是在这个协议里定义的。
界面部分最值得一提的就是右上角的“展开/折叠”了。这个按钮默认情况下并不会显示,需要我们添加一些代码来实现:
1 | override func viewDidLoad() { |
extensionContext
我们的小组件是支持展开的,这个属性的默认值是 .compact
maxSize
是系统限制的当前模式下的最大尺寸,使用 iPhone XR 模拟器测试时,.compact
模式下是 (398, 110)
,.expanded
模式下是 (398, 748)
。可见,苹果限制了折叠状态下最大高度为 110,超出部分会直接截掉;而展开状态下,最大高度为设备的高度。界面部分就没什么了,剩下的该是具体问题具体分析。接下来轮到功能逻辑的部分。
实际上,小组件还是通过 Universal Link 的机制来唤起载体应用的,与应用间跳转没有什么区别,只不过需要通过 extensionContext
来调用:
1 | // 唤起 URL Schemes 为 davidleee 的应用 |
需要注意的是,如果你的小组件要打开第三方的应用,在提交 App Store 审核的时候需要特别说明一下,否则会被打回。
关于 Universal Link 的官方文档在这里:Universal Links - Apple Developer,我也写过一篇文章记录了可能存在的一些坑,感兴趣的可以瞅瞅:Universal Link(iOS)踩坑
既然 Today 小组件的目的就是为用户提供最新鲜的数据,那么数据更新的部分就一定不能马虎。
在 Xcode 帮我们创建的 TodayViewController
里面,我们可以看到一个叫 widgetPerformUpdate(completionHandler:)
的方法,在它的描述里能看到这么一句话:
This method is called to give a widget an opportunity to update its contents and redraw its view prior to an operation such as a snapshot.
苹果设计这个 API 是为了把数据的更新统一放到一个地方去。如果我们实现了这个方法,系统就会在合适的时候调用这个方法(比如系统想要给你的小组件进行 snapshot 之前),给我们一次更新数据的机会,并且这个机会不一定出现在小组件显示出来的时候,在后台的情况下也有可能触发这个回调。
于是我们就有两个拉数据的机会:
viewDidLoad
里面widgetPerformUpdate(completionHandler:)
里面实验发现,小组件只要不可见的时间稍微长一点点,比如滚动出了屏幕,或离开 Today 视图一小会,它就会被重新初始化,也就是说
viewDidLoad
的调用会比想象中更频繁。但这并不意味着我们可以完全依赖viewDidLoad
来做数据更新。
在 SO 上的这个讨论里,对数据更新的时机进行了更多的讨论,总结起来就是两点:
viewDidLoad
要用viewDidLoad
还不够,那就用上 widgetPerformUpdate(completionHandler:)
,毕竟前者并不会在后台情况下被调用Today 小组件就是一个用来展示小块数据和处理简单任务的地方。
注意上面那句话加粗的两个词,这给小组件定下了一个主基调:敏捷,所以凡是逻辑越写越复杂的时候,都该停下来想一想:这些逻辑是不是应该挪到载体应用里面去做?
用这个理由去怼产品经理吧,就说是那个估值超万亿的苹果的产品经理说的~
我们都知道 iOS 的应用是跑在一个属于自己的沙盒里面的,为了实现应用间的数据共享,苹果提供了一个叫 App Groups 的概念。只有当应用属于同一个 App Groups 的时候,才能访问到共享的数据存储区域。
我们可以在载体应用的项目配置 Capabilities -> App Groups 里创建一个应用分组:
然后在应用扩展的项目配置 Capabilities -> App Groups 里会出现我们刚刚新建的应用分组,直接钩上就可以了。
这样我们就等于分配了一个共享空间给这哥俩,为我们接下来的数据共享做好准备了。
做完上面的准备之后,我们就可以通过三种方式去访问共享空间,它们分别是 UserDefaults
、FileManager
和 CoreData
。
UserDefaults
有一个带参数的初始化方法,通过这个方法我们可以访问到一个共享的用户配置空间。在上一篇文章里,我们成功把 Safari 分享出来的一个 URL 打印了出来,现在我们把它放到共享空间去,让载体应用也可以获取到这个链接:
1 | ... |
通过传入之前设置好的应用分组 ID,我们告诉 UserDefault
接下来要访问一个特定的共享空间,接着就像平常那样使用它即可。
上面的打印输出的是一堆 data,以为
URL
在保存到UserDefaults
的时候会被序列化,想看到原来的URL
对象的话还要再反序列化一下才行。
与 UserDefaults
类似,FileManager
也有一个特殊的获取方法,我们看看把刚刚的 URL 写到一个文本文件里应该是什么样子:
1 | ... |
好吧,CoreData 的共享空间其实跟 FileManager
是同一个,只是从写文件变成写数据库,再把数据库的文件放到共享空间而已。这个就不贴代码了,CoreData 里的类名是真的长…
感觉这篇文章跟应用扩展都没什么关系了…毕竟 App Goups 是 iOS 平台上一个比较通用的数据共享技术。
App Groups 的引入让 iOS 应用间数据共享成为可能,这不仅可以用在应用扩展和载体应用之间,还可以用在自家的多个独立应用之间,真可谓是沙盒墙上透过来的一道亮光。
应用扩展与应用本身是有不同的。尽管在上架应用扩展的时候,你必须以一个普通的应用为载体(Containing App),但是它实际上是一个独立的二进制文件,而且并不依赖于载体应用来运行。
具体来说,应用扩展分为了十多个类别,你可以通过它们来实现各种各样的功能。下面是官方文档里介绍扩展点(Extension Point)的表格,我调整了一下格式:
我们创建的每一个应用扩展都必须与上表的其中一个扩展点相对应,不允许出现一个应用扩展对应多个扩展点的情况。换句话说,每个应用扩展的职责都应该是单一的,我们应该给用户提供快速、线性、聚焦的体验。
应用扩展的生命周期有别于一般的应用。应用扩展通常会在另一个应用的使用过程中被唤起,这个应用被称为宿主应用(Host App),宿主应用定义了与应用扩展交流的上下文,并通过发送请求的方式把应用扩展给启动起来。一般来说,应用扩展在完成宿主应用请求的任务之后,生命周期就结束了。
注意区分“载体应用”和“宿主应用”。载体应用是这个应用扩展的容器,在我们实现应用扩展的时候一并开发出来的;“宿主应用”指的是实际使用过程中调起我们的应用扩展的那个应用。
在上图第2步里,系统在启动了我们的应用扩展之后,会在应用扩展和宿主应用之间建立一条通信通道,用于传递宿主应用定义好的上下文和相关信息。
应用扩展根据宿主应用发来的请求,执行相应的任务,这些任务可能是立即返回的,也可能通过一个后台进程去完成。但无论是哪种方式,在应用扩展跑完自己的代码逻辑之后,系统就会立马把它结束掉。
上面提到应用扩展和宿主应用之间的通信方式,一个完整的通信关系是这样的:
应用扩展不会直接跟载体应用打交道,因为大多数情况下,应用扩展在工作的时候,载体应用甚至都还没有被启动。
在特殊情况下,比如 Today 小组件,扩展可以向系统提出启动载体应用的申请(通过调用 NSExtensionContext
的 openURL:completionHandler:
方法)。这时,应用扩展与载体应用就可以通过一个私有的共享容器来传递数据了,如下图所示:
从系统层面上看,这已经涉及到进程间通信了,但苹果提供的高级 API 很好地屏蔽了这一点,所以我们完全不用考虑这些事情。
因为应用扩展与一般应用的设计是不同的,所以虽然开发起来差不多,但有些 API 是应用扩展无法使用的:
sharedApplication
NS_EXTENSION_UNAVAILABLE
的框架,比如 HealthKit 和 EventKit UI 框架NSURLSession
对象来实现数据上传和下载,最终的结果会给到载体应用因为每一个扩展点都对应了一个特定的应用场景,所以创建应用扩展的第一步是选择正确的扩展点(可以回到文章开头部分查看扩展点表格)。
在 File -> New -> Target 里面,找到 Application Extension 模块,在里面选择想要实现的扩展点。这里我选了分享扩展作为例子:
给你的应用扩展起个美美的名字之后,它就会出现在项目配置的侧边栏里了,同时,Xcode 还为我们新建的应用扩展添加了一个 Scheme,让我们可以直接调式扩展而不用启动载体应用:
直接运行应用扩展,Xcode 会让你选一个应用来作为应用扩展的宿主:
分享扩展的兼容性很好,我们选 Safari 来尝试分享一个网页好了:
随便打开一个网页,点击下面的分享按钮,可以看到我们的应用扩展已经出现在分享列表里面了!
应用扩展的图标会跟载体应用的图标一致
创建了应用扩展之后,会发现项目结构里多了一个属于应用扩展的位置:
看起来就像一个普通的应用项目的结构,但 Info.plist 里有一个不同点,就是这里的 NSExtension
字典:
看名字都挺好懂的,其中的 NSExtensionAttributes
用来配置一些通用参数,比如支持的媒体类型等等。默认情况下,NSExtensionActivationRule
是一个 String
类型,这个值就是让系统在所有分享场景里都显示我们的应用扩展(我全都要!)。更真实的场景应该是只支持特定的文件类型,这时可以把它改成 Dictionary
类型:
上图的设置表示:我们支持分享图片、视频、文件和网页链接,后面的数字表示:一次分享中支持带上多少个这种类型的附件。
除图片和视频外的文件类型,都包括在 File 的范围里面,所以上面的配置几乎涵盖了所有的文件分享场景了
在 Xcode 创建好的 ShareViewController
里,我们可以通过 extensionContext
来拿到宿主应用想要传达给我们的信息:
1 | let items = self.extensionContext?.inputItems |
这是一个 NSExtensionItem 数组,每一个 NSExtensionItem
都带有一系列属性,例如标题、内容、附件、用户信息。
系统会回调 didSelectPost
或 didSelectCancel
以通知我们用户操作的结果,在这两个回调方法里,我们需要调用 completeRequest(returningItems:completionHandler:)
返回一系列 NSExtensionItem
对象给宿主应用,或者调用 cancelRequest(withError:)
返回一个错误。
从 NSExtensionItems
里能直接获取到的信息是远远不够的,真正的大部头都在 attachments
这个属性里。这是一个 NSItemProvider 类型的数组,自此我们就基本看到了整个 NSExtensionContext
的构成了,借用一张其他博客的图片:
好,回到正题。拿到 NSItemProvider
之后,会发现要从这个类里面拿东西并不简单。
继续上面的例子,我们打算在用户点击 “Post” 按钮之后,获取从 Safari 分享过来的 URL,完整的代码是这样的:
1 | override func didSelectPost() { |
inputItems
是一个 [Any]
类型的数组,所以在使用之前需要转换一下items
和 attachments
import MobileCoreService
item
是 NSSecureCoding?
类型的,这里只简单打印了一下,真正使用的时候还需要补充一些额外处理应用扩展对于用户来说应该是敏捷而且轻量的小工具,所以它的启动速度务必要保持在1秒以内,系统会自动关闭启动耗时太长的应用扩展。
对于 Widget 来说,界面上通常会一次显示多个,所以 Widget 的内存使用要求是最严格的(大概是16 MB),一旦超出了限制,会显示 “Unable to Load” 的字样:
其他类型的应用扩展对内存的要求会松一点,但还是比一般应用要严格,比如自定义键盘要求 48 MB 以下,分享扩展要求 120 MB 以下,实际情况可能跟设备相关。
另外,应用扩展是公用同一个主线程的,所以不要在应用扩展的逻辑里做可能会阻塞主线程的操作。同理,GPU 也是这样一个共享资源,如果一个应用扩展需要执行大量绘图逻辑,系统会倾向于把它结束掉。
总而言之,开销大的操作都应该在载体应用里做,而不是让应用扩展去负责。
本文介绍了什么是应用扩展,并介绍了一个简单的分享扩展是怎么实现的。文章大体是来源于官方的文档,虽然文档已经被苹果归档了,但是文中的代码都是我写完用模拟器验证后得来的(2018年11月14日),大家可以直接拿走按需服用 :)
没想到只是介绍了一些基础就写了这么多。其实我还打算讲讲分享之后怎么跟载体应用交互,还想要看看今日小组件(Today Widget)怎么整…只好放到后面的文章里去了。 我发誓在这周之内把这两部分内容都给补上来!
你们看,我写完了:
在 Linux 系统下面,我们可以通过 systemctl
或者直接修改 rc.local 文件
来实现启动项的添加。但是这一套在 macOS 上面玩不转了,因为我们需要通过一个完全不一样的机制—— Launch Daemon 来实现这个功能。
因为 macOS 的启动项是通过一个 plist 去配置的,配置一个脚本远比配置一段要执行的命令行指令要简单,所以这里采用脚本的方式去实现。
于是我们要做的事情只有两步:
先创建一个脚本文件:
1 | vim startup.sh |
不考虑异常情况,就是简单地进到 docker-compose.yaml 所在的目录,然后执行一下启动命令:
1 | !/bin/bash |
为了避免我们执行
docker-compose
的时候 docker 自己还没有跑起来,所以用一个循环去检测我们的服务是不是真的启动了。另外还要记得把上面的
docker-nginx_nginx_1
改成你真正的的容器名称。
别忘了给脚本加上执行权限:
1 | chmod +x startup.sh |
现在我们要把上面的脚本添加到 Launch Daemon 里面去。
在此之前,让我们先理清一些概念。
macOS 通过一系列的 plist 文件来配置启动项,这些 plist 根据存放位置的不同而分为 Launch Daemon 和 Launch Agent。它们的区别在于,Agent 是在用户登录之后以该用户的身份去执行的任务,而 Daemon 是以根用户或 UserName
里指定的用户去执行的任务。
它们一般存放在这两个地方:
1 | /Library/LaunchDaemons |
我们这次的任务需要用到 root 权限,所以我们将会在 LaunchDaemons 里创建一个配置文件:
1 | sudo vim /Library/LaunchDaemons/com.file-service.plist |
然后在里面填上以下内容:(注释部分可以去掉咯)
1 |
|
最后就是把这个 plist 加载到 launchctl 里面去了:
1 | # `-w` 会把 plist 永久添加到 Launch Daemon 里面 |
在执行完上面的 launchctl load
指令之后,plist 里面配置的脚本会马上被执行,你可以通过 launchctl start
和 launchctl stop
来控制它的开关,不过我们这里只是执行了一个脚本,并不会像其他应用那样长驻,所以其实也就没有“开关”一说了。
SVG(Scalable Vector Graphics) 是一种基于 XML 语法的图像格式。跟基于像素处理的图片格式不同,它是基于对图像形状的描述来实现的,本质上是一个文本文件,体积上较小,而且在放大的时候也不会失真。
因为 SVG 是基于 XML 语法的,所以对于前端开发者来说,写起来应该比较顺手;对于 React-Native 的项目,因为 JSX 的关系,用起 SVG 来也是没有“语言障碍”的。
直接上源码:
1 | <svg width='100' height="100" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> |
这张 SVG 图渲染出来是这样的:
直接把上面的代码保存为文本,就可以用浏览器打开并显示了。macOS 的用户还可以直接空格预览。
配合一些参数的改变,做动画也是分分钟的事情。对于之前没有怎么用过 SVG 的我来说,简直是打开了新世界的大门。
接下来就让我们一起探究一下上面这个图是怎么来的。
根据上面的例子,我们可以大胆猜测一下: <path>
标签下 d
属性的值就是用来描绘这个心形的外框路径的。既然描绘路径已经是确定的了,那一张 SVG 图片是怎么实现缩放不失真的特性的呢?
要回答这个问题,就要让我们先了解一下 SVG 的一些基本显示原理。
假设我们要将一个 SVG 图形绘制到一张画布(Canvas)上,概念上这张画布应该是无限大的,这样我们的图形才可以是任意大小。然而,实际上 SVG 图片是显示在一个有限的区域里的,就像我们透过窗户看窗外的风景一样,这个有限区域被称为“观察孔”(Viewport)。
“观察孔”指的是 SVG 图片可见的那一部分,想象我们透过窗户看窗外的风景,这个窗子就是外面风景的观察孔。
类似的,我们在浏览网页的时候面对的也是这种情况,网页的大小通常比浏览器的窗口要大,这时候浏览器就是这个网页的观察孔了。
我们通过设置 <svg>
标签的 width
和 height
属性来确定这张 SVG 的观察孔大小,对于上面的心形来说,观察孔是 100x100 的正方形:
1 | <!-- the viewport will be 100px by 100px --> |
在 SVG 里,数值的单位是可选的。在我们不主动提供的时候,默认会以
px
为单位。可选的单位有em
、ex
、px
、pt
、pc
、cm
、mm
、in
和百分比。
在观察孔的大小确定下来之后,SVG 就会建立一套初始的坐标系统:以最左上角为 (0, 0)
点,x 轴和 y 轴分别向右和向下延伸(就像移动客户端和网页显示里那样)。在这个基础上,我们刚刚创建的观察孔也就有了属于自己的一套位置标识:(x: 0, y: 0, width: 100, height: 100)
。
viewbox
在了解了上述知识之后,我们就可以来说说 viewbox
这个属性了。
我们可以把 viewbox
理解为“真正的坐标系统”,因为它决定了 SVG 图形是怎么绘制到画布上的。一个 SVG 图形的大小可以与观察孔不一样,它可能会完整地显示出来,也可能会被观察孔裁减掉一部分。
就像一张普通图片一样,当你需要把图形完整地放进一个视图里面时,可以调节图片的拉伸模式,一边让图片的大小更为合理。SVG 里对应的属性是
preserveAspectRatio
。
viewbox
会一次性设置4个参数:
1 | <svg viewbox="<min-x> <min-y> <width> <height>"></svg> |
其中,<min-x>
和 <min-y>
不能设置为负数,<width>
和 <height>
设置为 0 的话,元素就压根不会绘制了。
所以说,viewbox="0 0 100 100"
就做了下面几件事情:
那对于上面的爱心图片来说,我们尝试调整一下 viewbox
的原点,将它设置为 viewbox="50 50 100 100"
试试:
可以看到,就像地图软件一样,镜头往画面的右下方移动了一段。这也相当于把整个画面往左上方推了过去一点,我们可以通过设置画布的 transform
属性来实现相同的效果:transform="translate(-50 -50)"
。
当设计师给你一张 SVG 图片的时候,其中的图案路径可能是按照一定的大小和位移来绘制的,比如从(10, 10) 点开始画的一张 40x40 的图,这时候你的
viewbox
就应该设置为viewbox="10 10 40 40"
,让图片放到最合适的坐标系统上。
上面的例子演示了一张 SVG 图的常规操作,限于篇幅原因,还有一些有意思的情况没有展示到,比如当 viewbox
里设置的宽高比与我们在 <svg>
标签里设置的宽高比不一样会发生什么呢?这种情况下,就需要我们去了解一下 preserveAspectRatio
属性了。
在参考文章 Understanding SVG Coordinate Systems and Transformations (Part 1) 里,有对这方面更详细的解释,而且作者还提供了非常直观的在线预览工具,相信会对偏向于使用图像思维的同学们更有帮助。
除此之外,它还有很好的错误恢复机制,它能避免在错误发生时把所有东西都弄得一团乱:比如说在它碰到不认识的声明时,它会直接忽略掉这个东西。但从另一方面来说,这也让错误更难被发现了。
借着最近前端同事事务繁忙的机会,我这客户端工程师赶鸭子上架怼了一个静态网页出来,顺便产下了两篇副产品,都与 CSS 基础相关,这是第二篇(第一篇在此: CSS 是怎么运作的),希望对其他可能有相同需要的同志送上一些帮助。
正如上一篇文章里说到的,CSS 是由选择器和一组属性组成的。其中,属性部分是由一系列的键值对组成,在 CSS 的世界里,它们有着自己的名字:
这样的一组“属性-值”的组合,我在前面直接称呼为“键值对”了,但它在 CSS 世界里的本名其实是 CSS 声明(CSS declaration)。
被一对大括号包裹起来的一组 CSS 声明被称为 CSS 声明块(CSS declaration blocks)。
最后,一个 CSS 声明块会跟一个选择器搭配起来,称为 CSS 规则(CSS Rulesets/Rules)。
把 CSS 属性设置为一个特定的值,可以说是 CSS 这门语言的最核心功能了。需要注意的是,属性和值都是区分大小写的,它们之间用 “:” 来分隔。
目前,CSS 世界里一共有300 种不同的属性,每种属性都有其对应的可选值。
在 CSS 语法里(包括其他 web 标准中),美式拼写是唯一的拼写标准。比如说,在需要设置颜色的时候,
color
永远优于colour
。
CSS 声明以代码块的形式存在,用一对大括号括起来。
CSS 声明块可以是空的(里面不带任何声明)
CSS 声明块里的不同声明是通过 “;” 来分隔的。
实际上,最后一组声明是可以不用分号结尾的,但是好好的干嘛要逼死强迫症呢?
在写好了声明块之后,我们还需要告诉浏览器这些属性要用到哪里去,这就需要在这个声明块前面加上一个前缀——选择器了。
选择器可以是非常复杂的:你可以把一个声明块应用到好几个选择器上,通过逗号分隔;你还可以链式地构造一个指向性更明确的选择器,比如:选择一个类名是 “abc” 的元素,它要在 <article>
标签下,而且只有鼠标移动到它上面的时候才生效。
一个元素可能被多个选择器看上,所以同一个属性可能会被改变多次,CSS 会通过层叠算法(cascade algorithm)来判断这些属性修改的优先级。
对于同一个声明块,在使用复杂选择器的时候(比如存在多个选择器),如果其中的某一项选择有误,那么其他的选择器是不会被影响的,该怎么工作还是怎么工作。
除了上面看到的声明块之外, CSS 里还有一些其他类型的语句:
1 | @import 'custom.css'; /* 从另一个 css 文件中引入规则 */ |
@media
运行设备符合某些条件时才执行@supports
浏览器支持某些测试特性的时候才执行@document
当前页面符合某些条件时才执行1 | @media (min-width: 801px) { |
上述针对 body
的规则,只在设备宽度大于 800px 的时候才会生效。
分两篇叙述的 CSS 相关知识就讲完了。这两篇文章的主要目的是把我们领进前端世界的大门,读完之后,我们应该可以实现一些简单的静态页面了!(小声说:虽然具体怎么用还需要自己去谷歌百度一下)当然,前端的魅力还远不止如此,要想把 CSS 玩出花儿来,还需要持续的磨练。
我这个半吊子的前端工程师总算是把整个静态页面的需求给怼出来啦!接下来如果有时间的话,我会再把页面里用到的一些 JS 实现的逻辑也拉出来溜一溜。要是这下一篇文章真的有诞生之日的话,那读到完整三个部分的同学们就会在前端界六得飞起(假的)了!
借着最近前端同事事务繁忙的机会,我这客户端工程师赶鸭子上架怼了一个静态网页出来,顺便产下了两篇副产品,都与 CSS 基础相关,这是第一篇,希望能对其他可能有相同需要的同志送上一些帮助。
文章的内容基本是翻译 MDN 文档来的,怕有什么遗漏的同学可以直接翻到文末看官文,也欢迎指出本文的错误 :)
网页浏览器会把 CSS 规则应用到文档上,以改变文档内容的表现形式,一个单一的 CSS 规则是由下面这两个东西组成的:
一个 CSS 规则约定了某个元素长什么样,一个包含了一组 CSS 规则(Rulesets/Rules)的 stylesheet
就定义了一个网页的长相。
来看一个简单的 HTML 文档,这个例子里包含了 <h1>
和 <p>
标签,而 stylesheet
则是通过 <link>
元素实现的:
1 |
|
然后看两个 CSS 的规则:
1 | h1 { |
这两个 CSS 规则是写在 style.css 文件里的,跟上面的 .html 文件放在一起就可以通过相对路径引用到
大括号签名的标签(h1
和 p
)就是选择器,它告诉浏览器这些规则要作用在什么标签上;而大括号里的键值对就约定了这些标签的内容的显示规则。
浏览器在处理网页的时候,会分两步走:
一个 DOM 的内容是以树状结构保存的。每个通过 markup 语言表述的元素、属性、文字等会变成 DOM 节点保存在树上。
假设我们有一段 HTML 代码:
1 | <p> |
将它转换为 DOM 之后,这个 DOM 会是这样的:
1 | P |
现在来加一个 CSS 约束试试:
1 | span { |
emmm…还是跟刚刚一样的 DOM,不过这些 CSS 约束会被加到 span
选择器上面去,于是渲染出来的样子就不一样了。
就是上面例子里用到的方法,CSS 约束是写在一个单独的 .css 文件里的
即直接通过 <style>
标签来定义元素的长相,这个 <style>
需要写在 <head>
标签下才会生效:
1 |
|
这个例子跟之前的例子是同样效果的。
对于只想改变单独一个标签元素的情况下,可以通过标签的 style
属性实现:
1 |
|
然而这种做法并没有很受待见,因为这样定义的样式没办法复用,你可能需要在好几个文档里面写上同样的几个样式,维护成本大大升高了。
另一方面,把 HTML 语法跟 CSS 语法混合在一起,看起来就不那么清晰易懂了,建议是把不同类型的代码分开,保持纯粹。
到这里,我们已经不止能写 HTML 网页了,还能通过 CSS 给这简陋的网页披上华丽丽的外衣。在下一篇文章里,我们会继续深入学习 CSS 的句法,距离踏入前端世界的大门又要近一些了!
这是 macOS 开发系列的第三篇文章 —— 文本输入系统基础。
电脑的文字编辑功能比手机上的强大(难搞)真不是吹!
The Macintosh operating system has provided sophisticated text handling and typesetting capabilities from its beginning. In fact, these features sparked the desktop publishing revolution. —— Apple
在 macOS 的世界里,要显示或者编辑文字主要会用到两个控件,一个是 NSTextView,另一个是 NSTextField。
虽然控件库里面有个叫 Label 的东西,但是拖出来之后就会发现,它其实也是一个 NSTextField
它们的继承关系是这样的:
NSTextView 是苹果花了大心血打造的一个“满足几乎所有显示和管理文字需求”的一个控件,也是 macOS 引以为傲的文字编辑系统的主心骨;NSTextField 则相当于一个简化版的 NSTextView,在大多数情况下它可以满足字数较少的输入需求。
正如我们在 iOS 开发里常做的那样,在需要用户输入的地方,通常是直接展示一个 Text 相关的控件,然后通过 delegate 方法来控制输入的内容;又或者继承一个官方的控件,然后在内部直接实现想要的内容控制逻辑。
在 macOS 上,这个流程也是大致相同的。
以前写过一篇简单介绍 NSTextView 用法的文章( 20分钟手把手教你写 macOS 文本编辑器),等不及的童鞋们可以在这篇文章里过过瘾。
虽然从使用上来看,跟开发者直接打交道的就是 NSTextView 和 NSTextField 这两个类,最多再加上它们带着的一些协议/代理,但是继续往深了看,会发现有一个未知的世界在支撑这这一切。
macOS 上有一个叫 Field Editor 的概念。
在输入框获得焦点的时候,系统会实例化一个 NSTextView 作为 Field Editor,并把它作为 first responder 插入到这个输入框的事件响应链当中。如此一来,Field Editor 会负责处理所有的用户输入事件,在这个过程中,获得焦点的输入框会作为 Field Editor 的代理,以便对文本的内容进行控制处理。
这就是为什么 NSWindow 的
firstResponder
返回的是一个不可见的对象,而不是我们获取了焦点的输入框,因为这个对象就是上面说的 Field Editor。
Field Editor 是同一个窗口里所有输入框共用的,所以在我们用 Tab 键切换输入框的时候,Field Editor 就会切换事件响应的对象。另一方面,这个机制也确保了同一个窗口中只能有一个控件去响应用户输入事件。不过,我们也可以实现自定义的 Field Editor 来推翻上面说的这些功能。
虽然 Field Editor 一般会是一个 NSTextView 的实例,但是它们对 Tab 和 Return 的事件处理是不同的。对于 Field Editor 来说,这两个键盘事件是“结束编辑”的意思。
这是外界和文本输入系统之间沟通的桥梁。
在用户进行输入的时候,keyDown
消息会被传递到获取了焦点的输入框里,输入框接下来会调用 NSTextInputContext 的 handleEvent
方法,以便让 NSTextInputContext 告诉自己需要怎么处理这个用户事件。而作为响应,NSTextInputContext 会把处理结果通过 NSTextInputClient 这个协议告知输入框。
从上图可以看到,NSTextInputContext 会跟一个叫 Key-bindings dictionary 的字典保持密切联系。这个字典默认来自于 AppKit 内部的一个文件(*/System/Library/Frameworks/AppKit.framework/Resources/StandardKeyBinding.dict*),里面保存了系统默认定义好的所有快捷键,只有当用户输入的值在这个字典里找不到匹配的键值对时,这次输入才会作为普通字符回调给输入框,否则 NSTextInputContext 就会在这里把这次输入拦截下来,并让输入框执行相应的特殊操作。
我们可以通过修改 ~/Library/KeyBindings/DefaultKeyBinding.dict 里的值来覆盖默认的快捷键
到此为止,macOS 文本编辑系统的一些内在机理已经了解地差不多了。在往后使用 NSTextView 和 NSTextField 的过程中,碰到一些不明觉厉的问题也能有个大概的问题排查方向了。(不过也仅限于“大概方向”了)
好吧,我知道这篇文章偏理论了一些,大多数情况下也不会用到。更多详细的说明可以在文章里提供的各种链接上找到。
在后续的文章里,还会讲到 NSTextField 实际应用上的一些内容。NSTextView 因为暂时没有用上,所以可能会等有机会研究清楚些再讲咯。
升级之后,配置上唯一的不同在于 v2 版本中干掉了 createReduxBoundAddListener(Key)
方法,取而代之的是 reduxifyNavigator(Navigator, Key)
。
在 v1 版本中,我们需要把前者构造出来的 addListener
作为参数传给 AppNavigator:
1 | import { |
而在 v2 版本中,使用新方法可以简化上述步骤:
1 | import { |
原来 state.nav
对应的 props
键叫 “nav”,现在改为 “state” 了:
1 | // v1 |
另外,之前为了处理 Android 返回按钮的问题,可能会自定义一个类包裹着上面构造出来的 AppNavigator,然后通过 react-redux 的 connect
方法把 mapStateToProps
给作用到这个自定义的类上去,比如:
1 | class ReduxNavigation extends React.Component { |
经测试发现,在 v2 版本里,这种操作会报 “undefined is not an object(evaluating ‘state.routes’)” 的错误,猜测可能跟 Props 的键值变化有关。把 connect
的调用提前,让它先作用到 AppNavigator 再包裹到自定义类里面即可:
1 | const ConnectedNavigator = connect(mapStateToProps)(AppNavigator) |
对于同一个 Navigator, reduxifyNavigator
如果在 connect
之后调用,会报重复定义navigation
属性的错误。所以加上前面的配置过程,完整的例子应该长这样:
1 | /* importing the whole world */ |
Redux integration · React Navigation
Redux integration · React Navigation (v1)
这是 macOS 开发系列的第二篇文章——让人眼花缭乱的 macOS 菜单。
这有什么好说的,你不要骗我!手机上的菜单都是我用自定义视图撸出来的!
在 macOS 开发中,所谓“菜单”并不只是一个自定义视图了(虽然自定义视图也可以实现),在 AppKit 里面名字直接叫“菜单”的类就占了两席之地,分别是 NSMenu 和 NSMenuItem。
在实际应用中,菜单对应了 5 种表现形式:
应用的菜单栏,在屏幕的最上方
弹出菜单,可以出现在当前窗口中的任何地方
状态栏,从屏幕上方的菜单栏右边开始向左延伸
“上下文菜单”(Contextual Menus),点击右键或 “control + 左键” 触发
Dock 菜单,对程序坞(Dock)的应用图片点击右键或 “control + 左键” 触发
看着挺多,但是用起来倒是挺简单方便的。下面就把这几个新玩具都拉出来溜一溜~
这个菜单栏在上一篇文章(不一样的 macOS Storyboard)里已经纠结过了。
概括一下:每个应用初始化的时候就自带了一个应用菜单栏,如果是使用 Storyboard 开发的项目,在 “Main.storyboard” 里面就可以直接对这个菜单栏进行各种各样功能上的调整了(也就限于逻辑,样子大概是改不动了,只能用系统控件…)
这种菜单的含义比较宽泛,所有在当前窗口里面出现的、带有“弹出”感觉的菜单都可以属于这一类。从视觉上大致可以分成两种:对话框型 & 按钮型。
这中文名是我自己取的…因为它长得像呀!它就是上面类型介绍里的图片所示的样子,对应的类是 NSPopover。
这玩意儿在 iOS 9 和更老的版本中有类似的用法叫 UIPopoverController,在 iOS 9 之后就变成了 UIViewController 的一种展现方式了,具体参见 UIPopoverPresentationController。
NSPopover 用起来跟上面说的几个类也是差不多的,只是它本身不是一个 ViewController,所以在展示之前需要先设置 contentViewController
以负责界面的显示。
更多风骚的用法还是参考官方文档为好 - NSPopover。
顾名思义,这是个看起来很像按钮的菜单,打开系统偏好设置 -> 通用,就能见到大把的按钮型弹出菜单,它们对应的类是 NSPopUpButton。
在 xib 或者 storyboard 里面拖一个出来,能看到它跟普通按钮相比多出了这么一些独有的配置:
其中 “Type” 部分有两个可选值:Pop up & Pull Down,它们最直观的区别在于按钮后面跟着的蓝色部分,前者是上下箭头,后者则只有一个向下的箭头:
当然,它们在列表展开方式和使用场景上也是不一样的,想要追究其中细节的童鞋们,推荐一篇官方文档:Managing Pop-Up Buttons and Pull-Down Lists
作为一个中规中矩的菜单,状态栏菜单也是由两个部件组成的:NSStatusBar & NSStatusItem,从名字就能看出它们的关系了,具体用法也是看文档咯。
额外推荐一篇很详细的文章:Menus and Popovers in Menu Bar Apps for macOS | Ray Wenderlich
根据官方的说法,状态栏的位置稀缺,不保证你应用的菜单在上面一直是可用的,所以建议把它放在最后考虑(是的,甚至在 Dock Menu 之后)。做事比较克制的微信只把它用来显示未读消息条数,点击回调也只是打开微信主窗口而已。
而且苹果还建议我们提供一个隐藏的选项,在必要时给用户隐藏掉我们状态栏图标的机会。
Emmm….虽然苹果的话我们也不一定听就是了…
也就是俗称的右键菜单?
macOS 上还可以用 control + 左键触发
它跟应用菜单栏一样,是通过最常见的菜单样式来展现的,对应的类是: NSMenu & NSMenuItem。
唯一不同的是,它不是通过点击一个什么按钮去触发的,而是通过重载 NSView 的 defaultMenu
属性来实现的,我们只需要定义好菜单的样子和内部逻辑,打开/收起菜单这样的琐事就交给系统去做好了。
在 xib 或 storyboard 里,把菜单链接到其他视图的 Outlets -> menu 上面,同样能实现右键触发的效果:
话说上图这个界面本身也是一个 Contextual Menu 呢。
阿哈!这也是个普普通通的 Menu,通过实现 NSDockTilePlugIn 这个协议里的 dockMenu()
方法就可以返回一个我们自定义的菜单啦。
我们还可以通过它来自定我们的应用图标在 Dock 上面的样子,比如加个角标或者改一下图标颜色什么的,不过在这样做之前,我们还需要看看这个类 NSDockTile。这就超纲了啊,不说了不说了。
虽说自定义视图和显隐逻辑也可以实现菜单的功能,但是 AppKit 已经为我们封装了好几个类,让我们可以方便快捷地怼出一个功能丰富的应用了,它们是:
除了一些特殊的应用之外,我们的主要功能应该是在窗口里面提供的,而菜单通常都是些锦上添花的东西。不过在有余力的时候,为我们的用户增添一分“意外之喜”也是极好的吧。
]]>