老人新兵 —— 一款 iOS APP 的开发手记

本文是 2020 年初,我在 知乎 上写的一篇文章。记录了我重新开始学习编程的一点心路历程。现在回看起来,虽然里面有关技术方面的理解有点幼稚( 请无视文中的技术细节 ),但还蛮有意思的。恰逢博客的文章数达到了 100 篇,将其搬运回来以提醒自己不忘初心。

写在前面的话

我接触电脑的时间比较早( 第一台电脑型号是 CP-80,CPU 是 MC6800 ),开始学习编程也比较早( 从中华学习机开始,Apple II 的国内兼容机 )。对于电脑和编程也都一直很有兴趣,不过从来也没有真正的把写代码当做过职业。虽然也使用过几种编程语言在不同的平台上写过些代码,但都不能算作完整的产品。习惯性地对信息行业的前沿动态以及一些新的技术方向保持着关注,但由于生意及其它方面的原因,从 10 几年前便完全没有再接触过编程了。

最近 6 — 7 年,由于疾病的原因,我的精力基本都集中在治疗上。由于治疗的需要,我作为一个不喜欢记笔记的人,却需要每天要记录大量的数据( 其实主要还是依赖我妻子 )。前年通过手术,疾病获得了很大的改善,本以为所需记录的数据能少一点,但事与愿违,数据量减少了,数据种类却大大地增加了,而且可以明确的是,这些数据将要在我有生之年一直记录下去。

img

上图中的化验单是最近 1 年内的验血结果

之前也使用过电子表格整理过数据,但并不顺手。在 app 种类非常丰富的今天,也找过不少的 app 试图进行集中管理,不过效果并不理想。考虑到身体也已经恢复到了一个不错的状态,就决定尝试做一个能满足自己需求的 app, 一方面是活动活动脑子,另一方面也算重拾当前的兴趣。

十多年间,信息技术的发展巨大,非常多的新技术、新方法、新概念以及之前难以想象的算力提升都给我这个老人新兵带来了不小的考验。

健康笔记 - 全家人的健康助手

健康笔记适用于任何有健康管理需求的人士。提供了强大的自定义数据类型功能,可以记录生活中绝大多数的健康项目数据。你可以为每个家庭成员创建各自的记录笔记,或者针对某个特定项目、特定时期创建对应的笔记。

推荐

开发平台及框架的选择

作为一个苹果产品的长期使用者( 从 Apple II 兼容机、Apple II、灰度显示的 PowerBook、伪彩的 PowerBook、若干代台灯、小白、小黑到如今的 iMac、MacBook Pro 以及众多的全家桶产品 ),开发一个能在 iOS 上使用的 app 是再自然不过的想法。

该 app 主要针对的对象就是像我这样对健康类数据有长期关注、管理、分析需求的人群。

曾考虑过使用可跨平台的开发框架,不过出于以下几个原因最终还是选择了 Native 的开发方式:

  • 身边没有安卓设备
  • 能力及精力有限,无法做大规模适配
  • 有开发打算时恰逢 2019 年 WWDC 结束不久,苹果新推出的 SwiftUI、Combine 以及 Core Data 中的新特性对我很有吸引力
  • 兴趣驱动,没有商业压力和历史包袱,因此可直接采用尚未成熟或前景不明的技术

在经过一番了解后,最终选择 SwiftUI + Combine + Core Data 的组合。其中既有庆幸也有艰辛,下文中也会有更多说明。

技术准备

十多年不接触代码我最大的担心不是我的知识储备不够,而是没有手感了。这个忧虑最终也得到了证实。

之前接触过不少种编程语言,因此对我来说编程语言基本的语法理解起来没有什么太大问题,主要是如何能将其特性发挥出来。OC 一方面是学习成本要高于 Swift,另外在 5.0 后,Swift 也已经逐步稳定下来,出于多快好省的想法,使用 Swift 是当前在 iOS 下进行开发的不错选择。

买了几本 Swift 的基础书籍( 也是真够基础的 ),通读了两遍,对其有了一定的了解。本以为至少读读其他人的代码应该不难吧,结果发现很多的代码都读不太明白。究其原因,大多 Swift 代码中使用了泛型、函数式编程等特性,这些知识在基础书籍里都是一笔带过的,从而导致代码看得一头雾水。没关系,回炉重造。这里我要特别感谢 ObjCCN 翻译并撰写的书籍。通过这些书籍,我对 Swift 语言有了更多的认识和了解。当然其中很多的资料并不太容易理解,不过咬牙读下来还是会有极大的收获。

