IOS-终极面试指南-全-
IOS 终极面试指南(全)
原文:
zh.annas-archive.org/md5/a25530be5b8381f911ebfe974c15e15c
译者:飞龙
前言
自从苹果公司推出其首个 iPhone 开发 SDK(那时 iOS 就是用这个名字)以来,已经过去了许多年,iOS 生态系统已经成为世界上最具影响力的生态系统之一。为了支持数十亿台设备并使用最新的技术,对 iOS 开发者的需求始终很高。
然而,事情已经变得更加复杂。在 2009 年,添加一个简单的表格视图或按钮就足以获得一份工作。然而,如今,这些任务甚至不会在面试中被问到。今天的知识范围如此广泛,以至于面试官关注的不仅仅是基础知识。
今天 iOS 开发世界的复杂性,加上激烈的竞争,已经形成了一个多样化的招聘流程,这个流程不仅需要构建一个简单的屏幕。因此,我们迫切需要在专业和广泛的基础上做好准备。
《终极 iOS 面试宝典》 是一本全面的书,它指导你从面试准备的早期基础知识到 Swift、不同的框架,甚至设计、架构和编码任务。
这本书面向的对象
所有级别的 iOS 开发者都会发现这本书吸引人且有用。然而,有三类开发者会发现它更有价值:
-
寻找第一份工作的初级开发者:作为一名 iOS 开发者找到第一份工作可能具有挑战性。虽然工作场所理解初级开发者可能没有丰富的经验,但他们仍然期望他们具备该职位所需的最基本技能和知识。《终极 iOS 面试宝典》可以帮助缺乏经验的开发者成功应对这一艰巨的任务。
-
那些在职业生涯中感到停滞不前的经验丰富的开发者:在同一个工作场所或角色中度过几年有其好处,但也可能导致面试技能的下降,阻碍在新的开发领域中的成长。此外,对于经验丰富的开发者来说,他们完成学业已经很久了,这是一个重新审视基础的好机会。
-
希望提升职业发展的开发者:那些将他们的专业视为长期职业道路的人需要采取下一步来进步。寻找新的角色并非易事,尤其是在全职工作和试图建立个人品牌的同时。
总结来说,这本书面向广泛的 iOS 开发者,但以下这些特定群体可以从《终极 iOS》面试宝典中受益匪浅。
这本书涵盖的内容
第一章,面试前准备,描述了面试本身之前我们需要采取的行动,包括公司研究、简历撰写和面试准备。
第二章,面试流程概述,提供了面试流程的概述,包括其不同的步骤和目标。
第三章, 开发者品牌建设,介绍了如何利用我们的品牌作为开发者,例如创建令人印象深刻的 GitHub 和 Stack Overflow 账户,从事个人项目,以及编写。
第四章, 数据结构和算法,探讨了 Swift 和通用编程的基础构建块。它深入探讨了诸如结构体、类、数组、Codable、字典和集合等主题。本章包括常见的面试问题和示例。
第五章, Swift 编程语言,提供了 Swift 语言基础的重要概述。它涵盖了诸如可选类型、访问控制、闭包、协议、内存管理和泛型等主题。本章包括代码示例和面试问题。
第六章, 管理你的代码,涵盖了 iOS 开发者角色的不同方面,并有助于培养其他重要技能,如规划、测试、调试和文档编写。
第七章, 使用 UIKit 构建优秀用户体验,检查了 iOS 开发中最重要的框架之一——UIKit。它包括 Auto Layout、UIView
、UIViewController
、UITableView
、导航和动画等主题。本章提供了带有代码示例和面试问题的全面解释。
第八章, SwiftUI 与声明式编程,通过关注 SwiftUI 和声明式编程来探讨 iOS 开发的未来。即使你在这个领域没有经验,这一章也是至关重要的。它涵盖了声明式编程、状态和可观察对象、导航、SwiftUI 生命周期和 Combine。
第九章, 理解持久化内存,涵盖了一个较少见的面试话题。它提供了对 Core Data、UserDefaults
、Keychain
和 Files 的简要了解。本章的主要目标是让我们为高级阶段,如设计和架构面试做好准备。
第十章, 库管理,概述了将第三方库集成到我们的项目中(这是当今开发者的一项关键技能)以及模块化我们的项目。本章包括面试问题和代码示例。
第十一章, 解决复杂问题的设计模式,涵盖了在项目中实现有效设计所需的工具集。它概述了 iOS 开发中最常见的模式,如 MVC、MVVM、依赖注入、委托、单例和并发。
第十二章, 深入探究应用架构,探讨了应用架构的含义以及构建优秀架构的基本概念。它包括关注点分离原则、应用层、协议使用和网络。
第十三章,通过编码评估,探讨了面试中的编码部分,包括白板和远程任务。本章解释了如何优先处理我们的工作,如何应对编码面试,以及如何避免暴露红旗。
为了充分利用本书
本书提供了许多简短示例,仅用于演示目的,您无需直接执行它们即可理解。然而,建议您使用 Xcode 和 Mac 实践本书中讨论的一些主题。
本书涵盖的软件/硬件 | 操作系统要求 |
---|---|
Xcode | macOS |
CocoaPods |
使用的约定
本书中使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和推特用户名。以下是一个示例:“让我们看看一旦将其提取到Person
结构体中,函数接口看起来会是什么样子。”
代码块设置如下:
struct A { var name: String
}
let a = A(name: "Avi")
a.name = "John"
当我们希望将您的注意力引向代码块中的特定部分时,相关的行或项目将以粗体显示:
platform :ios, '14.0'target 'MyApp' do
use_frameworks!
pod 'MyFramework', :path => '../MyFramework'
end
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从菜单栏中选择文件 | 新建 | 包以打开 Xcode。”
小贴士或重要注意事项
如此显示。
联系我们
我们读者的反馈始终受到欢迎。
customercare@packtpub.com
并在邮件主题中提及书名。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。
copyright@packt.com
并附上材料链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问 authors.packtpub.com。
下载本书的免费 PDF 版本
感谢您购买本书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM-free PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781803246314
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱
第一部分:关于面试的一切
这本书的初始部分专注于在开始面试流程之前需要采取的基本步骤。它涵盖了各种行动,例如寻找合适的公司、撰写吸引人的简历、了解面试流程以及建立强大的开发者品牌。到本节结束时,你将拥有在第一次面试中取得成功的必要工具。
在这部分,我们包含以下章节:
-
第一章,面试前
-
第二章,面试流程
-
第三章,开发者品牌
第一章:面试前
我们认为自己是很棒的 iOS 开发者。有多棒?嗯,我们在生活中完成了一些令人印象深刻的事情。例如,我们构建了华丽的动画,实现了 Combine,将应用版本上传到 App Store,并调试了复杂的错误。
那么,具体问题是什么?为什么我们需要这本书?
因为了解 Swift、UIKit 和 Combine 是了不起的,调试、算法和 CI 管理都是 iOS 开发者必备的技能。但对于许多开发者来说,还需要添加一项技能,那就是如何在 iOS 开发者劳动力市场中成为一员。在这个市场中竞争需要我们学习和适应一些我们可能还需要学习的技能,比如自我表达、措辞、沟通甚至营销方面的技能。
在这本书的结尾,我们将运用我们庞大的 iOS 开发知识,学习如何使用它来掌握一项新技能:通过 iOS 面试的能力。
那么,我们如何开始?
老子(一位中国哲学家)说:
千里之行,始于足下。
但问题是,第一步是什么?
回答一个 Swift 问题或为架构问题草拟一个设计是否是第一步?嗯,我们的第一步是理解我们希望从我们的工作场所得到什么,我们希望在哪里,并自信地准备好征服它。
理解这一切可能听起来像是一项简单的任务。我们只需将我们(在两分钟内写的)简历发送给我们所知道的每家科技公司,并期待着会有所发生。不幸的是,事情并非如此。我们必须研究市场,并根据我们的需求调整我们的简历。更重要的是,在我们审视市场之前,我们必须审视自己,了解我们是谁,什么对我们是有益的。
在本章中,我们将学习如何以最佳条件与最适合我们的公司进行第一次面试。我们将一起构建公司档案,了解那里存在的不同类型的公司。我们还将了解一份好的(或不好的)简历是什么样的,并写一份适合我们和我们目标工作场所的简历。然后,我们将学习如何准备我们第一次面试的所有事宜。
为了这个目的,我们将在本章中涵盖以下主题:
-
进行公司研究
-
构建我们的简历
-
准备面试
进行公司研究
许多候选人可能会觉得下一句话很奇怪,但有些人因为缺乏对面试公司的了解而在 iOS 面试中失败。你相信吗?事实是,掌握 UIKit 或 SwiftUI 是至关重要的,成为一名 Swift 专家是至关重要的,但工作场所的概况同样重要。
将在公司工作视为一段长期关系,将面试过程视为盲约见面,一切从这里开始。记得那份激动,我们与朋友一起进行的研究,或者每个人在会话中提出的问题。好吧,工作面试是你的第一次约会。打扮得体!
求职面试是一个双向过程。在面试过程中,我们检查工作场所的程度与工作场所检查我们的程度一样。
了解我们正在面试的公司的主要原因很明显:我们想确保我们的下一个工作场所像手套一样适合我们。但还有一个原因。如果我们熟悉公司、其产品和市场,这将有助于面试过程。
在开始面试流程之前,我们应该构建一个公司形象,这样我们就可以了解其文化、规模、工作环境等等。我们可以获取官方和非官方信息,这两者对我们准备都是至关重要的。
为什么?让我们找出答案!
了解你要去哪里是面试的一部分
这里有一个只有在这本书中才能找到的秘密:许多面试官不仅检查我们的iOS开发知识,还检查我们对工作场所本身的了解。有时候,当比较具有相似编码技能的候选人时,这可能会产生差异!
面试官寻找热情、自信的候选人,他们熟悉产品、市场和行业。
这种先前的知识引导我们展示我们个性的另一面:我们在面试之前就成熟、认真并进行了全面的研究。这一面极大地提高了我们在角色中成功的可能性,这也是面试官所寻找的。
公司形象影响我们的回答
信不信由你,一些公司特征可能会影响我们的回答,尤其是在设计和架构问题方面。而这正是我们在书中将要学习的内容。在许多情况下,面试官希望在我们的个性中看到灰色,而不仅仅是黑白。
我的意思是,我们总是需要平衡我们的回答,并理解几乎任何问题都有利弊。以我们正在面试的公司的心态回答问题,这真的会在当时面试我们的那个人眼中产生很大的差异。
学习公司特征
在进行研究时,我们需要寻找哪些东西?是什么构建了我们的公司形象?
通常,公司形象分为三个部分:产品和行业、公司详情和工作环境。我认为它们在某种程度上都是重要的,以便我们开始了解我们正在面试的地方,以及工作场所是否以及如何满足我们的需求。
让我们看看我们是如何构建每一部分的。
产品和行业
在面试中,我无法描述熟悉公司产品或行业意味着什么。让我们谈谈这对我们作为面试者的意义。
首先,它增强了我们的自信心。当我们了解围绕面试过程的广泛生态系统时,我们感到自己处于控制之中。当面试官谈论公司和其产品时,我们能够迅速理解他在说什么。但更重要的是,了解产品将使我们的回答指向正确的方向和上下文。在面试社交应用公司或构建 SDK 的广告公司时回答架构问题之间有很大的区别。在 iOS 开发世界中,这些都是两种不同的生物。我们将在第十二章中讨论这一点。
其次,向面试官提出有关公司的明智问题表明我们已经做了功课,并且知道我们在谈论什么。在大多数地方,这都很有价值。
获取公司详细信息
公司详细信息是我们公司简介中最易获取且最简短的章节。在面试前和面试过程中,我们应该问自己几个问题。这些问题可以让我们了解这家公司是否符合我们的需求。
我们应该提出的问题至关重要且相互关联:
-
公司规模是什么?
-
公司目前在其生命周期中的哪个阶段?
-
公司是上市公司还是私营公司?
你可能觉得这些问题没有必要,但实际上,它们对你的 iOS 开发者职业生涯甚至更广泛的影响是巨大的。
让我们详细讨论每一个问题,并从第一个问题 – 公司规模 – 开始。
考虑公司规模
有三种公司规模:小型、中型和大型。
在美国,一个小型科技公司被认为拥有少于 100 名员工,一个中型科技公司雇佣的员工人数少于 1,000 人,而拥有超过 1,000 名员工的公司被认为是大型公司。
尽管这些定义在不同的国家和行业中并不完全相同,但在我们的讨论中,这并不重要。重要的是这对我们作为员工意味着什么。
小型公司使我们能够对公司和其产品产生更大的影响。这通常意味着在小型团队中工作;有时团队只有一个人(!)。由于这些公司规模较小,它们也给我们带来了亲密感,这伴随着良好的工作与生活平衡。小型公司的另一个优点是节奏 – 它要快得多。这种快速节奏主要是因为小型团队的工作流程简化了。小型公司也有缺点。它们通常比大型公司更不稳定,并且成长空间更小。
大型公司则相反。尽管它们更加稳定,但员工对公司产品和其路径的影响要小得多。要在大型公司中获得更多影响力,我们需要在等级制度中上升,这不像小型公司那样困难。此外,由于团队和公司规定的更多依赖性,发展速度要慢得多。
在某种程度上,中等公司就像大公司和小型公司的混合体。它们仍然提供一种亲密的家庭环境,同时比小型公司更稳定。因此,对大多数技术工作者来说,中等公司是首选公司,这一点并不令人惊讶。稳定、温暖的环境和快速的发展步伐满足了行业大多数人的偏好。
但是,将公司规模视为唯一适合性的指标可能过于肤浅。正如本节前面所述,还有更多因素需要考虑,例如公司在其生命周期中的位置。
分析公司的生命周期
公司像人类和其他生物一样有一个生命周期。你现在可能会问自己,“生物?”
是的!就像人类一样,公司有年龄、DNA、创新、想法和许多其他特征。
以下图表显示了创业公司的生命周期:
图 1.1 – 创业公司的生命周期
公司诞生、成长、成熟,有时甚至衰落和消亡。就像人类一样,它们生命周期的每个阶段都有其独特的兴奋和风险。因此,在寻找公司时,需要关注其生命周期的阶段。
公司的阶段可能指向我们可能经历的工作氛围。它可以告诉我们机会,以及公司是否处于一切都在顺利进行的状态,或者我们需要从头开始建立工作流程。
另一个我们需要深入考虑的更深层次方面是移动团队的生命周期本身。公司可能已经成熟,但移动团队可能刚刚诞生。无论新团队可能拥有的不同机会如何,它们使用的技术也可能存在差异,这也是我们面试中的一个关键问题。
在许多情况下,面试官倾向于询问他们项目中使用或希望使用的科技问题。例如,如果我们谈论一个成熟的移动团队,其开发者可能使用UIKit,甚至可能使用Objective-C(但愿不是!)。在这种情况下,可以期待有关UIViews或Objective-C类别和块的问题。
此外,在成熟的团队(和成熟的产品)中,项目可能包含遗留代码,这可能会很痛苦,有时甚至需要重构。
当我们进行公司研究时,我们必须就公司的阶段、团队和项目提出正确的问题。我们将得到的答案可以给我们一个概述,了解我们可能在面试和工作场所中期待什么。
现在,请注意,问题“公司目前处于哪个阶段?”并不总是简单直接的问题,并不是所有面试官都能轻易回答。在提问时,你需要深入挖掘,并自己得出结论。在分析公司状态时,我们需要考虑的一个问题是它是否是上市公司或私人公司。
在公共和私人之间权衡
公公司和私人公司之间的区别相当简单:其所有权。私人公司由一个或少数个人拥有,而上市公司则由购买股票的股东拥有。
很难说哪种类型的公司“更好”工作,因为这将取决于个人的个人偏好和职业目标。一些开发者可能更喜欢大型上市公司提供的稳定性和增长潜力。相比之下,其他人可能更喜欢小型私人公司的灵活性和创业精神。
如果你在这两种论点“公共与私人”和“大与小”之间感到困惑,这并非巧合。上市公司通常更为突出。它们结构更完善,而私人公司则被认为是较小的。
但这不仅仅是规模问题。所有权差异对公司文化有影响。例如,在上市公司中,由于需要考虑股东的利益,决策过程更为复杂。而私人公司则拥有更敏捷和更快的流程,这种情况并不存在。
但我想澄清这一点:我们仍然可以在上市公司中找到“初创”氛围,在初创公司中找到企业氛围。因此,感知工作环境并考虑结果至关重要。
让我们看看我们需要关注哪些因素。
感知工作环境
信不信由你,编程在生活中并非一切(但我同意它很重要)!
产品可以非常出色,团队可以非常强大。但是,如果你想不仅开发 iOS 应用,还想发展你的职业生涯,你需要在一个能够实现这一点的支持性环境中工作。
我们可以尝试发现关于公司工作环境的几个问题:
-
公司的层级是什么?它是扁平的还是层级的?
-
研发部门(R&D)和移动团队是如何构建的?
-
平均工作日是什么样的?我们是否有日常或周会?
-
是否有代码审查会议?谁可以进行代码审查?
-
工作/生活平衡程序是如何的?
-
iOS 开发者是否有机会编写后端代码并成为全栈开发者?
-
iOS 团队和Android团队之间有共享知识吗?
-
iOS 团队与其他公司方面(如产品)的参与度如何?
这些问题的主要问题是很难从面试官那里得到可靠的答案。我并不是说他在撒谎!但记住,面试官的一个目标就是推销公司和 iOS 团队。然而,你的一个目标是要得到最真实的答案,这两个目标并不总是相匹配。
总是带着怀疑的态度去听面试官的话,自己去寻找真相。
那么,接下来,让我们谈谈获取一些有用信息,看看我们如何构建一个非官方档案。
建立非官方公司档案
我们在“学习公司特征”部分学习了如何使用基本问题进行基本公司档案的编制。
现在我们已经确保公司的“干燥细节”符合我们的需求和要求,是时候深入挖掘并收集一些非官方信息了。有三种方法可以做到这一点:研究、研究,还有…研究!
分析职位发布
通过分析职位描述,我们可以学到很多东西。此外,这不必是 iOS 开发者的职位描述,可以是公司中的任何其他角色。通过阅读职位描述,我们可以揭示公司如何优先考虑其价值观,尽管公司可能并没有打算与我们分享这种优先级。
例如,如果一方面工作描述强调按时完成任务的重要性,另一方面又举办欢乐时光活动和谈论忠诚度。然而,另一方面,他们没有提及灵活性,这可能是一个很好的迹象,表明公司对工作与生活平衡的看法。如果工作描述将氛围描述为动态和年轻,但没有提到专业和强大,这可能是一个很好的迹象,表明代码审查将是什么样子。
记住,没有什么是固定不变的,但收集拼图碎片对于看清整体画面至关重要。
查看社交媒体档案
就像职位发布一样,查看公司的社交媒体,如领英、Facebook、Twitter 和 Instagram,可以让我们对公司氛围有一个感觉。例如,我们可以看到公司举办海滩日和职业活动(如聚会和讲座)的数量。
我们可以在领英上打开公司页面,看看一些有趣的内容,比如多样性、领导力等等。
社交网络可以为我们提供我们在面试或公司网站上得不到的关于公司的有趣内部信息。
另一件有趣的事情是员工、客户或合作伙伴分享的内容。你是否觉得他们感到满意或不满意?人们在这个公司待的时间长吗,还是看起来像是一个旋转门?做一些间谍工作!这是完全合法且有帮助的。
浏览公司网站
公司网站是另一个寻找关于你梦想中的工作场所的额外细节的绝佳地方。除了产品外,你还可以发现有关公司价值观、领导力等方面的有趣信息。
但你可以找到更多令人着迷的信息,例如公司的博客和活动。许多公司都设有专业博客,员工可以在其中发布与工作相关的文章。
由员工维护的专业博客可以表明公司非常重视开发者品牌。如果你认为这很重要,博客可以为这个方程式增加一些分数。
与员工交谈
这可能是了解你想要申请的公司工作感觉的最佳方式。
今天,世界比以往任何时候都更加紧密地连接在一起,接近你想要接近的几乎任何人都是轻而易举的。
LinkedIn的公司页面可能是连接目前在该公司工作或曾工作过的 iOS(甚至 Android)开发者最简单的方式。告诉他们你正在考虑申请,并希望咨询他们。
在谈话之前,写下关于在其他渠道中难以获得的信息的问题。这种内部信息非常宝贵,因此要为这次谈话做好充分的准备。
这里有一些关于问题的示例:
-
你的工作时间是什么?你也从家工作吗?
-
自从你开始在那里工作以来,项目中添加了哪些技术或设计模式?
-
你在为功能做技术设计吗?如果是,谁在编写它们?
我们应该总是对人们说的话持一些怀疑态度,因此直接提问可能会很棘手。我们每个人交谈的人都是不同的,每个人都有自己与工作场所的不同历史和经验。我们需要做的就是倾听那些没有说出的内容,并试图注意对话中的任何隐含意义。
例如,我们可以问他们,“你最喜欢公司的什么?”显然,他们会列出一些事情,比如公司活动以及他们一起工作的同事。如果他们没有提到像“高标准”这样的内容,并不意味着它们不存在,但如果几个员工都没有提到,这可能是一个很好的迹象,表明这不是公司关注的重点。
有时候,成为一名优秀的侦探或调查员可能会得到回报!
Glassdoor
Glassdoor 是一个允许员工匿名评论其工作场所的网站。这不就是我们刚才试图实现的目标吗?太好了!
此外,Glassdoor 还提供了额外的福利,例如能够看到不仅员工的评论,还能在描述面试、办公室甚至面试问题和编码任务时看到候选人的个人经历。像 Glassdoor 这样的网站是想要进行高级公司研究的候选人的宝藏,并且可以帮助我们为面试日做好准备。
早点到办公室
这是在公司研究中的一个小众技巧,但仍然非常有效。当你被邀请参加第一次现场面试时,尽早总是好的。这表明我们认真且成熟,而且不用担心这会带来负面影响。
但提前到达还有一个额外的好处。这是体验办公室一天看起来如何的绝佳方法。我们可以坐在办公室里,等待时泡一杯咖啡,并听听员工们说什么。
他们是否谈论工作?具体的问题?他们是否在不同的团队工作(这样我们可以了解协作水平)?
如果是早上,办公室里有多少工人?这可能表明工作时间或工作与生活的平衡。
记住,在面试过程中,我们不一定总是能听到关于工作场所的不同信息的完整真相,所以用你自己的眼睛看事情是无价的。
总结来说,知道我们去哪里可以使我们为面试过程做好准备,并帮助我们了解我们面试的公司是否符合我们的要求。
正如我之前所说,面试是双向的。我们检查工作场所的程度与它检查我们的程度一样。而且既然它将成为我们的下一个“家”,我们应该尽可能多地投入精力。
现在我们已经完成了公司研究,我们需要继续前进并申请那些公司。最流行的方式是通过建立我们的简历。
建立我们的简历
我们现在看到,进行公司研究根本不是一项困难的任务。但方程式的另一面是我们自己:我们如何获得我们渴望的公司面试?
在成百上千的其他候选人中脱颖而出并不容易。当有那么多优秀的候选人时,让我们的简历熠熠生辉听起来像是一项不可能的任务。一些招聘人员声称写一份好的简历不是科学而是艺术,这并非巧合。当然,有关于如何做到这一点的指南,但为了脱颖而出,我们需要一点艺术感。
说到艺术,让我们看看如何将我们的个人资料看作是一部电影或一本书,这对我们有什么好处。
简历就像一本书或一部电影
好吧...什么?
想象你走进一家书店。你想要挑选一到两本书,但书店里有成千上万本书。你怎么办?你选择一个有趣标题的书,翻过来,看看背面文字。几秒钟后,你已经在没有打开它或读完背面文字的情况下决定了这本书。原因很明显。你没有时间去审查这么多书,你决定把时间投资在那些标题吸引人且背面文字有趣的书籍上。
我刚才描述的经历正是那些接收大量求职申请的工作场所发生的事情。在大公司,招聘人员平均扫描简历的时间在六到十秒之间。这是一个很短的时间来给人留下深刻印象,不是吗?好吧,这就是为什么我说这更像是一门艺术而不是科学。
现在,如果我们通过了那个阶段,招聘人员决定阅读简历的其余部分。假设我们的资料符合公司要求,我们需要给招聘经理留下深刻印象,这绝对不是一件容易的事情。
哦,我没有告诉你吗?这就是与书籍的不同之处。我们需要有几个人阅读我们的简历并喜欢它!
让我们看看我们如何一起应对这个挑战。
结构化简历大纲
基本的简历大纲基于内容的重要性。我们以我们的名字作为标题,联系方式,自我总结,专业知识,然后是技能、教育以及我们的项目和成就。
确实存在不同的简历大纲变体。例如,有些人说添加个人见解,如爱好或志愿服务活动也是一个好主意。有些人甚至添加了照片肖像。
但在我看来,关于简历,少即是多。我们应该保持简单、清晰,直截了当。
专注于设计和布局
那么,关于设计和布局呢?
我们可以从网络上轻松下载大量的简历设计模板。尽管其中一些看起来很漂亮,但我们应该记住,文档的目的是发送一个清晰的信息,关于我们是谁以及为什么我们是最佳面试人选。
由于我们只有六到十秒的时间来吸引招聘人员的注意,我们应该根据这个假设来规划我们的简历(Curriculum Vitae -> “course of life*”)。其中最著名的方法之一是使用所谓的F 型模式。
什么是 F 型模式?
快速扫描文档听起来一开始很简单。我们可能想象招聘人员开始阅读你的文档,然后过一会儿就停下来。基本假设,即招聘人员是阅读我们的文档而不是扫描它,这是错误的。
Nielsen Norman Group(NNGroup)在 2006 年进行了一些激动人心的研究。他们的研究人员向 2000 多名学生提供了数千份文件,并要求他们在快速分析眼球追踪运动的同时审阅这些文件。
研究结果表明,用户在“扫描”文档时有一个恒定的模式——他们的眼睛移动的轨迹看起来像字母 F:
-
首先,他们从左到右阅读前两行。
-
然后,他们会稍微向下移动一点,跳过几行,也许,然后阅读另一行,但这次他们不会一直读到结尾。
-
最后,他们会从顶部到底部,从左侧扫描文档。
从右到左的语言
对于从右到左的语言,如阿拉伯语或希伯来语,F 型模式是颠倒的。
看看以下来自 NNGroup 网站的照片(www.nngroup.com/articles/f-shaped-pattern-reading-web-content-discovered/
):
图 1.2 – NNGroup 文档扫描实验结果
那么,为什么用户会这样做?
好吧,读者有一个目标。他们希望他们的扫描尽可能有效。他们旨在以最少的努力和时间从文档中获得尽可能多的信息。
我们现在明白招聘人员在扫描你的简历时眼睛会看哪里,但我们如何确保读者不会错过我们的主要目标?
好的,现在我们正在争夺用户的注意力,但这并不意味着我们在失去。以下是一些处理这种情况的技巧:
-
由于 F-Pattern 研究,我们知道用户在我们的文档顶部投入了大量的精力。我们应该把对我们用户最有意义的信息放在这个地方。换句话说,这就是我们的“金钱应该花的地方”。
-
这同样适用于我们文档的每个部分。我们始终需要从信息开始。当我们描述我们参与的项目时,我们应该从我们对项目的贡献开始,而不是描述项目的历史。
-
我们需要通过使用排版来让读者更容易理解什么重要,什么不重要。例如,用粗体标记主要单词可以帮助我们强调文档的要点(就像我在这里所做的那样)。
-
在章节之间添加空格,并使用大而清晰的标题。这有助于读者理解文档结构,避免在扫描任务中迷失方向。
-
使用简短的列表项而不是冗长乏味的段落。简短的列表项将大量信息以结构化的列表形式展示,帮助用户快速扫描和理解我们想要表达的内容。
使用排版是帮助用户理解如何阅读我们的简历,同时利用 F-Pattern 并克服其缺陷的绝佳方式。
让我们把我们的简历分成不同的部分。我们将从你的个人信息开始。
个人信息部分是什么?
记住,我们之前已经说过我们的简历内容应该按优先级排序。那么,还有什么比我们更重要?
回到书店的类比,读者首先扫描的是书名。在简历的世界里,我们的名字是书名,而职业是副标题。我们还可以添加证书和奖项,使副标题更加吸引人。
在我们个人信息部分最重要的东西是确保所有提供的信息都是准确的。我们不希望因为我们没有足够注意而处于让审阅者难以联系我们的境地。
一个棘手的信息点是我们的地址。
个人地址
关于个人地址的几句话,因为它是一个有点敏感的话题。
传统上,简历文档通常包含个人地址。个人地址是联系候选人的主要部分,也是公司考虑其位置是否合适的一个重大因素。尽管事情已经改变,我们生活在不同的时代,但许多招聘人员仍然期望简历中包含地址。
然而,有几个原因我们不希望在简历文档中提供完整的地址:
-
这是敏感信息。出于隐私和安全原因,完全理解为什么一些候选人更愿意不让他们详细的地址信息流传。
-
有些地方可能会把远程工作地点视为问题并拒绝我们。有些国家歧视位置是非法的,但我们仍然不想让自己陷入麻烦。
如果我们仍然决定在简历中提供地址,这里有一些提示:
-
我们不必提供完整的地址。一个城市或地区对于大多数审阅者来说就足够了。
-
如果我们知道我们的位置可能会成为许多公司的难题,我们就可以声明我们的搬迁意愿(当然,如果这是准确的话)。
-
在我们的简历中强调我们在远程工作(再次,要准确)的能力和经验。对于 iOS 开发者来说,远程工作在今天是完全合法的,尤其是自 COVID-19 以来。
现在,让我们继续到我们的主菜,并制定我们的个人总结部分。
制定个人总结部分
总结部分可能是我们简历中最重要的部分,并在审阅者决定是否继续我们的申请中扮演着重要的角色。
现在,尽管我们称之为“总结”,但它并没有总结我们的简历,我们很快就会明白原因。
当我们开始撰写总结部分时,我们需要做的第一件事是换位思考,像雇主一样思考。我们在寻找什么样的 iOS 开发者?什么可以帮助我们的团队或公司?什么样的人能融入我们的团队?
既然我们已经改变了我们的观点,我们有一个良好的起点。现在,让我们卷起袖子,根据以下提示构建我们的总结。
按结构工作
让我先提醒你,审阅者扫描我们的简历并做出决定需要 6 到 10 秒。
6 到 10 秒。这就是我们所有的时间。
因此,我们需要根据这个假设来撰写我们的总结部分。简短、简单、直接,同时保持 3 到 5 句话,不超过 50 个字。
但我们到底需要写什么呢?
让我们从它是如何构建的来开始。基本结构建立在三个东西上:我们是谁,我们提供什么,以及我们的目标。
由于我们都是 iOS 开发者,让我尝试用格式化字符串来描述:
let adjective: String = "Self motivated…"let role: String = " iOS Developer"
let experience: String = " with 3+ years of experience with
iOS project.."
let goals: String = "eager to learn and develop myself"
let summary = adjective + role + experience + goals
也许我们还没有在简历制作上变得专业,但 Swift 我们知道,不是吗?
找到我们的软技能和硬技能
如果你第一次听到这些术语,解释它们是什么以及为什么它们是必要的是个好主意。
软技能与特定角色或行业无关。软技能是非技术性的,可以帮助员工完成各种任务。一些例子包括时间管理、沟通和问题解决。
相反,硬技能是技术性和角色/职位特定的,例如使用 Swift、Combine、测试等。有些情况下,硬技能伴随着证书和学位。
我们应该在总结中结合这两种技能类型,并尝试强调我们擅长的技能。
例如,如果你缺乏开发经验,尝试强调你的硬技能。如果你是一位经验丰富的 iOS 开发者,那么你可能在过去的几年里获得了一些了不起的软技能,你可以突出它们。
使用术语适应你的工作场所
现在,这是一个实用的技巧:使用精确的关键词将个人总结调整到职位发布描述中,并针对工作场所的需求和期望。
是的!这意味着我们可能需要为不同的职位发布创建几个总结版本。那么为什么这很重要呢?
首先,我们希望用我们的审稿人的语言来表达。如果他们在寻找“有动力的 iOS 开发者”,那么我们的审稿人将会兴奋地找到他们所寻找的。
但这还没有结束。在接下来的章节中,我将告诉你大多数候选人不知道的一个黑暗秘密:申请跟踪系统(ATS)。
ATS 是什么?
(再次!)回到 6 到 10 秒的扫描过程。当工作场所每天收到数十份简历时,招聘人员扫描简历是高效的。但是,当工作场所每天收到数百甚至数千份简历时,我们该怎么办呢?
哦,我知道!他们会雇佣更多的审稿人,对吧?
不。
他们使用一种叫做 ATS 的系统,这个系统可以做很多事情。它还可以解析简历并提取标题、联系信息和关键词。
实际上,在大公司中,大多数简历甚至都没有被人类阅读!ATS 在这些地方充当守门人,过滤掉不相关的候选人。想要打败 ATS?根据职位发布包含关键词,并使用清晰的格式。
与雇主的需求保持一致
每家公司对我们都有不同的需求。一家公司需要我们交付一个极其健壮的 SDK,而另一家公司则需要我们构建一个光滑的用户界面。SDK 和光滑的用户界面是两回事。我们无法在 50 个字内表达我们在两个领域都做得有多好!
我们需要选择,因此进行研究,并理解公司的需求。在此基础上,我们可以调整我们的总结以回答公司的要求。
适应后 COVID 时代——保持灵活
如果 COVID-19 教会了我们什么,那就是没有什么是一成不变的,我们需要尽可能灵活。灵活性可以通过搬迁、角色变化或技术来体现。
如果其中一些在你的情况下是合法的,尝试在你的总结中包含那一面,并展示你如何随着岁月的推移发展和调整自己。
灵活性和愿意改变可以给雇主信心,在某些公司,强调这一方面可能是有用的。
专注于专长
完美总结的最后一条建议是,我们每个人都有一个专长——我们在成为强大的 iOS 开发者方面擅长的事情。
如果我们想在竞争我们角色的其他开发者中脱颖而出,我们需要强调我们具有卓越能力的领域。
现在,最大的问题是,如果这种能力与我们的理想工作场所不相关怎么办?答案是它没关系。经理就像忍者,是超级开发者。如果我们在一个领域表现良好,这可能意味着我们可以在另一个领域做得很好。
列出我们的专业知识
处理专业知识意味着招聘人员或招聘经理阅读我们的总结,并决定它足够有趣,值得花更多的时间和精力去阅读简历的其余部分。为我们鼓掌!
“专业知识”这一节包含了我们参与的工作场所和开源项目的列表。通常,列表是按时间顺序排列的,这是完全可以理解的。我们最后的工作场所是最重要的,因此它应该放在最上面。但在编写这个列表时,我们需要确保三件事:
-
专注于我们对公司和团队的贡献,而不是我们的最后或当前头衔。“我从零开始创建了一个应用程序”和“我领导了公司主要应用程序的 iOS 开发”是贡献的例子。
-
用项目名称、数字和故事来涵盖我们的贡献。一些细节使我们的贡献描述更加可靠和精确。
-
说到可靠性,不要错过任何东西!永远不要隐藏一个工作场所,即使你在那里只工作了几个月并被解雇。在这个时候,招聘人员可能会试图挖掘一些关于我们申请的细节,隐藏这样一个重要的细节可能会让我们失去工作和我们自己的声誉。
专业知识只是我们想要展示的画面的一部分。另一个主要因素是我们的技能。
技能
本节旨在展示我们的能力,并向审阅者展示我们的知识。
首先,让我们提醒自己之前讨论过的关于软技能和硬技能的内容。硬技能是技术技能,如 Swift、Combine 和 SwiftUI。软技能与 iOS 开发没有特别关系,涉及我们作为员工的一般能力,如沟通和解决问题的能力。
我们现在需要做的是巧妙而有效地列出我们的技能(包括硬技能和软技能)。让我们看看如何。
调整我们的技能以符合要求
就像个人总结部分一样,技能部分需要调整到你所寻找的和职位发布的要求。
例如,如果我们正在寻找远程工作,我们需要强调有助于我们远程工作的软技能。沟通、时间管理和独立性都是雇主在远程角色中寻找的软技能的例子。
混合我们的技能列表
记住,即使我们在初次筛选中成功通过,在审查我们的申请时仍然存在注意力限制。我们不能列出无穷无尽的技能,将列表限制在 10 到 15 项技能是最好的。
别担心,限制有其显著的优势。它推动我们集中精力,并迫使我们优先考虑我们的清单。
在 10 到 15 项技能列表中,我们必须拥有这里列出的不同类型的技能,并创建一种有趣的技能混合包:
-
与工作要求相关的硬技能很重要。社交应用、支付、游戏和 SDK 都是相关硬技能的例子。
-
与工作环境、角色和公司规模相关的软技能也有帮助。
-
添加一项特殊技能,使我们在与其他候选人相比时脱颖而出,并展示我们在特定领域的发光能力。
一些技能需要放在个人总结部分,我们必须确保我们的总结与技能列表同步。
混合技能列表可以确保我们没有盲点,并安全地覆盖招聘经理的要求。
杂项
在我们展示我们的技能和个性之后,我们进入需要展示一些证据的部分。
在这部分,我们可以展示我们的教育背景、证书、培训、博客、出版物、GitHub 仓库等等。
我刚才写的一部分是我们所说的开发者品牌建设,我们将在第三章中讨论它。
避免红旗
我们的专长可能非常出色,我们的总结也可能写得很好,但简历审阅者正在寻找我们可能不喜欢的一些额外信息,这些是红旗。
关于我们简历中的红旗,我们首先必须理解的是没有简历是完美的。我们中的大多数人都曾有过问题。我们为了整理生活而度过了一段非常长的假期,被解雇,或者事情就是无法顺利进行。
因此,我们需要确保我们的简历是可靠的。另一方面,我们有时需要强调我们的积极一面。
我们需要解决的第一面红旗是跳槽。在一个地方待不到一年可能会在审查我们简历的工作场所引起一些关注。这给人一种我们不稳定、成熟,以及我们在工作场所中存在适应问题的感觉。在跳槽的情况下,许多候选人会从他们的简历中删除那份工作,借口是,“它无论如何都没有意义。”
现在,隐藏我们工作专长中的细节不是一个好主意。简历中的漏洞在那里是为了被发现,最终,这类事情总是漂浮着,可能造成的损害比我们想要避免的要多得多。
在这种情况下,最好的做法是突出我们在每个角色中的成就,而不是我们的工作时长。当我们强调在每个工作场所的经验时,我们展示了在不同团队和公司工作如何增加了我们的知识广度和能力。
我们应该考虑将不利因素(跳槽)转化为优势(工作多样性和广泛经验)。
当然,这个教训对我们简历的其他部分都适用,但当我们有一个内置的警告警报时,这一点尤其正确。
邀请另一双眼睛
一旦我们完成了简历文档的撰写,强烈建议将其交给其他人阅读并给出他们的反馈。
但——不要随便选择任何人来做那份工作。你的祖母可能很聪明、诚实,但她可能不是我们正在寻找的档案。
我们必须仔细挑选我们的“第二双眼睛”,并确保我们的反馈尽可能无感情且专业。尽量选择招聘人员或人才获取人员来审查你的简历结构,以及招聘经理来审查你的总结和专长。即使是一两个评论也能产生重大差异。
准备面试
因此,我们接到一个招聘人员的电话,说他们已经审查了我们的简历,并有兴趣继续与我们合作!这个电话意味着我们做得很好,成功地以我们想要的方式展示了我们的经验和能力。
但——艰苦的工作只在我们面前。
在第二章中,我们将讨论面试过程、不同阶段及其主要目标,但现在,我想讨论面试前的这段时间。
花时间
当招聘人员想要安排你的第一次面试时,许多候选人首先做的事情就是尽快安排,因为得到面试的机会令人兴奋。嗯,那是一个大胆的错误。
我们应该花时间,确保我们以 100%的准备开始这段旅程。一至两周的时间框架应该足够开始第一阶段。
技术准备、个人准备和后勤准备
准备面试并不意味着只是复习 Swift 问题。我们需要确保三个层面的准备:技术、个人和后勤。
技术
大多数关于 iOS 面试的书籍都涉及技术准备。在这个阶段,我们需要确保的首要任务是 iOS 开发的基础牢固且扎实。在基础问题和任务上出错将对我们造成巨大打击,并比任何其他因素都更有可能危及我们的求职申请。
我们需要做的第二件事是获取一个白板来练习设计和架构问题。在设计问题中,我们需要习惯在白板上绘制 UML 或系统图。尽管在白板上绘图听起来很简单,但它需要练习和经验。
除了绘图任务本身(这对许多候选人来说并不容易)之外,我们还需要知道如何展示一个系统,决定我们在图表中认为什么是模块,并口头解释它。我们将在本书的第四部分:设计和架构中关注这一点,但我们需要在我们的准备计划中为此留出时间。
第三件事是建立我们的日常例行程序。无论我们是否有日常工作还是失业,如果没有日常练习程序,我们将很难前进并填补所有知识差距。我们是夜猫子还是早起的人?了解自己可以帮助我们在日程中预留一个专门的练习时段。
个人
我们的个性是这个过程的重要方面之一。作为面试准备的一部分,我们必须构建我们的开发者故事。我们的故事是什么?我们是如何进入开发世界的,尤其是 iOS 开发?为什么我们决定在过去的公司工作?在大公司或小公司工作是什么样的体验?为什么我们现在在寻找新的工作场所?
这些问题的答案有助于面试官构建我们的开发者档案,因此我们绝对不能忘记在我们的准备计划中这一点。
物流
这部分很简单,但我们不能因此掉以轻心。我们首先需要打印我们的简历。总是带着它们是好事。带着简历表明我们是认真的,没有什么要隐藏的。它还通过关注我们开始了面试,这正是我们想要的。
第二件事是计划我们的到达旅程。提前到达永远不会损害我们继续这个过程的机会,但迟到肯定会的。绝对不要迟到面试。
第三件事可能听起来很奇怪,但比你想象的要重要得多:确保你闻起来很好。现在,我提到气味,因为许多人(在我们的情况下是面试官)倾向于将气味与他们交谈的人联系起来。现在,为了清楚起见,气味和人的联系不是面试官的错;这只是我们大脑的结构。记忆、情感和我们的嗅觉之间有直接的联系。在面试现场时,在包里放点止汗剂或香水可能是个好主意。
总结
在本章中,我们学习了公司档案是什么以及如何构建它,如何描述我们的需求,以及如何与合适的公司匹配。我们还学习了如何编写简历以便快速招聘人员和深入阅读。
我们已经讨论了我们拥有的不同技能,并试图找到解决我们专业知识中可能存在的差距和问题的解决方案。
最后,我们讨论了用全力准备我们的第一次面试。
公司研究是面试准备的重要部分。它增加了我们选择对我们合适的地方的机会,并帮助我们通过申请流程并获得第一次面试。
但第一场面试以及之后到底会发生什么?这正是我们将在下一章中讨论的内容。
第二章:通过面试流程
许多候选人(甚至雇主)将 iOS 开发总结为主要是“Swift”。但将 iOS 开发者角色仅限于编程语言是非常简单化的看法。iOS 开发者(实际上,任何开发者)都有一系列的能力,即使我们并不这样认为。我们必须在 Swift 之外,还要在计算机科学方面展示知识。一些 iOS 开发的基本知识也是必不可少的,例如 UIKit 或 Foundation。
成为 iOS 开发者不仅仅是编码技能。高级 iOS 开发者必须设计一个健壮的应用程序,具有清晰的架构,编写单元测试,管理 CI/CD 流程,部署 beta/alpha 版本,管理证书和配置文件。
那么关于一些软技能呢?沟通和时间管理是当今动态市场中的基本要素。
从我们刚才讨论的线索中可以看出,成为一名 iOS 开发者意味着一系列的能力,远远超出了 Swift 本身。招聘流程的目标是涵盖我们所有的能力,从硬技能到软技能。
在本章中,我们将了解标准 iOS 开发者招聘流程的不同阶段。到本章结束时,我们将处于一个位置,这个过程对我们来说就像我们真的在那里一样熟悉!
为了这个目的,我们将涵盖以下内容:
-
理解招聘流程
-
准备筛选面试
-
关于 iOS 技术面试的所有内容
-
通过编码面试
-
通过架构面试
正如我说的,让我们从理解招聘流程的工作方式开始。
理解招聘流程
招聘流程的一个目标是在尽可能多的范围内获得候选人的技能的完整画面。
软技能和硬技能
如果你忘记了软技能和硬技能是什么,硬技能是特定于角色的,例如 iOS 编码、GitHub 知识等。软技能是与许多工作相关的技能。沟通和领导力是软技能的例子。请参阅第一章以获取更多信息。
想象我们的候选人画面就像一个巨大的拼图。
当我们开始招聘流程时,每个阶段都会拼凑出拼图的一部分,这个过程帮助我们的招聘经理在我们提供之前看到我们的技能集。
每个阶段都会检查我们作为 iOS 候选人的不同部分,就像一场淘汰赛,决定新揭示的画面部分是否足够好,可以进入下一阶段。
学习招聘漏斗
我提到淘汰赛只是为了吸引你的注意。现实是招聘流程看起来更像是一个漏斗。漏斗的每个阶段都会深入下去,揭示更多高级技能。
这里有一个这样的漏斗的例子:
图 2.1 – 标准漏斗
它被称为“漏斗”,因为它在我们向下移动时变窄,并且更多的候选人退出。
但我们面试的具体工作场所的漏斗是什么样的呢?
区分不同公司
我们在图 2.1中看到的漏斗只是一个例子。每个公司的招聘流程都不同,这个领域没有固定的规则。
这种差异有几个原因:
-
不同角色需要不同的阶段和不同的主题来检查。不同的角色不仅仅是开发者和设计师之间的区别,还包括开发者和团队领导之间的区别。
-
每家公司都有自己的看法,认为这样的漏斗应该是什么样子。有些公司侧重于个性,而有些公司侧重于编码。每家公司都有自己的 DNA(记得在第一章中,我们将公司比作人类),因此寻找特定类型的候选人。这种候选人类型可以从招聘过程中快速了解。看看以下两个例子(图 2.2):
图 2.2 – 不同角色的不同漏斗
我们可以看到角色A非常注重编码技能,而角色B则更多地关注设计和架构。这可能代表不同的角色(开发者和技术领导),但也可能显示公司或团队如何看待开发者职位。在小公司或团队中,开发者更为核心,责任更大。在这种情况下,角色B似乎更适合初创公司而不是企业。
最后可能影响漏斗的因素是产品。面向用户的产品可能需要高 UI 技能。将屏幕设计和 UIKit/SwiftUI 作为招聘流程的一部分听起来是个不错的想法。
因此,这里还有一个(免费)小贴士:我们在第一章中看到,我们只需通过查看职位发布,就能了解很多关于团队和角色的信息。现在,我们可以看到招聘流程也能让我们了解很多关于工作和工作场所的期望。
现在,让我们从一次简短但至关重要的面试开始我们的漏斗之旅:筛选面试。
准备筛选面试
在大多数情况下,招聘流程的第一步不会是深入和高级的技术面试。
我们成功通过了简历筛选步骤并获得了面试机会,但在这个阶段,我们和其他许多幸运的候选人一样。然而,由于候选人众多,工作场所必须在进入下一阶段之前尽可能以最小的努力进行筛选。这就是筛选面试的全部内容。
将筛选面试想象成招聘漏斗中一个超大的、粗略的过滤器,它需要在短时间内处理大量候选人。这也意味着我们在回答几个重要问题的同时,大约有 30 分钟的时间给面试官留下深刻印象,以便继续漏斗的下一阶段。
筛选面试包括什么?
大多数筛选面试都围绕三个主题展开——公司、候选人的背景和软/硬技能。
让我们谈谈公司
通常,面试官会以关于公司和产品的电梯演讲开始面试。我想澄清一下什么是电梯演讲,因为我们很快就会需要它。
让我们想象一下,我们和另一个人一起乘坐电梯。我们想要向那个人展示一个想法,甚至是我们自己,但我们有大约 30 秒的时间到达电梯的目的地。这个简短的演讲被称为电梯演讲。
在筛选面试中,有两个电梯演讲。第一个是面试官谈论公司和产品。第二个是我们的,我们很快就会讨论这一点。
面试官的电梯演讲可能持续超过 30 秒。我们需要做的是仔细聆听公司和产品的描述。这里有几个原因:
-
记得我们在第一章中提到要确保工作场所适合我们吗?现在是我们验证我们的假设的时候了。在第一章中,我们投入了宝贵的时间进行研究。现在是我们找出真相的时候了。这是否是我们想要工作的公司?开发团队是否真的达到了我们预期的规模?我们是否听到了令我们烦恼的事情?我们应该利用这个机会。
-
“这会在考试中吗?”嗯,有点。在筛选面试结束时,面试官会问我们是否有问题,并期望我们提出一些问题。我们将讨论这一点,但我们需要确保我们理解面试官的演讲,这样我们才能提出相关的问题。问一些在演讲中已经回答过的问题不是一个好主意。
-
面试官的演讲可以帮助我们调整我们的回答以符合工作场所的需求。我们需要利用我们刚刚获得的额外和宝贵的信息,并利用我们作为 iOS 开发者的提供。
但面试官并不是这个对话中唯一说话的人,也不是唯一有电梯演讲的人——我们也是面试的一部分!
构建我们的电梯演讲
在某个时候,面试官会说,“请告诉我关于你的情况。”那将是我们展示电梯演讲的时刻。
他们说我们每个人都需要有点销售员的特质,这是真的。尽管我们的专业是 iOS 开发,但我们有显著的优势,我们需要销售一些东西——作为 iOS 开发者的我们的技能和专业知识。
因此,提前规划我们的电梯演讲并为此时刻做好充分准备是极其重要的。建议写下我们的演讲稿,大声朗读,对着镜子练习,甚至可以向亲密的朋友朗读。面试官需要记住我们。这就是它为什么如此重要的原因!
我们希望面试官记住关于我们的哪些信息?
那么,我们如何构建我们的推销?首先,我们需要记住,没有必要展开我们的整个简历。面试官可能已经阅读过了。即使他们没有,用 40-50 秒列出我们的专业知识也是无效的。
我们需要解释为什么我们适合这份工作,有两种简单的推销公式:现在、过去和未来,以及过去、现在和未来:
-
在“现在、过去和未来”中,我们首先解释我们的当前角色,然后转向我们的专业知识,然后讨论我们认为我们为什么有资格做这份工作。
-
在“过去、现在和未来”中,我们首先从我们的专业知识开始,然后转向我们的当前角色,最后解释为什么我们认为我们有资格做这份工作。
这两种公式都很棒——两者都内置了智能逻辑,并且都能完成工作。但我们可以更好地调整我们的推销。
如果我们的当前角色与职位发布有关,那么以我们的当前角色开始我们的推销可能是个好主意。然而,如果我们的过往专业知识与工作要求更为相关,我们应该从我们的专业知识开始,然后才转向我们现在正在做的事情。
现在我们有了公式,我们需要记住,我们有很短的时间来展示我们的个人简介。正确的方法是将我们的专业知识和当前角色提炼成成就和影响力。我们必须推销自己,而不是让面试官充满枯燥的工作历史细节。
在“未来”部分,我们需要包括我们对招聘流程和求职的期望。将我们的抱负和工作场所要求混合在一起会导致“未来”部分内容显著增加。
既然我们已经有了公式,让我们现在尝试用点击诱饵来增加一些趣味性。
包含点击诱饵
我们都可能熟悉“点击诱饵”这个术语。如果你不知道它的意思,点击诱饵是为了吸引听众或读者消费更多内容而设计的文本或图片。它在推文、帖子文章中是一种常见的做法。
是的,我知道电梯推销不是推文或帖子,但人的大脑工作方式是一样的。点击诱饵/预告创造了一个“好奇心差距”并利用它。在我们的推销中,我们也需要这样做!
我们的目标是植入一条使我们独一无二的信息,这将驱使审阅者深入挖掘并倾听更多。这条信息也将帮助我们留下深刻印象。
这里有一些预告的例子:
-
“我经常在会议上发表演讲”
-
“我在应用商店有一个拥有许多活跃用户的项目”
-
“我是两个大型开源项目的参与者”
当然,不言而喻的是,你植入的预告必须准确可靠。当你的审阅者开始深入挖掘时,扭曲真相看起来并不好。
在你的推销中不要包含的内容
关于在推销中不要包含的内容的答案可能因国家和文化而异。但我想最好的做法是不包含个人信仰、宗教以及任何与成为一名优秀的 iOS 开发者无关的内容。这甚至可能伤害我们继续推进流程的机会。
说我们以前工作场所的坏话也不是一个好主意。最好是说尽管我们过去可能有过问题和冲突,但我们仍然感激在那里工作的时光。
记住,在这些情况下我们永远不应该情绪化。在求职面试中,聪明是非常重要的。
现在让我们谈谈一些真正深思熟虑的问题,比如为什么我们想要换工作。
准备回答“你为什么想换工作?”的问题
如果我们的电梯演讲遵循我之前提到的公式之一,它也应该在“未来”部分涵盖这个问题。
然而,面试官仍然可以就我们改变当前工作场所的动机提问我们。可能是因为他们没有仔细听,或者因为我们只有 30-40 秒的时间,这根本不够。
有很多原因会让求职者想要换工作。以下是一些原因:
-
我不喜欢我的公司
-
我想要更高的薪水
-
我在寻找机会
-
我是因为个人原因
-
我想要灵活性
-
我被解雇了
-
我不喜欢我公司的文化
-
我没有得到及时的培训和开发
每一个原因都是完全合法的。但我们可以以不同的方式向面试官展示我们的理由。
让我们以“我想* 更高的薪水。”这个原因为例。
我们可以简单地回答以下内容:
我的当前薪水太低,我的工作场所不会给我 加薪。
嗯,这听起来不太好,不是吗?这不仅听起来很抱怨,还可能引起一些人的怀疑——也许这就是我们没有加薪的原因。
另一方面,我们可以传达不同的信息:
我喜欢接受挑战,得到奖励激励我更加努力工作。对我来说,经济支持是我 辛勤工作 的奖励。
将薪水和动力联系起来,而不是将薪水和自我联系起来,这会给“我想更高的薪水”这个声明增添深度。
当我们选择正确的原因时,我们应该考虑如何以积极的态度表达它,并将其与工作场所所需的价值联系起来。
准备技能测试
正如我说的,筛选面试就像一个巨大的粗筛子。除了专业知识和个人期望之外,面试官还可能检查我们的另一个标准:技能测试。
技能测试比高级流程阶段要窄得多。记住,面试官可能不是我们的招聘经理或 iOS 开发者。此外,我们只有有限的时间——毕竟,这只是筛选面试。
然而,技能测试是我们应该准备的重要部分,我们将从讨论我们的软技能开始。
发现软技能问题
软技能测试很棘手!没有人会告诉我们,“现在我们将检查你的软技能。”我们的软技能在整个面试过程中在各种地方被检验。我们的电梯演讲可能是展示我们软技能的地方之一,但不是唯一的地方。
如果我们面试迟到,这可能表明时间管理不善或个人成熟度问题,这是一个例子。沟通技巧可能在讨论作为团队一员或远程工作时出现。
我们应该把面试的每一个部分或每一个方面都看作是我们正在接受的一项软技能测试的一部分。
即使在回答硬技能问题时,软技能也存在于其中。还记得吗?让我们谈谈它们。
回答硬技能问题
与软技能不同,硬技能测试更加明确。并非每个筛选面试都包含硬技能,这取决于我们正在面试的工作场所。
硬技能测试可能包含关于 Swift 和 UIKit 的基本问题。它们的主要目的不是检查我们的 Swift 编程技能水平,而是确保我们达到继续过程的最低标准。
我们需要记住,在这个阶段,面试官可能甚至不是 iOS 开发者,他们可能有一系列的问题和答案列表。
一个典型的基本问题可能是:
什么是 let 和 var 的区别?
对于一个标准的 iOS 开发者来说,前面的问题可能看起来非常基础。然而,我们必须记住,面试官在这个阶段并不知道我们的实际经验和知识。iOS 开发者或招聘人员可以提出那个问题,因为像“我不记得”这样的回答是一个强烈的信号,表明候选人可能在 Swift 语言的知识上存在令人担忧的差距。
在筛选面试中,可能会有一系列的多选题,以便非开发者面试官可以提问和分析答案。
本书第二部分“Swift 语言和编码”讨论了 Swift 语言,仔细复习其章节将极大地为我们准备大多数筛选面试问题。
现在,让我们转到面试中另一个隐藏的软技能:提问的能力。
提问,“你有什么问题要问我吗?”
面试官并不是唯一提问的人。我们也会提问。
这不会是我们第一次在招聘过程中向面试官提问。实际上,向面试官提问是筛选面试中的另一项任务,也许在其他所有讨论中也是如此。
换句话说,对“你有什么问题要问我吗?”这个问题的回答总是,总是“是”。对这个问题的否定回答将大大降低我们在过程结束时获得工作机会的机会,我必须说——这是合理的。没有问题的候选人被认为对他正在面试的工作场所不感兴趣。
就像任何其他阶段一样,我们应该做好准备。在这种情况下——通过问题库。
创建问题库
当然,我们可以在面试前准备一个问题。但如果那个问题的答案已经在谈话中涵盖了怎么办?技巧是准备一个问题列表,这个列表不仅对筛选面试很有用,对其他面试也是如此。
每个不同的问题都展示了我们个性的另一面。听起来很奇怪,对吧?让我们看看一些例子以及它们是如何工作的:
-
你能告诉我更多关于这个角色的日常职责吗? 我们展示了我们满足雇主期望的愿望。
-
我如何在头三个月给你留下深刻印象? 这表明我们思考如何做出积极的贡献。
-
在这个角色/公司内部有培训和发展机会吗? 我们对职业认真负责,并致力于公司的未来。
-
你认为公司在未来五年将走向何方? 我们展示了对公司感兴趣和长期承诺的态度。
-
你能描述一下公司的企业文化吗? 我们展示了成熟和适应它的动力。
-
你能告诉我更多关于我将要加入的团队的信息吗? 我们展示了我们对团队合作和沟通的兴趣。
你想强调哪一方面?只需选择正确的问题——就像餐厅菜单!我们可以看到,即使是一个问题(而不是答案)也能向面试官发送信号,并利用我们的机会。
发送感谢邮件
这里有一个与面试、会议以及我们可能进行的任何有意义的沟通相关的优秀建议——在面试后立即发送感谢邮件,如果做得恰当,可以带来显著的优势。
现在,为什么还要发送邮件?面试官已经有了我们的电子邮件地址和其他沟通方式吗?我们不是在他的名单上吗?
因此,我们需要记住,面试官看待我们不仅仅作为 iOS 开发者,而是作为人类。发送感谢邮件表明我们是一个珍惜面试官时间并具有良好礼仪的人。
但感谢邮件还提供了两个额外的优势:能够添加我们在面试中未能展示的更多信息,以及附上我们希望他们看到的包含重要链接的签名。
记住,感谢邮件不仅仅是两个词,而是更多。但究竟要多多少?
学习如何撰写
感谢邮件应该简短且专注。写超过两段内容对面试官来说太多,毕竟我们是在谈论一个筛选面试。
邮件应该从对面试官时间的感谢开始,并重申对职位的兴趣。然后,我们需要通过突出我们的专业知识和技能来提醒面试官我们对该职位的资格。
现在,对于我们的签名,它应该包含链接到我们的 LinkedIn 个人资料和可能拥有的个人博客。
我们需要将感谢邮件视为一个个人印记,在完成筛选面试后应与你同行。只要它做得得体,它就会影响我们在流程中前进的机会。
接下来,让我们准备主菜:iOS 技术面试。
所有关于 iOS 技术面试的内容
我认为“技术面试”这个术语太宽泛了。它是否意味着用 Swift 进行编码?关于 UIKit 的问题?设计?
当我们收到技术面试的邀请时,这通常意味着我们被期望展示我们对 iOS 开发的总体知识。
不,这并不意味着不会有关于设计模式或计算机科学的问题,但我们应该带着我们对 iOS 相关问题的所有专业知识来。
换句话说,预期在这次面试中会遇到所有情况!
那次面试中发生了什么?
尽管 iOS 技术面试中可能发生任何事情,但我认为我们可以将其缩小到几种可能的表现方式。
注意,面试官会混合一些这些选项并将它们包括在内,因此我们应该为任何情况做好准备。
其中一个选项是面试官像在射击场一样向我们提问!
问题的范围
“问题范围”可能是最常见的面试类型,因为它是最简单的。这种类型对面试官和应聘者来说都很简单,因为没有什么比提出简短且答案明确的短问题更容易了。
我们应该将这种类型视为驾驶理论考试——问题众多,我们需要验证我们几乎知道所有答案。
但我们确实需要确保我们了解答案背后的内容,而不仅仅是记住它。因为这种测试非常通用,我们预计会回答我们不太了解且从未遇到过的问题。
这里有一些这类问题的例子:
-
“UIKit 视图控制器生命周期是什么?”
-
“GCD 和 NSOperationQueue 之间的区别是什么?”
-
“你能解释原子属性和非原子属性之间的区别吗?”
我们将从第四章开始讨论面试问题,这是为我们准备这一过程部分的另一个优秀资源。
完成的工作项目
当我们开始技术面试时,我们的专业知识是我们身份的一部分。我们知道什么,我们在短暂(或漫长)的工作历史中做了什么,这是我们工作申请的一部分。我们面试官的一个目标是要了解现实与我们的故事之间的差距。
展示我们完成的项目是面试官弥合我们故事和我们自己之间的差距的一种方式。但弥合这个差距不仅是面试的目标,也是我们的目标。我们希望他们看到我们真正的自己!
因此,带着我们过去完成的项目来参加这种面试是一个很好的主意。带着我们过去开发的应用程序充满的 iPhone 可能很重要。但更好的是,我们可以带着已经启动的 Xcode 项目的 MacBook 来。
这就引出了我的下一个观点。在面试前,我们需要回顾我们过去所做的一切,以便我们的解释流畅且令人信服。
在他们的开发过程中选择一个或两个我们参与过的特性,并在大声说话的同时解释其设计,这是一种出色的准备,将使我们处于面试的有利位置。
记住,我们不需要假装。我们是非常优秀的开发者,如果我们为我们所做的事情感到自豪,我们只需要展示出来。相信我,兴奋是会传染的。
回答 Swift 和算法问题
Swift 可能是我们作为 iOS 开发者拥有的主要工具。但当一个开发者说“Swift”时,许多人会想到比仅仅是一种语言更广泛的东西。
在这种情况下,Swift 远不止“可选解包”。数据结构、闭包和内存管理也是 Swift 的一部分。有时,Swift 只是构建更重大基础设施的基础:设计模式和算法。
在 iOS 技术面试中,问题可以从简单的解包到递归函数,再到委托与响应式编程的辩论。这个巨大的范围使得这种类型的面试成为招聘过程中最具挑战性的部分,从专业角度来看。
在这本书中,我们将涵盖与面试相关的 Swift 的许多方面,并解释为什么每个方面对我们日常工作和面试都是重要的。记住,Swift 和算法只是第一层楼。我们还有更多的楼层,比如 UIKit 或 SwiftUI。
解决关于 UIKit 和 SwiftUI 的基本问题
除了 Foundation,UIKit 和 SwiftUI 是唯一可能成为标准面试部分的框架。原因很明显——UI 几乎是任何我们将要工作的 iOS 项目的主要部分。
我并不是没有原因地提到“标准面试”和“几乎”。有些情况下,UI 可能不起主要作用,甚至不起任何作用。例如,一个我们完全没有 UI 功能的 SDK,或者我们虽然有 UI,但使用 SceneKit 或 Unity 而不是 UIKit 或 SwiftUI 来构建游戏。
但对于大多数应用来说,在较高层次上学习 UIKit 或 SwiftUI 是强制性的。许多 iOS 开发者今天的一个缺点是,他们非常熟悉一个框架(例如,SwiftUI),但没有其他框架(UIKit)的任何经验。
如果我们遇到的情况是这样的,我们需要在另一个框架上获得经验,并在面试中解释这个差距。即使是一点点知识也表明我们没有脱离现实,并且有强大的能力填补我们可能拥有的信息空白。
记住,许多应用都使用这两个框架,因此学习新框架的能力至关重要。
精通开发工具
如果框架是我们代码所骑的汽车,那么 Xcode 就是构建和修复这辆汽车的工具。那么,如果我们不精通我们的工具,我们怎么能成为优秀的 iOS 工程师呢?
精通 Xcode 有助于 iOS 开发者调试、更高效地编写代码、配置环境和项目、分析应用,并在工作中更快地推进。
我必须承认,询问关于 Xcode 的问题并不像我所提到的其他部分那样常见。也许 Xcode 并不被视为核心主题或必要的内容。关于 Xcode 问题的特点是,尽管它们很罕见,但在这些方面失败在面试官眼中被视为一个红旗。我们绝对不能在可能破坏我们进步的小问题上失败。
Xcode 不仅仅是 IDE 本身,还包括所有其他工具,如 Instruments 应用程序,这是每个 iOS 开发者都必须使用的,UI 调试等。
技术面试测试 iOS 开发的背后理论。现在让我们转到实际部分:编码面试。
破解编程面试
我们知道世界是如何旋转的——有些人面试非常出色。他们在准备考试方面很擅长,并且是强大的面试者。我们从学校或大学了解他们,他们总是取得好成绩。但是,当涉及到实际任务时,理论并不一定意味着成功。
正因如此,有时我们在过程中会有另一个阶段:编码面试。
编码面试的目标是尽可能接近地看到我们如何处理现实生活中的情况。在这些测试中,结果并不总是关键的——面试官想看到我们如何思考、规划、编写以及如何应对变化和困境。
即使编码面试可能持续 15-20 分钟,它也可能是一个需要一两天才能完成的家庭评估。这听起来不仅对我们自己,也对面试官来说都是一项相当大的努力!
正因如此,大多数时候,编码面试阶段将在技术面试之后。招聘经理想确保他们对值得的候选人付出额外的努力!
工作场所可以通过几种方式看到我们的编码,这取决于工作场所本身及其文化。行业在考虑测试候选人的最佳方式上并不一致,因为对此有长期的争论。例如,家庭评估允许候选人在舒适的环境中根据自己的节奏完成编码测试,而现场编码测试则为公司提供了观察候选人在实时观察其解决问题的能力和沟通技巧的机会。
首先,让我们回顾一下不同的方法。我们将从现场编码面试开始。
现场编码面试
在现场编码面试中,面试官会给我们一个编码挑战。这可能是一个类,一个算法问题,甚至是一个小型项目的开发。
现场编码的主要目标是看到我们的编码实际操作。面试官想看到我们编写、规划和思考。
虽然这可能对一些候选人来说是一次压力很大的经历,但我们可以利用以下一些事情来应对这种情况:
-
一个优点是能够展示我们如何思考和应对任务中可能遇到的持续困境。面试官期望我们大声思考。即使我们遇到了难题,良好的思考方向也能在面试中为我们赢得分数。这是在非现场编码任务中无法获得的。
-
我们可能拥有的第二个优势是在考试期间提问,这可以给我们提供解决方案的线索。
如我们所见,应对现场编码测试的最佳方法是将之视为展示个人知识和技能的绝佳机会。另一种不同方向的选项是家庭评估。
进行家庭评估
现场编码有其缺点——它压力山大且耗时,这也使得它具有时效性。因此,它涵盖的主题和领域较少。
为了在编码测试中涵盖更多内容,一个标准选项是异步执行(是的,就像在 Swift 中为重任务打开新的后台线程一样)。
家庭评估有效地涵盖了 iOS 编程的基本主题——设计模式、架构、API 调用以及一些具有数据源的 UI。我们不应该期望复杂的任务——家庭评估的主要目标是观察我们如何处理和接近整个项目,如何将代码和责任分离到类和层中,进行网络和 UI 操作,以及总体上,作为 iOS 开发者,如何展示真实任务的结果。
家庭评估有明显的优势。它们成本低廉,因为不需要占用面试官的时间。实际上,时间(几乎)不是一个因素。另一方面,一些工作场所不喜欢家庭评估,因为它们不能测试我们在有限时间内解决复杂问题的能力。此外,家庭评估不能展示我们在工作中如何思考和面对困境。为了获取这些信息,面试官需要提问并与我们一起审查代码,这使得我之前提到的优势变得微不足道。
但别担心!我们将在第十三章**.中涵盖现场编码和家庭评估。
接下来,我们将讨论复杂架构问题的架构面试。
通过架构面试
架构面试旨在测试复杂的架构问题。其一方面是观察我们如何规划、如何解决问题以及提出相关问题。与处理简单或较小问题的编码面试不同,架构面试要求我们具有更广阔的视角,了解 iOS 应用是如何构建的。这也是为什么面试官很可能是资深开发者、技术专家或团队领导的原因之一。
面试涵盖了设计模式、计算机科学原理和架构决策。这是我们可以展示自己作为 iOS 开发者最佳状态的机会。我们必须引导讨论,传达我们的担忧,并展示我们的想法。
我们已经涵盖了第十一章和第十二章,涉及应用架构和设计模式(我们也会讨论它们之间的区别,不用担心!)。
在大多数招聘过程中,架构面试是我们进入人力资源面试和获得工作机会之前的最后一个阶段。
摘要
在本章中,我们学习了招聘过程及其各个阶段。我们深入探讨了筛选面试,因为它是流程中的第一个面试。我们还讨论了其他面试——技术面试、编码面试和架构面试。
招聘过程现在是不是更清晰了?但在我们进入面试本身之前,在下一章中,让我们谈谈我们职业发展过程中缺失的一环,这是我们所有人都应该拥有的:一个出色的品牌!
第三章:开发者品牌
到目前为止,我们已经讨论了选择我们想要的职场,并了解了招聘流程的样子。现在,我想深入探讨一下许多开发者没有注意到的某个方面:开发者品牌。
开发者品牌是指软件开发者向公众展示自己和其工作的方式。它可能包括个人网站、社交媒体存在和对开源项目的贡献等元素。
在本章中,我们将涵盖以下主要主题:
-
理解打造品牌的重要性
-
贡献社区
-
编写内容
-
结合所有更多
-
理解每一次面对面互动的重要性
到本章结束时,我们将拥有开始构建个人开发者品牌的工具。首先,让我们了解为什么这对我们的主要目标——通过 iOS 面试——很重要。
理解打造品牌的重要性
这本书讨论了 iOS 面试,但这并不意味着它教授我们 iOS 开发。为什么?因为我假设我们已经知道 iOS 开发;问题不在于我们的技能水平,而在于我们通过招聘流程的能力。
但通过 iOS 面试比我们想象的要复杂。它结合了我们的 iOS 开发知识,这需要全面和广泛,以及我们对招聘流程的熟悉。但有一个新的因素可以帮助我们获得面试和录用——那就是我们的名字,换句话说,我们的品牌。
让我们深入探讨“品牌”的含义。
学习什么是品牌
当我们想到品牌时,我们通常会想到苹果、谷歌或可口可乐,我们是对的!品牌是区别于其他产品或服务的公开产品或服务。产品或公司通过建立声誉、专注于特定领域并将情感和思想与其名称联系起来来实现这一点。
但——你知道吗?品牌不仅仅是公司的?事实上,它甚至不仅仅是为企业。作为 iOS 开发者,我们可以建立自己的品牌和声誉。
但“品牌”究竟是什么意思?我们是产品吗?
在现代 iOS 开发者的劳动力市场中,我们被视为产品,我们需要像任何产品一样进行销售和营销。我们还必须打造自己的品牌,以创造需求,就像在任何营销过程中一样。
通常,当我们想到品牌时,我们会想象特定的颜色、字体或独特的标志。这是真的——这些都是品牌的视觉表现。但这个视觉表现是在更深层次之下的,而这个层次包含的远不止这些。它包括价值观、历史、贡献、质量和专业领域。
苹果品牌让我们想到高级用户界面、高端感觉和“它就是如此工作”的口号。可口可乐品牌让我们想到派对和自由。如果你花更多时间思考,你会发现这适用于更多品牌。
你知道吗?这就是“品牌”概念与我们相遇的地方。我们的目标是绑定那些使我们从 iOS 开发者市场中脱颖而出的特定属性。一旦我们做到了这一点,它就能提高我们在面试过程中的机会。
提高我们通过简历筛选的机会
一个强大的品牌可以帮助我们获得面试机会。你还记得我们讨论过的个人资料筛选过程吗?第一章?这就是我们的开发者名字派上用场的地方。如果我们保持我们的名字并加以建设,那么招聘人员很可能就会熟悉它。记住我们只有六到八秒的时间来给人留下第一印象,我们能够理解在这个阶段我们的声誉有多么重要。
想象一下,当你浏览候选人名单时,突然遇到一个熟悉的名字。在大多数情况下,这个名字会吸引你的注意,你将花比平时更多的八秒时间阅读他们的简历。所以,是的,我们在个人资料上花的时间越多,我们获得筛选面试邀请的机会就越大。这就是它的运作方式。当然,这也适用于面试过程本身,直到最终获得工作机会。
提高我们获得工作机会的机会
我们的品牌影响力并不止于获得面试。它还帮助我们处理招聘过程本身。在招聘过程中,我们的名字是招聘团队内部讨论的一部分,如果我们有一个强大的名字,这些讨论就会有所不同。无论我们怎么看,它都增加了我们在流程中进入下一阶段(甚至跳过几个阶段!)并最终获得工作机会的机会。
另一方面,我们强大的存在并不意味着我们不应该保持低调。这与品牌或我们的技能无关。这完全关乎我们的个性和成为一个愉快的工作伙伴或联系对象。
讨论联系引出了我的下一个观点:网络。
扩展我们的网络
加强我们名字的一个副作用是专业网络的扩展。你知道,有些人不关心网络,要么是因为他们的性格不善于社交,要么只是想专注于工作,不再考虑其他。这是合法的!但如果我们想建立一个强大的品牌,我们的网络就是其中的一部分。
网络意味着参加会议和聚会。但它也意味着参与社交媒体讨论和不断创造内容。这些都是品牌发展的组成部分,这就是为什么它与品牌建设紧密相连。
现在我们(希望)已经相信品牌的重要性,让我们了解如何建立它。
那么,我们从哪里开始呢?
如何开始打造你的品牌
第一条也许是最主要的规则是专注于代表我们作为开发者的东西。如果我们觉得站在舞台上不是我们的风格,我们应该寻找另一条道路。如果我们觉得写博客文章会让我们紧张和痒,我们可能考虑走向开源的方向。如果我们对正在做的事情感到不舒服,我们在这个领域坚持下去的可能性相当低。
建立我们的名字需要毅力和努力。没有动力和乐趣,我们将难以实现我们的目标。
话虽如此,第二条规则是我们必须不留在我们的舒适区。这听起来可能与我们第一条规则相矛盾,但我没有承诺你一个简单直接的方法。毕竟,生活是复杂的。
在本章中,我将列出一些关于我们如何发展自己名字的想法,并鼓励你至少考虑它们作为一个选项。体验它们将是最好的选择。
让我们从基础开始:贡献社区,也就是贡献给自己。
贡献社区
我知道“贡献”听起来像是一项艰巨的任务,但这个领域如此广泛,你在这里也可以找到你的细分市场。贡献不仅仅意味着参与一个项目,还意味着帮助其他开发者或分享你在编码方面的成就。
贡献的一个好处是能够以最实际的方式分享你的解决方案,那就是编码。
我们不需要想出一个新的贡献方式,因为有许多资源可以帮助我们。在当今时代,有许多网站托管了大量分享代码、提问和交流想法的开发者。你可能熟悉的一个网站就是Stack Overflow。
成为 Stack Overflow 的明星
我不确定这本书是不是讨论Stack Overflow网站的地方。但如果你对 Stack Overflow 不熟悉,那么在过去 15 年里你可能没有生活在地球上,所以通过 iOS 面试至少不是你的问题。
但最大的问题是——你在 Stack Overflow 上的存在感是什么?Stack Overflow 有四种类型的用户:
-
第一种类型是被动读者,这是大多数开发者所遵循的。在大多数情况下,你可能是其中之一。被动读者通过在谷歌上搜索问题来找到 Stack Overflow,但他们从不提问或回答问题,因为他们是,嗯,被动的。
-
第二种类型是提问者。提问者喜欢提问,但从不回答或评论。听起来可能很奇怪,但提问可以为我们提供评分,更重要的是,它使我们接触到社区,并与其他开发者开辟新的沟通渠道。提问最重要的就是做得正确——重复的问题将被删除或标记为垃圾邮件。此外,我们需要提供所有细节和代码片段,并标记正确的标签。
-
为了提供答案,我们必须积极进入 Stack Overflow 并搜索问题或添加评论。这是网站的一个完全不同的用途,并引出了下一个用户类型。
受访者是指遇到问题并知道解决方案的用户类型,因此他们会回答问题。他们在网站上花费的时间不多,但投入最小的努力来做出贡献。如果我们想建立我们的品牌,至少在 Stack Overflow 上我们应该是一个受访者。
- 最后一种类型是重度贡献者。他们每天都会进入 Stack Overflow 并寻找问题来回答。这是一种不同类型的用户——他们喜欢用短语表达复杂问题的解决方案,发布代码片段,并就最佳实践和神话进行讨论。这是一个在 Stack Overflow 上查看问题(通过标签如iOS、Swift或UIKit过滤)的同时开始我们工作日的好方法。在 Stack Overflow 上回答问题并获得高声誉并不是不可能的任务;这只是一个优先级和努力的问题。
另一种较轻的贡献方式是评论答案和问题。当我们需要澄清、批评提供的解决方案或改进答案或问题时,我们会使用评论。这些选项为我们提供了许多新的方式来参与讨论,即使我们并没有完美的答案。
我提到了所有这些类型,因为如果我们想成为明星,我们应该努力成为第四种类型。如果我们想建立我们的品牌,使用 Stack Overflow 来为社区做出贡献是一个很好的方式。
一旦我们在 Stack Overflow 上获得更多的评分和经验,我们就可以开始以新的方式做出贡献,例如编辑问题或答案、标记问题,甚至删除它们。
为 Stack Overflow 做出贡献是一种双赢策略——你可以在学习的同时在社区中引起注意。
维护一个公共 GitHub 仓库
我们在第一章中就拥有一个出色的简历进行了长时间的讨论。虽然拥有一个好的简历是至关重要的,但我们还可以通过一个令人印象深刻的GitHub仓库来完善我们的开发者形象。例如,当我们谈论一个建筑师时,我们期望看到他的作品。同样的,对于设计师或摄影师也是如此。为什么 iOS 开发者的情况需要不同呢?
GitHub 仓库是我们的代码作品集,是我们展示我们能力的方式。通过社交媒体推送新的更新、想法和库可以显著影响我们作为 iOS 开发者给人的印象。
大问题是——我们需要在我们的公共 Git 仓库上放些什么?我们不可能把工作中的当前项目放在上面,对吧?
让我们把它分解一下。
使用 Swift Package 或 pods 分享解决方案
我们是 iOS 开发者,我们很棒!这意味着在我们的职业生涯中,我们解决了成打甚至数百个复杂的问题。我们很可能不是第一个遇到这些问题的人。如果我们(只是一个想法,对吧?)将我们的一个或一些解决方案与世界分享会怎样呢?
让我们试着回忆一下——我们是否有什么独特且值得骄傲的东西?我们是否以某种方式构建了可以轻松用于不同项目的成果?如果是这样的话,我们应该在我们的GitHub账户中创建一个公共仓库并分享它。最简单的方法是使用Swift Package或pod。
创建副项目
GitHub 仓库的另一个选择是创建一个副项目。副项目让我们能够展示我们的代码作品集,展示我们从头开始构建项目并随着时间的推移维护它。维护是副项目的主要挑战之一。以下是我们维护副项目的几点建议:
-
许多开发者都有副项目的想法。开始一个副项目很简单:设置一个新的仓库,创建一个新的项目,并编写一些类。主要问题,嗯...是完成这些项目。这就是副项目的第一个建议。开始一个适度但实际可工作的项目比一个雄心勃勃但永远无法完成的要好。
-
另一件需要关注的事情是它的架构而不是功能。我并不是说我们的副项目不需要很好地工作,但我们应该记住它的目标,那就是展示我们的能力。如果是这样的话,它需要看起来很好。干净的设计模式、良好的命名约定和单一职责原则比修复一个额外的错误更重要。
-
第三个建议是我们的时间管理。我知道在繁忙的日程中找到时间来处理副项目的感受。但做一下数学计算——每天 10 分钟累计起来就是每月 5 小时,半年 30 小时。我们不必辞职或通宵达旦地工作在副项目上。我们只需要更好地管理我们的时间,并制定一个具有现实里程碑的长期计划。
-
我的最后一个建议是关于我们工作中的优先级。试图解决我们遇到的每一个小问题,追求完美,并不会带我们达到目标,反而只会让我们感到沮丧。持续进步并留下未完成的待办事项比纠结于非问题要好。记住,完成副项目比使其完美更重要,尤其是在心理层面上。
现在,对于一些人来说,副项目是一个大事情。我们需要想法、雄心和时间。但不必走得太远,因为还有其他方式来展示我们的能力。
添加 gists 和展示项目
binary tree search (BTS) algorithm works in Swift, we can build a small app that uses that algorithm. But for that, we don’t even need a project to show a piece of code. We can use a GitHub feature called Gist.
什么是二叉树搜索?
二叉树搜索是一种数据结构,由按层次顺序排列的节点组成,其中每个节点最多有两个子节点。它通过利用节点左子树只包含键值小于节点键值的节点,而右子树只包含键值大于节点键值的节点的属性,允许高效地搜索、插入和删除节点。尽管二叉树搜索不在这个章节的范围内,但这是一个学习它、在 Swift 中实现它并创建一个可以与世界分享的精彩 gists 的好机会。
Gist 是 GitHub 的一个功能,允许我们分享代码片段。我们需要展示如何使用正则表达式来匹配电子邮件地址吗?太好了!Gist 非常适合这个。
记住,通过每天编写代码,我们执行创新和复杂的任务。所以,让我们将这些想法转化为有意义的内客。一旦我们有一个书面代码片段或项目的线索,正确添加 gists 就变得至关重要。就像生活中的每一件事一样,专注是成功的关键。
如何专注于重要的事情
让我们从显而易见的事情开始——这一章不是 GitHub 教程,这本书也不会解释如何开发一个解决 Swift 算法问题的应用程序。主要目标是帮助你获得面试并通过它,就像国王一样。既然这样,我们现在明白追求一个看起来好的 GitHub 账户是多么重要。
如我之前所说,如果我们的目标是品牌建设,那么我们的 GitHub 外观就具有很大的权重。让我们分解这意味着什么。
使我们的 README 文件看起来更有趣
让我们再次回到第一章中“构建我们的简历”部分的简历扫描——还记得那个问题吗?我们知道招聘人员有六到八秒的时间来扫描我们的作品集,并决定他们是继续阅读还是将其移至垃圾桶。这个六到八秒的规则也适用于代码文档扫描;不仅仅是简历。
每个 GitHub 仓库都有一个README文件,当你进入仓库的网页时会出现。通常,它包含安装和使用说明,但总是从解释项目开始。
那个解释使我们能够推广我们的项目并使其更具吸引力。就像我们的个人简介描述一样,我们不是从代码的功能开始,而是从它解决的问题以及为什么这个问题至关重要开始。
让我们的项目看起来更有吸引力的另一种方法是添加图片或甚至 GIF 动画(取决于项目类型)。添加艺术品可以吸引人的注意,使扫描更有效。
现在我们有一个出色的 README 文件,让我们转向主菜:代码,它必须是可读的。
编写可读的代码
gist, we must ensure it looks perfect.
让我们列出一些可读代码的技巧:
-
添加明确的注释来解释我们选择的原因
-
为类和变量选择好的命名约定
-
正确且按照书本(除非这本书是我们的,当然)使用设计模式
-
编写描述代码功能及其预期的单元测试
这些是一些让我们的代码发光的方法!
记住,将审查我们代码的开发者不会是招聘人员,而是招聘经理或技术团队负责人。他们对 iOS 开发有一定的了解,所以我们的代码需要看起来完美且完整。
我们项目的完整性
在我们公开之前完成我们的项目怎么样?这是一个重要的观点。不要留下半成品项目或甚至代码片段。完成一个小项目比计划一个大项目但不完成要好。我们之前讨论过这一点,但当我们向世界展示时,这一点变得更加关键。
一个副项目是一件大事。它需要时间和精力,但当我们建立品牌时,它也给我们带来亮点。
如果我们想参与一个项目但又不想自己开始,还有另一种方法,也许这更适合我们。
加入开源项目
开发者喜欢为开源项目做贡献的原因有很多。其中最重要的原因是改变我们经常使用的产品的动力。另一个原因是提升我们的技能。我们不是在这里讨论开源项目如何帮助我们作为开发者,而是在讨论为开源项目做贡献如何帮助我们建立我们的品牌。
我们之前说过,品牌应该反映我们作为开发者或我们想要成为的开发者的风格。
开源项目需要比个人副项目不同的技能(既有软技能也有硬技能),为开源项目做贡献将这些技能带到前台。因此,在我们分叉Git 仓库并添加新代码之前,我们必须确认这项工作符合我们的个性。
之前我说过,建立我们品牌的最关键的事情是确保我们选择的方式适合我们和想要塑造的形象。乐趣和快乐在这里起着重要作用!而且,一开始为开源项目做贡献可能会让人感到沮丧。我们必须深入一个现有的项目,与其他开发者进行密集的沟通,并习惯于那些带有侮辱性评论的残酷的拉取请求。这是我们计划加入开源项目时所想象的那样吗?
许多开发者想要与世界分享他们的知识和经验教训,但开源项目并不总是适合他们的技能集。幸运的是,还有另一种实现这一目标的方式:写作。
写作内容
建立品牌的另一种方式是内容写作。博客、教程,甚至书籍都是可以利用我们的开发者品牌的内容写作的绝佳例子。
它对我们品牌有重大影响的原因很明显,但内容写作还可以以其他方式帮助我们通过面试过程,这被认为是一种双赢的解决方案。让我们接下来详细阐述这一点。
成为专家
阅读一篇专业文章对于学习来说是非常好的,但撰写一篇专业文章则是更深层次的理解。
想象一下,当我们写文章时需要我们做什么——研究、编码、边缘情况覆盖、解释为什么事情会以这种方式完成的能力,以及管理替代讨论。
我们现在明白,写文章实际上不是写作——它是学习!这意味着成为某个领域的专家,并从所有角度覆盖它。我们写的博客文章越多,覆盖的领域就越广,这是我们招聘过程的绝佳起点。
但知识覆盖面并不只是这些。它还提供了我们解释这些知识的能力,这引出了我的下一个观点。
提高措辞和表达能力
在写内容时,我们获得的另一项技能是措辞和自我表达能力。内容写作使我们的思想条理化,使我们能够列出利弊,讨论替代方案,并解释(例如)为什么我们需要强制包装一个IBOutlet而不是使其可选。
自我表达能力在面试中至关重要。从下一章开始,我们将学习如何回答问题,但主要的是,我们将学习有很多问题没有明确的答案。在这些情况下,我们讨论甚至辩论这些问题的能力是至关重要的。
写内容不仅能在面试中引发讨论,还能作为对博客文章本身的反应。所以,我们还有另一个好处!联系!
扩大我们的网络
记得我曾经说过内容写作是一个“双赢”的努力吗?现在,我们看到这是一个永不停止给予的礼物。
在我们发布文章后,我们将开始收到来自全球 iOS 开发者的回应。每个回答都是一个讨论线程的线索,而这个线程又创造了一个新的联系。
此外,每篇博客文章都会带来新的追随者,这反过来又增加了更多问题并创造了新的联系,因此这个过程甚至呈指数增长!
广泛的网络和高数量的追随者可以帮助我们在面试过程中获得第一次面试。我记得面试一份工作时,面试官是我的追随者之一,他甚至使用了我的一篇博客文章来解决他的问题。这使面试有了不同的光亮,不是吗?
我刚才给出的例子也提出了另一个好处:将我们的知识公之于众。
让世界了解我们的知识
通过面试的任务是竞争世界的一部分,在这个世界里,我们需要“玩游戏”。
我们知道我们有多聪明是不够的。我们需要别人也看到这一点。我知道这听起来很肤浅。但是的——这是建立品牌和推销我们和我们的能力的一部分。
分享我们知识的第四个好处是向世界展示我们所知、我们的思考方式以及我们在新领域进行研究的才能。当我们这样想的时候,管理一个博客或拥有自己的书籍为我们打开了一扇了解我们的知识、我们的心态和我们的思维方式之窗。这起初可能听起来有些令人不安,但如果我们做得正确,它可以在求职时带来显著的好处。
让我们稍微思考一下——我们的第一个也许是最具挑战性的目标是通过简历筛选阶段,而这只有当审阅者认为我们适合该职位时才能实现。对于一些审阅者来说,我们拥有的 iOS 开发知识被公之于众,这就像是我们能力的证书。这是一个巨大的优势,尤其是如果审阅者同时也是招聘经理的话。
因此,我们可以看到我们在内容写作方面有多个优势,这实际上是一个双赢的局面。但它是如何与之前讨论的其他方式相匹配的呢?我们将在下一节中看到。
结合所有和更多
你知道,撰写内容对我们品牌发展工作来说是优秀且宝贵的。但真正的力量在于我们使用内容作为我们品牌的基础设施,并将其与我们之前提到的其他方法相结合,例如在 GitHub 和 Stack Overflow 上保持活跃。
例如,如果我们写了一篇关于特定主题的文章,我们可以在其基础上构建内容。一个 GitHub 仓库解释了我们的核心思想,通过这样做,我们获得了一种分享我们技能的新方式。然后,我们可以使用我们的仓库或文章作为参考,在 Stack Overflow 上回答问题时获得更多的曝光。
但真正能产生重大差异的是将这个包升级到极致。两个能让我们脱颖而出的优秀选择是在会议上发表演讲和撰写一本书。我之前没有提到它们,因为与其它选项不同,成为一名公众演讲者或写一本书需要特殊技能、时间和资源。
梦想着站在舞台上或看到我们的书在亚马逊上出售是多么令人兴奋和激动人心。这两个选择可以利用我们的品牌将我们的品牌提升到新的高度,并在行业中打开新的大门。
在我们总结本章之前,我想让我们回到基础的基础:面对面互动。
认识到每一次面对面互动的重要性
在第二章中,我们讨论了在面试中留下良好印象的重要性。但讨论是在获得聘用的背景下进行的。我们应该记住,在现实生活中给人留下深刻印象不仅对面试至关重要,而且对于打造我们的品牌也是必不可少的。
开源贡献、Stack Overflow 问题、博客关注者等等,都是我们需要与其他开发者进行一定数量互动的地方的例子。
展望未来,这些互动也有助于我们在行业中塑造我们的形象。有时,那次单独的互动是我们向其他开发者展示自己的唯一机会。
我们留下的印象是什么?就像每一个工业产品一样,我们的名字在我们前面,建立在成千上万的谈话、帮助会议和通信之上。
摘要
我们刚刚探讨了一个激动人心且独特的主题!开发者品牌建设远不止于 Swift 和 UIKit 的实践——它还可以帮助我们发展开发职业生涯。更有趣的是,它的范围非常灵活。我们可以从 Gist 中的一个代码片段开始,逐步提升到公开演讲和撰写书籍。我们可以根据自己的资源和技能选择我们想要达到的位置。
我们已经了解了品牌是什么,为什么贡献是其中的一部分,如何维护 GitHub 仓库,成为 Stack Overflow 的明星,以及如何制作我们的专业内容。
到现在为止,我们应该已经拥有了启动我们的品牌发展计划,成为 iOS 开发社区新星的基本工具!
但还有一件事是缺失的:我们成功通过技术面试的能力……(毕竟,这是我们所有人都在这里的原因,对吧?)。幸运的是,在我们接下来的章节中,我们将讨论我们都喜欢学习的内容——Swift 语言和编码。乐趣才刚刚开始。
第二部分:Swift 语言与编码
接下来,我们进入本书的第二部分,开始探讨面试官经常询问且认为有价值的 Swift 关键主题。在这一部分,我们将找到涵盖数据结构、可选类型、内存管理、泛型和测试等领域的十二个面试问题。到这一部分的结尾,我们将具备应对大多数与 Swift 相关的面试问题的知识。
在这一部分,我们包含以下章节:
-
第四章, 数据结构与算法
-
第五章, Swift 编程语言
-
第六章, 管理你的代码
第四章:数据结构与算法
我们在简历上投入了时间和精力,进行了公司研究并建立了我们的开发者品牌。但我们为什么要这样做?为了开始我们想要工作的公司的面试流程,并且被雇佣!
因此,现在我们已经迈出了第一步,我们将转向我们的下一个挑战,通过 iOS 技术面试。
通过 iOS 技术面试的第一个主题将涵盖类、结构体、字典和数组等数据结构。虽然我们可以从 Swift 语言的基本原则开始,但这可能被认为是一个更技术性的主题。相比之下,数据结构涉及更抽象的概念。因此,我们将把 Swift 语言留到第五章。
本章将涵盖在 iOS 技术面试中经常被问到的基础数据结构。为此,我们将在本章中介绍以下内容:
-
学习数据结构的重要性
-
回答类和结构体的问题
-
回答关于 Swift 数组的提问
-
覆盖 Codable 协议
-
准备与字典和集合相关的面试问题
在我们开始之前,让我们花一点时间来理解数据结构的重要性以及为什么我选择从这一主题开始我们的技术讨论。
学习数据结构的重要性
数据结构是我们 iOS 开发的基石。实际上,数据结构是许多编程语言的基石,对该领域的深入理解是成功开发和因此通过 iOS 面试的关键。
什么是数据结构?嗯,类、结构体、数组、字典和集合都是数据结构的例子。
但究竟是什么使得数据结构如此重要?以下是一些为什么数据结构是 iOS 面试不可或缺部分的原因。在我们讨论一些面试问题之前,让我们先列出它们。
提高效率
当我们讨论资源时,我们总是面临短缺和限制。尽管 iOS 设备在过去几年中变得更为强大,但 iOS 开发也不例外,需要效率。
每种数据结构在时间和空间复杂度方面都有其独特的优势;因此,在我们的代码中会有不同的用途。有时,性能的差异可能非常显著,以至于它可以使我们的应用程序在即使是功能最强大的 iPhone 上也运行得非常慢。
什么是“时间和空间复杂度?”
时间复杂度是指执行算法或解决问题所需的时间,它是输入大小的函数。它通常以算法执行的基本操作数量来衡量。
相反,空间复杂度是指执行算法或解决问题所需的内存量,它是输入大小的函数。它通常以算法用于存储数据和中间结果的内存量来衡量。
在面试中展示出巨大的知识差距会引发一个大红旗,而对于 iOS 开发者来说,即使对于初级开发者,具备基本知识也是一个最低要求。
尽管如此,让我们尝试改变一下书本氛围——不是所有东西都是红旗,我们甚至可以在面试中得分。模块化就是一个例子。
使我们的代码模块化
叫我“超级极客”吧,但我认为数据结构的一个很好的用途是让我们的代码看起来像一件艺术品。模块化可能是艺术代码的最佳例子。
数据结构提供了一种组织和封装我们代码的方法,使其更容易阅读和维护。
跟随我的在线内容的开发者已经知道,我是一个单一职责原则的大粉丝,这是 SOLID 原则集的一部分。这个原则指出,每个模块、函数、类,甚至变量都应该只有一个且仅有一个职责。
关于 SOLID 原则
SOLID 是面向对象编程中一组原则的缩写,有助于设计更易于维护、可扩展和可重用的代码。SOLID 原则是由 Robert C. Martin 在 2000 年代初的论文《设计原则和设计模式》中提出的。
五个 SOLID 原则如下:
-
单一职责原则(SRP):一个类应该只有一个改变的理由
-
开闭原则(OCP):软件实体应该对扩展开放但对修改封闭
-
里氏替换原则(LSP):子类型应该是其基类型的可替换的
-
接口隔离原则(ISP):客户端不应该被迫依赖于它们不使用的接口
-
依赖倒置原则(DIP):高层模块不应该依赖于低层模块;两者都应依赖于抽象
让我们看看以下代码:
class Employee { var name: String
var salary: Double
// responsibility 1: store employee data
init(name: String, salary: Double{
self.name = name
self.salary = salary
}
// responsibility 2: calculate payroll
func calculatePayroll() -> Double {
}
}
我们有一个Employee
类,它有两个职责——第一个是存储员工的个人数据,第二个是计算工资。
我将从基础知识开始讲起——这不是你希望面试官看到的代码片段,因为它在一个类中混合了两种职责。将calculatePayroll()
函数移到名为Payroll
(例如)的单独类中会更好。
代码分离之所以至关重要,是因为我们理解,控制我们代码中发生的事情是至关重要的。如果一个类或结构有更多的职责,它可能会产生副作用,导致其他问题。
我们总是需要解释(对面试官和我们自己)我们刚刚编写的类或函数的目标,它在所选的设计模式中的作用,以及为方法和变量做同样的事情。
模块化代码不仅用于逻辑分离——它还在我的下一个要点中扮演着重要的角色,那就是可重用性。
代码的重用
我需要解释代码重用的重要性吗?
但以防万一,当编写可以在不同地方使用的代码时,代码重用可以防止错误和不一致的行为。
在数据结构中的代码重用指的是逻辑和数据的双重重用。这可以包括使用类和结构体来重用逻辑和数据结构,例如数组和字典,以可重用的方式存储和访问数据。
而这正是代码重用与先前的模块化要点相关联的地方——如果一个数据结构只有一个职责,它就更容易重用,因为没有副作用会阻止我们在其他地方使用这段代码。
数据结构中代码重用的另一个例子是类继承。当我们创建一个子类时,我们可以使用所有超类代码。
通常,面试官喜欢看到可重用代码,因为它使我们的代码更有效且更少出错。数据结构还可以以另一种方式减少我们的代码出错,那就是 API 和接口。
使用数据结构进行 API 设计
数据结构的另一个优秀用途是用于API和接口。我们应该将数据结构视为表示实体或某些其他复杂数据集合的方式。如果你这样想,创建我们代码中不同组件之间的 API 接口会更容易。
让我们通过编写一个sendPersonToServer()
函数来看看它在代码中的含义:
func sendPersonToServer(name: String, age: Int, email: String, phone: String, address: String) {
}
sendPersonToServer()
函数的目标是将一个人的详细信息发送到服务器,但我们很容易看到这里的主要问题。首先,我们需要提供一个很长的参数列表,这非常不方便。但更重要的是,看起来所有参数都可以封装到一个我们可以称之为的数据结构中——Person
。
让我们看看一旦我们将它提取到一个Person
结构体中,函数接口看起来会是什么样子:
struct Person { let name: String
let age: Int
let email: String
let phone: String
let address: String
}
func sendPersonToServer(person: Person) {
}
这看起来更优雅,不是吗?
看起来sendPersonToServer()
函数的接口现在更清晰了,也更一致。每次我们向Person
添加一个新变量时,我们不再需要更新函数头,因为它现在被封装在结构体定义中。
这也是为什么面试官在讨论潜在策略时,通常会欣赏看到涉及函数和 API 接口重用的解决方案。
我们讨论了数据结构的重要性——我们提到了效率、模块化、可重用代码和接口。我的主要目标是为你提供一个知识基础设施,帮助你面试时构建答案。
现在,让我们回顾一些关于数据结构的面试问题,从类和结构体开始!
回答类和结构体问题
在数据结构、类和结构体的范畴下,类和结构体问题可能是我们在项目中使用的最基本形式。原因是类和结构体不仅包含数据,还提供了应用的主要逻辑以及它们所代表的对象。
当苹果宣布 Swift 时,他们不断强调在许多情况下使用结构体而不是类。当宣布基于结构体而非类的SwiftUI时,这一趋势变得更加极端。因此,不言而喻,为什么类和结构体的问题在 iOS 面试中扮演着重要的角色。
让我们转到第一个也是最流行的问题——类与结构体的比较。
“类和结构体之间的区别是什么?”
这个问题为什么重要?
类和结构体有许多共同特征——它们都用于定义复杂的数据类型、方法和属性。
“函数”还是“方法?”
在 iOS 开发中进行工作面试意味着我们需要专业。作为专业的一部分,术语至关重要,因此区分函数和方法很重要。函数是一个执行特定任务的代码块,可以从程序的任何地方调用。相比之下,“方法”是与类或结构体相关联的函数。
然而,类和结构体也有一些重要的区别,这些区别会影响我们解决问题时的选择。
答案是什么?
类和结构体之间有三个主要区别:
- 第一个区别是,类是引用类型,而结构体是值类型。这意味着当我们将基于类的对象传递给函数或另一个实例时,我们实际上是在操作和修改原始实例,因为我们传递的对象只是一个引用。结构体则不是这样——每次我们将结构体传递给函数时,我们都在处理该结构体的一个副本,而不是原始的那个。
看看下面的代码:
struct A { var name: String
}
var a = A(name: "Avi")
let b = a
a.name = "John"
print(b)
print(a)
代码的结果将是Avi
,然后是John
。然而,如果我们将A
声明为类,结果将是John
和John
,因为它是对原始变量的引用。
-
另一个区别是我们可以继承类,这意味着我们可以从一个类派生出一个类,包括属性和方法。结构体不能从另一个结构体(或类)派生。
-
最后的区别是可变性。因为结构体是值类型,如果我们将其标记为let,则不能更改其属性。类则不是这样——如果类被标记为let,我们仍然可以修改其属性。
以下代码将引发错误:
struct A { var name: String
}
let a = A(name: "Avi")
a.name = "John"
然而,如果我们将A
改为类,那么这将有效:
class A { var name: String
init(name: String) {
self.name = name
}
}
let a = A(name: "Avi")
a.name = "John"
重要提示
在我们继续之前,让我们澄清一下——在诸如数据结构这样的主题中,并不存在“更好”这一说法。总是存在权衡;我们应该在我们的面试中强调这一事实。
现在我们来讨论下一个问题。
“类和结构体哪个更好?”
这个问题为什么重要?
要给面试官留下深刻印象,不仅需要能够区分结构体和类,而且还需要展示对不同情况下何时使用每种数据结构的理解。
正如我们在本章前面讨论的那样,结构体和类有不同的特性,因此它们的使用场景也不同。这是一个比仅仅知道它们之间的区别更实际的问题。
是什么 答案?
它们都是针对不同目的的绝佳数据结构。从结构体开始,看看它是否满足我们的需求是个好主意。如果我们需要额外的功能,我们可以将其更改为类。
如果我们需要做以下事情,我们应该使用类:
-
将其用作引用类型
-
从另一个类继承(例如,例如从UIViewController子类化)
如果我们需要做以下事情,我们应该使用结构体:
-
在线程之间传递
-
优化性能
重要的一点是,没有上下文就没有“更好”这一说。一切都基于用例和我们的代码需求。
“为什么结构体(struct)比类(class)更快?”
为什么 这个问题重要?
这个问题本身可能没有意义,但面试官喜欢问它,因为他们想检查候选人是否深入理解结构体和类在设备内存中的存储方式。知道这个问题的答案,甚至在其他情况下解释差异,都可以在我们的面试得分板上加分。
是什么 答案?
结构体比类更快,是因为它们在内存中的存储方式。结构体是值类型;因此,它们存储在栈中,栈也存储局部变量和函数参数。
另一方面,类是引用类型,并且它们间接存储在堆中。堆用于存储动态分配的对象。
栈比堆更快,因为它的组织更可预测,并且 I/O 操作执行得更快。
重要的是要说明,性能差异并不显著。我们应该首先根据我们的需求选择合适的数据结构。只有当我们遇到性能问题时,才考虑对其进行优化。
类和结构体是 iOS 开发中极其重要的主题。我们每天都会遇到这些数据结构,并且对它们的深入了解对于编写引人入胜和优秀的代码至关重要。
但在编码中,数据结构通常是在其他数据结构之上创建的。数组就是这样。
让我们确保我们对 Swift 中数组的工作方式有一个全面的理解。
回答关于 Swift 数组的疑问
与许多其他数据结构不同,数组被认为比较棘手。一方面,数组非常适合存储数据集合,并且在许多广泛使用的情况下非常有价值,例如管理对象和实体的列表。另一方面,我们需要了解它们的优缺点才能有效地使用它们。
面试官喜欢问一些问题,以检查我们对数组内部工作原理的更深入知识。
像这样的问题,“如何声明一个数组?”并不常见,因为假设 iOS 开发者知道如何创建数组。
那么,关于数组的有趣面试问题有哪些?
通常,关于数组的问题集中在优缺点、内存管理和数据处理上。
让我们从优点开始。
“请列出 Swift 数组的优点”
这个问题为什么重要?
Swift 数组具有几个优点,使它们非常有用。如果开发者不了解这些优点,数组可能不是正确的数据结构。记住,对于每个优点,也可能存在缺点。
什么是 答案?
数组有几个优点:
-
通过索引轻松访问元素:可以通过提供所需元素的索引号快速检索或修改元素
-
类型安全:数组只能包含来自预定义类型的元素
-
内置操作:数组有许多操作,例如添加、删除、过滤、排序等
记住,像这样的问题往往是更多问题的基石,比如“数组的用例有哪些?”或“列出数组的缺点.”深入研究材料可以帮助你为任何意外惊喜做好准备。
“如何从数组中移除重复项?”
这个问题为什么重要?
与 Set
数据结构不同,Swift 数组可以包含重复项。这个问题测试了我们操纵数据数组的能力,并与面试官讨论解决方案。有几种方法可以解决这个问题,展示其中一些方法可以让面试官感觉到我们能够从不同方向操纵数据。
什么是 答案?
让我们看看可能的解决方案。
解决方案 #1
移除重复元素最简单的方法是将数组转换为 Set
,然后再将其转换回数组。Set
是一种不能包含重复元素的数据结构,将数组转换为 Set
会移除这些重复元素。让我们看看它是如何实现的:
let arrayWithDuplicates = [1, 2, 3, 3, 4, 5, 5]let arrayWithNoDuplicates = Array(Set(arrayWithDuplicates))
这段简单优雅的代码片段将非常有效!但对于一些面试官来说,这样做可能看起来像“作弊。”
记住,将转换为 Set
可能会 破坏项目的顺序,所以如果顺序很重要,这可能不是最佳解决方案。
因此,最好明确我们还有其他解决问题的方法。
解决方案 #2
另一种解决方案是构建一个新的数组,并且只有当项目不存在时才添加:
var newArray: [Int] = []for number in array {
if ! newArray.contains(number) {
newArray.append(number)
}
}
虽然这个解决方案是可行的,但它被认为是一种 暴力 解决方案。
什么是“暴力”解法?
暴力解法是一种依赖于纯粹的计算能力来解决而不是使用更巧妙方法的算法。这通常是一种简单直接的方法,但这也可能非常耗时,并且可能不是解决更大问题的最有效方法。
一种使循环更高效的方法是使用 Set
来检查项目是否已存在,而不是使用 array.contains()
:
var newArray: [Int] = []var newAddedItems = Set<Int>()
for number in array {
if ! newAddedItems.contains(number) {
newArray.append(number)
newAddedItems.insert(number)
}
}
与数组的 contains
方法不同,Set
的 contains
方法的平均时间复杂度为 O(1)
,这将提高我们的答案。
解决方案 #3
更优雅的方法是使用数组的 filter 方法:
let numbers = [1, 2, 3, 3, 4, 5, 5]let uniqueNumbers = numbers.filter { number in
numbers.firstIndex(of: number) == numbers.lastIndex
(of: number)
}
通常,我们应该始终优先选择 filter 方法而不是循环元素。filter 方法更易于阅读且更高效,因为 Swift 语言的本地优化(我们不需要在面试中深入了解,尽管我承认这很迷人)。
“如何使用数组实现队列?”
为什么这个问题很重要?
如果我们知道在计算机科学中 队列 的含义以及基本的数组操作方法,这个问题就很简单。
这些就是为什么这个问题相对流行的原因。它展示了我们对队列基本概念的理解,以及在使用数组执行此任务时的通常权衡。
答案是什么?
要使用数组创建队列,我们应该首先创建一个具有基本方法(如 isEmpty
、count
、enqueue
和 dequeue
)的 Queue
结构体。
Queue
结构体包含一个底层数组,我们可以使用 append
和 removeFirst
数组方法来执行队列的基本功能。让我们看看一个例子:
struct Queue<Element> { private var array: [Element] = []
var isEmpty: Bool {
return array.isEmpty
}
var count: Int {
return array.count
}
mutating func enqueue(_ element: Element) {
array.append(element)
}
mutating func dequeue() -> Element? {
return array.isEmpty ? nil : array.removeFirst()
}
}
不要害怕。没有必要记住那个解决方案!
最好的做法是提高我们对队列的理解,并记住 append()
和 removeFirst()
数组方法。
最好练习一次或两次,以确保我们第一次就能解决这个问题。
“如何在 Swift 中通过映射现有数组的元素来创建一个新数组?”
为什么这个问题很重要?
除了搜索数组之外,iOS 开发者还需要知道如何从一个数据结构到另一个数据结构的操作和转换数据。这种能力不仅适用于数组,也适用于字典和集合。
转换或映射数据是开发中的常见做法,尤其是在 combine 和 reactive programming 的世界中。
答案是什么?
我们可以使用 map
方法将数组映射到新数组。map
方法接受一个闭包作为参数,并为每个元素返回一个新值。这样,map
方法就产生了一个包含新值的数组。
让我们看看一个基本的映射方法,它接受一个整数数组,并返回一个新数组,其值是原数组的两倍:
let array = [1, 2, 3, 4, 5]let doubledArray = array.map { element in return element * 2
}
循环结束后,数组包含 [2, 4, 6, 8, 10] 的值。
使用映射的另一种更优雅的方法是使用 Swift 的一个特性,称为 隐式闭包参数语法。使用该特性,我们可以创建更短、更易读的代码:
let doubledArray = array.map { $0 * 2 }
$0
代表闭包中的第一个参数,不需要使用 return
关键字,这使得语句看起来更加简洁!
我们可以看到数组在 Swift 和 iOS 开发中是基本的,在数据存储、状态、API 接口以及许多需要处理集合的其他情况下扮演着重要角色。优雅且高效地操作数组是面试官喜欢测试的内容!
现在,我们已经完成了简单数据结构的处理,接下来我们将使用 Codable 来序列化它们。
覆盖 Codable 协议
Codable 协议是 Swift 中的一个重要特性,它允许我们将序列化数据(如字符串)转换为我们可以处理的数据结构。
关于 Codable 协议的第一件事要知道的是,它结合了两个其他协议——“Encodable”和“Decodable”。这两个协议帮助我们双向转换数据。
数据对象(如结构体)需要遵循 Codable 协议,这样我们才能进行双向转换。
这里有一个简单的代码片段:
struct Person: Codable { var name: String
var age: Int
var address: String
}
let person = Person(name: "John", age: 30, address: "123 Main St.")
let encoder = JSONEncoder()
let data = try encoder.encode(person)
let decoder = JSONDecoder()
let person = try decoder.decode(Person.self, from: data)
如前述代码块所示,Person
结构体遵循 Codable 协议,因此我们可以将其转换为数据,并在两个方向上进行转换。
关于 Codable 的第二件事是我们需要知道,所有结构体属性都必须遵循它。
Person has three properties from the String and Int types. String and Int already conform to Codable, so we don’t need to do anything else. However, if we want to add additional custom properties, we need to make sure they conform to Codable as well.
以下示例将 Child
属性添加到 Person
结构体中:
struct Person: Codable { var name: String
var age: Int
var address: String
var children: [Child]
}
struct Child: Codable {
var name: String
var age: Int
}
Child
结构体遵循 Codable 协议的事实使得 Person
结构体也遵循 Codable 协议。
让我们回顾一下关于 Codable 协议的一些问题!
“在使用 Codable 协议时,你是如何处理可选属性的?”
这个问题为什么重要?
可选属性在处理 Codable 时是必不可少的,因为它在需要与 API 一起工作时非常有用。有时(或者说,经常),返回的数据可能不包括我们在结构体中定义的所有属性。
熟悉 Codable 在常见 API 用例中的工作方式,可以证明你对它的理解。
答案是什么?
要处理可选属性,我们只需将它们声明为可选,使用 ?
参数即可。
让我们以之前示例中的 Person
结构体为例:
struct Person: Codable { var name: String
var age: Int?
var address: String?
}
我们可以看到 address
和 age
属性都是可选的。如果我们没有在 API 响应中收到这些值,它们将保持为 nil。
另一方面,如果一个值没有被标记为可选,并且没有设置默认值,那么如果相应的键在 JSON 响应中不存在,将会抛出异常。
“你是如何使用 CodingKeys 枚举将 JSON 对象中的键映射到自定义数据类型的属性中的?”
这个问题为什么重要?
CodingKeys 枚举允许我们自定义编码/解码过程中使用的键。
一个关于 CodingKey 的好答案表明我们完全理解 Codable 协议的工作方式,并在需要时可以处理更复杂的解析。
答案是什么?
Codable 将键映射到属性的方式是通过使用它们的名称。如果我们以 Person
结构体为例,name
属性将被映射到 JSON 结构中的 name
键,因为它们具有相同的键名。
然而,我们可以通过定义自定义映射来使用 CodingKey 枚举轻松地自定义它。
我们需要在符合 CodingKey 的结构体下创建enum
并定义一个新的映射值。
看看以下示例:
struct Person: Codable { var name: String
var age: Int
var address: String
enum CodingKeys: String, CodingKey {
case name = "full_name"
case age
case address
}
}
Person struct has a name property, but that property is now mapped to a full_name key that will appear in the JSON.
这里是带有full_name
键的 JSON:
{ "full_name": "Avi Tsadok",
"age": 42,
"address": "Hamargalit Street
}
使用 CodingKeys 可以帮助我们处理我们结构体和需要解析的数据之间的不同命名。然而,当将一种数据类型转换为另一种数据类型时,CodingKeys 并不能提供帮助。在这种情况下,我们有一个解码器。让我们看看一个与这个问题直接相关的问题。
“如何在 Codable 中将格式化的日期字符串转换为日期对象?”
为什么这个问题重要?
当与 API 一起工作时,除了使用String
或Double
之外,没有其他方式来表示日期值。
如果我们想要一个包含日期值的结构体,我们需要一种方法将Double
或String
值转换为日期对象。
我们需要理解这些类型的用例并不罕见。
有更多示例,我们需要将 JSON 对象中已有的值转换为我们的结构体中的另一种类型。以下是一些这些示例:
-
将 JSON 值转换为枚举。
-
创建嵌套对象
-
处理可选属性中的默认值
最终,解析 API 响应是 iOS 开发者的一项常见且重要的职责,在这个领域的熟练度是必不可少的。
什么是 答案?
我们使用几个特性来将基于字符串的日期转换为日期对象。第一个是 CodingKey,用于映射键和属性,正如我们在上一个问题中学到的。其次,初始化using init(from decoder: Decoder)
结构体,最后使用DateFormatter
将字符串转换为日期对象。
让我们看看一个例子。这是Person
属性列表:
struct Person: Codable { var name: String
var age: Int
var address: Address
var birthday: Date
enum CodingKeys: String, CodingKey {
case name
case age
case address
case birthday
}
enum Address: String, Codable {
case home
case work
}
这是init(from decoder:)
函数:
init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy:CodingKeys.self)
name = try container.decode(String.self,forKey: .name)
age = try container.decode(Int.self, forKey: .age)
address = try container.decode(Address.self,forKey: .address)
// Decode birthday using a custom date formatter
let dateFormatter = DateFormatter()dateFormatter.dateFormat =
"yyyy-MM-dd"
if let birthdayString = try? container.decode(String.self, forKey:
.birthday) {
birthday = dateFormatter.date(from:birthdayString) ?? Date()
} else {
birthday = Date
这是一段值得注意的代码!
在面试中,提供关于如何实现本节中提到的先前功能的详细解释是很重要的。
当我们想要根据序列化数据创建结构体时,会调用init(from:)
。
使用这个方法,我们可以进入接收到的数据的键容器,并将它们映射到结构体属性上。这为我们解析更复杂的数据结构提供了完全的灵活性。
在我们继续之前,有一个关于 Codable 的重要注意事项:从技术上讲,我们“不必”在我们的项目中使用 Codable。还有其他解决方案可以解析和序列化对象和结构体。但在现代 Swift 开发中,Codable 是 API 管理和数据存储的主要参与者;因此,它是面试中必须讨论的主题,我们需要确保我们完全准备好了。
Codable 协议非常适合结构体,但它不是管理复杂数据结构的唯一选项。字典是组织不同类型和结构数据的灵活方法,并且在面试中经常被使用。
准备与字典和集合相关的面试问题
字典和集合都是高度有效的数据结构,能够实现快速存储、检索和数据操作。特别是,字典能够以键值格式存储数据,并且它们也是编码和解码复杂数据结构的基础。
作为一名 iOS 开发者,在我们的项目中有一些可以使用字典的场景:
-
快速查找:由于字典使用键值来存储数据,因此它们非常适合快速保存和检索数据。这使得字典非常适合保存用户账户、设置列表、缓存数据等。
-
计数和频率跟踪:当字典键代表项目的类型,而值是该类型的实例数量时,我们可以用它来跟踪单词、项目等的频率。
-
编码和解码复杂数据结构:字典非常灵活,我们可以存储几乎任何数据结构。此外,字典以 JSON 的形式存在,因此它们适合编码和解码 API 请求和响应。
-
配置数据:字典的键值性质使它们非常适合存储配置数据。
尝试将这些用例作为面试中关于字典问题的基础。了解字典的主要优势和用法可以轻松帮助我们通过与字典相关的问题。
从存储和快速检索数据的角度来看,集合与字典相似,但集合不使用字典那样的键值结构。相反,它们是唯一值的列表,可以通过O(1)
的时间复杂度访问,这使得它们在许多用例中非常高效的数据结构。
实际上,将集合与数组进行比较更为合适。如果我们不需要我们的项目列表排序或包含重复项,那么在大多数情况下,集合是比数组更好的选择。
就像字典一样,让我们看看一些集合的使用场景:
-
从数组中删除重复项:你还记得使用集合从数组中删除重复项的例子吗?这是集合的经典应用。
-
快速成员测试:集合有助于确定某个特定值是否已经被使用。集合中的contains函数比数组更高效,在许多情况下都很有帮助。
-
实体之间的关系:集合是创建两个实体(如用户和产品)之间关系的理想数据结构,因为它们不需要排序或重复。这就是为什么集合是核心数据中“多对一”关系的默认选择。
现在,我们来看看一些与集合和字典相关的面试题。
“你能使用字典来存储配置数据吗?”
为什么这个问题很重要?
如前所述,字典是存储数据键值对的宝贵工具,配置数据是这种数据结构的一种常见用例。这个问题涉及到创建一个“配置管理器”类,该类使用字典作为其主要数据结构,并设计一个接口来存储和检索字典中的值。这个任务结合了多种编码技能,包括创建类和实现字典数据结构。
这里有一个很好的技巧!
通常来说,作为一个 iOS 开发者,这是我们日常工作和面试中遇到的一种模式。
让我们再次回顾一下这个模式:
-
决定适合任务的数据结构
-
在类中封装数据结构
-
设计并创建一个简单的接口来处理该数据结构
遵循这些指南是许多答案的关键!
答案是什么?
这是一个很好的代码示例,用于在字典中存储配置数据:
class Configuration { static let shared = Configuration()
private var values: [String: Any] = [:]
func setValue(_ value: Any, forKey key: String) {
values[key] = value
}
func value(forKey key: String) -> Any? {
return values[key]
}
}
// Setting configuration values
Configuration.shared.setValue("Dark", forKey: "theme")
Configuration.shared.setValue(true, forKey: "enable_notifications")
通过查看代码,我们可以看到我提到的三个原则 – 数据结构(字典)、包装类(Configuration
)和接口(setValue()
和 value()
)。这将是一个完美的答案。
“你是如何使用 filter 方法根据条件从字典中选择键值对子集的?”
为什么这个问题很重要?
这个问题测试了我们操作字典数据的能力。为了做到这一点,我们需要完成两个任务 – 遍历 字典值并创建一个 新字典,根据某些条件存储一些值。
这个操作在许多不同类型的程序中都很有用,因为它允许你只关注与特定任务相关的键值对。例如,你可能用它来选择只包含在测试中获得“A”级的学生键值对,或者只包含在文本中频繁使用的单词键值对。
答案是什么?
解决这个问题有几个方法 – for
循环、过滤和遍历字典键。
第一种解决方案 – for 循环
for 循环解决方案遍历字典的键和值,并执行简单的 if-then
检查以填充一个新的过滤字典,如下所示:
var wordFrequencies: [String: Int] = ["apple": 4, "banana": 3, "cherry": 2, "date": 1]
var highFrequencyWords: [String: Int] = [:]
for (word, frequency) in wordFrequencies {
if frequency >= 3 {
highFrequencyWords[word] = frequency
}
}
print(highFrequencyWords) // prints ["apple": 4, "banana": 3]
记住使用 for
循环遍历字典的语法:
for (key, value) in dictionary { // code to be executed for each key-value pair
}
这在其他问题中也会很有用。
第二种解决方案 – 使用 filter() 方法
filter
方法解决方案被认为比 for
循环更 优雅,因为它更易读且优化。此外,它将两个操作(遍历和过滤)合并为一个方法。
让我们看看如何使用 filter 方法执行相同的任务:
let wordFrequencies: [String: Int] = ["apple": 4, "banana": 3, "cherry": 2, "date": 1]
let highFrequencyWords = wordFrequencies.filter { $0.value >= 3 }
print(highFrequencyWords) // prints ["apple": 4, "banana": 3]
看看这里我们使用了多少更少的代码。这就是专业人士的做法!
第三种解决方案 – 遍历键
filter 中的条件不一定基于字典的值;它也可能存在于键中。在这种情况下,我们需要遍历键并应用过滤条件。
以下代码过滤了相同的字典,但这次它创建了一个以 a
开头的果名字典子集:
var wordFrequencies: [String: Int] = ["apple": 4, "banana": 3, "cherry": 2, "date": 1]
var highFrequencyWords: [String: Int] = [:]
for (word, frequency) in wordFrequencies {
if frequency >= 3 {
highFrequencyWords[word] = frequency
}
}
print(aWords) // prints ["apple": 4]
这个解决方案使我们能够为更多潜在的使用案例和问题执行更强大的字典过滤。
“常见集合操作(如插入元素或检查成员资格)的时间复杂度是多少?”
为什么这个问题很重要?
操作的 时间复杂度 是数据结构问题中的热门话题,尤其是在我们讨论集合时。
如果你对这个术语不熟悉,是时候赶上进度了!作为 iOS 开发者,我们做出的一个决定是为正确的任务选择合适的数据结构。
使用集合而不是数组的一个关键优势是其操作效率,例如插入元素和检查成员资格。因此,当处理缓存、避免重复项等情况时,我们将选择集合。
答案是什么?
常见集合操作的时间复杂度如下:
-
将一个项目插入到集合中具有平均时间复杂度 O(1) 和最坏情况时间复杂度 O(n)
-
对于检查成员资格也是如此——平均时间复杂度为 O(1) 和最坏情况时间复杂度为 O(n)
集合在常见操作中可以有平均时间复杂度 O(n),因为在某些情况下集合可能会聚集,时间复杂度取决于集合的大小。
无论如何,集合在插入和检查方面都比数组更高效。
“在 Swift 的集合集合中是否可以存储任何类型的数据?”
为什么这个问题很重要?
这个问题评估了我们对于 Swift 集合数据结构和数据类型哈希过程的理解。
与原始数据类型(如 Int
或 Double
)一起工作很简单。但其他类型,尤其是我们定义的类型,如结构体或类,则不是这样。
因此,理解如何使用 Hashable 协议与集合数据结构对于 iOS 开发者至关重要。
答案是什么?
只要符合 Hashable
协议,就可以在集合集合中存储任何类型的数据。Hashable
协议允许自定义类型生成一个唯一值,该值需要存储在集合和字典数据结构中。
让我们看看如何将名为 Person
的结构体存储在集合中的示例:
struct Person: Hashable { var age: Int
func hash(into hasher: inout Hasher) {
hasher.combine(age)
}
}
let newSet: Set<Person> = [Person(age: 21),
Person(age: 35), Person(age: 49)]
在 Swift 中处理集合和字典时,理解 Hashable
协议至关重要,因为它决定了值和键在这些集合中的存储方式。
摘要
在本章中,我们讨论了数据结构的重要性以及为什么它们在面试中如此重要。我们涵盖了结构体和类、数组、Codable 协议、集合和字典。
这是一个关键章节,我们现在对数据结构了解得更多了!
然而,那只是一个热身,因为在下一章中,我们将讨论 iOS 面试中的另一个关键主题——Swift 语言本身。
第五章:Swift 编程语言
正如我们在第四章中讨论的那样,理解数据结构是任何开发者,无论他们使用的是哪个平台或语言,都至关重要的复杂技能。数据结构是计算机科学编程和算法的基础,掌握它们对于开发者成功至关重要。现在我们已经对数据结构有了坚实的理解,是时候转向 iOS 开发的重要方面:Swift。
Swift 是 iOS 面试中一个非常热门的话题,它不仅是一种 iOS 开发者的编程语言,也是苹果公司新框架和技术的核心基础。
因此,理解 Swift 的主要特性,如结构体、属性包装器、泛型和更多内容,对于在 iOS 开发中取得成功和通过面试至关重要。Swift 与苹果最新技术之间的紧密关系使得对语言的深入理解对任何 iOS 开发者来说都至关重要。
在本章中,我们将学习关于可选类型、访问级别和闭包的内容。我们还将回顾计算属性和懒加载属性、扩展、泛型、错误处理、协议和内存管理问题。
因此,我们将涵盖以下主题:
-
我们如何掌握所有 Swift 特性?
-
基本 Swift 特性
-
高级 Swift 语言特性
确保我们对主要语言特性有良好的掌握是 iOS 面试过程中取得优异成绩的关键。但我们应该如何确保我们在知识和理解上全面覆盖呢?我们将在本章中看到。
我们如何掌握所有 Swift 特性?
首先,阅读本章将帮助我们了解 iOS 技术面试中面试官询问的大多数重要 Swift 特性。
但这还不够。
要成为一名真正的专业人士,我们必须开始像专业人士一样行事。
例如,阅读官方 Swift 文档是确保我们覆盖了最新的 Swift 增强功能的绝佳开始。我们将通过回顾访问级别、错误处理和扩展来确保我们覆盖了基础知识。但不要将 Swift 仅仅视为一种编程语言。一些特性是通过深入思考和有趣的方法开发的。
理想情况下,我们不应该仅仅通过记忆技术文档来回答 Swift 面试问题——面试官更希望听到我们的思考、最佳实践和建议。
让我们用扩展来解释这个想法。
对于“你能告诉我关于 Swift 扩展的内容吗?”这个问题的一个典型回答可能是,“Swift 扩展允许我们向现有的类 或结构体 添加功能。”
虽然这个回答并不算错误,但它仍然非常技术性。试着深入思考:
-
我们为什么需要扩展?
-
扩展如何帮助我们编写更好的代码?
-
哪些用例使得扩展如此强大?
一个更好的回答可能是以下内容:
Swift 扩展是一个强大的工具,允许开发者向现有的类、结构体、枚举和协议添加新功能。它们通过将相关功能分组在一起来组织代码,使其更容易阅读和维护。它们还增加了代码的可重用性、可读性和可测试性。
当然,我们必须确保我们完全理解扩展,以便表达这类答案。
我们下一步将是我们刚刚学到的关于扩展的知识,并将其应用于其他主题,例如可选类型、协议、泛型和 Swift 的其他特性。这正是我们将在本章中做的。
我们是否需要了解所有 Swift 特性?答案是肯定的。我们是否需要非常出色地了解所有特性?强烈推荐,但我们可以在对某些特性没有专业知识的情况下通过一些面试。
正因如此,我将 Swift 特性分为两个层次:基础和高级。
让我们从一些基本语言特性开始,比如可选类型、访问级别和闭包。
基础 Swift 特性
对 Swift 的基本概念有一个扎实的理解是至关重要的,因为这些领域的知识不足可能会给 iOS 开发者带来重大问题,更不用说求职面试了。
回答可选类型问题
变量类型后的 ?
。
这里有一个例子:
var name: String?
在上一行代码中,name
可以包含一个值或 nil。
解包可选类型并提取其值的一个简单方法是使用 if let
语句:
var name: String? = "Avi"if let unwrappedValue = name {
print("The unwrapped value is: \(unwrappedValue)")
} else {
print("The optional was nil")
}
if let statement safely “extracts” the value from the unwrappedValue variable and provides an else statement in case it is nil.
注意,自 Swift 5.7 以来,可以更优雅地解包,同时保持可选名称不变:
var name: String? = "Avi"if let name {
print("The unwrapped value is: \(name)")
} else {
print("The optional was nil")
}
if let
简写使解包变得更加简单,因为它不需要我们创建与可选类型同名的新变量/常量。
现在,让我们转向一些面试问题。
“你能举一个例子说明你会在代码中使用可选类型的情况吗?”
为什么这个问题很重要?
这个问题测试了我们对于 Swift 可选类型的实际理解。因为可选类型是一个广泛使用的特性,它涉及到 API 接口设计、函数声明和控制流,面试官需要看到我们是否正确理解了如何使用它。
答案是什么?
我们可以在一些日常情况下在我们的代码中使用可选类型。以下是一些例子:
-
函数声明中的可选参数:
func checkPerson(name: String, age: Int, address: Address?) -> Bool
-
处理函数可能返回 null 的情况:
func getParentViewController() -> UIViewController?
-
使用结构体中的可选类型处理 JSON 响应中的缺失数据:
struct Person { var name: String var age: Int var address: String?}
我们应该在理解我们可能不会收到值并收到 nil 的任何地方使用可选类型。
“列出你了解的所有解包可选类型的方法”
为什么这个问题很重要?
解包可选类型有几种方法。这并不意味着它们都是彼此的替代品——每种方法都解决不同的用例。了解大多数方法和它们的用例显示了我们在代码中优雅且有效地解包可选类型的能力。
答案是什么?
让我们来看看解包可选的一些方法:
-
使用if let语法来执行带有未包装值的代码块:
if let value = optionalValue { // Do something with the unwrapped value}
-
使用可选链来避免多个if let语句:
if let country = person.address?.country { print("The person lives in \(country).")} else { print("The person's address is unknown.")}
-
使用guard let来设置停止条件,如果值是 nil 则退出作用域:
guard let value = optionalValue else { return}// Do something with the unwrapped value
-
使用!运算符。如果我们确定可选包含一个值,则强制解包:
let value = optionalValue!// Do something with the unwrapped value
-
使用空合并(??)来提供默认值:
let value = optionalValue ?? defaultValue
没有一种解包值的首选方法。这完全取决于控制流和情况。
“使用强制解包会在 nil 的情况下崩溃我们的应用。那么我们为什么要使用它?”
为什么这个问题很重要?
这是一个在面试中经常被问到的问题。以下是一段代码:
let value = optionalValue!
如果optionalValue
为 nil,我们会得到一个异常。那么,我们为什么要使用那个方法呢?
这个面试问题实际上并不是关于可选的——它是关于我们管理代码中异常的能力,并在需要时崩溃应用。
答案是什么?
最直接的答案可能是,“当我们确定值不是 nil 时。”以下是一个例子:
var maybeString: String?maybeString = "Hello"
if let unwrappedString = maybeString {
// If the optional has a value, print it
print(unwrappedString) // Output: Hello
print(maybeString!) // force unwrapping
}
但这个答案并不完整。为什么我们甚至要接近maybeString
变量,即使我们只是解包了它?
如前所述,这个问题测试了我们使用可选来管理异常的能力。有些情况下,可选必须包含一个值。否则,程序无法继续。
一个流行的例子是将IBOutlet
声明为强制解包:
class ViewController: UIViewController { // Declare an IBOutlet
@IBOutlet var label: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
// Force unwrap the label outlet
label.text = "Hello World!"
}
}
如果label
为 nil,我们会得到一个异常。一般来说,我们不希望我们的应用崩溃,但在这个情况下,崩溃表明我们的程序设置有误——我们可能断开了那个出口,甚至从故事板中移除了它。
另一个很好的例子是在cellForRow
方法中强制转换UITableViewCell
。尽管这是一个转换操作,但它与可选相关,因为转换的结果是一个可选值,我们强制它成功。
如果这个转换失败,我们的程序就不再相关,因此我们将使用强制转换:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell
(withIdentifier: "customCell", for: indexPath) as!
CustomTableViewCell
// configure the cell using the properties and methods
of the custom class
return cell
}
总结来说,强制解包不是一个常见的技巧,但在某些情况下,它可能很有帮助。
解决访问级别问题
起初,访问级别问题看起来像是一个小主题。从技术上讲,它确实是一个小主题。学习和记住不同的访问级别相当直接。
问题始终是,我们是否正确地使用了访问级别?
当一个关键字代表访问级别时,它们会影响代码封装、可见性、项目和组织的可读性。
访问级别还影响我们简单应用组件之间的接口看起来如何。
我们应该了解不同的级别代表什么,以及它们对我们项目结构意味着什么。
“Swift 中有哪些不同的访问级别,以及它们的用例是什么?”
为什么这个问题很重要?
这个问题被认为是一个筛选问题,其目的是确保我们在继续探讨这个主题的更高级问题之前,理解 Swift 中的基本访问级别。
一个筛选问题
筛选问题是一个面试官提出的问题,以确保我们符合该职位的最低资格,并且具备该角色的基本知识。经验丰富的开发者可能会觉得这些问题很奇怪——但我们应该记住,面试官之前并不认识我们。我们应该对这些问题保持谨慎,并确保它们不会成为我们面试的陷阱。筛选问题也被称为“基本”或“核心”问题。
答案是什么?
有五种不同的访问级别:
-
公开 —— 被标记为公开的实体可以被任何其他模块,包括其他框架访问和子类化。当我们希望允许我们的类或方法被子类化或重写时,使用此级别。
-
公开 —— 使用公开,我们允许实体可以从任何其他模块或框架访问,而无需子类化它。有时,由于向后兼容性或安全性,我们不希望其他用户子类化我们的类,使用公开是一个确保这一点的绝佳方式。
-
内部 —— 当我们希望我们的实体在同一模块内可访问但不在外部时,应使用内部访问级别。将实体标记为内部不是强制性的——如果我们没有明确定义它,这是默认访问级别。但在库中,将类明确标记为内部是一个最佳实践。
-
fileprivate —— 被标记为 fileprivate 的实体在同一文件内可访问。当我们有一个名为 A 的类,并且我们想要添加另一个仅与类 A 相关的类时,这被使用。如果我们将这两个类都写在同一个文件中,fileprivate实体将确保这个约束。
-
私有 —— 私有方法和变量仅对同一类或结构体(封装声明)可访问。使用私有,我们可以隐藏实体外的代码实现。
“访问级别如何影响代码组织和可读性?”
为什么这个问题很重要?
就像上一个问题一样,作为 iOS 开发者,我们不应该将访问级别视为技术特性。访问级别极大地影响了我们的代码组织和视图。事实上,在某种程度上,访问级别在我们的代码文档中扮演了一定的角色,因为它描述了哪些方法是接口的一部分,哪些方法是实现的一部分。
答案是什么?
访问级别通过将其分离为接口和实现来影响代码组织。例如,让我们看看以下Game
类:
class Game { private var gameOver: Bool = false
public func restart() {
gameOver = false
// Other restart logic here
}
}
我们可以看到Game
类有一个gameOver
属性,它是被声明为private
的,还有一个restart()
方法,它是public
的。我们理解gameOver
是被隐藏的,不能直接从类外部修改。唯一改变它的方法是通过使用restart()
方法,这引出了我的主要观点——可读性。
通过查看Game
类,我们可以立即看到停止游戏只有一个方法:调用restart()
。它们可以安全地忽略任何其他私有方法或变量,因为它们只用于实现。如果gameOver
不是私有的,那么从外部修改它是可能的,而不需要调用restart()
方法中正在进行的必要步骤。
简而言之——访问级别解释了如何使用类或结构体,并将它们很好地分离为接口和实现。
处理关于闭包的问题
闭包取代了 Objective-C 中曾经使用的 Blocks,并且在 Swift 开发中被广泛使用。但我将其归入基本 Swift 特性,是因为闭包已经成为许多高级 Swift 特性的基本组成部分。它被用作完成处理程序、高级集合类型函数、SwiftUI 和 Combine。如果不熟悉闭包,可能会影响我们作为 iOS 开发者快速移动和实现高级功能的能力。
“你是如何使用闭包来处理 iOS 中的回调的?”
这个问题的意义是什么?
我选择从这个问题开始,因为回调和异步操作是许多 Swift 应用程序中如何使用闭包的典型例子。与代理不同,闭包可以使异步任务看起来简单,并且始终处于上下文中。
答案是什么?
闭包作为参数传递给函数,可以在稍后执行。假设异步操作基于代理或任何其他机制,其响应超出了函数的作用域。在这种情况下,我们可以通过将闭包保存到实例变量中,并在完成任务时调用闭包来处理这个依赖关系。
这里有一个代码示例来解释这一点:
class SomeClass: SomeDelegate { var completion: ((Bool) -> Void)?
Func startAsyncOperation(completion: @escaping ((Bool) -> Void)) {
self.completion = completion
// Start async operation
NetworkManager.shared.performAsyncOperation (delegate: self)
}
func operationDidFinish(success: Bool) {
self.completion?(success)
}
}
protocol SomeDelegate: AnyObject {
func operationDidFinish(success: Bool)
}
现在,让我们看看如何使用闭包而不使用任何代理:
let someObject = SomeClass()someObject.startAsyncOperation { success in
if success {
print("Async operation succeeded")
} else {
print("Async operation failed")
}
}
在前面的代码块中,我演示了如何将代理封装在SomeClass
中,并仅暴露一个在异步操作结束时运行的闭包。这种模式在调用startAsyncOperation
时为开发者提供了一个更清晰的接口。
“你能解释一下 Swift 中的闭包捕获语义如何导致保留循环以及如何避免它们吗?”
这个问题的意义是什么?
这个经典的面试问题是初级开发者在与闭包一起工作时常见的陷阱。
在 iOS 开发中,主题之间相互关联,即使我们处理的是闭包而不是内存管理。
闭包很强大,但如果我们使用不当,它们可能会产生内存泄漏并影响我们的应用性能。
这个问题测试了我们对于闭包工作原理的理解。它检查我们在创建和调用闭包时,在应用程序内存中会发生什么,以及如何处理作用域。
答案是什么?
闭包通过 强引用 从周围的作用域捕获变量和常量。这些常量中可能包含持有闭包本身的对象,这可能导致 保留周期。
看看以下代码:
class SomeClass { let someProperty = "property value"
var closure: (() -> Void)?
func setupClosure() {
closure = {
print(self.someProperty)
}
}
}
let someObject = SomeClass()
someObject.setupClosure()
我们可以看到 SomeClass
对 closure
有一个强引用,closure
打印 someProperty
,这要求 closure
对 SomeClass
(即 self
)有一个强引用。
避免保留周期的最简单方法是将 self
声明为 weak
引用,并通过这种方式解开保留周期:
class SomeClass { let someProperty = "property value"
var closure: (() -> Void)?
func setupClosure() {
closure = { [weak self] in
guard let self else { return }
print(self.someProperty)
}
}
}
我们也可以使用 unowned
而不是 weak
,但这是一种危险的方法 – 闭包可能仍然存在,而 self
被释放,这可能导致异常。然而,在某些情况下,使用 unowned
而不是 weak
是安全的,并且这可以从我们类之间的关系中推导出来。一个很好的例子是 Country
类和 CapitalCity
类。一个国家有一个对其首都城市的引用,而首都城市可以有一个对其国家的 unowned
引用。我们理解首都城市的生命周期与其国家的生命周期是一致的,因此,它不能在没有其国家的情况下存在。因此,在这种情况下使用 unowned
引用将更加实用,如果发生异常,则表明代码实现中存在错误。
这里有一个代码示例,演示了在 Country
类和 CapitalCity
类之间使用 unowned
:
class Country { let name: String
var capital: CapitalCity?
init(name: String) {
self.name = name
}
deinit {
print("\(name) is no longer a country.")
}
}
class CapitalCity {
let name: String
unowned let country: Country
init(name: String, country: Country) {
self.name = name
self.country = country
}
deinit {
print("\(name) is no longer a capital city.")
}
}
在 CapitalCity
和 Country
之间有一个 unowned
引用确保我们避免了保留周期,同时仍然保持类之间的引用。
现在我们已经了解了基本的 Swift 特性,我们正在转向更高级的 Swift 特性,以确保我们在这方面有所覆盖。
高级 Swift 语言特性
通常,面试官喜欢用 Swift 特性开始,检查不同的语言方面,并试图找到我们可能对 Swift 存在的任何红旗。
在本节中,我们将探讨 Swift 的更多高级特性,从计算变量和懒变量开始。
解决计算变量和懒变量问题
计算变量和懒变量都是 Swift 变量的高级特性,提供了提高性能和代码可读性的有效方法。
首先,让我们明确计算变量和懒变量是什么:
- 计算变量 – 一种根据其他属性计算其值的变量,不将其值存储在内存中,每次访问时都会重新计算
在以下 Rectangle
类中,area
是一个基于 width
和 height
值的计算变量:
class Rectangle { var width: Double
var height: Double
var area: Double {
return width * height
}
}
- 懒变量 – 当它首次访问时,其初始值只计算一次的变量
以下代码示例解释了什么是懒变量:
class ExpensiveObject { // Some expensive initialization
}
class MyClass {
lazy var expensiveObject = ExpensiveObject()
}
expensiveObject
变量只有在第一次访问它时才会初始化。我们可以看到变量声明前缀了lazy
关键字,使其变为懒加载。
许多 iOS 开发者很少使用计算属性和懒变量,大多数情况下,原因是对其缺乏理解以及过早的优化。
现在,让我们深入探讨我们的第一个问题。
“你会在什么情况下使用计算属性而不是存储属性,反之亦然?”
这个问题为什么重要?
这是一个深思熟虑的问题,它有助于测试我们如何将理论应用于实践的理解。在性能和准确性方面,计算属性和存储属性都有其优缺点,因此这个问题超出了仅仅技术考虑的范围。
答案是什么?
当需要每次访问属性时都计算值时,会使用计算属性。计算属性通常使用其他属性来计算其值。一些例子包括日期格式化、矩形的面积,或基于其他属性(如名和姓)的全名值。
另一方面,存储属性是从类的外部根据用户输入或其他事件存储和更改的——例如,用户名、配置值等。
计算属性具有更动态的特性。它们不断被计算,因此更准确。缺点是它们在许多情况下效率较低,尤其是在值倾向于变化时。
计算属性和存储属性之间存在紧张关系。存储属性在性能方面非常出色,但我们需要维护它们的数据准确性。计算属性则相反——它们总是准确的,但需要不断计算。
“你如何使用懒变量来提高加载大量数据的应用的性能?”
这个问题为什么重要?
懒变量对性能和内存消耗非常重要。这个问题测试了我们使用懒变量优化应用和 UI 加载的能力。
答案是什么?
懒变量可以通过延迟数据的初始化直到实际需要时来提高我们应用的性能。加载一个对象始终被视为一项繁重的任务,因为运行时环境需要初始化对象及其属性。因此,需要初始化和加载大量数据的变量可能会影响被加载对象的加载时间(以及内存消耗)。如果有可能将数据加载推迟到以后,那么可以提高对象的加载时间。
这是一个懒加载代码的例子:
class MyData { lazy var largeData: [String] = {
// load large data from a file or remote API
return loadLargeData()
}()
private func loadLargeData() -> [String] {
// perform the expensive operation to load the
large data
// here we just return an array of string but it
could be some large data
return ["large","data","loaded"]
}
}
let data = MyData()
// the largeData is not loaded until this point
print(data.largeData)
从前面的代码中我们可以看到,largeData
变量可能需要一些时间来初始化,所以我们将其声明为lazy
。当我们分配data
时,largeData
还没有被分配,直到我们使用print
命令调用它。
解决扩展问题
我们讨论的一些特性与代码组织相关。例如,访问级别不仅仅是技术限制;它们也是组织我们的代码和声明接口部分以及封装部分的一部分。
在那个领域,另一个重要的特性是扩展。
Swift 中的扩展有几个重要的角色:
-
扩展允许我们向现有的类、结构体和枚举添加新功能,而无需修改它们的源代码
-
扩展可以帮助我们分组相关功能,提高我们的代码可读性和组织性
-
扩展用于向类型添加协议符合性,使它们的接口与其他符合同一协议的类型对齐
我们可以看到,Swift 语言中有多少扩展是必不可少的,因为它们在我们的日常 iOS 开发中被广泛使用。
尽管扩展功能强大,但它们的使用和理解却毫不费力。这就是为什么我们必须对这个主题做好充分准备,因为任何错误都可能在我们面试官面前拉响红灯。
现在,让我们转到我们的第一个问题。
“你能否使用扩展向结构体或类添加新属性?”
这个问题为什么重要?
这个问题看起来像是一个简单的肯定/否定问题,但现实情况是它隐藏了面试官希望听到的两个更深的理解层次。
首先,他们想听到实际层面——在扩展中什么可行,什么不可行(即完整答案)。
但其次,这是一个额外的要求,他们想听到为什么扩展会以这种方式工作。这将展示我们如何深入理解 Swift 的内存使用。
别担心,我们会在答案中涵盖这两个层次。
答案是什么?
简短的答案是“不”,我们不能在扩展中添加存储属性。但值得一提的是,我们可以添加计算属性。原因是我们可以向类型添加新功能,但不能修改其内存布局,这可以暗示我们使用扩展可以向类型添加什么/不能添加什么。
对于这个问题,有几个解决方案——包装原始类型或使用全局变量来存储属性值,但基本思路是一样的。
现在是答案的“额外”部分:一个类型的内存布局是在编译时确定的,并嵌入到二进制文件中。这意味着我们不能使用扩展即时添加新的存储属性,因为它们将改变之前设置的内存布局。将这个事实添加到答案中将在面试中给我们额外的分数!
“你能否使用扩展向协议添加方法?如果是,怎么做?”
这个问题为什么重要?
这是一个棘手的问题。协议不是类型。扩展协议就像为符合协议的类型添加新功能。困惑吗?这就是为什么这个问题棘手……让我们看看答案来澄清一下。
答案是什么?
是的,可以扩展一个协议。扩展协议为所有符合该协议的类型添加了新功能,允许我们为协议方法添加默认实现。
让我们看看扩展协议的代码示例:
protocol MyProtocol { // existing protocol requirements
}
extension MyProtocol {
func newMethod() {
// implementation
}
}
我们可以看到,MyProtocol
扩展添加了一个新方法:newMethod()
。这个新方法可以在所有符合 MyProtocol
的类型中使用。让我们继续代码示例来解释这一点:
struct MyStruct: MyProtocol { // existing struct properties and methods
}
let myStruct = MyStruct()
myStruct.newMethod()
现在应该更清晰了,因为 myStruct
可以调用 newMethod()
,即使它没有在原始协议声明中定义。
解决泛型问题
泛型是 Swift 特性,允许 iOS 开发者编写可重用的代码,这些代码可以与任何类型的数据一起使用。
对于 iOS 开发者来说,泛型尤为重要,因为它们可以用来编写可重用且类型安全的代码。这意味着开发者可以在应用程序的多个地方使用相同的代码,而无需担心类型转换或其他类型相关的问题。此外,泛型可以帮助防止运行时错误,并通过允许编译器在编译时优化代码来提高性能。总的来说,泛型是强大的工具,可以帮助 iOS 开发者编写更健壮和高效的代码。
现在,让我们看看一个可以与任何类型一起工作的数组反转方法的例子:
func reverseArray<T>(arr: [T]) -> [T] { var reversedArr: [T] = []
for i in stride(from: arr.count - 1, through: 0, by: -1) {
reversedArr.append(arr[i])
}
return reversedArr
}
let numbers = [1, 2, 3, 4, 5]
let reversedNumbers = reverseArray(arr: numbers)
// reversedNumbers is [5, 4, 3, 2, 1]
let words = ["apple", "banana", "cherry"]
let reversedWords = reverseArray(arr: words)
// reversedWords is ["cherry", "banana", "apple"]
理解这段代码最重要的地方是这一行:
func reverseArray<T>(arr: [T]) -> [T]
reverseArray()
方法接收一个特定类型的数组,并返回一个数组。这可能就是泛型的核心概念——不仅仅是创建可重用的代码,而且还要保持类型安全并避免类型转换问题。
“你能给出一个使用泛型解决的问题的例子吗?”
为什么这个问题很重要?
与之前的问题一样,这个问题通过将一个理论话题与如何在实际生活中使用它的例子相结合来挑战我们。
与其他 Swift 特性相比,如果不通过实际问题和解决方案来了解,泛型的优势就难以理解。
答案是什么?
缓存类是使用泛型解决问题的一个绝佳例子。如果我们想要缓存数据,我们需要为每种想要缓存的数据类型创建一个单独的类,或者在某个抽象类中创建不同的方法。
在这种情况下,泛型让我们可以为不同类型重用相同的代码:
class Cache<T> { private var cache = [String: T]()
func set(value: T, for key: String) {
cache[key] = value
}
func get(for key: String) -> T? {
return cache[key]
}
}
这就是如何使用 Cache
类与 Int
:
let cache = Cache<[Int]>()cache.set(value: [1, 2, 3, 4, 5], for: "numbers")
let cachedNumbers = cache.get(for: "numbers")
// cachedNumbers is [1, 2, 3, 4, 5]
缓存是一个很好的例子,因为它不需要我们转换返回的类型。我们可以初始化一个新的缓存实例,每次都使用不同类型的数据。
“如何在泛型协议中使用关联类型?”
为什么这个问题很重要?
关联类型是 iOS 开发者很少使用的一个特性,但我仍然想为它们专门提出一个问题。原因是它可以给你一个更好的泛型在 Swift 中的使用和示例的概览。许多 iOS 开发者很难找到泛型的实际用例,所以从一个不同的角度提供一个例子可能会帮助你准备面试。
答案是什么?
关联类型实际上是协议的泛型。它们与类和结构体使用泛型的方式相同。
要使用关联类型,我们需要在协议中使用关键字 associatedtype
来定义它:
protocol DataSource { associatedtype Data
func fetchData() -> Data
}
DataSource
协议包含一个 Data
关联类型,但它没有指定它将使用哪种类型。我们在协议实现中这样做。
例如,这是一个使用 Int
作为 Data
的 DataSource
的实现:
struct LocalDataSource: DataSource { typealias Data = [Int]
func fetchData() -> [Int] {
}
}
当然,其他结构体或类可以通过在 Data
类型别名中定义它来实现该协议,这使得这个协议更加灵活和可重用。
解决错误处理问题
错误处理是每种语言和平台中一个基本的话题。它使我们能够对意外的事件或条件做出响应(当我们这样考虑时,它们就变得“可预期”)。
当我们讨论工作面试时,错误处理和 Swift 是很有趣的——首先,当我们从 Objective-C 转移到 Swift 时,这个领域有了巨大的改进。然而,在不同的 Swift 版本之间,它也发生了巨大的变化。考虑到你阅读这本书的时候,错误处理可能已经发生了更多的变化,所以看看它是值得的。
此外,Combine 和 SwiftUI 的日益流行使得错误处理变得更加普遍。我们可以有信心地说,错误处理是 Combine 数据流的一个基本部分,如果我们对这个领域感到不安全,现在是赶上来的时候了!
“你如何在 Swift 中使用 try? 和 try! 操作符进行错误处理?”
为什么这个问题很重要?
try?
和 try!
是处理错误更简洁的操作符。
解释这两个操作符之间的区别并在我们的代码流中实现它们是很重要的。
答案是什么?
而不是使用 do-catch
流,我们可以使用 try?
操作符来绕过它,类似于我们解包可选值的方式:
let result = try? someThrowingFunction()if result != nil {
// Use the result
} else {
// Handle the error
}
在这个代码示例中,我们使用 try?
操作符包裹对 someThrowingFunction()
的调用。结果是可选值——如果函数抛出异常,返回的值将是 nil。
然而,try!
与强制解包非常相似。如果函数抛出异常,我们的程序将被终止:
let result = try! someThrowingFunction()
注意,我们应该谨慎使用 try!
,并在函数抛出异常时没有继续程序的意义的情况下使用它。
“你能解释并给出一个例子说明你如何在 Swift 中编写一个会抛出错误的函数吗?”
为什么这个问题很重要?
许多开发者都知道如何执行基本的 do-catch
块,主要是因为在很多情况下这是必需的。
向前迈出的自然一步是我们自己执行抛出操作。知道如何编写抛出异常的函数表明了对 Swift 的错误处理机制的深刻理解。
答案是什么?
要实现一个抛出异常的函数,我们需要做三件事情:
-
第一件事是在其声明中添加 throws 关键字:
func readFile(at path: String) throws -> String { … }
-
第二件事是在出现问题时有一个可以 抛回的错误:
enum FileError: Error { case fileNotFound}
-
第三件事将是实现并 在出现异常时抛出错误(完整的代码如下):
enum FileError: Error { case fileNotFound}func readFile(at path: String) throws -> String { guard let data = FileManager.default.contents (atPath: path) else { throw FileError.fileNotFound } return String(data: data, encoding: .utf8) ?? ""}
记住抛出函数的这三个基本组成部分将帮助我们有效地掌握这个函数。
如果你仍然对错误处理感到不安全,尝试回到你的一个项目中,并在你认为相关的位置添加错误处理。没有什么比实际经验更能处理你不太关心的主题了。
解决协议问题
计算机科学中最重要的原则之一是 关注点的分离。为了实现这一点,我们想要做的事情之一是减少代码不同部分之间的耦合——解耦对象和类。
协议在这个任务中扮演着重要的角色,使我们的代码更加灵活和可重用。在现代 iOS 开发中,协议是开发的基础部分,我们可以在几乎每个 API 和 SDK 中找到它们被大量使用。
“你能解释一下在 iOS 开发中使用协议导向编程的用途吗?”
为什么这个问题很重要?
尽管这是一个开放性问题,但在面试中很常见。也许因为它是一个开放性问题,面试官喜欢问它,这样他们就可以了解候选人的思考方式。
协议就像烹饪中的调料——技术上,它们很容易使用。问题始于使用多少和何时。
我们应该对此问题有所准备,这也是传播我们关于协议在代码编写中作用的观点的机会。
答案是什么?
关于 协议导向编程(Protocol-Oriented Programming,POP)的第一件事是要理解它是一种编程范式。这意味着 POP 是一组用于组织和结构化我们代码的指南和规则。
POP 的主要思想是对象通过协议相互通信。这使得我们的代码更加灵活和可重用,因为不同类型可以实施不同的行为,并且通过遵循相同的接口仍然可以与其他对象一起工作。
POP 与 面向对象编程(Object-Oriented Programming,OOP)一起工作,并不取代它。
“你如何在你的 iOS 应用中使用协议?”
为什么这个问题很重要?
这个问题将理论话题(协议)转移到实际考虑和权衡的世界中。面试官不希望听到二分法答案,而更希望听到一个更深刻的解决方案,涉及我们对 Swift 开发的看法。
答案是什么?
我先从底线开始:我们不应该总是使用协议。我们只应该在它有效且不会使我们的项目比现在更复杂时使用它们。我们应该谨慎行事,并根据以下因素:
-
接口可重用性 – 如果我们想在不同的类型之间重用特定的接口。
-
抽象 – 协议通过定义一组由不同对象使用的方法和属性,为我们代码提供了另一层抽象。
-
依赖注入 – 我们可以使用协议将依赖项注入到类中,从而使其更具灵活性和可测试性。
总结来说,当我们需要更多灵活性和减少代码耦合时,协议是一个很好的解决方案。
另一方面,协议可以为我们的代码增加一层复杂性,在类之间添加一个虚拟层。这在编程中是一个预期的权衡 – 复杂性 与 耦合性。
解决内存管理问题
对于 iOS 开发者来说,内存管理自 iOS 开发初期以来一直是一个关键问题。
我必须说,随着时间的推移,事情变得更好了——苹果增加了自动引用计数(ARC),调试工具变得更好,硬件也发生了巨大变化。
话虽如此,在讨论资源管理时,效率仍然是至关重要的。
准备好在你下一次 iOS 技术面试中回答有关该主题的一些问题。
“iOS 中强引用和弱引用的区别是什么?”
这个问题为什么重要?
强和弱引用是我们在 Swift 中内存所有权概念的核心组成部分。
所有权是 ARC 的关键,这是 iOS 中内存管理机制的基础,如果我们不了解该机制的工作原理,我们就会走上创建内存泄漏和保留循环的道路。
答案是什么?
答案相当简单:强引用(除非另有定义,否则默认为强引用)是一种指示一个对象被一个或多个元素在内存中持有的方式。相比之下,弱引用允许对象在不再需要时被释放。
强引用会将引用计数增加一,而弱引用则完全不改变引用计数。
弱引用的一个很好的例子是一个 委托模式。
让我们看看一个例子:
class ViewController: UIViewController { var delegate: ViewControllerDelegate?
}
protocol ViewControllerDelegate: class {
func didTapButton()
}
class AnotherViewController: UIViewController,
ViewControllerDelegate {
weak var viewController: ViewController?
override func viewDidLoad() {
super.viewDidLoad()
viewController = ViewController()
viewController!.delegate = self
}
func didTapButton() {
// Perform some action
}
}
在这个代码示例中,我们可以看到ViewController
对其delegate
对象有强引用,而delegate
对象对其ViewController
有弱引用。这种安排的原因是为了避免保留循环,从而增加我们的应用内存使用。
“如何在 iOS 中处理低内存警告?”
为什么这个问题重要?
这个问题旨在评估我们对资源管理的理解。有些情况下,收到低内存警告是可以接受的,但问题是当它发生时我们应该如何处理。控制我们应用中的资源使我们能够适当地管理和应对这些情况。
答案是什么?
当我们收到低内存警告时,我们需要做的一件事是释放任何不必要的资源并减少应用的内存占用。
这里有一些减少我们应用内存占用的例子:
-
释放缓存数据
-
使用 autorelease pools
-
使用弱引用
-
释放未使用的资源,例如屏幕外的视图
-
使用 NSCache
那就是深入挖掘我们过去项目的内存,并尝试回忆起在收到低内存警告时可以释放的资源。
摘要
在这一章中,我们讨论了 Swift 开发中的许多主题,包括基础和高级内容。我们涵盖了可选类型、访问级别、闭包、计算属性和懒加载属性、扩展、泛型、错误处理、协议和内存管理。
这些有很多主题!但另一方面,我们是有经验的 Swift 开发者,所有这些都需要熟悉。
如我之前所说,我们是优秀的 iOS 开发者,并且对工作非常了解。我们只需要组织我们的知识,以便为我们的面试做好准备。
我们接下来的章节将讨论不仅仅是 Swift 的内容——我们将讨论代码管理。
第六章:管理您的代码
在第五章中,我们介绍了 Swift 语言的必要方面。在本章中,我们将探讨更多与 iOS 开发相关的话题,例如 UIKit 和各种框架。
准备 iOS 面试主要围绕 Swift、UIKit 和编码。显然,这是因为这些都是 iOS 开发的核心。但成为一名 iOS 开发者远不止于此。
根据我的经验,知道如何规划任务、解决/测试复杂错误和记录工作的开发者是真正的专业人士,无论他们的代码质量如何。我认为这就是优秀开发者和真正的专业 iOS 开发者之间的区别。
在良好且有效的招聘过程中,这些技能作为经理面试或家庭评估阶段的一部分进行测试,我们应该为它们做好准备。与其他大多数主题不同,高质量工作的 原则 在这里至关重要。
本章将涵盖管理我们项目以下四个主要主题:
-
我们将确保我们有能力 规划 我们的项目和功能,并了解如何开发技术文档。
-
我们将涵盖 测试,不仅包括单元测试,还包括集成和性能测试。我们还将了解编写可测试代码的含义。
-
我们将介绍针对不同类型问题的 调试 技巧。
-
我们将回答一些关于 文档 的问题——如何正确注释以及如何作为更大团队的一部分处理文档。
让我们从本章我认为的必要部分开始:规划。
规划
规划和设计是开发者(尤其是高级开发者)的两个关键方面。许多人认为“规划”是一种估算交付日期的方法,但交付日期实际上只是故事的一小部分。
规划背后的真正故事是深入细节。在我看来,规划等同于学习。当我们规划时,我们会研究我们的任务,试图理解以下内容:
-
我们能否 理解产品需求 并将其转化为任务?
-
我们与其他团队/开发者的 依赖关系 是什么?
-
我们需要执行哪些 额外研究?我们需要一个 概念验证(POC)吗?
-
哪些任务会变得复杂,哪些任务会变得简单?
-
我们是否处理了 边缘情况?我们能否定义它们?
当我们规划时,我们会考虑我们可能遇到的不同方面和挑战。因此,“规划”远不止估算;它实际上是一个学习过程。
成为专业人士意味着在开始之前就学习我们的任务。根据我的经验,并非所有面试都强调这一点。在许多招聘过程中,规划甚至不是面试的一部分。
即使没有明确要求,我也建议提出这一点。我们希望引导面试官将对话引向我们想要展示我们优势的地方。
“你如何创建项目时间表并规划 iOS 应用程序的开发过程?”
为什么这个问题很重要?
与这本书中我们将找到的许多问题类似,这个问题也没有明确的正确或错误答案。但面试官并不关心答案的细节。他们的主要目标是评估我们的经验、组织技能以及我们对开发过程观点。
在 第二章 中,我们讨论了“软技能”。规划和时间管理确实是面试官希望看到的必要软技能。一个理解如何规划、挑战和依赖关系、管理时间并优先处理任务的 iOS 开发者是团队的一大补充。
答案是什么?
无论我们被要求提供哪些细节,我们都需要确保在回答中突出以下三个要点:
-
展示我们的经验:提供例子、方法和经验教训是展示我们理解和过去处理挑战的好方法。
-
展示我们理解开发过程:任务管理、估算、资源和优先级都是可以帮助我们制定良好计划的工具的例子。
-
展示我们能够协作:在开发项目中工作涉及大量的协作。我们需要强调我们并非独自工作。协作意味着任务所有权、协作技术设计、审查、依赖关系,以及与设计师、QA 团队、产品经理和后端开发者合作。
为什么这些步骤至关重要?因为在大多数情况下,我们是在加入一个现有的团队和项目。了解良好的和健康的过程是什么样的至关重要。
现在,让我们深入了解项目规划流程的标准框架:
-
理解项目的需求、目的和受众。
-
将项目分解 为小而可管理的任务:设计、编码和测试。
-
为每个任务提供开发 估算 和结果:时间表。
-
在不同的团队成员之间分配 资源。
-
监控 进度以确保我们按时完成。
-
测试 和部署。
我们需要将这个问题视为一个机会,将我们的观点和经验带到桌面上,同时采用标准流程并将其分解为我提到的三个关键点。
“你如何估算任务的开发时间?”
为什么这个问题很重要?
时间估算对于开发者来说是最具挑战性的任务之一,我们如何处理任务估算显示了我们的经验。
开发时间估算既是软技能也是硬技能。
一方面,它需要广泛的技术知识,了解开发挑战和风险,将它们分解成更小的任务并管理它们。
另一方面,我们需要良好的沟通技巧来与其他队友合作,出色的时间管理技巧,以及分析可能影响我们时间表依赖关系的能力。
总的来说,我们选择开发专业的 iOS 应用程序,这是一项复杂的任务……
答案是什么?
这个答案与关于项目时间线的上一个答案相似。不同之处在于,在任务估计中,更容易进入细节并了解风险。
让我们回顾一下我们需要经历的各个阶段:
-
理解需求。
-
将其分解成小任务。
-
评估每个子任务的复杂性,并考虑任何潜在的风险和挑战。如有需要,研究这些风险或甚至创建一个 POC 来理解。
-
考虑依赖关系,以确保我们不会被阻止继续我们的开发过程。
-
为审查和精炼结果添加更多时间。
我认为第三点可能是最重要的。当一个开发任务没有达到其估计目标时,主要是因为我们没有考虑到的风险。设置标准用户界面(UI)屏幕时很难出错,但未知问题正是使我们的任务延误的原因。
“你如何为 iOS 任务创建一个技术设计文档?”
为什么这个问题重要?
这是一个从前两个问题延续下来的问题。我们开始于项目规划和任务时间估计,现在我们试图了解如何设计单个开发任务。
技术文档的创建封装了我们处理技术任务的专业知识。即使我们通常不编写技术文档,从技术角度设计功能的过程对于开发者来说也是至关重要的。
把建议的答案放在一边——尝试回顾你过去使用过的技术文档,并检索导致该输出的过程。将你的经验带入讨论是最好的答案,因为它将更容易解释。
是什么答案?
技术文档包含以下主题:介绍、需求、架构、流程、数据模型、测试和部署。
创建它的以下步骤如下:
-
计划和调研:收集有关项目的信息,包括需求和约束。
-
概述文档:写出章节和子章节,确保我们没有遗漏任何重要主题。
-
描述需求:详细说明需求。
-
讨论系统架构:详细描述系统架构。写下涉及该功能的各个不同层。
-
定义数据模型:描述实体和不同的 API 调用。
-
指定用户界面:包括屏幕、UI 组件和导航流程。
-
概述测试策略:包括测试类型、工具和目标。
-
讨论部署过程:包括 Beta 发布、A/B 测试和应用程序版本。
重要的是要说明,我详细说明的大多数步骤(如果不是所有步骤)与 Swift 没有直接关系——这是这个问题的优点之一。规划是一种语言和平台无关的任务,你可以找到很多人帮助你理解如何处理技术文档设计,即使你以前没有做过。
测试
测试是 iOS 开发者的重要组成部分。它确保我们的代码库的质量和可靠性,并提高我们对项目的信心水平。
面试中关于测试的问题远不止技术层面。编写单元测试是一项简单的任务,可以快速学会。但测试经验展示了 iOS 开发者不同的一面。它提供了我们管理可靠和健康代码库的方法。
回答以下问题:
-
我们在修复错误后是否编写测试?
-
测试我们的代码有多简单?
-
测试在部署过程中的角色是什么?
成为专业人士不仅仅意味着编写好的代码,还要很好地维护它。
与本书中的大多数主题不同,没有一些经验很难获得测试知识。我建议你选择你的一个项目并编写一些测试。在面试之前,你需要亲自感受一下。
“在 iOS 开发的背景下,单元测试和集成测试的意义是什么?”
为什么这个问题很重要?
测试领域充满了不同的术语和方法。定义单元测试和集成测试是至关重要的,因为它们代表了不同的用例和覆盖范围。
这个问题旨在了解我们是否有足够经验理解不同的用例。
答案是什么?
单元测试检查单个组件的行为,而集成测试检查不同组件如何协同工作。
在投入更多时间进行单元或集成测试时,考虑应用上下文是至关重要的。
例如 - 单元测试主要检查如下逻辑函数:
import XCTestclass MyClassTests: XCTestCase {
func testExample() {
let myClass = MyClass()
let result = myClass.doSomething()
XCTAssertEqual(result, 42)
}
}
我们可以看到,前面的代码块测试了一个特定的逻辑函数。它不关心其他应用组件,如网络、核心数据或应用状态,而是将范围隔离到函数本身。
另一方面,集成测试检查涉及多个层和功能的用例,例如网络请求:
import XCTestclass MyAppTests: XCTestCase {
func testExample() {
let apiClient = APIClient()
let user = User(username: "testuser", password: "secret")
apiClient.login(user) { (error) in XCTAssertNil(error)
let data = apiClient.fetchData() XCTAssertNotNil(data)
}
}
}
上述代码展示了我们如何测试项目中的两层如何协同工作。网络层可以很好地工作,apiClient
的本地功能也是如此。但当它们协同工作时,我们可能会遇到问题,这就是集成测试。
大多数 iOS 应用需要集成测试而不是单元测试,因为大多数功能处理用户交互和不同的应用层与逻辑代码。这是关于集成和单元测试平衡的一个基本见解。
“如何在 iOS 应用中执行性能测试?”
为什么这个问题很重要?
性能测试在 iOS 开发中不如单元测试或集成测试常见,并且被认为是一个更高级的话题。这个问题旨在评估你在测试方面的知识深度和经验。设置一个性能测试来了解它是如何工作的是个好主意,但不需要有丰富的经验。
答案是什么?
设置性能测试比听起来要简单得多。我们首先需要做的是选择我们想要测量的函数或代码块。
其次,我们需要编写一个测试函数来执行那个代码块,通常需要多次运行。为什么我们需要多次运行它?因为我们更容易用大数字来衡量测试运行。如果一个测试运行大约需要 3-4 毫秒,那么衡量随时间的变化将很困难。但如果我们运行它 100 次,那么衡量函数代码中任何小的调整将容易得多。让我们看看性能测试的样子:
import XCTestclass MyClassPerformanceTests: XCTestCase {
func testPerformanceExample() {
let myClass = MyClass()
measure {
for _ in 0..<1000 {
let _ = myClass.doSomethingExpensive()
}
}
}
}
XCTest
框架使用平均运行时间作为基准,每次运行测试时,XCTest
都会将结果与该基准进行比较。
性能测试的一个缺点是它们的取决于运行它们的设备。最佳实践是确保性能测试在同一设备上运行,最好是实际设备而不是模拟器。
“你能解释一下什么是可测试的代码吗?”
为什么这个问题很重要?
当然,我们现在正在讨论测试,但可测试的代码远不止是为我们的代码库准备测试——它关乎编写我们能够轻松维护和解耦的代码。
在这个问题中,面试官喜欢看到我们理解是什么使得代码可测试,确保我们的代码可以轻松维护。
答案是什么?
编写可测试的代码意味着编写旨在使其容易编写单元、集成和性能测试的代码。
在实践中,可测试的代码意味着以下内容:
-
保持关注点分离的原则:当我们的代码库的每个部分都有一个单一、明确的职责时,测试一个用例就更容易,无需要求多个层或类。
-
确保松耦合:高耦合使得独立测试一个组件变得困难。
-
设计我们的对象以便于模拟:干净、明确定义的接口可以帮助我们的对象容易被模拟。一个很好的例子是使用协议导向编程(POP)。
-
使用更纯的函数:纯函数是没有共享状态或副作用的功能。它们以一种使它们非常容易测试的方式进行隔离。
这里有一个可测试代码与非可测试代码的例子。我将从可测试版本开始:
class Calculator { func add(a: Int, b: Int) -> Int {
return a + b
}
}
现在是非可测试代码版本:
class Calculator { func calculateResult() -> Int {
let a = UserDefaults.standard.integer(forKey: "a")
let b = UserDefaults.standard.integer(forKey: "b")
return a + b
}
}
在第一个例子中,我们可以看到add()
方法接受两个参数,并仅使用它们来返回一个结果。
然而,在第二个例子中,我们看到一个依赖于两个UserDefaults
键的函数,每次运行它可能都会返回不同的结果。在可测试的代码中,我们希望每次运行测试都能得到相同的结果,所以这不是一个很好的可测试代码示例。
这就是一个可测试代码与非可测试代码片段的绝佳例子。
测试是面试的重要组成部分,比几年前更加重要。原因是测试不是一个单独的主题——它们代表了一种整体的开发方法,包括代码设计和维护。测试是我们追求完美的一个明显优势。
调试
有句名言说:
“编程不是关于写代码写得最好,而是关于调试代码最好。”
这句话听起来很奇怪,对吧?但当我们思考时,我们花费了很多时间调试我们的(或他人的)代码。
我们的调试技能有时可以决定一个阶段是持续几天还是几周。
开发者在准备面试时犯的一个错误是只关注代码编写,而忽略了调试。但调试是我们作为开发者拥有的最重要的工具之一,我们可以在面试中期待至少一到两个调试问题。
“你能解释一下如何在 iOS 应用程序中调试内存泄漏吗?”
为什么这个问题很重要?
首先,让我们先了解一下什么是内存泄漏。内存泄漏并不意味着应用程序内存使用量高——这是一个普遍的误解。
内存泄漏意味着应用程序为某物分配了内存,然后停止使用它,但内存空间没有被释放。
结果可能是高内存使用,但增加的内存使用并不表示存在内存泄漏。
内存泄漏很难调试,但它们指向低效的资源消耗,并可能导致应用程序终止。这就是为什么内存泄漏调试是 iOS 开发中的一个重要主题。
答案是什么?
话虽如此,内存泄漏很难调试。幸运的是,有许多方法可以解决这个问题:
-
Instruments:Instruments 是 Xcode 附带的一个强大工具,可以帮助我们分析应用程序的不同方面,包括内存分配和泄漏。它是一个高级工具,可以分析特定对象,记录其保留/释放操作,甚至指导我们到代码库中的特定位置。
-
内存图调试:这是一个相对较新的功能,目前还没有很多人了解。使用内存图调试,可以在任何一点停止运行并查看活动对象列表及其之间的关系。它还可以突出显示它识别为内存泄漏的部分,并指出原因。
-
NSZombie:NSZombie 是一个工具,它允许我们在对象被释放之前检测和跟踪它们。
-
deinit 函数:在某些情况下,我们可以在对象的 deinit() 函数中添加打印语句或断点。deinit() 函数会在对象被释放之前被调用。这是一种简单且有效的方法来检查对象是否泄漏,而不需要启动外部和复杂的工具。
-
内存仪表盘:我们可以定期使用 Xcode 的内存仪表盘来查看内存是否被释放并且不会持续增长。这是一个很好的迹象,我们应该进一步使用列表中的其他工具来调查问题。
如我们所见,iOS 中调试内存泄漏的方法和工具有很多。一些是用来 监控我们的内存 消耗的,而一些则非常先进,帮助精确地检测 泄漏发生的位置和时间。这些工具的组合为我们提供了完美的工具集,以跟踪和修复内存泄漏。
“你能解释如何在 iOS 应用程序中调试 UI 相关的问题吗?”
为什么这个问题很重要?
到目前为止,大多数问题都只涉及 Swift,而没有涉及框架相关层的上下文。但是,随着我们书籍的推进,我们会发现 iOS 开发中不仅仅有 Swift。UI 是 iOS 开发者另一个主要话题,作为其中的一部分,调试被认为是一项极具挑战性的任务。如果你使用过 UIKit,你可能在你的职业生涯中调查过 UI 相关的问题。以下答案为你组织了这些内容。
答案是什么?
“UI 相关”是对问题的一个广泛定义。一些问题与 UI 生命周期相关,一些与用户交互相关,还有一些与布局和动画相关。
因此,答案可以分为三个部分:
-
检查布局:我们有四种主要的方式来调试我们的 UI 布局:
-
调试视图层次结构:调试视图层次结构是 Xcode 内置的一个工具,它允许我们在运行时以一个漂亮的 3D 视图来调试我们的布局,显示屏幕的不同层次,使我们能够检查每一层的属性,包括颜色和布局。这是理解 UI 如何组织、Auto Layout 方程机制如何工作以及层次结构是什么的一个很好的方法。
-
打开辅助功能检查器:这个工具是 Xcode 开发套件的一部分,可以帮助我们从辅助功能的角度调试我们的视图。辅助功能检查器是一个不太为人所知的工具,它帮助我们检查应用程序处理辅助功能问题的方法。
-
为我们的视图着色:这是一种原始但有用的调试 UI 的方法。我们只需在代码中设置视图的背景或边框颜色,然后重新运行应用程序来检查结果。其他工具可能更先进,但为视图着色有时可以是一个非常快速和高效的调试方法,例如在动画和复杂的布局中。
-
模拟器调试工具:iOS 模拟器内置了用于我们的 UI 的调试工具,包括颜色视图和慢速动画。这些工具非常适合在运行时快速查看我们的布局。
-
-
生命周期调试:生命周期调试意味着调试我们屏幕的各个阶段——当它被创建、出现、推送以及更多。主要的方法是围绕断点和打印语句。我们可以在生命周期方法(如 viewDidLoad 和 viewDidAppear)中设置断点。另一个很好的建议是使用 日志来跟踪 UI 流程。跟踪这些日志可以帮助我们在开发期间调试,也可以在 QA 问题调试期间使用。我们还可以使用 Instruments 时间分析器来检查生命周期事件,以跟踪屏幕启动期间的调用。
-
用户交互调试:我们还可以使用 Accessibility 检查器、打印到控制台、添加断点,并使用 视图调试器来理解不同的层次结构和属性。
调试 UI 有很多方法!而且有原因——UI 调试需要经验和许多“试错”尝试,因此我们需要每一个可能的工具。
“你如何在 iOS 应用程序中调试性能问题?”
为什么这个问题很重要?
在今天的移动开发世界中,性能问题不像以前那样重要了。我们现在处理的是功能强大的设备,而且大多数情况下,我们的产品需求甚至远未接近挑战最弱小的机器。
恶劣的编码和设计可能导致令人烦恼的延迟和长时间等待的操作。在这些情况下,性能调试可以帮助我们快速定位问题。
这个问题测试了 iOS 开发者所需的关键技能集,因为用户期望应用运行快速且响应灵敏。它还检查了对 iOS 平台和不同调试工具的理解。
这个答案是什么?
在调试性能问题时,有几个理想的步骤可以采取:
-
重现性能问题:重新运行应用并确保我们可以快速重现问题。这根本不是一个显而易见的任务——因为我们还没有找到问题的根本原因,所以我们不能确定问题一定会被重现。
-
分析应用:使用 Instruments 时间分析器和/或 Core Animation 工具来检查应用并收集有关问题的信息。
-
分析信息:尝试对问题的原因做出假设。
-
执行修复或更改:实现某种形式的解决方案。这不必是最终解决方案;它可以是某种临时代码修改,以隔离问题。
-
测试和验证:重新运行应用以查看是否有变化。如果需要,重新启动此过程。
注意,这些步骤仅是一个推荐的调试程序的概要,对于面试回答来说非常好。然而,我们应该注意,性能问题可能更加复杂,可能需要额外的或不同的步骤。
文档
技术面试中通常不包含文档问题,但它们可能是旨在深入了解我们作为开发者的面试的一部分。
然而,文档是 iOS 开发者不可或缺的一部分,当我们是团队的一员时,这部分会得到特殊的位置。
这里有一些为什么文档至关重要的原因:
-
更好的协作:文档使得多个开发者更容易在同一个代码库上工作,他们需要解释代码的较小部分。
-
提高代码理解:你知道写代码一周后回过头来看不懂自己当时为什么这样做的感觉吗?这在开发者中是常见的事情。文档不能消除这一点,但它可以极大地改善它。
-
入职新成员:这是一个关键点。向经验丰富的开发者解释某事是直接的,但向新团队成员做同样的事情要复杂得多。这也是文档在这里发挥重要作用的原因之一。
-
改进代码审查:一个“代码审查”是一个事件,其中没有编写代码的人试图阅读和理解它。不言而喻,记录它是至关重要的。
看到这个列表,我们可以理解为什么文档是一个强大的工具,尤其是在团队中,为什么它不是技术面试的一部分,而是“个性”审查的一部分。但文档是专业开发者不可或缺的一部分,所以最好准备好例子和观点陈述。
“你能解释一下你是如何记录你的 iOS 代码的吗?”
为什么这个问题很重要?
我们已经讨论了为什么文档对于 iOS 开发者来说是必不可少的。现在,面试官想看看我们处理注释和文档的技术和方法。
答案是什么?
iOS 代码文档的基础是通过在整个代码库中使用注释来完成的。
我们可以使用几种类型的注释来记录我们的项目:
-
一个“为什么”注释:这不需要我们解释我们做了什么,而是解释我们为什么这样做(这是开发者常见的错误)。这些注释应该放在我们的决策有理由但未反映在代码中的地方。这样的注释可以帮助其他开发者,同时我们也可以从中受益。以下是一个这样的注释示例:
let password = "secret_password_1234"// Use the hashValue property to get a unique identifier for the password stringlet passwordHash = password.hashValue
我们添加了一个注释,解释了我们为什么使用hashValue
而不是使用它的实际情况。
-
代码组织:注释的一个很好的用途是组织代码;一个常见的做法是使用“pragma mark”。Pragma mark 帮助我们将代码分成几个部分,使其更易于阅读和导航。让我们看看如何使用 pragma marks 来组织我们的代码:
// MARK: - Propertiesvar name: Stringvar age: Int// MARK: - Initializationinit(name: String, age: Int) { self.name = name self.age = age}// MARK: - Methodsfunc sayHello() { print("Hello, my name is \(name) and I am \(age) years old.")}
Xcode 知道如何读取这些类型的注释,并提供了一种轻松地在不同部分之间跳转的方法。
-
方法文档:我们可以使用代码生成注释来记录方法、属性和类。这些注释可以由 Xcode 或其他第三方工具自动生成,并成为我们项目文档的一部分。这可以通过使用 / / 标记轻松完成:
/** * Generates a random password of a specified length. * * @param {number} length - The length of the password to generate. * @return {string} The generated password. */function generatePassword(length) { // Implementation details}
将@params
和@return
信息添加到我们的函数声明注释中,为 Xcode(或其他相关工具)提供了自动生成类似我们在 Xcode 开发者文档中找到的文档的能力。
最后,理解不同类型的注释以构建一个良好的答案是很重要的。像“解释我做什么”这样的简短答案不是一个“专业”的答案,也不反映我们的专业知识。
值得注意的是,良好的方法、类和变量命名约定可以使我们的代码更加易于阅读和自我解释,从而减少对文档的需求。
“你能解释如何记录 iOS 开发中的设计模式和最佳实践吗?”
为什么这个问题很重要?
技术设计文档不是初级或中级开发者做的事情,而是高级开发者和技术负责人做的事情。
因此,这个问题的重要性取决于我们的面试角色。
此外,我们在技术设计文档中的专业知识可能基于我们当前和以前的工作场所。小型初创公司并不总是对技术文档有严格的要求,这是我们可以在回答这个问题时提到的事情。
答案是什么?
技术设计文档不仅仅是为了创建一个文档。它存在是为了回答以下问题:
-
功能的目的是什么?
-
有哪些替代方案?
-
我们为什么选择了这个解决方案?
-
详细说明首选解决方案。
现在我们有了这些问题在心中,我们理解技术文档反映了我们解决方案背后的思考过程,而不仅仅是描述它。
要记录一个功能,我们需要遵循以下步骤:
-
选择格式:为文档选择一个适合的格式,使其在不同功能中保持一致。
-
包含简介:解释功能的目标和它包含的内容。
-
讨论替代方案:解释解决该功能的不同方法和它们的权衡。
-
描述所选选项:描述在替代方案中的选择。
-
详细描述所选选项:提供代码示例、图表和流程图来解释我们所做的工作。
重要的是要注意,技术文档的格式和流程因地方而异,但理念保持不变。只要我们自信且理解地提供详细的答案,就足以通过这个问题。
“如何处理多个团队成员共同开发的代码的文档?”
为什么这个问题很重要?
为我们自己编写文档是直接的。我们很可能很容易与自己沟通…
当我们的代码文档需要服务于我们的队友时,真正的挑战才开始。
这个问题测试了当我们需要阅读和编写此类文档并维护它时,我们将代码文档视为团队的一部分的方式。
答案是什么?
对于这个问题没有神奇的答案,因为它取决于文化、项目和团队规模。
但一些最佳实践是有帮助的:
-
建立文档标准:团队必须就一些评论和文档格式指南达成一致。例如——如何进行评论?强调哪些类型的评论?如何解释设计决策?指南是管理团队文档的绝佳开始。
-
使用协作工具管理文档:许多协作工具可以帮助我们与队友共同工作在同一份文档上,包括评论和讨论。我们应该利用这些工具确保整个团队参与文档编写。
-
鼓励协作:这不仅仅是使用协作工具。如果我们希望团队中的每个人都参与进来,我们应该鼓励团队相互审查彼此的文档,并在代码审查中对其代码进行评论。记住——审查也是协作的一部分。
-
提供培训:对新团队成员进行如何评论和撰写文档的培训。培训不必成为负担——它可以简短,或者作为每周会议的一部分。跨团队协调对于确保我们的文档对每个人都是有效的至关重要。
遵循这些指南是确保整个团队以高标准和动力共同负责文档的绝佳开始。
摘要
在本章中,我们讨论了与编码不直接相关的话题,但对于 iOS 开发者来说,它们至关重要。记住,大多数移动团队规模较小——有时,一个团队只包括一名开发者,因此成为一名成熟和专业的开发者至关重要。
本章独特之处在于——它不是在谈论编码,而是考察质量和沟通。这些技能更难测试,甚至在面试中展示也更困难。但在有效流程的良好工作场所,这些话题会以某种方式出现。
在下一章中,我们将讨论 iOS 开发中可能最重要的框架:UIKit。没有关于该框架的问题,iOS 面试就无法结束。
第三部分:框架
在本部分,我们迈出了重要的一步,开始介绍我们用来构建优秀应用的各个框架。我们将讨论与 UIKit、SwiftUI、Combine 和持久内存相关的面试问题。此外,我们还将讨论 CocoaPods 和 Swift Package Manager。到本部分结束时,我们将熟悉主要的流行框架。
在本部分,我们包含以下章节:
-
第七章, 使用 UIKit 构建卓越的用户体验
-
第八章, SwiftUI 和声明式编程
-
第九章, 理解持久内存
-
w, 库管理
第七章:使用 UIKit 构建出色的用户体验
在第六章中,我们暂时放下了编码,讨论了那些使我们的代码演化的主题,如测试、调试等。现在,是时候回到我们热爱的事情上,而在 iOS 开发中,还有什么比构建一个出色的 UI 体验更令人喜爱呢?
对于大多数 iOS 开发者来说,UIKit 被认为是继 Foundation 之后最重要的框架,在 iOS 面试中,它是一个必考话题。
尽管 UIKit 是一个庞大的框架,但本章将涵盖 iOS 开发者所需的必要主题:
-
我们将回顾自动 布局系统
-
我们将讨论不同的UIView功能
-
我们将确保对UITableViews有深入的理解
-
我们将讨论UIViewController及其在我们应用中的作用
-
我们将深入导航的世界
-
我们将学习动画的基本概念
正如我所说——UIKit 是一个巨大的主题,还有很多其他内容,但我们专注于必须面试的问题。
我们将从驱动我们布局的东西开始,那就是自动布局系统。
回答关于自动布局的问题
UIKit 是一个庞大的主题,多年来,它已经变得更为重要,获得了越来越多的功能。
驱动屏幕上元素布局的引擎,苹果称之为自动布局(Auto Layout),这就是为什么我选择以这个主题为起点来开始 UIKit 章节。
自动布局是苹果技术,它定义了屏幕上不同元素之间的关系,极大地影响了我们快速进行 UI 开发的能力。我们可以这样说,掌握自动布局使我们能够在合理的时间内提供出色的 UI。
但这不仅仅是时间效率——自动布局可以帮助我们适应不同的屏幕尺寸或甚至平台(iPad与iPhone),它还可以帮助我们根据当前的本地化自动设置 UI 方向。
现在,我们将回顾一些最常见的自动布局问题。这不是 UIKit 章节的一个极好的开始吗?
“你能解释一下在自动布局中内容吸附和压缩抵抗是什么,以及它们是如何用来控制 UI 元素布局的吗?”
为什么这个问题很重要?
压缩抵抗和内容吸附是自动布局中的两个基本概念,它们定义了当视图的大小和布局发生变化时视图的行为。在这个阶段,面试官假设我们已经了解了自动布局的基础知识,并想看看我们如何处理更复杂的情况,即两个不同的视图在有限的区域内“争夺”空间以满足所有约束。
什么是 答案?
压缩抵抗和内容吸附是 UIView 的属性,当没有足够的空间来满足所有约束时,它们定义了布局行为。
让我们来看看这些术语对约束的含义:
-
内容紧缩:当内容紧缩被设置为高优先级时,视图希望沿着特定轴尽可能小。
-
压缩阻力:当压缩阻力属性被设置为高时,视图希望沿着特定轴尽可能大。
一个很好的例子来展示两个视图之间可能存在的冲突是一个 UIView(比如说UITableViewCell
)有两个子视图——一个引导标签和一个按钮(见图 7.1*):
图 7.1 – 带有标签和按钮的视图
查看图 7.1。1,我们可以看到一个可能的用例——标签和按钮都可以有简短的文字,因此它们的固有内容大小很小。如果这两个视图尝试根据其内容设置宽度,其中一个将不得不“放弃”并填充剩余的空间。为了确保按钮会尝试尽可能小,而标签会填充剩余的空间,我们需要相应地设置它们的内容紧缩和压缩阻力。让我们看看如何在代码中做到这一点:
class MyTableViewCell: UITableViewCell { @IBOutlet weak var label: UILabel!
@IBOutlet weak var button: UIButton!
override func awakeFromNib() {
super.awakeFromNib()
label.setCompressionResistancePriority (.defaultHigh, for: .horizontal)
button.setContentHuggingPriority (.defaultHigh, for: .horizontal)
}
}
为label
调用setCompressionResistancePriority
意味着当单元格水平调整大小时,自动布局系统将尝试保持标签的固有内容大小并防止其被压缩。
然而,为按钮调用setContentHuggingPriority
意味着当单元格水平调整大小并且有额外空间可用时,自动布局系统将尝试扩展button
以填充可用空间并防止其过度拉伸。
我们可以在代码和 Interface Builder 中非常容易地设置这些优先级。
有许多其他例子需要这种解决方案,例如页面标题的宽度与其对齐方式冲突,或者具有动态字体大小的复杂屏幕。
“你能解释一下如何在 Interface Builder 中使用尺寸类别来适应不同屏幕尺寸和方向的布局吗?”
这个问题为什么重要?
这个问题很重要,因为它通过使用自动布局并尝试将我们的布局适应于不同的大小和方向来测试我们的理解。
注意,我没有提到 iPad 或 iPhone——在讨论自动布局时,这些术语是不相关的。我们必须根据不同的尺寸级别全面考虑我们的布局,也就是所谓的尺寸类别。
答案是什么?
尺寸类别是一个功能,允许我们为各种屏幕尺寸创建一个 UI。屏幕尺寸可以是 iPhone 或 iPad,也可以是正在分割屏幕上展示的 iPad 应用,因此需要将其布局更改为 iPhone 应用。
我们今天有的类是紧凑型和常规型。紧凑型通常意味着在分割屏幕上的 iPhone 或 iPad 应用,而常规型意味着 iPad 应用。如前所述,我们不应该将这些类别视为 iPhone 与 iPad 之间的对比。尺寸类别允许我们无论应用设备如何都能以响应式的方式思考。
要在 Interface Builder 中使用尺寸类别,我们首先需要打开我们想要工作的故事板。然后,在 w Any h Any
类的右下角,这意味着布局将适用于所有设备和方向。我们可以从控制面板中选择另一个尺寸类别来为特定屏幕尺寸或方向创建不同的布局。例如,我们可以选择 w Compact h Regular
类来为竖屏方向的 iPhone 创建布局。
一个很好的用例是登录屏幕,在小型屏幕上,我们希望用户名和密码文本字段垂直布局,而在大屏幕上,我们可能希望它们水平布局。可以根据尺寸管理文本字段的布局。
不同尺寸类别中的不同值示例包括字体、Auto Layout 和固定大小。
“Auto Layout 中的安全区域有什么作用?你如何确保你的视图在安全区域内正确定位?”
这个 问题为什么重要?
当在不同设备上处理布局时,安全区域是一个重要的话题。每个 iOS 开发者都必须知道如何处理安全区域,它包含状态栏、传感器和现代 iPhone 的圆角。这个问题测试了我们与不同设备工作的经验,以及创建与设备类型无关的布局的能力。
答案是什么?
安全区域是 Auto Layout 中的一个功能,它提供了一个 布局指南,帮助我们定位屏幕上为传感器、圆角以及用户不应触摸的一般区域预留的区域上方和下方。
然而,我们可以在安全区域区域内定位非交互视图,例如视频或背景。尽管如此,我们必须考虑到 iOS 元素、传感器和屏幕圆角可能会部分覆盖这些视图。
为了确保我们将视图定位在安全区域之外,我们可以使用一个名为 safeAreaLayoutGuide
的属性。以下是一个使用 safeAreaLayoutGuide
将标签定位在顶部安全区域下方的示例:
NSLayoutConstraint.activate( myLabel.leadingAnchor.constraint(equalTo:
view.safeAreaLayoutGuide.leadingAnchor),
myLabel.trailingAnchor.constraint(equalTo:
view.safeAreaLayoutGuide.trailingAnchor),
myLabel.topAnchor.constraint(equalTo:
view.safeAreaLayoutGuide.topAnchor, constant: 30),
myLabel.heightAnchor.constraint(equalToConstant: 20)
我们可以看到 view
,即 UIViewController 的主视图,有一个名为 safeAreaLayoutGuide
的属性,而这个指南代表安全区域区的末端。这个指南相当于旧设备上的屏幕边缘,但在现代设备上,它意味着显示器的交互部分。
最佳实践是使用不同的设备检查你的布局,以确保它在所有显示设备上可用。
Auto Layout 是 UIKit 和 iOS 开发中的基本主题。没有与 Auto Layout 亲密合作就无法进入 UI 开发,UI 是 iOS 中的一个重要主题。如果你以 SwiftUI 开始你的 iOS 开发生涯,确保你熟悉 Auto Layout,至少是基本术语。
解决 UIView 问题
UIView 是 iOS UIKit 中用户交互的基本构建块。在本质上,它代表屏幕上的一个矩形,可以显示图形并处理用户交互和动画。
在我们讨论任何面试问题之前,理解 UIView 在 UIKit 中的作用及其与 CALayer 的关系至关重要。
让我们回顾一下 UIView 的主要功能:
-
管理子视图:UIView 可以包含额外的 UIView,称为子视图,这些子视图可以包含它们自己的子视图。这种能力使我们能够构建复杂的 UI 和可重用组件。UIView 还负责使用我们在上一章中讨论的自动布局系统处理其子视图的布局。
-
响应用户交互:UIView 另一个重要的角色是响应用户交互,这并非一个微不足道的话题。准备面试涉及学习响应者链,它处理与 UIView 层次结构的用户交互。
-
绘制图形:UIView 可以绘制图形:线条、形状、图像和文本。UIView 使用另一个名为Core Graphics的框架来完成这项工作,该框架负责使用CPU绘制基本图形。
那么,CALayer 呢?好吧,我们已经知道 UIView 可以使用 Core Graphics 进行绘制,但这并不是一个高效的方法。因此,它有一个 CALayer。CALayer 负责绘制 UIView 内部的内容,它使用Core Animation和设备的GPU。每个 UIView 都有一个主要的 CALayer,它可以有自己的子层。
CALayer 负责内容绘制,而 UIView 负责布局和用户交互。
“你能解释一下在 iOS 中响应者链是如何工作的吗?”
为什么这个问题很重要?
响应者链是 iOS UI 开发中的一个关键概念。这个概念讨论了多层屏幕中用户交互的管理。
这个问题至关重要,因为用户交互是 UI 开发中的一个关键主题,响应者链并不是一个一开始就能简单理解的概念。
答案是什么?
“响应者链”这个术语指的是用户触摸屏幕时的一种机制,每个 UIView 将触摸传递给其对应的子视图,直到其中一个视图做出响应。
让我们在图 7.2中探索这个问题:
![图 7.2 – iOS 中响应者链的示例图 7.2 – iOS 中响应者链的示例如图 7.2所示,触摸从UIApplication
开始,向下传递,直到它达到第一个响应触摸的视图,在这种情况下,是UITextField
。响应链“询问”每个视图是否是第一个响应者,通过调用becomeFirstResponder()
函数。这就是为什么直接在文本字段上调用becomeFirstResponder()
会弹出键盘,并使文本字段成为当前活动输入字段的原因。简而言之,响应者链是我们控制哪个视图在视图重叠时捕获用户交互的能力。还有更多情况下这很有用,比如透明视图或滚动视图。## “你如何在 UIView 中响应设备方向变化?”为什么这个问题很重要?在许多应用程序中,响应设备方向至关重要,因为它通过旋转设备为用户提供了一个可选的布局。但这并不是正确理解这个问题的真正原因。我们应该知道如何构建我们的 UI 以支持不同的屏幕比例,并根据新的方向调整布局和控制。这个问题测试了我们的灵活性和对激进布局变化的准备情况。答案是什么?处理设备方向变化需要从不同的角度来解决问题。让我们列出我们可以做的事情:+ 验证我们的 Auto Layout 约束:Auto Layout 是一种确保在更改屏幕边界后布局仍然可用的优秀技术。我们可以定义约束关系并限制视图大小或边距,以确保布局能够随着方向的变化正确更新。+ 动画变化:如果可能的话,我们应该对视图的变化进行动画处理,以向用户提供无缝且平滑的变化体验。+ 重写 willTransition(to:with)方法:在视图过渡到新大小或特性集合之前,willTransition(to:with)方法会被调用。这就是我们可以在 Auto Layout 已经更改的基础上修改视图外观的地方。例如,我们可以显示或隐藏子视图,更改文本或修改约束值。+ 更新布局:定位或重新排列视图,并在一般情况下,根据我们的设计和产品要求对布局进行更改。当然,这是根据我们的设计和产品要求来进行的。并非所有应用程序开发者都支持他们的产品中的横屏和竖屏状态,因为这更多的是一个设计决策而不是一个工程决策。但考虑到方向变化来构建我们的视图是一个好的实践。## “为什么 UIView 没有像 UIViewController 那样的‘viewDidAppear’方法?”这个问题为什么重要?我们还没有讨论视图控制器,但这是一个许多候选人难以回答的问题。这个问题旨在了解我们是否理解 UIView 与其视图控制器之间的关系。许多初级开发者会问自己这个问题,因为理解 UIView 的角色并不直观。更有经验的开发者应该更容易回答这个问题。答案是什么?UIView 没有像UIViewController那样的viewDidAppear
方法,因为 UIView 的主要角色是作为一个视觉组件,而不是处理生命周期事件。当我们回顾viewDidLoad
、viewWillAppear
和viewDidAppear
时。如果我们需要在生命周期事件中执行任务,例如执行网络请求、加载数据或设置状态,我们应该在视图控制器中执行这些操作并相应地更新视图。执行这些操作中的任何一项都不是 UIView 的角色。MVC 将在第十一章中详细讨论。## “你能解释一下 setNeedsLayout、layoutSubviews 和 layoutIfNeeded 在 UIView 中的区别吗?你会在什么情况下使用这些方法,它们如何影响布局过程?”为什么这个问题很重要?这三个 UIView 方法(setNeedsLayout
、layoutSubviews
和layoutIfNeeded
)讲述了 UIView 渲染周期优化的故事。这不仅仅是一个是/否的问题,更像是一个加分问题。在我的职业生涯中,我面试了数百名 iOS 开发者,其中大多数人无法完全回答这个问题,因为他们并不完全理解布局系统是如何工作的。正确回答这个问题将有助于我们在面试场合。什么是 答案?首先,让我们了解布局系统是如何工作的——UIView 在每次屏幕刷新率时刷新其子视图的布局(在 60Hz 中,它每 16.67 毫秒刷新一次),只有当它需要时。这意味着什么?例如,如果视图改变了它的 frame,它必须刷新其子视图的布局。它每 16.67 毫秒发生一次的原因是效率。如果我们在这 16.67 毫秒内多次更改 UIView 的 frame,它将只刷新一次其子视图的布局。现在,"刷新其子视图的布局"是什么意思?这意味着系统会运行layoutSubviews()
方法,我们可以覆盖它并执行我们喜欢的额外更改。我们现在明白,改变 UIView 的 frame 会将视图标记为“脏”,因此在下一次运行循环中,它将运行layoutSubviews()
。但我们不必改变它的 frame 或相关的约束来标记视图为脏。我们只需调用setNeedsLayout()
来确保视图将在下一个运行循环中更新其子视图。有时候,我们需要视图立即运行layoutSubviews
,而不必等待下一个运行循环。一个很好的例子是动画和约束变化。在这种情况下,我们可以调用layoutIfNeeded()
,这只会调用layoutSubviews()
,如果 UIView 被标记为脏。现在很清楚为什么我们从不直接调用layoutSubviews()
——系统会为我们更高效地完成这项工作,并且坚持自然过程会更好。正如我在本节开头所说——UIView 是我们的 UI 构建块,也是 MVC 和MVVM的基本组成部分之一。理解它如何工作,而不仅仅是添加和删除子视图,对我们 iOS 开发者来说至关重要,并且可以帮助我们影响我们的应用性能和体验。此外,本节中讨论的一些问题将在面试中被问到。现在,让我们转向 MVC 模式另一个重要的方面:UIViewController。## “frame 和 bounds 属性之间的区别是什么?”为什么这个问题很重要?尽管bounds
和frame
非常相似,但它们之间的区别对于理解布局系统的工作方式至关重要。这种区别在处理动画、定位和转换时尤为重要。答案是什么?简而言之,frame
属性表示 UIView 相对于其父视图坐标系统的位置和尺寸,而bounds
属性表示 UIView 相对于其自身坐标系统的位置和尺寸。以下是一个关于相同视图的bounds
和frame
属性的示例,该视图位于x:50 和y:100:+ 帧大小: + 原点:(x: 50,y: 100) + 尺寸:(宽度:200,高度:150)+ 边界: + 原点:(x: 0,y: 0) + 尺寸:(宽度:200,高度:150)我们可以看到原点不同,但尺寸相同。这是因为帧中的原点是相对于其父视图的。然而,需要注意的是,在某些情况下,帧和边界的大小可能不同。与表示视图在其自身坐标系统中的尺寸的bounds
属性不同,帧大小是在动画过程中计算和可能改变的。因此,在frame
和bounds
属性之间可能会观察到不同的尺寸值。虽然边界尺寸属性保持不变,但帧尺寸属性可以在动画或转换期间反映当前尺寸值。# 理解关于 UIControler 的一切在 iOS 开发中,UIControler 是一个核心类,它作为使用 UIKit 的大多数 iOS 应用的构建块。在 iOS 开发中,UIViewController 扮演着多个角色:+ 它是 MVC 模式中的 C:如果 UIView 是 V(视图)而我们的模型是 M,那么 UIViewController 就是协调表示层和业务层之间的那个。这个角色影响了许多与 UIViewController 相关的功能,例如生命周期事件和内存管理功能。+ 处理生命周期事件:我们在上一节中解释了 UIViewController 的这个角色。UIViewController 还有一个功能:管理屏幕上的各种生命周期事件。通过创建 UIViewController 的子类,我们可以利用其不同的方法来处理屏幕生命周期的所有阶段。+ 导航系统中的领先者:我们可以将 UIViewController 显示在另一个 UIViewController 之上,或者将其推入和从导航堆栈中弹出。因此,通过代表项目中的“屏幕”(注意——一个 UIViewController 并不等同于“屏幕”,但一个屏幕始终有一个根视图控制器),UIControler 在 iOS 应用导航中发挥着重要作用。+ 加载和卸载视图:通常,我们不会在没有处理它们的 UIViewController 的情况下在屏幕上显示视图。我们确实可以向应用程序窗口添加 UIView,但这只是一个特殊情况。将视图添加到窗口会带来许多问题,如生命周期管理、模型和数据链接等,这些都不被认为是理想的。我不确定作为 iOS 候选人,我们是否会遇到一个不涉及 UIViewController 问题的面试。## “你能按发生的顺序列出所有 UIControler 的生命周期事件或方法吗?”为什么这个问题很重要?这可能是面试官最常问的问题之一。这个问题并不难,而且也容易学习和完成。这个问题通常被认为是面试过程中的一个关键因素,因为表现不佳可能会引起大多数面试官的担忧。让我们理解一下原因——我们对 UIViewController 生命周期事件的了解会影响我们关于在哪里加载数据和释放数据、如何构建我们的 UI、执行动画以及为用户提供良好用户体验的决定。熟悉 UIViewController 生命周期对于处理用户交互和视图更新也很重要。我们需要确保我们的答案对此问题的回答没有遗漏。答案是什么?让我们按调用时间顺序列出生命周期事件:+ loadView(): 这是在视图层次结构创建之前被调用的。UIKit 在调用 loadView() 之前不会创建视图,因此当我们访问 UIViewController 的 view 属性时,会得到 nil。+ viewDidLoad(): 这是在视图加载之后被调用的。在那里我们可以对视图进行额外的设置,例如添加子视图和观察者。与许多其他生命周期方法不同,viewDidLoad 只会被调用一次。+ viewWillLayoutSubviews(): 这是在视图布局其子视图之前被调用的。我们可以在此时对约束进行额外的修改。+ viewDidLayoutSubviews(): 这是在视图布局其子视图之后被调用的。我们可以执行需要最终布局的任务。例如,定位视图、滚动视图内容大小和动画。+ viewWillAppear(): 这是在视图在屏幕上呈现之前被调用的。UIKit 会调用该方法一次或多次。如果需要,那是一个加载数据的好地方。+ viewDidAppear(): 这是在视图已经在屏幕上呈现之后被调用的。UIKit 会调用该方法一次或多次。通常,那是一个展示开始动画的好地方。+ viewWillDisappear(): 这是在视图即将从父视图控制器中移除或被模态视图控制器隐藏之前被调用的。我们可以在那里执行一些清理任务,例如停止计时器和动画、执行保存操作或停止媒体播放。+ viewDidDisappear(): 这是在视图从父视图控制器中移除之后被调用的。我们通常执行不会影响用户体验的任务——例如,记录日志、保存状态、清理临时文件和重置数据。强调一点,列表中的某些方法在不同的用例中可能会被多次调用。例如,当主视图的大小改变时,如方向改变,viewWillLayoutSubviews
可能会被调用。viewWillDisappear
方法可能在模型在视图控制器之上呈现时被调用。我们应该提供每个生命周期事件的示例来展示我们的理解。## “你能解释一下 UIViewController 容纳的概念吗?你如何在你的应用中实现它?”为什么这个问题重要?与前一个问题相比,这个问题更高级,需要了解设计模式和架构知识。UIViewController
包含功能创建模块化和可重用的 UI 界面,并增加了我们的项目灵活性。什么是 答案?使用UIViewController
包含,我们可以将一个视图控制器添加到另一个视图控制器中,并使其成为子视图控制器。这与添加子视图不同,因为UIViewController
代表一个独立的 MVC 单元,并有其自己的职责。看看图 7**.3:
图 7.3 – 将屏幕划分为不同的视图控制器
图 7**.3显示,由UIViewContoller
表示的屏幕被划分为两个额外的视图控制器。
添加视图控制器子控制器主要有两种方式:
-
在故事板中拖动一个新的视图控制器:我们可以使用 Xcode Storyboard将一个新的视图控制器拖动到一个现有的视图控制器上。这会创建一个容器视图,它与另一个视图控制器相关联。因为单个XIB 文件代表一个视图控制器或视图,所以我们只能在故事板中这样做,而不能在标准的 XIB 文件中这样做。
-
使用代码添加子控制器:我们可以使用
**addChild(UIViewController:)**
方法轻松地在代码中添加一个新的子视图控制器。让我们看看一个例子:// Add child view controllerparentViewController.addChild(childViewController)parentViewController.view.addSubview (childViewController.view)childViewController.view.frame = parentViewController.view.boundschildViewController.didMove(toParent: parentViewController)
我们需要执行四个步骤:
-
调用
**addChild**
方法以确保新的UIViewController
被添加到视图控制器层次结构中作为子控制器。 -
将新的视图控制器主视图作为子视图添加到父视图控制器。视图层次结构需要与视图控制器层次结构相对应。
-
设置子视图控制器的视图框架或约束。它可以是我们需要的任何东西。
-
通知系统子视图控制器已被移动到父控制器。
按照指南添加新的视图控制器“根据指南”的一个优点是,我们可以同步我们在前一个问题中讨论的生命周期事件。当在父视图控制器上调用viewWillAppear
方法时,只要它们被正确添加,也会在子视图控制器上调用。当将视图控制器作为不同屏幕上的子视图控制器重用时,生命周期事件的同步至关重要。
我们已经在我们的应用中使用UIViewController
包含功能了!
如果你之前没有尝试过,你可能会觉得UIViewController
包含很奇怪。但——很可能是,你已经在你的应用中使用了一些形式的子视图控制器实现。让我们看看两个好的例子:
UINavigationController: 在 UIKit 中导航是通过父视图控制器(UINavigationController)和子视图控制器,即顶部视图控制器来完成的。想象一下你自己实现自己的导航控制器——你会怎么做?你将如何实现推入和弹出视图控制器的动作?这是一个很好的思考练习,可以帮助你为这个问题做好准备。
UISplitViewController: 苹果为在 iPad 上运行的应用程序提供了一个分割视图控制器。在 UISplitViewController 中,我们有两个额外的子视图控制器——主视图控制器和详细视图控制器。两者都将屏幕分为两个不同的区域,每个区域都是一个独立的视图控制器。现在你知道了如何添加子视图控制器,这很简单。
“如何在 iOS 中在视图控制器之间传递数据?”
为什么这个问题很重要?
在 iOS 开发中,在视图控制器之间传递数据是一个重要的任务。应用程序动态呈现相同 UI 但包含不同信息的特点要求我们不断更新视图控制器以包含新信息。这个问题测试了我们对于在对象之间,尤其是视图控制器之间传递数据的各种设计模式的知识。
答案是什么?
在视图控制器之间传递数据有许多方法!问题是所有这些方法都使得回答问题变得极其容易。因此,我们需要解释我们向面试官展示的每一种方法的用例和原因。让我们看看一些例子:
-
使用代理: 如果我们有一个子-父关系,我们使用代理来通知子视图控制器和父视图控制器之间的事件和数据。代理是一个简单的基于协议的模式,当我们想要实现一个具有明确定义接口的简单更新时使用。然而,随着今天有更多高级模式,代理模式被认为有点过时了。
-
使用依赖注入: 在呈现或推入堆栈时将数据传递给新的视图控制器的一种方法是通过依赖注入。这可以通过使用init函数或设置其属性之一来实现。一个例子可以是一个显示联系信息的屏幕。在init函数中,我们可以传递需要显示的联系实体。需要注意的是,这种方法创建了一个单向数据流,并且可以在需要显示新的视图控制器时使用。
-
使用闭包: 如果我们只向一个方向传递信息,闭包是一个很好的方法。我们可以在目标视图控制器上定义一个闭包,并将其设置在源视图控制器上。每当我们要从源传递信息到其父控制器时,源只需要运行带有相关参数的闭包。这是一个简单的方法,以最小的耦合传递数据。
-
使用 Combine: Combine 是闭包的高级和响应式版本。它允许我们从一个对象向另一个对象流式传输数据更新,包括错误处理、异步操作和数据操作。
-
发布通知:如果两个视图控制器没有相互引用,通知可能是一个好的解决方案。尽管我们可以将数据附加到通知上,但许多开发者认为发布通知是一种反模式。通知没有直接的接口;所有活动对象都可以观察它并做出响应。仅仅这两个原因就使得它不如其他方法推荐。
你已经了解了我刚刚展示的所有方法,但列出它们可以帮助你回答那个问题,并帮助你完成设计模式任务和家庭作业。这是本书的一个目标之一——组织你的思想。
确保我们准备好使用 UITableView
UITableView,随后是 UICollectionView,是 UIKit 中最古老的 UI 组件之一。事实上,UITableView 从一开始就存在,而 UICollectionView 是在四年后被添加的。
为什么 UITableView 被认为是一个基本组件?原因很明显。
两者,UITableView 和 UICollectionView,都擅长高效且直接地显示大量数据。
UITableView 不仅于此 – 它提供了一个界面,用于以适合 小屏幕 的方式显示项目,包括多选、编辑、头部和尾部等特性。它成为许多应用程序显示菜单和数据的主要方式。
Apple 确保了从第一天起将组件样式 UITableView 带到 SwiftUI 中,以保持这种能力。
“如何在 UITableView 中实现队列机制,以及有哪些最佳实践可以优化其性能?”
为什么这个问题很重要?
就像我们之前讨论的几个问题(例如 – UIViewController 生命周期)一样,这也是一个你可能会想要确保准备好的去或不去的问题。这个问题测试了我们对于使 UITableView 高效和性能良好的主要机制的理解。
UITableView 的队列机制是许多顶部特性和我们可能遇到的问题的基础,例如滚动时的异步操作、优化和状态管理。
那个机制也是 UICollectionView 和 MKMapView 等附加控件的基础。
这个答案是什么?
UITableView 机制确保在滚动大量数据时效率和性能高。
显示大量项目的主要问题是 内存。分配如此多的视图,其中大多数在屏幕之外,会导致内存过载,最终导致应用程序终止。
我们想要做的是只分配屏幕上看到的视图,并在滚动列表时释放屏幕外的视图。
但每次分配新视图都会引发性能问题。如果用户滚动速度快,分配和创建新视图需要几毫秒,这即使在强大的设备上运行也会造成延迟。
这种延迟是队列解决方案开始实施的地方。当视图离开屏幕时,UITableView 不会将其销毁,而是将其放入 deque 池,并在需要显示列表中的新视图时从池中取出。
队列机制使得 UITableView 滚动快速且平滑。但 dequeuing 单元格也带来了新的问题,其中一些问题在此详细说明:
-
处理现有单元格:在我们显示单元格之前,我们必须记住它可能已经存在信息。我们需要在单元格从池中移出后通过调用prepareForReuse方法或在我们显示它之前覆盖其属性来清除单元格。
-
验证异步操作响应:在 UITableView 中,一个常见问题是异步从后端服务加载图片。我们在单元格显示时开始请求,但当我们得到响应时,单元格已经被 dequeued 并连接到另一个实体。在这种情况下,我们需要确保我们得到的响应数据与单元格应该表示的当前实体相匹配。
-
与多个池一起工作:有些情况下,我们拥有多种类型的单元格,可能基于不同的类和 UI。在这种情况下,我们需要创建多个 deque 池,我们还需要处理并确保它们。
总结来说,UITableView 提供了一个出色的机制来显示大量信息,但也带来了我们必须处理的新问题。
“UITableView 中的分页是什么,你将如何实现它以高效地加载和显示大量数据,同时保持良好的性能和用户体验?”
为什么这个问题很重要?
当我们想到 UITableView 时,我们会想象联系人列表或披萨食谱。但有时它可能需要时间来加载数据,或者列表太大,以至于造成内存过载。例如——社交媒体帖子、图片和后端的数据。从我们的角度来看——无限数量的项目。
面试官想看到我们如何处理比仅显示一个关闭的项目列表更复杂的情况。
这个答案是什么?
UITableView 中的分页是一种技术,它涉及根据表格视图的滚动位置分批加载和显示数据。
当我们拥有大量数据时,我们使用分页,同时加载所有数据是不高效的。例如,如果我们有来自后端的数据或存储在我们持久存储中的大图片和视频。
使用分页,我们加载屏幕上要显示的数据(以及一点点更多),当用户继续滚动时,我们加载“另一页”的数据。这种按需加载数据的技术对于初始加载来说要快得多,不会造成内存过载,并且总体上更高效。
然而,分页创建了一些我们需要解决的挑战,这些挑战在此详细说明:
-
决定页面加载触发器:为了执行额外的加载操作,我们需要决定加载触发器。例如,当用户达到最后一个可见行或滚动偏移量时加载数据。此外,我们需要确保我们不会同时发送多个请求,因为触发器可能会在滚动过程中多次发生。
-
显示加载指示器:向用户提供一个指示还有更多数据可以查看并且正在加载是很重要的。在列表底部最后一行显示指示是常见的做法。
-
在后台加载数据:为了提供平滑的滚动而不会阻塞 UI 和产生延迟,我们应该使用异步函数、GCD 或NSOperation在后台线程中加载数据。
-
处理“没有更多数据”的情况:这听起来可能像是一个奇怪的问题,但开发者有时会忘记处理它。当用户滚动到列表的最后一项时,加载更多数据的触发器被激活。如果请求返回空数据,触发器可能会再次被激活,因为我们的列表满足了触发条件。在这种情况下,列表将进入一个无限循环,试图获取数据但没有结果。解决方案可能是使用一些临时标志来避免持续获取。
分页是一种既有优点也有缺点的技术,需要仔细考虑。它涉及到后端和客户端,可以提供高性能和良好的用户体验,但需要我们处理更复杂的获取模式。
“在 UITableView 中调整单元格大小的不同方法有哪些,以及你是如何根据它们将要显示的内容确定单元格的最佳大小的?”
为什么这个问题很重要?
在 UITableView 中,单元格大小一直是 iOS 开发者的问题,尤其是在需要可访问性和动态字体大小随时间演变的年份。
这个问题测试了我们对 UITableView 代理的了解,我们使用 Auto Layout 与单元格的能力,以及我们在性能和简单性之间需要做出的权衡。
答案是什么?
调整单元格大小有两种方法:
-
为每一行设置自定义高度:实现返回每行不同高度的tableView(_:heightForRowAt:)代理方法。这样,我们就需要自己计算每行的大小。它可以是固定大小或根据单元格内容。如果做得不正确,自己计算单元格大小可能会不准确,但在处理大量数据集时,它可能更快、更高效。以下是一个这样的实现示例:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { let cellData = dataSource[indexPath.row] let height = cellData.text.height (withConstrainedWidth: tableView.frame.width - 32, font: UIFont.systemFont(ofSize: 14)) return height + 16}
在这个代码示例中,我们根据单元格的行索引获取单元格数据,并根据 UITableView 的大小计算大小。我们可以看到这个代码片段有多容易出错,因为它需要非常精确。但是——因为我们没有使用 Auto Layout 系统,所以它要快得多。
-
使用自适应单元格: 另一个选项是使用自适应单元格。在自适应单元格中,单元格的高度会自动根据其内容通过自动布局约束来设置。以下是一些需要注意的事项:
-
我们必须确保单元格从上到下有一个连续的约束序列,以便获得有效的固有内容大小。
-
我们需要确保
UITableView
的rowHeight
属性设置为automaticDimension
。 -
我们提到自适应单元格在性能方面效率较低。为了“帮助”
UITableView
测量其大小,我们可以使用estimatedRowHeight
属性或实现相应的tableView(_:estimatedHeightForRow:)
代理方法来提供行高估计,直到单元格显示在屏幕上。
-
自适应单元格对于大多数情况来说应该足够好,除非我们遇到需要为单元格设置自定义高度的性能问题,否则它应该是我们的首选方法。
UITableView
是 iOS 开发和面试中的一个核心主题。编码或家庭评估可能包括 UITableView
作为主要组件。确保这个控件没有错误!
进行良好的导航
导航是 UIKit 和 iOS 开发中的一个关键组件。导航允许我们直观且简单地让用户从一个 UIViewController
跳转到另一个。
有两种方式将用户导航到另一个屏幕:
-
显示模态视图控制器: 如果我们需要显示一个需要完成任务或做出决定的屏幕,一个视图控制器可以在其上方显示另一个视图控制器。
-
推送另一个视图控制器: 如果我们想要将用户导航到应用层次结构的下一阶段,我们可以将新的视图控制器推入堆栈。这种技术需要一个
UINavigationController
来处理推送和弹出操作,并提供一个导航栏以简化过渡。
面试官通常不会问关于推送和显示的问题,因为这些操作相对简单易懂且易于实现。大多数问题和挑战都与过渡、导航栏、生命周期方法和设计模式相关。
“视图控制器中 navigationItem
属性的目的是什么?你如何使用它来定制 iOS 开发中导航栏的行为和外观?”
为什么这个问题很重要?
navigationItem
属性就是这样,一个属性。为什么会有关于特定属性的问题?
嗯,这是因为 navigationItem
背后有一个完整的概念。这个问题测试了我们理解导航控制器工作方式和赋予视图控制器影响导航栏外观设计概念的能力。
通常,navigationItem
和视图控制器的工作方式是一个有趣的设计模式,可以在其他情况下使用,并且值得学习。
答案是什么?
每个 UIViewController 都有一个名为 navigationItem
的属性。该属性包含几个属性和方法,用于使用与视图控制器本身相关联的数据来自定义导航栏的行为。
例如,navigationItem
包含 title
属性,该属性用于设置在导航栏中显示的 当前标题 值。
可以在 navigationItem
中设置的其它重要信息项包括 左右按钮。让我们看看视图控制器如何使用 navigationItem
属性修改导航栏的示例:
class MyViewController: UIViewController { override func viewDidLoad(_ animated: Bool) {
super. viewDidLoad (animated)
navigationItem.title = "My Title"
let button = UIBarButtonItem(title: "Button", style: .plain,
target: self, action:#selector(buttonTapped))
navigationItem.rightBarButtonItem = button
}
@objc func buttonTapped() {}
}
在我们的代码示例中,navigationItem
属性包含一个标题和一个带有 "按钮"
标题的右侧栏按钮。当用户从该屏幕导航时,导航控制器将从下一个控制器获取新的 navigationItem
属性并更新其导航栏属性。
这是一种使导航控制器能够根据可见视图控制器的上下文来更新其导航栏的技术。进一步思考,我们还可以在其他情况下使用这项技术。
“iOS 中展示 UIViewController 的预设选项有哪些,为什么理解这些选项很重要?”
为什么这个问题很重要?
使用另一个视图控制器来展示视图控制器是 iOS 开发者经常进行的一项熟悉且直接的任务。
然而,有几种方式可以展示视图控制器。每个选项都适合不同的用例,并且可以影响展示者的视图控制器生命周期事件。
我们的职责是向产品团队解释不同的选项,并选择适合我们用例的选项。
答案是什么?
我们有几种预设选项可供选择。每个选项都会影响所展示的视图控制器的外观和感觉。
让我们列举一些:
-
全屏:所展示的视图控制器占据整个屏幕。
-
页面视图:所展示的视图控制器不会填满整个屏幕,可以通过简单的滑动手势向下拉动屏幕。
-
覆盖当前上下文:视图控制器被展示并隐藏了当前视图控制器上下文。
显然,不同类型的展示需要适合我们希望提供给用户的用户体验。但我们还必须考虑这些类型对正在触发不同生命周期事件的影响。
例如 - 如果我们使用展示视图控制器的 viewWillDisappear
和 viewDidDisappear
没有被调用。在页面视图中,我们假设底层视图控制器仍然可见。因此,一些与外观相关的生命周期事件也没有被调用。
在这种情况下,我们必须改变我们展示模态视图控制器的方式,或者使用代理模式或 Combine 来传达任何更改或更新。
规则很简单 – 我们需要调整预设选项以提供我们希望提供给用户的用户体验。我们是否隐藏了底层的屏幕?这是一个很好的起始问题。
“你将如何设计一个使用协调器模式的 iOS 应用的导航系统,其中视图控制器不决定下一步去哪里,而是由协调器对象负责管理导航流程?”
为什么这个问题很重要?
这是一个关于 iOS 应用导航的高级问题,它与如何技术实现 UINavigationController 或以模态方式显示视图控制器没有直接关系。
这个问题涉及到关注点分离原则和设计模式,我们可以在项目中使用它们来提供更多的灵活性和模块化。
这是一些没有现成答案的问题之一,你可以在面试期间思考你的答案,并与面试官讨论。更重要的是,要开发一套原则和想法,以应对这个挑战。
答案是什么?
协调器模式是一种流行的设计模式,它将导航逻辑与 UI 分离。
那么,让我们来讨论如何解决这个问题,并确定我们想要保留的基本原则:
-
视图控制器(或其视图模型,无论如何)不决定导航到哪里,只是发送触摸事件
-
我们将导航逻辑放在另一个名为协调器的类中
-
我们可以为每个视图控制器创建一个协调器,或者为单个流程创建一个协调器。
-
协调器有一个对导航控制器的引用,并且可以根据需要推送或弹出视图控制器
-
协调器需要观察当前显示的视图控制器中发生的事件
基于这些假设,我们可以想象以下模式(图 7.4):
图 7.4 – 协调器模式(建议)
注意,这只是一个建议,因为这个模式并不是一成不变的。
根据不同项目的需求,这里有一些我们可以提出的修改建议:
-
如果项目变得更加突出,我们可以有多个协调器,有时甚至是一个协调器的层次结构。
-
我们可以决定协调器观察视图模型的状态变化。
-
我们也不必使用观察者进行导航,而是为视图控制器提供一个协调器的引用。视图控制器可以向协调器发送发生了什么,协调器可以决定接下来做什么。
如果我们保持基本的设计原则并解释我们做了什么,我们可以对这个模式进行一些调整。
通过动画赋予用户体验力量
与许多人认为的不同,iOS 中的动画不是为了“娱乐”- 它在我们应用中提供了流畅和顺滑体验的重要作用。因此,它们是我们开发者工具集的一部分。
iOS 中的动画依赖于一个名为Core Animation的框架,这是 UIKit 的依赖之一。因此,尽管本章讨论的是 UIKit,我们可以在 UIKit 的许多类和方法中找到 Core Animation。
回顾 UIKit 中动画的核心概念
那么,在准备面试时,我们需要了解哪些关于动画的知识呢?
我们需要了解几个类、术语、方法和技巧。在我们继续讨论高级面试问题之前,让我们回顾一下它们。
执行 UIView 动画
UIView 动画可能是 UIKit 中最简单的动画之一。在 UIView 动画中,我们提供一个包含最终状态的闭包,UIKit 会自动运行动画。让我们看看一个代码示例,它接受一个视图并通过改变其alpha
值来将其淡出:
UIView.animate(withDuration: 0.3) { myView.alpha = 0.0
}
我们看到一段简单的代码,它接收一个持续时间和一个闭包,并在闭包内部动画化变化,在这种情况下,改变myView
的 alpha 属性为0.0
。注意,Core Animation 并不动画化所有 UIView 属性。以下是可动画化的 UIView 属性列表:frame
、bounds
、center
、transform
、alpha
、backgroundColor
和tintcolor
。
动画化自动布局约束变化
我们需要理解如何动画化约束变化,因为这不像我们修改的其他属性那样直接。约束是自动布局的一部分,UIKit 在每次运行循环(每 16.67 毫秒)时都会执行自动布局变化。这意味着我们需要通过调用layoutIfNeeded
方法“强制”自动布局在动画闭包内执行这些变化。以下是一个视图约束变化的示例:
UIView.animate(withDuration: 0.5) { myView.topAnchor.constraint(equalTo: superview.
topAnchor, constant: 100).isActive = true
superview?.layoutIfNeeded()
}
我们可以看到,仅仅在动画闭包内创建或修改约束是不够的。我们还需要“强制”这些变化按时发生;因此,我们调用layoutIfNeeded
方法。
玩转时序和缓动
我们可以通过调整动画的时序和缓动来创建用户想要感知的效果。时序意味着不同的持续时间和延迟,因此我们可以有一个更长或更短的动画。springWithDamping
和initialSpringVelocity
可以帮助我们实现更定制的动画缓动。让我们看看一个示例:
UIView.animate(withDuration: 1.0, delay: 0.0,
usingSpringWithDamping: 0.5,
initialSpringVelocity: 10.0,
options: .curveEaseInOut,
animations: {
myView.transform = CGAffineTransform
(scaleX: 1.5, y: 1.5)
},
completion: nil)
我们通过修改myView
的transform
属性并动画化这个过程来缩放myView
的大小。我们还可以看到我们传递了这两个重要的参数:
-
使用 springWithDamping:这控制了弹簧效果的阻尼程度,更高的值会导致更少的振荡和更快的稳定时间
-
initialSpringVelocity:这控制了动画的初始速度,更高的值会导致更快的开始和更显著的效果
重要的是要调整这些值以实现期望的结果。
构建关键帧动画
对于更复杂的动画,我们可以使用关键帧动画。关键帧动画允许我们分阶段或序列地创建动画。我们可以定义不同的阶段,并为每个阶段提供相对持续时间和起始时间。以下是一个关键帧动画的示例:
UIView.animateKeyframes(withDuration: 3.0, delay: 0.0, options: [], animations: {
UIView.addKeyframe(withRelativeStartTime: 0.0,
relativeDuration: 0.5, animations: {
view.transform = CGAffineTransform (rotationAngle: .pi / 2)
})
UIView.addKeyframe(withRelativeStartTime: 0.5,
relativeDuration: 0.5, animations: {
view.transform = CGAffineTransform.identity
})
}, completion: nil)
我们如何阅读这个代码块?很简单。整个动画的持续时间是 3 秒,并且分为两个序列。
第一个序列从开始(相对时间 0)开始并持续总时间的一半(相对持续时间 0.5,意味着 1.5 秒)。
第二个序列在总动画时间的中点开始(相对时间 0.5,意味着 1.5 秒)并持续动画时间的一半(相对持续时间 0.5,意味着 1.5 秒)。
序列不必相互同步,如果需要,由我们来同步它们。
在屏幕之间执行过渡
UIKit 允许我们在屏幕之间执行过渡,或者更准确地说,在 UIViewControllers 之间。我们可以选择内置的过渡之一,甚至创建自己的过渡。
这就是我们如何使用溶解动画来展示 UIViewController:
let viewController = MyViewController()viewController.modalTransitionStyle = .crossDissolve
present(viewController, animated: true, completion: nil)
UIKit 提供了一小部分内置动画,在很多情况下,这些动画是不够的。因此,我们有创建自定义动画甚至交互式动画的选项。
现在,在面试的背景下——就像许多其他主题一样,理解自定义过渡的工作方式很重要。我们不需要记住每个 API 的细节,但我们需要知道我们能够做什么,它打开了哪些可能性,以及如何构建它,以便在整个项目中重用。
操作 CALayers
我们在讨论 UIView 时提到了 CALayer,但还没有讨论它对我们作为 iOS 开发者的重要性。
我们已经知道 iOS 图形是通过层构建的——UIView(由 CALayers 支持)以及其下是 Core Animation,它是建立在Metal之上的。
CALayers 提供了硬件加速渲染组件,这使得我们可以操纵图形并执行复杂的动画和图像处理。我们还可以混合层并添加视觉效果。总的来说,CALayers 帮助我们深入图形架构,更接近硬件和 GPU,以实现更多功能和效率。
理解动画技术的核心概念可以帮助我们实现更好的用户体验。作为 iOS 开发者,我们预计应该熟悉提供所需动画的基本工具和类。
现在,让我们回顾一个与 UIKit 中动画相关有趣的面试问题。
“如何在 iOS 应用中创建 UIViewControllers 之间的自定义过渡?”
为什么这个问题很重要?
我们在介绍动画核心概念时简要讨论了自定义过渡,这个问题测试了我们对自定义过渡 API 的经验。自定义 UIViewController 过渡 API 要求我们处理可重用性,深入理解动画的工作原理,视图控制器如何相互协作,如何执行高级技术,如快照,以及如何实现相对复杂的 UIKit API。
自定义动画被认为是一个高级主题,我们至少应该了解其基本概念。
答案是什么?
要在 UIViewControllers 之间创建自定义过渡,我们必须实现 UIViewControllerAnimatedTransitioning
协议(任何对象都可以遵守该协议)。
此协议包含两个函数:animateTransition(using:)
和 transitionDuration(using:)
。
在 animateTransition(using:)
中,我们处理视图层次结构、约束更改和动画。
在 transitionDuration(using:)
中,我们返回 TimeInterval
中的持续时间。
UIViewControllerAnimatedTransitioning
定义了一个过渡,展示或消失。为了精确确定每个场景中发生的事情,我们必须实现 UIViewControllerTransitionDelegate
来指定哪个对象处理每个用例。
一旦我们这样做,为了使用我们的自定义过渡,我们需要将 UIViewController 的 modalPresentationStyle
属性设置为自定义,并将 transitionDelegate
属性设置为符合 UIViewControllerAnimatedTransitioning
的对象。
由于我们有两个不同的协议需要实现,这里的事情变得有些复杂,让我们看看 图 7.5 来理解所有这些是如何相互关联的:
图 7.5 – 自定义 UIViewController 过渡代理
我们可以看到,UIViewControllerTransitionDelegate
的主要作用是在展示或消失展示视图控制器时决定哪个对象将处理动画。值得一提的是,这三个组件可以是同一个对象,这就是可重用性概念所在。如果我们希望能够在整个项目中重用我们的过渡,我们需要做两件事:
-
将不同对象的 不同组件 分离。
-
在动画过程中,应通过协议而不是直接引用原始视图控制器类来减少展示/展示视图控制器与过渡对象之间的耦合,使用协议。例如,假设我们正在将一个视图从一个控制器动画到另一个控制器。在这种情况下,我们不应直接使用视图的引用,而应使用具有 getView() 方法的协议,这样我们就可以与另一个视图控制器重用它。
正如我们所见,视图控制器之间的过渡是一个涉及不同技术的高级 iOS 开发者需要完成的任务。然而,这个概念至少需要做一次,才能完全理解它。
概述
在这一章中,我们讨论了许多与 UIKit 相关的重要主题,例如 UIView、UIViewController、UITableView、导航和动画。UIKit 在 iOS 开发者日常工作中占有巨大比重,并对用户体验产生了重大影响。
但——UIKit 也被认为是“旧”的 UI 框架。iOS 开发界正在向一个新、更现代的声明式编程时代过渡。
我们接下来的章节将专门处理这个问题——SwiftUI 和声明式编程。
第八章:SwiftUI 和声明式编程
前一章内容非常密集。我们讨论了 iOS 开发中最关键的框架,除了Foundation之外。
本章不仅仅讨论一个框架——我们将讨论一个理念,一个编程范式。
现在要在 iOS 领域参加面试,如果没有对声明式编程的基本了解,那几乎是不可能的,而几年前这个话题还只是“锦上添花”。
如果你知识体系中有空白或经验有限,在开始面试之前,请仔细阅读本章,以填补这些知识空白。
本章涵盖了声明式编程中的这些令人兴奋的话题:
-
探索开发新时代
-
理解声明式编程
-
学习状态和可观察对象
-
导航 SwiftUI 视图
-
精通 SwiftUI 的生命周期
-
精通 Combine
让我们从声明式编程的简要背景开始。
进入一个新开发时代
SwiftUI 和 Combine 不仅是有趣的框架,而且象征着苹果引领我们走向的新方向。这个方向并非与当前行业标准脱节,正如我们从许多开发者的日常工作中观察到的React、Flutter和RxJava的存在。
我选择将整整一章内容献给这两个尚未被广泛使用的框架的原因是,这两个框架标志着 iOS 项目在未来十年将呈现出的样子。
如果你到目前为止还没有任何关于 SwiftUI 和 Combine 的经验,你应该至少做到理解基本术语和概念,这些都是本章目标的一部分。
首先,让我们回顾最重要的概念——声明式编程。
理解声明式编程
声明式编程是一种全新的编程范式,它为我们提供了更易读和更健壮的代码。实际上,声明式编程根本不是什么新概念——事实上,我们可以在 30-40 年前找到声明式编程的根源。但只有在过去十年中,声明式编程才变得流行起来。
让我们通过回答我们可能在面试中遇到的一些问题,来更详细地了解声明式编程。
“声明式编程与‘经典’编程范式(也称为命令式编程)有什么区别?”
为什么这个问题很重要?
如果我们面试的公司在其项目中使用 Combine 或 SwiftUI,我们可能不得不回答这个问题的某个变体。原因是我们在处理代码的方式上的差异如此之大,以至于我们无法避免重组我们的思维来回答这个问题。
答案是什么?
在声明式编程中,我们关注代码的输出和结果。我们观察变化,并精确地定义其他对象的结果以及数据如何被操作。
在命令式编程中,我们关注导致我们结果的一系列步骤。
初看起来,这种差异并不明显。什么是“关注结果而不是步骤”?
让我们尝试用代码示例来解释。
我们有一个带有按钮(UIButton
)和文本字段(UITextField
)的屏幕,我们希望根据文本字段输入启用或禁用按钮。让我们看看我们如何在命令式编程中做到这一点:
import UIKitclass ViewController: UIViewController {
@IBOutlet weak var textField: UITextField!
@IBOutlet weak var button: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
textField.addTarget(self, action: #selector
(textFieldDidChange(_:)), for: .editingChanged)
button.isEnabled = false
}
@objc private func textFieldDidChange(_ textField: UITextField) {
button.isEnabled = textField.text?.isEmpty == false
}
}
代码应该是直接的,因为我们与命令式编程一起编写了这个模式数百次。在 iOS 开发中,将委托连接到文本字段是常见的。但看看它有多不清楚——当我们设置文本字段时,我们定义的是当用户更改文本时将调用哪个函数,而不是会发生什么。这意味着我们关注的是步骤和实现,而不是最终结果。
在文本字段委托函数中,我们确实更新了按钮的isEnabled
属性,但这段代码是在另一个函数的另一个地方调用的,可能甚至在另一个文件中。
让我们看看对这个问题的声明式方法:
import Combineimport UIKit
class ViewController: UIViewController {
@IBOutlet weak var textField: UITextField!
@IBOutlet weak var button: UIButton!
private var subscriptions = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
textField.publisher(for: \.text)
.map { $0?.isEmpty == false }
.assign(to: \.isEnabled, on: button)
.store(in: &subscriptions)
}
}
在前面的代码中,我们可以看到一个更清晰的解决方案,可以根据文本字段输入启用按钮。我们观察文本字段的“编辑改变”事件,将isEmpty
属性映射到另一个布尔值,并将其分配给按钮的isEnabled
属性。
这意味着我们声明当特定值更改时会发生什么,而不涉及任何控制流或委托。
当处理更复杂的流程时,这两种编程范式之间的差异非常明显。
“如何使用声明式编程帮助处理 iOS 应用中的状态管理?”
为什么这个问题很重要?
声明式编程和状态管理之间存在紧密的关系。在我们回答这个问题之前,了解什么是状态以及您如何在您的应用中使用状态是至关重要的。
通常,状态是我们应用、屏幕或视图的条件。
例如,一个状态可以是一个布尔变量,表示用户是否已登录到您的应用。另一个状态示例是按钮是否应该可见。
很明显,状态是我们都在我们的应用中使用过的东西,在声明式编程中,状态是一个主要话题。
答案是什么?
看看我的最后一个例子——“按钮是否应该可见。”为按钮可见性设置状态似乎是个好主意。问题是每次我们更改状态值时,我们必须确保按钮也被更新。
一种选择是使用didSet
属性观察者:
var isTextEmpty: Bool = true { didSet {
// Disable the button if the text is empty,
enable it otherwise
button.isEnabled = !isTextEmpty
}
}
尽管使用didSet
属性观察者将状态绑定到按钮可见性是一种简单的方法,但它并不是一个理想的解决方案,原因有几个:
-
关注点分离:一个变量只能有一个属性观察者,这意味着我们无法分离不同的关注点或责任。例如,我们不能为分析有一个
didSet
块,为 UI 更新有另一个didSet
块。 -
不可测试性: 这与前面的观点相关。因为didSet块包含多个动作,包括可能的 UI 更改,测试它可能具有挑战性,因为它可能有额外的可能副作用。
-
无法观察多个变量: 观察一个属性是很好的,但如果我们想观察多个属性的变化并根据这些变化执行一个动作怎么办?didSet不适合这种情况。
现在,这里有一个 Combine 示例版本:
private var cancellables = Set<AnyCancellable>() private var buttonVisible = PassthroughSubject<Bool,
Never>()
override func viewDidLoad() {
super.viewDidLoad()
buttonVisible
.assign(to: \.isEnabled, on: button)
.store(in: &cancellables)
}
Combine 版本是处理状态的一种更加优雅的方式。我们就像在didSet
示例中那样将状态绑定到按钮启用。但这次,我们还获得了更多的好处,例如以下内容:
-
我们可以在多个地方观察buttonVisible变量,用于不同的目的
-
我们可以使用多个buttonVisible实例以及更多变量
-
我们可以更高效地执行异步操作,并向流中添加复杂的操作符
声明式编程适合处理状态,因为它让我们精确地说明每次状态更改时应该做什么,这对于状态管理来说是非常理想的。
说到状态 – 让我们深入探讨 SwiftUI 中的状态,因为它们在屏幕更新和布局中起着重要作用。
学习状态和可观察对象
“状态”是 SwiftUI 和声明式编程的一个主要主题。与我们可以直接在屏幕上更新 UI 元素的命令式编程不同,声明式编程以相反的方式工作 – 我们更新状态,UI 会根据我们的更改进行更新。
事实上,使用状态是创建 SwiftUI 中动态视图的唯一方法。
SwiftUI 使用一种称为属性包装器的机制来标记某些变量为状态。
这里有一些例子:
-
@State: 用于管理简单的 UI 状态
-
@Binding: 允许视图与其子视图之间进行双向更新
-
@ObservedObject: 用于在视图之间共享数据
-
@EnvironmentObject: 用于在应用程序中跨视图共享数据
当被问及 SwiftUI 时,这些不同的属性包装器在理解 SwiftUI 的工作原理以及使用 SwiftUI 构建功能齐全的应用程序中起着重要作用。
如果你想了解更多关于在 SwiftUI 中管理用户界面状态的信息,你可以访问developer.apple.com/documentation/swiftui/managing-user-interface-state
。有关属性包装器的概述,请查看www.swift.org/blog/property-wrappers/
上的链接。
现在,让我们看看关于这个主题的两个关键问题。
“你能解释@State 和@Binding 属性包装器在 SwiftUI 中的区别和使用场景吗?”
为什么这个问题很重要?
这两个属性包装器对于理解 SwiftUI 的工作方式至关重要。回到理解声明式编程部分,@State
和 @Binding
是声明式编程概念的纯实现。
@State
和 @Binding
是创建复杂和可重用视图的基本包装器。
答案是什么?
@State
是一个属性包装器,用于在视图内部管理本地状态。它用于由单个视图管理的简单值,例如开关或表单数据。当 @State
属性的值发生变化时,SwiftUI 会自动更新视图以反映新的状态。以下是一个示例:
struct MyView: View { @State var toggleIsOn = false
var body: some View {
Toggle(isOn: $toggleIsOn) {
Text("Toggle is on: \(toggleIsOn.description)")
}
}
}
toggleIsOn
变量被 @State
包装,允许 SwiftUI 在需要时观察和更新 MyView
。在视图内部,有一个与 toggleIsOn
状态链接的 Toggle
。随着状态值的改变,相应的文本也会更新。
@Binding
是一个属性包装器,它为子视图和父视图之间提供双向连接。它用于将状态向下传递到视图层次结构,允许子视图修改存储在父视图中的值。当 @Binding
属性的值发生变化时,子视图和父视图都会更新以反映新的状态。以下是一个示例:
struct MyParentView: View { @State var toggleIsOn = false
var body: some View {
VStack {
MyChildView(toggleIsOn: $toggleIsOn)
Text("Toggle is on: \(toggleIsOn.description)")
}
}
}
struct MyChildView: View {
@Binding var toggleIsOn: Bool
var body: some View {
Toggle(isOn: $toggleIsOn) {
Text("Toggle is on: \(toggleIsOn.description)")
}
}
}
在这个例子中,MyParentView
管理着 toggleIsOn
状态,并通过一个 @Binding
属性将其传递给 MyChildView
。MyChildView
可以通过更新 toggleIsOn
属性来修改状态。当发生这种情况时,两个视图都会自动更新以反映新的状态。
我们可以看到 @State
和 @Binding
之间有着紧密的联系。@State
是其子视图的 @Binding
。如果我们将其与 UIKit 的命令式编程进行比较,@Binding
功能类似于我们熟悉和喜爱的代理模式,但更强大、更简单,主要是声明式的。
“在 SwiftUI 中,@ObservedObject 的作用是什么?在什么情况下你会用它来代替 @State 或 @Binding?”
这个问题为什么重要?
现在我们已经知道了 @State
和 @Binding
在 SwiftUI 中的作用,我们必须了解 @ObservedObject
如何融入我们的应用架构,以及它与其他视图属性包装器的区别。
答案是什么?
@ObservedObject
属性包装器在 @ObservedObject
中也会自动更新。
观察对象可以是单例吗?当然可以。更重要的是,它应该是我们注入到不同视图中的相同实例。
让我们看看图 8.1*:
图 8.1:观察对象在我们应用架构中的作用
图 8.1* 展示了使用观察对象时的不同依赖关系。在应用中添加更多层来管理持久数据和网络等,并保持观察对象在视图之间共享数据是一种很好的实践。
让我们看看一个使用观察对象的 SwiftUI 代码示例,展示联系人列表。
首先,是联系人列表视图:
import SwiftUIstruct ContactListView: View {
@ObservedObject var viewModel : ContactViewModel
var body: some View {
List(viewModel.contacts) { contact in Text(contact.name ?? "")
}
}
}
我们可以看到联系人列表使用了一个名为 viewModel
的观察对象,它可以注入到视图中或用作 @ObservedObject
,因为 SwiftUI 在需要刷新视图时不会重新创建它,所以它可以安全地存储数据。
现在,让我们看看 ContactViewModel
类的样子:
class ContactViewModel: ObservableObject { @Published var contacts = [Contact]()
private let dataLayer: ContactDataLayer
init(dataLayer: ContactDataLayer) {
self.dataLayer = dataLayer
loadContacts()
}
func loadContacts() {
contacts = dataLayer.loadContacts()
}
}
在这个代码示例中有三个重要的事项需要注意:
-
遵循 ObservableObject 协议:如果我们想让类成为观察对象,我们需要它遵循 ObservableObject 协议。
-
使用 @Published 为联系人列表:联系人列表变量有一个 @Published 属性包装器,它允许视图观察联系人列表的变化。
-
数据层作为依赖项:为了遵循关注点分离原则,我们将实际的获取和存储与共享类分开。ContactViewModel 类的唯一责任是在视图之间共享数据。ContactDataLayer 类执行持久操作。
总结来说,ObservedObject
是一种促进视图之间数据共享的机制。它易于理解和整合,可以帮助将项目的结构划分为不同的层。
在 SwiftUI 视图中导航
在移动应用中,导航一直是关键问题。UIKit 从一开始就支持导航,而 SwiftUI 伴随着 NavigationView
的基本支持推出。
在 SwiftUI 中,导航与 UIKit 非常不同。在 UIKit 中,我们必须创建一个新的视图控制器,并使用 UINavigationController
将其推送到堆栈中,而在 SwiftUI 中,它的工作方式略有不同。
记得我们在这章前面讨论了声明式编程吗?这就是 SwiftUI 中导航的工作方式。我们不是创建和推送一个新的视图,而是使用状态来呈现表单、模态和链接以导航到新视图。
让我们看看如何在 SwiftUI 中使用状态修改来呈现模态视图:
struct ContentView: View { @State var isModalPresented = false
var body: some View {
VStack {
Button("Present Modal") {
isModalPresented = true
}
}
.sheet(isPresented: $isModalPresented) {
ModalView()
}
}
}
在这个例子中,我们有一个名为 isModalPresented
的状态变量。当用户点击“呈现模态”按钮时,我们将 isModalPresented
设置为 true
,这会触发视图修改器 ModalView
。
对于那些多年来一直使用命令式编程的开发者来说,使用状态来呈现模态可能感觉有些奇怪,但这种模式自然地融入了声明式编程。
现在,让我们继续探讨一些关于 SwiftUI 导航的有趣问题。
“你如何使用 SwiftUI 导航系统在视图之间传递数据?”
为什么这个问题很重要?
在视图之间传递数据对于实现有效的导航模式至关重要。
这不是一个简单的问题——其他模式使我们能够在不传递数据的情况下导航到新视图。我们可以使用我们在上一节中回顾的观察对象模式,或者我们可以使用一些全局状态管理器来了解要呈现哪些数据。
然而,将数据传递给新屏幕被认为是最佳实践,以实现更好的分离和模块化。
答案是什么?
答案是这里没有我们不知道的 UIKit 中其他模式中的技巧。
在视图之间传递数据最好的方式是在初始化新视图时注入数据。
让我们创建一个包含国家列表的屏幕:
struct Country { let name: String
}
class DataStore {
let countries = [
Country(name: "USA"),
Country(name: "Canada"),
Country(name: "Mexico")
]
}
struct CountryListView: View {
let dataStore = DataStore()
var body: some View {
NavigationView {
List(dataStore.countries, id: \.name)
{ country in
NavigationLink(destination:
CountryDetailView(country: country)) {
Text(country.name)
}
}
.navigationTitle("Countries")
}
}
}
现在,让我们创建具有 country
属性的 CountryDetailView
:
struct CountryDetailView: View { let country: Country
var body: some View {
Text("Selected country: \(country.name)")
.navigationTitle("Country Detail")
}
}
CountryDetailView
结构体有一个名为 country
的属性。在 Swift 中,编译器会自动为它们的属性生成成员初始化器。我们使用它来在初始化 CountryDetailView
时传递 country
对象。
如我们所见,仅通过依赖注入就可以轻松地在视图之间传递数据。我们还可以评估该模式,并在一个视图传递状态,在下一个视图传递绑定,以在两个视图之间创建双向更新,例如 UIKit 中的代理模式。
“你能解释一下如何在 SwiftUI 导航中使用 @Environment(.presentationMode) 来关闭展示视图吗?”
为什么这个问题很重要?
使用 NavigationLink
移动到新位置很容易,但我们是怎样关闭或导航回的呢?
这个问题测试了我们对于导航和名为 @Environment
的属性包装器的理解,它可以暴露环境变量,提供更多功能。
这个答案是什么?
@Environment(\.presentationMode)
是一个属性包装器,它提供了对视图的展示模式的访问权限,允许我们在 SwiftUI 导航中关闭展示的视图。
下面是一个如何使用 @Environment(\.presentationMode)
来关闭展示视图的例子:
struct DetailView: View { @Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Text("Detail View")
Button("Dismiss") {
presentationMode.wrappedValue.dismiss()
}
}
.navigationTitle("Detail View")
}
}
在这个例子中,我们当前在屏幕上展示的是 DetailView
。我们使用 @Environment(\.presentationMode)
属性包装器来访问视图的展示模式。
当用户点击 presentationMode
来关闭函数时,它会带我们回到上一个屏幕。
注意,如果视图当前没有展示,尝试这样做将会导致运行时错误。因此,如果我们不确定在关闭视图之前视图是否展示,我们可以使用相同的展示模式进行检查:
if presentationMode.wrappedValue.isPresented { Button("Dismiss") {
presentationMode.wrappedValue.dismiss()
}
}
在这个代码示例中,关闭按钮仅在视图展示时出现,由于它是声明式的,所以当它不展示时将会被隐藏。
导航是任何移动应用的关键组件,在某种程度上,它通过 SwiftUI 变得更加简单。前两个问题应该足以让我们为这个面试部分做好准备。
熟悉 SwiftUI 生命周期
如果我们不完全理解 UI 生命周期,我们就无法构建 UI 屏幕。状态和 onChange
、onAppear
等修改器是 SwiftUI 生命周期的核心,对于构建一个功能性的应用程序至关重要。
我们在前面几节中已经讨论了一些与 SwiftUI 生命周期相关的内容——例如,观察对象和状态是 SwiftUI 生命周期的一部分。现在,我们必须了解当视图需要重新加载、更改或移动到新屏幕时,它们是如何工作的。
“SwiftUI 如何处理视图生命周期中的状态变化?”
为什么这个问题很重要?
SwiftUI 的状态管理方法与传统 UIKit 或 AppKit 方法不同,理解 SwiftUI 如何处理状态变化和更新对于避免我们应用中的意外行为至关重要。
答案是什么?
SwiftUI 根据当前状态生成视图层次结构。每当状态发生变化时,SwiftUI 都会生成一个新的视图层次结构,并将其与当前显示的层次结构进行比较。
这意味着每次都会重新生成所有变量,除了像 @State
和 @Binding
这样的属性包装器。将当前视图与比较,告诉 SwiftUI 需要更新哪些视图以反映新状态。SwiftUI 通过添加、删除或更新当前视图来将更改应用于用户界面。
这个过程非常高效,因为 SwiftUI 只更新需要更改的用户界面部分。
看看下面的代码:
struct ContentView: View { @State var labelText = "Hello, World!"
var body: some View {
VStack {
Text(labelText)
.padding()
Button("Change Label Text") {
labelText = "New Label Text"
}
}
}
}
当用户点击按钮时,它改变了 labelText
状态。在这种情况下,SwiftUI 生成一个新的视图,其中标签(Text
)的新值,并将其与当前视图层次结构进行比较。由于只有 Text
发生了变化,而按钮保持不变,SwiftUI 将只更新 Text
,而不会渲染整个屏幕,以保持其效率更高。
“如何在 SwiftUI 中使用 onChange
修饰符,它响应哪些状态变化?”
为什么这个问题很重要?
这个问题很重要,因为它评估了我们对于在 SwiftUI 中响应状态变化的理解。响应状态变化是构建 SwiftUI 用户界面的基本方面,而 onChange
修饰符是实现这一任务的关键工具。
答案是什么?
我们使用 onChange
修饰符来响应特定状态变量的变化。当应用于视图时,onChange
修饰符将在指定的状态变量发生变化时执行一个闭包。
看看下面的语法:
.onChange(of: stateVariable) { newValue in // Execute code here
}
在这个语法中,stateVariable
是我们想要观察的状态的名称,而 newValue
是该变量的新值。
这里是一些我们可以考虑的前一个用例:
-
根据用户输入的变化更新视图的布局
-
当按钮被点击时,改变按钮的颜色
-
根据环境变化更新布局
-
响应数据模型的变化并显示横幅
-
当网络加载完成时导航到新屏幕
这里是一个最后一个用例的示例——在网络加载完成和模型更新后导航到新屏幕:
struct ContentView: View { @StateObject var viewModel = ViewModel()
@State private var navigateToDetail = false
var body: some View {
NavigationView {
VStack {
if viewModel.isLoading {
ProgressView("Loading...")
} else {
Button("View Detail") {
viewModel.loadDetail()
}
}
}
.onChange(of: viewModel.detail) { detail in
if detail != nil {
navigateToDetail = true
}
}
.sheet(isPresented: $navigateToDetail) {
DetailView(detail: viewModel.detail!)
}
.navigationBarTitle("Content")
}
}
}
我们可以看到代码观察了 viewModel
的详细属性,一旦它被填充,它就会导航到新的视图。
Combine 的专业知识
我们在本章中已经讨论了声明式编程,所以现在,让我们专注于 Combine 框架一段时间。
苹果在 2019 年的 WWDC 上作为 iOS 13 版本的一部分引入了 Combine。Combine 是苹果对其他流行响应式框架,如 React 和 RxSwift 的回应。
Combine 框架帮助开发者构建具有强大异步操作和数据更新的响应式应用程序。
Combine 有三个主要组件:
-
发布者:发布者是一个对象,它会在一段时间内发出一系列值。发布者可以被视为数据源,数据可以来自各种来源,例如用户输入、网络请求或计时器。发布者可以发出不同类型的值,如整数、字符串或自定义数据类型,并且可以发出无限数量的值或有限数量的值。
-
操作符:操作符是一类函数,可以用来转换、过滤或组合由发布者发出的值流。操作符可以接受一个或多个发布者作为输入,并返回一个新的发布者,该发布者会发出转换后的值。一些操作符的例子包括 map、filter、flatMap 和 zip。
-
订阅者:订阅者是一个接收并处理发布者发出的值的对象。订阅者可以被视为数据消费者,可以以各种方式处理发布者发出的值,例如打印到控制台、更新用户界面或存储到数据库中。订阅者可以接收不同类型的值,请求一定数量的值或接收无限数量的值。
通过组合发布者、操作符和订阅者,我们可以在我们应用程序的不同部分之间创建强大的数据流。
让我们看看 Combine 的一个使用例子:
import Combinelet numbersPublisher = PassthroughSubject<Int, Never>()
let lettersPublisher = PassthroughSubject<String, Never>()
let cancellable = Publishers
.combineLatest(numbersPublisher, lettersPublisher)
.map { (number, letter) -> String in
return "Number: \(number), Letter: \(letter)"
}
.filter { value in
return value.count > 10
}
.sink { value in
print(value)
}
numbersPublisher.send(1)
lettersPublisher.send("A")
numbersPublisher.send(2)
lettersPublisher.send("B")
在这个例子中,我们将演示 Combine 中不同的组件,正如之前所描述的:
-
我们有两个不同的发布者(numbersPublisher 和 lettersPublisher),它们在一段时间内发送不同的值。
-
我们使用 combineLatest 操作符将这两个发布者 组合 在一起,每次其中一个发布者更新时,它都会返回两个最新值。
-
我们然后使用 map 操作符将值 映射 到一个字符串,接着是一个过滤操作符,它只返回超过 10 个字符的字符串。
-
sink 方法有助于 订阅 Combine 流 并打印输出。
这个复杂而有趣的 Combine 流有效地展示了所有不同的 Combine 组件。
如果你想了解更多关于 Combine 基本和原则的信息,你可以访问 developer.apple.com/documentation/combine#
。
现在,让我们继续讨论一些关于 Combine 的问题。
“你能提供一个在 iOS 应用中使用 Combine 的例子吗?”
这个问题为什么重要?
我刚才展示的复杂 Combine 示例很好,但并不实用,它只是为了解释 Combine 框架的原则。
真正的挑战是理解在现实世界的用例中,如何在我们的应用程序架构中实现 Combine。
答案是什么?
Combine 有许多实际应用场景。让我们列举一些:
-
执行 网络请求 并处理数据或错误
-
使用 数据绑定 或状态变化更新 UI 元素
-
验证 用户输入 并显示反馈
-
实现 MVVM 或其他架构模式
-
与定时器、通知、键值观察 等一起工作
由于我们被要求提供一个我们会在应用中使用 Combine 的示例,这里有一个将数据绑定到 UI 的示例。
在以下示例中,我们观察通知的数量并使用相关图像更新通知按钮:
import UIKitimport Combine
class ViewController: UIViewController {
@IBOutlet weak var notificationsButton: UIButton!
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
let notificationsPublisher = NotificationsManager.
shared.getNotificationsPublisher()
notificationsPublisher
.map { count -> UIImage? in
if count > 0 {
return UIImage(systemName: "bell.
fill")?.withTintColor(.red)
} else {
return UIImage(systemName: "bell")
}
}
.assign(to: \.image, on: notificationsButton)
.store(in: &cancellables)
}
在我们的代码示例中,我们可以看到数据与特定 UI 元素之间出色的绑定。这个示例也可以用于其他示例——标题更新、颜色更改、按钮可见性等。
将数据作为 UI 元素绑定是 MVVM 设计模式 中的优秀技术,其中我们可以在视图模型和视图之间绑定状态。
现在,让我们看看如何使用 Combine 的一个更复杂的示例——从网络请求中获取数据并使用 MVVM 设计模式更新表格视图:
import UIKitimport Combine
struct Article: Codable {
let title: String
let description: String
let url: URL
}
class ArticlesViewModel {
private let url = URL(string: "https://api.example.com/articles")!
private let decoder = JSONDecoder()
@Published private(set) var articles: [Article] = []
init() {
fetchArticles()
}
private func fetchArticles() {
URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: [Article].self, decoder: decoder)
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.assign(to: &$articles)
}
}
fetchArticles()
函数在请求数据、映射它、将其解码到 articles
数组、移动到主线程并将数据分配给 articles
的 published
变量时做了大部分工作。
现在,让我们看看视图控制器:
class ArticlesTableViewController: UITableViewController { private let viewModel = ArticlesViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.$articles
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] _ in
self?.tableView.reloadData()
})
.store(in: &cancellables)
}
override func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
return viewModel.articles.count
}
override func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) ->
UITableViewCell {
let cell = tableView.dequeueReusableCell
(withIdentifier: "ArticleCell", for: indexPath)
let article = viewModel.articles[indexPath.row]
cell.textLabel?.text = article.title
cell.detailTextLabel?.text = article.description
return cell
}
}
在视图控制器中,我们通过观察变化并在每次收到更新时重新加载数据,将 articles
数组绑定到表格视图。
最后一个示例展示了如何使用 Combine 在几行代码中链式调用不同的操作符来执行网络请求、解析、移动到主线程和处理错误。
我认为这两个示例代表了使用 Combine 的许多常见用例。我们应该彻底学习它们,这将帮助我们高效地回答这个问题。
“你如何调试一个 Combine 流?”
为什么这个问题很重要?
我们已经知道,调试对于开发者来说至关重要,而不仅仅是 iOS 开发者。
我们作为 iOS 开发者的调试经验大多围绕命令式编程和标准代码流程。另一方面,Combine 在调试领域带来了不同的东西,带来了新的挑战。
在这个问题中,面试官想了解我们如何处理我们在工作中可能遇到的 Combine 问题。
答案是什么?
我们可以使用 Xcode 内置的调试工具来调试 Combine 流,例如设置断点、检查变量和单步执行代码执行。
然而,Combine 框架提供了额外的工具来帮助我们调试。让我们列出其中的两个。
使用 print()
和 handleEvents()
读取控制台
print
操作符是一个调试工具,它允许我们打印通过 Combine 管道流动的事件。我们可以使用它来可视化数据转换并识别意外的行为或错误。print
操作符可以放置在管道的任何位置,并将打印所有在其下游发生的事件。
下面是一个演示如何使用 print
操作符的例子:
import Combinelet numbers = [1, 2, 3, 4, 5]
let publisher = numbers.publisher
let pipeline = publisher
.map { $0 * 2 }
.print("Debug:")
.filter { $0 % 3 == 0 }
let subscriber = Subscribers.Sink<Int, Never>(
receiveCompletion: { completion in
print("Completion: \(completion)")
},
receiveValue: { value in
print("Value: \(value)")
}
)
在这个例子中,我们使用 print
操作符用 "Debug:"
标记调试输出。这将帮助我们区分控制台中的调试日志和其他任何输出。当我们运行此代码时,我们将在控制台看到以下输出:
Debug: receive subscription: (Sequence)Debug: request unlimited
Debug: receive value: (2)
Debug: receive value: (6)
Debug: receive value: (10)
Completion: finished
handleEvents
操作符类似于 print
操作符,但它不是打印事件,而是允许我们在管道的特定点触发副作用。
我们可以使用它来执行诸如记录日志、更新 UI 元素或触发通知等操作。handleEvents
操作符可以放置在管道的任何位置,并且它将触发所有在其下游发生的事件的副作用。
下面是一个演示如何使用 handleEvents
操作符的例子:
let pipeline = publisher .map { $0 * 2 }
.handleEvents(
receiveSubscription: { subscription in
print("Subscription: \(subscription)")
},
receiveOutput: { output in
print("Output: \(output)")
},
receiveCompletion: { completion in
print("Completion: \(completion)")
},
receiveCancel: {
print("Cancelled")
}
)
.filter { $0 % 3 == 0 }
使用 handleEvents
操作符,我们可以单独打印每个事件,并对我们的打印操作有完全的控制。
在我们的流中包含断点
Combine 框架提供了额外的操作符来生成流中的断点。
第一个也是主要的操作符是 breakpoint()
,它可以帮助我们在特定事件暂停程序,类似于 handleEvents()
:
let pipeline = publisher .map { $0 * 2 }
.breakpoint(
receiveSubscription: { subscription in
return false
},
receiveOutput: { output in
print("Output: \(output)")
return output > 8
},
receiveCompletion: { completion in
return true
}
)
.filter { $0 % 3 == 0 }
在这段代码中,我们通过在相应位置返回 true
来在程序完成或输出大于八时暂停程序。
第二个断点操作符是 breakpointOnError()
,当任何上游发布者抛出错误时,它会暂停程序:
let pipeline = publisher .tryMap { value -> Int in
if value == 4 {
throw ExampleError.example
}
return value * 2
}
.breakpointOnError()
.filter { $0 % 3 == 0 }
这个例子很简单——tryMap
操作符抛出一个错误。因此,程序将暂停,多亏了 breakpointOnError()
命令。
breakpoint()
和 breakpointOnError()
都是我们在需要执行深入的 Combine 问题调查时暂停程序的好方法。
摘要
在本章中,我们讨论了声明式编程、SwiftUI 和 Combine 的关键主题。我们讨论了 SwiftUI 生命周期、调试 Combine、现实世界的例子、导航和状态。到现在为止,当我们被问到 SwiftUI 和 Combine 时,我们应该已经完全准备好了。
下一章将有所不同。我们将讨论我们架构中的一个关键层——数据层,特别是持久化数据层。
第九章:理解持久化存储
虽然,作为 iOS 开发者,我们主要关注 UI 相关的主题,如 UIKit 和 SwiftUI,但还有其他 iOS 开发的关键方面需要考虑,例如持久化存储。这个主题至关重要,因为它使我们能够在 App 关闭后存储和检索信息。
管理持久化存储的好处很多,其中一些细节如下:
-
提升用户体验:能够保存和稍后检索用户数据的 App 可以提供更好的用户体验。例如,如果用户已从我们的后端服务器下载了信息,我们可以在他们下次进入 App 时立即展示这些信息,而无需等待网络请求完成。
-
提供离线访问:离线访问是一个伟大的功能,允许用户在离线时也能使用我们的 App。例如,一个消息应用可能允许用户在无互联网连接的情况下查看他们之前的对话。
-
保持本地状态:使用持久化存储,我们可以在 App 关闭后保持本地状态。例如,存储访问令牌、用户配置文件详情或从用户上次停止的地方继续用户体验是我们可以添加到 App 中的关键功能。
持久化存储对于 iOS 开发者来说是一个关键组件,它涉及到用户体验、安全和效率。
本章涵盖了持久化存储中的以下重要主题:
-
掌握 Core Data 问题
-
使用 UserDefaults 处理持久化状态
-
在 密钥链 中存储敏感信息
-
与 文件系统 一起工作
许多年轻的开发者常常对名为 Core Data 的框架感到畏惧。让我们从探索这个主题开始。
掌握 Core Data 问题
大多数面试官不会询问关于通用框架的问题(除了 UIKit、SwiftUI 和 Foundation 之外),但 Core Data 被视为一个例外。Core Data 是 iOS 开发中的基本框架,因为它是一个设置数据层和管理持久化存储的优化且简单的解决方案。
Core Data 在这些年中不断发展,成为许多开发者的主要框架。它完美地集成到 iOS 开发生态系统中,与 Xcode 和其他 iOS 框架完美配合。
关于 Core Data 有几个概念需要了解:
-
数据模型:Core Data 围绕一个数据模型构建,该模型定义了 App 中存储的数据结构。数据模型通常使用 Xcode 的数据建模工具确定,包括实体(代表 App 中的对象)、属性(描述这些对象的属性)和关系(定义实体之间如何相互关联)。
-
管理对象上下文:管理对象上下文是 Core Data 框架的核心。它负责管理应用数据对象(称为“管理对象”)的生命周期,并为应用提供了查询、创建、更新和删除这些对象的方式。它还负责处理撤销/重做操作和管理对象关系。
-
持久化存储协调器:持久化存储协调器负责管理应用的数据持久化存储,数据存储在磁盘上。它协调管理对象上下文和持久化存储之间的通信,并确保对管理对象上下文所做的更改被正确地持久化到磁盘。
-
查询请求:我们使用查询请求来查询应用的数据模型并从管理对象上下文中检索特定对象。我们还可以使用谓词(以过滤结果)、排序描述符(以排序结果)和查询限制(以限制返回的结果数量)来自定义查询请求。
-
关系:Core Data 提供了一种强大的机制来定义数据模型中实体之间的关系。关系可以是一对一、一对多或多对多,可以是单向的或双向的。关系还可以配置删除规则,这些规则定义了当关系断开时对象应该如何被删除。
-
迁移:Core Data 包含在不同数据模型版本之间迁移数据的工具。这允许开发者随着时间的推移更改数据模型,同时保留现有数据。
我们还了解 Core Data 的另一个重要术语,那就是 Core Data 栈。Core Data 栈是一个层集,允许我们的应用与持久化存储进行交互。
这里是 Core Data 栈的三个层级:
-
管理对象模型:这是包含数据模型的底层。因为一切都是围绕数据结构构建的,所以我们从这个层开始。
-
持久化存储协调器:这是基于对象模型构建的持久化存储协调器。协调器使用数据模型来定义一个与数据方案相对应的持久化存储。存储可以基于 XML、SQLite、JSON 或后端服务。Core Data 允许我们基于我们想要的任何技术来构建持久化存储。关于持久化存储的另一个重要事项是,它需要与数据模型完全匹配。任何数据模型的变化都要求对存储进行修改并执行迁移。
-
管理对象上下文:这是管理应用数据对象生命周期(称为“管理对象”)的管理对象上下文。它为应用提供了查询、创建、更新和删除这些对象的方式。它还负责处理撤销/重做操作和管理对象关系。
我们需要设置 Core Data 栈以开始使用 Core Data。这可以通过使用 NSPersistentContainer
快速完成:
import CoreData// 1\. Create a persistent container
let container = NSPersistentContainer(name: "MyDataModel")
// 2\. Load the persistent store
container.loadPersistentStores { (storeDescription, error)
in
if let error = error {
fatalError("Failed to load persistent store: \(error)")
}
}
// 3\. Create a managed object context
let context = container.viewContext
在代码示例中,我们使用MyDataModel
。这一行也创建了持久存储并返回一个容器对象。
之后,我们将持久存储加载到堆栈中,并创建一个管理对象上下文,这样我们就可以开始使用 Core Data 了。
一旦我们有了上下文,我们就可以执行实体创建、获取、更新和删除操作:
let newEmployee = Employee(context: context)newEmployee.name = "John Doe"
newEmployee.title = "Software Engineer"
newEmployee.startDate = Date()
do {
try context.save()
} catch {
fatalError("Failed to save context: \(error)")
}
代码如此简单,以至于它自身就能说明一切。我们根据之前创建的上下文创建一个新的Employee
对象,设置其属性,并使用context
的保存方法保存新对象。
Employee
是NSManagedObject
的子类,它允许我们直接访问实体的属性,并提供了修改其数据的一种简单方式。context.save()
操作将更改提交到持久存储。
一般而言,Core Data 的主要用法很简单,而且随着时间的推移变得更加简单。虽然设置 Core Data 堆栈对于使用 Core Data 至关重要,但这并不是开发者需要掌握的唯一方面。由于与 Core Data 一起工作的挑战和复杂性,面试官通常会询问这些挑战,而不仅仅是基本设置。
这里有一些我们可能在面试前需要很好地学习的挑战:
-
处理并发:并发是一个复杂的话题,不仅在于 Core Data,还在于任何持久存储或本地数据。在 Core Data 中执行并发任务并避免数据丢失和异常,存在几种技术和模式。
-
设计数据模型:从技术上讲,设置具有实体和属性的数据模型是一项简单的工作,因为我们的大部分工作都在模型编辑器中完成,这是一个内置的 Xcode 编辑器。但真正的挑战是以一种服务于我们应用程序关键旅程和任务的方式设计数据模型。我们需要掌握主要术语,如多对多和一对多,并完全理解删除规则。
-
理解数据迁移:随着时间的推移,我们不得不修改我们的数据模型,添加、编辑和删除实体和属性。在持久存储中有数据时更改数据模型称为“数据迁移”,这也是作为 iOS 开发者,我们需要理解和知道如何执行的关键话题。在这个领域的错误可能会导致数据损坏甚至崩溃。
现在,让我们过渡到一些专门针对这些主题的 Core Data 相关的问题。
“你如何设计一个支持并发同时确保线程安全的 Core Data 堆栈,以及你如何在多线程环境中使用 NSManagedObjectIDs 来促进这一点?”
这个问题的意义是什么?
并发是 Core Data 开发中的一个重要话题,妥善处理并发显示了我们对 Core Data 上下文工作原理的深入理解。
如果我们理解了 Core Data 上下文的工作原理,我们也应该回答这个问题的第二部分——NSManagedObjectID
,它可以帮助我们在不同的上下文中识别对象。
答案是什么?
首先,让我们谈谈与 Core Data 并发相关的两个原则:
-
将上下文视为沙盒:当与上下文一起工作时,我们可以创建对象、更新和删除它们。我们只在这个上下文中执行所有这些操作,而不是在持久存储中。当我们调用save()方法时,Core Data 会将更改推送到父上下文,如果没有父上下文,则推送到持久存储。我们应该将上下文视为沙盒——我们可以在准备好时执行更改并提交它们。
-
从创建上下文的同一线程访问上下文:在 Core Data 中,上下文属于一个线程。一旦我们创建了一个新的后台线程,我们就无法访问在另一个线程中创建或检索的对象。每个线程都需要自己的上下文来允许它。
在我们理解了这两个原则之后,我们可以尝试定义设置我们的 Core Data 堆栈的不同模式。这些模式依赖于我们的应用程序需求和需求。
让我们回顾一下。
使用多个上下文
在多个上下文模式中,我们有几个私有上下文,并且每个上下文都直接与持久存储一起工作。多个上下文负责读取和写入,并提供了一种灵活的方式来处理我们应用程序中的不同并发操作,甚至以模块化的方式做到这一点。
这里有一个例子:
let persistentContainer = NSPersistentContainer(name: "MyApp")persistentContainer.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error {
fatalError("Failed to load persistent store: \(error)")
}
})
let writeContext = NSManagedObjectContext(concurrencyType:
.privateQueueConcurrencyType)
writeContext.persistentStoreCoordinator =
persistentContainer.persistentStoreCoordinator
let readContext = NSManagedObjectContext(concurrencyType:
.privateQueueConcurrencyType)
readContext.persistentStoreCoordinator =
persistentContainer.persistentStoreCoordinator
在这个例子中,我们创建了两个上下文用于写入(writeContext
)和读取(readContext
)。重要的是要注意,我们对写入上下文所做的更改不会反映在读取上下文中,直到我们执行保存和重新检索。
多个上下文模式有一些显著的缺点。例如,这种模式可能会增加我们的数据库复杂性,并要求我们在上下文之间管理更改。
我们可能遇到的另一个缺点是与批处理更改和重做/撤销等特性相关的复杂性,这在某些应用程序用例中可能是关键的。
然而,我们有一个相当不错的替代方案:父-子上下文。让我们来谈谈它。
使用父-子上下文
如果多个上下文模式具有“扁平”结构,则父-子模式更具层次性。基本原理指出,我们有一个根上下文(也称为“父”),专门用于写入操作,而子上下文专门用于读取操作。父上下文是私有的,子上下文与主队列一起工作。我们在写入上下文中进行的每个更改都会反映在子上下文中。
父-子模式对于后台更新、离线编辑和撤销-重做用例非常出色。
这里是一个父-子模式的代码示例:
let parentContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
parentContext.persistentStoreCoordinator =
persistentStoreCoordinator
let readContext1 = NSManagedObjectContext(concurrencyType:
.mainQueueConcurrencyType)
readContext1.parent = parentContext
let readContext2 = NSManagedObjectContext(concurrencyType:
.mainQueueConcurrencyType)
readContext2.parent = parentContext
我们可以看到父上下文是私有的,而子上下文是主要的。我们还可以看到我们如何直接将子上下文链接到其父上下文,而不是链接到持久存储。
“私有上下文”是什么意思?
私有上下文通常用于执行导入或导出数据等后台任务,允许这些任务异步执行而不阻塞主线程。
总体而言,父-子关系和多个上下文服务于不同的目的和用例。重要的是要说明它们提供了使用 Core Data 管理并发的基本原则。结合这些模式,甚至使用它们的原理来创建新的模式是完全可行的。
那么,使用NSManagedObjectID
呢?
当与上下文一起工作时,我们不能将一个上下文中获取的托管对象用于另一个上下文。我们需要使用NSManagedObjectID
重新获取它。这个关键点如果没有正确执行可能会导致异常。
这里有一个使用NSManagedObjectID
在另一个上下文中重新获取相同对象的代码示例:
var object: MyEntity?let fetchRequest: NSFetchRequest<MyEntity> = MyEntity.fetchRequest()
fetchRequest.predicate = NSPredicate (format: "id == %@", someID)
do {
object = try mainContext.fetch(fetchRequest).first
} catch {
print("Error fetching object: \(error)")
}
guard let objectID = object?.objectID else {
return
}
Let backgroundContext = NSManagedObjectContext
(concurrencyType: .privateQueueConcurrencyType)
backgroundContext.persistentStoreCoordinator =
persistentStoreCoordinator
backgroundContext.perform {
let backgroundObject = backgroundContext.object(with:objectID)
}
每个托管对象都有一个名为objectID
的属性,它在不同上下文中与同一对象相同。它的主要目的正是如此——确保在上下文之间移动时使用的是同一对象。NSManagedObjectID
是执行 Core Data 并发操作时的一个基本概念。
“对于一个包含成分、烹饪说明和用户评分的食谱应用,你的 Core Data 数据模型会是什么样子?”
为什么这个问题很重要?
我们是 iOS 开发者,而不是数据库管理员,但我们有重要的责任来设计一个符合我们应用业务需求的数据模型。糟糕的数据模型设计会直接影响应用的性能和稳定性。作为开发者,将应用的需求和工作流程转换为技术设计是我们的责任,这个问题评估了我们实现这一目标的熟练程度。
答案是什么?
为特定的应用使用创建数据模型需要我们遵循以下步骤:
-
理解应用的需求和基本流程。
-
定义不同的实体及其属性,包括数据类型、默认值等。
-
为相关属性创建索引。
-
理解不同实体之间的关系——一对一或一对多。
-
设置删除规则。
这些步骤在接近数据模型设计时始终相关,问题中描述的用例也不例外。
第一步是确定应用的主要工作流程。重要的是要记住,数据层应该支持业务逻辑和 UI,而不是独立存在。向面试官提出额外的问题是完全合法的;这甚至是任务的一部分。
第二步,我们可以定义基本实体:
-
食谱:这包含单个食谱的基本细节。属性:标题,创建日期,描述,难度级别和类别。
-
成分:这可以在多个食谱之间共享。属性:名称。
-
指令:这是使用配方时所需的步骤之一。属性:描述,标题。
-
用户评分:这是对单个食谱的评论。属性:名称,评分(int),和描述。
虽然实体是数据模型的关键部分,但如果没有明确定义它们之间的关系,它们通常是没有效果的。
因此,让我们了解实体是如何链接的,以及主要挑战(因为有一些挑战!)。
我们理解Recipe
与Instruction
和User Rating
之间存在一对一和一对一的关系。这很简单。我们该如何处理成分呢?简单的方法是定义一个与Ingredient
的一对多关系,就像我们处理Instruction
和User Rating
一样。关于Ingredient
的问题是我们可以在多个食谱之间共享它。例如,假设我们有一个帮助用户根据成分找到所有可用食谱的功能。
在这种情况下,这里需要一个多对多的关系。因此,这里有一些我们学到的知识:为了有效地构建数据模型,与面试官沟通并评估应用程序的各种功能是很重要的。我们还应该讨论数据的潜在用例,以确定最佳方法。
如果我们需要在食谱之间共享成分并创建多对多关系,我们可能需要开发一个不同的实体,那就是IngredientUsage
。
IngredientUsage
代表一个食谱中Ingredient
的一次使用,并在Recipe
和Ingredient
之间建立链接。除了这个链接之外,它还提供了更多信息,例如数量。
为了进一步探讨这个问题,让我们来看一下图 9**.1:
图 9.1 – Recipe、IngredientUsage 和 Ingredient 之间的关系
在图 9**.1中,我们解决的主要问题是 Core Data 无法在关系中放置关于关系的附加信息。我们可以在Recipe
和Ingredient
之间定义一对多关系,但不能告诉数量。数量不能成为Ingredient
的一部分,因为我们希望与其他食谱共享成分,这些食谱可能有不同的数量。这就是IngredientUsage
的作用。
另一个有用的提示是记住,在多对多关系的任何情况下都可能发生类似的问题。如果需要加载包含更多信息的关系,为关系创建一个专门的实体可以有效地解决这个问题。
现在我们已经定义了数据模型之间的关系,我们需要考虑删除规则。Core Data 有几个删除规则,选择正确的规则很重要,这样我们才能随着时间的推移维护我们的数据。
例如,如果我们删除Recipe
,我们希望其IngredientUsage
、User Rating
和Instruction
数据被删除。因此,我们将Recipe
与其他实体之间的删除规则定义为级联。级联是一个删除规则,如果源对象被删除,则删除相关对象。
然而,IngredientUsage
和Ingredient
之间并不是这样。如果我们删除IngredientUsage
,我们不希望删除成分,因为它正在与其他IngredientUsage
对象共享。在这种情况下,我们设置了Nullify的删除规则。
关于反向关系呢?如果我们删除了一个 User Rating
对象,是否需要删除一个 Recipe
对象?可能不需要。如果我们删除一个 User Rating
对象,我们希望保留 Recipe
。对于 Instruction
也是如此。在这两种情况下,我们都将删除规则设置为 Nullify。
为了回顾我们的方法,我首先概述了五个基本步骤,这些步骤构成了定义数据模型的过程。重要的是要注意,每一步都是建立在之前步骤的基础上的。为了继续回答,我们可以与面试官采取一种协作方法,通过逐一讨论每个步骤并大声说出我们的思考过程。这样做将确保得到一个很好的答案,即使解决方案可能不是完美或理想的。记住,面试官想看到我们的思考,而不是解决一个真实的问题。
“你将如何测试 iOS 应用中的 Core Data?”
为什么这个问题很重要?
Core Data 是一个至关重要的框架,在应用程序的数据层中扮演着重要的角色。因此,在我们测试应用程序时,它将始终存在。但这究竟意味着什么?我们如何测试持久化存储?这个问题检验了我们对于使用 Core Data 可以执行的不同测试以及执行这些测试的有效和一致性的工具的理解。
答案是什么?
我们可以使用 Core Data 执行三种不同的测试类型,我们已经在本书的早期讨论了测试。如果您需要刷新,请回到第六章并确保您熟悉不同类型的测试。
我们可以执行的第一种测试类型是单元测试——在这种情况下,我们想要模拟一个 Core Data 栈,而不使用实际的持久化存储文件。为了做到这一点,我们可以创建一个 内存中的持久化存储。内存中的持久化存储轻量级,不使用实际的数据库文件,并在 RAM 中执行所有 I/O 操作。
设置内存存储很简单:
let managedObjectModel = NSManagedObjectModel.mergedModel (from: [Bundle.main])!
let persistentStoreCoordinator =NSPersistentStoreCoordinator
(managedObjectModel: managedObjectModel)
do {
try persistentStoreCoordinator.addPersistentStore(ofType:
NSInMemoryStoreType, configurationName: nil, at: nil, options: nil)
} catch {
fatalError("Failed to create in-memory persistent store
coordinator: \(error)")
}
在前面的代码中,我们添加了一个类型为 NSInMemoryStoreType
的持久化存储,它只创建一个内存中的存储。
与 Core Data 相关的另一种相关测试类型是集成测试——在这种情况下,我们希望保持 Core Data 存储不变,并验证我们在业务逻辑或甚至 UI 层执行的操作是否被保存到 Core Data 文件中。在测试用例前后使用临时数据库文件或清理数据存储是一个好的做法。
与 Core Data 相关的第三种测试是性能测试。Core Data 包含一些可能执行起来很重的 I/O 操作,确保我们的应用程序中没有瓶颈是一个好主意。我们可以使用 XCTestCase
中的 measure
函数来检查 I/O 操作:
func testFetchPerformance() { let context = persistentContainer.viewContext
let request = NSFetchRequest<MyEntity>(entityName: "MyEntity")
measure {
do {
for _ in 0..<200 {
let result = try context.fetch(request)
}
let result = try context.fetch(request)
XCTAssertEqual(result.count, 1000)
} catch {
XCTFail("Failed to fetch objects:
\(error.localizedDescription)")
}
}
在前面的例子中,我们执行了 Core Data 检索并测量了其持续时间。为了模拟工作负载,我们执行了 200
次检索操作。测试断言当持续时间超过某个阈值时。
总结一下——我们可以在单元测试中测试 Core Data,消除任何 I/O 操作,集成测试以确保我们的系统按预期工作,以及性能测试来检查 Core Data 对我们应用性能的影响。
使用 UserDefaults 处理持久状态
UserDefaults
是 iOS 开发者以键值格式存储持久信息的根本且简单的方法。我们可以轻松地使用 UserDefaults 存储和检索布尔值、整数、字符串、数组和字典值。
例如,这是我们在 UserDefaults 中存储布尔值的方法:
let defaults = UserDefaults.standarddefaults.set(true, forKey: "isUserLoggedIn")
这是读取它的方法:
let defaults = UserDefaults.standardlet isUserLoggedIn = defaults.bool(forKey: "isUserLoggedIn")
UserDefaults 的目标不是存储和检索大型数据集——对于这种情况,UserDefaults 是一个慢速且不安全的解决方案。如果我们想管理本地数据存储,我们应该使用 Core Data 或 SQLite 来完成这项任务。
如前所述,UserDefaults 是一个非常简单且直接的工具。然而,它仍然有一些我们可能在准备面试时需要了解的高级功能。让我们回顾一些:
“解释 iOS 应用及其扩展如何使用 UserDefaults 共享数据。设置和使用 UserDefaults 在应用及其扩展之间共享数据涉及哪些步骤?”
为什么这个问题很重要?
许多应用在其生命周期中快乐地独立存在。但一旦我们添加了一个扩展或另一个应用,我们可能需要在它们之间共享一些数据。例如,我们可能需要共享密钥、令牌或配置文件信息。
幸运的是,Apple 为我们提供了一个安全且简单的方式来做到这一点。让我们看看答案。
答案是什么?
这些是我们需要在 iOS 应用和其扩展之间共享数据时执行的步骤:
-
设置 App Group:App Group 功能允许我们在多个扩展或应用之间组合和同步数据。我们使用开发者门户创建一个新的 App Group,并为其应用和扩展启用。
-
配置 Xcode 项目设置:一旦我们在开发者门户中设置了 App Group,我们就需要为我们的应用和扩展配置 Xcode 项目设置。我们需要指定 App Group 标识符并启用我们的应用和扩展的 App Group 权限。
-
使用 UserDefaults 共享数据:一旦配置了 Xcode 项目设置,我们就可以使用 UserDefaults 在应用和扩展之间共享数据。为此,我们需要创建一个新的UserDefaults对象,将 App Group 标识符作为套件名称,然后使用set(_:forKey:)方法保存数据,并使用object(forKey:)方法检索数据。
下面是一个如何设置和从共享UserDefaults
中读取数据的代码示例:
let defaults = UserDefaults(suiteName: "group.com.yourcompany. yourapp")!
defaults.set(true, forKey: "myBoolValue")
let myBoolValue = defaults.bool(forKey: "myBoolValue")
在这个代码示例中,我们正在设置一个新的UserDefaults
,并传递我们在开发者门户中定义的 App Group 标识符。
在本节中,其余的代码保持不变:使用键值机制进行读取和写入。
如我们所见——在应用和扩展之间共享数据是直接的,但这也是许多开发者不太熟悉的事情。在应用和扩展之间共享 Core Data 存储也是一个基本功能。
“你能解释如何在 iOS 应用中用 UserDefaults 存储结构体或类吗?”
为什么这个问题很重要?
保存原始字典和数组是使用 UserDefaults
时最常见的情况。但有许多情况我们需要存储整个对象或类。例如,我们有时需要保存包含完整详细信息的用户对象,而 Core Data 就像是用大锤砸核桃。
什么是 答案?
UserDefaults
存储对象或结构体有两种流行的方式:
-
第一个选项是使用 NSKeyedArchiver。我们可以在 UserDefaults 中保存的数据类型之一是 Data。NSKeyedArchiver 可以将结构体或对象转换为 Data 对象,可以直接保存到 NSKeyedArchiver 中。要解档对象,我们可以使用 NSKeyedUnarchiver。让我们看看一个代码示例:
struct Person { var name: String var age: Int}let person = Person(name: "John Smith", age: 30)let data = try? NSKeyedArchiver.archivedData(withRoot Object: person, requiringSecureCoding: false)UserDefaults.standard.set(data, forKey: "person")let storedData = UserDefaults.standard.data(forKey:"person")if let storedPerson = try? NSKeyedUnarchiver.unarchivedObject (ofClass: Person.self, from:storedData!) {}
在这个代码示例中,我们使用 NSKeyedArchiver
将 Person
结构体转换为 Data
对象。数据可以像任何其他保存到 UserDefaults
的数据类型一样轻松设置和恢复。在获取数据后,我们可以使用 NSKeyedUnarchiver
再次将其转换为 Person
对象。
- 第二个选项是使用 JSONEncoder。与 NSKeyedArchiver 一样,我们可以将结构体转换为 Data 对象。一旦我们有了数据对象,我们就可以从 UserDefaults 中设置和恢复 Person 对象。
让我们看看我们如何使用 JSONEncoder
保存和恢复相同的 Person
结构体:
struct Person: Codable { var name: String
var age: Int
}
let person = Person(name: "John Smith", age: 30)
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(person) {
UserDefaults.standard.set(encoded, forKey: "person")
if let storedData = UserDefaults.standard.data
(forKey: "person") {
let decoder = JSONDecoder()
if let storedPerson = try? decoder.decode
(Person.self, from: storedData) {
}
}
}
我们使用 JSONEncoder
将结构体转换为数据,使用 JSONDecoder
将数据恢复回 Person
。注意,在这种情况下,Person
需要遵守 conform to Codable,这要求其属性也遵守 Codable。
哪个选项更好?没有明确的答案。通常,对于我们的结构体和类来说,遵守 Codable 是最佳实践,因为它提供了更多功能,如自动解析和编码。
另一方面,当处理大型或复杂的数据集时,NSKeyedArchiver
比 JSONEncoder
更高效。
这两个 API 对于大多数情况都足够好,这取决于我们的应用结构和需求。
在 Keychain 中存储敏感信息
UserDefaults
是一个出色的存储机制,但它不适合存储密码或令牌等数据。Keychain 是 Apple 为存储敏感数据提供的解决方案,它提供了更高的安全性,并且是 iOS 开发者保护其数据的重要工具。
在密钥链中存储数据比使用其他解决方案要复杂得多。密钥链提供了一个基于 C 函数的特定 API,以防止恶意黑客对该 API 的调用进行逆向工程。密钥链在保存时还需要更多信息,以便更有效地保存和索引。
让我们看看如何将一个简单的令牌存储在密钥链中,同时用类包装以方便使用:
import UIKitimport Security
class KeychainManager {
private let serviceName = "MyAppTokenService"
func saveToken(token: String) -> Bool {
guard let tokenData = token.data(using: .utf8) else {
return false
}
let keychainItem = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: serviceName,
kSecAttrAccount: "MyAppToken",
kSecValueData: tokenData
] as CFDictionary
let status = SecItemAdd(keychainItem, nil)
return status == errSecSuccess
}
}
从那个代码示例中,我们首先注意到我们导入了 Security
框架,该框架也负责身份验证、安全传输和数据保护。
我们还可以看到,代码比使用 UserDefaults
要复杂得多。我们需要创建一个具有多个属性的密钥链项目,而不是存储一个简单的值:
-
kSecClass: 此密钥指定了项目的安全级别。我们可以从几个常量中选择,例如 kSecClassGenericPassword、kSecClassInternetPassword 和 kSecClassIdentity。选择正确的类别确保我们的数据有适当的安全级别。
-
kSecAttrService: kSecAttrService 定义了我们项目的服务名称。我们可以将多个项目分组在一起,以增加安全性,以防应用程序的一部分被破坏,以共享部分密钥链项目,以及更好地组织。
-
kSecAttrAccount: 这用于向密钥链项目添加标识,例如用户名或电子邮件。
-
kSecValueData: 这是我们要保存的实际数据。它可以是 Data 或 String。
这四个密钥并不是我们创建密钥链项目时唯一可以使用的密钥,但它们是最常见的。一旦我们有了 CFDictionary
,我们就可以使用 SecItemAdd
将密钥链项目推入密钥链。
这是如何从密钥链中读取令牌的示例:
func readToken() -> String? { let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as
CFDictionary, &result)
if status == errSecSuccess, let tokenData =
result as? Data, let token = String(data:
tokenData, encoding: .utf8) {
return token
} else {
return nil
}
}
要读取令牌,我们再次创建 CFDictionary
并使用 SecItemCopyMatching
查询密钥链并检索令牌。之后,我们检查结果——如果状态是 success
并且我们有 tokenData
,我们可以通过将其转换为字符串来提取令牌并返回它。
如我们所见,密钥链管理并不像其他存储工具那样简单,密钥链包装器是一个很好的解决方案,可以帮助我们简化这个过程。
“什么是密钥链访问组,以及如何使用它来在 iOS 应用程序的不同组件之间安全地共享密钥链项目,例如应用程序及其扩展?”
为什么这个问题很重要?
在上一节中,我们讨论了 UserDefaults
以及如何在我们的应用程序和扩展(或其他应用程序)之间共享信息。现在我们继续这个问题,并询问如何在不同组件之间共享敏感信息。这样,我们的应用程序扩展可以更强大且更独立。
答案是什么?
密钥链访问组 是一个唯一标识符,指定了哪些密钥链项目可以被特定的应用程序或扩展访问。
访问组在应用程序的权限文件中定义,并在应用程序的不同组件之间提供敏感数据的 secure sharing,例如应用程序及其扩展。
通过为应用程序的多个组件指定相同的访问组,这些组件可以安全地共享密钥链项,而不会损害其完整性。这个特性对于 iOS 应用程序开发者来说非常重要,因为它使他们能够为应用程序的多个组件提供安全可靠的数据存储机制。
这就是如何设置密钥链访问组并使用它的方法。
首先,我们需要在应用程序的权限文件中定义一个新的访问组:
<key>com.example.myapp.shared-keychain</key><array>
<string>$(AppIdentifierPrefix)com.example.myapp</string>
</array>
我们还必须在扩展的权限文件中定义相同的访问组。
现在,我们可以在我们的代码中使用我们创建的访问组来保存和检索密钥链值:
func savePasswordToKeychain(password: String) -> Bool { guard let data = password.data(using: .utf8) else {
return false
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessGroup as String:
"com.example.myapp.shared-keychain",
kSecAttrAccount as String: "myPassword",
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
注意我们添加了kSecAttrAccessGroup
键到我们的密钥链项中,并使用我们的新密钥链组。
当与密钥链一起工作时,我们可以看到我们的大部分样板代码是不同的密钥链值管理,而设置访问组则简单且直接。
一般而言——与 iOS 密钥链一起工作并不像我们拥有的其他工具那样简单——它需要更多的代码,使用 C 函数,并提供额外的键和信息。但 iOS 开发要求我们处理敏感信息,甚至在我们之间共享它。因此,我们必须了解密钥链是如何工作的,以及如何使用其 API 来处理它。
与文件系统一起工作
有一个常见的假设是 iOS“没有文件系统”。尽管有“文件”应用,但对于大多数标准用户来说,文件系统几乎是隐藏的。
对于 iOS 开发者来说,情况并非如此。
iOS 开发者使用 iOS 文件系统来存储文档、图像、缓存文件,甚至数据库文件。
文件系统允许我们存储大量信息,处理资源,甚至与其他应用程序组件共享数据。大多数面试问题都集中在组织我们的文件和响应不同的用例上。理解沙盒是如何构建的对于我们作为开发者来说至关重要。
因此,让我们回顾一个关于我们的沙盒结构的问题。
“你能解释一下 iOS 应用程序中以下文件夹的用途吗:Documents、Library、Cache 和 Temp?你将如何决定在你的应用程序中存储不同类型的文件使用哪个文件夹?”
为什么这个问题很重要?
从技术角度来看,iOS 中的文件操作在读取和写入方面通常很简单。然而,关键在于掌握在适当文件夹中正确组织文件的方法论概念。每个文件夹都有其独特的作用和特性,iOS 系统会明确管理每个文件夹。
答案是什么?
如前所述,每个文件夹都有其独特的作用和特性,所以让我们在这里了解一下:
-
Documents: 此文件夹旨在存储用户可以创建或编辑的数据,例如文档、图片和视频。此文件夹由 iCloud 备份,并且用户可以通过 iTunes 文件共享看到。我们应该为此文件夹使用用户期望在应用关闭后仍然可用的数据。
-
Library: 此文件夹旨在存储由用户未创建的应用特定数据,例如下载内容、缓存文件和首选项。此文件夹由 iCloud 备份,但用户通过 iTunes 文件共享无法看到。我们应该为此文件夹使用对应用功能重要但必要时可以重新创建的数据。
-
Cache: 此文件夹设计用于存储可以重新生成或再次下载的临时文件。此文件夹不由 iCloud 备份,当设备存储空间不足时,系统可以清空此文件夹。我们应该为此文件夹使用对应用功能非关键且必要时可以丢弃的数据。
-
Temp: iOS 还为存储临时文件提供了一个临时目录,称为Temp文件夹。此文件夹仅用于存放所需的临时文件,当应用关闭时可以删除。
内容类型引导我们决定将文件存储在哪个文件夹中。例如,我们希望使用Documents
文件夹来存储用户生成的文件。如果我们需要临时文件来生成信息或计算,可以使用Temp
文件夹。Library
文件夹适合存储本地数据持久存储文件。
将文件存储在错误的文件夹可能会导致意外的行为,例如数据丢失、性能问题,以及无端增加用户备份大小。
摘要
在本章中,我们讨论了持久内存的一些关键主题。我们讨论了 Core Data 并发和数据模型设计、UserDefaults
的高级主题、如何使用 Keychain 处理敏感信息,以及我们应用沙盒中的不同文件夹。
到现在为止,我们应该为面试中的那个主题做好准备!
下一章将涵盖一个可能阻碍 iOS 开发者(如果他们不熟悉)的应用可扩展性的关键主题:CocoaPods 和 Swift 包管理器。
第十章:库管理
到目前为止,我们已经讨论了 我们编写的代码 - Swift、UIKit 和 SwiftUI。但现代开发者的工作并不仅仅是编写代码。了解如何集成代码可以是一个生产力倍增器,大大提高我们的效率,并使我们能够在更短的时间内完成更多的工作,而不仅仅是知道如何编码。
CocoaPods 和 Swift 包管理器 是我们今天管理第三方和本地依赖项的主要解决方案。对于任何 iOS 开发者来说,彻底理解这些工具是至关重要的。
本章从以下主题的角度涵盖了 CocoaPods 和 Swift 包管理器:
-
学习 CocoaPods 的构建方式,包括如 Podfile 和 Podspec 文件等不同组件
-
概述 CocoaPods 的最佳实践 和用例
-
涵盖 Swift 包管理器的 创建过程
-
学习 Swift 包管理器的 常用命令
-
学习如何在我们的项目中 使用 Swift 包
-
比较 Swift 包管理器与 CocoaPods 的 不同优势
-
使用 Swift 包管理器 组织我们的项目
这是一个“简单”的章节,然而,在当今的 iOS 开发世界中,它是一个至关重要的主题。让我们从 CocoaPods 作为我们的第一个依赖管理器开始。
精通 CocoaPods
CocoaPods 是 iOS 开发者中最受欢迎的依赖管理器之一,并且作为一个开源项目已经维护了多年。
CocoaPods 经常是许多库开发者的首选,并且拥有大量可以轻松集成到 iOS 项目中的框架。
除了拥有大量的框架之外,CocoaPods 还支持与本地框架的集成。它可以帮助我们将项目模块化到不同的库中,使其更加灵活和有序。
让我们看看 CocoaPods 的工作原理以及它是如何构建的。
学习如何构建 CocoaPods
当我们使用 CocoaPods 在 Xcode 项目中管理依赖项时,CocoaPods 会创建一个新的工作区,该工作区包括我们的项目以及我们在 Podfile
文件中指定的任何依赖项。当我们运行 pod install
命令时,CocoaPods 会自动创建此工作区。
Xcode 工作区
在 Xcode 中,工作区是一个用于存放一个或多个 Xcode 项目以及构建我们的应用程序所需的任何其他文件和资源的容器。工作区用于组织和管理我们应用程序的不同组件,使开发测试我们的代码更加容易。
在 CocoaPods 中使用工作区有几个好处。它通过将项目和其依赖项包含在同一个工作区中来简化依赖项管理,简化集成,并通过保持依赖项更新来遵循关注点分离原则,无论我们的主项目如何。
除了工作区之外,CocoaPods 还包含几个不同的组件,每个组件都在管理 iOS 的依赖项中扮演着角色。
这里是 CocoaPods 的一些关键组件。
Podfile
Podfile
是一个文件,它指定了我们项目所需的依赖项。它使用简单的 Ruby 语法声明每个 pod 的名称和版本号,以及任何需要的选项或配置。Podfile
通常位于项目的根目录中。
这里是一个 Podfile
文件的例子:
platform :ios, 16.0'target 'MyApp' do
use_frameworks!
pod 'Alamofire', '~> 5.4'
pod 'SwiftyJSON', '~> 4.0'
end
现在,让我们了解文件是如何构建的:
-
要针对的平台是 iOS 16.0
-
Podfile 的目标是名为 MyApp 的 Xcode 项目。
-
use_frameworks! 指令告诉 CocoaPods 将依赖项构建为 动态框架。
-
pod 指令指定了项目的两个依赖项,Alamofire 和 SwiftyJSON,以及它们的版本要求。
~>
操作符是一个 乐观操作符,用于指定 pod 的版本,并允许它更新到下一个主要版本。
例如,看看以下行:
pod 'Alamofire', '~> 5.4'
在这种情况下,CocoaPods 将安装和更新 Alamofire
pod 到版本 6.0(不包括 6.0 本身)。这允许我们享受热修复和次要版本,而不会破坏向后兼容性。
Podfile
是我们所有依赖项的项目配置文件,必须仔细维护。
Podfile.lock
Podfile.lock
是一个文件,它存储了我们项目中安装的依赖项的具体版本信息。它确保在每台机器上安装相同的依赖项版本,这有助于防止版本冲突和其他问题。
它看起来是这样的:
PODS: - AFNetworking (2.6.3)
- Firebase/Analytics (7.6.0)
- Firebase/CoreOnly (7.6.0)
- FirebaseAnalytics (7.6.0)
- FirebaseCore (7.6.0)
- FirebaseCoreDiagnostics (7.6.0)
- FirebaseInstallations (7.6.0)
- GoogleAppMeasurement (7.6.0)
DEPENDENCIES:
- AFNetworking (~> 2.6.3)
- Firebase/Analytics
- GoogleAppMeasurement (~> 7.6.0)
当我们运行 pod update
或 pod install
时,CocoaPods 会自动生成 Podfile.lock
,我们不应该手动更新它,因为它可能导致冲突和问题。
Pods 目录
Pods
目录是一个包含 CocoaPods 为我们的项目安装的所有依赖项的目录。它包括每个 pod 的源代码、头文件和编译的二进制文件。
Podspec
Podspec
是一个文件,它描述了一个单独的 pod,包括其名称、版本、源代码位置、依赖项和其他元数据。Podspecs 发布到 CocoaPods 仓库,并由 CocoaPods 用于下载和安装 pod。
让我们看看 podspec 文件的例子:
Pod::Spec.new do |s| s.name = "MyLibrary"
s.version = "1.0.0"
s.summary = "A library for iOS and macOS
development."
s.description = "MyLibrary provides a set of tools and
utilities for iOS and macOS development."
s.homepage = "https://github.com/
myusername/MyLibrary"
s.license = "MIT"
s.author = { "My Name" => "myemail@example.com" }
s.platform = :ios, '14.0'
s.source = { :git => "https://github.com/
myusername/MyLibrary.git", :tag => "#{s.version}" }
s.source_files = "Sources/**/*.{h,m,swift}"
s.swift_version = '5.4'
s.dependency "Alamofire", "~> 5.4"
s.dependency "SwiftyJSON", "~> 4.0"
end
podspec 文件是用 Ruby 编写的,Ruby 是一种动态的面向对象的编程语言。实际上,Podfile
文件也是用 Ruby 编写的,因为它被认为是编写框架配置文件的方便且流行的方式。
在这个例子中,podspec 文件描述了一个名为 MyLibrary
的库,版本为 1.0.0
。我们还可以看到摘要、描述和其他一般细节,例如主页、许可证和作者。
在源文件和源代码中,我们可以看到 Git 仓库的位置(在这个例子中,在 GitHub 上)以及 pod 中包含的文件在 通配符模式 下的位置。
通配符模式
在 Podfile 中,可以使用通配符模式来定义 pod 应该安装的目录。
通配符模式(“*****”)代表任何字符以匹配任何文件或目录。例如,如果我们想指定所有以“M”开头的目录,我们可以使用以下命令:
Pod "MyPod", :path => "***M"
通配符模式不是 Podfile 特有的模式——它在许多类 Unix 命令行工具和终端中都有使用。
最后,我们添加了 pod 所需的依赖项,这样 CocoaPods 就会知道相应地管理其依赖关系树。
重要的是要注意,我们可以轻松地使用一个 podspec 文件来创建一个本地库并将其集成到我们的项目中。以下是一个本地库的 podspec 文件示例:
Pod::Spec.new do |s| s.name = "MyFramework"
s.version = "1.0.0"
s.summary = "A framework that modularizes code from
MyProject."
s.homepage = "https://github.com/
myusername/MyFramework"
s.license = "MIT"
s.author = { "My Name" => "myemail@example.com" }
s.platform = :ios, '14.0'
s.source = { :path => "." }
s.source_files = "MyFramework/**/*.{h,m,swift}"
s.public_header_files = "MyFramework/**/*.h"
s.frameworks = "UIKit"
s.dependency "Alamofire", "~> 5.4"
s.dependency "SwiftyJSON", "~> 4.0"
s.swift_version = '5.4'
s.pod_target_xcconfig = {
'SWIFT_INCLUDE_PATHS' => '$(SRCROOT)
/MyProject/MyModule'
}
end
除了修改我们的source
和source_files
属性外,我们还可以看到我们现在有一个pod_target_xcconfig
属性来指定项目中模块的源代码路径。
Podfile
在这种情况下将看起来像这样:
platform :ios, '14.0'target 'MyApp' do
use_frameworks!
pod 'MyFramework', :path => '../MyFramework'
end
在Podfile
中,我们指导MyFramework
到其本地路径,其中存在源文件。
Pod 命令行工具
pod 命令行工具是处理 CocoaPods 的主要接口。它提供了一套命令,允许我们安装、更新和管理项目的依赖项。
这些是我们可以使用的一些常用命令:
-
pod install:这个命令安装Podfile中指定的依赖项并生成一个 Xcode 工作空间,其中包含我们的项目和已安装的依赖项。
-
pod update:这个命令将Podfile中指定的依赖项更新到最新版本并安装。我们可以选择更新特定的库或一系列库。
-
pod lib create:这个命令生成一个新的 CocoaPods 库模板。我们可以用它快速设置新 pod 的目录结构、文件和配置。
-
pod search:这个命令在 CocoaPods 仓库中搜索与给定查询匹配的库。我们可以通过名称、描述、作者或其他标准来搜索库。
就像许多开发工具一样,CocoaPods 基于命令行工具、配置文件和终端,因此在 CocoaPods 的情况下不建议有“终端恐惧症”。
我们讨论了五个不同的 CocoaPods 组件,用于管理和理解 CocoaPods。如果你以前从未使用过 CocoaPods 或者没有创建过你的 pod,创建一个新的项目并尝试使用它是一个好主意,然后阅读 CocoaPods 文档,它清晰且直接。我保证,一个小时后你会更容易理解。
现在,让我们跳入两个可能在面试中遇到的问题,关于 CocoaPods 的。
“你在使用 CocoaPods 时遵循哪些最佳实践,为什么它们很重要?”
为什么这个问题很重要?
CocoaPods 或任何其他依赖关系管理器都是项目的一个基本组成部分。
在某种程度上,我们甚至可以说这是我们的项目中的脆弱部分,原因有几个:
-
这不是我们自己编写的代码:CocoaPods 将其他开发者编写的数千行代码集成到我们的项目中。我们对添加他人编写的代码对安全性、性能和稳定性产生的影响几乎没有控制权。然而,这些代码可能已经经过多次修复和测试周期。
-
存在依赖项冲突的潜在风险:一个结构不良的Podfile可能导致不同版本的依赖项之间发生冲突,并可能损害项目的稳定性。
-
过时的框架可能导致安全漏洞:开发者经常锁定框架到特定版本以保持项目稳定性高。这可能导致具有安全和稳定性问题的过时代码。
由于我刚才提到的这些原因,一个结构良好的Podfile
极大地影响我们的项目质量。
答案是什么?
当我们使用 CocoaPods 时,可以遵循一些最佳实践:
-
保持我们的框架更新:我们需要确保我们获得最新的错误修复和功能,以保持项目稳定。我之前提到的
~>
运算符建议用于获取最新的热修复和次要版本。 -
理解语义版本控制:保持框架更新至关重要。然而,我们应该了解语义版本控制是如何工作的。我们希望确保向后兼容性并避免破坏性更改。
-
保持依赖项的最小化:我们应该只包含我们真正需要的框架,并移除那些 Apple SDK 可以替换的框架。保持我们的项目轻量级和简单至关重要,避免潜在的冲突和崩溃问题。
-
保持 Podfile 的整洁和可读性:我们应该将我们的Podfile视为代码库的一部分。以逻辑方式分组依赖项可以为我们提供灵活性和清晰度。在此处的一个最佳实践是在每个库旁边添加注释并解释我们为什么添加每个库。库可能在我们应用中存在多年,我们添加的注释可以帮助我们在未来。
-
实现适配器模式:虽然适配器模式并非 CocoaPods 独有,但它是一个将库无缝集成到我们项目中的优秀模式。通常,库的接口与我们的现有代码库并不自然地匹配。通过引入一个充当我们的代码库和库之间接口的类,我们可以有效地连接这两个组件。此外,适配器还可以帮助我们解耦代码库和库,减少依赖。
我们必须记住一个重要的事情——库是我们代码的一部分。我们应该像管理我们的项目代码库一样关注第三方框架,因为它们极大地影响我们项目的性能。
“pod update 和 pod install 有什么区别?”
这个 问题为什么重要?
在上一个问题中,我们讨论了管理 CocoaPods 的最佳实践。我们讨论的一个关键方面是管理 pods,以防止它们引起任何问题或破坏我们的代码。
pod update
和 pod install
命令都帮助我们决定更新 pods 的策略,实现一种谨慎和负责任的方式来保持第三方库的更新。
答案是什么?
pod install
和 pod update
是在 iOS 项目的 CocoaPods 依赖管理器中使用的命令。它们之间的主要区别在于它们处理依赖解析的方式。
pod install
安装 Podfile.lock
中指定的依赖项,并确保 pod 不会收到意外的更新。
另一方面,pod update
将当前 pods 更新到最新的次要版本,并最终将 Podfile.lock
更新到新版本。
有一个需要注意的事项是,如果没有 Podfile.lock
,这两个命令 表现相似。
在日常工作中,这两个命令之间的区别至关重要。Podfile.lock
帮助我们控制框架的更新,并保持 pods 在特定版本,只要我们使用 pod install
,而 pod update
可以绕过 Podfile.lock
中写的内容。
我建议在讨论最佳实践时将 Podfile.lock
包含在项目的代码库中。这在与团队协作时尤其重要,因为 Podfile.lock
确保所有团队成员使用相同的框架版本。
总结本节,到现在为止,我们应该了解 CocoaPods 的工作原理以及如何使用它来链接第三方库并维护一个稳定和健壮的项目。
作为管理 iOS 项目依赖项的基本工具,CocoaPods 应该被给予极高的重视。这适用于任何依赖管理器,包括我们将要审查的 Swift 包管理器。
了解 Swift 包管理器
在过去几年中,CocoaPods 和 Carthage 在管理 iOS 项目的依赖项方面发挥了重要作用。
虽然 CocoaPods 和 Carthage 是出色的工具,但每个平台都需要有自己的内部依赖管理器,苹果确实开发了一个名为 Swift 包管理器(SPM)的本地依赖管理器。
那么,SPM 是什么?
SPM 是一个直接集成到 Xcode 中的依赖管理器,允许开发者轻松创建、管理和共享 Swift 包,这些是包含在代码中的自包含单元,可以在不同的项目中使用。一个包可以包含一个或多个目标,每个目标都是一个模块,可以被其他包或项目导入和使用。
让我们从零开始创建一个 Swift 包。
创建一个 Swift 包
创建一个新的 Swift 包很简单。有两种方法可以做到这一点——使用 终端 和 Xcode:
-
使用终端:打开 终端 应用,进入项目文件夹(或任何其他文件夹),并输入以下命令:
swift package init --type library
此命令将创建一个包含相关子文件夹和文件的新的文件夹,以设置一个基本且空的 Swift 包。
- 使用 Xcode 创建 Swift 包: 如果您不想使用终端来创建 Swift 包,另一个选项是使用 Xcode。
让我们回顾创建新的 Swift 包所需的步骤:
-
打开 Xcode 并从菜单栏选择 文件 | 新建 | 包。
-
在 创建新的 Swift 包 对话框中,输入包详细信息,例如包名、组织机构和类型。您可以选择库或可执行包,并指定包的平台和产品。
-
点击 创建 以创建新的 Swift 包。Xcode 将生成一个包含 Sources 目录和 Package.swift 清单文件的基本项目结构。
这两种选项都很简单直观,只需几秒钟即可完成。现在,让我们了解我们创建了什么。
检查包清单和文件夹
Swift 包由三个组件组成:
-
源 目录: 此目录包含我们包的 Swift 源文件。默认情况下,它包括一个与包同名的子目录和一个与目录同名的单个源文件。
-
测试 目录: 此目录包含我们代码的测试文件。默认情况下,它包括一个单个子目录,一个与目录同名的单个测试文件。
-
Package.swift: 此文件是包清单文件。它包含有关包的信息,例如其名称、版本和依赖项,并由 SPM 用于构建和管理包。
在 精通 CocoaPods 部分,我们讨论了 CocoaPods 并提到了 podspec
文件。在 SPM 中,package.swift
文件与 CocoaPods 中的 podspec
文件具有类似的作用。
这里是一个标准 package.swift
文件内容的简短示例:
// Package.swiftimport PackageDescription
let package = Package(
name: "MyPackage",
platforms: [
.macOS(.v10_12), .iOS(.v10), .watchOS(.v3), .tvOS(.v10)
],
products: [
.library(name: "MyPackage", targets: ["MyPackage"])
],
dependencies: [
.package(url: "https://github.com/
Alamofire/Alamofire.git", from: "5.0.0")
],
targets: [
.target(name: "MyPackage", dependencies: ["Alamofire"]),
.testTarget(name: "MyPackageTests", dependencies:
["MyPackage"])
]
)
我们可以看到 package.swift
文件是用,嗯…… Swift 编写的。因此,对于 iOS 开发者来说阅读它应该是简单的。让我们了解它说了什么。
此 Package.swift
文件指定了名为 "MyPackage"
的 Swift 包的详细信息。该包针对多个平台 - macOS、iOS、watchOS 和 tvOS。它提供了一个与包同名的单个库产品。
该包依赖于 Alamofire
包,指定为具有最小版本 5.0.0
的依赖项。该包还包含两个目标,一个用于包本身,另一个用于其测试。包目标依赖于 Alamofire
包,而测试目标依赖于 MyPackage
目标。
当 SPM 安装我们的包时,它也会建立我们在 package.swift
文件中定义的依赖项,就像它与 CocoaPods 一样工作。
另一个我们应该了解的有趣的事情是为什么它被称为“包”而不是“库”。原因是 Swift 包可以包含 多个库(在“产品”下)和不同的目标,理解这个层次结构对于创建灵活的包至关重要。
我们如何构建和测试包?让我们看看。
Swift 包常见命令
虽然可以在 Xcode 中使用 SPM,但了解主要的终端命令仍然至关重要。这是因为用户界面倾向于更频繁地变化,而命令行工具则随着时间的推移保持更一致。
然而,一个更重要的原因是,终端命令为我们提供了将它们集成到脚本和 CI 机器中的能力,使它们变得强大而有效。
这里是命令列表:
-
swift package init:我们之前在讨论 Swift 包创建时看到了这个命令。它将在当前目录中初始化一个新的空 Swift 包。
-
swift package update:这会将包的依赖项更新到最新兼容版本。它与 CocoaPods 中的 pod update 类似。
-
swift build:这将构建包及其依赖项,生成二进制或库产品。
-
swift test:如果需要,这将运行包的单元测试,并构建包。
-
swift package clean:这将删除包的构建工件,包括构建目录。
在面试的背景下,将这个命令列表视为一个“功能列表”。这个列表应该显示我们如何操作和维护一个 Swift 包。
使用 Swift 包
使用 Swift 包库就像我们添加到项目中的任何其他库一样。我们关于访问级别的所有知识也适用于这种情况。
例如,一个应用只能访问公共和开放的功能、类和属性,而内部级别则保留给库。
此外,为了使用库,我们需要将其导入到我们的代码中。以下是一个示例:
import MyPackagelet myObject = MyClass()
myObject.myMethod()
在这个例子中,我们首先使用 import
语句将 MyPackage
模块导入到我们的代码中。然后,我们创建 MyClass
类的一个实例并调用其 myMethod
函数。在这种情况下,MyClass
是 MyPackage
的一部分。这个例子适用于处理依赖项时的应用和包。
一般而言,使用 SPM 非常简单直接。苹果在保持其能够在终端命令中执行一切功能的同时,出色地将它集成到 Xcode 中。
这里的目标是简要解释 SPM,然后我们再继续下一个面试问题。
“与 CocoaPods 相比,使用 SPM 的优缺点是什么?”
为什么这个问题很重要?
这两个工具都非常适合管理 iOS 中的依赖项,但就像任何其他工具一样,它们都有其优点和缺点。
理解这些工具的实际差异可能甚至比使用它们更重要,因为后者是直接和技术的。选择正确的工具对我们的应用维护和稳定性有重大影响。
答案是什么?
就像任何利弊一样,答案取决于项目的需求和需求。然而,CocoaPods 和 SPM 之间有一些已知的不同之处。
这些是 SPM 的优点:
-
Swift 项目的内置工具,因此无需第三方依赖
-
简单且 易于使用 的语法
-
与 Swift 和 Xcode 集成,包括生成 Xcode 项目的支持
-
自动依赖解析 和缓存
-
更快的 构建时间,适用于更小的项目
这些是 SPM 的缺点:
-
对二进制依赖的支持有限
-
不支持 Objective-C 或混合语言项目
-
构建设置的定制选项有限
这个列表表明,SPM 的优缺点与之前在 掌握 CocoaPods 部分中提到的相反。
一方面,CocoaPods 比 SPM 更常用且更灵活。另一方面,它更复杂、更慢,在 Apple 的开发生态系统中表现得像一个外来者。
在开始面试之前,你应该尝试每个解决方案,以了解它们的感觉和可能性。
“有哪些最佳实践可以用来组织和结构化 Swift 包以优化构建时间和最小化依赖冲突?”
为什么这个问题很重要?
这个问题超出了技术范畴。正如我们之前看到的,处理 Swift 包的技术部分很简单,即使是初级开发者也能轻松应对。然而,有效地组织和高效地管理项目以适应包,才是真正的挑战。
关于代码模块化和组织的大量文章和研究证明了这个问题的重要性。其中大部分甚至没有提到 SPM。
即使遵循这里提到的最佳实践的很小一部分,也能显著影响我们的项目。
答案是什么?
有一些最佳实践可以用来组织我们的代码以优化构建时间和解耦我们的依赖:
-
保持包小:包含许多依赖的大型包可能会减慢构建时间并增加冲突的风险。为了最小化这些问题,保持包小和模块化是一个好的实践。这使得管理依赖变得更容易,并减少了冲突的风险。
-
最小化依赖:为了减少冲突的风险并提高构建时间,尽可能最小化依赖是一个好的实践。这可以通过仅使用包的必需依赖项并避免不必要的或重复的依赖项来实现。
-
使用语义版本控制:我们可以使用语义版本控制来管理包及其依赖的版本号。通过使用语义版本控制,我们可以向其他开发者和包的用户传达更改和兼容性要求。
-
使用增量构建:SPM 支持增量构建,这意味着只有当进行更改时,才会重新构建包的必要部分。这有助于提高构建时间并减少不必要的重新编译。
我们可以说,这里描述的最佳实践可以用来模块化任何项目,甚至任何类。扁平的层次结构、最小化依赖和小的库都是我们在项目中管理库的好建议。
摘要
依赖管理器就像双刃剑。无论是 SPM 还是 CocoaPods,它们都可以在模块化和分离方面为我们节省时间,成为我们项目的优秀补充。然而,如果处理不当,它们也可能对我们的应用程序的稳定性和架构的简洁性产生破坏性的影响。这就是为什么作为 iOS 开发者,我们必须掌握这个话题。
在本章中,我们学习了 CocoaPods 和 SPM 的基础知识,包括最佳实践及其优缺点。到目前为止,当我们被问到关于 iOS 最常见的第三方依赖管理器时,我们应该已经涵盖了所有内容。
从某种意义上说,我们的下一章与本章所讨论的内容紧密相连。我们将逐渐从标准的 Swift 和 UIKit 主题转向设计和架构的世界。
我们的下一章将会非常精彩!
第四部分:设计和架构
这是本书的最后一部分,在这里我们释放了代码设计和架构的力量。我们将涵盖不同的设计模式,例如 MVVM 和依赖注入,讨论架构,并为编码评估做准备。到这一部分结束时,我们将准备好成功结束面试并收到我们的工作提案。
在这一部分,我们有以下章节:
-
第十一章,解决复杂问题的设计模式
-
第十二章,深入应用架构
-
第十三章,通过编码评估
第十一章:解决复杂问题的设计模式
在前面的章节中,我们讨论了 iOS 开发的不同方面。我们涵盖了 UIKit、Swift、响应式编程、SwiftUI、Core Data 以及更多。这些构建块帮助我们达到下一个层次——设计模式。
设计模式就像工具。每一个都解决不同的问题或不同的需求,例如以下内容:
-
我们需要改变特定实例的行为吗?我们可以使用 依赖 注入(DI)。
-
我们需要管理复杂的状态吗?我们可以使用 模型-视图-视图模型(MVVM)。
-
我们需要定义对象之间的通信吗?我们可以使用代理。
我们的设计模式工具箱越丰富,我们能解决的问题就越多。我们应该记住,设计模式本身并不是我们的目标——它们是完成我们任务的工具。我们应该记住,在面试中,我们可能需要选择一个特定的设计模式或讨论它。
在本章中,我们将介绍 iOS 开发中常用的一些设计模式。我们将做以下几件事:
-
讨论 模型-视图-控制器(MVC)和 MVVM,包括一些面试问题
-
使用依赖注入解耦我们的代码
-
通过代理改进通信
-
使用单例共享状态
-
使用并发改进性能
面试中最常问的话题之一是列表中的第一个——MVC 和 MVVM。所以,让我们直接进入正题。
使用 MVC/MVVM 构建用户界面
几种已知的设计模式可以帮助我们构建稳定且复杂的界面,但 MVC 和 MVVM 是最常见和最著名的设计模式。
就像许多开发领域一样,MVC 和 MVVM 的主题可能会受到个人偏好和观点的影响,并且可能并不总是与实际考虑相符。我们始终需要小心这一点,尤其是在求职面试时。让我解释一下我的意思。
使用 MVC 和 MVVM 解决不同的问题
我想讨论一下在与面试官或同事进行专业讨论时建议避免的几个句子:
-
“我的应用程序是使用 MVVM 架构构建的。”
-
“MVC 是过时的,是一种糟糕的架构。我从不使用它。”
-
“这不是 MVVM 的工作方式。让我给你演示一下。”
记住我在书中多次提到的一点——作为开发者,我们应该避免二分法思考。MVC 和 MVVM 解决不同的问题,我们应该将这两种模式视为解决各种问题的不同方案。
事实上,我们可以在同一个应用程序、同一个功能或同一个界面上使用不同的设计模式。
此外,实现 MVC 和 MVVM 的方法不止一种。更重要的是遵循不同的原则并解释它们。
让我们从更直接的模式——MVC 开始。
学习 MVC
MVC 代表模型-视图-控制器。当 iOS 开发时代开始时,苹果公司使用 MVC 来展示构建 UI 界面的最佳实践,并且它是构建应用程序的主要设计模式。
MVC 的基本原则是在视图和模型之间进行分离,视图是用户所见并与之交互的部分,而模型则代表业务逻辑和数据层。
在 MVC 中,视图和模型之间没有直接连接,所有数据流都是通过控制器完成的。
让我们看看一个经典的 MVC 模式(图 11.1):
图 11.1 – MVC 设计模式
在图 11.1中,我们可以看到视图和模型通过控制器相互通信。这种分离使我们能够在项目的不同用例中重用每个组件。
我们如何在 iOS 世界中实现 MVC?让我们看看:
-
视图:视图代表屏幕上显示的 UI。因此,我们通常使用 UIKit 视图元素之一来实现它,例如按钮、标签和文本字段(我们将在下一节中讨论 SwiftUI)。
-
模型:模型是我们的数据和业务逻辑。我们使用数据结构、持久存储、不同的算法和网络请求来实现它。
-
控制器:在 iOS 开发中,控制器主要是UIViewController及其不同的子类,如UITableViewController和UIAlertController。
注意苹果如何在 iOS 中实现 MVC 模式。UIViewController
并不像我们在图 11.1中看到的那样是一个纯粹的控制器。它有自己的视图,并负责用户交互。从某种意义上说,UIViewController
是 UI 的一部分,而不仅仅是控制器。
在 SwiftUI 中,情况略有不同——SwiftUI 的模式更类似于 MVVM 而不是 MVC。当我们讨论到 MVVM 时,我们会更详细地考察这一点。
让我们看看如何在 iOS 中实现 MVC。以下是模型部分:
class Person { var name: String
var age: Int
init(name: String, age: Int) {
self.name = name
self.age = age
}
func canVote() -> Bool {
return age >= 18
}
}
Person
代表数据结构,并有一个逻辑函数(canVote()
)。它没有任何对视图或控制器的引用。
下面是视图:
class PersonView: UIView { var nameLabel: UILabel
var ageLabel: UILabel
var canVoteLabel: UILabel
init(frame: CGRect) {
super.init(frame: frame)
nameLabel = UILabel()
ageLabel = UILabel()
canVoteLabel = UILabel()
}
func configure(with person: Person) {
nameLabel.text = person.name
ageLabel.text = "\(person.age)"
canVoteLabel.text = person.canVote() ? "Can vote" :
"Can't vote"
}
}
我们可以看到PersonView
类与逻辑无关,纯粹关注 UI 展示。这使得它可以与其他逻辑和模型一起重用。
注意PersonView
有一个configure(with person:Person)
函数。这种常见的做法有助于我们使用特定的模型来加载视图。我们可以将此代码移动到扩展中,以增加代码的分离。
现在,让我们转向控制器:
class PersonViewController: UIViewController { var person: Person
var personView: PersonView
init(person: Person) {
self.person = person
personView = PersonView()
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(personView)
personView.configure(with: person)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
PersonViewController
对Person
和PersonView
都有引用,并负责在它们之间建立联系,并使用来自Person
的数据加载视图。注意PersonView
和Person
之间没有引用——PersonViewController
充当控制器,并在viewDidLoad
函数中设置所需的内容。
之前,我们讨论了 MVC 如何改进我们的代码,使其可重用,因为我们可以在项目中重用模型和视图组件。在 iOS 开发中,我们还应该将 MVC 视为一个可以重用的自包含单元。
例如,我们可以有一个基于两个嵌入式视图控制器(每个都是一个 MVC 单元)的屏幕。看看图 11.2:
图 11.2 – 两个嵌入式 MVC 单元
MVC 单元不一定要是一个完整的屏幕——这种方法可以帮助我们重用屏幕的一部分并扩展我们项目的灵活性。
MVC 模式对于不需要复杂状态和数据操作的简单屏幕非常出色。在这种情况下,我们必须转向更复杂的模式——MVVM。
探索 MVVM
我认为这是本章(也许甚至是整本书)的一个关键检查点。开发者往往对特定的模式有很强的依赖性,尤其是与 UI 相关的。MVVM 并不比“MVC”更好,反之亦然。它们都是针对不同用例的模式,这在面试中至关重要。我们永远不应该被特定的技术或模式所束缚,尤其是在面试中。
我们说过 MVC 不是复杂状态和数据管理的最佳模式。但为什么呢?
复杂的屏幕需要状态管理——显示/隐藏某些 UI 元素、更新文本、更改颜色,并在屏幕上呈现动态信息。所有这些都可以使我们的视图控制器变得臃肿。
这也是为什么 iOS 开发者曾在 App Store 的第一个时代依赖 MVC,但很快转向了更合适的模式——MVVM。
MVVM 代表模型-视图-视图模型。其理念是视图通过视图模型连接到模型——另一个可以帮助我们管理状态和操作数据的组件。
看看图 11.3:
图 11.3 – MVVM 设计模式
在图 11.3中,我们可以看到视图模型像 MVC 模式中的控制器一样位于视图和模型之间。
但在 MVVM 中,不同组件的责任更加透明和直观。
现在我们来回顾一下不同的组件:
-
视图:视图负责展示信息和响应用户交互。它是唯一可以访问 UIKit 框架(我们很快会谈到 SwiftUI)的组件,这与我们之前讨论的 MVC 模式有显著的不同。
-
视图模型:视图模型处理状态并为展示准备数据。此外,视图模型决定用户交互并将请求向前推进到模型层。
-
模型:模型层是实际的业务逻辑,负责访问持久存储和执行网络请求。
注意到视图(View)和视图模型(ViewModel)是通过数据绑定进行通信的,这连接了输入字段到对应的数据模型。实际上,视图模型甚至没有视图的引用——视图观察视图模型的变化并相应地刷新自己。
这就是 SwiftUI 和 Combine 派上用场的地方。例如,在 SwiftUI 中,ViewModel 通常是从@ObservableObject
类派生出来的,具有@Published
属性。
忘记了 SwiftUI 和 Combine?
现在,是时候回到第八章并刷新一下关于 SwiftUI 和 Combine 的记忆。看起来苹果仔细研究了开发者如何开发屏幕,并为 MVVM 构建了一个专门的框架。
现在,让我们看看 MVVM 的实际应用。
查看一些代码示例
让我们看看 MVVM 设计模式的代码示例。在我们的例子中,我们有一个带有标签的屏幕,该标签显示加载请求的状态(加载中…,就绪或错误)。标签绑定到一个特定的 ViewModel,该 ViewModel 与 Model 通信并更新其视图。
让我们从 ViewModel 开始:
import Foundationimport Combine
class StatusViewModel {
private let networkService: NetworkService
private var cancellables = Set<AnyCancellable>()
private let statusDidChange = PassthroughSubject
<String, Never>()
var status: String = "Loading..." {
didSet {
statusDidChange.send(status)
}
}
init(networkService: NetworkService) {
self.networkService = networkService
}
func fetchStatus() {
networkService.fetchStatus()
.sink { completion in
switch completion {
case .failure(let error):
self.status = "Error: \
(error.localizedDescription)"
case .finished:
break
}
} receiveValue: { isReady in
self.status = isReady ? "Ready" : "Not ready"
}
.store(in: &cancellables)
}
现在,让我们观察状态变化:
func observeStatusChange(handler: @escaping (String) -> Void) {
statusDidChange
.receive(on: RunLoop.main)
.sink { status in
handler(status)
}
.store(in: &cancellables)
}
}
代码示例有点长,但很容易理解。ViewModel 通过 Combine 模式观察网络请求-响应并更新其值。Combine 流从网络服务开始,并将结果转发到status
属性,该属性可以被视图观察。请注意,ViewModel 不处理任何 UI 元素——那是视图的工作。
现在,让我们看看视图:
import UIKitclass StatusLabel: UILabel {
var viewModel: StatusViewModel? {
didSet {
viewModel?.observeStatusChange { [weak self]
status in
self?.text = status
}
viewModel?.fetchStatus()
}
}
}
StatusLabel
类有一个对 ViewModel 的直接引用,并观察变化以刷新自己。
剩下的唯一事情就是将视图与 ViewModel 连接起来,这就是在这个情况下视图控制器唯一的工作:
import UIKitclass ViewController: UIViewController {
let networkService = NetworkService()
let statusViewModel = StatusViewModel(networkService:
NetworkService())
let statusLabel = StatusLabel()
override func viewDidLoad() {
super.viewDidLoad()
statusLabel.frame = CGRect(x: 50, y: 50, width:
200, height: 50)
statusLabel.textAlignment = .center
view.addSubview(statusLabel)
statusLabel.viewModel = statusViewModel
}
}
ViewController
将 ViewModel 注入到视图中,将NetworkService
注入到 ViewModel 中。想象一下在 MVC 中这样做,有多个组件和复杂的数据操作;你会得到一个包含 3,000 行代码的视图控制器。
与 MVC 相比,MVVM 是一种现代的设计模式,可以帮助我们更有效地分离关注点,并处理更复杂的状态管理和数据操作。我们可以为每个我们认为需要 ViewModel 的视图创建一个 ViewModel,并按我们的意愿组织我们的展示逻辑。
现在,让我们继续讨论一些关于 MVC/MVVM 设计模式的问题。
“如何在 MVVM 架构中实现导航,考虑到 ViewModel 不应该了解视图?”
为什么这个问题很重要?
在理论上学习 MVVM 很容易。将 ViewModel 属性绑定到 UI 元素是简单的,但将其应用于现实世界问题才是真正的挑战。iOS 和移动开发中最常见的现实世界问题之一是导航与状态和逻辑的结合。
当 ViewModel 缺乏对视图的直接引用,而视图又不能独立处理导航时,导航问题的解决方案是什么?
答案是什么?
当讨论导航时,我们需要决定三件事:
-
如何触发导航操作
-
如何选择导航位置
-
如何导航
这些是三种可以分离到各种组件中的不同责任。例如,我们可以决定 ViewModel 可以触发导航操作,并选择去哪里,而视图可以处理导航操作本身。
另一个选择是决定 ViewModel 触发导航。然而,去哪里可以是视图的一部分或另一个专门为此目的而明确创建的对象。
让我们看看图 11.4:
图 11.4 – 使用 MVVM 的简单导航模式
在图 11.4中,ViewModel 通过委托模式或Combine触发导航并通知视图。然后视图请求导航控制器导航到特定的目的地,这也是视图控制器有导航控制器引用的部分原因。这种简单的导航模式将导航责任放在了视图上。
下面是如何在代码中实现这一点。这是 ViewModel 的样子:
import Foundationimport Combine
class MyViewModel {
private let navigationSubject = PassthroughSubject
<Void, Never>()
var navigation: AnyPublisher<Void, Never> {
return navigationSubject.eraseToAnyPublisher()
}
func didTapButton() {
navigationSubject.send(())
}
}
我们可以看到 ViewModel 有一个didTapButton()
方法,并决定使用navigationSubject
发送消息。
现在,让我们回顾一下视图控制器:
import UIKitimport Combine
class MyViewController: UIViewController {
var viewModel: MyViewModel!
private var cancellables = Set<AnyCancellable>()
let button = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
viewModel.navigation
.sink { [weak self] in
self?.navigateToDetails()
}
.store(in: &cancellables)
}
private func setupUI() {
button.addTarget(self, action: #selector
(didTapButton), for: .touchUpInside)
}
@objc private func didTapButton() {
viewModel.didTapButton()
}
private func navigateToDetails() {
let detailsViewController = DetailsViewController()
navigationController?.pushViewController
(detailsViewController, animated: true)
}
}
ViewModel 的导航主题根据其自己的逻辑触发导航,在这个例子中是点击按钮。
ViewController
观察 ViewModel 的导航发布者,并使用导航控制器推送detailsViewcontroller
。
如前所述,这个模式很简单,将很多责任放在了视图控制器上。如果我们想分离我们的代码,我们可以将导航责任委托给另一个类(Coordinator)。参见图 11.5:
图 11.5 – MVVM 和协调器模式
在协调器模式中,ViewModel 通知协调器关于一个导航意图,从而推送一个新的屏幕。看看 ViewModel 现在是什么样子:
import Foundationimport Combine
class MyViewModel {
private let didTapButtonSubject = PassthroughSubject
<Void, Never>()
var didTapButtonPublisher: AnyPublisher<Void, Never> {
return didTapButtonSubject.eraseToAnyPublisher()
}
func didTapButton() {
didTapButtonSubject.send(())
}
}
ViewModel 发送一个带有按钮点击的消息,协调器可以订阅它并做出响应。这是协调器的样子:
class MyMainCoordinator: MyCoordinator { private var cancellables = Set<AnyCancellable>()
func start() {
let viewModel = MyViewModel()
viewModel.didTapButtonPublisher
.sink { [weak self] _ in self?.didTapButton()
}
.store(in: &cancellables)
let viewController = MyViewController()
viewController.viewModel = viewModel
navigationController.pushViewController
(viewController, animated: true)
}
func didTapButton() {
let detailsViewController = DetailsViewController()
navigationController.pushViewController
(detailsViewController, animated: true)
}
}
我们可以看到协调器创建了 ViewModel 和视图,然后将一切连接起来。在前面的例子中,视图控制器观察 ViewModel 事件并推送一个新的视图控制器。同样,在这种情况下,协调器观察didTapButtonPublisher
并决定推送一个新的视图控制器 – DetailsViewController
。视图和 ViewModel 对这个过渡一无所知,因为它完全由协调器管理。
通常,两种方式都有其优缺点。使用协调器是一个强大且复杂的模式。然而,对于更简单的情况,我们可以将导航控制器连接到视图控制器,这样开销很小。重要的是要理解导航原则并平衡责任。
“为什么 MVVM 架构被认为在 iOS 应用开发中具有良好的可测试性?”
为什么这个问题很重要?
在近年来设计代码时,测试变得越来越重要。这不仅仅是能够测试代码,还关于编写高质量、结构良好且易于维护的代码。考虑到这一点,面试官希望我们在回答中考虑编写可测试代码的重要性。
答案是什么?
MVVM 设计模式适合测试,因为它以简单的方式分离了关注点。状态和数据操作代码,这是我们想要测试的最重要部分,是 ViewModel 的一部分。我们可以轻松设置 ViewModel,无需处理 UI,只需通过模拟 View 进行测试。
我们也可以测试 MVC 单元,但由于状态管理是 View Controller 或 View 的一部分,所以它比测试 MVVM 模式更复杂。
这里是一个如何测试 ViewModel 的代码示例。让我们从定义一个标准的 ViewModel 开始:
import Foundationimport Combine
class MyViewModel {
let didTapButton = PassthroughSubject<Void, Never>()
@Published var labelValue: String = ""
private var cancellables = Set<AnyCancellable>()
init() {
didTapButton
.map { "Ready" }
.assign(to: \.labelValue, on: self)
.store(in: &cancellables)
}
}
我们创建了一个处理点击按钮并更新标签的 ViewModel。注意,这里没有 UIKit 相关的代码,所以它应该相对容易测试。现在,让我们看看测试本身:
import XCTestimport Combine
@testable import MyProject
class MyViewModelTests: XCTestCase {
func testLabelValue() {
let viewModel = MyViewModel()
let labelValueExpectation = viewModel.$labelValue
.dropFirst() // Ignore initial value
.sink { labelValue in XCTAssertEqual(labelValue, "Ready")
}
viewModel.didTapButton.send(())
labelValueExpectation.cancel()
}
}
testLabelValue()
函数观察 ViewModel 的 labelValue
,以查看在点击按钮时它是否等于 "Ready"
。我们没有设置 View 就完成了所有这些,在这种情况下,View 只处理 UI 逻辑。
使用依赖注入解耦
DI 是一种强大的模式,帮助我们创建模块化和可测试的代码。它是我们工具箱中的另一个工具,可以帮助我们使代码灵活且解耦。
在 iOS 中实现依赖注入(DI)有几种方法,接下来将进行讨论。
使用构造函数注入
这是 iOS 中最常用的 DI 形式。在构造函数注入中,依赖项通过初始化器传递给对象。例如,如果我们有一个依赖于数据管理器的视图控制器,我们可以在视图控制器的初始化器中注入数据管理器。
在以下代码示例中,我们创建了一个自定义的 init()
函数和一个私有变量来保存注入的数据管理器:
class MyViewController: UIViewController { private let dataManager: DataManager
init(dataManager: DataManager) {
self.dataManager = dataManager
super.init(nibName: nil, bundle: nil)
}
}
let viewController = MyViewController
(dataManager: dataManager)
构造函数 DI 的主要优势是我们有一个清晰的接口,用于类所需的依赖项,因为我们必须在 init()
方法中传递它们。
使用设置器注入简化事情
在设置器注入中,设置器方法将依赖项传递给对象。对象将其依赖项声明为公共属性,DI 框架使用适当的依赖项设置这些属性。设置器注入不如构造函数注入常见,但在运行时更改对象的依赖项时可能很有帮助。
这里是一个设置器注入的代码示例:
// Define a view controller that depends on a data managerclass MyViewController: UIViewController {
var dataManager: DataManager?
}
}
let dataManager = ConcreteDataManager()
let viewController = MyViewController()
viewController.dataManager = dataManager
在这个例子中,我们没有更改视图控制器的 init
函数。在创建视图控制器后,我们使用内置的设置器属性方法传递了 dataManager
。
使用设置器注入的主要优点是简单性;我们不需要修改 init
函数和解耦。另一方面,这种方法不适合所需的依赖项。
使用方法注入的纯函数
在方法注入中,依赖项作为参数传递给对象的函数。这与构造函数注入类似,但允许对何时以及如何注入依赖项有更精细的控制。
fetchData()
方法是一个纯函数的例子:
// Define a view controller that depends on a data managerclass MyViewController: UIViewController {
func fetchData(dataManager: DataManager) {
let data = dataManager.fetchData()
}
}
let dataManager = ConcreteDataManager()
let viewController = MyViewController()
viewController.fetchData(dataManager: dataManager)
在这个代码示例中,我们拥有相同的视图控制器和数据管理器,但这次我们没有为数据管理器创建实例变量。我们将数据管理器作为 fetchData
方法的部分传入,从而使函数成为纯函数。
什么是纯函数?
纯函数是一个不依赖于实例变量或其作用域之外的任何状态,并且只操作其输入参数的函数。它对相同的输入产生相同的输出,并且对程序或环境没有副作用。纯函数可以依赖方法注入来使用外部依赖,其中依赖项作为参数传入,而不是依赖于全局或实例变量。
方法注入对于可测试性非常好,因为它增加了类与依赖项的解耦。它还使类与方法(这是我们讨论的“纯”定义的一部分)解耦,但它也要求我们的方法签名更复杂,并管理类外部的状态。
这三种依赖注入(DI)的方式都非常有助于提高解耦和可测试性。但我们可以使所有这些方式更加解耦。如何?简单,使用协议。
使用协议解耦我们的代码
当我们讨论 Swift 语言特性时,我们在 第五章 中讨论了协议。协议在设计模式中扮演着重要的角色,尤其是在依赖注入(DI)中。
只要它们符合协议接口,我们可以使用协议注入具有不同行为的不同对象。
这里是一个使用协议注入具有不同实现的不同对象的例子:
protocol DataManager { func fetchData() -> [String]
}
class ConcreteDataManager: DataManager {
func fetchData() -> [String] {
return ["Item 1", "Item 2", "Item 3"]
}
}
class OtherDataManager: DataManager {
func fetchData() -> [String] {
return ["Item A", "Item B", "Item C"]
}
}
class MyViewController: UIViewController {
let dataManager: DataManager
init(dataManager: DataManager) {
self.dataManager = dataManager
super.init(nibName: nil, bundle: nil)
}
}
let concreteDataManager = ConcreteDataManager()
let viewController1 = MyViewController
(dataManager: concreteDataManager)
let otherDataManager = OtherDataManager()
let viewController2 = MyViewController
(dataManager: otherDataManager)
在这个例子中,我们有 MyViewController
的两个实例。我们将 concreteDataManager
,DataManager
协议的一个实例,注入到第一个实例中,并将 otherDataManager
,另一个 DataManager
协议的实例,注入到第二个 MyViewController
实例中。它们具有相同的接口但不同的实现。在这种情况下,它们在 fetchData()
方法中返回不同的元素。这种技术使我们能够通过任何对象注入我们想要的任何实现。这对于测试特别强大,它帮助我们使用代码中的模拟。
总结依赖注入(DI),我们可以从提到的三种模式中选择任何一种——构造函数、设置器或方法——并使用协议扩展其功能。这完全取决于我们想要的简单性和耦合程度。
使用代理进行通信
委托是一种简单的模式,它允许对象通过松散耦合的接口相互通信。委托还基于一种协议,允许类与不同类型的对象进行通信。
让我们看看一个关于委托的小例子:
protocol MyViewDelegate: AnyObject { func didTapButton()
}
class MyView: UIView {
weak var delegate: MyViewDelegate?
private let button = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
button.addTarget(self, action: #selector
(buttonTapped), for: .touchUpInside)
addSubview(button)
}
override func layoutSubviews() {
super.layoutSubviews()
button.frame = bounds
}
@objc private func buttonTapped() {
delegate?.didTapButton()
}
}
class MyViewController: UIViewController, MyViewDelegate {
private let myView = MyView()
override func viewDidLoad() {
super.viewDidLoad()
myView.delegate = self
view.addSubview(myView)
}
func didTapButton() {
print("Button tapped!")
}
}
MyViewController
类有一个名为 MyView
的视图,并遵循一个名为 MyViewDelegate
的协议。这个协议有一个名为 didTapButton()
的方法。
MyView
需要与 MyViewController
进行通信,但它没有直接的引用。相反,它有一个 MyViewDelegate
类型的委托属性。这个委托属性在视图和它的视图控制器(它的“委托”)之间创建了一个松散耦合的接口。
从这个代码示例中,我们可以得到以下两点体会:
-
我们需要创建一个委托属性:与委托进行通信的对象需要有一个委托属性,而这个委托属性是从我们刚才声明的协议类型中获得的。这确保了一个松散耦合的关系;每个对象都可以遵循那个协议,即使是用于测试的模拟对象。
-
我们必须将委托标记为弱引用:这是一个关键点。根据我们的代码,视图控制器对视图有一个强引用,而视图通过委托属性对视图控制器有一个引用。这意味着委托属性必须是弱引用,这是许多开发者常见的错误。事实上,对委托属性的弱引用是面试中的常见话题,我们应该记住这一点。
尽管委托是一个广泛使用的模式,但与 Combine 等现代模式相比,它有一些缺点,许多开发者认为它有点过时。
首先,当使用委托在多个对象之间传递消息时,代码可能会变得繁琐且难以阅读。这尤其适用于每个对象都必须作为前一个对象的委托,因为它需要创建多个协议和委托属性。结果,代码可能会变得像样板代码一样,难以跟随和维护。
创建协议可以减少我们的耦合度,但我们的委托必须遵循一个特定的接口。在响应式编程中,订阅者观察更新而不被绑定到特定的接口,这使得通信更加松散耦合。
那么,选择委托而不是 Combine 的原因可能是什么?使用委托,我们可以定义一个清晰的接口,甚至是一个复杂的接口,这在 Combine 中并不总是成立。但不仅如此——委托明确地分离了关注点,帮助我们保持代码模块化和易于维护。
让我们回顾一个关于委托设计模式的常见面试问题。
“在 Swift 中,委托模式与其他通信模式,如通知或闭包,有何不同?”
为什么这个问题很重要?
委托设计模式只是对象之间通信的一种方式。多年来,已经添加了新的通信方式——通知、闭包,当然还有 Combine 和 RxSwift 这样的响应式方法。
每个选项都有其优缺点,我们应该将模式与我们要解决的问题相匹配。因此,了解这些选项之间的实际差异非常重要。
答案是什么?
在几个关键方面,委托设计模式与通知、闭包和 Combine 不同:
-
一对一通信关系:虽然通知模式和 Combine 发布者可以向无限数量的实例发送消息,但委托模式通常与单个对象进行通信。这最初可能看起来像是一个缺点,但它也简化了需要更多控制代码耦合的复杂情况。
-
形式化协议:在委托模式中,由于使用了协议,与委托一起工作的接口是清晰的。协议形式化了通信,并为委托和所有者提供了明确的期望。当通信变得复杂时,这一点尤为重要——例如,需要实现多个参数的多个函数。
-
更复杂设置:当我们想在应用程序的不同层和组件之间传递值或事件时,使用委托模式变得更复杂。但这不仅仅是设置——跟踪数据流变得繁琐,因为你必须在不同协议实现之间跳转。这是灵活性与简单性用例的另一个例子。
总体而言,委托模式对于某些用例来说非常好,选择正确的通信选项取决于我们问题的具体要求。
至于面试问题,特别是与设计模式相关的问题,我们必须记住,“更好”取决于上下文。
我认为下一个主题完美地描述了这一点。
使用 Singleton 共享状态
面试官喜欢问的两个问题是:
-
“我们如何创建一个 Singleton?”
-
“在我们的应用程序中拥有单例是否好?”
第一个问题很技术性,但第二个问题很棘手。
让我们从 Singleton 的定义开始。
什么是 Singleton?
在 Singleton 设计模式中,只有一个类的实例可以被全局访问,通过一个 静态 属性。它通常用于管理程序中的共享资源或状态,其中多个实例可能导致同步或一致性方面的问题。为了实现 Singleton,一个类通常有一个私有的构造函数和一个静态方法或属性,它返回类的单个实例。
在 Swift 中,创建 Singleton 很简单。我们使用静态属性来完成这项任务:
final class MySingleton { static let shared = MySingleton()
private init() {}
func doSomething() {
print("Doing something...")
}
}
MySingleton.shared.doSomething()
注意,Singleton 只用一行定义:
static let shared = Singleton()
关键是要始终使用shared
属性来访问单例,正如前一个代码示例中所示。为了防止创建MySingleton
类的另一个实例,我们可以将init()
方法标记为私有,并确保只有一个实例。
但那只是一个简单的问题。真正的问题是——我们应该在我们的项目中使用单例吗?它被认为是合法的模式还是反模式?
有几个原因说明为什么单例对于许多开发者来说可能是一个反模式。让我们讨论其中的一些:
-
拥有全局状态:单例为应用提供了一个全局状态,多个应用组件可以操作这个全局状态。结果,很难追踪到这些更改是在哪里和什么时候进行的。从某种意义上说,当应用变得更大时,可以从代码的任何地方访问的全局状态的主要问题。
-
耦合增加:我们构建了一个模块化应用,代码分离做得很好。但一旦我们的不同应用组件访问共享实例(即单例),这些组件之间的耦合就会增加。随着程序中单例数量的增加,紧密耦合成为一个更大的问题。
-
多线程挑战:因为单例可以从任何地方访问,这也意味着我们可以从不同的线程修改和读取共享实例的值。这可能导致竞态条件和其他同步问题。
那么,答案是什么呢?嗯,有些情况下拥有一个单例(Singleton)是完全合法的。例如,在应用中应该只有一个特定对象的实例——比如配置管理器或数据库连接。
但我们应该尽可能避免使用单例。怎么做?让我们用一个面试问题来回答。
“你将如何在代码中避免使用单例?你能描述一些你可能考虑的替代方法吗?”
为什么这个问题很重要?
到这个时候,我们应该已经熟悉了不同的设计模式,并且可以提出一个合适的替代方案。这正是问题的目标——测试你将经验和知识转化为良好、可接受解决方案的能力。
如果找到替代方案是可能的,关于单例的一般性指导原则是避免使用它们。
答案是什么?
避免单例最常见的方法是使用依赖注入(DI)来注入类或函数内部使用的服务。
我们可以不创建一个静态常量(单例),而是从Client
类来处理Service
实例:
class Service { static let shared = Service()
func doSomething() {}
}
class Client {
func useService() {
Service.shared.doSomething()
}
}
我们可以创建一个实例并将其注入到Client
类中:
class Service { func doSomething() {}
}
class Client {
let service: Service
init(service: Service) {
self.service = service
}
func useService() {
service.doSomething()
}
}
let service = Service()
let client = Client(service: service)
如果不需要全局状态,注入一个新的实例(或传递现有的实例)而不是使用单例会更好。
我们可以通过将Service
转换为协议来改进这个例子:
protocol Service { func doSomething()
}
class ServiceImpl: Service {
func doSomething() {}
}
let service = ServiceImpl()
let client = Client(service: service)
现在,由于Service
是一个协议,这使得我们的耦合更加松散。这是一个很好的代码修改,我们可以进行。
“你能描述一下在多线程环境中使用单例可能遇到的问题,以及如何解决这些问题吗?”
为什么这个问题很重要?
当我们之前在本节中讨论单例的缺点时,我们简要提到了多线程问题。单例是一个共享资源,因此我们必须了解它在多线程环境中的位置。
答案是什么?
在多线程环境中,我们可能会遇到与单例相关的以下潜在问题:
-
出现竞态条件:如果有多个线程同时尝试访问和修改同一个单例实例,可能会导致竞态条件,这可能导致不可预测的行为和数据损坏
-
遇到死锁:如果有多个线程以不同的顺序尝试访问单例实例,可能会导致死锁,其中一个线程等待另一个线程释放对单例的锁
-
状态不一致:如果一个单例实例被一个线程修改,而另一个线程正在读取或使用它,可能会导致不稳定的状态和意外的行为
作为开发者,不可预测的行为是我们遇到的最具挑战性的问题之一,这使得调试和调查变得更加困难。
最简单的解决方案是假设单例不是一个线程安全的对象,并且只能从同一个线程访问。以下是一个示例:
MySingleton.shared.doSomething()DispatchQueue.global().async {
DispatchQueue.main.async {
MySingleton.shared.doSomething()
}
}
在这个例子中,我们假设单例只能从主线程访问。我们使用Grand Central Dispatch(GCD)API 转移到主线程,以确保单例总是从主线程访问。
另一个选项是将单例变成线程安全的对象,通过使用NSLock来锁定访问:
class MySingleton { static private var privateShared: MySingleton?
static private let lock = NSLock()
private init() {
// Perform any necessary setup or initialization
}
static func threadSafeShared() -> MySingleton {
lock.lock()
defer {
lock.unlock()
}
if privateShared == nil {
privateShared = MySingleton()
}
return privateShared!
}
func doSomething() {
// Perform some action or logic
}
}
在这个例子中,我们将共享实例作为一个私有属性,并添加一个静态方法,确保在返回单例实例之前进行锁定和解锁。这是一种常见的做法,用于锁定对象并使其线程安全。
总结这个话题——在 iOS 开发者社区中有一个持续的辩论和讨论。一些开发者认为单例是一个有用的工具,可以简化状态和资源的共享,而另一些开发者则认为单例是一个可能导致问题的反模式。事实,就像往常一样,介于两者之间。
使用并发提高性能
并发是一个复杂的计算机科学话题,不仅在 iOS 开发中。糟糕的设计可能导致崩溃、竞态条件、死锁和延迟。
但别担心——你还记得我们在本章引言中提到的设计模式吗?它们就是为了解决我们的问题而存在的。所以,让我们回顾一些关于并发的设计模式和最佳实践,看看我们可以添加哪些工具到我们的工具箱中。
使用 GCD
GCD 是一个强大的并发框架,它能够高效且可扩展地执行任务。GCD 提供了一种简单的方法来创建任务队列并安排它们执行,而无需手动管理线程。这使得编写高效且响应迅速的代码变得容易,可以充分利用系统资源。
这里有一个如何使用 GCD 在后台异步下载图像的例子:
func downloadImage(url: URL, completion: @escaping (UIImage?) -> Void) {
let queue = DispatchQueue.global(qos: .background)
queue.async {
if let data = try? Data(contentsOf: url),
let image = UIImage(data: data) {completion(image)
} else {
completion(nil)
}
}
}
在这个例子中,我们定义了一个函数 downloadImage
,它接受一个 URL 和一个完成处理程序作为参数。该函数使用 DispatchQueue.global
方法创建一个全局后台队列,然后调用异步方法将任务添加到队列中。任务从 URL 下载图像数据,将其转换为图像,然后使用结果调用完成处理程序。因为任务在后台异步执行,所以它不会阻塞主线程,并允许应用程序保持响应。
使用 OperationQueue 创建高级队列
操作队列是一个高级并发机制,它管理任务并发执行,就像我们刚才讨论的 GCD API 一样。OperationQueue 提供了高级功能和简单的接口来管理队列。
OperationQueue
的基本单位是 Operation
,它可以执行特定的任务。操作队列的任务是取一个操作并执行它,可以同时执行或依次执行。
我们只需继承 Operation
类并实现 main()
函数来创建一个操作。
这里是一个使用 Operation Queue 下载多个图像的例子:
class ImageDownloadOperation: Operation { let url: URL
var result: UIImage?
init(url: URL) {
self.url = url
}
override func main() {
if let data = try? Data(contentsOf: url),
let image = UIImage(data: data) {
result = image
}
}
}
let urls: [URL] = // an array of image URLs
let queue = OperationQueue()
let downloadOperations = urls.map { url in
ImageDownloadOperation(url: url)
}
queue.addOperations(downloadOperations,
waitUntilFinished: true)
let images = downloadOperations.compactMap { $0.result }
在这个例子中,我们通过创建 ImageDownloadOperation
类来继承 Operation
,该类有一个 url
属性并在其主方法中执行下载操作。
在此之后,我们创建了一个名为 queue
的操作队列和一个下载操作数组。我们将操作数组添加到我们创建的队列中,并通过调用 result
属性来收集它们。
Operation Queue 的最佳特性之一是能够配置其工作方式。如果我们想使队列同时执行最多三个操作,我们可以设置其 maxConcurrentOperationCount
的值:
queue.maxConcurrentOperationCount = 3
如果我们将该属性设置为 1
,我们的队列将依次执行操作。
另一个令人兴奋的选项是 在队列操作之间添加依赖关系 - 我们可以定义一个特定的操作在另一个操作结束之前不能开始。
这里有一个例子:
let downloadOp1 = ImageDownloadOperation(url: url1)let downloadOp2 = ImageDownloadOperation(url: url2)
downloadOp2.addDependency(downloadOp1)
在这个例子中,downloadOp2
在 downloadOp1
结束之前无法开始。
Operation Queue 通常提供高级功能和模式,以更精细地执行复杂的后台操作。
使用 NSLock 阻塞线程
NSLock
是一个用于在多线程环境中管理对共享资源访问的同步机制。NSLock
提供了一种简单的方法来阻塞尝试访问已锁定资源的线程,允许一次只有一个线程访问资源。
这里有一个如何使用 NSLock
来保护共享资源的示例:
class SharedResource { private var count = 0
private let lock = NSLock()
func increment() {
lock.lock()
count += 1
lock.unlock()
}
func getCount() -> Int {
lock.lock()
let result = count
lock.unlock()
return result
}
}
假设 SharedResource
可以在不同的线程中使用。这可能导致在尝试同时访问和修改 count
变量时出现竞态条件和死锁。
为了处理这个问题,我们使用 NSLock
“锁定”读取和写入访问权限——我们在读取/写入操作之前调用 lock()
,并在之后通过调用 unlock()
释放它。
使用 Combine Future
发布者实现异步操作
我们之前已经讨论了 Combine 第八章,但现在让我们讨论如何将异步操作集成到 Combine 流中。
对于任何值不会立即接收的动作的 Future
发布者。例如,Future
类型可以包括打开模态、选择项目以及关闭它。
但让我们看看我们如何使用 Future
来进行异步操作。
Future
发布者有一个带有 promise
参数的闭包,该参数根据操作结果具有成功或失败。
让我们看看一个示例:
func loadJSONFile() -> Future<Data, Error> { return Future { promise in
DispatchQueue.global().async {
let fileURL = FileManager.default.urls(for:
.libraryDirectory, in: .userDomainMask)[0].
appendingPathComponent("articles.json")
do {
let data = try Data(contentsOf: fileURL)
promise(.success(data))
} catch {
promise(.failure(error))
}
}
}
}
在这个示例中,loadJSONFile
函数返回一个 Future
发布者。在内部,它创建一个后台队列并加载一个大的 JSON 文件。如果一切顺利,它会调用带有成功和数据的结果。如果不顺利,它会发送失败和错误。
现在,让我们看看如何在 Combine 流中使用该函数:
let newArticles = loadJSONFile() .decode(type: [Article].self, decoder: JSONDecoder())
.map { articles -> [Article] in
let lastUpdate = UserDefaults.standard.object(
forKey: "lastUpdate") as? Date ?? Date.distantPast
return articles.filter { $0.publishedAt > lastUpdate }
}
.eraseToAnyPublisher()
现在,我们可以将 JSON 加载操作整合到一行中,解码其数据,并将其映射和过滤,作为 Combine 流的一部分。
我们可以看到 Future
发布者如何使将异步操作整合到 Combine 流中变得非常容易。我们还可以通过结合 OperationQueue
和 Future
来在 Combine 中包含更复杂的异步操作。
探讨并发开发最佳实践
无论我们刚刚学到的不同技术如何,都有一些最佳实践我们应该遵循,以使我们的代码免受竞态条件和死锁的影响。
其中一些是从我们至今所学的内容中派生出来的。此外,这些最佳实践对于关于并发的面试讨论也非常出色:
-
避免阻塞主线程:主线程处理 UI 更新,因此阻塞它可能会导致应用无响应。为了避免阻塞主线程,请使用后台线程或操作队列来执行长时间运行或 CPU 密集型任务。
-
使用结构体以确保安全:结构体是值类型,这意味着它们默认是线程安全的。如果我们需要在线程或队列之间传递数据,使用结构体可以帮助防止竞态条件和其他并发问题。
-
避免共享状态:线程或队列之间的共享状态可能导致竞态条件和其他并发问题。相反,尝试将状态保持在每个线程或队列的本地,并使用消息传递或其他通信机制在它们之间共享数据。
-
使用 Combine:Combine 是 iOS 中响应式编程的一个强大框架,它可以通过允许你定义数据流及其操作来简化并发。通过使用 Combine,你可以避免复杂的线程管理和同步问题。
-
始终在相同的线程中返回闭包:在进行异步操作时,确保任何闭包或回调都在它们最初创建的相同线程或队列中执行是至关重要的。这有助于避免在跨多个线程执行代码时出现竞争条件和其他并发问题。
摘要
这章内容很长,这个主题本身就可以写成一本书。实际上,许多书籍只专注于设计模式,原因很明显——设计模式是我们进行 iOS 开发时所有工作的工具箱。
本章向我们介绍了 MVC/MVVM、依赖注入(DI)、代理、单例以及并发模式和工具。到目前为止,我们应该对 iOS 中的主要设计模式有了很好的理解。
这种对设计模式的知识是构建我们为下一章做准备的一个很好的基础,下一章将专注于应用程序架构和开发。
第十二章:深入探讨应用程序架构
在最后一章,我们讨论了一个重要的话题——设计模式。我们说,设计模式是可重复的解决方案,用于解决常见问题。我们也可以说,设计模式是代码的构建块。在我们这本书中查看设计模式之前,我们先学习了 Swift,然后在此基础上应用设计模式。现在我们处于最高层次——应用程序架构。
应用程序架构是面试中的一个关键话题,不仅是在架构设计面试中。架构讨论可以发生得更早,甚至在第一阶段。例如,面试官可以询问我们的先前项目以及我们是如何构建它们的。理解基本术语并具备在面试中使用这些术语的技能是至关重要的。
但别担心,因为在这一章中,我们将介绍移动架构的最基本原理:
-
我们将回顾关注点分离(Separation of Concerns)(SoC)原则
-
我们将介绍一些关于代码分离的优秀技巧
-
我们将学习如何将我们的应用程序分层以及数据如何在它们之间流动
-
我们将讨论设计面试,如何应对它,以及如何与面试官沟通
我们几乎到达了本书的结尾,正如你现在所学的,我喜欢从基础开始,因为这有助于我更好地解释复杂话题。应用程序架构的基础是 SoC 原则。
所有关于关注点分离(Separation of Concerns)原则的内容
我在书中多次提到了SoC 原则。事实上,我在我之前的多本书和文章中也多次提到了这个原则。从某种意义上说,SoC 原则位于许多设计模式和架构决策的核心,而且有很好的理由。在我们深入理解为什么之前,让我们先尝试理解 SoC 的含义。
定义关注点分离(Separation of Concerns)原则
我们将从定义什么是 SoC 开始。SoC 指的是将代码组织起来,将不同的功能分离到不同的对象和所有者中。这意味着一个类或模块必须只有一个且仅有一个责任。
看看以下例子:
func processUserData(userData: [String: Any]) { // Responsibility 1: Validate the data
guard let name = userData["name"] as? String,
!name.isEmpty,
let age = userData["age"] as? Int, age > 0,
let email = userData["email"] as? String,
!email.isEmpty else {
print("Invalid user data")
return
}
// Responsibility 2: Save the data to a file
let documentsDirectory = FileManager.
default.urls(for: .documentDirectory, in:
.userDomainMask).first!
let fileURL = documentsDirectory.
appendingPathComponent("userData.txt")
let userDataString = "Name: \(name)\nAge:
\(age)\nEmail: \(email)\n"
do {
try userDataString.write(to: fileURL,
atomically: true, encoding: .utf8)
print("User data saved to file.")
} catch {
print("Error saving user data to file: \(error)")
}
// Responsibility 3: Send a welcome email
print("Sending welcome email to: \(email)")
}
}
在我们的代码示例中,我们可以看到processUserData
函数有三个不同的责任:
-
验证用户数据
-
保存用户数据到文件
-
发送欢迎邮件给用户
注意,问题开始得更早——名称processUserData
表明该函数有一个不明确的责任。
将功能分为三个不同的函数——validateUserData
、saveUserDataToFile
和sendWelcomeEmail
——将是解决不明确性问题的一个优秀解决方案。
SoC 原则适用于函数和变量——一个变量应该有自己的责任,就像函数和类一样。
以下是一个例子:
class BadSoCExample { var name: String
init(name: String) {
self.name = name
}
func printFullName(firstName: String, lastName: String) {
name = firstName
print("First name: \(name)")
name = lastName
print("Last name: \(name)")
print("Full name: \(firstName) \(name)")
}
}
let example = BadSoCExample(name: "John Doe")
example.printFullName(firstName: "John", lastName: "Doe")
在这个例子中,我们使用name
来存储名和姓,而不是创建两个专门的变量——firstName
和lastName
。这些不良做法通常发生在试图节省时间时,但它们容易出错,可能导致问题。
我们可以看到,SoC 原则是开发所有级别的相关因素,从变量、函数和类到模块。
但是……为什么它如此重要呢?让我们看看。
解释 SoC 的重要性
现在我们已经知道了 SoC 原则是什么,是时候了解为什么在设计编写代码时它很重要了。
有几个原因让我们希望我们的应用程序的每个部分和每个变量都有单一的责任。让我们列举一些:
-
使代码更明确:当每个部分都专注于单一任务时,我们(以及其他人)更容易理解正在发生的事情。清晰度在调试和代码调查中也非常关键——这里有一句来自 Brian Kernighan 的名言:“调试代码比最初编写代码难两倍。因此,如果你尽可能聪明地编写代码,那么按照定义,你不够聪明来调试它。”减少编写和调试之间的差距的唯一方法是清晰度。
-
使代码维护更容易:我们都知道编写代码比维护代码容易。其中一个原因是,我们进行的每一次代码修改都可能创建一个新的错误。此外,修改通常改变代码结构,使其与我们编写代码时没有计划的结构不同。当我们将任务隔离到特定的函数或类时,我们正在减少这两种风险。
-
提高代码的可重用性:这是 SoC 原则的另一个重要好处。当一个视图、控制器或模块有一个特定的任务和责任时,它使得重用变得更加容易。让我们以一个处理文本操作的库为例。向该库添加更多功能和能力可能会增加其依赖性和副作用。这也使得它更大,更容易出错,因为它现在处理了更多与我们需求无关的责任,并且可能与我们要链接的另一个库冲突。将库的一部分与另一部分分离更明智,并允许我们像乐高积木一样使用不同的库。
-
提高代码的可测试性:测试的一个关键方面是确保结果可预测。当一个方法负责多个任务时,它可能会随着时间的推移降低其可预测性。想象一个返回计算值并更新用户默认值的函数。测试函数返回值可能会在我们的测试用例中产生我们不希望出现的副作用。将这个计算分离到另一个函数中并单独测试它更好。
这些好处是我们做出任何设计模式或架构决策的基础。此外,SoC 是在面试中处理架构任务时必须遵循的基本原则。它是我们面试期间可能进行的任何专业讨论的基础。
现在,让我们深入一点,看看一些关于 SoC 原则的实际技巧。
基于关注点分离原则的实践
本章旨在为我们准备架构面试,其中可能包含白板任务和专业讨论。我们理解 SoC 原则的重要性,但如何将其转化为实际工具呢?
好消息是我们在前面的章节中已经回顾了这些工具。现在让我们列出它们,并添加一些更多内容。
对使用 UI 设计模式有清晰的理解
我不怀疑 MVVM 或 MVC 这个话题将在你的面试中占据核心位置。这里重要的是真正理解不同的组件及其职责。如果我们决定为我们的屏幕使用 MVVM,我们必须确保我们这样做是因为我们需要管理复杂的状态,而不是因为“这是今天做事的方式。”我们必须确保我们使用正确的工具来完成正确的工作,并且每个组件都发挥其作用。
为了帮助理解这一点,请查看图 12.1:
图 12.1 – iOS 中最流行的 UI 设计模式
在图 12.1中,我们可以看到我们选择的设计模式不仅限于 MVC 和 MVVM。此外,这里没有规则,只有最佳实践。在考虑不同的职责和分离的情况下选择正确的设计模式对于实现 SoC 原则至关重要。
例如,VIPER 对于处理许多服务和数据实体的屏幕来说可能非常出色,而 MVP 适合于专注于格式化和展示调整的 UI。
VIPER 和 MVP?
我不会过多介绍 VIPER 和 MVP,因为它们在 iOS 开发中不如 MVC 和 MVVM 常见。我建议阅读有关这些模式的内容,了解它们的优缺点,以便你在面试中拥有更广泛的知识。
这里有一些关于 VIPER 和 MVP 的精彩阅读材料:
使用 Clean Architecture
在设计应用架构部分,我们将讨论应用架构,但到目前为止,我们只是在打下基础,Clean Architecture是一个很好的基础。那么,什么是 Clean Architecture 呢?
清洁架构是一种在开发完整应用程序时强调 SoC(单一职责原则)的架构方法。它涉及将我们的项目划分为层——数据层、表示层、业务逻辑层和网络层——并推动不同层和组件之间的清洁 SoC。如果我们更进一步,我们就是在讨论为项目的其他部分创建各种库,并试图让我们的应用程序感觉像是一个巨大的拼图。请注意,将我们的应用程序拆分为模块是有代价的——维护不同的模块可能很困难,需要规划,有时还需要做出复杂的接口决策。我们必须始终考虑稳定结构和复杂接口之间的权衡。
编写小型函数
这是一个我们大多数人之前都听过几次的建议。他们说函数长度应该是“小于屏幕”,但如果我们想走极端,我们可以说函数应该是尽可能小。将一个长函数拆分成两个/三个函数是一个好主意,可以使我们的代码更干净、更少出错、更容易维护。
看看下面的代码示例:
func calculateTotalPrice(itemPrices: [Double], itemQuantities: [Int]) -> Double {
var totalPrice = 0.0
for i in 0..<itemPrices.count {
totalPrice += itemPrices[i] * Double (itemQuantities[i])
}
return totalPrice
}
虽然这个函数能正常工作,但它有多个职责——它遍历项目列表并计算它们的价格。为了提高 SoC(单一职责原则),我们可以将这段代码分离成另一个函数,用于计算单个项目的价格:
func calculateItemPrice(price: Double, quantity: Int) -> Double { return price * Double(quantity)
}
func calculateTotalPrice(itemPrices: [Double],
itemQuantities: [Int]) -> Double {
var totalPrice = 0.0
for i in 0..<itemPrices.count {
totalPrice += calculateItemPrice(price:
itemPrices[i], quantity: itemQuantities[i])
}
return totalPrice
}
现在我们有一个专门的函数来计算特定项目的价格,并在原始的 calculateTotalPrice()
函数中使用它。这是一个小修改吗?好吧,从代码设计的角度来看,这是一个巨大的变化——我们可以单独测试计算,并在代码的其他地方重用它。此外,代码的可读性更高,calculateItemPrice()
函数的名称也使我们免于解释 price*Double(quantity)
做了什么,甚至避免了不必要的注释。
很有趣的是,即使原始函数很小且工作良好,我们仍然可以将其拆分,并在多个方面改进我们的代码。这就是为什么“尽可能小”这个说法更加准确。
使用描述性名称
描述性名称总是一个好主意,但命名如何帮助我们拥有良好、清洁和分离的代码?
命名具有一种神秘的力量——它迫使我们思考函数的职责,并精确描述其功能。我们给予它的关注可以帮助我们做出正确的设计决策。
这里有一个例子——看看下一个函数接口:
func getProducts() -> [Product] {}
很明显,getProducts()
函数是用来获取并返回产品的。但它具体做了什么?它是从本地数据存储中加载产品还是使用网络请求?
让我们改进函数的名称:
func retrieveProductsFromServer() -> [Product] {}
现在,事情变得更加清晰。我们确切地知道这个函数的作用。但问题仍然不明确——我们对函数工作的疑问帮助我们深入思考其职责。也许最初,我们提出了以下名称:
func retrieveProductsFromServerAndSaveThemToDB() -> [Proudct] {}
这是一个很好的迹象,我们需要打破函数,因为描述其责任变得冗长而繁琐。
这里有一些好的命名示例:
fetchData() -> fetchUserDataFromServer()calculate() -> calculateAvrageSalary()
validate() -> validatePasswordStrength()
add() -> addItemToCart()
load() -> loadDataFromCache()
在面试过程中考虑到命名规范是个好主意。你知道吗?你一生中(如果不是更多的话)已经编写了数百(如果不是更多)个函数。你只需要回顾并反思它们。也许我们可以通过我们的代码学到一些东西。
这里有一个免责声明!
我知道这里的一些提示不是关于“架构”的。但这些原则和建议在任何级别都是相关的。
因此,SoC 为设计可扩展、模块化和可维护的应用架构奠定了基础。现在我们明白了这一点,我们可以继续设计应用架构。
设计应用架构
当被问到应用架构时,应聘者最常见的错误之一是回答,“当然是 MVVM!”
所以,我想提醒你——MVVM 是一种设计模式,而不是架构,我想强调这一点。
设计模式是对常见问题的一种可重用解决方案。依赖注入、单例和 MVVM 是设计模式的例子。另一方面,架构是我们项目的总体结构,它代表了我们的应用理念。
一个优秀的现实世界例子是建筑物。在这种情况下,架构描述了楼层数量、停车场和入口门的位置,或者我们有什么类型的屋顶。设计模式描述了每个公寓是如何建造的——每个公寓的房间数量、厨房的位置和电气布线。
我们可以说每个公寓和楼层都可以设计得不同——这意味着我们可以为各种问题和需求使用不同的设计模式。
通过与建筑行业进行类比,我们可以获得有价值的见解,因为构建应用程序和构建结构之间有许多相似之处。
我们没有公寓,而是有应用程序屏幕;我们没有屋顶和大厅,而是有应用程序层。
那么,让我们先谈谈应用层。
将架构分解为应用层
应用程序的不同层可以作为描述应用架构的起点。但什么是层呢?
层是具有不同关注点和应用责任的不同组件或组件集合。大多数应用程序使用三个主要层:
-
表示层:负责使用 UI 展示信息。在这里我们可以找到不同的屏幕和 UI 组件。
-
业务逻辑层:负责应用程序逻辑、规则和计算。
-
数据层:负责从本地数据库或其他来源存储和检索数据。
层或层?
当讨论层时,人们常犯的一个错误是将它们称为“层”。术语“层”指的是应用的物理组件,而“层”指的是软件组件。例如,一个层可以是一个负责特定关注点的不同计算机或服务器。在 iOS 应用架构的情况下,除非包括后端部分,否则“层”这个术语是不相关的。
为了快速提醒,我们将回顾设计模式 – MVVM 是作为表示层一部分实现屏幕的一个例子。单例(Singleton)是一种设计模式,可以帮助我们将 Core Data 处理器作为数据层的一部分实现。
让我们看看一个典型应用架构(图 12.2):
图 12.2 – 一个典型的 iOS 应用架构
不要被图 12.2中的图表吓倒 – 记住,我们已经知道我们需要在脑海中重新组织这些信息。
我们可以看到,图 12.2中的架构被划分为我们在本节前面讨论的三个不同层:
-
我们可以看到,表示层有各种技术和设计模式。
-
在业务逻辑层,我们还有另一个设计模式 – 门面模式(Façade),这是一种提供对复杂和大型代码块简化接口的设计模式。这是我们进入应用逻辑的入口点。我们还可以看到我们在业务层中处理的不同实体,工作流程的逻辑,以及一般的应用逻辑。
-
在数据层,我们可以看到不同的服务代理(连接器),它们可以帮助我们连接到各种服务。我们还可以看到分析和辅助服务。
在图表下方,我们有外部服务,如数据库、网络和配置。这些服务是数据层的数据源。
架构图还说明了另一个方面:数据在不同层之间的流动,我们将在下一节中看到。
了解数据流
我们理解,如果表示层需要向用户展示信息,我们需要它从业务层一直获取信息到数据层和数据库。有些情况下,我们可以有一个具有更多层的应用,例如通知、安全和持久化层。在这种情况下,数据流会变得稍微复杂一些。
可能出现的一个有趣问题是,在检索数据时,一个层是否可以绕过另一个层。
让我们简要回答一下。
当设计分层系统时,有两个术语我们需要了解 – 封闭层和开放层:
-
封闭层:封闭层意味着给定的层不能绕过它下面的任何层
-
开放层:开放层意味着给定的层可以被传递到它下面的任何层
被认为是一个系统完全封闭或开放的最好实践,但在大多数情况下,系统都有开放和封闭层的混合。
那么,拥有关闭或开放层有什么好处呢?
看看 图 12.3:
图 12.3 – 开放和关闭层
在 图 12.3 中,我们可以看到一个包含不同层及其依赖关系的系统架构。层 B 是关闭的,这意味着层 A 无法访问层 D 和 E。另一方面,层 C 是开放的,这意味着层 A 可以访问层 F 和 G。
灰色填充的层是层 A 可以访问的层,但也意味着这些都是依赖项。
开放层通过将顶层暴露给更多层来增加耦合。另一方面,关闭层更难维护,需要提前设计一个优秀且灵活的接口。我们已经知道,减少依赖项可以创建一个松散且灵活的系统,但这并不意味着我们需要创建一个过于复杂的设计。这是一个简单性和灵活性之间权衡的绝佳例子。
更重要的是,在设计架构和做出决策时理解这些术语。完全不思考就构建分层系统可能是我们能做的最糟糕的事情。
架构概念与设计模式相结合,是我们为项目设计良好架构的基础。
让我们用一个现实世界的例子来一起回顾其设计。
设计离线优先的系统架构
离线优先的系统架构是许多面试官喜欢讨论的典型设计。原因是这个用例涉及到处理不同的数据源和设计模式,以实现似乎是在移动原生应用而不是网页应用中拥有的一项重要优势。
离线优先系统的工作方式是通过拥有两个数据源——一个 持久存储 和 网络 通信。在此基础上,我们有一个 同步服务 负责将网络数据更新到持久存储。连接 UI 和数据层的业务逻辑层直接与持久存储工作,而不考虑网络状态。
让我们看看这样一个系统的图示 (图 12.4):
图 12.4:离线优先的系统架构
查看 图 12.4,我们可以看到业务层上的 Articles Handler 直接与 Core Data 无意识 地工作,而不考虑网络层。同步服务是唯一连接 Core Data 存储和网络层的组件。
通过添加离线加载,我们为用户提供了一个卓越的用户体验。除此之外,我们还实现了卓越的 SoC 原则,通过解耦的系统使得其组件完全独立。
在面试中,候选人喜欢回答的一个典型实现选项是在业务逻辑或甚至作为视图模型的一部分进行所有同步工作。当然,这个选项可以工作得很好,但在设计一个可以扩展并随时间维护的系统时,这是一个非常狭隘的观点。拥有一个专门的服务来处理同步逻辑并单独维护它是完全可行的。
总是提醒自己系统中每个组件的责任以及它们如何与其他组件通信,这可以引导我们设计出更好、更清晰的设计。
架构设计面试
架构设计面试是我们提案的关键步骤,它需要的是更多软技能和沟通技巧。
与 Swift、Combine 和 Core Data 的问题不同,架构设计面试要求我们向前迈出一步,提出一个更全面的视角——产品需求、后端、扩展性、分析和用户体验都是我们在尝试设计一个完整系统时需要考虑的因素。
架构面试,可能比招聘流程中的其他步骤更注重沟通和满足期望。因此,我们将从理解面试官的视角开始。
进入面试官的头脑
那么,我们的面试官希望我们从他们那里得到什么?他们的期望是什么?
在这里需要理解的最重要的一点是,面试官并不关心你的答案是否是最优解,甚至不关心它是对是错。架构设计面试绝对属于另一个领域,面试官在寻找的是其他东西——他们想看看我们是怎样思考的,如何处理问题,权衡利弊,以及根据产品需求找到一条通向合理解决方案的路径。
让我们以以下面试问题为例:
“设计随 iOS 附带的消息应用程序。”
显然,顶级应用程序功能将是消息屏幕(“聊天”)。以下是我们接近该问题时需要解决的问题列表:
-
我们将使用哪些 UI 组件?
-
数据模型是什么?每条消息的属性将是什么?
-
我们需要哪些端点?我们将实现分页吗?如果不实现,我们将如何处理无限数量的消息?
-
我们是否支持离线使用?我们将如何实现?
-
我们是否支持附件?它将如何工作?
-
如果我们想要进行群聊怎么办?
-
我们将支持实时更新吗?
这些只是当接近这个任务时出现的一些问题,而且都不是直接的。我们的任务是找到答案。
那么,我们如何开始?
接近任务
我一生中面试过数百名候选人,其中许多人挣扎于理解如何应对架构面试。
他们并不是不知道如何设计应用程序或解释他们的想法——他们不知道两件事:
-
任务开始的起点
-
他们的边界是什么
这两个主题对于面试预热并朝着伟大解决方案的方向前进至关重要。
让我们从起点开始——理解问题和范围。
理解问题和范围
当我们接近设计问题时,我们首先需要做的是停下来,深呼吸,并试图理解面试官期望我们从我们这里得到什么。大多数候选人在这一点上失败,因为他们知道他们回答问题的时间有限,所以他们急忙在白板上画盒子。
但设计面试代表了一个现实生活中的任务。面试官期望我们在描述我们做什么之前理解问题。
让我们回到我们的问题——设计一个类似于 iOS 自带的消息应用。我们可以向面试官提出以下问题:
-
我们有 UI 线框图,还是必须我们自己制作?
-
我们有多少个屏幕?
-
设计是否需要包含后端服务?
-
它是跨平台(包括 Android 或 Web)还是仅限 iOS?
-
我们有一个既定的数据库模式,还是需要我们自己规划?
这些只是几个例子,但它们可以帮助我们理解我们必须做什么。
一旦我们理解了问题和要做什么,我们就可以检索产品需求。
获取产品需求
与传统开发任务不同,在架构设计面试中,我们没有产品需求文档(PRD)或与产品经理的开场会议。相反,我们必须理解产品需求并向我们的面试官询问更多信息。事实上——这正是面试官所寻找的!
想象一下面试就像一个黑暗的迷宫,我们用手电筒导航并揭示更多区域、房间和路径。有时甚至面试官也不知道我们会把面试引向何方!
回到消息应用的任务,在设计应用时我们可以考虑一些问题。以下是一些例子:
-
我们支持离线阅读和写入吗?
-
我们是否与设备联系人集成了?
-
我们需要支持通知和实时聊天吗?
-
用户可以编辑或删除消息吗?
-
我们需要支持横幅模式吗?
这些问题并非出于好奇。它们会影响我们需要做出的设计和技术决策。例如,离线支持对于理解我们的数据源行为和同步机制至关重要。与设备联系人的集成影响我们的数据模型。实时聊天定义了我们的网络方法,而编辑和删除功能会影响我们同步信息回后端的方式。
在面试开始时,不问所有问题完全可以接受——有时我们只需要开始设计来了解我们需要问什么,但有一个好的开始是个好主意。
但我们如何开始设计?从线框图开始?还是从实体开始?这是一个好问题,让我们看看。
开始设计
设计部分是动态的。没有人期望我们在开始绘图时就拥有最终答案,我们应该预料到在面试过程中根据新的发现和结论,事情会发生变化。清晰地详细地展示事物可以帮助我们更好地与面试官沟通,并更好地表达我们的想法。
前往白板 – 线框
我们不是产品设计师,没有人期望我们成为。但知道如何在白板上展示我们的想法对于这个过程至关重要。我们已经在第一章中讨论了技术准备时讨论过这一点,现在我们完全理解为什么。
那么,我们如何开始?一些开发者喜欢从描述不同实体或类的基本 UML 开始。但在我看来,当涉及到应用程序架构时,最好从绘制不同屏幕的线框开始。让我们从消息屏幕(图 12.5)开始:
图 12.5 – 消息屏幕
看看图 12.5,我们可以理解为什么从 UI 开始更好。忽略字体大小和布局(我知道我不是一个好的设计师),看起来我们可以从这个线框中学习到很多东西。让我们列出它们:
-
我们开始理解不同的实体。例如,我们看到一个全名和一个头像——这描述了联系人实体。我们还可以看到列表显示了联系人的最后一条消息,其中包含文本属性,因此我们在这里还有一个消息实体。
-
我们看到列表是按时间排序的。这可能是一个深入挖掘的地方——我们是否希望根据每个联系人的最新消息进行排序,或者我们是否希望为联系人实体添加一个updatedTime属性?这是一个经典的性能与简单性之间的权衡;我们应该与我们的面试官讨论这个问题。
-
关于UI呢?我们知道我们在这里应该有一些UITableView。但我们是否希望将所有消息加载到表格视图中,或者我们是否希望支持分页?我们将使用哪种设计模式,MVC 还是 MVVM?我们应该根据应用程序的规模、首次体验和常见用户使用来做出决定。
在我们继续之前,我想让你注意一点——没有明确的答案,只有考虑和权衡。我提出了问题,但还没有给你任何答案,因为我们所提出的问题和面试官的问题是过程的一部分。这就是我们展示我们理解存在灰色区域,我们需要在这里做出决策的地方。
那么,我们只是在用白板绘制 UI 吗?不一定——让我们继续。
添加实体和后端服务
这是第十二章,我们知道一个应用程序不仅仅是 UI。但一个屏幕就足够开始设计其他部分了吗?当然,是的!
让我们添加实体(图 12.6):
图 12.6 – 消息应用程序的初始实体
在白板上写下实体听起来像是一项技术任务,但与线框图类似,它可以帮助我们发现关于我们应用程序的更多有趣之处。
例如,我们提到了联系人头像的 URL——这意味着我们需要基于该 URL 创建某种类型的图像下载服务和一个缓存机制。结果,图像下载器可以添加到我们在白板上的绘制中。
看看我们基于单个实体属性所取得的成就!
但实际绘制实体的价值在于我们开始思考它们之间的关系。我们有Contact
和Message
。但什么描述了与联系人的对话?也许我们需要创建另一个名为MessagesThread
的实体。如果我们有一个MessagesThread
实体,它的属性会是什么?
在这个阶段,事情变得稍微复杂一些,因为思考连接会引发更多问题——例如,我们是否支持群组消息?答案确定了MessageThread
和Contact
之间的关系类型。
在我们的线框图旁边绘制实体创建了一个来回的过程,这有助于我们塑造设计并使其更加完整。每个决策都会引发更多问题,进而导致更多设计决策。它还为我们下一个任务设定了路径:设计与后端交互。
添加网络调用
现在我们有了基本的 UI 线框和实体,规划我们将如何与后端服务合作应该更容易。记住,UI 和实体仍然是我们的应用程序学习阶段的一部分——现在我们更好地理解了我们需要做什么。不同的端点定义了用户体验和我们将使用的设计模式,这正是我们可以真正着手构建应用程序架构的地方,正如我们在本章中学到的。
让我们看看我们需要为主屏幕提供哪些端点:
-
GET/threads:检索所有线程的列表
-
POST/thread:创建新的消息线程
通常,当处理信息列表时,将这两个端点作为我们设计的一部分是很常见的。但这里还有更多需要考虑的事情:
-
线程是如何排序的?我们是直接从服务器获取排序好的线程,还是根据特定的属性进行排序?
-
我们是否有分页机制?或者我们获取所有线程并使用增量更新同步?
-
我们在这个阶段需要POST请求吗?或者我们只能在发送第一条消息之后才能这样做?
端点也帮助我们定义应用程序架构。它们回答有关 UI 层及其设计模式(MVC/MVVM)的重要问题。但它们也帮助我们理解我们的数据层和我们将需要的不同服务。以下是一些示例:
-
核心数据处理器
-
图片下载器
-
同步服务
-
网络服务和实时管理
如果我们继续向其他屏幕添加更多端点,我们将了解更多关于我们的应用,并获得更多答案。
设计应用架构是一个发现过程。一开始没有什么事情是清晰的,向面试官传达寻找答案的过程是至关重要的。这就是为什么我们的下一个主题极其重要——我们与面试官的沟通。
与面试官沟通
如本节前面所述,许多候选人认为他们在架构设计面试中的主要目标是提供他们刚刚收到的最优化解决方案。但事实是,目标是让面试官看到我们的思考方式,并为可能出现的难题提供一个合理的解决方案。
因此,与面试官的有效沟通对于在这种面试中取得成功至关重要。我们的一些最重要的软技能在这里被测试!
让我们回顾一些有助于我们关注重要事项的建议:
-
倾听面试官:这显然是显而易见的,对吧?当然,我们会倾听面试官!但我意思是,真正地倾听他们。首先,因为任务要求和范围非常重要,我们需要精确地了解我们在做什么。此外,我们的面试官会给出一些宝贵的建议,这可以帮助我们实现目标。
-
澄清疑问:如果你对自己的决定不确定,你必须向面试官传达这一点。面试官需要看到候选人看到的是灰色而不是黑白,这也是一种获取线索或与面试官讨论事情的方式。
-
自信:我知道这听起来似乎与前面的观点相矛盾,但它并不矛盾。当我们有疑问时,我们需要传达它们,但当我们对某事有信心时,我们必须表现出来。自信是这些面试中的一个关键因素。
-
大声思考:我们知道我们想要通过我们的设计实现什么目标,我们甚至已经在白板上画出了它。但对我们来说明显的事情,并不总是对房间里其他人明显。大声思考可以帮助我们更清楚地解释我们在做什么,并帮助面试官理解我们刚刚设计的出色架构。
-
使用正确的术语:在这本书中,我对所使用的不同术语非常严格——方法与函数、设计模式与架构等等。在这些面试中使用正确的术语至关重要,这不仅是为了显得专业;这也使得我们的解释对面试官来说更加清晰。并非所有面试官都对此很严格——但这并不意味着我们不应该这样做。
-
开放接受反馈:面试官可能在面试过程中提供反馈或建议。有时这可能是我们可采取方向的线索,有时则是因为我们超出了范围。在这种情况下,许多时候,候选人会失去自信并封闭自己,难以接受反馈。当我们进入设计面试时(实际上,我们应该始终放下自我),应该利用面试官的反馈来改进我们的回答。面试过程中的反馈并不意味着我们失败了——这意味着我们有提供更好解决方案的机会。
看起来我们正在接受如何沟通和表达自己的测试,但这正是现实!面试官想了解与我们合作、讨论设计问题和进行架构辩论的情况。这就是我们个性凸显的地方。
摘要
架构设计面试是招聘过程中的亮点。它融合了广泛的设计模式、架构、iOS 用户体验以及关键的软技能,如规划、沟通和演示。
然而,到目前为止,我们应该处于一个知道如何通过几个主要步骤通过面试的位置。
在本章中,我们学习了 SoC 及其在 iOS 架构中的应用,包括功能大小和命名。我们了解了应用层和数据流,甚至讨论了一个关于离线工作架构的优秀示例。最后,我们讨论了架构设计面试——如何应对以及与面试官沟通。
在下一章(也是最后一章)中,我们将讨论面试中最实用的步骤:现场编码面试和家庭评估。现在所有的招聘流程都包括这一步骤,我们的任务是准备好应对未知。
第十三章:如何在编码评估中取得优异成绩
在上一章中,我们深入讨论了架构。架构讨论往往非常理论化和方法论;同样可以说,设计模式讨论也是如此。
在本章中,我们将深入探讨实质内容——代码、使用 Xcode 以及玩转算法。我们将涵盖与编码面试任务相关的所有内容,例如以下内容:
-
如何在现场编码面试中取得成功,不同的测试,以及如何像专业人士一样编码
-
如何通过讨论不同的技能和复习家庭评估示例来在家评估中脱颖而出
-
如何避免在面试中可能引起红灯的失误
这将是最后一章!你将在本章结束时为你的第一次面试做好充分准备。现在,让我们直接进入现场编码面试的技巧。
现场编码的成功
现场编码面试可能是整个过程中最令人畏惧的。
很容易理解为什么——对于大多数开发者来说,这无论如何都是一个不舒服的情况。
首先,在大多数情况下,我们并不是在我们心爱的(对于一些人来说)Xcode 中工作。我们需要处理我们通常在日常生活中不会面对的任务,而且我们是在压力下完成的,而且有人在每一步都监视着我们。
我在本文节开头说“可能”和“大多数开发者”的原因是,这并不一定令人感到畏惧。当然,现场编码是一个压力很大的面试,但我们可以通过正确的方法让它变得有趣得多,也更加愉快。
怀疑?不要怀疑!在前面的 12 章中,我们已经观察到,适当的准备可以使任何事情变得可行。
在我们匆忙进入面试本身之前,让我们了解一下我们将工作的不同环境和不同类型的测试。
了解不同的现场编码测试
与其他面试阶段不同,现场编码面试通常有不同的形式和结构,这会极大地影响整个面试的感觉和外观。
有三种类型的现场编码面试——白板、在线和面对面。在这里,我们称之为现场编码,但每种都提供不同的体验和挑战。让我们从白板面试开始。
白板面试
如果我记得正确的话,这是我们第三次提到白板的重要性。第一次是在第二章,当时我们讨论了面试准备过程(在“准备筛选面试”部分)。第二次是在第十二章(在“架构设计面试”部分)讨论架构面试,现在,我们再次提到它,是在进入现场编码阶段时。
为什么白板在面试中如此重要?是什么让面试官高度重视白板,以及我们如何最大限度地利用它来为我们自身谋利?
好吧,白板有一个显著的优势——它促进了人与人之间的清晰沟通,并允许我们在解释一个想法时可视化我们的思考。这就是为什么大多数会议室都包括白板的原因。
现在,作为候选人,如果我们不习惯使用白板,有时很难使用它。而且,我们站在那里努力解决编码问题,这也不利于这种情况。
但它也可以是我们与面试官沟通的好工具,就像我们在上一章中所做的那样。
如果你认为沟通在面试中不是什么大问题,那就让我们进入在线面试,你将会明白为什么它是至关重要的。
在线面试
在线面试是通过使用 Zoom、FaceTime 或 Google Meet 等平台进行的视频会议。
你可能会认为在线面试的一个显著优势是使用集成开发环境(IDE)。但在大多数情况下,会议是在一个专门的网站上进行的,没有缩进、代码补全或语法高亮。
此外,在线编码面试还有一个我们在白板面试中没有的缺点:沟通。
我观察到,在多次面试中,与面试官的互动在面试成功中起着至关重要的作用。不可否认的是,与面对面会议相比,在线会议中的沟通更具挑战性。
然而,抱怨不是通往成功的道路。
我们需要寻找在线编码会议的其他优势。例如,我们是在电脑上编码的事实使我们能够在需要时编辑和向下推行代码。白板编辑要困难得多,并迫使我们规划将要写的内容。
此外,安排和准备在线编码面试相对简单。远程会议和使用电脑可能在我们舒适区内,因为 Covid-19 时代教会了我们如何有效地处理这些方面。
结合两个世界——面对面编码面试
“面对面”编码面试是在面试官面前的笔记本电脑上进行的。与之前两种方法(白板和远程)相比,这是一种独特的方法,而且有很好的理由。面对面编码面试在个人互动方面具有优势。然而,考虑到随之而来的后勤努力,这种优势通常并不足够强大。
因此,在讨论面对面会议时,白板编码面试更受欢迎。
话虽如此,面对面的编码面试通常发生在希望看到我们在自然工作环境中编码的公司。在某些情况下,面试官在我们编码时陪伴我们,而在其他情况下,我们被给予隐私,在房间里自己编码,面试官定期检查以评估我们的进度。这两种情况都可能对开发者造成压力,因为他们需要在有人观看的情况下编码。但有时,这可能是测试的一部分——看看我们在轻微的压力下如何执行复杂任务。
所有三种方式(白板、面对面和远程编码)都很常见,而且所有这些都可以是压力很大的体验。降低压力水平的最佳方法是练习并为此挑战做好准备。
为编码面试做好准备
我知道你可能认为,作为资深开发者,我们已经准备好编码面试,因为编码是我们几乎每天都在做的事情。但你在这一点上大错特错了——编码面试需要新的技能和技术才能成功通过。
我们已经回顾了不同的编码测试类型——白板、面对面和远程。我们将看到,为了通过这些测试,需要磨练一些技能。我们将从第一个和最重要的一个开始——在纯文本编辑器中编写代码。
使用纯文本编辑器
在大多数编码面试中,我们将使用一个纯文本编辑器。实际上,白板也是一个纯文本编辑器。
纯文本编辑器缺乏语法高亮、缩进和代码补全,为我们创造了一个不熟悉的环境。
在纯文本编辑器中声明一个函数可能很容易,没有任何问题,就像以下代码所示:
func foo() { print("foo") }
但这只是容易的部分!让我们看看挑战是什么,以及我们如何应对它们。
识别语法错误
语法高亮帮助我们识别关键词和表达式,但它也帮助我们找到语法错误,例如缺少括号、方括号或分号。在文本编辑器中,有一个最佳实践是先输入开闭括号或括号,然后再写表达式。
然而,在白板上,我们不能使用那种技术。一个选择是我们自己突出分隔符并将它们用不同的颜色写出来。当然,这样做可能会减慢我们的速度,但会使我们的代码更加清晰和美观。另一个选择是将分隔符画得更大,这是另一种突出显示的方法。
避免打字错误
虽然语法是 IDE 的一个方面,但代码补全是一个另一个关键特性。我们习惯于编写扩展描述性函数和变量名,依赖代码编辑器通过代码补全来处理它们。然而,在纯文本编辑器中并非如此。没有代码补全,打字错误可能会破坏我们的代码。尽管在白板编码面试中,打字错误可能比面对面或远程面试更可接受,但它们仍然看起来不好,不够专业。
为了帮助我们避免打字错误,我们可以为函数和变量使用清晰简短的名字。这可以加快编码速度,并帮助我们组织白板上的写作。
掌握复杂的 Swift 表达式
我之前提到过在 Swift 中声明函数是多么简单。但 Swift 远不止函数和变量声明。因此,要掌握 Swift,我们需要对更复杂的表达式和函数有全面的知识,例如以下内容:
-
集合函数 – filter、map 和 reduce
-
闭包
-
高级类型系统特性 – 泛型和协议
-
元组与枚举
这个列表包含容易出错的特性,当我提到错误时,我不仅仅是指外观上的错误。例如,闭包格式有直接影响我们代码流程的关键组件。泛型和协议也是如此。
我们应该练习 Swift 的复杂表达式,并确保我们掌握了它们。
维护代码组织
代码组织是一个关键话题,因为在使用纯文本编辑器时,这是一个真正的挑战。正如你所知,纯文本编辑器没有缩进,这通常有助于我们创建可读性和组织性强的代码。还记得闭包和过滤吗?在没有缩进的情况下尝试阅读这些 Swift 功能是复杂的,代码的可读性影响我们的成功机会。
当在白板上编码时,缩进变得尤为重要。至少在笔记本电脑上,我们可以使用制表符,而白板上没有这种功能。
我们现在明白,我们在纯文本编辑器编码中面临的许多挑战在白板上变得更加突出。别担心!还有时间来管理这些。
在白板上练习
对于我们大多数人来说,在白板上编码并不自然。考虑那些在电脑前编码了近 10,000 小时的开发者;转向在白板上编码可能是一个挑战。
当涉及到在白板上编码而不是在笔记本电脑上编码时,有几个挑战我们应该记住。例如,白板没有滚动功能,编辑或插入新行要困难得多,没有直线或字体大小,我们的画布只是一个没有网格的大白表面。
话虽如此,一些技巧可以帮助我们提高白板技能。让我们来看看它们:
- 可视化我们的代码结构:正如我说的,白板没有内置的滚动机制,它们的编码区域是固定的。因此,在白板上编码之前,我们应该可视化我们的答案结构。注意我写的是 answer 而不是 code。这是因为我们的答案远不止我们本应实现的函数——还有测试、图表,甚至可能还有我们需要考虑的笔记。我们应该将白板划分为区域,并为每个答案的组件分配空间。当然,这也适用于编码区域本身——尝试想象你的答案会有多长,并相应地选择字体大小。看看 图 13**.1:
图 13.1 – 将白板划分为空间
图 13.1 展示了如何为我们的答案的每一部分设置不同的空间。组织你的白板是展示清晰答案的关键。
-
使用图表:我们讨论了为图表分配特定空间。然而,图表绝不是白板的限制——绘制图表是我们笔记本电脑上没有的显著优势,尤其是在远程工作时。图表让我们能够可视化我们的算法、想法和思考,并将它们更好地传达给我们的面试官。在练习编码问题时,我们应该尝试说明我们的思考和答案。学习如何做到这一点最好的方式是观看互联网上解释如何解决不同算法的视频,并关注视觉部分。我们应该知道如何在白板上描述流程、数组、树和数据库模式。
-
大声练习:许多开发者面临的一个常见挑战是在白板上解决编码问题,同时有效地解释它。为什么?因为大多数开发者不习惯在白板上写下代码,并同时解释它。我们不仅应该练习在白板上写作,还应该练习在思考的同时展示我们的解决方案,无论是给自己还是给别人。
很少有开发者有很好的白板绘图技能,但练习是显著提高的有效方法。这些小贴士可以提高标准,并为我们提供更多技能。
现在,让我们继续进行实际的编码。我们该如何开始?
开始编码
现在我们已经了解了不同的面试选项,我们可以开始真正的乐趣——编码。你能猜到当我面对与 iOS、Swift 或一般编码无关的编码问题时有什么建议吗?
慢慢来
我们首先需要放松,退后一步,分析我们的任务。即使我们(认为)已经知道答案,立即开始编码是一个常见的错误。
首先,当然,你可能知道答案,或者至少知道如何接近这个问题。但花上一两分钟重新思考你将做什么。也许找到一个更有效的方法来解决这个题目,或者一个更有吸引力的方式来展示答案。如果你有时间,为什么不利用它呢?
此外,花时间并不是面试官眼中的坏信号。相反,这是真的。你之所以不急于编码,是因为你行动前先思考,想要专注于规划和研究。在上一章中,我们提到面试官在架构面试中寻找软技能。然而,软技能在各个地方都会被测试,包括在编码面试中。规划和批判性思维,这些基本的软技能,在面试过程中也会被评估。
从测试开始
我们是在做测试驱动开发(TDD)吗?嗯,不是的。编码面试和架构面试之间最明显的区别之一是,在编码面试中,我们必须从一开始就理解所有约束和指南。我们不能在面试过程中发现它们。
在编码面试中,会话开始于我们向面试官询问完成任务的必要信息。
但我们如何知道要问什么?
最好的方法是写下我们将要编写的函数的测试用例。
看看以下编码问题:
编写一个 Swift 函数,该函数接受一个整数数组作为输入,并返回数组中任意两个元素之间的最大差值。最大差值应通过从最大元素中减去最小元素来计算。
现在,让我们写下我们的测试用例:
Input: [1, 2, 3, 4, 5] | Expected Output: 4Input: [7, 2, 9, 5, 1] | Expected Output: 8
Input: [10, 3, 5, 2, 8] | Expected Output: 8
Input: [6, 5] | Expected Output: 1
Input: [6] | Expected Output: 0
Input: [] | Expected Output: 0
Input: [-10, -5] | Expected Output: -5
这些测试用例对我们提供良好答案至关重要。首先,它们回答了我们的所有问题——如果我们有负数怎么办?如果输入只有一个元素或没有元素怎么办?
测试用例不仅可以帮助我们确定需要编写的算法,而且在整个会话中充当检查清单。如果我们想确保我们已经完成了编码并覆盖了所有情况,我们可以查看列表并检查我们的代码。
开门见山——不要害怕使用暴力法
在面试过程中,我们脑海中回响的一件事是我们需要提供一个在空间和时间复杂度方面优化的解决方案。
这一点是正确的——提供有效的解决方案是我们需要接受考验的事情之一。但是,作为一个起点,我们需要专注于完成任务,然后才能进行优化。换句话说,我们应该从暴力法开始。
什么是暴力法?
术语暴力法指的是一种直接解决问题的方法,涉及基本算法,而不考虑优化和效率。由于空间和时间复杂度较高,暴力法在实际场景中不切实际。
如果暴力法在现实场景中不实用,为什么我建议从它开始?
当我们收到一个任务时,我们首先想要提供一个可行的解决方案。可行的解决方案证明我们理解了问题,并且是优化的良好起点。我们可以开始测量时间和空间复杂度,并执行增量更新。
也就是说,暴力法是我们想要进行的进一步更改的锚点,为我们提供了灵活性,以便在出现问题时可以回退并找到另一条路径。
让我们来看一个例子:
找到一个整数数组中子数组的最大和。
暴力法的解决方案是使用两个嵌套循环遍历数组,同时维护一个maxSum
变量:
func maxSubarraySum(_ nums: [Int]) -> Int { var maxSum = Int.min
for i in 0..<nums.count {
var currentSum = 0
for j in i..<nums.count {
currentSum += nums[j]
maxSum = max(maxSum, currentSum)
}
}
return maxSum
}
在第一个循环中,我们遍历数组中的所有元素,并且对于每次迭代,我们创建另一个从当前元素开始到数组末尾的循环。在这种方法中,我们覆盖了所有不同的子数组组合,并尝试使用maxSum
数组找到最大和。
这个解决方案是有效的!但我们理解使用嵌套数组使我们的算法在时间复杂度为 O(n²)的情况下变得低效。
那么,我们如何改进它呢?
我们说过暴力解法是优化的一个很好的起点。有两个嵌套循环可能会引起担忧——我们理解我们的时间复杂度太高了。通常,时间与空间的权衡。在这种情况下,我们可以有一个数组迭代,并保留当前和在一个特定的变量中。
让我们看看代码的优化版本:
func maxSubarraySum(_ nums: [Int]) -> Int { var maxSum = nums[0]
var currentSum = nums[0]
for i in 1..<nums.count {
currentSum = max(nums[i], currentSum + nums[i])
maxSum = max(maxSum, currentSum)
}
return maxSum
}
上述算法被称为Kadane 算法,它是一种高效解决该问题的方法。在 Kadane 算法中,我们通过使用前一个索引的子数组计算来计算以特定索引结束的最大子数组。
感到困惑?让我们在一个随机数组上测试它:
[2, -3, 5, -1, 6]Iteration 1: currentSum = 2, maxSum = 2
Iteration 2: currentSum = -1, maxSum = 2
Iteration 3: currentSum = 5, maxSum = 5
Iteration 4: currentSum = 4, maxSum = 5
Iteration 5: currentSum = 10, maxSum = 10
Kadane 算法是一个巧妙的算法,它提供了一个高效且直接解决复杂问题的方法。如果你之前没有遇到过这个算法,你不会期望能够使用它。但这里的重点是,我们有我们的答案的两个版本,除非最优解突然出现在我们的脑海中,否则我们应该从暴力方法开始,然后继续解决问题。
专业的编码
那么,我们如何像专业人士一样编写代码?我们已经知道我们成功通过编码面试的机会与我们在家练习和解决算法问题有很大关系。但练习只是解决方案的一部分。在会话期间,我们应该遵循一些关键因素。
让我们从两个关键因素——时间和空间复杂度——开始。
使用“时间复杂度”和“空间复杂度”这两个术语
我们在本章中多次提到时间和空间复杂度,这是有充分理由的。我们知道与面试官的有效沟通至关重要,而且应该使用适当的术语。
在我们完成暴力解法(尽可能快)之后,我们需要对其进行优化并使用正确的术语。这一点非常重要,因为时间和空间复杂度是评估我们代码效率的客观方法。
例如,说“嵌套循环不是一种高效的方法”并不是描述算法的专业方式。正确的方式应该是,“这个算法的时间复杂度为 O(N²),但我认为我们可以将其改进到 O(N)。”
那么,时间复杂度是什么?以下信息框提供了一个定义。
什么是时间复杂度?
时间复杂度指的是随着输入规模的增加,算法运行所需的时间。我们描述输入规模为N,并用大 O 符号表示复杂度。
时间复杂度有几个应用场景:
-
常数时间复杂度,“O(1)”:访问数组中的特定索引或执行基本算术运算
-
线性时间复杂度,“O(n)”:迭代大小为 n 的数组或链表
-
二次时间复杂度,“O(n²)”:使用嵌套循环或冒泡排序
-
对数时间复杂度,“O(log n)”:二分查找
我不想过多地讨论时间和空间复杂度,但在面试讨论复杂度时,有两件事需要记住:
-
复杂度计算:这是一个我们在面试开始前应该解决的问题,而且比我们想象的要广泛。首先,我们应该计算代码中的不同操作,并定义每个操作的复杂度。然后,我们需要将它们加起来,最后确定不同输入的复杂度——最佳、最坏和平均情况。
-
理解对数时间复杂度:我之前提到的大多数用例都很直接,但 O(log n) 是让许多开发者感到困惑的一个。这种复杂度描述了一个算法,其运行时间随着输入大小的增加呈非线性(对数)增长。增长速度比 O(n) 和 O(n²) 慢,使其更有效。
让我们来看看下面的 findElement
函数,并尝试计算其时间复杂度:
func findElement(_ array: [Int], target: Int) -> Bool { for element in array {
if element == target {
return true
}
}
return false
}
算法很简单——我们在数组中迭代,检查每个元素是否与目标函数参数相等。如果我们找到一个具有相同值的元素,我们返回 true
。否则,我们返回 false
。
为了计算函数的时间复杂度,我们需要列出我们的操作,并分别描述它们的复杂度。我们有一个时间复杂度为 O(n) 的 for
循环和一个时间复杂度为 O(1) 的 if
语句。
在这种情况下,函数的整体时间复杂度是 O(n),它由线性主导操作决定。
那么关于 空间复杂度 呢?
什么是空间复杂度?
空间复杂度指的是算法在输入大小增加时所需的内存或存储。我们将输入大小表示为 N,并用大-O 符号表示其复杂度。
空间复杂度通常是时间复杂度的权衡。当我们优化算法时,我们需要考虑它消耗的内存量和执行算法所需的时间之间的平衡。
我们以类似的方式测量空间复杂度,就像我们测量时间复杂度一样。例如,让我们看看以下代码:
func printNumbers(n: Int) { var numbers = [Int]() // Array to store numbers
for i in 1...n {
numbers.append(i) // Adding numbers to the array
}
for number in numbers {
print(number) // Printing each number
}
}
printNumbers()
函数接收 n
作为输入,并创建一个数组来存储从 1 到 n
的数字。这个函数的空间复杂度是 O(n),因为数字的大小与输入 n
线性增长。
使用时间和空间复杂度的主要目标是不仅与面试官进行专业沟通,而且能够评估我们的解决方案,以便我们能够提供优化的答案。
带着一套工具
永远不要在没有武器或弹药的情况下进入战场——这对编码面试和合适的工具集也是一样的。如果我们准备了一份技术列表来帮助我们,每个面试问题或挑战都可以得到解决。
例如,我们可以有一个关于链表的复杂问题。为了解决这个问题,我们必须掌握链表的基本知识——如何遍历列表、删除/添加元素,或者将列表转换为数组以及相反。在我们到达面试之前,我们应该知道如何做这些事情。
熟练掌握基本数据结构可以帮助我们专注于真正的挑战,即算法本身。在前一章中,我们讨论了应用架构,并说设计模式是架构的构建块。在这里,我们也有构建块。我们首先从数据结构开始,然后了解操纵这些数据结构的基本设计模式,最后是建立在这些模式之上的算法。
为测试留出时间
不进行测试是许多候选人在编码面试中犯的常见错误。当我们讨论如何开始编码面试时,我们讨论了测试作为理解问题的关键。最终,编码面试围绕着测试展开——它们是理解问题的关键,但它们也是我们的清单,确保我们的答案满足面试官的要求。
但不仅仅是留出时间进行测试——假设你的测试会失败,你需要修复它们,所以你还需要留出时间来修复错误,这类似于现实世界的部署过程。
在这本书中,我旨在通过陪伴你每一步,指导你解决复杂的面试挑战。架构面试(在第第十二章中讨论)和编码面试是针对你的开发技能从不同角度进行的单独面试。家庭评估是结合了你大部分技能的面试。
在家庭评估中表现出色
家庭评估是许多公司用来检查候选人处理现实世界问题技能的常见面试任务。家庭评估要求我们规划、设计、编码、测试和部署,有时涉及到我们不熟悉的 iOS 主题。
让我们从分析什么是家庭评估开始。
家庭评估是什么样的?
正常家庭评估的框架不会让你感到惊讶。然而,仍然值得回顾它,这样我们就可以在流程上达成一致:
-
任务分配:这是初始阶段,我们接收任务,包括要求和说明。
-
理解:在这个阶段,我们仔细阅读说明并分析要求。这也是我们向面试官询问任何不清楚的问题并澄清我们必须做什么的阶段。
-
执行: 这是当前作业的实现,通常是在远程进行。然而,也有可能在招聘公司的办公室进行。这一步骤包括规划和编写我们的任务,如果在家进行,执行阶段可能持续几天。
-
测试: 一旦实现完成,我们就测试我们的解决方案。这包括运行测试用例和解决错误和问题。
-
提交: 在测试之后,我们必须将作业提交给面试官。在大多数情况下,提交是通过 Git 来方便完成的。
-
交付后讨论: 在许多公司,面试之后会有一场讨论或另一场面试,面试官会与你一起回顾评估,以了解在家庭评估过程中所采取的不同决策和做法。
不同的公司和评估可能有不同的具体结构,但这个框架为我们提供了对过程中涉及的重要步骤的理解。
现在我们已经了解了家庭评估的样子,让我们来理解为什么公司愿意花费他们的时间和我们的时间在这类任务上。
家庭评估中测试的不同技能
家庭评估检查了一些在其他面试阶段难以测试的技能,主要是开发端到端应用程序的能力。
家庭评估所需的技能既有软技能也有硬技能,代表了 iOS 开发者在实际情况下所需具备的技能。
大多数技能都是我们在前面的章节中讨论过的,但让我们在这里列出它们,以便我们能够明确面试官对我们有什么期望:
-
技术熟练度: 我们正在接受对我们技术技能的测试——Swift、框架、Xcode 和其他工具都是测试中需要掌握的技能的一部分。
-
问题解决: 家庭评估检查我们解决问题的能力,并在使用我们的经验和创造力将问题分解成更小、更易于管理的步骤时,这些技能是必不可少的。架构和应用程序设计是这些技能的一部分,在这些类型的测试中至关重要。
-
算法设计: 这可能不如编码测试那样高级,但它也是家庭评估的一部分。请注意,我们有一个项目,可能是一个小程序,需要开发。能够选择合适的数据结构和编写高效的算法可能是解决复杂问题并展示我们知识所必需的。
-
注重细节: 与其他面试步骤不同,在家庭评估中,我们有时间。有多少时间?足够的时间来提供一个好、准确且无错误的解决方案。所以,你看,时间越多,对我们期望就越高,我们也会被测试是否能够处理边缘情况并完成代码。
-
代码质量:我们的家庭评估方法在代码质量方面需要类似于完整项目的样子。这意味着我们需要使用注释来记录代码,进行测试和质量保证,选择合适的命名约定,并拥有有组织的文件结构。我知道,对于一个面试来说,这是一个小项目。但这里有一个秘密——在这个阶段,面试官已经知道我们能够构建表格视图并设置单例。实现的质量才是最重要的。
-
独立性:家庭评估的最好之处之一是它们允许我们展示从项目设置、编码、测试到部署的端到端完成任务的能力,同时受到观察。
话虽如此,所需的不同技能取决于我们申请的具体职位和公司。在这个阶段,我们应该已经了解公司的文化以及对我们有什么期望。
现在,让我们回顾一些家庭评估中可能遇到的项目常见示例。
检查示例
在继续之前,简要说明如何阅读这些示例。你可能不会在面试中遇到这些示例之一,这完全正常,因为那不是我的意图。提供这些示例的目的是加强你的技能,让你为未知做好准备。与 iOS 面试问题不同,那里的知识很重要,而家庭评估关注的是技能和技术。这就是为什么我要强调技能列表和家庭评估过程。
每个示例都可以教会我们家庭评估的不同方面,而快速构建一个小型应用并提供简短解释的能力对于这项任务至关重要。
让我们回顾一下示例,并注意哪些是重要的:
-
构建待办事项应用:家庭评估侧重于表格视图、本地数据存储和状态管理。此外,典型的待办事项应用通常支持离线工作并提供流畅的用户体验。
-
构建一个具有吸引人的用户界面和反应式 API 更新的天气应用:在这个类型的应用中集成 Combine 框架很重要,以优雅的设计模式向用户提供更新信息。
-
构建照片库应用:照片库应用需要与PhotoKit(iOS SDK 的照片框架)紧密合作,同时优化其快速加载和缓存性能。此外,内存管理能力对于处理大量数据至关重要。
-
创建社交媒体动态:社交媒体动态通常结合表格视图和分页模式工作。提供有效的内存管理、用户交互(如点赞和评论)以及作为动态一部分的图片加载是至关重要的。
-
构建日历应用:日历应用需要管理事件列表,并与EventKit框架(帮助我们连接到日历的 Apple 框架)紧密集成,包括具有分页设计模式的表格视图。
每个这样的评估都需要我们面对不同的挑战,实现各种设计模式,并调整架构以创建一个有组织的项目。尝试思考不同的解决方案和架构是一项很好的脑力锻炼,可以提高你在测试中成功的机会。
尽管我们的代码结果极其重要,但重要的是要理解我们的面试官正在关注我们工作的其他方面——红旗。
避免红旗
现在我们已经了解了现场编码面试和家庭评估是什么,让我们简要讨论一下完美。我们是否必须提供一个完美的解决方案才能通过面试?面试官在寻找什么?
这不是一个简单的问题,因为它可能因不同的面试官和公司而异。
但所有面试官都会寻找的是:红旗。我们没有提供优化解决方案或不知道特定的 Swift 功能这一事实,许多面试官可以接受——在大多数情况下,他们寻找的是我们处理、思考和编码方式中不健康信号的迹象。
有一些红旗我们应该避免,即使它们在面试过程中可能看起来微不足道。现在让我们简要回顾一些。
无法解释或捍卫解决方案
这是一个红旗,它包含了两个关键缺失的技能。第一个是深入思考和代码/设计理解。许多开发者只是依靠记忆来重复解决方案,而不理解为什么以及它们是如何工作的。通过面试问题是不够的;我们还应该理解我们为什么这样做。现在我们到了书的结尾,回到过去并验证我们是否完全理解了不同的答案和解决方案是至关重要的。
第二个技能是沟通和解释能力。有时很难找到合适的词语来描述我们为什么做出某些决定,这可以转化为沟通技巧。但这就是这本书的目的——帮助你表达你的知识,并为你准备面试。
二分法思维
许多面试问题,尤其是设计、架构和编码问题,需要权衡和考虑多种方法。如果不解释替代解决方案,就没有黑白分明的思维空间。我们应该始终灵活,并理解并非总是只有一个正确答案。
错误处理有限
虽然这可能听起来微不足道,但忽视错误处理可能会被面试官视为红旗,表明可能存在不健康的方法。专注于快乐流程表明缺乏对细节的关注和非常浅薄的开发水平。处理可能产生错误和意外结果的代码流程是重要的。
代码质量差
我们的代码是我们的艺术品,它应该看起来很好,并表达我们作为开发者的专业能力和技能。但这究竟意味着什么?这意味着我们应该编写结构化和有组织的代码,包括注释和文档在内的清晰命名约定。尽量避免使用过短且不清晰的名字,并在你的代码中添加缩进和空格。
此外,良好的分离、简短的功能和文件夹组织可以极大地帮助我们改善代码的外观和清晰度。在这个阶段,基础知识确实很重要。
摘要
就这样!我们已经到达了这本书的终点,这是一段多么激动人心的旅程!
在这一章的结尾,我们深入探讨了现场编码、家庭评估和避免红旗。我们学习了如何应对白板任务,编码时应关注什么,以及如何处理这个过程的重要阶段。此时,你应该感到准备充分,去应对面试过程中最实验性的阶段:编码评估。
虽然这本书可能已经结束,但我们的实践和努力之旅才刚刚开始。iOS 开发的世界广阔无垠,总有更多东西可以学习和探索。每一章、每一节和每一个问题都为你打开了一扇通往众多可以帮助你提升的话题的大门。
所以,现在不要停下来;你才刚刚开始。拥抱持续学习,并在你的 iOS 开发职业生涯中追求卓越。