├── .gitignore ├── README.md ├── cn ├── memory-management-1.md └── native-app-development.md ├── examples └── .gitkeep └── images ├── memory-management └── title.jpg └── native-app-development ├── android-studio-basics.png ├── android-studio-gradle.png ├── fragment-screen-sizes.png ├── ios-scroll-view.png ├── mix-ios-android.png ├── xcode-basics.png └── xcode-targets.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *.out 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GUI Engineering Guide for Web Developers 2 | 写给前端的 GUI 工程基础入门 3 | 4 | ## 目录 5 | * [写给前端的原生开发基础入门](./cn/native-app-development.md) 6 | * 写给前端的手动内存管理基础入门 7 | * [返璞归真:从引用类型到裸指针](./cn/memory-management-1.md) 8 | * 新瓶旧酒:面向对象的资源管理 9 | * 半自动化:引用计数与智能指针 10 | * 触类旁通:进入 Rust 时代 11 | 12 | ## Index 13 | TODO 14 | 15 | ## License 16 | Copyright (c) 2020-2021 Yifeng Wang 17 | -------------------------------------------------------------------------------- /cn/memory-management-1.md: -------------------------------------------------------------------------------- 1 | # 写给前端的手动内存管理基础入门(一)返璞归真:从引用类型到裸指针 2 | 3 | ![](../images/memory-management/title.jpg) 4 | 5 | 作为一名经常需要在团队中搞跨界合作的前端开发者,我发现许多往往被人们敬而远之的「底层」技能其实并不必学到精通才能应用。只要能以问题导向按需学习,就足以有效地完成工作,取得成果了。像 C、C++ 和 Rust 中的手动内存管理,就是这样的例子。我们完全可以绕开语言的黑魔法,只学习它们对工程而言最必要的特性与最佳实践。这就足够我们开发出它们与 JS 交互时的原生扩展,或调用平台库实现功能了。 6 | 7 | 因此,本系列文章将以实用和入门角度出发,专注于解释这几门「原生语言」中,与手动内存管理相关的子集。整个主题(暂定)依次包括这些部分: 8 | 9 | * **返璞归真:从引用类型到裸指针**——在 GC 的庇护下,我们对 JS 中引用类型(如对象)占用的内存可以放心地一无所知。那么如果想自己手动管理一段内存空间,这时最朴素最简单最经典的做法,是怎样的呢?令许多计算机专业新生「闻风丧胆」的指针,理解起来真的比 JS 困难很多吗? 10 | * **新瓶旧酒:面向对象的资源管理**——原始的 C 语言是面向过程的,并不易于维护。在智能指针到来前,C++ 已为此投入了大量工作。我们是否能借此以更为声明式的代码和思想来管理资源呢?这时最常见的坑又是什么样的呢? 11 | * **半自动化:引用计数与智能指针**——无 GC 的内存管理只能完全手动吗?是时候接触现代 C++ 中的智能指针了。理想情况下只要利用好这套基础设施来写 C++,其开发效率未必会差 JS 太多(当然理想和现实是有差距的)。 12 | * **触类旁通:进入 Rust 时代**——最后,很多人在光有 JS 背景去硬啃 Rust 时,可能会遇到不少显得奇怪的语言特性。但有了上面的铺垫后,你或许就可以「秒懂」Rust 为什么要这么设计了。你会看到只要理解了原理,要把内存管理的经验在 C++ 和 Rust 之间复用,其实并非难事。 13 | 14 | 由于在内存管理方面,JS 和其他常见的 Java、Python 和 Dart 等带 GC 语言的差别并不大。因此熟悉这些语言的同学,也可以很容易地按这篇文章介绍的方式,由浅入深地掌握这项工程技能。另外在前端开发者们常用的 macOS 上,C/C++ 的编译环境更完全是现成的,没有任何折腾的必要。本文中的代码都是无需第三方依赖的单线程简单示例,因此也不必接触复杂的构建配置和多线程心智模型。 15 | 16 | 作为系列的起点,下面我们将从 JS 返回 C 的世界。有位做 C++ 的朋友提醒我说「*裸指针可能是最难的*」。但其实个人认为对现在**普遍熟悉强类型语言**的前端开发者们来说,裸指针反倒很容易通过一些技巧来快速类比掌握。不过鉴于指针的新人杀手性质,本文会尝试提供一条尽可能平滑的学习曲线。相信只要过了这一关,大家读起后面的内容会轻松顺利很多。 17 | 18 | 本篇介绍可分为如下几个部分: 19 | 20 | * 你可能已经掌握的 C 子集 21 | * 熟悉指针类型 22 | * 为指针分配内存 23 | * 指针、数组与字符串 24 | * 指针与 C++ 中的引用 25 | 26 | 下面,让我们首先从每位前端开发者都耳熟能详的「基础类型」和「引用类型」说起吧。 27 | 28 | ## 你可能已经掌握的 C 子集 29 | 当我们探讨「原生」语言中的内存管理时,C 语言是我们很难绕开的话题。它的影响极为深远,以至于很多时候当你熟练地使用其他高级语言编码时,你甚至未必知道自己可能已经(部分地)学会了 C。比如,如果只考虑 `double` 和 `int` 这样的基础类型,那么你会发现这时的 C 语言,几乎不可思议地接近 JS 和 Dart。像下面这段用来计算斐波那契数列的经典代码,就同时既是有效的 C,又是有效的 Dart: 30 | 31 | ``` c 32 | int fib(int n) { 33 | if (n <= 0) return 0; 34 | else if (n == 1) return 1; 35 | else return fib(n - 1) + fib(n - 2); 36 | } 37 | ``` 38 | 39 | 上面这个例子中,在发生形如 `fib(n - 1)` 的函数调用时,调用方 `n - 1` 表达式的值会被复制一份,传给被调用的函数,这也就是所谓 [pass by value](https://en.wikipedia.org/wiki/Evaluation_strategy#Call_by_value) 的经典心智模型了。对于整数和浮点数等「小」数据类型,这种设计从 C 时代起,一直在主流工业语言中沿用至今。所以如果你只用 JS 或 Dart 做这类纯粹的算术计算,那么你完全可以(自豪地)认为这时你写的代码就是 C 语言子集的一种方言变体,当然这个子集其实也没多少实用价值就是了。 40 | 41 | > 有些语言会把类型定义放在变量标识符前面,如 C、C++、Java、C# 和 Dart。也有一些则会把类型放在标识符后面,如 TypeScript、Rust 和 Go。像 `int x` 还是 `x: int` 这种风格问题本身并没有多少好坏之分,大家只要入乡随俗地习惯就行。 42 | 43 | 根据小学二年级的前端知识,我们知道在 JS 中,整数和浮点数属于基本类型([primitive values](https://262.ecma-international.org/11.0/#sec-primitive-value)),而对象则属于引用类型。所谓「引用」就像超链接一样,是个指向实际对象的标识符。在 JS 中,当我们把对象作为参数来执行函数调用时,引用会被复制一份传入被调用的函数,而引用所指向的对象本身则不会被拷贝——前端社区里解释这件事的文章早已汗牛充栋,这里就不再赘述了。关键的问题在于,在 C 语言里传递「对象」时又是怎样的呢? 44 | 45 | 有趣的是,**如果我们坚持写语法最简单的 C,那么你会发现这时的 C 甚至在心智模型上能比 JS 更加简单**,简单到你都可以不用考虑「引用」这个常常困扰初学者的概念。实际上,当我们在 C 语言中使用 `int a` 这样的语法来定义变量时,数据通常会被分配在栈(此处指原生程序的运行时调用栈,即所谓的 [The Stack](https://en.wikipedia.org/wiki/Call_stack))上。这类变量的内存能做到被优雅地零开销自动管理,因为在离开函数作用域时,在栈上分配的内存都会被自动回收。而对于 C 语言中常用于模拟对象的 `struct` 结构体来说,默认你也能用这种方式来管理它的内存——**也就是完全不用你费心管**!没有内存泄漏,没有悬空指针,一切简单明了地自动完成。 46 | 47 | 让我们举例说明这种「最简单的 C」吧。假设我们想基于 Skia 这样的 2D 图形绘制库,实现自己的 UI 框架。那么这时框架中所建模的最重要的抽象概念,应该就是屏幕上的矩形图层(这里把它称为 Layer)了。基于朴素的 C 结构体语法,我们可以这么建模图层 Layer 的基础结构: 48 | 49 | ``` c 50 | // 导入标准库中的 printf 函数 51 | #include 52 | 53 | // 定义名为 Layer 的结构体,包含 x y w h 四个字段 54 | struct Layer { 55 | double x; 56 | double y; 57 | double w; 58 | double h; 59 | }; 60 | 61 | // 输出 layer 的宽高尺寸 62 | void printSize(struct Layer layer) { 63 | // 使用 layer.xxx 语法来获取字段 64 | printf("width: %f, height: %f\n", layer.w, layer.h); 65 | } 66 | 67 | int main() { 68 | // 使用字面量语法建立栈上的 layer 实例 69 | struct Layer a = {0.0, 0.0, 100.0, 100.0}; 70 | printSize(a); 71 | } 72 | ``` 73 | 74 | 不过 `struct Layer` 这样的类型名有些啰嗦,所以我们常常会用 `typedef` 语法来简化一下它,像这样: 75 | 76 | ``` c 77 | // 把 struct Layer 定义为 Layer 78 | typedef struct Layer { 79 | double x; 80 | double y; 81 | double w; 82 | double h; 83 | } Layer; 84 | ``` 85 | 86 | 这样一来,我们就可以用 `Layer` 这个类型来替代 `struct Layer` 了: 87 | 88 | ``` c 89 | void printSize(Layer layer) { 90 | printf("width: %f, height: %f\n", layer.w, layer.h); 91 | } 92 | 93 | int main() { 94 | Layer a = {0.0, 0.0, 100.0, 100.0}; 95 | printSize(a); 96 | } 97 | ``` 98 | 99 | 基于这种语法,你甚至可以在 C 语言中轻松地获得纯粹的,无副作用的的纯函数。它完全通过返回值来输出计算结果,从而彻底避开 JS 函数内部直接 mutate 外部对象的问题: 100 | 101 | ``` c 102 | // 将某个 layer 的尺寸放大两倍 103 | // 不同于 JS,这里传入的 layer 相当于原始值的拷贝 104 | Layer doubleSize(Layer layer) { 105 | // 这里不会 mutate 原本的结构体数据 106 | layer.w *= 2.0; 107 | layer.h *= 2.0; 108 | return layer; 109 | } 110 | 111 | int main() { 112 | Layer a = {0.0, 0.0, 100.0, 100.0}; 113 | // 如果只调用 doubleSize(a) 而不赋值,是不会修改 a 的 114 | a = doubleSize(a); 115 | printSize(a); 116 | } 117 | ``` 118 | 119 | 遗憾的是,上面这种手法虽然看起来简单易懂,但真实的 C 工程项目中经常不会这么做。为什么呢?首先,这种传值形式可能在函数调用之间产生较多冗余的拷贝,影响程序的性能。并且有些资源(例如 GPU 纹理)甚至未必位于内存中,它们更是不可随意拷贝的。这时该怎么办呢? 120 | 121 | ## 熟悉指针类型 122 | 终于,是时候让我们讨论 C 语言中的「引用类型」了——通过引入「**指针**」的概念,C 语言赋予了你自由地引用和解引用内存地址的能力。传统上,指针常常让新手感到艰深晦涩。而某些应试教育中人为制造的 `int ****p` 等脱离实际的问题,更加深了普通人对其的畏惧感。但下面我们将介绍一种简单的技巧,展示如何通过引入一点点写法上的改变,让你即便只有 JS 系语言的使用经验,也能轻松地写出可以通过编译的指针操作代码。 123 | 124 | 这条技巧说起来其实很容易,**那就是把指针当作一种特殊的变量类型即可**。你可以在任何类型的后面加一个 `*`,获得其相应的指针类型,像这样: 125 | 126 | ``` c 127 | int x1; // 定义出 int 类型的变量 x1 128 | int* x2; // 定义出 int* 类型的变量 x2 129 | Layer layer1; // 定义出 Layer 类型的变量 layer1 130 | Layer* layer2; // 定义出 Layer* 类型的变量 layer2 131 | ``` 132 | 133 | > 为了便于理解,本文把指针类型以外的类型统称为「普通类型」。对所谓「普通类型」更准确的定义其实是「值类型」。C 语言中的值类型既包含整数和布尔值这样的基础类型,也包括了 `struct` 和 `enum` 这样的复合类型,在 C++ 中还包括了 `class`。**总之如果不使用指针,那么 C 语言并不像 JS 那样「对象就是引用类型」**。更详细的相关定义可参见 [Wikipedia](https://en.wikipedia.org/wiki/Value_type_and_reference_type)。 134 | 135 | 上面这种写法,看起来(至少在个人眼里)很贴近前端在使用 TypeScript 等强类型语言时的思维模型。但要注意的是,它其实和「经典」的 C 编程风格是有所不同的。传统上,C 语言代码中习惯使用 `int *p` 的风格来定义指针变量。其理由很简单,如下所示: 136 | 137 | ``` c 138 | int* p, q; // 这里的 q 是 int 类型,不是 int* 类型! 139 | int *p, *q; // 改成这样就不会写错 140 | ``` 141 | 142 | 不过,个人认为使用 `int* p` 的风格,更有助于我们将指针概念融入大家现在所习惯的类型系统,降低一些学习成本。例如在谷歌 Chromium 项目中,就使用了这样的编码风格。基于这种风格,不论是整数、浮点数还是结构体,如果它们默认的类型是 `Type t`,那么 `Type* t` 就是其相应的指针类型。不管在哪种情况下,你定义出的变量名始终是 `t` 而不是 `*t`。 143 | 144 | 按照这个「把指针当作一种变量类型」的理解方式,你会发现指针变量如果(十分鲁莽地)不考虑安全性问题,它们在使用时的心智负担实际上非常小。**具备指针类型的变量同样可以被重新赋值,也可以作为函数参数自由传递**。像这样: 145 | 146 | ``` c 147 | // 这个函数接收 Type* 类型的变量,这在 C 中非常常见 148 | void renderLayer(Layer* layer) { 149 | // ... 150 | } 151 | 152 | int main() { 153 | // Layer* 类型变量可以这样简单地定义出来 154 | Layer* a; 155 | // Layer* 类型变量也可以用函数来创建,先忽略这里的细节 156 | Layer* b = makeLayer(0.0, 0.0, 100.0, 100.0); 157 | // Layer* 类型变量之间可以自由地互相赋值 158 | a = b; 159 | // Layer* 类型变量也可以作为函数参数传递 160 | renderLayer(a); 161 | } 162 | ``` 163 | 164 | 看起来是不是也很简单呢?不过,指针类型和普通类型显然还是有所不同的。回到 Layer 的例子,`Layer*` 类型的指针变量在使用时相比于 `Layer` 类型的普通变量,其区别简单看来只有这么两条: 165 | 166 | * 指针类型变量,在函数调用之间传递的是引用的拷贝。 167 | * 指针类型变量,需要用形如 `obj->x` 的语法替代 `obj.x` 来存取值。 168 | 169 | 体现这两条区别的例子是这样的: 170 | 171 | ``` c 172 | void doubleSize(Layer* layer) { 173 | // 通过指针变量,可以直接 mutate 原本的结构体数据 174 | layer->w *= 2.0; 175 | layer->h *= 2.0; 176 | printSize(layer); 177 | } 178 | 179 | int main() { 180 | // 先忽略创建 Layer* 类型变量时的细节 181 | Layer* a = makeLayer(0.0, 0.0, 100.0, 100.0); 182 | // 因为传递的是引用,这里不再需要函数返回值了 183 | doubleSize(a); 184 | } 185 | ``` 186 | 187 | 在这个例子中,通过引入指针,我们使用 C 的方式已经发生了变化,能以引用类型的心智模型来编写逻辑了。不过,指针变量和普通变量之间并不直接兼容。这也就是说,需要 `Layer*` 变量的地方不能传入 `Layer` 变量,反之亦然。所幸它们之间可以简单地进行双向转换,其语法是这样的: 188 | 189 | * 通过 `&` 操作符,你可以把 `Layer` 类型转换成 `Layer*` 类型,亦即所谓的**引用**(reference)。 190 | * 通过 `*` 操作符,你可以把 `Layer*` 类型转换为 `Layer` 类型,亦即所谓的**解引用**(dereference)。 191 | 192 | 所谓的引用和解引用,其实很类似 Vue 3.0 中的 `ref` 和 `unref`。这种转换的编写本身并不需要额外的条件,只要类型匹配就能通过编译。其相应的例子类似这样: 193 | 194 | ``` c 195 | void printSize1(Layer layer) { 196 | printf("width: %f, height: %f\n", layer.w, layer.h); 197 | } 198 | 199 | void printSize2(Layer* layer) { 200 | printf("width: %f, height: %f\n", layer->w, layer->h); 201 | } 202 | 203 | int main() { 204 | Layer* a = makeLayer(0.0, 0.0, 100.0, 100.0); 205 | Layer b = {0.0, 0.0, 100.0, 100.0}; 206 | printSize1(*a); // 将 Layer* 转为 Layer 207 | printSize2(&b); // 将 Layer 转为 Layer* 208 | } 209 | ``` 210 | 211 | ## 为指针分配内存 212 | 了解指针类型与普通类型之间的转换后,擅长活学活用的同学可能立刻就能想到,我们是不是可以像下面这样简单直接地实现 `makeLayer` 函数呢? 213 | 214 | ``` c 215 | Layer* wronglyMakeLayer() { 216 | Layer layer = {0.0, 0.0, 100.0, 100.0}; 217 | return &layer; 218 | } 219 | ``` 220 | 221 | 不幸的是,虽然这段代码能通过编译,但它却犯了个严重的错误。我们把函数内部局部变量所在的内存地址传了出去,而这份分配在栈上的内存空间,在函数返回后就会直接被回收!因此即便能通过编译并有时「凑巧」能正常工作,这段代码也是有问题的,会收到现代编译器的警告。 222 | 223 | 那么,到底该如何合法地创建出指针类型的复杂结构呢?这需要我们手动申请和销毁内存。C 函数自动管理的内存一般分配在栈上,而这类动态分配的内存则位于堆([heap](https://en.wikipedia.org/wiki/Memory_management#HEAP))上。让我们来看看这个过程涉及到哪些 API 吧。 224 | 225 | 我们知道,要在 C 和 C++ 中使用库,需要引入相应的 `.h` 头文件。像 `stdio.h` 就包含了 `printf` 函数。对于内存分配,我们可以使用标准库 `stdlib.h` 中的 `malloc` 函数。只要为它传入所需的内存大小,就可以获得一个指向这段空间的 `void*` 类型指针了。可以认为 `void` 相当于 TypeScript 中的 `any`,因此 `void*` 也就是能指向任意类型数据的指针。C 语言中的类型转换规则较为宽松,这个 `void*` 类型变量可以被直接赋值给任意的指针类型,并需要用 `free` 函数销毁。至于 `malloc` 所需的具体字节尺寸数字,则可以通过 `sizeof` 关键字在编译期计算出来。像这样: 226 | 227 | ``` c 228 | #include // 导入用于手动管理内存的库函数 229 | 230 | Layer* makeLayer(double x, double y, double w, double h) { 231 | // 分配 Layer 所需尺寸的内存空间 232 | Layer* layer = malloc(sizeof(Layer)); 233 | layer->x = x; 234 | layer->y = y; 235 | layer->w = w; 236 | layer->h = h; 237 | return layer; 238 | } 239 | 240 | void test() { 241 | Layer* a = makeLayer(0.0, 0.0, 100.0, 100.0); 242 | // ... 243 | free(a); // 用完记得 free 掉 244 | } 245 | ``` 246 | 247 | 除了 `malloc` 以外,C 标准中还有能将分配到的空间清零的 `calloc` 函数,以及能就地调节原指针指向空间大小的 `realloc` 函数。它们返回的都是 `void*` 类型的指针,可以用 `free` 销毁。并且在 C 里,我们还可以通过 `TypeB* b = (TypeB*)a` 这样的强制类型转换语法,把指向某段内存空间的指针映射到任意类型。但不论如何转换类型,对于指向某段内存地址的指针,其在 `malloc`/`calloc`/`realloc` 时所分配的内存空间,都能正确地被 `free` 掉。对此有种简单易懂的理解,就是认为 `free` 既然接受的是 `void*` 类型,它到底要释放多少内存空间显然与特定类型无关。不过这背后更具体的原理,则在《Operating Systems: Three Easy Pieces》中有很精彩的论述,推荐感兴趣的同学阅读。 248 | 249 | 当然,如果觉得指针只要学会了 `malloc` 和 `free` 就能用好,那就太小看它了。C 中原始的裸指针机制,非常容易带来一些棘手的问题。前面我们说到过,所谓「引用」就像 URL 超链接一样,是个指向实际内容的标识符。而在没有 GC 当保姆的时候,这种标识符机制所产生的问题,和我们日常上网使用超链接时遇到的很像。比如这么几种: 250 | 251 | * **使用了未初始化的指针**,即所谓野指针(wild pointer)——相当于链接生成后还没准备好内容就点开,于是页面一片空白。 252 | * **使用了已经被释放的指针**,即所谓悬空指针(dangling pointer)——相当于原页面被删除但链接却留着,于是一点开链接就是 404。 253 | * **内存泄漏**——相当于链接被遗忘了,于是某个页面虽然已经没有价值,却没有被及时删除掉。 254 | 255 | 如何感受指针的危险呢?在 JS 中,我们常用一个对象是否为 `null` 来判断它是否存在。这种「天经地义」的效果在 C 中是不成立的。虽然 C 中有 `NULL` 宏,但一旦出现悬空指针和野指针,它们都可以通过 `NULL` 检查!比如这样: 256 | 257 | ``` c 258 | void renderLayer(Layer* layer) { 259 | // 常见的防御判断对悬空指针无效! 260 | if (layer == NULL) return; 261 | // ... 262 | } 263 | 264 | int main() { 265 | Layer* a = makeLayer(0.0, 0.0, 100.0, 100.0); 266 | free(a); 267 | // 这时的 a 不为 NULL,它是个悬空指针 268 | renderLayer(a); 269 | } 270 | ``` 271 | 272 | 如果把失效的地址拿来解引用,很容易导致程序的运行时崩溃。诸如此类的各种问题使得指针虽然容易通过编译,准确地用好它却很难——不过作为手动内存管理的元祖级特性,现在我们至少已经知道该怎么理解和编写它了。并且对前端开发者而言,就算还没有指针的实际工程经验,只要借助对其概念的理解,C 的许多重要语言特性一下就会显得很简单了。 273 | 274 | ## 指针、数组与字符串 275 | 在经典的谭书等 C 语言教材中,是先讲字符串和数组,再讲指针的。如果把 C 作为第一门编程入门语言,这个安排有其合理性。但本文并未遵循这条路线,**因为只要我们先从 JS 的引用类型出发理解了 C 的指针类型,很容易继续把 C 的数组当作一种指针来理解,进而理解字符串的结构**。 276 | 277 | 首先,指针是可以和整数之间做加减运算的——对具备「对象是引用类型,数字是基本类型」经验的前端同学来说,这恐怕有点毁三观。毕竟基于 JS 的思维模式,`obj + 1` 难道不应该是……`"[object Object]1"` 吗?但在 C 语言里,这是极为重要的指针运算。假设我们为某种类型的 N 个实例分配了一整段内存空间,而这段空间的起始位置又有个指针。那么我们就可以用这种方式,指向其中的某个实例: 278 | 279 | ``` c 280 | // 在堆上分配足够容纳 5 个 Layer 的内存空间 281 | Layer* p = calloc(5, sizeof(Layer)); 282 | 283 | // 可以直接解引用出第一个 Layer 284 | Layer first = *p; 285 | 286 | // 或获得指向第二个 Layer 的指针 287 | Layer* secondA = p + 1; 288 | 289 | // 也可以这样解引用出第二个 Layer 290 | Layer secondB = *(p + 1); 291 | ``` 292 | 293 | 但是,上面 `*(p + 1)` 这种写法实在太不语义化了。为此 C 设计了数组语法,可以给指针操作披上一层壳: 294 | 295 | ``` c 296 | // 在栈上分配 5 个 Layer 实例 297 | Layer a, b, c, d, e; 298 | Layer layers[] = {a, b, c, d, e}; 299 | 300 | // 这比 *(p + 1) 直观多了吧 301 | Layer second = layers[1]; 302 | ``` 303 | 304 | 可以看出,`*(ptr + offset)` 等价于 `ptr[offset]`,而这个 `offset` 每次 `+1` 时对应的字节数,正是 `ptr` 所指类型的 `sizeof` 大小。比如假设 `sizeof(Layer)` 的结果是 32,那么 `layers[2]` 就等价于偏移 `2 * 32` 个字节的内存地址——**这种简单明了的对应关系,其实也是 C 语言从零开始计算数组下标的原因之一**。除了 `[]` 以外,前面提到的 `->` 运算符也有这样的语法糖性质,`obj->x` 实际上就等价于 `(*obj).x`。 305 | 306 | 对于装载任意类型数据的 C 数组,你都可以创建出指向它的指针。如果数组的类型是 `Type[]`,那么相应指针的类型就是 `Type*`。并且,数组和指针都能用 `[]` 运算符来取下标。像这样: 307 | 308 | ``` c 309 | // int[] 类型的数组 310 | int arr[] = {0, 1, 2}; 311 | // int* 类型的指针,指向数组起始位置 312 | int* p = arr; 313 | 314 | // 下面这两行代码是等价的 315 | int tmp1 = arr[1]; 316 | int tmp2 = p[1]; 317 | ``` 318 | 319 | 别忘了 `int` 和 `Layer` 都属于值类型,所以我们也能轻松照猫画虎地写出这样的代码: 320 | 321 | ``` c 322 | Layer a, b, c; 323 | // Layer[] 类型的数组 324 | Layer layers[] = {a, b, c}; 325 | // Layer* 类型的指针,指向数组起始位置 326 | Layer* p = layers; 327 | 328 | // 下面这两行代码也是等价的 329 | Layer tmp1 = layers[0]; 330 | Layer tmp2 = p[0]; 331 | ``` 332 | 333 | 更进一步地,数组里可不只能装普通类型,也能装指针类型: 334 | 335 | ``` c 336 | Layer* a = malloc(sizeof(Layer)); 337 | Layer* b = malloc(sizeof(Layer)); 338 | 339 | // 数组中的每项都是 Layer* 类型 340 | Layer* layers[] = {a, b}; 341 | // 等效的指针形式,指向数组起始位置 342 | Layer** p = layers; 343 | 344 | Layer* first = layers[0]; // 等价于 p[0] 345 | first == a; // true 346 | 347 | // 记得销毁 malloc 出的对象 348 | free(a); 349 | free(b); 350 | ``` 351 | 352 | `Layer**` 这个类型可能有些费解,它的字面意义是 `Ptr>`,在实践中既可以兼容 `Ptr>`,也可以兼容 `Array>`,我们这里使用的是后者。**不要再纠结于传统 C 语言教程中所谓「数组指针」和「指针数组」这种含糊的概念了。直接从类型的角度理解它们,会准确而可靠得多**。 353 | 354 | C 中的数组非常简单,它纯粹只是一段连续的内存空间,并不携带长度信息。在将数组作为函数参数传递时,一般还要单独将其长度作为参数传递。这时候它们对外的 API 形式也都很接近: 355 | 356 | ``` c 357 | // 接收数组为参数的函数 358 | void printArr(int arr[], int len) { 359 | arr[len - 1]; // 数组的最后一项 360 | } 361 | 362 | // 等价的指针形式,虽可用但语义化程度较差 363 | void printPtr(int* arr, int len) { 364 | arr[len - 1]; // 数组的最后一项 365 | } 366 | ``` 367 | 368 | 为什么 `int arr[]` 好像总能转换成 `int* arr` 来使用呢?实际上在需要指针类型的地方,C 会将数组类型自动退化(decay)为指针类型。因此 `int[]` 和 `int*` 虽然类型不同,但需要 `int*` 的地方总可以传入 `int[]`。 369 | 370 | 既然 `int[]` 和 `int*` 的兼容性这么好,`char[]` 和 `char*` 自然也不例外——**然后我们就可以很容易地理解 C 中的「字符串」了**。C 中从来没有 `string` 类型,只有用来表示单个字节的 `char` 类型。于是,由一串 `char` 类型字符数据组成的数组,就构成了「真 · 字符串」。假设我们要为 Layer 增加 `name` 字段,就可以直接这么写: 371 | 372 | ``` c 373 | typedef struct Layer { 374 | double x; 375 | double y; 376 | double w; 377 | double h; 378 | char name[50]; // 预留 50 字节的位置 379 | } Layer; 380 | ``` 381 | 382 | 或者把 `char[]` 换成不受长度限制的 `char*` 类型: 383 | 384 | ``` c 385 | typedef struct Layer { 386 | // ... 387 | char* name; 388 | } Layer; 389 | ``` 390 | 391 | 这两种形式都可以这么初始化: 392 | 393 | ``` c 394 | Layer a; 395 | Layer b = {0.0, 0.0, 0.0, 0.0, "hello"}; 396 | ``` 397 | 398 | 但 `char[]` 有个地方需要注意,那就是它不能被重新赋值。比如这样: 399 | 400 | ``` c 401 | #include 402 | 403 | typedef struct Layer { 404 | // ... 405 | char name[50]; 406 | } Layer; 407 | 408 | int main() { 409 | Layer layer; 410 | layer.name = "world"; // 但这是无法通过编译的 411 | strcpy(layer.name, "world"); // 要换成这样 412 | } 413 | ``` 414 | 415 | 相比之下,`char*` 就没有这种限制: 416 | 417 | ``` c 418 | typedef struct Layer { 419 | // ... 420 | char* name; 421 | } Layer; 422 | 423 | int main() { 424 | Layer layer; 425 | layer.name = "world"; // 这样可以通过编译 426 | strcpy(layer.name, "world"); // 这样也可以 427 | } 428 | ``` 429 | 430 | 除了赋值时的区别外,C 中的字符串也不能靠 `==` 来比较。`==` 只能用于比较指针和算术类型。如果直接比较两个 `char[]` 数组,这时虽然可以通过编译,但 C 会将数组退化为指针来进行比较,其结果并不是我们想要的。因此不管是 `char[]` 还是 `char*` 类型的字符串,其通用的比较方式都是使用 `string.h` 标准库中的 `strcmp` 函数。 431 | 432 | > 另外,前面的结构体 `Layer` 类型也不能用 `==` 来比较,只有 `Layer*` 可以。不妨想想这是为什么,又该怎么办呢? 433 | 434 | 我们已经发现,字符串既可以是 `char[]` 类型,也可以是 `char*` 类型。这里存在一个容易混淆之处,亦即字符串的存储位置。像下面的三行代码虽然同属 `char` 家族,但它们运行时所处的内存区域却各不相同: 435 | 436 | ``` c 437 | int main () { 438 | char a[] = "hello"; // 分配在栈上 439 | char* b = malloc(10); // 分配在堆上 440 | char* c = "world"; // 指针 c 在栈上,"world" 在常量区 441 | } 442 | ``` 443 | 444 | 上面的例子,反映出了 C 中字符串可能对应的几种内存分配位置: 445 | 446 | * **栈上**:对于 `"hello"`这样赋值给 `char[]` 的字符串,它和 `int a[] = {1, 2, 3}` 一样,整个数组内的数据都是分配在栈上的。 447 | * **堆上**:对于 `malloc` 动态分配出的字符串空间,自然分配在堆上。如果想动态改变这类字符串的长度,还可以通过 `realloc` 来实现。不妨将此留作习题(狗头)。 448 | * **常量区上**:对于 `"world"` 这样直接赋值给 `char*` 的字符串字面量,一般在程序运行过程中始终位于固定的内存空间,因此不需要操心它的释放。但注意指针 `c` 本身也是个函数内的局部变量,因此它分配在栈上。`c` 本身可以通过栈的内存管理来自动销毁,但它指向的东西则不行——其他指针也是这么个道理。 449 | 450 | 在这里,我们可以再次感受到 C 的抽象层次之低。作为入门性质的文章,这里不会继续深入相关的 OS 和编译产物细节。不过至少目前来看,这个例子可以告诉我们,为什么有些地方传来的字符串需要手动销毁,有些则不需要了。 451 | 452 | ## 指针与 C++ 中的引用 453 | 上文已经覆盖了对指针常用知识的基本介绍。在传递引用方面,C 为我们提供的特性实质上也就只有它了。但对于「引用类型」这个概念,最后值得简单一提的还有 C++ 中的引用(reference)语言特性。 454 | 455 | > C++ 中的引用和 C 的指针,都可以认为是(相对于值类型的)引用类型。本文中除非特别提及,所谓「引用类型」都指广义上的通用概念,而不是 C++ 中的这项具体语言特性。 456 | 457 | 经过上面的论述,我们已经知道对于 `Layer` 这个类型,存在着 `Layer*` 和 `Layer[]` 这两种引用类型了。C++ 中加入了一种新的类型,那就是 `Layer&`。像这样: 458 | 459 | ``` cpp 460 | Layer a; 461 | Layer& b = a; // 创建一个 Layer& 类型变量 462 | ``` 463 | 464 | 或许不少人只要一看到带着 `&` 和 `*` 的类型就头疼。不过这里有条好消息,那就是 `Layer&` 类型可以当做普通的 `Layer` 类型来用!像这样: 465 | 466 | ``` cpp 467 | void printSize(Layer layer) { 468 | printf("width: %f, height: %f\n", layer.w, layer.h); 469 | } 470 | 471 | int main() { 472 | Layer a; 473 | Layer& b = a; 474 | 475 | // Layer 和 Layer& 的存取值语法一样 476 | a.w = 100.0; 477 | b.w = 200.0; 478 | 479 | // 作为参数传递时的用法也一样 480 | printSize(a); 481 | printSize(b); 482 | } 483 | ``` 484 | 485 | 这么看来,这种类型有什么存在的意义呢?它的名字已经告诉了我们答案,那就是「引用」能力了。如果我们写的是朴素的 `Layer b = a` 而非 `Layer& b = a`,那么这里的 `a` 和 `b` 是两个独立的实例,对 `b` 的修改不会影响 `a`。而对于 `Layer&` 类型的 `b` 来说,对它的修改也会直接影响到 `a`。这个差异应该很容易理解,这里就不展开赘述了。 486 | 487 | C++ 引用特性的一种常见用途,是优化函数传参时的拷贝开销。刚才我们用来接收 `Layer` 的函数是这样的: 488 | 489 | ``` cpp 490 | // 接收普通的值类型,会产生拷贝 491 | void printSize(Layer layer) { 492 | // ... 493 | } 494 | ``` 495 | 496 | 每次调用这个函数时,传入的 Layer 数据都会被完整地拷贝一份。为此,我们可以把输入参数的类型从 `Layer` 换成 `Layer&`——由于 `Layer` 和 `Layer&` 之间的良好兼容性,这个改动应该不会报错。于是我们只要加一个 `&` 就能优化掉拷贝开销,其他地方还是和原来一样该怎么写怎么写,属于躺着就能拿到的性能优化: 497 | 498 | ``` cpp 499 | // 接收引用类型,能避免拷贝,但 layer 可能被 mutate 500 | void printSize(Layer& layer) { 501 | // ... 502 | } 503 | ``` 504 | 505 | 但这时候,代码的潜在限制就会发生改变了。比如,我们现在可以通过修改 `layer.x` 来改变原有的数据,这就产生了代码语义上的差异。为此,我们可以继续用 `const` 修饰符来禁止修改它。这样一来,我们就得到了在许多 C++ 代码库中非常常见的这种函数: 506 | 507 | ``` cpp 508 | // 接收常量引用类型,它不仅能避免拷贝,layer 还不会被 mutate 509 | void printSize(const Layer& layer) { 510 | // ... 511 | } 512 | ``` 513 | 514 | 当作为函数形参时,`Layer` 类型和 `const Layer&` 类型的使用效果几乎是一致的:不用担心传入的变量被修改,也都能用 `layer.x` 的语法来读取值。并且相比于传递朴素的值类型,传递 `const` 引用可以避免拷贝——所以,下次在 C++ 函数参数列表里见到 `const Type&` 类型的时候不要慌,它想表达的就是个更好的 `Type` 类型而已。 515 | 516 | 为了解决指针的常见问题,引用做出了几条限制: 517 | 518 | * 引用必须定义时就初始化,杜绝了 `int& x;` 这样会导致野指针的写法。 519 | * 引用所绑定的对象是固定的。比如 `Layer& b = a` 之后,`b = x` 的意思实际上就是 `a = x`。 520 | * 引用不允许赋值为 `NULL`。 521 | 522 | 引用算是相当容易应用的 C++ 特性。只要你在朴素的 C 代码库里用上它,那么你就可以说自己写的已经不再是 C,而是「C++ 的子集」了……后面我们也会继续按这种思路,按需地引入 C++ 中的一些重要概念。 523 | 524 | 到这里,与 C 系语言中「引用类型」相关的介绍就到此为止了。总结如下: 525 | 526 | * C 语言离普通前端开发者其实并没有那么遥远,你很可能早已掌握了它的一个子集。 527 | * C 最朴素的玩法甚至可以比 JS 更简单,同样能全自动且安全地管理内存。 528 | * 指针相当于需要手动管理内存的引用类型变量。通过一点技巧,同样很容易用 C 写出合法(能通过编译)的指针操作代码,但别忘了安全问题。 529 | * C 的数组可视为指针的语法糖,而字符串相当于 `char*` 或 `char[]`。 530 | * C++ 增加了引用的概念。它和指针同属引用类型但更加安全易用,可在适合时作为替代。 531 | 532 | 依靠本文目前介绍的 C 特性的表达力,已经足够高手用清晰的代码开发出强大的软件。比如哪怕只基于本文涉及的这么一点东西,你在阅读 Fabrice Bellard 的 QuickJS 引擎源码时,恐怕都已经不会受到多少语言特性上的困扰了。但 C 本身作为「最简单的高级语言」,其特性实在还是太少。它的继任者 C++ 做出了很多努力,便于我们靠更高层面的心智模型来开发大型项目。在下一篇文章中,我们会继续从 JS 背景开发者的视角出发,介绍在披上「面向对象」外衣时,原生语言中的内存管理是什么样的。 533 | -------------------------------------------------------------------------------- /cn/native-app-development.md: -------------------------------------------------------------------------------- 1 | # 写给前端的原生开发基础入门 2 | 3 | ![](../images/native-app-development/mix-ios-android.png) 4 | 5 | 跨平台的 Hybrid 混合式开发技术栈,一直是一项非常受业界欢迎的技术。然而,许多投身其中的前端开发者往往只熟悉其中的 JS 部分,对于整个应用中基础性的原生部分了解非常有限,这是十分可惜的。 6 | 7 | 作为一名前端开发者,我在过去的一年中都在开发 Hybrid 应用框架(参见我的 [QCon Plus](https://qconplus.infoq.cn/2020/beijing/presentation/2763) 分享)。这个过程中我(不得不)学习了许多与原生应用开发相关的知识。在终于把许多点串起来之后,我发现原生应用与 Web 应用之间其实共享着相似的理念模型,二者间本质上并没有多高的壁垒和门槛。因此我整理出了这篇文章,帮助大家用前端的思维模型去理解原生应用的开发。希望读完本文,哪怕是没有相关背景的前端同学在面对一个 iOS 和安卓的典型工程时,也能知道「这大概是在干嘛」,不会受限在项目中 `*.js` 和 `*.dart` 的小世界里,更好地和原生开发同学协作。 8 | 9 | 下面我们会依次介绍这几个部分的内容: 10 | 11 | * 前端开发与原生开发之间的异同 12 | * 前端视角下的 iOS 开发 13 | * 理解 Objective-C 语法 14 | * Delegate 与 Protocol 概念 15 | * 基于 MVC 的 UI 16 | * 上手 Xcode 17 | * 前端视角下的安卓开发 18 | * 连接 Java 与 XML 19 | * Activity 和 Fragment 20 | * 构建与依赖管理 21 | * 原生技术栈与前端技术栈间的交互 22 | 23 | > 注意,所谓「原生」的概念是有歧义的。在某些语境下,使用 Swift、Objective-C 和 Java 的移动端开发者会认为这些应用层开发语言才属于「原生」,而更「底层」的 C/C++ 则不属于这一范畴。本文中的「原生」泛指能直接接触到平台(操作系统)标准 API 的技术栈,不会分得这么细。另外在本文语境下,所谓「前端应用」等价于「Web 应用」。 24 | 25 | 26 | ## 前端开发与原生开发之间的异同 27 | 很多前端同学可能对原生开发多少有种畏惧感——毕竟那可是会编译成机器码的东西啊!但其实 Web 应用与原生应用之间有两条值得一提的共性,它们可以增强你的自信心: 28 | 29 | * **在各个平台上,GUI 的标准 API 设计都是相当共通的**。不管是 iOS 的 UIView 还是安卓的 View,它们都和 DOM 元素类似地,是一系列支持注册事件回调的 UI 对象(亦即所谓的「[保留模式 GUI](https://en.wikipedia.org/wiki/Retained_mode)」)。因此只要你熟悉 DOM 和 React/Vue 等前端 UI 框架,它们的核心概念几乎都能平滑地体现在原生应用中。不过 JSX 这样语义化嵌套表达组件的能力,属于前端社区的首创。相比之下,原生平台上朴素的 UI 构建代码往往是面条式的,较为繁冗。 30 | * **现代 Web 应用已经和原生应用一样,是需要一套构建工具链的**。打包大型 Web 应用的过程,并不比 IDE 的构建过程更简单。因此只要你熟悉 TypeScript 这样需要 AOT 编译的语言,也能 `npm install` 配出 Webpack 全家桶环境,那么你其实已经熟悉这种「自动化管理依赖,并通过工具链来编译应用」的工作流了,心智模型的适应成本并不高。 31 | 32 | 当然,原生应用仍然是比 Web 应用更为「low-level」的,这种差异会在很多地方体现出来: 33 | 34 | * 原生开发会涉及更丰富的 OS 能力,例如多线程、文件系统、Socket、图形上下文和各式传感器等。如果你想试试更下层的 C/C++,这也很容易通过 iOS 上的混编和安卓上的 NDK 来实现。名著《[UNIX 环境高级编程](http://www.apuebook.com/apue3e.html)》中演示的各色 POSIX 系统调用,也都尽在你的掌握之中。 35 | * 原生开发的调试成本更高,你不能像修改 `node_modules` 源码那样直接修改你依赖的库,没有可以随手求值表达式的控制台,没有 HMR 热更新,空指针更可能直接让应用崩溃…… 36 | 37 | > 不过,Web 应用也未必就比原生应用更简单。比如作为输入 URL 就能直接访问的平台,Web 应用非常关注首屏性能,有许多加速页面载入的深度优化策略。这反而不是原生应用开发中的关注重点。 38 | 39 | 纸上谈兵式的心灵鸡汤和预防针到此为止。相信大家现在最关心的一定是这件事——**如果我只有前端开发的背景,怎样看明白一个 iOS 或安卓应用的代码是在干嘛呢**?接下来我们就来解答这个问题。 40 | 41 | ## 前端视角下的 iOS 开发 42 | 有个可能有些令人难过的事实,那就是直到 2020 年,Objective-C 语言仍然是 iOS 开发中的一道坎。尤其对 Hybrid 应用来说,一旦涉及原生插件开发,或者接入某些第三方 SDK,那么你还是很难绕开它。比如在 React Native 中如果想用 Swift 开发 Bridge,仍然要加上 `@objc` 修饰符,并配置用于混编的头文件——所以我们不如干脆直接来搞懂 Objective-C 吧!个人经验是只要能看懂这门表面上古怪的语言,iOS 平台令人畏惧的程度一下就会低很多。 43 | 44 | ### 理解 Objective-C 语法 45 | 在我认识的(非狂热果粉)开发者群体里,大家普遍认为 Objective-C 是一门看起来十分晦涩的语言。它独特的方括号语法、排版方式和冗长的 API,都使很多人对其望而却步。但某种程度上,深受 Smalltalk 影响的它才是正统「面向对象」今日的遗孤。要理解这种风格,最重要的一点在于接受方括号的「[发消息](https://en.wikipedia.org/wiki/Message_passing)」语义。**这时一种非常实用的理解方式,是把方括号语法直接当作换皮的方法调用**。举几个最简单的例子: 46 | 47 | ``` objc 48 | // 向 NSDate 类发送 date 消息,获得 now 实例 49 | NSDate* now = [NSDate date]; 50 | 51 | // 向 now 实例发送 timeIntervalSince1970 消息,获得秒级时间戳 52 | double seconds = [now timeIntervalSince1970]; 53 | 54 | // 向 now 实例发送 dateByAddingTimeInterval 消息,参数为 114514 55 | NSDate* later = [now dateByAddingTimeInterval:114514]; 56 | ``` 57 | 58 | 它们改写成等价的 JS 就是这样的: 59 | 60 | ``` js 61 | let now = NSDate.date() 62 | let seconds = now.timeIntervalSince1970() 63 | let later = now.dateByAddingTimeInterval(114514) 64 | ``` 65 | 66 | 然后对稍微复杂一点的情况也是同理: 67 | 68 | ``` objc 69 | // 另一种初始化方式,即先发 alloc 消息,再发 init 消息 70 | NSDate* now = [[NSDate alloc] init]; 71 | 72 | // 初始化一个 NSCalendar 日期实例 73 | NSCalendar* obj = [NSCalendar currentCalendar]; 74 | 75 | // 给实例发多个参数的消息 76 | // 消息名为 ordinalityOfUnit:inUnit:forDate: 77 | NSUInteger day = [obj ordinalityOfUnit:NSDayCalendarUnit 78 | inUnit:NSMonthCalendarUnit 79 | forDate:now]; 80 | ``` 81 | 82 | 这几行发送消息的代码,换成 JS 的方式来理解则大概是这样的: 83 | 84 | ``` js 85 | let now = NSDate.alloc().init() // [[NSDate alloc] init] 86 | let obj = NSCalendar.currentCalendar() 87 | 88 | // 方法名由参数列表拼接组成 89 | let name = "ordinalityOfUnit:inUnit:forDate:" 90 | let day = obj[name](NSDayCalendarUnit, NSMonthCalendarUnit, now) 91 | ``` 92 | 93 | 为什么不直接 `new NSDate()` 呢?因为 Objective-C 是对 C 语言的扩展,它没有选择像 C++ 那样加入 `new` 关键字,而是设计了 `[[NSDate alloc] init]` 这样的消息组合,来完成对象的构造。`alloc` 相当于 C 语言里的内存分配函数 `malloc`,而 `init` 则相当于默认的实例构造器。如果需要其他的构造器,Objective-C 里的方式是自己动手实现形如 `initWithXxx` 的方法(即所谓的 *initialiser* 初始化器)。像这样的代码: 94 | 95 | ``` objc 96 | // initWithXxx 在 Objective-C 中非常常见 97 | MyWidget* widget = [[MyWidget alloc] initWithStr:@"hello" 98 | width:114 99 | height:514]; 100 | ``` 101 | 102 | 就可以根据上面的规律,近似地这么转换: 103 | 104 | ``` js 105 | let name = "initWithStr:width:height:" // 将消息类型理解为方法名 106 | let initialiser = MyWidget.alloc()[name] // 先 alloc 分配空间 107 | let widget = initialiser("hello", 114, 514) // 再 init 初始化 108 | 109 | // 或者这样更简单的理解 110 | let widget = new MyWidget("hello", 114, 514) 111 | ``` 112 | 113 | 这里的 `MyWidget` 是一个类——作为面向对象语言,Objective-C 里是有类的。因此现代 JS 中 class 语法对应的「类和实例」经典心智模型,在这里是完全可复用的,只是语法较为非主流而已。和 C++ 类似地,Objective-C 中典型的 class 需要在 `.h` 头文件里暴露出 `@inferface` 声明,然后在 `.m` 文件里编写相应的 `@implementation` 实现。像上面的 `MyWidget` 就可以按这种方式来声明: 114 | 115 | ``` objc 116 | // MyWidget.h 117 | 118 | @interface MyWidget : NSObject // 继承 NSObject 119 | @property TypeA a; // 声明一个属性,写在这里的属性对外可见 120 | // 初始化器,instancetype 表示返回该类的实例 121 | - (instancetype)initWithA:(TypeA)a 122 | b:(TypeB)b 123 | c:(TypeC)c; 124 | // "-" 开头的是实例方法 125 | // 需要用 [myWidget doSomething1:x] 形式调用 126 | - (void)doSomething1:(TypeX)x; 127 | // "+" 开头的是类方法 128 | // 可以用 [MyWidget doSomething2:y] 形式调用 129 | + (void)doSomething2:(TypeY)y; 130 | @end 131 | ``` 132 | 133 | 其相应的实现则是这样的: 134 | 135 | ``` objc 136 | // MyWidget.m 137 | 138 | #import "MyWidget.h" // import 相当于 include 139 | 140 | @implementation MyWidget 141 | @property TypeB b; // 声明另一个属性,写在这里的属性是私有的 142 | - (instancetype)initWithA:(TypeA)a 143 | b:(TypeB)b 144 | c:(TypeC)c { 145 | if(self = [super init]) { 146 | self.a = a; 147 | self.b = b; 148 | // ... 149 | } 150 | } 151 | - (void)doSomething1:(TypeX)x { 152 | // ... 153 | } 154 | + (void)doSomething2:(TypeY)y { 155 | // ... 156 | } 157 | @end 158 | ``` 159 | 160 | 别被上面这些代码唬住了,它们其实只相当于写了这么一点点 JS 而已: 161 | 162 | ``` js 163 | class MyWidget extends Object { 164 | constructor(a, b, c) { 165 | super() 166 | this.a = a 167 | this.b = b 168 | // ... 169 | } 170 | doSomething1(x) { 171 | // ... 172 | } 173 | static doSomething2(y) { 174 | // ... 175 | } 176 | } 177 | ``` 178 | 179 | 只要能看明白 Objective-C 的这套表层语法,你可能会发现自己一下子就能读懂很多东西了。比如下面这段 [React Native 中用于定制原生组件的官方示例代码](https://reactnative.dev/docs/native-modules-ios): 180 | 181 | ``` objc 182 | // 初始化出某个支持了 React Bridge 功能的对象 183 | // id 相当于指向任意类型对象的指针,这里加了个可选的类型提示 184 | // RCT 是 React 的简写 185 | id moduleInitialiser = [[classThatImplementsRCTBridgeDelegate alloc] init]; 186 | 187 | // 用上面的对象来建立 bridge 实例 188 | RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:moduleInitialiser launchOptions:nil]; 189 | 190 | // 用上面的 bridge 实例来建立 React Native 的 root view 191 | RCTRootView *rootView = [[RCTRootView alloc] 192 | initWithBridge:bridge 193 | moduleName:kModuleName 194 | initialProperties:nil]; 195 | ``` 196 | 197 | 是不是看起来容易理解很多了呢? 198 | 199 | 最后,如果你实在不习惯 Objective-C 的代码排版风格,这里还有个小贴士:安装 VSCode 的 [clang-format](https://marketplace.visualstudio.com/items?itemName=xaver.clang-format) 插件,在项目根目录中添加一个 `.clang-format` 文件,其内容只要一行 `BasedOnStyle: Chromium` 即可。这样就能用 VSCode 把「千奇百怪的丑」变成「整齐划一的丑」了——而且其实看久了以后,[它还是蛮有一股混元形意劲的](https://www.zhihu.com/question/24115153/answer/1597551264)。 200 | 201 | ### Delegate 与 Protocol 概念 202 | 上面的基础语法介绍,只足够让你阅读最基本的 Objective-C 代码。如果想更好地理解 iOS 原生应用的 UI 逻辑,还需要了解其中非常常见的 Delegate 与 Protocol 概念。 203 | 204 | 什么是 Delegate 呢?这是 Objective-C 中用于替代继承的设计。我们都知道,经典的继承机制虽然容易理解,但继承链带来的问题也是很明显的。比如假设框架提供了某种功能复杂而强大的 UIView 对象,那么按照 JS 中最朴素的形式,我们会通过直接继承这个类的方式来复用它: 205 | 206 | ``` js 207 | class MyView extends UIView { 208 | // 在 view 加载完成时触发的生命周期钩子 209 | viewDidLoad() { 210 | // 这里的 this 上什么宝贝都能掏出来 211 | } 212 | } 213 | ``` 214 | 215 | 这不仅容易制造出层层包装的臃肿对象,还不易于让一个类同时扮演多种角色(涉及复杂的多继承)。相比之下,Objective-C 选择通过协议(*protocol*)机制来替代继承,其基本理念是这样的:各种框架层对象会把自己可供定制的部分(例如各种回调方法的 API)规定为一份协议,然后由我们自己实现出符合协议接口(*interface*)的对象,把这个自定义的对象作为代理「嵌入」重型的框架对象。举例来说,假如我们想自定义 WebView 的一些行为,那就不应该直接去继承 UIWebView 这个类,而是这样的: 216 | 217 | 1. 框架(UIWebView)会约定出一份名为 UIWebViewDelegate 的协议,其中包括了自己全部对外的 API,例如 `webViewDidStartLoad` 这样的生命周期钩子。这份协议中的方法既可以是必选的,也可以是可选的。 218 | 2. 框架使用者把自己需要的回调实现在一个独立的类里,并实例化它。这个对象就是作为代理的所谓 *delegate object*。这个类所实现的方法,必须能兼容 UIWebViewDelegate 这份协议。 219 | 3. 把我们的 UIWebViewDelegate 实例指派给 UIWebView 实例(即被代理的 *delegating object*),相当于对 UIWebView 的 `delegate` 属性赋值。 220 | 4. 在 UIWebView 实例运行时,它会查找并向自身的 `delegate` 成员对象发消息(执行方法调用),完成整个流程。 221 | 222 | > 如果你还是不习惯 Delegate 这个词,也可以把它理解为 Controller。iOS 中 UIView 和 UIViewController 之间的关系,就是典型的代理关系。 223 | 224 | 要演示这个代理机制,大概只需要这么几段 Objective-C 代码: 225 | 226 | ``` objc 227 | // 由框架声明 UIWebView 代理协议 228 | @protocol UIWebViewDelegate 229 | @optional // 可选方法 230 | - (void)webViewDidStartLoad:(UIWebView*)webView; 231 | // 其他各种方法 232 | @end 233 | 234 | // 由使用者实现符合该协议的类 235 | @interface MyClass 236 | // ... 237 | @end 238 | 239 | // 为已有的框架对象实例设置代理 240 | // 一般还可以通过 initWithDelegate 直接初始化框架对象 241 | MyClass* delegate = [[MyClass alloc] init]; 242 | [webView setDelegate:delegate]; 243 | ``` 244 | 245 | 上面的 Objective-C 代码,大体上可以理解为这样的 JS 模式: 246 | 247 | ``` js 248 | class MyClass implements WebViewDelegate { 249 | // 根据协议实现的方法 250 | webViewDidStartLoad() { 251 | // 不再存在包罗万象的 this 252 | } 253 | } 254 | 255 | // 实例化我们的轻量级 delegate 对象 256 | let delegate = new MyClass() 257 | myView.delegate = delegate 258 | ``` 259 | 260 | 现在,我们就能更好地理解上面那段 React Native 的原生组件示例代码了: 261 | 262 | ``` objc 263 | // 实例化出我们自定义的代理对象 264 | id moduleInitialiser = [[classThatImplementsRCTBridgeDelegate alloc] init]; 265 | 266 | // 用代理对象实例化出 React Native 的框架 bridge 对象 267 | RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:moduleInitialiser launchOptions:nil]; 268 | 269 | // 用 bridge 实例化出 React Native 的 root view 270 | RCTRootView *rootView = [[RCTRootView alloc] 271 | initWithBridge:bridge 272 | moduleName:kModuleName 273 | initialProperties:nil]; 274 | ``` 275 | 276 | 对于代理机制的更多细节,可以参考 Stack Overflow 上的 [How do I create delegates in Objective-C](https://stackoverflow.com/questions/626898/how-do-i-create-delegates-in-objective-c) 讨论,以及苹果官方的 [Delegation](https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/Delegation.html) 文档。 277 | 278 | 理解 Delegate 机制后,相信大家在阅读 Objective-C 代码时就不会存在太多障碍了,无非就是数组(NSMutableArray)和 Map(NSDictionary)之类标准数据结构的 API 冗长一点而已。当然,Objective-C 的特性不可能在这么简单的篇幅里覆盖全,它还是有很多亮点的。例如它的 ARC 内存管理机制,其实并不输于现代 C++ 的智能指针;再比如它基于 GCD 的多线程能力,也是很优秀的设计。如果你需要了解更多 Objective-C 的语言特性,推荐参考《[Objective-C Programming - The Big Nerd Ranch Guide](https://www.academia.edu/4928270/Big_Nerd_Ranch_Objective_C_Programming_Oct_2011)》一书。 279 | 280 | ### 基于 MVC 的 UI 281 | 在上一节中,我们已经提到了 UIView 这样的 UI 对象,不过仍然没有演示典型的 iOS UI 是如何建立的。这里有个好消息,那就是经典 iOS 应用的 UI 也遵循 MVC 设计模式,相信每位现在的前端同学都多少接触过它的变体(比如把 JSX 或 DOM 模板理解为 View 层,将 State 或 Store 理解为 Model 层,将 ViewModel 理解为 Controller 层)。典型的例子就是常用于实现可滚动长列表的 [UITableView](https://developer.apple.com/documentation/uikit/uitableview?language=objc): 282 | 283 | ![](../images/native-app-development/ios-scroll-view.png) 284 | 285 | 怎样在屏幕上绘制出这样的 UI 呢?在最简单的情况下,大体上有这么一些需要知道并控制的事: 286 | 287 | * 整个 iOS 应用最顶层的实例,是 Xcode 为我们默认生成出的 AppDelegate 类,可以直接在这里添加一个 UITableView 类型的属性。 288 | * 在 AppDelegate 中表示应用启动完成的生命周期钩子(`didFinishLaunchingWithOptions`,好啰嗦)里,实例化这个 UITableView 对象。这里通用的风格是先建立出一个 CGRect 类型的矩形 Frame 对象,再用这个 Frame 去初始化 View,也就是 `initWithFrame`。 289 | * UITableView 对象需要一个 `dataSource` 数据源对象,这也是一种典型的代理形式。可以直接让 AppDelegate 实现 UITableView 需要的协议,然后把它设置为 UITableView 对象的数据源(代理)。 290 | 291 | 上面的描述对应的实际代码大致是这样的,就不翻译成 JS 了: 292 | 293 | ``` objc 294 | // AppDelegate.h 295 | // AppDelegate 默认支持 UIApplicationDelegate 协议 296 | // 这里再让它多支持一个 UITableViewDataSource 协议 297 | @interface MyAppDelegate 298 | : UIResponder { 299 | // 也可以用这种语法定义成员变量,这里的 table 属于 view 层 300 | UITableView* table; 301 | // 用数组存储列表数据,作为 model 层 302 | NSMutableArray* data = @[@"foo", @"bar", @"baz"]; 303 | } 304 | 305 | // AppDelegate.m 306 | // ... 307 | // 在应用初始化完成时触发的生命周期钩子 308 | didFinishLaunchingWithOptions:(NSDictionary*)launchOptions { 309 | // 没有用 Storyboard 拖控件的话,要手动初始化 UIWindow 310 | // 这几行和 UITableView 本身无关 311 | CGRect windowFrame = [[UIScreen mainScreen] bounds]; 312 | UIWindow *theWindow = [[UIWindow alloc] initWithFrame:windowFrame]; 313 | [self setWindow:theWindow]; 314 | 315 | // 先建立 frame 对象,然后用它初始化 UITableView 316 | CGRect tableFrame = CGRectMake(0, 80, 320, 380); 317 | table = [[UITableView alloc] initWithFrame:tableFrame 318 | style:UITableViewStylePlain]; 319 | [table setSeparatorStyle:UITableViewCellSeparatorStyleNone]; 320 | 321 | // 设置 table 的数据源为 AppDelegate 322 | [table setDataSource:self]; 323 | 324 | // 把 table 挂载到 window 上 325 | [[self window] addSubview:table]; 326 | } 327 | // ... 328 | // UITableViewDataSource 协议规定的方法,用于获取列表长度 329 | - (NSInteger)tableView:(UITableView*)tableView 330 | numberOfRowsInSection:(NSInteger)section { 331 | // 返回列表数组的长度 332 | return [data count]; 333 | } 334 | // UITableViewDataSource 协议规定的方法,用于获取列表项 335 | - (UITableViewCell*)tableView:(UITableView*)tableView 336 | cellForRowAtIndexPath:(NSIndexPath*)indexPath { 337 | // 为提高性能,每次请求列表项时应先尝试复用已有的 cell 实例 338 | UITableViewCell* c = [table dequeueReusableCellWithIdentifier:@"Cell"]; 339 | if (!c) { 340 | // 当不存在空余 cell 实例时,初始化新的 cell 实例 341 | c = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault 342 | reuseIdentifier:@"Cell"]; 343 | } 344 | // 将 cell 实例的内容设置为 data 数组中相应的字符串 345 | // 这里的 [indexPath row] 对应 UITableView 列表的下标 346 | NSString* item = [data objectAtIndex:[indexPath row]]; 347 | [[c textLabel] setText:item]; 348 | 349 | // 返回 cell 实例供框架渲染 350 | return c; 351 | } 352 | ``` 353 | 354 | 可见,整个 AppDelegate 的用途相当于 Controller 层。上面的 `didFinishLaunchingWithOptions` 属于典型的 iOS 应用生命周期钩子,类似的还有 `viewDidLoad`。很明显,React 中经典的 `componentDidMount`/`componentWillMount`/`shouldComponentUpdate` 这些 API,就是借鉴 iOS 平台的命名风格而设计的。这或许是 Objective-C 对前端社区最大的影响了吧。如果你有其他 API 命名层面上的疑惑,可以参考苹果的 [Objective-C 命名风格文档](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CodingGuidelines/Articles/NamingMethods.html)。 355 | 356 | 注意,AppDelegate 还可以实现多种不同的协议,从而使单个类能无缝地扮演多种角色,支持与不同 UI 对象互动。个人认为这也是「组合优于继承」的体现。当然,把所有业务逻辑都放在单个对象上肯定是不好的行为,实际场景中一般会更细粒度地拆分出负责不同应用模块的对象。如果你想把上面纯粹建立静态 UITableView 的代码发展为一个 TodoMVC 性质的简单应用,参见《[Objective-C Programming - The Big Nerd Ranch Guide](https://www.academia.edu/4928270/Big_Nerd_Ranch_Objective_C_Programming_Oct_2011)》的 Chapter 27,这里不再赘述。 357 | 358 | 另外在 SwiftUI 之前,iOS 的 UI 开发使用的都是比较传统的 OO 风格,不支持 JSX 那样直接声明式地编写出组件的层级结构。这样在建立多个具备复杂嵌套结构的 View 时,很容易出现一大堆类似于手动生成 DOM 对象的面条代码。为此,Xcode 提供了 [Interface Builder](https://developer.apple.com/xcode/interface-builder/) 这样拖控件建立 UI 的工具。它可以生成 XIB 格式的布局文件,简化手动编码的工作量,也支持用 [Auto Layout](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/index.html) 来便捷地配置 UI 样式。而在管理多个 ViewController 之间的切换(类似页面路由跳转)时,Xcode 也提供了 [Storyboard](https://developer.apple.com/library/archive/documentation/ToolsLanguages/Conceptual/Xcode_Overview/DesigningwithStoryboards.html) 这样的可视化工具。不过由于在开发 Hybrid 框架时往往并不需要拖控件,因此个人并不熟悉上面这些工具,感兴趣的同学可以自己搜索 iOS 开发教程学习。尤其是在设计一些时下流行的「拖拽组件生成页面」的前端 Low Code 平台时,Xcode 的经典设计或许也能对大家有所启发。 359 | 360 | ### 上手 Xcode 361 | 上面的介绍主要属于纯粹的代码层面。但对于 iOS 这样自成体系的开发,几乎没有人会使用纯粹的文本编辑器来编码,而是使用 Xcode 这个 IDE。接下来我们会简单介绍一些 Xcode 的基础操作。 362 | 363 | 我们以 Flutter 插件来作为例子。在 Flutter 中,插件([Plugin](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#plugin))可以用来把平台特定的能力包装给 Dart 侧使用。每个 Flutter 插件项目中,都既会包含「作为库被使用的」可复用 Dart 代码,也会包含调用这些库 API 的示例 Dart 代码。同样地,**在创建插件时,Flutter 默认既会创建出供复用的 iOS 平台代码,也会创建出调用这些代码的示例 iOS 项目**。这个过程只需要一行命令就足够了: 364 | 365 | ``` sh 366 | $ flutter create --template=plugin --platforms=android,ios -i objc hello_world 367 | ``` 368 | 369 | 我们这里关心的是插件的 iOS 部分。因此接下来不要去直接 `flutter run`,而是手动这么干: 370 | 371 | ``` sh 372 | $ cd hello_world/example/ios 373 | $ pod install # 需要先安装好包管理器 CocoaPods 374 | ``` 375 | 376 | 现在打开 `hello_world/example/ios` 目录下的 `Runner.xcworkspace` 文件(注意不是 `hello_world/ios`,也不是 `Runner.xcodeproj`),就可以看到 iOS 视角下的 Flutter 工程了。如图所示: 377 | 378 | ![](../images/native-app-development/xcode-basics.png) 379 | 380 | 这里我们打开的是名为 Runner 的 Xcode *Workspace*。Runner 是由 Flutter 生成的名字,可以理解成这是用来运行 Flutter 环境的一层壳。Xcode 中的每个 Workspace 可以包括多个 *Project*,在这个例子中就是左侧蓝色的 Runner Project(即插件的示例应用)和 Pods Project(即作为 CocoaPods 依赖被安装进来的插件工程,还有 Flutter 框架)。上图中字母标记出的位置,则对应一些最主要的 IDE 功能: 381 | 382 | * **A** - 开闭左侧的 Navigator。它下面的这排 Navigator 小图标对应一些非常常用的功能,如文件目录、类层级结构、查找替换、编译警告、性能占用、断点、构建日志等。它们可以用 `cmd` + 数字切换。 383 | * **B** - 编译运行 App,快捷键 `cmd` + `R`。 384 | * **C** - 切换部署设备,如真机和模拟器。 385 | * **D** - 开闭右侧的 Inspector。这个部分主要用于拖控件时的配置,在做纯编码工作时可以关掉。 386 | * **E** - 点这里选中 Runner Project,即可查看插件示例工程的整体配置。这下面的 AppDelegate 等标准 iOS 代码都只是一层壳,毕竟重点都是在 `.dart` 里。 387 | * **F** - 点这里选中 Pods 这个 Project,即可查看插件本身的整体配置(插件和插件示例是两个独立的工程,后者依赖前者)。展开这下面 Development Pods 中的 `hello_world` 子目录,可以查看插件实际的 iOS 平台源码。 388 | * **G** - 开闭底部输出日志的 Debug Area,快捷键 `cmd` + `shift` + `Y`。 389 | 390 | 上面这些功能主要是用于具体编码的,还有一个重要的部分是进行项目整体的配置。点击上面的 E 或 F 即可进入: 391 | 392 | ![](../images/native-app-development/xcode-targets.png) 393 | 394 | 这里可以看到 **PROJECT** 和 **TARGETS** 两个不同的标签。每个 Xcode 的 Project 能编译到多种 [Target](https://developer.apple.com/library/archive/documentation/ToolsLanguages/Conceptual/Xcode_Overview/WorkingwithTargets.html)。对标准的 iOS App 来说,默认 Target 就是整个应用的 Bundle。在这个界面下,可以配置各种编译选项,比如库和框架的搜索路径、自定义宏参数、环境变量等。iOS 工程中 `.h` 和 `.m` 源码的编译方式,基本与经典的 C/C++ 项目一致。可以把这里当作一个可视化的 Webpack 配置界面,有需要时在这里修改具体的配置。 395 | 396 | 在这个项目里,还可以用 [CocoaPods](https://cocoapods.org/) 安装第三方依赖。CocoaPods 和 NPM 的用法很像,安装后的依赖会出现在 Pods Project 下。最后还有个地方值得一提,那就是 Xcode 中的目录结构是虚拟的,如果打开 Finder 发现文件路径对不上的话不要觉得奇怪,以 IDE 中最终展示的为准即可。 397 | 398 | 目前介绍的这些内容,应该足够帮助大家更好地理解 React Native 和 Flutter 插件的 iOS 部分了。限于篇幅,本文中的 iOS 部分也就到此为止。总体来看,虽然 Objective-C 和 Xcode 对前端同学们可能较为陌生,但它们反映了苹果在 UI 开发范式上多年来的积累和探索,是值得尊重的。另外 iOS 对于混编 C++ 的支持很不错,如果你希望在移动端尝试一些经典的 C++ 库,它也是比较方便的选择。 399 | 400 | ## 前端视角下的安卓开发 401 | 和 iOS 部分相比,本文对安卓的介绍会相对少很多——Java 与 TypeScript(至少在表层语法上)看起来很接近,其基于继承的 OO 机制也和 ES2015 后的 JavaScript 基本一致。因此这里无需花费精力像 Objective-C 那样去从头介绍基本的语言语法和 OO 机制,节约了很多篇幅。 402 | 403 | ### 连接 Java 与 XML 404 | 安卓大量应用了 XML 来管理可视化拖拽控件生成的 UI,以及很多静态配置和资源。但和 iOS 中重度依赖 Interface Builder 的 XIB 不同,安卓的 XML 是更为语义化的,对手工编辑更为友好(iOS 在 XIB 之前甚至还使用的是二进制的 NIB,它对 Git 工作流来说简直是灾难)。下面我们给出一段安卓应用中典型的 XML: 405 | 406 | ``` xml 407 | 410 | 417 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | ``` 429 | 430 | 先不管这段 XML 的实际用途,你觉得它和前端常见的 HTML 在语法上有什么不同呢?概括说来大致有这么几点: 431 | 432 | * XML 需要必选的 `xmlns` 命名空间(namespace)属性。这在 HTML5 里是可选(即支持但没多少人用)的。这里形如 `xmlns:android="http://schemas.android.com/apk/res/android"` 的属性,就表示这份 XML 内部的`android` 属性所对应的 URI(即唯一的资源标识符,但不是有效的 URL 地址)。用这种方式,还可以把我们自己定义出的 TextView 和安卓默认的 TextView 区分开。 433 | * 属性值可以用 `".MainActivity"` 的语法来指向 Java 中的 class,这里指向的就是后面会提到的 MainActivity。 434 | * 属性值可以用 `"@string/app_name"` 的语法来互相链接,像这里这个 `app_name` 的值就会指向另一份 XML 文件中的 `HelloWorld` 标签,从而获得其中的 `HelloWorld` 常量。 435 | 436 | 上面的 XML 就是所谓的 [Manifest](https://developer.android.com/guide/topics/manifest/manifest-intro),这是安卓应用顶层的配置。除此之外,安卓中标准的 UI 元素也使用 XML 形式来定义,例如 TextView 和 Button 就是下面这样的,初看起来很像 React: 437 | 438 | ``` xml 439 | 448 |