在对 Swift 基本语法有所掌握的情况下,我开始学习 SwiftUI。最开始阅读的资料自然是 Apple 自带文档中的例程。对于我这样没有 UIKit 使用经验的人来说,声明式带来的好处起初并没有什么太大感觉( 我最后的编程记忆停留在 Django 尚未 release 的时期,初步接触了 Django 的 MVC 模式 ),直到后来由于功能的需要在 SwiftUI 下混合使用 UIKit 开发才真正体会到了声明式的优势。

官方资料中的例子不错,但当我想以此为蓝本,实现一个完整的 app 时,确发现无从下手。这里仍然要感谢 ObjCCN 王巍编写的 SwiftUI 与 Combine 编程一书。我是在其预购时便开始阅读的,受益匪浅。尤其是对于 Combine 的响应式思想有了更加完善的认识。严格说 SwiftUI 是无法脱离 Combine 的,但是 Combine 可以和其他各种框架进行结合。

相较于声明式思想,响应式编程给我带来了更多的震撼。尤其在之后的开发过程,随着理解的深入,越发感受到了它的魅力所在。

在实现 app UI 有了一定技术基础的情况下,我便开始了关于 i 数据库方面的选择。

很早前使用过关系型数据库,因此还算有点基础。但对于在移动端应使用什么样的产品或框架则完全没有概念。想着既然作为兴趣作品,那么可以借机多了解一下当下流行的移动数据持久化方案。读了点 NoSQL 资料,也接触了点当下主流的云数据解决方案,各有特点,一时也拿不定主意。

其实最初我就打算使用 Core Data,但由于其资料不多,学习曲线陡峭,另外感觉到国内的 app 开发者好像普遍不太喜欢使用它,所以暂时搁置。最后在反复权衡后仍然回头选择了 Core Data。首先在 iOS 13 下它自带的云同步非常吸引我( 基本免费、性价比超高 ),另外由于 Core Data 并非一个 ORM( 应该称其为对象图管理框架 ),在性能及安全等方面还有不少其他的优势。

学习 Core Data 时面临的问题不少,主要体现在资料少、入手难。我目前所能找到的最好的资料是 objc 创作的 Core Data 中文译本,不过说实话这本书非常不适合初学者阅读。通过不断地在网上查找资料、观看油管视频、研究苹果文档里令人费解的说明,用了将近一个月的时间才初窥门径。随着理解的深入,我对于 Core Data 的好感也不断加深。如果你的 app 并不打算跨平台( 仅支持苹果生态 ),或者希望使用 native 的方式进行 iOS 下的本地数据管理,Core Data 真的是相当不错的选择。比如像我的 app 这样只在 iOS 上运行的话,只需要最小的代码开销便可完成非常优秀的云同步。另外如果利用好 Core Data 的特性,在当 SwiftUI + Combine 下你可以获得极为方便的动态数据管理流程。

另外在储备期间还学习了 DesignCode 的 SwiftUI 和 Sketch 两个视频课程,尤其是 Sketch 对于之后的开发起到了不小的帮助作用。

从去年( 2019 年 )十一开始进入到学习状态,到 11 月底,用了两个月的时间,达到了基本具备构建一个完整 app 的能力( 个人认为 ),从 11 月 24 开始( Git 上第一个 commit 时间 )进入到了正式的开发阶段。

正式开发

由于对需求的考虑比较充分( 了解自己需要什么样的工具 ),因此最初几天的进展很快。SwiftUI 给我创建了一个非常高效的环境,在短时间内便可以将整个 app 的原型跑起来,但当真正地将具体实现以及数据流完全串联起来时才发现一切并不那么简单。

有以下几个难点:

  • SwiftUI 功能十分有限

在真正要实现诸多功能时发现,目前很多场景下仍然要通过 UIKit 才能完成,为此又耗费了些心力学习了点 UIKit 的内容( 至少需要掌握两者之间如何混合使用 )。在最后的 app 里面有接近一半的显示控制其实都是在 UIKit 下完成的,即使像 TextField 这样最基本的需求,SwiftUI 的原生版本有时都无法胜任。

  • SwiftUI 和 Combine 的 Bug 太多

尽管做好新产品并不完善的准备,不过 bug 的数量还是远远多于我的预期。在整个开发过程中我通过 feedback 汇报了十余处明显的 bug,还有很多灵异现象由于无法使用简短的例程重现我都没有办法汇报。总之在逐渐摸清了这两个老爷的脾气后,已经基本上能够和这些 bug 和睦相处了。

  • 编程思想

尽管我有使用最新编程思想的觉悟,也在设计和开发中向着这个方向努力,但一方面是之前的经验惯性,另外还是对新思路的掌握浅薄,在整个的开发中走了不少的弯路。我的数据流控制逻辑基本上推倒重写了 4 次,目前版本的代码量在完成更多功能、更加稳定并且每个 view 中的数据都无需干预、动态更新的情况下少了一半。

  • 开发环境

多年不接触编程,在相当长的一段时间里仍无法完全适应当今如此复杂的 IDE 工具。另外 Xcode 的某些错误提醒也很神奇,一部分很准确、一部分很无语,把本来简单的错误指引到了奇怪的地方。用了差不多半个月才基本搞清楚什么能信什么不能信。

另外开发中的包管理、版本管理等对我来说都是新课题,总之每每遇到新问题都是一种修行。

  • 上线审核

我是打算在本次开发中,多接触点新的课题。最后在 app 中使用了应用内购买、自动续费等多种方式。随后发现真是给自己挖了个大坑,十分庆幸总算搞定。主要的问题并不是技术方面,而是由于完全没有审核经验因此走了很多冤枉路。

总之经过了一个半月的开发( 其中包括半个月的各种审核问题 ),第一个版本目前已登陆 App Store。

健康笔记 - 全家人的健康助手

健康笔记适用于任何有健康管理需求的人士。提供了强大的自定义数据类型功能,可以记录生活中绝大多数的健康项目数据。你可以为每个家庭成员创建各自的记录笔记,或者针对某个特定项目、特定时期创建对应的笔记。

推荐

吐槽、提示、经验、总结

本文基本上处于意识流状态,想哪写哪。下文是关于我在开发过程中遇到的一些问题,bug ,总结的技巧,获得的一点点经验等。没有必然的前后顺序,如果里面有错误,希望大家轻点点评 😅。

TabView

SwiftUi 中的 TabView 本来是一个很方便的控件,寥寥几句代码便可完成一个标准的屏幕底部页面切换功能,不过它有几个问题:

  • item 的版式控制力差,这个还是可以接受的,可以通过一些手段调整,不过就不优美了;
  • 切换页面 view 会重置状态,比如说 view1 里有个 ScrollView, 你已经进行了滚动,当你通过 TabView 切换到其他视图后切换回来,ScrollView 不会保留在原来的地方,直接会回到顶部
  • 由于切换重置,在复杂页面加载时效率低到可怕。TabView 在切换时,应该是把原来的 view 完全销毁掉,而且销毁的效率很低。导致如果页面复杂切换就像机械相机按动快门一样,会闪。这个是所有问题里最不能够接受的一点

本来想实在不行就通过桥接 UIKit 来实现吧,最后采用通过 ZStack 模拟 TabView 功能的方案,解决了以上的问题,并获得了更多的控制能力。当然也有弊端,使用了 ZStack 后,所有的 view 即使看不见实际上也已经初始化并显示了,并且不会销毁,因此失去通过 onAppear 和 onDisappear 进行介入的手段( 最后采用了其他的变通方式 )。

ScrollView

SwiftUI 中的 ScrollView 沿袭了其他 SwiftUI 控件的特点,使用起来非常轻巧,但几乎不提供额外的控制选项。在我的 app 中,多数情况它还是胜任的,不过它和某些 UIKit 的实现结合起来使用会出现灵异现象,最后在个别页面中,还是使用了 UIScrollView 才解决了问题。

NavigationView

灵异现象较多。

最大的一个是如果 view 的内容比较复杂,且 barItem 使用中文或图片,缓慢地从左侧滑动页面返回时,会出现不同 View 顶部的 NavigationBarItem 重叠的现象,导致 BarItem 的按钮失效。这个 bug,我已经通过 feedback 和 apple 沟通了几轮,由于简单的例子很难重现,为了 feedback,我几乎单独又写了个小程序给他们。目前这个 bug 还没有解决,为了不让用户的体验出现问题( 只是偶尔出现,但也很烦人 ),我暂时屏蔽了 app 中的从屏幕左侧滑动返回的功能。

模拟器中 NavigationLink 只能使用一次,第二次点会失效,实机没有问题。

很难实现直接返回到根视图,通过 dissmiss 只能返回到上层视图。在 Xcode 11 的 beta 版本中还可以采用一些非常规手段实现这一功能,不过目前已被屏蔽了。导致我无法很好地实现双击 TabView 图标返回该 Tab 的根视图,比较郁闷。其实也有笨办法,通过自己管理一个 View Stack, 然后使用 onReceive 一层层的给 view 传递 dismiss 指令。但由于这个多层返回是显式的,也就是都有完整的动画,超过一层时,使用者的感觉会更怪。最后仅是在当 view 在 ZStack 的后面时使用了这个手段。

Sheet

问题很奇怪。

环境值和环境对象必须显式注入,否则会运行错误。从这一点来看 Sheet 应该和其他的 view 在数据环境上是隔绝的。

当一个 view 中有基于 ForEach 动态变化的数据时,如果该 view 在 sheet 中,数据变化后会导致触发异常,如果将 view 从 Sheet 中提取出来直接显示则无此问题。

复杂的 Form 在同一个 view 中,处于 Sheet 和非 Sheet 下也会出现异常。

TextField

简单应用没有问题,除了不支持多行输入。

但如果对 TextField 的 binding text 进行实时判断处理的话,系统自带的中文输入法将无法输入中文,绝大多数第三方输入法没有问题。最后使用 UITextView 解决。

如果 TextField 在 ScrollView 中,当在不同的 Segment Picker 中切换时,使用系统自带中文输入会闪退。英文和第三方中文输入没有问题。

不支持输入后隐藏输入法,需要通过 UIKit 想办法解决。

Text

没什么大问题,挺好用,就是版式控制弱了点。

Form

如果 Form 中使用 if 根据条件动态显示的话,会有灵异事件。同样代码,有时可以正常编译,有时不能。同样的 Form 代码,有时在 Sheet 中可以正常编译,移出 Sheet 又编译错误,有时反之。

List

List 具备惰性数据加载能力,很适合数据量较大的场景。但版式控制力差。如果非要在 init 中使用类似 UITableView.appearance 之类的方式进行设定的话,单个 view 中的设定会影响整个 app( 除非能够很好地控制该 view 的初始化和销毁 )。这也是目前 SwiftUI 中控件显示设定的一个问题( 主要是官方并不推荐和支持这样的行为 ),各个 view 中如果通过 UIKit 修改设定的话,之间不隔离。

如果有 animation 的话,数据多时效率会很低,使用 id 强制重绘可以解决。

VStack HStack ZStack

版式控制很方便,可以在短时间内就完成较复杂的版式构图。不过其对 ViewBuilder 的类型支持比较有限,一个 Stack 中所能包含的 Stack 也不能过于复杂( 目前 apple 也就只定义了几种 TupleView 的形式 )。尤其对于在 if 后的 Stack 要求严格。判断分支中的 some view 有时会要求得极为变态,但有时又会适当放松。

Picker

种类基本够用,细节还需要进一步加强。Segment 必须动画显示完才转换,有粘滞感。Date 占地较大。

ForEach

视图声明中唯一的循环控制方式,控制力有待加强。

如果使用 data: Range<Int> 的话,range 不可变。比如说 0..<students.count students 数量如果动态变化会 game over 。

GeometryReader PreferenceKey 等

视图自我认知的好手段。通过 Geometry Proxy 可以获得足够的空间信息,如果需要将信息传递给上层视图的话,通过 PreferenceKey 即可完成

Preview

很好的想法,对于简单的 view 响应很快。不过一旦 view 比较复杂,有时候还不如 rebuild 来的爽。另外如果 view 的初始参数比较复杂,比如直接传递个 NSManagedObject,构建 preview 也很麻烦。后期多数时间都直接删除,前期构建框架时很好用。

Combine

很好用,很方便。和很多系统自带框架结合也很好,不愧是系统级的支持。

效率目前有问题。SwiftUI 中的多数控件都是采用 bind 的方式来响应及传递数据,设计起来思路会很清晰,不过执行起来会有粘滞感。复杂 Sheet 会尤其明显。所有基于异步的设计响应都会有延时。希望将来能够对响应进行一定的优化。我的 app 中有几处 Sheet 弹出的响应就比较慢( 将 view 移出 sheet,使用 NavigationLink 调用显示就很好 ),尤其是退出时的响应更慢。感觉 SwiftUI 在销毁 view 上的代码有比较严重的效率 bug( 参见上面的 TabView )。

Core Data

新的基于 iCloud 同步( 不是 CloudKit )很好用,设置也非常方便。

开发环境下 app 里云数据库中的数据和 app store 下载的 app 数据不互通( 同一个 id ),开发时模拟器里的数据也不能和实机的数据云同步,必须在多个实机中才能测试。

我的 app 是有纯本地数据库( 无需同步 )和同步数据库的,各自在不同的 Configuration 里。不同的 Configuration 之间不能建立 relationship,毕竟是两个不同的 sqlit 库。

不要用数据库的思路使用 Core Data.

RelationShip 是一个好东西,系统会自动维护数据之间的关系。

由于有了 RelationShip,多数情况下无需自己设计主键,这是一个极大的便利,但当需要将数据库手动导出备份时却出现了问题。你无法使用系统内置的主键或 ObjectID。最后还是在需要导出的 Entite 中追加了可标识的属性。平时的程序运行完全不依赖于该属性,在导出 JSON 时则依赖这些属性来标注他们之间的 relationship.

在托管上下文中,数据的执行效率很高。

@FetchRequest 对数据的动态管理非常好,在 SwiftUI 中数据的任何变化都能动态体现。

@FetchRequest 目前只能在 init 中通过参数动态设置一次( 无法动态修改 ),如果需要显示不同的谓词或排序结果,只能通过上层视图重新设置。

@FetchRequest 没办法设置 fetchLimit 等更多优化参数。

应用内购买

技术上并不复杂,因为我并不需要自设服务器来二度认证,所以逻辑上就简单了很多。唯一的就是苹果在购买完成后最初的完成反馈其实并不能保证用户已经付款,所以需要在后台查询确保已付款成功。退款等也不会有提示,反正定期更新收据就行了。

App Store 审核

我在审核上卡的时间比较久,完全是因为自己没有搞懂它的使用方法。碰到的大多问题都源自应用内购买。

最初是因为元数据缺失,反复了两三次后我才搞明白需要填写内购资费的截图和备注。

后来发现还需要在 app 提交中选择 app 中使用的内购资费。

如果此时我不做任何动作就没问题了。但我一时头脑发热在 app 已经进入了 review 的情况下改动了资费的元数据,结果 app 被拒,而此时该资费便始终处于审核状态。

等不下去了,删除了原来的资费数据又重新创建了资费数据,提交审核资费通过。

将新的资费数据重新填入 app 的提交中,再度被拒。

原来描述中没有内购资费的详细说明,修改后终于通过。

用时较长的主要原因是每次的反馈都需要 1 天,而且 apple 的回复也确实太泛泛,让人疑惑。

其他的以后有时间再写

虽然目前仍有很多不足,但 SwiftUI + Combine 的方向绝对正确,即使在当下也可以带来很大的效率提升。再过 2—3 年,相信能有非常大的提高。CoreData 很好用,原生的 iOS 程序还是可以多多考量它的。

尾声

一不小心就乱写了一大堆,就当是对这几个月的一个简单小结吧。

我正以聊天室、Twitter、博客留言等讨论为灵感,从中选取有代表性的问题和技巧制作成 Tips ,发布在 Twitter 上。每周也会对当周博客上的新文章以及在 Twitter 上发布的 Tips 进行汇总,并通过邮件列表的形式发送给订阅者。

订阅下方的 邮件列表,可以及时获得每周的 Tips 汇总。

本博客文章采用CC 4.0 协议,转载需注明出处和作者。

鼓励作者