普通视图

发现新文章,点击刷新页面。
昨天以前戴铭的博客 - 星光社

全网最全的日本传统颜色指南,看完你会更懂日本

作者 戴铭
2024年11月4日 12:10

最近,我的颜气 APP 迎来了激动人心的升级,现在已正式发布 2.0 版本,审核也顺利通过,心情格外愉悦!在这一版本中,我新增了小组件功能,用户无需打开程序即可直接查看色彩。同时,小组件还可以显示重要节日和节气的提醒,增加天气信息及当日步数,完美地将日本传统色彩融入你的日常生活。

你可以通过这个地址下载颜气 APP,或者直接搜索我的名字“戴铭”来找到它。

开发2.0 Widget、WeatherKit 相关技术也都整理到了小册子中。

在开发这款 APP 的过程中,我查阅了大量资料,深入了解了日本传统颜色的丰富内涵。接下来,我将与大家分享这些知识。我将从日本历史的角度出发,跨越绳纹时代、弥生时代、古坟时代、飞鸟时代、奈良时代、平安时代、镰仓时代、战国时代,一直到日本传统色彩的巅峰——江户时代,介绍这些时代中诞生的色彩及其意义与用途。同时,我会结合宗教、习俗、自然与人文的和谐共生,探讨这些色彩如何与日本人的生活息息相关。最后,我将通过日本文学艺术作品、美食、传统工艺、服饰及建筑中对色彩的运用,从全新的视角来了解日本的古代艺术。

以下是正文内容,希望你能够喜欢!

引言

在樱花盛开的季节,身着鲜艳和服的人们漫步在古色古香的街道上,那些色彩交织成一幅美丽的画卷。你是否好奇,为何日本的和服总是那么色彩斑斓?这些色彩背后又隐藏着怎样的文化寓意呢?

在日本,色彩不仅仅是视觉的享受,它们是故事的载体,是历史的见证者,更是文化灵魂的流露。当你漫步在京都的古老街道上,它静静地诉说着日本的过去与现在。颜色不仅仅是颜色,它是日本人对美的追求、对和谐生活的向往,以及与自然和谐共生的哲学思考。

每一抹色调都不仅是视觉的享受,更是文化的符号,是那份跨越千年的韵味与情怀,等待着我们去揭开它的秘密。探索那些隐藏在色彩背后的动人故事。今天准备好踏上一场色彩之旅,重新认识这个国度了吗?

色彩的历史与文化背景

历史沿革

绳纹时代(公元前300年之前)

日本绳纹时代标志着日本从原始社会向农业社会的转变。在这一时期,传统颜色的起源与发展显著反映了当时人们的生活方式、自然环境以及文化信仰。

在绳纹时代,日本人主要依赖自然界中的植物、动物和矿物来获取颜色。由于当时染色技术相对原始,颜色的提取过程较为繁琐,通常采用植物染料和矿石颜料。

由于绳纹时代距离现代非常遥远,且当时并没有形成完整的颜色命名体系,因此很难准确列举出所有颜色的名字。不过,根据现有的考古发现和文献记载,我们可以推测当时可能存在的颜色名字,但无法确保这些名字的准确性和完整性。以下是一些在绳纹时代常见的传统颜色及其推测的名称:

  • 赤(あか) - 红色:象征着生命和活力,被视为吉祥的颜色,常用于祭祀、节庆和婚礼中。
  • 青(あお) - 蓝色:代表着天空和水,常用来表现自然景观。
  • 黄(きいろ) - 黄色:象征着丰收和希望,常用于农耕相关的仪式。
  • 白(しろ) - 白色:代表纯洁和神圣,常常与神道教相关联,用于祭祀神灵或祖先,以示尊重。
  • 黒(くろ) - 黑色:象征着神秘和死亡,常用于丧葬仪式。但在某些情况下也被视为力量和权威的象征。
  • 緑(みどり) - 绿色:代表生命与自然,反映了人与自然的紧密联系。

最普遍的是红色和白色。红色来源于红色黏土,含有酸化铁成分,经过烧制后可以呈现出鲜艳的红色。白色来源于白色珍珠、白色土壤(含有石灰、硅酸盐成分)。

绳文时代最有名的就是绳文陶器,其表面通过火烧的方式,形成了黑褐色、红褐色等原始色调。这些陶器的颜色是由于泥土成分和烧制温度的不同而产生的。绳文人也可能使用天然矿物颜料,如红色的赤铁矿(含氧化铁)和黑色的锰矿石,来为身体或装饰物上色,类似于许多古代文明的做法。部分考古证据表明,这些颜料可能用于宗教仪式或装饰。虽然尚未广泛使用,植物染料可能在一定程度上被用于制作衣物和装饰品,但在考古证据上比较少见。相较于后来奈良、平安时期的染色技术,绳文时代的染色方式仍非常初级,可能只是简单的浸泡和晾晒。

弥生时代(公元前300-公元250)

弥生时代,日本向更复杂的农业社会转变,引入了农业、稻作和青铜、铁器等先进工具,逐渐形成了以稻作文化为基础的定居型社会。

根据历史文献和考古发现,可以推测当时可能使用的一些颜色

  • 踯躅色:虽然踯躅色(即杜鹃花色)的正式命名可能出现在后世的平安时代,但杜鹃花作为一种在弥生时代就已经在日本生长的植物,其颜色很可能在当时就被人们所使用,并作为一种自然色彩被感知。
  • 桃色:桃色来源于中国古代象征长寿的桃,弥生时代已经传入日本。《万叶集》《古事记》中都记载过桃花,染成桃花一样的颜色在过去被称作“桃染”。

弥生陶器是弥生文化的重要象征,其颜色主要是赤褐色和黑褐色。由于陶器的泥土成分和烧制温度的不同,赤褐色是弥生陶器的主色调,而这种颜色的出现与后来日本文化中对土色的偏好有一定渊源。铁器制作,武器、工具和装饰品的出现带来了自然的金属光泽(铁灰色),虽然这不是染色结果,但铁器的色彩对人们的视觉产生了一定的影响。弥生时代的社会生活与稻作息息相关,因此稻草的淡黄色和干草的褐色常见于弥生村落中。稻草不仅被用作屋顶材料,还用于编织篓子、草鞋等日用品,形成了独特的自然色系。衣物多由麻和葛纤维制成,这些天然纤维的颜色通常为米色、灰白色和浅褐色,反映了人们朴素的生活方式。因为缺乏染色技术,衣物主要呈现自然的纤维色调。

考古发现显示弥生时代有少量装饰用的矿物颜料,如赤铁矿的红色、木炭的黑色,这些颜色可能用于祭祀装饰或绘画,但具体用途不多,且没有形成体系化的色彩应用。

古坟时代(250-592年)

日本古坟时代的颜色,受到了当时社会文化、自然环境以及与中国等周边国家交流的多重影响。这一时期的特点是出现了大型古坟和更具权力的首领阶层。

古坟时代的陶器被称为「埴轮」(はにわ),这些陶器多用于墓葬中,造型包括人物、动物、房屋等。埴轮陶器的色调通常是土灰色、黑褐色和浅褐色,这些颜色来自当地泥土的天然色调和烧制温度。埴轮的颜色反映了古坟时代质朴的美学风格。引入了更先进的金属加工技术,尤其是青铜器和铁器的制造,这些金属器具有自然的金属色,如铁灰色和铜绿色。青铜器常带有青绿色的铜锈,而铁器则呈现出深灰或铁黑色,这些金属器具在墓葬中象征地位和权力。装饰品中,珠饰是常见的随葬品之一。勾玉(まがたま)是一种独特的弯曲形状的玉饰,常见的颜色有绿色、蓝色和红色,这些色彩来自天然矿物和玉石。绿色勾玉象征富贵,红色则与活力和血脉相连。古坟时代的纺织技术有所提升,但大多数衣物仍然以麻和葛纤维为主。由于染色工艺较为原始,衣物主要呈现自然的米色、浅褐色和灰白色。然而,考古发现中少量的染色布料显示出红色和蓝色的使用,这些颜色可能来源于天然植物染料,但还未形成系统化的染色技术。大型石棺和石墓中使用了大量的石材,这些石材常见灰色、暗褐色和黄褐色。石棺的色彩主要取决于当地岩石的颜色,有时带有自然的纹理。

在古坟时代末期,通过丝绸之路传入的蓼蓝(蓼科的蓝植物)被用作染料,染出的蓝色被称为“日本蓝”。这种颜色深邃而鲜艳,成为当时日本的一大特色。此外,随着佛教的传播,建造寺庙所需的红花、苏芳等染料也源源不断地传入日本,进一步丰富了红色系的色彩。

飞鸟时代(592-710年)

飞鸟时代日本开始受到中国隋唐文化的强烈影响,同时接受了佛教,并逐渐发展出更复杂的文化、艺术和工艺体系。确立了一些基本的颜色,特别是在佛教仪式和服饰中。

在飞鸟时代,日本开始接触到中国的染色技术和颜色文化,同时也引入了用于宗教、礼仪和阶级区分的颜色规范。尽管这一时期的颜色命名还不完整,但某些颜色因其独特的用途和象征意义逐渐被认可。以下是飞鸟时代一些主要颜色的描述:

颜色名称 日文名称 来源 象征意义及用途
紫色 むらさき 紫草染料 高贵、皇室与贵族的象征
茜色 あかねいろ 茜草染料 活力、生命、佛教装饰
靛蓝 あお 靛蓝染料 普遍应用于日常服饰,防虫效果
草色 みどりいろ 植物染料混合 自然、新生,常用于装饰品
黄檗色 きはだいろ 黄檗树皮 高阶层服饰、宗教服饰
朱色 しゅいろ 矿物朱砂 庙宇和神社建筑、神圣象征
白色 しろ 未染色麻、丝 纯洁、佛教仪式

随着佛教的引入,飞鸟时代的色彩观念受到佛教文化的影响,许多佛教袈裟和仪式装饰采用了红色、黄色和白色,象征着佛教中的生命、纯洁和智慧。紫色和黄色在飞鸟时代非常珍贵,因其染料稀有,只有贵族和皇族才被允许穿着这类颜色的衣物。这种色彩象征体系在后来的奈良和平安时代进一步发展。日常生活中,蓝色、绿色和浅褐色等自然色较为常见,特别是在平民服饰中。这些颜色从植物染料中提取,耐用且防虫,适合日常穿着。

“冠位十二阶”制度是飞鸟时代(603年)由圣德太子推行的一项官员品级制度。该制度分为十二个等级,不仅标志着官位的高低,还通过冠的颜色来区分地位和权力。冠位十二阶的颜色制度受到中国隋朝官阶制度的影响,采用颜色象征品级,逐渐形成了早期日本色彩的阶层含义和社会制度中的象征体系。

在“冠位十二阶”制度之前,日本的官职主要由氏姓制度决定,氏姓制度是基于家族血缘关系来划分社会地位和权力。圣德太子意识到这种制度限制了人才的选拔,因此引入了“冠位十二阶”,旨在根据个人能力而非出生背景来任用官员。

圣德太子推行冠位十二阶,通过不同颜色的冠饰来区分官员等级,体现出“德治”的理念,赋予每个品阶以道德和能力的象征,同时也加强了中央集权的管理模式。十二个冠位的名称分别基于中国传统的「德」字,并以「大」和「小」区别阶位等级。每一阶位对应一种颜色,以下是冠位的详细颜色和品级介绍。

冠位十二阶分为六德:德、仁、礼、信、义、智,每个德分为大、小两阶。冠位以颜色逐级递进,从最低的黑色开始,到最高的紫色为止。

冠位 颜色 含义
大德 濃紫 为最高等级的官位,代表着最高的道德和智慧。
小德 薄紫 是仅次于大德的官位,同样代表着高尚的道德和智慧。
大仁 濃青 代表着仁爱之心和宽广的胸怀。
小仁 薄青 代表着仁爱之心和宽广的胸怀。
大礼 濃赤 象征着礼仪之邦的庄重和威严。
小礼 薄赤 象征着礼仪之邦的庄重和威严。
大信 濃黄 代表着诚信和忠诚。
小信 薄黄 代表着诚信和忠诚。
大义 濃白 象征着正义和公正。
小义 薄白 象征着正义和公正。
大智 濃黒 代表着智慧和谋略。
小智 薄黒 代表着智慧和谋略。

冠位十二阶颜色的象征意义

  • 紫色:紫色在古代日本被视为最高贵的颜色,象征着皇室和贵族的权威。紫色染料的提取过程复杂,需要从特定植物中提取,因此紫色成为禁色,只有最高级别的官员才能使用。
  • 青色:青色代表木元素,象征着生长和发展,与“仁”相对应。青色在古代日本也具有一定的尊贵意义,但不及紫色。
  • 红色:红色代表火元素,象征着热情和活力,与“礼”相对应。红色在古代日本常用于庆典和节日,象征着喜庆和快乐。
  • 黄色:黄色代表土元素,象征着稳定和丰收,与“信”相对应。黄色在古代日本常用于皇室和贵族的服饰,象征着尊贵和权威。
  • 白色:白色代表金元素,象征着纯洁和神圣,与“义”相对应。白色在古代日本常用于宗教和祭祀活动,象征着神圣和纯洁。
  • 黑色:黑色代表水元素,象征着深邃和宁静,与“智”相对应。黑色在古代日本常用于正式场合和服饰,象征着沉稳和严肃。

这个时期,低阶层的人不能穿戴比自己地位高的颜色,这些被称为“禁色”。

奈良时代(710–794年)

随着奈良迁都,日本开始建立完善的朝廷体制,同时受到隋唐文化的深远影响。在这一时期,日本的色彩文化进一步丰富,传统颜色逐渐在社会中形成系统。朝廷对服饰颜色做出了严格规定,不同颜色开始代表不同的身份和地位。佛教在这一时期快速发展,影响了许多颜色的象征意义。

佛教文化的传播,使得色彩在宗教和艺术中的象征意义大大提升。日本从唐朝引进了丰富的色彩文化,如唐三彩的仿制品“奈良三彩”,展现了绿、白、褐为主的色彩。

中国隋唐时期的文化对日本产生了深远影响。在这个时期,中国的金、银、铜等金属经朝鲜半岛传入日本,为日本色彩世界增加了金属色。同时,中国的染料和染色技术也传入日本,如红花和苏芳等染料,这些染料被用于制作各种颜色的衣物和装饰品。

奈良时代的颜色体系中,紫色、红色、蓝色、绿色、黄色和白色成为社会各阶层和宗教仪式中的重要颜色。

主要颜色及其名称与象征意义

颜色名称 日文名称 来源 用途与象征意义
紫色 紫色 (むらさき) 紫草 尊贵、皇族、贵族,象征最高权力和德行
深紫色 濃紫 (こむらさき) 紫草 多用于皇族,象征尊贵且身份最高的官员
赤色 赤 (あか) 茜草 活力、生命力,佛教仪式和皇族装饰
朱色 朱 (しゅ) 朱砂 常用于神社、佛寺建筑,象征神圣与庄严
黄色 黄 (き) 黄连、姜黄 高层官员服饰,佛教中的神圣色彩
薄黄 浅黄色 (うすき) 黄连 普通官员和贵族使用,象征权威与高贵
蓝色 青 (あお) 靛蓝 普通服饰、日常生活中的保护色,常见于平民
浅蓝 浅青 (うすあお) 靛蓝混合染料 佛教装饰,代表平和与智慧
深蓝 濃青 (こあお) 靛蓝 平民服饰,象征坚韧、忠诚
绿色 緑 (みどり) 植物染料(艾蒿) 自然与新生,常用于佛教装饰品和勾玉
白色 白 (しろ) 未染色麻、丝 纯洁、神圣,多用于佛教仪式和贵族服饰
灰色 灰 (はいいろ) 烧制陶土色 日常用品和建筑装饰,体现朴素和低调的美学
深红 深紅 (しんく) 茜草加深染色 高级官员服饰,象征地位与权力
橙色 橙 (だいだいいろ) 柑橘树皮 日常生活和艺术装饰,佛教的火焰象征

紫色在奈良时代被视为最尊贵的颜色,主要用于皇族、贵族的服饰。紫色染料使用紫草根制成,稀少,制作成本高昂,因此只有皇族和高级官员才能穿着紫色。深紫色(濃紫)尤其稀有,用于最高阶官员和皇族成员,象征着无上的权力和德行。佛教高僧也常穿着紫色衣物,显示其地位的崇高。

红色和朱色在奈良时代也很常见,赤色象征生命力和活力,主要用于佛教寺庙的装饰,尤其是朱色,常用于寺庙、神社的建筑上。红色在佛教中具有驱邪避恶的象征,因此朱色被广泛应用于神圣场所,以保护建筑不受邪灵侵扰。

黄色在奈良时代的象征意义相当重要,尤其在佛教文化中,黄色袈裟是佛教僧侣的标准服饰,象征纯洁、智慧和神圣。黄色和浅黄色在官员服饰中也使用,代表着高贵和权威,尤其是朝廷中的高级官员才被允许穿着黄色调的服饰。黄丹是太子礼服色。

蓝色是奈良时代非常常见的颜色,靛蓝染料在当时已广泛普及,用于平民的日常服饰。深蓝色和浅蓝色的使用显示出人们对自然的崇敬。蓝色的实用性、耐用性和防虫特性,使其成为各阶层都可穿着的颜色。浅蓝色也象征智慧和冷静,常用于佛教装饰品。

绿色在奈良时代象征着自然、和平和生命力,主要用于佛教装饰、雕塑和勾玉等饰品中。绿色的植物染料来自艾蒿等天然资源,颜色温和,充满自然气息,是佛教美术中的重要色调。

白色在奈良时代象征纯洁和神圣,特别在佛教仪式和宗教用品中广泛使用。佛教僧侣的衣物中也常见白色,同时白色的麻布和丝绸服饰被用于贵族的日常穿着。在日本自古为神圣纯洁之色,《古事记》中,神灵往往化身为白色的动物出现(如白鸟、白鹿等祥瑞之物),向人们传达神谕。“白”寓含光明,成为天与神性的象征色。因此白色作为后世天皇所用的“帛衣”之色,地位最高。

灰色主要体现在奈良时代的陶器和日常用品中,灰色的陶器和器皿展现出质朴和低调的审美风格。灰色在佛教寺庙的建筑和装饰上也被使用,象征着朴素与平和。

深红色象征权威与地位,尤其是高级官员的服饰中经常出现。深红色通过茜草的深层染色制成,颜色浓郁而庄重,突显出高位官员的威严。

橙色在奈良时代也开始被使用,尤其是来自柑橘树皮的天然染料。橙色在佛教中象征火焰的能量,常用于宗教仪式和佛教艺术装饰中,代表热情和精神力量。

奈良时代的色彩不仅限于装饰用途,还被赋予了深刻的象征意义,不同的颜色代表不同的社会阶层和宗教象征。

  • 紫色:尊贵与德行,象征皇族与佛教高僧。
  • 红色与朱色:活力与神圣,用于佛教寺庙的建筑与装饰。
  • 黄色:智慧与神圣,多见于佛教僧侣服饰。
  • 蓝色:日常实用色,象征坚韧与忠诚,平民服饰的主要颜色。
  • 绿色:自然和生命力,用于佛教雕塑与装饰品。
  • 白色:纯洁和神圣,用于宗教仪式和贵族服饰。

延续下来的一些颜色名:

  • 青色(あお):青色代表天空和海洋,常用于描绘自然景观。
  • 茶色(ちゃいろ):茶色是一种偏褐色,通常用于日常生活中的器皿和衣物。
  • 灰色(はいいろ):灰色通常与朴素、自然和谐相关联。
  • 山吹色(やまぶきいろ):一种偏橘黄色,来源于棣棠花,象征着财富与繁荣。这种花的日语名就叫“山吹”,因其花色艳丽,被称为“黄金色”。山吹色在服饰和艺术品中广泛使用,象征着春天的到来和新生的希望。
  • 群青色(ぐんじょういろ):带有紫调的蓝色,用于描绘水流和天空,是日本画中的重要颜色。它的名字取自于日本传统岩石颜料中的“群青”,这种颜料由矿石磨成粉后制成。群青色在绘画中广泛使用,特别是在表现自然景观时。它的使用不仅展示了日本人对自然美的敏锐感知,也反映了当时绘画艺术中对色彩的精细运用。
  • 鼠色(ねずみいろ):取自老鼠毛色的一种暗灰色,在江户时期流行,但其起源可追溯至奈良时代。
  • 茜色(あかねいろ):由茜草根提取而成,这种深红色象征生命和活力,也被广泛用于贵族服饰。
  • 胡粉色(ごふんいろ):一种淡白色,以海洋中的贝壳或矿石为原料,象征纯洁,常用于佛教装饰和贵族家族的纹样。
  • 空五倍子色(うつぶしいろ) - 这种偏褐色的颜色来自五倍子植物,通常被视为丧服的颜色,具有“凶色”的含义。

染色技术主要包括绞染和夹缬(或称夹染)等,这些技术深受中国唐代文化的影响。绞染是一种传统的染色工艺,通过将布料缠绕在木棍或其他物体上,然后浸入染料中,形成独特的花纹。夹缬是一种使用两块木板,板上刻有相同的花纹,将布料夹在中间,然后进行染色的技术。

另外还有一些染色技术,比如腊接染,使用蜡液在布帛上描出图案,然后染色,蜡凝固后,未染色的部分显现出美丽的图案。纸型染,使用纸型进行印染,特别是在江户时代,这种技术被广泛用于武士的正式服装和商人的时装。

平安时代(794年-1185年)

平安时代这一时代的宫廷生活华丽、优雅,色彩文化也得到了极大的发展和细化。贵族文化的鼎盛时期,色彩的使用达到了极致,贵族女性身着多层华服,颜色丰富多变。贵族阶层对色彩的使用非常讲究,不同的颜色在宫廷礼仪、服饰、文学、艺术等方面都有严格的象征和美学意义。

平安时代中期,藤原官员实行摄政,紫色更是成为了统治阶级的象征。在这个时期,人们还开始使用“禁色”和“允许的颜色”来区分社会等级。禁色被严格保留给最高级别的政府官员使用,而允许的颜色则供普通人使用。

这个时期的色彩名称和分类法逐渐成熟,并开始出现分级色彩系统:

  • 十二单衣(じゅうにひとえ):作为贵族女性的礼服,十二单衣的每一层都有特定的颜色搭配,代表季节、气候和节庆。每个季节都有对应的配色方案,如春天偏向淡粉和嫩绿,秋天则是红与橙的搭配。
  • 重色文化:平安时代对颜色有严格的等级划分,许多颜色因其稀有的染料来源而被赋予了特权和地位象征。例如,「藤紫」是由紫草根染色而成的浅紫色,是皇室的象征色,不允许平民穿戴。

在这一时期的绘画作品《源氏物语》中,许多场景和服饰的颜色描述也反映了这一时期对色彩的高度重视和精细理解。

随着和风美学的逐步形成,平安时代的色彩系统在奈良时代的基础上得到了丰富和创新,许多传统颜色一直沿用至今。

平安时代的传统颜色体系非常庞大,有的颜色甚至根据季节、节气、时间而变换,并形成了独特的配色方案(称为“襲(かさね)”配色,即色层叠穿配色)。以下是平安时代常见的传统颜色及其用途和象征意义:

颜色名称 日文名称 来源 用途与象征意义
深紫色 濃紫 (こむらさき) 紫草 表示最高贵,皇族和贵族女性的常用色彩,象征权威与德行
薄紫色 薄紫 (うすむらさき) 紫草稀释 常见于贵族的春季服饰,柔和而优雅,象征柔美
红色 赤 (あか) 茜草 活力与激情,多用于贵族女子和贵族服饰
薄红 薄紅 (うすくれない) 茜草稀释 春天和樱花盛开时的颜色,象征青春、浪漫
朱色 朱 (しゅ) 朱砂 宫廷装饰和祭祀仪式中常用,象征神圣与庄严
深红 紅 (くれない) 茜草 贵族男子服饰,多见于秋冬季节,代表强烈的情感
橙色 橙 (だいだいいろ) 柑橘染料 常见于秋季服饰,象征丰收与活力
黄色 黄 (き) 黄连、姜黄 高贵而神圣,多用于宫廷装饰,尤其在夏季服饰中常见
薄黄 浅黄色 (うすき) 黄连稀释 低调而优雅,用于贵族少女的日常服饰,象征清新
蓝色 青 (あお) 靛蓝 典雅的颜色,象征智慧和平静,多见于佛教僧侣服饰
浅蓝 浅青 (うすあお) 靛蓝稀释 平和与纯净,夏季和春季服饰中常见
深蓝 濃青 (こあお) 靛蓝 深沉与庄重,贵族服饰中的经典色彩
绿色 緑 (みどり) 艾蒿染料 自然、生命的象征,主要在春夏季节服饰中使用
翠绿色 翠 (みどり) 植物染料 表现新生与和平,夏季贵族男子的常见服饰
白色 白 (しろ) 未染色麻、丝 纯洁、神圣的象征,多见于贵族仪式服饰
灰色 灰 (はいいろ) 烧制陶土 低调而朴素,多用于日常用品和建筑装饰
藤色 藤色 (ふじいろ) 藤花 高贵和神秘,贵族女性的夏季服饰色彩
青藤色 青藤 (あおふじ) 藤花染料 青紫色,象征春天和自然,常见于贵族少女的服饰
桃色 桃色 (ももいろ) 桃花染料 甜美与青春,多用于少女的春季服饰
鹅黄 朽葉色 (くちばいろ) 树叶凋零色 秋季服饰,象征成熟与丰收
海松色 海松色 (みるいろ) 海松藻 象征深海的宁静和深沉,佛教僧侣的袈裟色之一
鸨羽色 鸨羽色 (ときはいろ) 鸟羽染料 高贵且稀有的颜色,多见于皇族服饰
青磁色 青磁 (せいじいろ) 瓷器釉色 典雅、古朴的颜色,用于高阶贵族的装饰品和服饰

紫色(濃紫)在平安时代被视为最高贵的颜色,象征权力和德行。紫色多用于高级贵族的服饰,尤其是贵族女性的日常和正式服饰,显示身份的尊贵。紫色的淡化版“薄紫”则常见于春季,和樱花一起象征柔美与诗意。红色和深红色(赤、紅)象征热情、活力和激情。平安贵族多在秋冬季节穿红色衣物,以彰显旺盛的生命力。浅红色(薄紅)则是春季的代表色,多用于贵族少女,象征青春和浪漫。

黄色在平安时代象征神圣与高贵,是宫廷和高级僧侣服饰的重要色彩。浅黄色则用在贵族少女的日常服饰中,温暖而清新。蓝色(青)在平安时代的色彩体系中,象征智慧和冷静,佛教僧侣的袈裟中常见蓝色。浅蓝色(浅青)代表着春天的清新和夏日的平静,而深蓝色(濃青)则用于贵族男子的日常服饰,显得庄重和沉稳。绿色(緑)象征自然的生命力,尤其在春夏季节用于贵族的服饰中。翠绿色(翠)多见于贵族男子的夏季服饰,颜色清新,象征和平与繁荣。

白色在平安时代象征纯洁和神圣,多用于贵族的正式服饰和宗教仪式的袍服,具有庄重的意义。灰色是低调且典雅的颜色,用于贵族的日常用品和建筑装饰,突显出朴素的和风美学。

藤色(藤色、青藤)是平安贵族非常喜爱的色彩,源自藤花,优雅而神秘。夏季的贵族女子常穿藤色服饰,象征贵族的独特气质。桃色象征青春和温柔,多见于贵族少女的春季服饰。鹅黄(朽葉色)是秋季服饰中重要的颜色,象征成熟和丰收。海松色代表海的宁静,多用于僧侣的服饰。鸨羽色是一种稀有的颜色,只有皇族才使用,象征尊贵和特殊的身份地位。青磁色取自陶器釉色,色调古朴,平安贵族中常用来作为装饰和服饰颜色,体现了典雅和低调的美学追求。

平安时代的服饰配色讲究色彩的层叠,称为“襲(かさね)”配色,通常在同一套服饰上采用多种颜色的重叠,如紫配白、红配绿等。不同季节、节气,甚至根据贵族的身份而定。通过不同色层的组合,表达了季节的转换、自然的美以及宫廷的审美规范。以下是几种典型的“襲(かさね)”配色示例:

  1. 春の桜(春樱)

    • 色层组合:浅红色(薄紅)+ 白色(白)
    • 象征意义:春天樱花盛开的美景,清新柔美,表达青春与浪漫。
  2. 夏の涼(夏凉)

    • 色层组合:浅蓝色(浅青)+ 浅绿色(浅緑)
    • 象征意义:夏季清凉的氛围,表现出自然的宁静和轻盈。
  3. 秋の紅葉(秋枫)

    • 色层组合:深红色(紅)+ 橙色(橙)
    • 象征意义:秋季枫叶的颜色,象征成熟与丰收,同时带有一种肃穆感。
  4. 冬の雪(冬雪)

    • 色层组合:深紫色(濃紫)+ 白色(白)
    • 象征意义:冬天的冷峻和宁静,紫色象征尊贵,白色则体现冬季的纯洁和清冷。
  5. 藤重ね(藤层叠)

    • 色层组合:藤色(藤色)+ 青紫色(青藤)
    • 象征意义:藤花盛开的美丽场景,是贵族女子夏季喜爱的配色,清雅而神秘。
  6. 菊襲(菊花层叠)

    • 色层组合:黄色(黄)+ 白色(白)
    • 象征意义:秋季盛开的菊花,象征高洁和长寿,多用于秋季的贵族服饰。

日本平安时代,人们生活相对安定,当时的贵族服饰颜色都是根据四季的植物、自然的风景等来命名的,典雅且富有诗意。以下是对该时代一些传统颜色的详细介绍及列举:

  • 萌木色:传统色中表现黄绿色的代表性颜色,作为春天的颜色,散发着新生的喜悦,适合朝气蓬勃的年轻人,常用在年轻武士的铠甲上。镰仓时代的《平家物语》中,就有描写身穿萌木色铠甲的情景。
  • 抚子色:抚子是石竹科石竹属花卉的总称,从平安时代开始,抚子色就被当成是夏天的颜色。因为夏日祭时,就有很多穿抚子花浴衣的女孩,抚子花的图案象征优雅、美丽和文静,女孩们就连腰带也会打成“抚子结”,看起来很可爱,是很适合年轻女孩的颜色。
  • 藤色(包括蓝藤色、淡藤色、白藤色等):藤色即紫藤萝花的颜色,亮蓝而偏浅的紫。因为紫藤的“藤”字与平安时代的权贵藤原氏的“藤”字相通,藤色也被视为高贵的颜色。淡雅柔和的藤色,是衬托女性美的颜色,因此常常会用在手帕、扇子等小配件上。在《枕草子》中,还出现了用紫色和纸书写和歌,并系上藤花而寄送的浪漫情书,所以,藤色也是浪漫的体现。
  • 桔梗色:桔梗花会开出紫色的花,桔梗色也因此被命名。桔梗属于“秋季七草”之一,也被称为“朝颜”。桔梗色浪漫神秘,给人以无限遐想的空间。
  • 梅色:古时起,梅色就很受欢迎,是高贵女性服饰颜色中的代表色。梅又叫做“春告草”,它象征着高洁、坚强、美丽、有傲骨之风、不畏严寒、贫寒却有德行的人,它有着坚贞不屈并且谦虚的品格,人们常把它当做是传春报喜的吉祥象征,而深受人们喜爱。
  • 莺色:是如同树莺羽毛一样的颜色,是素淡的黄绿色,诞生于江户时代。那时人们流行把树莺当动物来养,所以形成了穿莺色衣服的风尚。
  • 一斤染:是一种很淡的粉红色,在日本平安时代,红花是很昂贵的染料,红染绢一匹需费20斤红花,当时的平民女性只能用很淡的红紫色取代。这种颜色只要约一斤红花即可,染出稍带黄色的粉色,如此便有了“一斤染”的名称。粉色和蓝色是世界上最温柔的颜色,而一斤染也是古时日本即便是级别不高的官吏也可以使用的颜色。一斤染也会形容人也有着责任、关怀、善良。

这些颜色不仅本身很美,名字也起得令人赞叹,光读这些名字,就能感受到季节的更迭。更多平安时代颜色名有,樱色、水色、露草色、堇色、踯躅色、女郎花色、山鸠色、雀茶和瓶覗等。

镰仓时代(1185-1333年)

镰仓时代是日本由贵族统治向武士阶层统治过渡的重要时期,伴随着这一变化,色彩的使用和象征意义也发生了转变。与前一代平安时代的繁复和优雅不同,镰仓时代的颜色美学趋向于简约和稳重,以契合武士文化的务实精神。这一时期的色彩强调朴素、内敛和实用,受到禅宗思想的影响,展现出对简朴之美的崇尚。

镰仓时代的色彩体系相比平安时代更为质朴,强调低调的美感。由于武士阶层崛起,镰仓时代的审美偏向深色和中性色,尤其是冷静的深蓝色、力量的灰色、胜利的茶色等,象征沉稳、勇气和力量,强调内敛、简朴和坚毅,形成了独特的“武士之美”。同时,禅宗在这一时期传入日本,崇尚素雅的色调成为流行趋势,许多僧侣袍服、武士装束都以深色系为主,禅宗的影响使得镰仓时代的色彩更趋自然和朴素。镰仓时代的色彩文化也融入了武士的朴素和坚定,逐渐形成了独特的风格。以下列举镰仓时代的常见颜色及其象征含义:

颜色名称 日文名称 来源 用途与象征意义
深蓝色 濃紺 (こいこん) 靛蓝 武士服装的主要颜色之一,象征冷静、威严和勇气
青色 青 (あお) 靛蓝 佛教僧侣常用色,代表智慧和平静
铁色 鉄色 (てついろ) 铁锈染料 武士铠甲色调,象征力量与坚毅
灰色 灰色 (はいいろ) 烧制陶土 低调和质朴,受到禅宗影响的僧侣服饰和日常装束常用
褐色 褐色 (かちいろ) 靛蓝深染 被认为是吉祥之色,武士的护身符色彩
茶色 茶色 (ちゃいろ) 茶树染料 朴素的象征,流行于日常服饰和僧侣袍服
薄茶色 薄茶 (うすちゃ) 茶树浅染 表现出低调的优雅,常见于日常便装
深紫色 紫紺 (しこん) 紫草 崇高的象征,多用于高级武士和僧侣
梅紫色 梅紫 (うめむらさき) 梅花染料 武士的祭典和仪式中使用,象征忠诚和高贵
枯草色 枯草色 (かれくさいろ) 干草色 冬季常见的颜色,象征坚韧和生命的延续
黒色 黒 (くろ) 碳墨或天然染料 武士装束的经典色彩,象征庄严、力量和神秘
白色 白 (しろ) 未染色麻、丝 纯洁、神圣的象征,尤其在僧侣服饰中常见
鸨羽色 鸨羽色 (ときはいろ) 鸟羽染料 高贵的象征,多用于高级武士装饰
鼠色 鼠色 (ねずみいろ) 烧制灰烬 低调简约,象征平和和克制
焦茶色 焦茶色 (こげちゃ) 焦茶染料 表现出沉稳和力量,用于武士的日常装束
金茶色 金茶色 (きんちゃ) 黄金染料 贵重色彩,用于武士的重要仪式场合
羊羹色 羊羹色 (ようかんいろ) 羊羹的深棕色 高级和质朴的结合,武士装束和仪式装饰常见

以下是一些在镰仓时代可能存在的传统颜色名字:

  1. 萌木色:如上所述,代表新生与希望。
  2. 深蓝:沉稳、内敛,常用于武士的服饰和铠甲。
  3. 墨绿:与深蓝相似,也是镰仓时代常见的颜色之一,常用于装饰和服饰。
  4. 茶色:一种低调而自然的颜色,可能用于武士的日常服饰或家居装饰。
  5. 薄柿:一种淡雅的红色,常用于和服等服饰。
  6. 朽叶色:接近秋季落叶的颜色,富有诗意和季节感。
  7. 錆鉄御納戸(纳户色):一种通过特殊染色工艺得到的暗沉蓝色,与纳户(储藏室)阴沉的样子相似。

战国时代(1467–1603年)

战国时代是日本历史上一个动荡不安的时期,各个大名之间的权力争夺激烈。这一时期不仅是武士阶层的黄金时代,也是日本传统色彩文化的重要发展阶段。在战争和武士文化的影响下,战国时代的颜色更加丰富多样,色彩的象征意义与社会地位、军事力量和文化习俗紧密相连。

武士阶层崛起,他们的审美也影响了传统颜色的发展。武士的盔甲、旗帜、袍服等使用许多独特颜色来表现忠诚、勇气与家族荣耀:

  • 墨色(すみいろ):以墨染的深灰色或黑色为主,象征沉稳与力量,是武士们常用的颜色。
  • 深红色:深红色在战国时代被广泛用作盔甲的颜色,象征勇敢无畏。

因此,武士们所使用的颜色也对日本色彩体系产生了影响,赋予了许多颜色“武士道”的象征意义。

受禅宗和武家文化的影响,色彩的使用趋于简约,中国宋元水墨画的影响开始显现。

以下列举战国时代的常见颜色及其象征含义:

颜色名称 日文名称 来源 用途与象征意义
深红色 濃紅 (こいくれない) 从红染料提炼 代表勇气和权力,常用于战斗和军事场合
朱色 朱 (あけいろ) 朱砂染料 用于武士的服饰,象征着热情、活力和胜利
蓝色 青 (あお) 靛蓝染料 表示忠诚与信任,常见于武士的日常服装
黄绿色 黄緑 (きみどり) 黄绿染料 表现自然活力,象征丰饶与希望
黑色 黒 (くろ) 碳墨或天然染料 武士的盔甲和传统服饰色,象征力量与神秘
深紫色 濃紫 (こいむらさき) 紫草染料 高贵的象征,常用于贵族和高级武士的服饰
茶色 茶色 (ちゃいろ) 茶树染料 朴素的象征,常见于日常生活和武士的非正式服装
白色 白 (しろ) 未染色麻或丝 纯洁与神圣,特别在宗教和仪式场合中使用
亮蓝色 空色 (そらいろ) 明亮的蓝色 表现平静与天空的广阔,常用于和平的象征
苍色 碧 (あおみ) 蓝绿色染料 表示清新与自然,通常在日常生活中使用
藤色 藤 (ふじいろ) 藤染料 优雅的象征,多用于女性的服饰和装饰
橙色 橙 (おういろ) 橙色染料 表现活力与生机,常用于庆典和节日的服装
绿色 緑 (みどり) 绿色染料 自然与生命的象征,常见于和服与祭典装饰
砂色 砂色 (すなじろ) 沙土的颜色 朴实的象征,常用于军队的盔甲和衣物
灰色 灰色 (はいいろ) 烧制陶土 稳重的颜色,武士和僧侣都常用以表现克制与内省
浅蓝色 浅葱 (あさぎ) 浅蓝色染料 表现清新与明亮,常见于年轻人的服饰
亜麻色 亜麻色 (あまいろ) 亚麻的颜色 表现自然和原始的美,常用于女性的服饰
透明色 透明色 (とうめい) 未染色 表现纯粹与真诚,常在宗教仪式中使用
玫瑰色 玫瑰色 (ばらいろ) 玫瑰花的颜色 表达浪漫与爱,常用于女性的节日和婚礼装束
赭色 赭色 (あかいろ) 土壤的颜色 表现大地和生命,象征稳定与坚韧

在战国时代,红色被视为一种醒目且具有威慑力的颜色。例如,武田信玄公就善用红色,他认为红色便于分辨敌我双方,同时也能给敌人带来足够的威慑力。白色在战国时代也具有重要意义。源氏集团在源平合战中举白旗,并最终获得了战役的胜利。因此,白色在某种程度上也象征着胜利和纯洁。藏蓝色是一种隐蔽性较强的颜色。在战国时代,身穿藏蓝色服装的部队在偷袭或撤退时能够凸显作用,可以说是日本战国时期的迷彩服。

在战国时代,颜色不仅仅是视觉的表现。各个大名使用特定颜色来标识其家族和地盘,使得颜色成为社会地位的象征。这一时期的颜色应用深刻影响了后世的和服设计、建筑装饰以及节日庆典等方面。

武士的服饰在战国时期尤为重要,不仅是个人风格的体现,也是地位和力量的象征。颜色的选择常常与家族的纹章(家纹)相结合,形成独特的视觉语言。例如,深红色和黑色的结合常用于战斗服饰,强调武士的勇敢与坚韧。

除了上述主要颜色外,日本战国时代还有其他多种传统颜色,以下列举部分颜色名字以及在战国时代所代表的意义:

  • 黄金色:象征财富和权力。
  • 古代紫色:一种深邃而神秘的颜色。
  • 薄桜色:浅粉色,象征春天的气息。
  • 枯茶色:一种暗淡的茶色,常用于表现沉稳和低调。
  • 櫨染花緑色:深绿色,象征生命力和活力。
  • 青茄子色:青紫色,带有一种神秘感。
  • 鴇鼠色:灰褐色,常用于表现朴素和自然。
  • 青緑色:深绿色,象征自然和生机。
  • 京紫色:深紫色,高贵而神秘。
  • 虹色:七彩颜色,象征希望和美好。
  • 土色:黄褐色,常用于表现大地的色彩。
  • 芥子色:淡黄色,象征明亮和温暖。
  • 水浅葱色:浅蓝色,清澈而明亮。
  • 蒲葡色:紫罗兰色,带有一种优雅和浪漫的感觉。

江户时代(1603年-1868年)

江户时代是日本色彩文化的一个辉煌时期,民间文化和武士阶层的审美追求带来了颜色的丰富发展。商人阶级的崛起推动了市民文化的蓬勃发展,这种社会结构的转变使得色彩运用从贵族逐步普及到平民百姓生活中,特别是在和服、浮世绘、陶瓷、漆器、装饰等方面,颜色的运用达到了前所未有的高度。江户时期对颜色的细分和命名在日本历史上十分独特,色彩成为了表达个性、地位、身份甚至情感的重要工具。以下列出江户时代一些著名的传统颜色及其名称和应用:

颜色名称 日文名称 颜色描述 文化意义与应用
浅葱色 浅葱色 (あさぎいろ) 浅蓝带绿色 常见于和服中,象征年轻、清新
浅紫色 浅紫 (あさむらさき) 浅紫色 女性服饰常用颜色,象征优雅
藍色 藍 (あい) 靛蓝色 男性常用色,象征坚韧与武士气质
錆浅葱 錆浅葱 (さびあさぎ) 带灰的浅蓝绿色 用于平民服装,象征朴实无华
鉛白 鉛白 (えんびゃく) 铅白色 在漆器中使用较多,象征高贵与典雅
桔梗色 桔梗色 (ききょういろ) 淡紫蓝色 多用于女性服饰和花纹,象征温柔
群青色 群青色 (ぐんじょういろ) 深蓝色 武士服饰常用,象征稳重和力量
黄土色 黄土色 (おうどいろ) 浅棕色 多用于陶器和装饰,象征自然和稳重
朱色 朱色 (しゅいろ) 明亮的橙红色 节庆装饰和传统建筑中使用,象征热情与活力
紅梅色 紅梅色 (こうばいいろ) 粉红色 象征浪漫和柔美,多用于女性和儿童服饰
蘇芳色 蘇芳色 (すおういろ) 深红棕色 在高贵场合使用,象征高雅
染黑色 染黒 (そめぐろ) 深黑色 武士服中常见,象征勇敢和决断
浅藍 浅藍 (あさあい) 浅蓝色 在日常服装中较为常见,清新且自然
桃色 桃色 (ももいろ) 粉红色 象征春天与幸福,常用于节庆和装饰
若草色 若草色 (わかくさいろ) 浅绿色 象征青春和自然,常用于春天的服饰
鳶色 鳶色 (とびいろ) 带红的棕色 用于建筑和家具装饰,象征大地与稳固
利休色 利休色 (りきゅういろ) 带灰的绿色 茶道中常用的颜色,象征平静与自律
山吹色 山吹色 (やまぶきいろ) 鲜艳的橙黄色 节日和婚礼服装中使用,象征丰收与喜庆
空色 空色 (そらいろ) 浅蓝色 天空的象征,常见于夏季和服与浮世绘中
白練 白練 (しろねり) 纯白色 象征纯洁无暇,多用于婚礼和祭祀服饰
深川鼠 深川鼠 (ふかがわねず) 带灰的蓝色 平民服饰中常见,朴素自然
銀鼠 銀鼠 (ぎんねず) 银灰色 高贵典雅,象征宁静与克制
櫻色 櫻色 (さくらいろ) 淡粉色 樱花的象征,象征春天的到来
鶸色 鶸色 (ひわいろ) 浅黄绿色 春季服饰中使用,象征生命力
江戸紫 江戸紫 (えどむらさき) 深紫色 江户时代的代表色,象征高雅、文化与历史
青磁色 青磁色 (せいじいろ) 浅蓝绿色 陶器色,象征冷静和优雅
黄緑 黄緑 (きみどり) 浅黄绿色 新生命的象征,多用于儿童服饰和春季装饰
枯茶 枯茶 (からちゃ) 深棕色 茶道和盆栽中常见,象征自然和简朴

在江户时代,颜色在和服中应用广泛,不同颜色通常用于区分不同的阶层和身份。商人、武士和市民阶层都选择特定的颜色来表现其身份和社会地位。例如,江户紫是一种在城市中常见的紫色,代表了市民文化的成熟和时尚。

浮世绘在江户时期蓬勃发展,画家使用鲜艳的朱色、青色和浅葱色等表现江户风情,浮世绘中的色彩设计直接影响了后世的艺术和设计风格。

江户时期的茶道崇尚“侘寂”之美,许多颜色如利休色、枯茶色和银鼠色等带有淡雅、朴素的质感。这些颜色象征了茶道中“简素”和“宁静”的精神境界,体现了对自然的尊重。

江户时代的市民文化将颜色应用于节日、婚礼等生活场景,鲜艳的颜色如山吹色和若草色象征生命的活力与喜庆的氛围。

江户时代的茶色系是一组富有深厚文化内涵和自然质朴美感的颜色。这些颜色受到“侘寂”(わびさび)美学的影响,崇尚自然与简约,强调在朴素和低调中寻找美的感受。茶色系的广泛应用也源于江户时期市民阶层的崛起。由于江户时代的奢侈禁令,过于华丽的色彩受到限制,而茶色系等低饱和度的色调则受到推崇,成为平民和武士的日常穿着及器物装饰中的主要色彩。颁布禁奢令后,百姓的服饰颜色趋于朴素,但同时也诞生了新的颜色名称和配色方法。紫色仍然与贵族联系在一起。同时,江户时代的人们开始更加注重色彩的搭配和调和,使得日本传统色彩更加丰富和多样。在这个时期,人们还开始使用合成染料来制作各种颜色的衣物和装饰品。

“茶色”一词最早源于唐代中国,传至日本后逐渐演变为指称一类带有温暖、柔和色调的褐色系。江户时代的茶色并非单一的棕褐色,而是包含从浅棕到深棕的多种色调。由于这些色彩质朴、低调,符合当时的伦理观念,茶色系逐渐成为社会上广泛接受的颜色。它们通常出现在和服、漆器、陶瓷和家居装饰等方面,象征自然、稳重、质朴与内敛的气质。以下列出江户时代常见的茶色系颜色及其名称、描述与象征意义:

颜色名称 日文名称 颜色描述 文化意义与应用
茶色 茶色 (ちゃいろ) 标准棕褐色 普通大众的日常服饰色,象征朴实与节俭
薄茶色 薄茶 (うすちゃいろ) 浅棕色 平民和武士的休闲服饰色,象征自然与沉稳
枯茶 枯茶 (からちゃ) 深棕色 茶道和侘寂美学的代表色,象征枯荣与自然
黄枯茶 黄枯茶 (きがらちゃ) 带黄的浅棕色 常见于平民服饰和装饰,朴素而不失温暖
利休茶 利休茶 (りきゅうちゃ) 柔和的绿色带棕色调 以茶道大师千利休命名,象征茶道精神与宁静
利休鼠 利休鼠 (りきゅうねず) 带灰的浅茶色 茶道常用色,象征简素、朴素,带有内敛的审美
赤茶 赤茶 (あかちゃ) 带红的深棕色 男性和武士的服饰中常见,象征强劲和稳重
胡桃色 胡桃色 (くるみいろ) 栗子壳般的深棕色 多用于漆器和家居装饰,象征自然、传统工艺
栗色 栗色 (くりいろ) 带红的栗棕色 常用于秋季服饰和装饰,象征丰收与自然
煤竹色 煤竹色 (すみたけいろ) 带黑的深棕色 武士服饰和日常器具中常见,象征力量与坚韧
木蘭色 木蘭色 (もくらんいろ) 带微红的浅棕色 家居装饰和和服中的常用色,温暖而舒适
黄茶 黄茶 (きちゃ) 带黄的棕色 秋季节庆服饰中常见,象征丰收与和谐
鳶色 鳶色 (とびいろ) 带红的深棕色 武士和工匠的服饰中常见,象征坚毅与力量
柿色 柿色 (かきいろ) 带橙的红棕色 和服中常用的颜色,象征丰收、温暖和幸福
栗皮茶 栗皮茶 (くりかわちゃ) 深栗色 武士服和男性服饰中的常见色,稳重而有力
土器色 土器色 (かわらけいろ) 带土黄色调的茶色 陶器色,象征与自然的亲近与融合
黄櫨染 黄櫨染 (こうろぜん) 微红的浅棕色 皇室传统色之一,象征高贵与尊严

茶色系与茶道文化有着深厚的联系。在茶道中,侘寂美学讲究朴素、自然、不完美的美感,茶色系的低调和谦逊符合这种追求。利休茶色、枯茶色等常用于茶室的装饰和茶具设计,强调人们应以宁静、内敛的心态对待生活,感受简朴之美。茶色在茶道中是重要的色彩选择,包括茶具、茶室内的装饰等,特别是枯茶、利休茶等色调反映了茶道的侘寂之美。

江户时代的茶色系颜色通常是平民百姓和低阶层武士的主要选择,因其简朴、低调的色调迎合了江户时代的社会伦理观念和节俭美学。这些颜色不张扬、不艳丽,但却自然舒适、亲和,与市民生活方式相契合。为了限制过度消费和奢侈风气,幕府政府颁布了一系列奢侈禁止令,茶色系低饱和度的色调便成为大众的首选,特别是在和服和日常装饰中,茶色系在和服中大量使用,特别是在市民阶层和武士的日常服饰中,茶色系象征着低调、朴素和稳重,这些颜色反映了当时人们对低调审美的推崇。特别是在歌舞伎演员中。这些颜色不仅用于舞台表演,也深受普通民众的喜爱。

茶色系色调多源自自然,如泥土、树皮、木材等元素,表达了江户时代人们对自然的尊重与热爱。茶色的朴实、稳定让人联想到自然的稳定性和持久性,是人与自然和谐相处的一种象征。茶色系的温暖色调在家居装饰中非常受欢迎,例如胡桃色、煤竹色等在家具、屏风等物件上常见,既美观又带有自然质感。

鼠色系颜色多由灰色与其他色调(如蓝、绿、紫等)混合而成,形成不同的灰调,具有柔和的过渡效果。鼠色系既有宁静、稳重的特点,又不失细腻和雅致,营造出内敛的高贵感。鼠色系颜色种类繁多,其中典型的颜色及其描述如下:

颜色名称 日文名称 颜色描述 文化意义与应用
鼠色 鼠色 (ねずみいろ) 标准灰色 最经典的鼠色,低调不失优雅,广泛用于和服、日常服饰中
利休鼠 利休鼠 (りきゅうねず) 带灰调的茶绿色 受茶道文化影响,象征宁静与简约,常用于和服和茶具装饰中
青鼠 青鼠 (あおねず) 带蓝的浅灰色 年轻人和平民服装中的常用色,展现出清新、柔和的气质
鉛鼠 鉛鼠 (なまりねず) 带金属感的深灰色 类似铅色,深沉且内敛,广泛应用于高级武士服饰和男性服饰
紅鼠 紅鼠 (べにねず) 带红调的灰色 柔和且富有层次感的灰色,常用于女性和服饰中,象征温柔与雅致
葡萄鼠 葡萄鼠 (ぶどうねず) 带紫的灰色 带紫调的灰色,优雅而神秘,适合正式场合穿着的服饰和器物装饰
灰青鼠 灰青鼠 (はいあおねず) 带灰调的青蓝色 体现成熟和冷静,常用于中性风格的服饰,展示出稳定和清冷的气质
蒲公英鼠 蒲公英鼠 (たんぽぽねず) 带黄色调的浅灰色 柔和且明亮,象征春天的温暖气息,多用于儿童和服饰装饰中
胡桃鼠 胡桃鼠 (くるみねず) 带棕调的深灰色 接近胡桃木的色泽,质朴自然,常用于和服和日常家具中
桔梗鼠 桔梗鼠 (ききょうねず) 带桔梗花的紫灰色 带紫调的高级灰色,常用于传统和服和贵族装饰,象征尊贵和高雅
月白鼠 月白鼠 (げっぱくねず) 带蓝白色的浅灰色 轻柔如月光,适合夜晚和轻便服饰中,象征宁静与和谐
錆鼠 錆鼠 (さびねず) 带铁锈色的灰色 稳重且富有历史感,常用于武士服饰和工艺品,象征持久与韧性
石竹鼠 石竹鼠 (せきちくねず) 带粉的灰色 柔和的灰色,适合女性和服中的柔美设计,带有温馨和浪漫的氛围
菫鼠 菫鼠 (すみれねず) 带菫花的紫灰色 高雅神秘的灰色,常用于高档和服和家居装饰中,象征内敛与静谧
墨鼠 墨鼠 (すみねず) 带墨的深灰色 黑灰色调,武士和男性常穿,象征沉稳、严肃和力量
涅鼠 涅鼠 (くりねず) 浅棕带灰色 质朴的灰棕色,朴素而自然,常用于日常和服和室内装饰

鼠色系的低饱和度与江户时代的“侘寂”美学一脉相承。这种美学追求的是自然、朴素、不完美的美感,鼠色系的柔和与细腻正是侘寂之美的体现。尤其在茶道、和服和建筑装饰中,鼠色系代表着一种对内在宁静和外在简约的追求。鼠色系象征着一种低调和优雅的生活态度,符合江户时代禁奢令下的审美观念。在服饰上,鼠色系适合日常穿着,不显得张扬,既能展现品味,又不会过于惹眼。这种色彩的流行反映了人们对简朴生活方式的认同。鼠色系色调多为中性灰,不易显出污渍,适合日常使用。无论是在服饰、家居还是器物装饰中,鼠色系都体现出其实用价值。特别是在武士服装和工匠的工作服中,深色调的鼠色常常代表坚韧和力量。

鼠色系既可表达庄重,又具备亲和力,成为当时市井生活的重要组成部分。平民百姓在日常和正式场合均会穿着鼠色系和服,既低调又不失庄重。尤其是中性和男性服饰中,既能保持朴素、稳重的风格,又能表现出优雅的气质。

鼠色系在家居装饰中也得到应用,尤其是木制家具、屏风、墙面等。鼠色系让空间显得宁静且富有层次感,非常适合茶室、书房等需要安静氛围的场所。

四十八茶百鼠”是江户时代对茶色系和鼠色系颜色的统称,是日本特有的传统色彩体系。在“四十八茶”中,茶色泛指以棕色为基础的各种色调,包括浅茶、深茶、黄茶等。在“百鼠”中,鼠色泛指以灰色为基础的各种色调,包括青鼠、赤鼠、紫鼠等。这两个色系的颜色种类繁多,没有确切的固定数量。

四十八茶可能是黄茶、浅黄茶、枯茶、利休茶、銀煤竹、焦茶、江户茶、紅茶、藁茶、渋茶、琥珀茶、狐茶、山吹茶、栗茶、朽葉茶、梅茶、亜麻茶、濃茶、榛茶、枯草茶、黄土茶、桑茶、胡桃茶、古代茶、利久茶、葡萄茶、伽羅茶、小豆茶、胡麻茶、栗皮茶、黒茶、花茶、土器茶、赤茶、鶯茶、涅茶、梔子茶、山吹茶、錆茶、木賊茶、藍茶、菜種茶、蜜柑茶、枇杷茶、茄子茶、葡萄皮茶、落栗茶和古代黄茶。

百鼠就是颜色中带鼠的颜色,颜色灰度比原色要低。

宗教与哲学

日本传统颜色在宗教(如佛教、神道教)和哲学思想(如茶道、花道)中有着独特而深远的象征意义,往往承载着宗教信仰、人生哲理和美学观念。

在神道教中,许多神社建筑涂有鲜艳的朱色(しゅいろ),如著名的伏见稻荷大社。朱色被认为能驱邪辟恶,同时象征生命的力量和神圣性。朱色在日本传统文化中具有特殊地位,不仅是美学上的选择,也是对神灵的敬畏。传说在建造稻荷神社时,稻荷大神显现为朱色的狐狸,因而后人将朱色用于神社建筑以祈求护佑。红色在神道教中象征着幸福与保护。许多神社的鸟居(大门)都是红色的。红色还常用于节庆活动,如七五三节,孩子们穿着红色衣物以求吉祥。

佛教中,青莲(青色莲花)象征智慧和清净。青色(群青)在佛教寺庙的装饰和壁画中象征着对超脱尘世的追求。相传,青莲花因其“出淤泥而不染”的品质,象征修行者在世俗生活中保持清净之心的信念。黄色象征着智慧和启蒙。在许多佛教寺庙中,可以看到黄色作为装饰色彩,用于表示对佛法的尊重和追求智慧的决心。例如,佛教徒在进行冥想时,常用黄色蜡烛或布料来营造宁静的氛围。在佛教寺庙和神道的地藏菩萨雕像上常能看到红色围巾或帽子。据说红色能抵挡邪恶之物,保护儿童和弱者,尤其是小孩子在疾病、事故中获救的传说层出不穷。家长们会为地藏菩萨穿上红色衣物,以求保佑子女平安成长。金色(きんいろ)在佛教中象征光明和智慧,佛像和佛堂中大量使用金色,寓意佛光普照、消除黑暗。在日本,京都的金阁寺就是一座外观镀金的建筑,象征吉祥、福德和永恒。据传,镀金建筑能吸引神灵和祖先护佑,金阁寺的外墙金光闪耀,成为祈福之地。在神道教的神器“三神器”中,镜和勾玉常用金色或白色,象征神圣之物的永恒光辉。镜子反射出真理之光,勾玉则代表灵魂与生命的永恒,金色象征对神灵的崇敬和对生命的祈愿。

在日本,紫色(むらさき)是一种高贵的颜色,常用于贵族和高僧的服装。紫色象征智慧与超然,尤其在佛教中,紫色的袈裟被认为是高僧的象征。平安时代以来,只有达到一定地位的僧侣才可穿着紫色袈裟,这不仅是地位的体现,更被认为是精神修为的象征。传说中,能够在冥想中观见紫光者被认为有着佛教的深厚因缘,能够步入智慧之境。

神道教的祭司服和巫女服以白色为主,白色(しろいろ)代表纯洁和神圣,用于清净和祓除仪式。神职人员穿白色祭服,以示对神灵的尊敬。据说在祭祀仪式中,白色能吸引神灵降临并传递祈愿,因此白色成为神道中极具代表性的颜色。白莲花在佛教中象征纯洁,寓意轮回中的超脱。净土宗信仰中,白色莲花是往生极乐净土的象征。白色莲花的清净之美象征无染的心灵,传说中,佛陀在说法时常有白莲花开放,白色也因此被视为神圣的象征。

平安时期的女作家紫式部因钟爱紫色而得名,她的《源氏物语》不仅传承了美学,也是日本文学史上的瑰宝。紫色象征着高雅与内心的丰富,如紫式部本人所追求的那般。平安贵族将紫色视为美德和高雅的象征,贯穿于文学和生活之中。

茶道强调“和、敬、清、寂”,茶色作为茶道中重要的颜色,象征着自然与朴素。茶道中的茶具通常采用茶色系,反映出与自然和谐共处的理念。茶道师傅们会选择不同深浅的茶色器具,以体现季节变化和心境。例如,在春天使用较浅的茶色器具,而在冬天则可能选择深沉的茶色,以营造温暖感。茶道创始人千利休推崇一种称为“利休茶”的深沉、灰棕色调,这种颜色表达了茶道中“侘寂”之美的理念。侘寂是一种对不完美、短暂和朴素的美的欣赏,枯茶色等朴素色彩成为茶道的象征。据说千利休在一次茶会中展示了一件沾有泥土的茶碗,以自然之色表达人生无常的哲理,激发人们对平凡之物的珍视。茶道园林中常见的枯山水设计也大量运用茶色和灰色,象征着内心的宁静与自然的平衡。这些颜色既符合简朴的“无为”哲学,也是对生活本质的回归,茶道中人们通过欣赏这些质朴色调,从而感悟“以少为多”的哲学理念。

浅葱色(あさぎいろ)是一种淡蓝色,在江户时期常用于武士的服饰,象征忠诚与坚韧。据说德川家康非常喜爱这种颜色,浅葱色在武士服装中象征对主君的忠诚和对死亡的无畏,正如武士道精神的体现。此外,武士在战场上佩戴浅葱色的旗帜,表达勇敢无惧的信念,留下了许多英雄事迹。

在花道(Ikebana)中,绿色不仅代表生命与自然,还象征着生长与希望。花艺作品中常会使用各种绿色植物,以体现四季变化和生命力。例如,在春季插花时,会选用嫩绿的新芽,而在秋季则可能使用深绿的松枝,以表现不同季节的特征。红花在花道中通常用于表达热情与活力。在插花作品中加入红花,如玫瑰或芍药,可以为整体作品增添生气和动感。

社会习俗

日本新娘的经典礼服,颜色是纯白色。自古以来白色是日本神圣的颜色,被用于法衣的颜色,后面也用于丧葬、切腹的服装。有人认为白色象征纯洁,在日本女子嫁入夫家便是在娘家死去了,白色又是寿衣的颜色,容易沾染夫家的颜色。白无垢的配件还包括“棉帽子”和“角之隐”,“棉帽子”就如同婚纱裙的头纱一样,在婚礼仪式没有完成之前,不让新郎看到新娘的面孔,是日本古典婚礼的一种习俗。“角之隐”则需要在新娘头发上打着日本传统的弯曲的高发髻,并在头顶上盖上一块带状的宽的布,表示女方加入男方后,将隐藏起象征着愤怒的角,从此成为一位顺从而优美的妻子。日本传统服装中的正礼服,最正式的大振袖都会有五枚家徽,即五纹,整个和服是一副完整的图案,不过也有一般的大振袖会省略家徽。大振袖比白无垢有着更久的历史,常常是日本武士家族的婚假礼服,后面才逐渐普及。和服的一种,其中白色的叫白无垢,彩色的就叫做色打褂。在日本婚礼上一般先穿白无垢,色打褂是结婚几天之后要换上的礼服,带有夫家的家纹,穿上色打褂也就意味着真正成为了已婚人士。在婚礼仪式中,新娘的发饰、头饰和腰带也多采用白色,突显她纯洁无瑕的姿态。在婚礼的仪式上,红白相间的装饰非常常见,象征吉祥与和谐。新娘有时会在婚礼后的“色直し”环节穿上红色和服,以祝福婚姻的幸福和长久。据说红白这两种颜色组合源自日本古代宫廷的喜庆装饰,象征福气延续至今。

在传统婚礼中,新娘的红色外衣上常绣有富贵花卉,如牡丹或菊花,这些花卉象征着繁荣与长寿。新郎则通常穿着黑色的和服,形成鲜明对比,象征着两人结合时的和谐美满。

在日本传统葬礼中,逝者通常穿白色衣物,称为“死装束”(しにしょうぞく),白色在这里象征离世后前往净土的纯洁之路。相传,这一传统源自日本早期的佛教葬礼,白色象征灵魂回归净土的清净。逝者的家属也会穿着深色或黑色和服,以表达悲痛和对亡者的敬意。在现代日本的葬礼中,黑色(くろいろ)是遗属和出席者的首选颜色,象征对逝者的尊重与悼念。这种传统始于明治时期,黑色从欧洲传入并逐渐成为葬礼的主色。葬礼上,黑色的和服和礼服突显庄重肃穆,寄托亲属对亡者的深情缅怀。日本人认为白色是吉祥、神圣的象征,白色在日本人生活中是不可或缺的颜色。早期日本并无印染技术,因此无论是贵族还是平民,每遇婚丧嫁娶只有偏白色的麻布粗服这一种选择。隋唐时期,日本向中国学习,专门的丧服开始出现。由于翻译失误,贵族的丧服由白色转变为灰黑色。庶民阶层因为经济能力有限而无力承担额外的染印费用,依旧保持着素色丧服的传统。鼠色丧服没能在下层民众中流行,却在平安时期被上流阶层所接受。上流社会也逐渐形成了用不同深浅颜色丧服来表示对失去亲人亲疏程度不同的意识。平安时代后期,上流阶层的丧服基本固定为黑色。此后,虽然白色丧服曾经一度占日本主流地位,但黑色丧服仍然作为传统保留延续至今。

在葬礼仪式中,亲属会用白色的纸张包裹香烛和供品,这些供品通常包括米、盐和水,象征着对逝者的供养与怀念。白色在这里代表了生命的轮回与对逝者的祝福。

在新年和七五三节等庆祝场合,红色(あかいろ)被广泛用于装饰和服装。例如在七五三节上,父母会为孩子穿上带有红色图案的和服,特别是女孩的和服,以祈求孩子健康成长。七五三节是日本庆祝3岁、5岁、7岁儿童的节日,红色在这里象征生命力和驱邪的力量,红色和服寄托了父母对孩子的美好祝福。金色(きんいろ)装饰经常出现在装饰和礼品包装中,象征着富贵与繁荣。在新年庆典上,人们用金色和红色的绳结装饰贺年卡和礼品,寓意新的一年繁荣昌盛。传说金色能吸引好运,金色装饰被认为是新年迎来福气的象征。许多女孩会穿上紫色和服,象征高贵和对成长的祝福。紫色自古以来代表尊贵和力量,在这种场合中,紫色和服被认为能够带来好运和成长的力量。紫色在这里寓意家长对孩子未来的期望,特别是希望女孩健康成长、聪慧优雅。在新年的装饰中,紫色绳结常用于礼物包装和祝贺卡上。紫色不仅象征尊贵,还寄托着长寿和家庭的和睦。传说紫色是长寿之色,因此新年使用紫色寓意新一年健康长寿,平安幸福。

在新年时,人们会将朱红色的装饰挂在家中,如注连绳(用于神社门口的装饰),以驱邪避祟。年初一时,人们会穿上朱红色的新衣,以迎接新的一年的好运。

在一些神社的护身符中,会用金色作为主要颜色,寓意吉祥和幸福。人们购买金色护身符以祈求好运和财富增长。江户时代以来,金色护身符逐渐普及,特别是在经济活动繁忙的区域,金色象征富裕,是祈求家庭和睦与生意兴隆的象征。在许多日本祭典上,参与者会穿上蓝色法被(はっぴ),法被通常采用靛蓝色,象征冷静和强大的集体精神。蓝色法被让人感到冷静和团结,特别是在一些战后祭典中,蓝色法被被视为重建和信念的象征。靛蓝色让人联想到大海和天空,传递出坚定信念和长久的坚持。

在端午节,日本人会在门前插上绿色的菖蒲(しょうぶ),并在浴池中放入菖蒲叶泡澡,称为“菖蒲汤”。菖蒲的绿色象征生命力和活力,据说菖蒲叶的香气能驱邪避灾,保护家人健康。这个习俗源自中国,传到日本后成为家庭成员为彼此祈福的重要活动。在丰收节期间,人们会准备黄色的小吃,如黄豆饭,以祈求来年的丰收。黄色小吃不仅美味,还寓意着金黄的稻谷和丰收的希望。盂兰盆节期间,人们会点燃青色灯笼,并用青色花朵装饰祭坛。这些青色元素代表着清凉与宁静,让灵魂感受到归属感。

日本人对红色与白色的喜爱由来已久,据说其起源与一场日本有名的权力争夺战争——源平合战有关。源平合战发生于平安时代末期的1180年至1185年,是源氏和平氏两大武士家族集团一系列争夺权力的战争的总称。它影响深远,至今仍是日本人津津乐道的话题。在这场大规模战争中,为了区别敌我,源氏使用了白色旗帜,平家使用了红色旗帜。因此,这也被视作双方对阵时,总是喜欢用“红白”来区分阵营的原因。关于红白的含义还有以下说法:红色代表新生婴儿,白色代表死亡、分别。将这两个颜色组合在一起,意味着生死,代表人的一生。如今日本影响力最大的新年节目红白歌合战就是采用红组与白组对抗的形式。

情感表达

白色在日本文化中象征纯洁无瑕、新的开始。禅宗的庭院设计中,白色砂石常用于象征海浪和山峦,白色带来心灵上的清净和空灵。参禅者坐于白色砂石构成的庭院前,感受宁静和空无,仿佛从日常烦恼中解脱出来。禅宗中讲究“无”,白色的沙石则让人体验到无欲无求的心境,促使人进入冥想和心灵的纯净状态。

黑色在日本传统文化中不仅象征神秘,还带有庄严的力量。在茶道中,黑色茶碗特别受到重视。茶道大师千利休认为黑色茶碗能更好地衬托抹茶的绿色,将观者的注意力集中在茶的本质上。黑色茶碗散发出一种沉稳而深邃的气息,让人感到平静和肃穆,尤其是在品茶时更能感受到禅意和静谧的氛围。黑色的深邃也被视为对自我的内省和对宇宙的敬畏。

靛蓝色(藍色)在日本的工匠文化中占有重要地位,象征忠诚与冷静。日本传统工匠的服装通常是靛蓝色,这种颜色具有镇静作用,帮助工匠在工作中保持专注。据传,江户时代的工匠以靛蓝色为象征,以表示对技艺的忠诚和对工作的尊重。靛蓝色服装让工匠感到安稳和踏实,在专注中追求技艺的精湛。在江户时代的武士文化中,靛蓝盔甲代表勇敢与冷静。据说一些著名的武士如武藏会选择穿靛蓝色的盔甲上战场,以冷静的心态迎接挑战。靛蓝色给人一种可靠的感觉,帮助武士在战斗中保持冷静,并让人们对其信任和尊敬。靛蓝色传达出一种稳重和不可动摇的精神力量。

在茶道中,抹茶的绿色象征自然之美和宁静心境,喝茶是一种与自然的亲密接触。茶道师傅通过抹茶的颜色唤起人们的平静心情,抹茶的绿色让人感受到来自大自然的温柔。绿色在茶道中成为连接人和自然的桥梁,参与者在绿茶的引导下,体会到大自然的宁静与和谐。

在日本神社中,金色护符象征财富和幸福,特别是在经济上代表着富贵之意。传说金色的护符能吸引好运,帮助人们实现财富和幸福的愿望。每当参拜者拿到金色护符时,会感到信心十足,仿佛未来充满光明和机会。金色象征着富足,也让人感受到一种积极向上的力量,激励着人们对未来的期望。

与自然的和谐共生

日本传统颜色的使用体现了日本人对自然的敬畏和热爱,颜色名称常常与季节变化和自然景观相关联。日本传统颜色大多来源于自然,如植物、矿物和动物。例如,“茜色”(akaneiro)来自茜草根,“群青色”(gunjou)则源于蓝铜矿。这些颜色的使用反映了日本人对自然的敬畏和热爱。颜色的命名也常常与季节变化和自然景观相关联,例如,「樱色」(さくらいろ)和「若草色」(わかくさいろ)是春天的象征,前者代表盛开的樱花,后者象征刚刚冒芽的嫩草;夏天的「空色」(そらいろ)是晴空的蓝色,而「若竹色」(わかたけいろ)则让人联想到竹林的清凉和生机。这些颜色不仅捕捉了自然的瞬间,也反映了日本人对四季的敬意,季节的更替在颜色中得到了生动体现。

春季

春天是日本樱花盛开的季节,人们会举办赏樱活动,穿着樱色的和服或浴衣,在樱花树下野餐、赏花,享受春天的美好。樱色因此成为了春天的代表色之一。樱色是以极淡的红色染料染出的颜色,象征着春天的到来。在古代,由于鲜艳的大红色需要大量染剂,较为昂贵,一般庶民们便普遍喜欢使用这个较廉价又好看的樱色。樱色让人联想到日本街头纷飞的樱花,是春天最具代表性的颜色之一。日本人对樱花有着特别的感情,认为它象征短暂和美好。传说源自平安时代的贵族会在樱花树下举行盛大的花见活动,赏花之余饮酒赋诗。樱花的粉色成为春天的象征,人们将其视为一年轮回的开始,象征着新的希望与重生。至今,樱花色仍然唤起日本人对春天的期待和对短暂美好事物的珍惜。

萌木色象征着万物复苏的春天,常用在年轻武士的铠甲上,寓意着朝气和活力。镰仓时代的《平家物语》中,就有描写平家贵公子平敦盛、源氏弓箭高手那须与一,身穿萌木色铠甲的情景。梅色是一种柔和的粉红色,象征着春天的到来和生命的复苏。梅花是冬末春初盛开的花朵,常被视为春天的使者。

山吹色是一种偏橘的黄色,来源于日本的一种植物——棣棠花。这种花的日语名就叫“山吹”,因其花色像黄金一样,人们又称它的花色为“黄金色”。棣棠花主要在春季开花,花期集中在4月到6月之间。在日本江户时期,商人们贿赂公务员的时候,会悄悄地从袖子下面把“小判”——也就是那个年代的金币——递过去,小判的颜色形状很像黄金色的小点心,于是人们会用“山吹色的点心”来代指贿赂时递过去的钱。在日本古代,有一个关于山吹花的传说:一位年轻的农夫在春天播种时,发现田野中开满了山吹花。他感到无比欣喜,认为这预示着丰收的一年。于是,他把山吹花作为祭品献给神灵,希望能得到丰收的庇佑。从此,山吹色便成为了丰收与希望的象征,在节庆和庆典中广泛使用。

据说,在平安时代,有位诗人爱上了盛开的藤花,他写下了一首动人的诗歌,以表达对藤花美丽与短暂生命的感慨。诗中描绘了藤花随风飘荡、落英缤纷的场景,让人感受到生命之美与无常。这首诗流传甚广,使得藤色成为了优雅与浪漫的象征。

在古代,日本人会在初春时进行“若草摘み”活动,即全家一起到野外采集新生的草药,用以制作祈求健康的料理。若草色的清新淡绿让人联想到春日的温暖和生命的重生,也让人们在视觉上感受到春天的生机。若草色成为春天常见的服饰色彩,尤其在和服中,年轻女性穿着若草色和服象征着青春和纯洁。

夏季

传说紫藤花的柔和色调象征着谦逊与优雅。古时一些贵族家族以藤为家徽,紫藤色成为贵族身份的象征。紫藤色的和服往往在夏初穿着,象征与自然的和谐,带给人安静和高贵的感受。紫藤色至今仍然是初夏的象征之一,藤花盛开时,人们会举行赏藤活动,象征夏日的到来。

抚子花期很长,从春到秋,但从平安时代开始,抚子色就被当成是夏天的颜色。因为夏日祭时,就有很多穿抚子花浴衣的女孩,抚子花的图案象征优雅、美丽和文静,女孩们就连腰带也会打成“抚子结”,看起来很可爱,是很适合年轻女孩的颜色。夏天,人们喜欢在水边嬉戏、游泳,水色因此成为了夏天的代表色之一。它给人一种清凉、舒适的感觉,常用于夏季和服的染色。

夏初时节,日本人喜爱在清晨欣赏嫩叶的色彩。相传古时一些贵族在夏季穿着若葉色的服饰,象征自己就像刚萌发的嫩叶,充满活力和纯洁。人们相信这种颜色可以带来新生活的希望,给人一种清新与平静的感觉。若葉色至今依旧是夏初最受欢迎的色彩之一,常用于和服和日用器具上。

青蓝色在日本夏季的染织中很常见,因为深蓝色不仅可以避开夏日炎热,还象征着水的凉爽。据说,江户时代的人们会在夏天穿青蓝色的浴衣以抵抗酷暑,尤其是火祭节这样的盛会。青蓝色让人们感到平静、清凉,仿佛置身于海边或水边,夏日的炎热在视觉上得以缓解,青蓝成为夏季舒适与清爽的象征。

秋季

桔梗花会开出紫色的花,桔梗色也因此被命名。桔梗属于“秋季七草”之一,也被称为“朝颜”。桔梗色浪漫神秘给人以无限遐想的空间。桔梗花一样的颜色,美丽的幻想空间。夏天转入秋天时,桔梗会开出像吊钟一样的紫色花,这就是桔梗色的来源。桔梗是青紫系颜色中具有代表性的传统色。女郎花是“秋季七草”之一,色如其名,是给人凉爽之感的黄色。它同样诞生于平安时代,被认为是适合秋天穿在身上的颜色。“女郎”,意指身份高贵的女性及年轻的女性,人们便用此花在秋风中静静摇曳的样子来比喻她们。女郎花色因此成为了秋天的代表色之一,寓意着成熟和优雅。

在江户时代,有一位农夫种植秋茄,每到秋天,他就会将成熟的秋茄送到市场上出售。每当他看到市场上人们因他的秋茄而露出的笑容,他便感到无比满足。他用这种深紫色来装饰自己的家,以纪念丰收的季节,并将其视为家庭幸福与团圆的象征。

据说每到秋天,京都的寺庙和神社中都会举办银杏观赏活动。人们会在銀杏树下散步或拍照,享受秋日的美景和凉爽。銀杏色的和服和装饰品成为秋季的流行,象征着丰收和成熟。人们相信银杏色会带来秋日的平静和内心的满足,带给人一种深秋的喜悦与宁静。

在古代,日本人会在秋天观赏枫叶(称为“红叶狩”),这个传统一直延续至今。平安时期的贵族们会穿紅葉色的服装前往山林赏枫,紅葉色的深红象征着秋日的美好和对自然的敬畏。紅葉色不仅代表丰收和成熟,还象征人们在秋季沉思自我、感悟生命的心境。至今,紅葉色被广泛运用于秋季和服、配饰和家居装饰上。

在古代,清晨的雾气被认为是神灵降临的标志。朝霧色带有朦胧感,平安时代贵族的和服中经常使用这种颜色来象征神秘和优雅。人们穿上朝霧色的和服,仿佛置身于秋冬清晨的雾霭中,带来一种宁静与幻想的氛围。朝霧色至今在服饰和家居中仍然广受欢迎,象征着神秘和优雅。

冬季

雪之白的纯净让人联想到安宁与静谧的冬季景象。在日本北方的冬季祭典中,人们会在雪中点亮灯笼,称为“雪灯笼祭”,象征冬日的平安。雪之白的和服常用于冬季节日,纯洁无暇的颜色象征着祈愿心灵的纯净,也提醒人们在冬季保持平和的心境。雪之白的清新色调为冬季带来一种独特的宁静氛围。

在平安时代,梅花被贵族们视为高贵的象征,女性常穿着梅色的和服,以展现优雅与品位。传说中,有位贵族女子在寒冷的冬天里,依然能在庭院中找到盛开的梅花,她将梅花插入发髻,象征着她对春天的期待与渴望。传说在古代,红梅象征不畏寒冬的勇敢精神,因此许多贵族特别钟爱红梅色的装饰。在寒冬里看到红梅绽放,被视为来年丰收的好兆头。在冬天穿上紅梅色的和服或围巾,带给人们在严寒中坚持的勇气,红梅色成为冬季服装的经典之选,充满着希望和温暖。

色彩的艺术表现

浮世绘

日本历史上有几位艺术家以其色彩运用和对日本传统色彩之美的独特诠释而闻名。

葛饰北斋(Katsushika Hokusai)的《神奈川冲浪里》(也称《巨浪》)是他《富岳三十六景》系列中的一幅著名作品。作品中使用的蓝色被称为“北斋蓝”或“普鲁士蓝”(Prussian Blue),这种蓝色是在19世纪初从欧洲传入日本的,北斋将其与日本传统的“群青色”结合,创造出深邃而浓厚的海洋色调。普鲁士蓝作为一种新材料,不易褪色,而北斋利用这一特性创作了许多色彩饱满的作品。《神奈川冲浪里》中的蓝色不仅象征大海的力量,还蕴含着一种冷静的“物哀”之美。这个蓝色在日本迅速流行起来,甚至在后来的浮世绘中成为一种独特的标志色彩,让日本传统色彩与西方材料融合。《富士山下白雨》在这幅作品中,北斋采用了丰富的黑色与蓝色调,营造出一种神秘而宁静的氛围。此画描绘了在富士山脚下,一场细雨即将来临的情景。北斋通过黑色的运用,表现出即将来临的雨云与富士山之间的对比,传达了自然变化带来的情感波动。

歌川广重(Utagawa Hiroshige)的《东海道五十三次》系列中,以细腻的色彩描绘了从江户到京都的沿途风景。广重善于表现不同季节、不同天气的微妙变化,常用“青磁色”(浅蓝绿)和“红丹色”(暗红)等传统色彩,展现自然景观的诗意之美。在《大井川》的画作中,他采用了朴素的茶色和青绿色,表现渡河时的宁静氛围,而在《大津》的作品中,他通过明暗对比,运用了“柿色”(红棕色)和“绀青色”(深蓝),赋予了夕阳下的景色一种温暖的感动。《江户近郊八景之内,羽根田落雁》这幅画描绘了雨中的田野,通过细腻的笔触和柔和的色彩,展现了自然的宁静与和谐。歌川广重善于运用色彩来表现季节和天气的变化,使画面充满了诗意。据说,广重在旅行途中会收集当地的植物样本和矿物质,用来研究颜料的配色,这些颜色在他的作品中带有明显的地域特色,传达了每个场景的独特气息。

喜多川歌麿(Kitagawa Utamaro)以描绘美人画而闻名。他善于运用柔和的色彩,常用“樱色”(淡粉色)、“薄红”(浅红)等传统色彩,赋予画中人物柔美的气质。他的作品色调细腻,表达了女性的优雅与神秘。歌麿在创作《美人图》时,非常讲究色彩的层次感。他在《妇人相学十篇》中的人物画像使用了柔和的粉色与浅褐色来描绘皮肤,细致刻画出人物的温柔。为了达到这种细腻的效果,歌麿会反复试验颜料的调和,甚至在每一笔上花费大量时间。这些颜色的选择在当时被认为非常符合江户时代的审美,优雅而低调,象征了日本女性的内敛之美。

铃木春信(Suzuki Harunobu)以柔和的色彩著称,他是“锦绘”技法的开创者之一,擅长用浅粉色、淡黄色和青绿色来营造温馨柔和的氛围。其作品《风流江户姿》中大量使用了“青藤色”(浅绿)和“薄红色”(淡粉),传达了生活的闲适与细腻情感。春信的作品强调人与自然的和谐之美,例如在《冬景》中,他用浅淡的颜色来表现雪景,配上柔和的冷调人物衣饰,画面宁静温暖。传说春信在绘画中以意境为主,选择的色彩常常是江户市民生活中常见的颜色,以便让观者产生共鸣。这种贴近生活的配色风格使他的作品倍受当时市民的喜爱,成为了浮世绘中独特的流派。

东洲斋写乐(Toshusai Sharaku)以其富有戏剧性的肖像画而闻名,他的作品以鲜明的“丹色”(朱红)和“墨色”对比见长,常常用大胆的色彩来表现戏剧演员的神情与个性。他所画的演员脸部表情夸张,通过色彩传达出一种强烈的舞台效果。写乐的《大谷鬼次的奴江户兵卫》肖像画中,采用了浓烈的丹色和黑色,增强了画面中的张力。这个配色方式打破了传统的柔和色调,凸显了歌舞伎演员的角色特征。作品中演员的表情因色彩的强化而更具戏剧性。写乐的一幅肖像画《歌舞伎演员肖像》描绘了一位著名歌舞伎演员,他穿着华丽的服饰,面带夸张表情。传说这位演员以其精湛技艺而闻名,写乐通过强烈的颜色对比和生动的表情捕捉了他在舞台上的魅力。写乐的这种风格虽然在当时不算主流,但后来被认为是极具创新性的色彩应用手法,展现了浮世绘中独特的力量美学。

菱川师宣(Hishikawa Moronobu)被称为浮世绘的奠基者,他的作品充满江户时代的风情,尤为擅长用“若草色”(嫩绿)和“山吹色”(金黄色)表现自然景色。他的绘画风格轻松写意,色彩运用丰富而不失典雅。在《游女图》中,师宣选用柔和的黄色和嫩绿色服饰,展现出一种青春活力,同时表达了江户市民的生活状态。师宣通过不同的色彩搭配,表达出人们对于自然与生活的热爱,体现了江户时代崇尚自然的生活态度。这些作品在江户时期极为流行,很多市民将其当作生活装饰画悬挂,以感受画中自然之美。

河鍋暁斎以其大胆而鲜明的颜色组合而闻名,他在鬼怪图中使用了强烈对比的红、绿、黄等颜色。《鬼怪图》这幅作品展示了各种鬼怪形象,传说这些鬼怪源自日本古代民间故事。暁斎通过丰富多彩的表现手法,将这些神秘生物栩栩如生地呈现出来,使观者感受到日本传统文化中的神秘与奇幻。

文学作品

日本文学中很多诗歌和小说中都通过颜色来表达人物的情感、暗示情节的发展,或者表现季节和自然之美。

在《源氏物语》中,紫之上与藤壶之间有着“红”与“白”的颜色隐喻。书中写道:“玉のような肌の白さが、上に何も纏っていない清らかな紅と重なり、まるで花びらのように。”(如玉般的肌肤之白与未加修饰的清纯红色相叠,如同花瓣一般。)白色象征女性的纯洁与高贵,而红色则暗示着她们的热情与真情。紫之上象征单纯、无邪的爱情,而藤壶则代表深情与难舍。红白二色构成一种对比,塑造了人物的内在美。

紫色不仅象征人物的地位和气质,更是女主角紫之上名字的来源。紫之上是光源氏心爱的女子,紫之上的名字来源于光源氏偶遇的一位年幼少女,她有着与光源氏逝去恋人相似的气质。光源氏将她视为替代,取名“紫之上”,并逐渐培养她长大成人。紫色在此象征爱情的忠诚与延续,同时暗示了光源氏无法忘怀的过去。紫色在整部作品中贯穿始终,成为古典文学中爱与哀愁的象征。女郎花是一种明亮的绿黄色,秋季的七草之一,也被称为“思念草”。自《万叶集》以来,女郎花就被写在许多诗歌中,在《源氏物语》中,有这样的描述:“帷子一重,紫苑色的花与女郎花交织的织物,重叠着,袖口微微露出”。女郎花色在这里不仅是对织物颜色的描述,还寓意着女性的柔美与哀愁。

《源氏物语》中“光源氏の衣装は、紅緋の衣に金糸で絵を描いたもので、非常に豪華で美しかった。”(光源氏的衣装是红绯色的衣服,上面用金线绣了图案,非常豪华美丽。)这段话描述了光源氏的华丽服饰,红绯色象征着贵族的尊贵和热情。“春の日、庭の山吹が咲き乱れ、その色はまるで絵巻物に描かれたような美しさだった。”(春天的日子里,庭院里的棣棠花盛开,那颜色就像画卷上描绘的一样美丽。)这段话描绘了春天的景象,山吹色象征着新生和希望。

松尾芭蕉的俳句中多次用到红色来描述秋天,他的诗句“秋深红叶树,寒露滴枝头”便是将红色与秋天的萧瑟之美联系起来。据说松尾芭蕉在旅途中,看到满山红叶,随即写下了这句俳句,以此纪念秋日的美景。他感受到红叶短暂却绚丽的生命力,将之与人类的生命比喻。这种通过颜色来传达情感的方式成为了俳句的一大特色,使得红色不仅仅是自然景物的描述,更是对人生短暂美好的反思。

《枕草子》是平安时代清少纳言的随笔集,她在其中以诗意的文字描绘了四季美景。清少纳言的《枕草子》中,以颜色来描述四季变化的妙趣。例如,关于秋天,她写道:“秋は夕暮れ。夕日の差して山の端いと近うなりたるに、烏の寝どころへ行くとて、三つ四つ、二つ三つなど、飛び急ぐさへあはれなり。”(秋天的傍晚,夕阳斜照在山巅,乌鸦匆忙飞回巢穴,三三两两,极富情趣。)在这里,“夕暮れ”意味着秋日夕阳的橙黄色调,而“山の端”则呈现山间的淡紫色,这种色彩组合象征着秋天的萧瑟与静谧,传达出一丝哀愁与思念。

在“冬はつとめて”这一章中,她描述冬天的雪景,以白色的雪来表现冬日的冷冽与静谧。《枕草子》中,金色象征着皇宫的高贵气质。她描述了平安贵族们穿着金色饰边的衣物,佩戴金饰,并在阳光下散发出耀眼的光芒。金色不仅体现出贵族的身份地位,更让人联想到平安时代奢华的生活。据说清少纳言曾亲眼目睹贵族们在日光下穿着华丽的金线刺绣衣物,光芒闪烁,仿佛一片辉煌的金色海洋。金色在平安文学中常作为地位和权力的象征,尤其在清少纳言的笔下,成为那个时代奢靡生活的象征。

在江户时代的幽灵故事集《雨月物语》中,青色(あお)常作为幽灵的象征色,具有神秘的氛围。书中的人物在幽暗的青色月光下遭遇幽灵,象征着不祥之兆和恐惧。青色不仅代表夜晚的静谧,还象征着阴阳之间的世界。故事讲述了一名年轻武士在月夜之下遇见一位美丽的女子。二人互相倾心,但翌日女子却消失无踪。武士后来得知,那名女子是一位早逝的亡灵,因夜色中青幽的月光而现身于人世。青色在此成为幽冥世界的象征,表现了江户时代人们对生死、鬼魂的深刻恐惧与尊重。

《徒然草》是镰仓时代吉田兼好的随笔集,其中黄色常被用来描绘秋季萧索之感。吉田兼好在《徒然草》中表达秋日萧索的感受:“秋の夕暮れ、黄葉の散り敷きて、何をか思ふ。”(秋日的黄昏,黄叶散落,令人浮想联翩。)秋天的黄色叶子象征着生命的衰败,同时也是思念的载体。这种色彩在夕阳下显得更为苍凉,给人一种深深的孤寂感,令人不禁思念往事,回忆故人。吉田兼好描写了深秋黄叶飘零的情景,以黄色象征生命的流逝与对往昔的追忆。吉田兼好在漫步秋日山间时,看到遍地黄叶,随即写下了思念故人的诗句。他将黄叶比作人生的末路,感叹人生如秋叶般短暂。黄色不仅是季节的体现,更是生命衰老、回顾往昔的象征,赋予作品一种深沉的哲思。

在文学作品《银河铁道之夜》中,作者宫泽贤治曾这样描绘桔梗色:“美丽的桔梗色的广阔天空下”。桔梗色在这里不仅是对自然景色的真实写照,也寓意着神秘、浪漫与无限的遐想空间。

在《万叶集》中,茜色被用来形容夜晚的月亮:“茜色的夕阳照耀着,但蝙蝠却在夜晚渡过,隐没了月亮,真令人惋惜”。这里的茜色不仅描绘了夕阳的余晖,还营造出一种淡淡的哀愁氛围。在其中一首著名的和歌中,有诗人描绘了青空下盛开的白梅花:“白梅花映衬着青空,仿佛在诉说春天的到来。”青色与白色的对比,让我们感受到自然之美。《万叶集》中的一首和歌写道:“若草の、つらつら草の、うらぶれて、戀ひつつぞ來ぬ。”(嫩草在渐渐枯萎,满怀着思念而来。)“若草”意指春日初生的绿色,象征着生命的希望和新生。这种清新的绿色在诗中表达了对过去的眷恋与对未来的期待,表现出一股生命力。绿色象征了生机,带给人们希望与勇气。

《万叶集》中“青い海に浮かぶ白い帆は、まるで雲のように美しい。”(青色的海上漂浮着的白色帆船,宛如云朵一般美丽。)这段话描绘了海上的景象,青色象征着自然和宁静。“黄金色の稲穂が風に揺れる様子は、まるで金の波が立っているようだ。”(黄金色的稻穗在风中摇曳的景象,宛如金色的波浪在起伏。)这段话描绘了秋天的丰收景象,黄色象征着丰收和富饶。

在镰仓时代的《平家物语》中,就有描写平家贵公子平敦盛、源氏弓箭高手那须与一,身穿萌木色铠甲的情景。这种颜色象征着朝气蓬勃与生命力。《平家物语》中“戦場には赤い旗が翻り、その色は血のように鮮やかだった。”(战场上翻飞的红旗,那颜色宛如鲜血般鲜艳。)这段话描绘了战场的激烈景象,赤色象征着战斗和热血。“夜空には黒い雲が流れ、その色はまるで墨を流したようだった。”(夜空中流动的黑色云朵,那颜色宛如流动的墨水。)这段话描绘了夜晚的景象,黑色象征着神秘和宁静。

《枕草子》是平安时代女作家清少纳言的著作,其中频繁提到各种颜色,如藤色梅色樱色等。清少纳言通过这些颜色描绘了自然景观和人们的情感。
在书中,清少纳言描述了春天樱花盛开的场景,她写道:“樱色的花瓣如同轻柔的雪花般飘落,给大地披上了一层粉红色的轻纱。”这种描写不仅展现了春天的美丽,也传达了对生命短暂与美好瞬间的感慨。清少纳言通过这些传统色彩让读者感受到季节变化带来的情感共鸣。

《枕草子》中“桜の花びらが舞い散る様子は、まるで夢の中の光景のようで、桜色の花びらが空に舞い上がる。”(樱花花瓣飘落的景象,宛如梦中的景色,樱花色的花瓣在空中飞舞。)这段话描绘了春天的美景,桜色象征着春天和生命。“秋の夕暮れ、桔梗色の空が美しく、その色はまるで絵のように静かだった。”(秋天的黄昏,桔梗色的天空非常美丽,那颜色就像画一样宁静。)这段话描绘了秋天的景色,桔梗色象征着秋天的成熟和宁静。

《古今和歌集》在一首描写秋天景象的和歌中,诗人写道:“红叶如火,映照着黄昏时分的天空。”传达了诗人对生命无常和美好瞬间流逝的感慨。红色与黄色之间的对比,充满张力。

松尾芭蕉在一首俳句中写道:“夏草や、兵どもが、夢の跡。”(夏草啊,武士们梦的遗址。)这里的“夏草”是绿色的象征,代表了生命的继续与时间的流逝。绿色的夏草覆盖着战场,象征着历史的遗忘与自然的无情。此处绿色不仅仅是景物的颜色,更寄托了诗人对人世变迁的感慨。

和泉式部在《和泉式部日记》中通过紫色表现内心情感,她写道:“紫草の色も、移りにけりな、いたづらに。”(紫草的颜色也渐渐褪去,变得无用了。)这里的紫色象征着爱恋的深切,但又不可避免地会随着时间而褪色。紫草的褪色暗示了感情的流逝,表现出式部对逝去情感的惋惜之情。紫色在此被赋予了象征意味,描绘出一种独特的美感和情感表达。

《小仓百人一首》中的一首和歌描述道:“逢ひ見ての、後の心に比ぶれば、昔はものを、思はざりけり。”(见面之后的思念,才发现以前的情感不算什么。)蓝色在日本文学中往往象征孤独与深沉的情感。这首诗描写了见面后的别离,令人更加痛苦。蓝色在这里象征夜色中孤寂的思念,传达出深刻的情感体验。

在江户时代的《雨月物语》中,幽暗的青色象征着恐怖与不可知的幽灵世界。例如“青い月夜、木の葉がしとしと降り、怪しげな影が忍び寄る。”(青色的月夜,树叶轻轻飘落,诡异的影子悄悄逼近。)青色在这里是夜晚的色调,神秘且带有寒意,表现出幽灵故事的阴森恐怖,烘托出惊悚氛围。青色象征着未知和阴森,具有非常强烈的心理暗示作用。

在《竹取物语》中,辉夜姬出现在竹林中的一瞬,她的容颜被描述为:“月光の如く輝く。”(如月光般闪耀。)这种描写带有金色的辉光,象征着她非凡的出身。金色象征着辉夜姬的高贵与神秘身份,也预示了她并非凡人。金色在这里表现出一种超凡脱俗的美,使得辉夜姬在整个故事中显得神圣而不可触及。

美食色彩

日本料理中强调视觉与味觉的双重享受,尤其在“和食”中,色彩搭配具有重要的美学意义。以自然为灵感来源,和食的色彩美学不仅注重色彩的和谐与层次,更是通过食材的季节性选择与自然联系。寿司、刺身等料理的颜色搭配讲究视觉上的美感,如红色的鱼肉、绿色的海苔和白色的米饭。传统甜点和糕点的颜色选择也常常与季节变化相关联。

在春天,日本料理中常见粉色和绿色的组合,象征着大地复苏的季节。例如,樱花豆腐(桜豆腐)是一道用樱花瓣制成的豆腐点心,呈现淡淡的粉色,与山菜天妇罗的翠绿色形成对比,象征春日的美丽与生机。每年春天,京都许多老字号餐馆都会推出“花见料理”,即为观赏樱花而准备的精致菜肴。樱花豆腐成为这道料理的核心,餐馆会采集盛开的樱花腌制入豆腐中,花瓣在豆腐中若隐若现,仿佛春天的气息被封存其中。传说这种料理最早源自于江户时代,贵族们常会在赏樱时品尝粉色料理,以此表示对春天的赞美。

樱饼诞生于江户时代,以红红的嫩叶与洁白的花瓣为特征的日本山樱为灵感,关东地区用面粉制皮,关西地区用道明寺粉制饼,将其裹住馅料,再用浸过盐的樱树叶包起来。樱饼的粉色樱花造型,不仅体现了春天的气息,也展示了和食在色彩上的精致与讲究。初春的点心,在包裹着馅料的糯米团上撒上青豆磨成的豆粉(莺粉),就成了莺饼,莺饼的豆粉是名副其实的莺色,即素淡的黄绿色,其左右拉长的独特造型代表树莺。莺饼诞生于安土桃山时代,相传是由丰臣秀吉命名的。

夏天的和食讲究清凉,色彩以蓝绿和白色为主,给人一种视觉上的清爽感。冷荞麦面(ざるそば)就是其中的代表,通常配以碧绿的紫苏叶或青紫苏酱油,显得凉爽宜人。此外,添上清脆的黄瓜和清淡的萝卜丝更显绿意。江户时代的京都贵族家庭中,夏天会用特制的竹制餐具盛冷荞麦面,竹筐透着清新的竹绿,与荞麦面的灰白形成和谐的对比。有传说京都的某些名门世家中,竹制器皿都是代代相传的手工制品,而他们用凉面待客不仅是美食的呈现,更是对亲友表达夏日清凉的心意。

夏日祭的夜摊上或午后的咖啡店里,刨冰作为解暑的食品很受人们喜爱。在各种刨冰口味中,草莓刨冰最受欢迎。染成了蔷薇色的白色沙冰,用它的“冰”字让人们的眼睛和舌头都“品尝”到了夏日凉爽的风味。由刨冰常用的蓝色糖浆调制而成,露草是鸭跖草科一年生草本植物,夏天开出青紫色的小花,将露草花瓣擦到纸、布上,得到的颜色就是“露草色”。露草色苏打水沁人心脾,是夏日里不可或缺的清凉饮品。

秋天是红叶(もみじ)和橙色南瓜的季节,料理中会大量使用这些带有秋日色彩的食材。例如,红叶天妇罗就是将红色枫叶裹上薄薄的天妇罗面糊,炸至金黄,象征着秋天落叶的美丽。橙色南瓜、红色胡萝卜也成为秋季料理的点缀。红叶天妇罗起源于大阪箕面市,传说这道菜已有上千年历史。每年秋天,当地人会将枫叶清洗后盐渍,再裹上天妇罗面糊油炸,以此表达对秋天的敬意和喜爱。这种料理不仅是一道菜,更是一种庆祝秋季的传统,展示了料理与自然的深厚联系。

在明治二十八年(1895年)十月拜访法隆寺的正冈子规,曾歌颂茶店端出的柿子及眼前的美景,鲜艳的亮橙色柿子似乎象征着丰收的秋天。正如它的名字“秋刀”那样,秋刀鱼身体细长,像刀子。秋天是秋刀鱼成熟的季节,眼珠清澈、鱼肉细腻、闪着钝色光泽的秋刀鱼油脂丰厚,新鲜可口。秋刀鱼最受欢迎的吃法是盐烤,烤好后挤上点酸橘汁即可食用。享受当季的秋刀鱼,是生在四季分明的日本才有的幸福。

冬天的和食注重温暖与朴素,白色和绿色成为主色调。例如,大根火锅(おでん)是一道经典冬季料理,使用白萝卜、土豆和其他根菜,白色与火锅的浅黄色汤底相映成趣,再加入青葱或海苔,点缀清新的绿色。在日本新年期间,家家户户会煮上一锅热气腾腾的大根火锅,以温暖亲友的心。据说江户时代的大阪曾有一位商人,因贫困买不起肉,只能以萝卜熬汤,但却意外吸引了许多顾客。这道朴实无华的料理因此广为流传,成为冬季日料中一道温暖人心的代表菜。

日本传统节日料理“五色饭”(五色のご飯)展示了色彩美学中的五色原则:赤(红)、青(绿)、黄(黄)、白(白)、黒(黑)。例如在新年,许多家庭会将米饭染成这五种颜色,红色的用梅子染,绿色的用青豆,黄色用蛋黄,白色保持原色,而黑色常用海苔来装饰。五色饭的传统始于平安时代,当时五色被认为象征五行(木火土金水)和五种美德(仁义礼智信)。这种色彩的组合象征着家庭的幸福与和谐,传说最早是由宫廷御厨为祈福而创造。每种颜色对应一种愿望,绿色象征健康,黄色象征财富,红色代表平安,而黑色代表除厄。至今,这一传统仍在一些节日中被保留。

精进料理是日本佛教徒食用的素食,色彩以自然色为主。常见配色为“赤”(红豆),“白”(豆腐)、“绿”(青菜)、“黑”(海藻)、“黄”(南瓜),反映了五行和四季更迭的意境。精进料理传说由一位高僧所创,他曾在山中修行,靠着采集野菜和制作豆腐度日,餐盘上五种颜色的菜肴成为他与自然和谐相处的象征。直至今日,京都等地的寺庙中仍会提供五色素食料理,象征五种禅意,也使食客在享受美食的同时感受自然的静谧之美。

传统工艺

日本传统艺术中的色彩运用以其丰富的象征性、独特的审美价值和深刻的文化意蕴而闻名,无论是和纸的素雅、漆器的深邃光泽,还是织锦的华丽色彩,都有着动人的故事和历史背景。

据说最早的和纸源于寺庙抄经需求,僧人们会在这些手工制作的素色纸上记录佛经。由于这种纸极其耐用,颜色柔和且不易变色,因此备受青睐。和纸的素雅色调让人联想到自然的质朴,特别适合传统书画和纸艺,象征着对永恒美的追求。现代也有使用天然植物染料(如茜草、靛蓝等)染色的和纸,以低饱和度的色彩呈现宁静和温柔的美感,深受人们喜爱。在夏日的祭典和秋季的收获节上,和纸灯笼被用来点亮夜晚,柔和的灯光穿过纸面,在夜色中显得格外温馨。据说在江户时期,寺庙会在盂兰盆节挂上无数灯笼,引导先祖灵魂归来,这些灯笼象征着人们对先人的思念。柔和的灯光不仅照亮黑夜,更温暖人心,成为家庭和睦的象征。

相传平安时代的贵族家族为了表现身份的高贵,特别喜爱黑漆器皿,并使用金粉和银粉来绘制家徽和吉祥图案。在武士时代,黑色象征着刚毅与力量,成为武士盔甲的主色调之一。漆器的黑红对比色调不仅展现出色彩的浓烈对比,更传达出生命的庄严感。在茶道中,漆器常用于盛放点心,因其深沉的色调能够衬托出茶道中的静谧与宁静。漆器在日本皇室和贵族生活中有着重要地位,尤其是茶道和盛宴中必不可少的器具。相传在室町时代,红黑漆器被用于茶道大师千利休的茶席中,黑色茶碗映衬着茶汤的青绿,使得茶色更加鲜明。千利休认为,黑色和红色的对比象征着“幽玄”之美,即隐藏在物象背后的深层次情感,这种色彩搭配让茶席变得格外庄重。即使在现代,日本漆器依旧常用于新年和重要节日上,红黑对比的色调寄托着吉祥与安宁的寓意。

茶道艺术兴起后,日本茶人非常喜爱青磁陶器,因为这种青绿色带有自然的气息。据说有一位著名茶人因其青磁茶碗被偶然摔坏,但他认为缺口之美象征着无常与自然,便将其用金粉修补,形成了“金缮”艺术。柿釉陶器的色调源自秋季柿子成熟时的色彩,也常用于茶道器具,带给茶人秋季的温暖之感。这些色调在日本陶器中的运用,使得陶器不仅仅是一种容器,更是一种传递四季变迁和禅意的艺术品。相传一位著名茶人千利休以追求朴素为美学核心,不求奢华,反而采用暗淡的色调来表达茶道的精神。在茶席上,使用浅淡无华的侘寂色彩器具,象征着对自然的谦卑和对时间流逝的接纳。侘寂色调的器具使参与者在品茶过程中感受到人生无常的美,唤起一种内在的平和之感。这种低饱和度的色彩一直影响着日本传统美学,成为简约与内敛的象征。日本茶碗中的“曜变天目”茶碗极其珍贵,其独特的深色釉面上有类似星辰的斑点,仿佛再现了夜空。相传这种茶碗让茶人产生宇宙浩渺的联想,成为茶道中最具禅意的器具。陶瓷器釉色的自然变化象征着对时间和无常的接受,色彩的流动感让人领略到自然的力量与美感。

织锦在平安时期成为贵族服饰的重要材料,据说每个贵族女性的衣服色彩会根据当季花卉和自然景致来选择。特别是每年春季,贵族女子会穿着绣有樱花色调的织锦和服,以展现对春天的喜爱。织锦的制作工艺复杂且考究,色彩和图案的选择象征着织工对大自然的敬仰,充分表达出日本人对“物哀”的理解,即对自然美的共鸣。织锦色彩的华丽和优雅,也让这种艺术品成为家族传承的象征。每到新年,和服的色彩选择格外讲究。例如在新年仪式上穿着红白相间的和服,象征喜庆和吉祥;在秋季的婚礼上,新娘会穿着红叶色调的和服,象征丰收和新生活的开始。和服的色彩运用不仅是服饰的美学,也是生活哲学的传达。传统的和服配色让穿着者与大自然和季节融为一体,增添了生活的仪式感。在夏日的烟火大会上,人们穿着青蓝色的浴衣,点缀以水波或萤火虫的图案,寓意夏夜的静谧与美好。据说江户时期,贵族们穿着浅色浴衣,在清晨观看盛夏的第一场烟火,深色的烟花映衬着青蓝浴衣上的水纹,仿佛置身水畔。如今,浴衣依旧是日本夏季的一大风景线,清凉的色彩让穿着者感到清爽,也象征着夏日的悠闲与活力。

藍染起源于江户时代,最初是农民和工匠的工作服,因其耐用而广受喜爱。藍染的“静蓝色”让人联想到大海和天空,充满禅意。传说在江户时代,有位染织匠人用藍染技术制作出一种独特的深蓝服饰,以象征日本人的坚韧和永恒。如今藍染仍然被认为是最具日本特色的颜色之一,深蓝色服饰被认为可以镇静心灵,带来心灵的平静与力量。

据说桃山时代,贵族家族会在屏风上绘制松树和竹林,背景以金泥涂抹,象征高贵和富饶。在战国时期,武士将屏风绘制成山川草木,展现自然的雄伟,绿青色成为象征大地和自然的颜色。屏风画中的色彩不仅装饰了室内空间,还让观赏者在视觉上感受到自然之美,唤起对四季更替的感悟。

在贵族们的香道会上,香具的颜色被精心设计,以微妙的色彩象征香气的无形与空灵。烟霞色调因其柔和的过渡色彩被广泛应用,使香道的参与者仿佛置身于云雾之中,提升了香道体验的氛围感。据说有位香道大师特别钟爱烟霞色的香具,认为这种颜色可以帮助人们在品香时放松心境,进入沉思的状态。

相传竹编制品最早源于农民和工匠的手工艺,但在茶道发展中成为茶席上不可或缺的器具。在夏季茶席上,竹编茶具的清新淡绿色让人倍感凉爽。茶道大师们认为竹子的柔韧性和清新色彩象征了生命的平衡与韧性,使得竹编器具成为茶道中的清净之物。现代日本人仍然钟爱竹编制品,认为其自然的色彩带来宁静与和谐。

和伞在节庆和婚礼上尤为常见,红色和伞被认为能带来幸福与祝福,尤其在新娘出嫁时,红伞象征吉祥如意。而在秋季的传统节庆中,深蓝色和伞搭配银杏或枫叶背景,象征着秋天的沉静美感。在雨中,和伞的艳丽色彩让人联想到晴空般的心情,增添了雨天的美感和趣味。

花道大师们在春天选用樱花的淡粉色和柳枝的嫩绿色,以呼应春日的朝气;夏日则使用白色的芍药和青绿色的枝叶,让人感到清凉和宁静。据说有一位花道大师曾在初雪天选用白色菊花、配上黑色陶瓷瓶,营造出“寒山秋水”的意境,让观者联想到冬季的寂静。通过这些柔和色调的搭配,花道不仅展现出花朵的自然之美,也象征着季节的流转与人生的无常。

每年在夏季举行的“祗园祭”中,京都街头会装饰上五彩缤纷的彩绸,象征着祝福和驱邪。这些彩绸随着风舞动,犹如自然界的灵气融入了人群,带来愉悦的氛围。据说彩绸的颜色搭配能够平衡阴阳,带来好运和安宁。彩绸在灯光下形成的绚丽色彩,成为节庆活动中最亮丽的风景,也让人们体会到生活的五彩缤纷与美好。

服饰

在日本,和服的色彩选择非常讲究,尤其是在季节和场合上,体现出“侘寂之美”,即自然、朴素而优雅的美感。不同季节、不同场合的和服颜色选择都有特定的讲究。

春季

在春季,和服通常以淡粉色、嫩绿和浅紫色为主,象征着万物复苏。樱花色(薄粉色)与嫩绿色被认为最能代表春天的生机和活力。相传,平安时代的贵族会在春天穿上樱花色的和服,与庭院中的樱花树相映成趣。

夏季

夏季气温较高,人们多选择清凉的色调,如青蓝色、浅绿色和白色。这些颜色既带来视觉上的清凉,又符合夏日的清新氛围。江户时代的夏日烟火大会上,人们常穿着青蓝色和服,搭配水纹或萤火虫图案,寓意清凉与夏夜的美好。和服上淡雅的蓝色在夜晚的烟火映照下,更显温柔与静谧。传说有一位商人穿着青蓝和服在盛夏观赏烟火,感受到沁人心脾的凉意,便认为这种配色能安抚人心,从此成为夏日经典的和服颜色。

秋季

秋天是丰收和成熟的季节,红叶色、枫叶色和金黄色成为和服的主要色调,象征丰收与生命的成熟。相传在京都的秋季祭典上,贵族们会穿着枫叶色和服,象征秋日红叶的绚丽。在一次祭典上,一位公主穿着红叶色的和服,搭配枫叶刺绣,与秋天的枫树相映成趣。她的和服在秋风中随风飘扬,犹如一片片红叶,令观者感叹大自然的美丽与无常。这种色彩逐渐成为秋季祭典上人们钟爱的和服配色,寓意丰收与幸福。

冬季

冬季常用墨色、深蓝色、雪白色等深沉色调,象征宁静与稳重,同时也能呼应冬季的冷峻。在冬季的茶道仪式上,茶道大师会穿上墨色或雪白色和服,象征雪地的洁白与寂静。相传有一位茶道大师在冬日举行茶会,穿着纯白色和服,与茶席上的白色茶具相呼应。宾客们仿佛置身于雪中,体验到一种清幽的宁静氛围。白色和墨色的搭配成为冬季茶道的经典配色,象征清净和纯洁。

婚礼

在婚礼上,传统和服多以红色和白色为主,红色象征喜庆和活力,白色则代表纯洁与庄重。这两种颜色搭配寓意幸福美满。婚礼上的新娘通常会穿着“白无垢”——纯白色的传统婚礼和服,象征对新家庭的纯洁与忠诚。婚礼仪式结束后,新娘会换上红色和服,以表达对未来生活的喜悦。据说这种习俗最早源于日本皇室,后来成为民间婚礼的标准服饰。白色的庄重与红色的热情在婚礼上相映成趣,传达出对新生活的美好祝福。

葬礼

在葬礼上,传统和服多采用深蓝色、黑色或墨色,以表达对逝者的哀思和庄重。据说这种葬礼和服的黑色象征着对生命的尊重与敬畏,也避免了色彩的喧嚣,让人能专注于悼念逝者。在江户时期,一位武士的妻子穿着黑色和服参加葬礼,以表达对亡夫的深切哀思。黑色从此成为葬礼的主要颜色,寄托着人们对逝者的哀悼与怀念。

节庆

在节庆中,和服色彩选择鲜艳多样,常用红色、蓝色、黄色、绿色和紫色等五色,象征五行、五方之意,带来喜庆与吉祥。每逢新年,人们会穿上色彩鲜艳的和服,寓意新年的好运与幸福。江户时代的庙会上,五彩和服象征五行平衡,吸引了大量百姓前来参与,认为五彩能驱邪避灾。据说一位农妇在庙会上穿着五色和服,随后好运连连,五彩缤纷的和服因此成为节庆活动中的亮丽风景线。

建筑

日本传统建筑的色彩设计深受自然环境、宗教信仰和美学原则的影响,不仅用于装饰,也带有象征意义和文化传承。

金阁寺(正式名称为鹿苑寺)位于京都,其外墙覆盖着闪亮的金箔,是“金色建筑”的典范。金色不仅与佛教的智慧和净化之意相关,还象征了足利义满作为幕府将军的权力和财富。这座寺庙建于14世纪,由幕府将军足利义满所建,最初是他的别墅。足利义满去世后,寺庙被改为禅寺。金阁寺在1950年曾遭遇纵火,原建筑完全被毁,现今的建筑是在20世纪50年代重建的,并继续使用金箔装饰,成为京都的象征之一。这种耀眼的金色让寺庙在湖面倒映下熠熠生辉。

清水寺作为京都的著名佛教建筑,其主堂悬在悬崖之上,建筑整体以白色为主色调,显得庄重而神秘。

朱红色是许多神社常见的色彩,比如京都的平安神宫、奈良的春日大社,以及遍布全国的鸟居。这种朱红色称为“丹色”,明亮夺目且极具象征性。朱红色源于天然朱砂,古代人们认为它能驱邪避灾,象征生命力和神圣性。在神社的鸟居和建筑上使用朱红色,表示敬畏和与俗世的隔离。特别是平安神宫,这座神社建于1895年,是为纪念平安京建都1100周年。神宫的朱红色外观象征了平安时代的繁荣和古都的长存。

京都伏见稻荷大社的千本鸟居,就是由一座座朱红色的鸟居紧密相连,形成了一条通往稻荷山山顶的朱红色长廊。传说中,神社的建立者为了祈求丰收与保护村庄,决定将鸟居涂成朱色,以吸引神灵并驱赶邪恶。

在宫崎县延冈市东海町海边的港神社,有一座罕见的蓝色鸟居。这座鸟居以海的颜色为灵感,采用了鲜艳的蓝色调。蓝色的鸟居通常出现在海边,以海的颜色来建造。港神社祭拜的是“龙神”,来访的人多半是渔民,他们祈求海上作业的安全和丰收。

京都岚山的野宫神社拥有一座黑木鸟居,这座鸟居以黑色为主色调,显得古朴而庄重。黑色在日本传统建筑中较为罕见,但被视为历史最悠久的鸟居色彩之一。

栃木县足利市的足利织姬神社有一座罕见的彩虹鸟居,这座鸟居以彩虹的七种颜色为灵感,采用了多彩的色调。彩虹鸟居代表了7种缘分,分别是创业、工作、学业、人生、健康、智慧和人缘。据说拜完此神社,可以觅得良缘。因此,不少游客专程前来求姻缘,希望在这座彩虹鸟居下找到属于自己的幸福。

京都龙安寺的苔藓庭院以苍翠的绿色苔藓和灰色石子铺成的“枯山水”而闻名,象征着自然的宁静与朴素。龙安寺庭院的绿色象征自然的永恒和生命的循环。苔藓的深绿展示了禅宗对生命的观察与感悟,不加修饰的自然之美让人们在庭院中感受到宁静与放松。庭院设计反映了“物哀”与“侘寂”之美。

伊势神宫被称为“日本神道信仰的最高圣地”,这里的建筑以素雅的白色和原木色为主,未经过多余装饰,显得古朴纯粹。伊势神宫的主色调遵循神道教“清洁”的原则,以原木和白色为主要色彩,象征清净无染。每20年,伊势神宫会举行“式年迁宫”仪式,将建筑物拆除后重建,保留其清新自然的外观,同时表达对神明永生不朽的信仰。这种洁白与朴素的设计使伊势神宫在众多神社中独树一帜。

奈良的东大寺以其青绿色的木梁和青铜大佛著称。南大门的屋顶使用了青绿色的瓦片,与周围的自然环境相协调,象征平和与智慧。东大寺的建造始于奈良时代(公元8世纪),当时日本正受到佛教的深刻影响。青色象征着佛教的安宁和智慧,而南大门的青绿色瓦片也被视为护佑信徒平安的象征。青绿色的大佛象征佛陀的智慧,使东大寺成为朝圣地,吸引了成千上万的信徒前来朝拜。

松本城位于长野县,被称为“乌城”或“黑城”,因其黑色的外墙而得名。这座城堡的黑色外墙在白雪映衬下显得庄严肃穆。松本城的黑色象征着防御与威慑,黑色外观不仅令人感到冷峻,还体现了武士的刚毅精神。松本城建于战国时代(16世纪),城堡的黑色设计还具有隐蔽性,有助于在战时混入夜色中,保护城堡不受侵扰。黑色的主调与周围自然形成鲜明对比,成为战国时代城堡建筑风格的象征。

高野山金刚峰寺是佛教真言宗的总本山,主要建筑以紫色为主色调,象征佛教的高贵和神秘。紫色在日本文化中是高贵之色,与佛教的庄严相契合。金刚峰寺的紫色调不仅展示了佛教的神秘性,还引导信徒在修行中追求内心的宁静与解脱。

白川乡合掌村的传统民居以原木色和茅草色为主,纯粹自然,融入群山之中。这些房屋多采用“合掌造”建筑风格,适应当地冬季的积雪,使用黑瓦来吸收阳光融化雪,有效抵御积雪,而白墙则反射阳光保持室内温暖。。白川乡合掌村的民居象征了日本人对自然的顺应与尊重。原木和茅草的色彩与大自然相融合,使村落与山林浑然一体。

姬路城以其洁白如雪的外观而闻名,被称为“白鹭城”。姬路城建于14世纪,是日本最著名的城堡之一。传说中,当城堡建成后,一只白鹭飞到城头,似乎在祝福这座城堡带来和平与繁荣。因此,白色不仅是城堡外观的重要特征,也象征着和平与安全。姬路城在历史上曾多次经历战乱,但它始终屹立不倒,成为日本文化遗产的重要组成部分。

现代融合

日本的传统色彩在现代设计中得到了创新和延续。

著名时尚设计师三宅一生(Issey Miyake)将日本的传统色彩与创新的褶皱工艺结合,创造出独具特色的时尚设计。三宅一生的“Pleats Please”系列运用了柔和的和服色彩,如“藤色”(淡紫)、“茜色”(深红)和“墨色”(黑灰)。三宅一生在设计樱花色系列时,特别注重传统色彩与现代设计的结合。他选择了樱花色作为主色调,这种色彩为时装增添了独特的韵味。三宅一生在一次采访中提到,他在创作过程中受到日本自然景观和四季变化的启发。他的服装常常采用低饱和度的颜色,传达一种温柔而优雅的感觉。

日本的传统色彩在现代设计中得到了创新和延续,成为日本设计的重要标志之一。以下是几个具体的应用实例,展示了传统色彩如何与现代设计相结合,体现出独特的和谐美感。三宅一生将“物哀”与“侘寂”的理念注入设计,使这些褶皱在穿着中展现出自然的形态变化,呼应了日本人对不完美之美的欣赏。传统色彩通过简洁、现代的服装设计得到了新的生命力。许多穿着三宅一生作品的日本人表示,穿上这些服饰仿佛是在讲述一种内敛的自我表达,而不是炫耀时尚潮流。

隈研吾是一位以使用自然材料而闻名的现代建筑师,他在设计东京的GINZA SIX购物中心时运用了“柿色”(红棕色)和“胡粉色”(白色)等传统色彩,与现代建筑的玻璃、金属结构相结合。GINZA SIX的设计灵感来源于日本的传统商铺与町屋,隈研吾希望通过暖色调的应用和木质材料的搭配,让现代化建筑与周围的传统街区融为一体,传达出一种亲切感。他的设计成功吸引了许多游客,特别是因为这种色彩搭配让人感到既摩登又富有文化气息,完美诠释了日本现代建筑中“新旧融合”的理念。

在涩谷站的再开发项目中,设计团队使用了“淡青色”(浅蓝绿)和“红丹色”(暗红)等日本传统色彩,使得这个现代化的交通枢纽不仅实用,还富有温馨感。设计团队选择传统色彩是为了让熙熙攘攘的车站更具“和”之精神,也向使用这座车站的每个人传达日本的色彩美学。涩谷站的东急百货大楼以“茶系”色调为主,具有浓郁的昭和风格,吸引了许多怀旧的日本人和游客,成为现代商业设计与传统色彩巧妙融合的典范。

无印良品(MUJI)的设计风格以简约著称,通常使用天然的木色、茶色、米色等传统色彩。无印良品的商品陈列采用“侘寂”美学,将“无印”理念与传统色彩结合,营造出朴素自然的生活美感。无印良品的创始人堤清二曾表示,他希望人们在使用这些产品时能感受到生活的本真,而这种朴素的美感来自于日本的传统色彩,这些颜色灵感来源于日本自然景观,如稻田、天空和山脉。无印良品的门店布置简约统一,让人仿佛置身于传统日式房屋之中,这种体验让许多顾客感到舒适和放松。

照明设计师西浦裕二运用和纸的“山吹色”(黄色)、“青藤色”(浅绿)等色彩,设计出柔和的灯具。和纸材质加上传统色彩,给灯具带来了温暖、柔和的光感,适合家居和公共空间的布置。西浦裕二表示,他希望通过这种设计让人们在光的映照下感受到日本的传统文化。和纸灯具被誉为“会呼吸的灯”,因为它会随着光源的强弱产生微妙的色彩变化,仿佛自然界的呼吸。这些灯具不仅装饰了日本家庭,也走向了世界各地,让更多人体验到日式传统色彩的独特魅力。

东京奥运会的会徽与视觉设计运用了大量传统色彩,如“群青色”(深蓝)和“红色”,这些色彩既现代又充满历史韵味,向全球观众展示了日本文化。奥运会的会徽设计者野老朝雄特别选用了象征忍耐与坚韧的“群青色”,以传达运动员奋力拼搏的精神,并象征日本在逆境中的不屈意志。红色象征日本国旗和太阳,群青色则象征着平和的日本精神。这些传统色彩的使用让奥运会不仅是一个体育赛事,更成为了日本文化的一次展示。

资生堂(Shiseido)以“茜色”(深红)为品牌标志色之一。茜色在日本古代象征尊贵和优雅,被视为贵族的色彩。资生堂将这一传统色彩融入现代化妆品包装,使产品既有时尚感,又充满东方韵味。资生堂的创始人福原有信特别选择茜色作为品牌的象征色,意在表达资生堂高贵优雅的品牌形象。茜色不仅在日本广受欢迎,甚至在国际市场上也被认为是“神秘而优雅的东方色彩”,吸引了许多外国消费者。

人物和作品命名的颜色

甚三紅

甚三紅(Jinzamomi)是一种充满故事的红色,最早可以追溯到江户时代(1603年-1868年)中期。这种红色由一位名叫桔梗屋甚三郎所开发,他精通草木染的技艺,并在当时的织染市场中享有很高的声誉。甚三红的制作方法特别讲究,他使用了进口的红花(べにばな / Benibana)作为主要染料,但通过特殊的调和工艺,将红花的色泽调至更加鲜艳柔和的粉红色。

甚三红的染色方法复杂且讲究,首先要精细地提取红花的染色成分,将红花花瓣在盐水中浸泡,提取其中的黄色色素后再浸入醋水中,使得鲜艳的红色色素析出。由于红花提取工序繁复,甚三在提取过程中会不断调整花瓣的浸泡时间和酸碱度,以确保颜色不变暗。

除了红花,甚三红的染色还需要一些调和材料,如加入少量乌梅(くろうめ / Kuro-Ume)的提取物,以柔化色泽并增加色牢度。这种工艺对温度和湿度也非常敏感,因此通常需要在秋季进行染色,这样可以保持颜色的稳定性。

江户时代的京都,上流社会尤其是女性喜爱穿着甚三红色的和服。这个色彩既柔美又不过分艳丽,极适合用于日常穿着与正式场合。特别是在京都祇园(ぎおん / Gion)一带,这种红色成为了舞妓(まいこ / Maiko)常用的色彩之一。舞妓们的腰带与和服内衬经常使用甚三红,以突显青春的活力。

江户时代末期,这种颜色的应用不仅限于和服,还逐渐在其他物品中流行,如扇子、胴服(どうふく / Doufuku)等装饰品上,甚至连茶器上也偶尔会用到甚三红。

利休茶利休白茶錆利休

“利休茶”这一系列色彩源自16世纪日本茶道文化的核心人物千利休(Sen no Rikyū,1522-1591)。千利休是安土桃山时期的茶道大师,以极简主义和自然主义风格著称,确立了“侘寂”精神。这种独特的茶色因其自然、朴素的氛围而得名,以纪念千利休在茶道中的重要地位。

利休茶色是千利休对“简朴”美学的一种表现。传说他在大阪的茶室“妙喜庵待庵”中,亲自指导了壁面的色调,为的是营造宁静且谦和的空间氛围。这种颜色偏向暗褐色,带有微微的绿色调,使其与周围环境自然融合,也正是千利休推崇的色彩搭配。

梅幸茶

“梅幸茶”是一种沉稳的茶褐色调,以江户时代的著名歌舞伎演员“二代目尾上梅幸”(Nidaime Onoe Baikō)的名字命名。尾上梅幸以优雅而细腻的表演风格闻名,尤其是在女性角色的演绎上,以其温柔和稳重的风格赢得观众喜爱。这种颜色因与梅幸的服饰和舞台装饰相似而得名,成为歌舞伎文化的一部分。

在梅幸出演的舞台剧中,梅幸茶色常用于角色服装的配色,尤其在饰演成熟、内敛的角色时,以梅幸茶的沉稳色调衬托角色的端庄形象。

岩井茶

岩井茶是一种带有茶色调的黄绿色,源于江户时代晚期。这种颜色与著名的歌舞伎演员五世岩井半四郎(Goze Iwai Hanshirō)密切相关。他是当时非常受欢迎的演员,以其出色的表演和独特的外貌而闻名,尤其以“目千両”的美丽眼睛和微微突出的下唇而受到赞誉。

五世岩井半四郎在他的演出中,岩井茶颜色的服装和道具,如“岩井櫛”(Iwai Kushi)和“半四郎小紋”(Hanshirō Komon),都成为了当时流行的时尚元素。该颜色不仅在歌舞伎界受欢迎,也被广泛应用于女性和服的设计中,成为一种流行色。

璃寛茶

璃寛茶色源于江户时代的著名歌舞伎演员中村璃寛(Nakamura Rikan),他是江户歌舞伎中极具影响力的演员之一,以极富个性的舞台表现和服饰搭配闻名。他在舞台上尝试的茶色服饰后来便被称作“璃寛茶”,这种茶色在灯光下呈现出柔和的棕灰色调,与他的舞台形象相得益彰。

中村璃寛最常在京都和大阪一带的剧场中演出,他的舞台服饰由一位著名的布料匠人岛村富士(Shimamura Fuji)设计。岛村富士在当时是京都一家布料店的店主,他深谙染色技艺,以独特的调色方式染出璃寛茶的色调,成功地将这种茶色定制为中村璃寛专用的表演服。为了让这种颜色在剧场灯光下更显沉稳,岛村富士在制作染料时特别加入了少量的栗壳和茶叶提取物,使布料具有微妙的暖灰色调。

芝翫茶

芝翫茶的颜色与著名的歌舞伎演员七世坂东芝翫(Nanase Bandō Shikan)相关,他是江户时代末期至明治时代初期的知名演员。七世坂东芝翫以其出色的表演和独特的风格而受到广泛欢迎,他在舞台上的表现常常吸引了大量观众。

在七世坂东芝翫的演出中,他经常穿着以芝翫茶色为主的服装,这种颜色在当时的歌舞伎界迅速流行开来。芝翫茶色成为了许多女性和服设计中的热门选择,尤其是在上层社会和艺妓圈中,这种颜色被视为优雅和时尚的象征。

光悦茶

光悦茶(こうえつちゃ)属于一种温和且沉稳的茶色。其名称源自江户时代初期的著名文化人和艺术家本阿弥光悦(ほんあみこうえつ),他在艺术、陶艺、书法等多个领域都有显著成就。光悦生于1558年,卒于1637年,活动于京都的鷹ヶ峰地区,建立了一个汇聚众多艺术家的村落。

本阿弥光悦被誉为江户时代的文化巨匠之一,他不仅是一位杰出的书法家,还以陶艺闻名。他的书法风格独特,与近卫信尹和松花堂昭乘并称为“寛永三笔”。光悦流书法在后世影响深远。除了书法,他还与琳派画家俵屋宗達、尾形光琳等人密切合作。

光悦茶色常用于制作传统的日本茶具和陶器。例如,在京都有许多以光悦茶色为灵感制作的和傘(日本伞),其中辻倉(Tsujikura)是著名的和傘制造商之一,他们生产的和傘经常使用这种优雅的颜色。此外,光悦所设计的一些茶道具,如茶碗和茶壶,也体现了这一颜色。

宗伝唐茶

“宗伝唐茶”属于茶系颜色,通常呈现深沉的褐色或深茶色。这一色名的产生,与日本茶道史上的一位重要人物宗传(そうでん,Souden)有关。宗传是千利休的弟子之一,千利休是茶道的奠基人之一,被誉为“茶道的祖师”。宗传继承了千利休的茶道理念,并将其发展为自己的风格,尤其在茶器的制作与使用上,他注重简朴与高雅的结合。

“唐茶”指的是源自中国唐代的茶文化影响。在中国唐代,茶叶作为文化交流的载体,在日本也产生了深远的影响。宗传通过研究和借鉴中国茶文化,尤其是唐代茶器的制作工艺,创造了“宗伝唐茶”这一独特的茶器色调。这个颜色通常是深褐色、带有一些黄褐色的色调,模仿了唐代茶具上常见的釉色。

“宗伝唐茶”这一色调最早出现在与茶道相关的茶具中,特别是在茶碗、茶壶和茶盘等物品上。宗传设计或影响的茶具,通常采用这种深沉的茶色,呈现出一种温暖而雅致的氛围。特别是在“唐茶碗”(とうちゃわん,Tou Chawan)中,这种颜色的茶碗往往使用了深褐色的釉料,模仿中国唐代茶器的风格,带有一些自然的色彩变化,展现出茶道中对自然和简朴的尊重。宗传的唐茶碗不仅在颜色上采用了这种色调,还在造型上力求与茶道的精神契合,传递出茶席中的静谧与和谐。

団十郎茶

団十郎茶(だんじゅうろうちゃ,Danjūrō-cha) 属于茶系颜色,呈现一种深沉的茶色或褐色,带有些许的红色调。这一色名与日本歌舞伎界的名伶市川団十郎(いちかわ だんじゅうろう,Ichikawa Danjūrō)密切相关。市川団十郎是日本歌舞伎历史上最著名的演员之一。市川团十郎本名小田切 友右衛門(おだぎり ゆうえもん,Odagiri Yūemon),他出生于江户时代,是歌舞伎界的代表性人物之一。团十郎家族在歌舞伎历史上有着重要地位,每代团十郎的演员都以其精湛的演技和深厚的文化底蕴闻名。

团十郎不仅在表演艺术上做出了卓越贡献,他在舞台上常穿着深色的服装,尤其是深红色、紫色或茶色等色调,强调人物的威严与气质。这些服装的色调也逐渐成为与市川团十郎个人形象密切相关的颜色,而“団十郎茶”这一色名正是反映了这一点。

团十郎家族的茶色服饰多是深褐色,带有一丝红色调,通常采用“藍染”(あいぞめ,Aizome)或“茜染”(あかねぞめ,Akanezome)等传统染色技术。这些染色技艺历经世代传承,与团十郎家族的舞台服饰紧密相连。团十郎茶色最初作为歌舞伎演员的服饰色调之一,被广泛应用于剧中的大衣、腰带和其他服装上。在歌舞伎舞台上,这种颜色常常用于表现角色的身份与气质,尤其是在表现一些具有权威或历史背景的角色时。团十郎茶色的服饰被认为是一种低调而有力的色彩,能够突出演员的威严感和深沉气质。在市川団十郎主演的经典剧目《仮名手本忠臣蔵》(かなでほん ちゅうしんぐら,Kanadehon Chūshingura)中,团十郎常常穿着带有这种颜色的服饰来饰演忠臣角色,给人一种厚重且严肃的视觉印象。

吉岡染

吉岡染的起源可以追溯到江户时代,染司吉岡家族已经传承了六代。创始人吉岡常雄(よしおか ときお)在20世纪初期开始致力于植物染色技术的研究与实践,特别是在不使用化学染料的情况下,探索如何通过自然材料来再现古老的日本传统色彩。吉岡常雄的儿子吉岡幸雄(よしおか ゆきお)继承了这一事业,并在1988年接任家族生意。

吉岡幸雄是一位著名的染织史家,他深入研究古代文献和传世遗品,努力恢复和再现《源氏物语》等古典文学作品中提到的色彩。他曾表示:“我希望将这些传统技艺传承下去,不让它们在我的时代消失。”他的努力使得许多古老的颜色得以复兴。

吉岡染所使用的颜色多种多样,主要通过植物染料如紫草、红花、茜草等进行染制。这些颜色不仅在视觉上具有吸引力,还蕴含着自然界的深度和温暖感。例如,吉岡染中的“帝王紫”便是通过特殊的方法再现了古代贵族所使用的颜色。

吉岡染主要在京都进行,特别是在京都市左京区的吉岡工房。这里不仅是染色工艺的中心,也是许多传统文化活动的重要场所。该工房使用来自伏见地区地下100米深处的水源,这种水质对植物染色至关重要。

在实际应用中,吉岡染广泛用于和服、浴衣等传统服饰,以及各种工艺品中。例如,在一些重要的节庆活动中,使用吉岡染制作的服装常常成为焦点。此外,吉岡幸雄还参与了许多寺庙和神社的传统活动,为这些场合提供了专门定制的服装,如东大寺和药师寺等著名寺庙。

我在韩国首尔 KWDC24 做的技术分享

作者 戴铭
2024年10月28日 10:58

韩国朋友真是太热情了。下面是这次分享的内容,文章后面我还会记录些这次首尔的见闻。

The topic I’ll be discussing is the evolution of iOS performance optimization. I hope you can take away some insights from my talk.

Let’s first talk about a few situations where an app becomes unusable, which can be simplified into app crashes or freezes. There are three main reasons, the first being OOM, meaning memory exhaustion.

When an app consumes too much memory, the system can no longer allocate more, leading to OOM. This issue doesn’t produce crash logs, making it tricky to trace.

The second reason is a null pointer, where the pointer points to an invalid memory address. The third common issue is accessing a nil element in an array, which is another frequent cause of crashes.

These are the three most common causes of crashes, with memory issues being the hardest to resolve. Next, I’ll focus on how to address memory issues.

In addition to crashes, performance issues can also affect the user experience, such as lagging or overheating.

  • Lag can be identified through Runloop monitoring to locate the part of the stack where execution takes too long;
  • Overheating can be addressed by monitoring CPU usage in threads to find the threads or methods causing CPU overload.

Slow app startup and large package sizes also impact user experience. As projects grow in complexity, solving these problems becomes increasingly challenging.

The above four issues lead to a poor user experience.

Upon analysis, these three problems are the hardest to solve: memory issues, slow startup, and large package sizes. I will focus on sharing some of the latest solutions to these problems next.

Memory issues fundamentally stem from improper memory usage. Memory is a finite resource, and if we misuse it, problems will inevitably arise.

The most common memory issues are threefold: the first is memory leaks, where memory is not released after being used, leading to increasing memory consumption.

The second issue is high memory peaks. When memory usage suddenly spikes at a certain point, the system may trigger the Jetsam mechanism, killing the app directly.

The third issue is memory thrashing, which refers to frequent garbage collection, causing performance corruption.

So, memory leaks, high memory peaks, and memory thrashing are the most common memory issues.

To solve memory issues, the first step is to understand memory usage. We can retrieve this information using system APIs, such as mach_task_basic_info, the physicalMemory property of NSProcessInfo, and the vm_statistics_data_t structure.

In addition to APIs, Xcode’s Memory Graph feature is very intuitive, allowing you to view the app’s memory usage in real-time, making it a very handy tool.

There are also some open-source libraries, such as KSCrash, which provide freeMemory and usableMemory functions to retrieve information about the system’s free and available memory.

Using these methods, we can clearly monitor the app’s memory usage.

What may seem like a small memory leak can accumulate over time, eventually causing system performance worse or even triggering an OOM crash.

The most common cause of memory leaks is retain cycles. Here are two open-source tools that can help us detect retain cycles.

The first is MLeaksFinder. It hooks the dealloc method to check whether an object still exists after being released, thereby determining if there is a memory leak.

The second tool is FBRetainCycleDetector. It traverses strong references between objects and builds a reference graph. If it detects a cycle, it indicates a retain cycle issue.

Retain cycles are relatively easy to detect. In addition to these open-source tools, Xcode’s tools can also help us detect memory leaks in a visual way.

In contrast, memory peaks and memory thrashing are like hide “little monsters” and are harder to detect. So, how do we track down these problems like detectives?

Here’s one method: by repeatedly sampling memory usage, we can calculate the differences and identify the objects with the fastest memory growth.

Rank the top 100 objects with the most significant growth. Specifically, this can be done by hooking the alloc and dealloc methods to track the allocation and release of objects.

Each time memory is allocated, we can maintain a counter—incrementing the counter on alloc and decrementing it on dealloc—this way, we can keep track of the number of currently active objects.

With this method, we can pinpoint the objects with the fastest memory growth, making it easier for further analysis.

Next, let’s introduce hook malloc, which allows us to capture every memory management operation. It’s like planting a “secret agent” to monitor each memory allocation action.

Below are some common methods to hook malloc, including macro definitions, symbol overriding, and function attributes. The most flexible method is using fishhook, which allows dynamic toggling.

fishhook is a technique that modifies Mach-O file symbols to achieve function replacement. We can use it to replace the malloc function.

In the code above, the purpose of rebind_symbol is to replace the malloc function with our custom-defined custom_malloc function. The second parameter, original_malloc, indicates that after replacing the function, the original function will continue to be executed.

This way, with each memory allocation, through the custom_malloc function, we can capture the size and address of every memory allocation.

Additionally, the system’s built-in malloc_logger tool can also comprehensively record the memory allocation process, offering a more straightforward solution.

malloc_logger is essentially a callback function. When memory is allocated or released, it will callback and log relevant information.

By tracking malloc and free operations, we can discover memory blocks that haven’t been correctly released.

After solving memory issues, remember to retest to ensure the problem is completely resolved.

Next, let’s look at how to customize this malloc_logger function to capture memory allocation and release information.

First, define a callback function with the same signature as malloc_logger, for example, custom_malloc_stack_logger.

The type indicates the type of memory operation, such as malloc, free, or realloc; arg1 represents the memory size, arg2 is the memory address, and result indicates the reallocated memory address.

Based on different type values, we can obtain this parameter information and record memory allocation details, especially for large memory allocations. We can also capture stack information to facilitate issue analysis.

Of course, a memory snapshot is also a comprehensive solution that captures complete memory information.

First, by traversing the process’s virtual memory space, we can identify all memory regions and log information like the start address and size of each region.

Using the malloc_get_all_zones function, we can retrieve all heap memory regions and analyze each region’s memory nodes one by one, ultimately identifying memory reference relationships.

With this more comprehensive information, we can resolve memory leaks, optimize memory usage, and prevent OOM crashes in one go.

Here is a code example for finding all memory regions. As you can see, the vm_region_recurse_64 function’s info parameter contains information like the memory region’s start address and size.

Using this information, we can construct a memory layout map to analyze the app’s memory state when issues occur, such as using the protection property to check if the app accessed unreadable or unwritable memory regions.

Compared to other methods, the benefit of malloc stack logging is that it automatically records data without needing to write code manually to capture memory information. You just need to enable it when necessary and disable it when not.

MallocStackLogging records every memory allocation, release, and reference count change. These logs can be analyzed with the system tool leaks to identify unreleased memory or with the malloc_history tool to translate stack IDs in the logs into readable stack trace information.

Here is an example code for using MallocStackLogging. We can use the enableStackLogging function to enable logging, disableStackLogging to disable logging, and getStackLoggingRecords to retrieve current memory operation details.

In the enableStackLogging function, turn_on_stack_logging is called to enable logging. disableStackLogging calls turn_off_stack_logging to disable logging. getStackLoggingRecords calls mach_stack_logging_enumerate_records and mach_stack_logging_frames_for_uniqued_stack to record the details of current memory operations.

The tools we used earlier, leak and malloc_history for analyzing MallocStackLogging logs, both come from the malloc library. The malloc library provides many tools for debugging memory.

In addition to MallocStackLogging, the system offers many tools for debugging memory, such as Guard Malloc and some environment variables and command-line tools.

The MallocScribble environment variable can detect memory corruption errors.

We’ve talked a lot about how to solve problems when they occur, but is there a way to optimize memory before problems even arise?

In fact, iOS itself evolves to optimize memory management. Especially in iOS, which is designed for mobile devices without swap partitions like desktop systems, it uses the Jetsam mechanism to help developers manage memory proactively when resources are tight.

Additionally, the system provides tools like thread-local storage and mmap(), which are methods that can improve memory efficiency.

Here are a few tips to help reduce unnecessary memory overhead:

  • Take advantage of the copy-on-write principle and avoid frequently modifying large strings.
  • Use value types as much as possible to avoid unnecessary object creation.
  • Make good use of caching and lazy loading.
  • Choose appropriate image formats and control image resolution and file size.

These are some of the optimizations the system does for you, but there are plenty of areas where we can optimize as well.

A slow app launch can be a frustrating experience. We all know that this is a big issue.

App launch actually happens in several stages. The first stage is called Pre-main, which refers to things the system does before the main() function executes, like loading app code, the dynamic linker working, Address Space Layout Randomization (ASLR), and some initialization operations.

After these preparations are done, the app truly starts running and enters the UI rendering stage, where tasks in didFinishLaunchingWithOptions begin executing. These tasks include both the main thread’s work and operations on other threads.

To summarize, app launch is a multi-stage process. From Pre-main to UI rendering, tasks must be properly arranged, and neither the main thread nor background threads should waste resources.

Next, let’s talk about factors affecting launch performance. In the Pre-main stage, the number of dynamic libraries, the number of ObjC classes, the number of C constructors, the number of C++ static objects, and ObjC’s +load methods all directly impact launch speed. Simply put, the fewer, the better.

After the main() function is executed, even more factors can affect the launch time, such as main() execution time, time spent in applicationWillFinishLaunching, view controller loading speed, business logic execution efficiency, the complexity of view hierarchy, number and speed of network requests, size of resource files, usage of locks, thread management, and time-consuming method calls—all of which can slow down the launch.

As you can see, many factors influence launch time, both before and after main(). However, this also means there are many opportunities for optimization.

For large apps, which are often developed by multiple teams, tasks executed at startup can change with each iteration. Therefore, we need an effective way to measure the time consumption of each task during startup to identify the “culprits” slowing down the launch, enabling targeted optimizations and checking the effectiveness of those optimizations.

Common measurement tools include Xcode Instruments’ Time Profiler, MetricKit’s os_signpost, hook initializers, hook objc_msgSend, and LLVM Pass.

Next, I’ll focus on hook objc_msgSend, which can record the execution time of each Objective-C method. For measuring the execution time of Swift functions, you can use LLVM Pass, which I’ll explain in detail when we discuss package size optimization.

By hooking objc_msgSend, we can record method call information, including method names, class names, and parameters. By inserting tracking code before and after method execution, we can calculate the execution time of each method.

The specific approach is to first allocate memory space for jumping, with the jump function being used to record the time. Then, save the register state: the x0 register can obtain the class name, the x1 register gets the method name, and the x2 to x7 registers can be used to get method parameters.

After completing the jump function call, restore the saved registers and use the br instruction to jump back to the original method and continue execution.

Although hook objc_msgSend uses assembly language, it’s not too complicated to write as long as you understand the roles of several registers and how the instructions work.

Next, I will introduce ten very useful startup optimization strategies:

  1. Reduce the use of +load methods.
  2. Reduce static initialization.
  3. Prefer static libraries over dynamic libraries to reduce the number of symbols.
  4. Control the number of dynamic libraries.
  5. Use the all_load compiler option.
  6. Perform binary reordering.

After the main function, we can do a lot more optimization, such as:

  • Optimizing business logic.
  • Using task scheduling frameworks to arrange tasks more efficiently.
  • Leveraging background mechanisms to handle non-essential tasks.
  • Refreshing regularly to fetch server data in a timely manner.

The final important topic is optimizing package size.

Optimizing package size has many benefits. For users, it improves download speed, saves device storage, and reduces resource consumption. For developers, it lowers development and maintenance costs while improving efficiency.

Through static analysis, we can identify some unused resources and code. Today, I will focus on how to discover unused code at runtime, starting with detecting unused classes.

In the meta-class, we can find the class_rw_t structure, which contains a flag that records the state of the class, including whether it has been initialized at runtime.

The code on the right shows how to access this flag and use it to determine whether a class has been initialized.

Next, let’s discuss how to determine which functions haven’t been executed at runtime.

This code shows how to customize an LLVM Pass to instrument each function and track whether they are called. The instrumentation code is written in the runOnFunction or runOnModule functions, where the former handles individual functions, and the latter handles the entire module.

Additionally, LLVM Pass can insert tracking code before and after function execution to record the execution time of each function.

以上就是分享的内容。下面是一些见闻。

KWDC 这次是在一所大学举办的。

这是我、徐驰和 falanke 的合影,会场有个大头照机器,很多人都在这里合影。

iOSConfSG 2025 组织团队负责人 Vina Melody 也来了,我分享结束后跟他们沟通了下明年我去新加坡 iOSConf 分享的内容。

第二天,KWDC团队组织我们在首尔 City walk,第一站是景福宫,我们玩起来 Cosplay。

freddi 是喵神的同事,在福岡。

River 是韩国的一名独立开发者,开发了很有品味的 APP Cherish。她不喜欢 KPOP,但她父母好像是从事表演的。

台湾最知名的 iOS Youtuber Jane 这次也来了。

中午我们吃了鸡肉火锅。

下午去了汉江野餐。晚上我们登上南山,看到了美丽的首尔夜景。

晚上,继续找地方喝酒。韩国晚上街上人依然很多。

giginet 聊了点技术问题,他也是喵神的同事。

我是怎么做个人知识管理(PKM)的,从史前时代备忘录、Sublime,经历了Evernote,Markdown兴起,Bear的优雅,Notion革命,飞书语雀,Obsidian、Flomo,到最后使用卢曼卡片盒笔记法串起了流程

作者 戴铭
2024年9月12日 19:43

前言

前段时间,我发布的几篇和动漫相关的公众号文章《鸟山明和他的龙珠》、《圣斗士星矢的前世今生,车田正美的坚持,城户光政的阴谋》得到很多阅读和关注。有些人感叹我涉猎广,其实是我平日爱收集各种奇奇怪怪的文章和资料。今天我就跟大家说说我知识管理的心路历程。

本文会从我所遇到知识管理的困境以及如何构建自己的知识管理流程说起,然后详细聊些知识管理各流程中的笔记工具和一些笔记记录的方法理论,比如卡片盒笔记法、P.A.R.A.、费曼学习法和 CODE 等。

在信息爆炸的时代,个人信息管理已经成为不可忽视的挑战。为了高效收集、整理、消化海量信息,也出现了很多工具。从史前的系统自带备忘录、Sublime、Word、Vim,Emacs orgmode,到 Evernote、Bear、Devonthink、Drafts、Ulysses,还有很多线上笔记管理平台比如语雀、飞书,最后时依赖各种高效笔记记录方法的 APP,Roam Research、Obsidian、Flomo、Craft。还有很多很多,这些工具提供了不同的管理方式和理念。

我最开始使用知识管理是 Evernote,开始尝试使用数字化工具来管理日常信息和笔记。然而,这并不是一次一劳永逸的解决方案。随着信息量的增长,Evernote 和 OneNote 等传统笔记工具逐渐显得力不从心。于是,我开始探索更多的工具,从 Bear、Noted,再到 Notion、Roam Research、Obsidian 等更具结构化思维和知识管理理念的工具,试图构建一个完整的信息管理系统。与此同时,我也引入了 NetNewsWire、Reeder、Readwise、Pocket、Hypothesis 等工具,将外部资料(如书籍标注、文章收藏)引入我的信息生态系统中。期间,我一度搭建了一个复杂的知识系统,囊括了项目管理、信息整理、笔记复盘、阅读记录等各个方面。然而,尽管工具愈发复杂和多样,我逐渐意识到,工具本身并不能解决一个问题,这个问题是如何高效消化这些海量信息并真正转化为可以使用的知识。

在长时间的实践过程中,我发现自己渐渐陷入了“信息囤积”的陷阱。无论是微信公众号文章、头条文章,还是深思好文,我收藏了大量内容,却很少有时间真正阅读和消化。同样的情况也出现在视频资源的消化上。不断下载、收藏,而这些内容大多只是躺在硬盘里。从教程、电子书到 PDF 文档,堆积的资料让我感到压力倍增,却又无法轻松地找到一个合适的时机来打开它们。

更糟糕的是,我在各种平台上收藏的笔记,如小红书、B站、RSS中的上千条收藏内容,已然变得难以追溯。每当需要查找某些信息时,我常常陷入找不到、看不见、用不上的窘境。尽管心知这些信息很可能再也不会有用,我依然继续收藏,仿佛只要“拥有”它们,就能够缓解对信息错失的焦虑。

在面对大量囤积的信息时,我逐渐意识到,真正的问题并不在于收集,而在于消化。收集信息的过程可能只需要短短几秒,但要真正理解并内化这些信息,则需要更多时间和精力。这种“藏品如山积,消化路漫漫”的现象说明了信息处理的关键并不在于收集多少,而在于如何高效地使用这些信息。

最早的时候,我的资料整理方式相对简单,按项目划分文件夹,将所有相关资料、需求和产出放在一个地方。当我遇到类似的项目时,会从旧的文件夹中查找资料。然而,随着项目增多,文件夹中的资料也越来越多,查找变得愈加困难。后来,我开始使用系统自带的备忘录,将日常遇到的可能有用的信息随手记录下来,但随着时间的推移,备忘录中的信息数量爆发式增长,最终变得难以管理和检索。

随着项目工作的减少,我更多地参与到已有项目的维护中,积累的信息越发显得冗余。尤其是长期在特定领域的深入学习,我不断发现许多新的、感兴趣的内容,但缺乏机会去实践和应用,于是这些信息就一直沉积在备忘录中。时间一长,这些信息变得难以追踪,也无法有效利用。

为了应对不断增长的信息压力,我决定将注意力从“收集信息”转移到“使用信息”上。我认识到,仅仅依赖工具本身是不够的,重要的是建立起一个高效的系统来帮助我处理这些信息。为此,我还开发了一款小册子 APP,并在其中加入了资料整理的功能。这个系统让我能够更好地分类、归档、回顾和消化资料,且不再依赖多个工具来切换工作流程。

与此并行,我调整了信息管理的方式。与其简单地收集信息,我更加注重系统化的消化流程。通过设定清晰的目标和计划,我能够优先处理对当前工作或生活最有价值的内容。那些暂时无用的信息则被归档存储,并定期进行复盘和清理。这一优化使得我能够减少信息焦虑,从“拥有大量信息”的负担中解脱出来,真正做到高效利用和整合信息资源。

我是怎么用小册子 APP 做知识管理的

我的知识管理流程分为两个并行的部分:日常收集和项目整理。

在日常的知识收集中,我的重点是快速、便捷地记录信息。无论是看到的文章、读到的书籍、自己的灵感,还是电影的感悟,我都会将它们整理成卡片并录入到我的知识管理工具中。我根据不同领域为这些卡片设置分类,比如有Apple技术、计算机科学、艺术、生活,还有当前项目。如果收集的信息和我正在进行的项目相关,我会为其打上一些粗力度的标签,以便日后整理。收集的过程非常高效:对于文章,我通常只需贴上 URL,程序会自动解析出标题;对于灵感,我直接在文本框中输入内容;书籍和电影则通过豆瓣链接快速录入。这样的操作使得整个录入过程简单而快捷,极大提升了我记录知识的效率。

与日常收集相对应,我在整理项目时会更为仔细。这一流程是对收集到的卡片进行进一步的分类和消化。我通常会从项目相关的标签入手,深入研究卡片的内容,并对其进行精细化分类。与日常收集阶段相比,在这个阶段我会为卡片设置更多、更细致的标签,并剔除重复或无用的信息。此外,已经消化并应用过的卡片会被归档,作为日后的参考。归档会沉到对应标签列表的最下面而不是移到另一处,方便以后的回顾。

标签管理是我知识整理中的核心要素之一。我采用纯文本的方式来管理标签,这与许多其他知识管理应用的标签管理方式不同。选择这种方式的原因在于文本标签具有极大的灵活性,特别是在标签数量众多的情况下。我可以通过简单的文本编辑,避免不相关领域的标签干扰到我当前的项目整理。当某个标签不再需要时,我可以直接从文本中删除,存入一个独立文档,待日后需要时再复制回来。这样的操作不仅简化了标签管理流程,也保证了标签系统的灵活性和可操作性。

标签的顺序管理同样依赖于文本编辑,这种方式虽然简单,但极为高效,特别是当需要对标签进行大规模调整时,文本编辑可以轻松完成。

在知识被消化和分类后,我会将其以树状结构呈现在我的知识手册中。这种结构清晰地展示了知识点的层次和相互关系,使得我在后续学习中能够快速定位到相关知识点。

在手册中的每个知识点右上角都有一个“相关资料管理”的按钮,点击该按钮,我可以将新的卡片与当前的知识点关联。这个功能不仅帮助我巩固已有的知识,还为未来的知识再生提供了条件。新的卡片在合适的时间会被重新整理并融入到现有知识体系中,进一步完善我的知识结构。这一过程形成了一个持续的知识再循环,确保了知识的更新与扩展。

在处理和管理个人知识时,我曾遇到一个常见但令人困扰的问题:很多资料的链接在我回顾时已经失效。尽管我尝试通过标题重新搜索这些内容,往往还是一无所获。这让我意识到,单靠链接保存是不够的。链接失效并不是个例,它反映了一个更广泛的互联网现象——“数字衰变”。皮尤研究中心(Pew Research Center)的一项最新研究揭示了互联网内容消失的严重性。研究表明,2013 年发布的网页中,有 38% 在十年后的今天已经无法访问。这些失效的链接覆盖了多个重要领域,包括政府网站、新闻平台、维基百科和社交媒体等,广泛而深远地影响了信息的持久性和可访问性。尤其值得注意的是,新闻网站的失效率达到了 23%。这些数据表明,即使是高流量、重要性较高的网站,也不能幸免于这一问题。这不仅限于普通的网络内容,连维基百科这样的全球最大在线百科全书中,也有 54% 的页面存在至少一个失效的引用链接。对于依赖这些引用来源的用户而言,失效的链接让信息的可靠性大打折扣,无法再追溯到原始的参考资料,这在知识传播和信息验证中是一个严重的问题。

为了应对这个问题我尝试了直接保存网页、转为 Markdown、保存为 PDF等方式,这些方式的痛点是保存和管理会耗费精力,最后我在小册子 APP 中实现了一键保存,这样内容会离线存在本地,无网情况也能看,避免了网页所属平台关闭后无法访问的问题。落得一个安心。

下面我会详细具体的说下我对个人知识管理的理解,各种工具以及一些方法论的介绍,讲讲我是怎么一步一步形成如今的的个人知识管理工作流的。

也不知道你现在处在什么样的时期,只是希望下面的内容能够引起你的共鸣。

PKM 是什么,为什么要有它

个人知识管理(Personal Knowledge Management,简称 PKM)是当今信息时代的热门话题。随着信息量的爆炸和数据的迅猛增长,如何有效管理、组织和利用这些知识成为每个人必须面对的问题。PKM 的核心不仅仅在于工具或系统,更在于思维方式和对信息的主动掌控。

工具无法决定输出,驱动力才是关键

工具本身并不能决定最终的输出效果。正如有人所言,即便只有纸和笔,依然能够达到想要的结果。工具的作用在于让你在输出的过程中减少痛苦,降低摩擦成本,从而让你更专注于核心的知识管理任务。然而,真正促使个人持续进行知识管理的并非工具本身,而是内心深处的兴趣和热爱。

例如,一位绘画爱好者可能在寒风中等待数小时,只为等待一张在夕阳照射下绝美的风景;一位跑步爱好者可能在跑步中,在风拂过面颊的瞬间不自觉地微笑。这些经历带来的内在反馈远比任何打卡、点赞等外部激励更能促使人坚持。

大脑擅长思考,但不擅长记忆

在个人知识管理的过程中,理解大脑的功能是关键。大脑擅长复杂的思考和逻辑推理,但并不擅长记忆大量的信息。这也是为什么建立一个有效的知识库如此重要。知识库擅长存储和记忆,让我们从记忆琐碎信息的负担中解脱出来,腾出更多精力进行创造性思考。

PKM 的目标是为大脑减轻负担,让知识和信息在需要的时候能够快速、准确地被调取,而不是依赖于记忆。通过构建一个系统化的知识库,我们可以将大脑从信息存储的压力中解放出来,专注于思考和创造。

PKM 中的常见争论:标签、文件夹与反向链接

在 PKM 的讨论中,关于工具和方法的选择也充满了争议。诸如“标签与文件夹之争”、“反向链接的价值”这类话题已是老生常谈,甚至已经进入了学术研究领域。而一些相对冷门的议题,如“笔记大小及其优缺点”则鲜有人提及,只有少数来自 Zettelkasten 用户的讨论。

事实上,PKM 领域内的大多数讨论都集中在如何组织和呈现知识这一点上。不同的知识组织方式适用于不同的使用场景和个人习惯。例如,反向链接可以增强知识的关联性,而文件夹和标签系统则更加适合线性思维的人群。然而,无论选择哪种方式,重要的是找到适合自己的组织方式,并坚持使用。

信息膨胀与管理挑战:数量是最大难点

PKM 面临的最大挑战之一是信息的数量。在我们日常生活中,信息膨胀的现象也随处可见。读者只需简单查看自己关注的公众号数量和近三个月的推文量,便能直观感受到信息的急速增长。虽然我们可以通过筛选优质信息源来精简管理,但随着时间的推移,信息依然会逐渐积累,形成庞大的知识库。现代社会中,每个人每天都会面对大量的信息涌入,如何筛选、存储并最终利用这些信息是一个巨大的挑战。一个简单的例子是,假如有一名喜好读书的人有一百本书时,可能通过简单的记忆便能轻松找到某本书的位置。然而,当这个数字扩大到万本时,如果没有一个有效的管理系统,几乎不可能在短时间内找到所需的书籍。

更多知识管理的研究可以参看这个 Github 上的仓库 Awesome Knowledge Management

工欲善其事,必先利其器

下面我们继续说说这些适合知识管理的工具们。

工具

工具的使用我觉得没必要被一个工具的各种功能束缚了,一个工具可能只有一个比其他工具好用的,那么你就用那个最好的功能就可以了。工具是可以组合起来用的,因此多了解些工具也没啥坏处。

史前时代的 Windows的记事本、Mac的备忘录、Word等。因为我是做开发的,所以我还会用 SublimeText、CotEditor、Emacs orgmode 等开发用的编辑器来写笔记,这些软件对大文件的支持都很好。Evernote 的出现带来了笔记整体管理的风潮,随之诞生出 Onenote、有道云、为知笔记等笔记应用。Markdown 格式笔记的流行,出现了 Typora、Notable、Ulysses 和 Bear 等 App。最后我还是留下了 Bear。主要是 Bear 的一些特色我很喜欢,无需特别了解 Markdown,即可自然使用。折叠功能支持对象有各级标题、次级列表、待办清单。图片大小调整,图片本地存储。简约美观。

飞书、语雀、Confluence、Thoughts 这样的将内容放在云端的应用解决了笔记同步的问题,开始流行起来。
Craft、Wolai、FlowUS 等类 Notion 应用,将笔记更加数据库化了,而且自定义能力也很强。功能不断迭代丰富,感觉就跟 CMS 系统一样。这样吸引了很多对定制要求高的用户。后面还出现了白板类 APP,比如无边记。完成了石板、黑板、白板再到数字画布的进化。

笔记记录法的流行诞生了 Roam Research、Obsidian 和 Flomo 这些主打方法论的 APP,有主云端的也有主本地的。

下面是按照在特定场景下工具组合使用来看。

日常信息获取时,对于聚合信息,会使用 RSS 订阅软件还有网页收藏夹。热点事件通过知乎、微博、X 等平台。艺术我通常会通过订阅和关注一些账号来发现自己喜欢的作品。一些期刊文献使用 Zotero,Calibra 来管理,MarginNote 标注,然后 obsidian 整理笔记。杂志书籍用 Apple Books,漫画用可达漫画。图片管理用 Eagle。

一些会议笔记用 Notability 或 GoodNotes,研究型的用 Obsidian。知识回顾用 Anki。提纲用 MindNode

另外还有些精品 APP 也值得一试:

  • DEVONthink - 官网(买断制,macOS & iOS)老牌知识管理软件,支持储存文件、管理档案、撰写笔记与文章,具备AI功能。
  • Heptabase - 官网(订阅制,macOS)由卡片、白板、文字编辑器组成的生产力工具,易于组织内容并建立关联性。
  • OmniFocus - 官网(买断制/订阅制,macOS & iOS)老牌GTD软件,支持拆解复杂任务,设定不同情境下的任务显示。

绝妙的个人生产力(Awesome Productivity 中文版) 里面很全面按分类的列出了各种工具。

接下来我打算按照输入、整理、消化、输出这个我使用的流程再详细说说。

输入

有效的信息输入不仅依赖于优质的信息源选择,更需要我们有意识地简化、系统化管理这些信息。通过借助合适的工具、培养良好的习惯,以及采用像卡片写作法这样的整理方式,我们可以在信息洪流中建立起属于自己的知识体系。这不仅能帮助我们在日常学习中游刃有余,还能为我们的长期成长打下坚实的基础。

二八原则(帕累托原则)告诉我们,80%的结果源自20%的输入。这一原理不仅适用于经济和管理,还可以延伸至学习和知识管理。当我们回顾积累的信息时,真正能够帮助我们成长的往往是少部分重要的内容。因此,做笔记时,要学会识别和提取关键信息,而不是事无巨细地记录一切。因此,选择优质的信息源,减少无效信息的输入,是优化信息输入的重要第一步。

优质的信息源不但能节约时间,还能提高信息的深度和可靠性。比如一些报纸杂志等传统媒体,此外,书籍、播客也是不可忽视的信息来源。了解信息的来源,并确保其权威性,不仅有助于提升知识质量,还能在引用时提高信息的可信度。

有效简化输入,提升思考深度。

过多的输入常常会稀释我们的注意力,使思考变得浅显。因此,简化输入变得尤为重要。我们需要有意识地控制信息的广度,从而为深度思考腾出空间。输入广度的减少,能帮助我们集中精力,深入思考。举例来说,在订阅新闻和学术论文时,关注核心主题的深度研究,而不是试图了解每一个细节。同时,保持思维的“留白”也是一个关键策略,为深入思考留下空间,而非被无尽的信息所淹没。

从不同渠道获取信息。

信息的输入并不仅限于文字或数据。灵感和想法有时会突然出现,或来源于日常生活中的对话与观察;我们还可以从社交媒体的信息流中获取某些时效性较强的观点。

播客资源近年来逐渐成为获取深度信息的热门选择,其中不少优质的中文播客资源都提供了非常多元的视角。下面是一些播客资源的整理:

还有一些 NewsLetter 资源的整理:

RSS 订阅同样是不可忽视的工具,可以通过 RSS Source 发现感兴趣的订阅源,并通过 Inoreader 等工具将其整合起来,进行高效的管理。

信息的有效管理需要借助工具。我使用了多个工具,结合它们的优势来实现系统化的收集与整理。例如:

  • CuboxNetNewsWire 是我常用的 RSS 阅读工具,能帮助我快速筛选并收集有价值的信息;
  • Readwise 用于同步和高亮阅读笔记,并可以将微信读书、Calibre、Apple Books 等平台的内容整合起来,方便跨平台管理;
  • DraftsThe Archive 则是用来记录和存储灵感的利器,特别是当灵感突然出现时,可以随时记录下这些碎片化的想法。

信息的收集只是第一步,吸收和整理才是信息输入的核心。无论是通过写作来总结,还是通过复盘来回顾,都可以帮助我们将碎片化的信息转化为系统化的知识。比如,卡片写作法对我来说是一种极其有效的方式。通过将思考过程拆解为一张张小卡片,每张卡片涵盖一个明确的主题或观点,降低了思维的门槛,使写作和总结变得更加轻松。原子笔记(Atomic Notes)的概念正是建立在这种写作方式之上,每个笔记单位仅涵盖一个明确的信息点,既独立又简洁,便于日后整理和复盘。

这种方法不仅适用于写作,还能帮助我们管理思维和时间。每次积累一点点,最后汇聚成千字的成果,这种日积月累的方式既高效又能产生实质性的进展。就像我们刷短视频时,总想着“再看几条”,结果一不小心,花的时间比看一部电影还多。因此,小步积累能带来长远的收获

除了系统化的信息输入,我也培养了一些帮助维持效率的小习惯。例如,每天早上起床就喝水;步行时会戴上耳机开始听播客;坐地铁时打开读书 APP;到办公桌上第一件事是写下接下来要做的事项。这些小习惯不仅能帮助我保持良好的时间管理,还能使信息输入变得自然。

笔记记录方法

对于想要长期保存的信息,笔记的撰写方式至关重要。一种极简的记录方法是只回答一个问题:「读了这篇文章,我最大的收获是什么?」这一方法看似简单,但实际上蕴含着深刻的洞察力。它通过提炼出文章的核心要点,既能帮助建立记录习惯,也要求对信息进行深入思考和概括。

笔记记录方法可以分为两大类:注重笔记静态结构重视笔记动态结构。前者主要关注笔记的层次和内容的组织形式,后者则更强调笔记之间的联系和思维的流动性。常见的静态结构笔记法包括康奈尔笔记法、麦肯锡笔记法、大纲笔记法、子弹笔记法和一元笔记法。这些方法往往以明确的层次结构、分类条理为特点,适合用于总结、归纳和整理知识点。

近年来,随着知识管理需求的变化,越来越多的人开始探索更加灵活、动态的笔记方式,强调想法的关联和思维过程。这种笔记法侧重笔记内容之间的关联性,以构建知识网络为核心,代表方法包括渐进式归纳法卢曼卡片盒笔记法。渐进式归纳法提倡逐步丰富笔记内容,确保笔记随着时间的推移不断演变和扩展,而卢曼的卡片盒笔记法则通过卡片之间的连接,构建出高度关联的知识网络。相比静态结构笔记法,这些方法更符合做笔记的理念,即在记录过程中不断推动思维深化和知识创新。

在这种动态笔记法的基础上,许多知识工作者也在进行个性化探索,发展出适合自身需求的系统。例如,Andy Matuschak 的长青笔记系统(Evergreen Notes)强调信息的长期价值和持续更新,通过不断复盘和优化,保证知识的鲜活性;而Maggie Appleton 的数字花园(Digital Garden)则通过图像化、模块化设计,鼓励思维的自由生长和创意的涌现。

下面我具体介绍下这些方法。

卡片盒笔记法

卢曼卡片盒笔记法,由德国社会学家尼可拉斯·卢曼(Niklas Luhmann)首创,是一种旨在捕捉想法、促进思考和提升写作效率的笔记方法论。卢曼通过这种方法积累了大量的学术成果,出版了50本专著和600篇论文,而这一切的背后离不开他所依赖的笔记系统——卡片盒笔记法(Zettelkasten)。卢曼卡片盒笔记法不仅是一种笔记方法,更是一种思维方式。通过卡片之间的非线性关联,它帮助用户构建属于自己的知识网络,实现从知识积累到创造性输出的跃升。随着技术的发展,这一方法论已融入到现代知识管理工具中,为更多人所用。在当今信息爆炸的时代,Zettelkasten 系统无疑为那些渴望深度思考、持续创新的人提供了一条独特的路径。

卢曼卡片盒笔记法的核心概念。

卢曼笔记法的核心在于建立一个由相互关联的知识卡片组成的网络,即所谓的“思想之网”(Web of Thoughts)。每张卡片记录一个独立的想法,并通过超文本链接形式将不同的卡片联系在一起,形成非线性的知识结构。这一系统不仅帮助卢曼理清思路,更激发了新的创意,成为他与自己长期智性交流的重要工具。

卢曼笔记法包含以下几类笔记:

  1. 闪念笔记:任何突发的想法都应立即记录下来,形式不拘,记录方式灵活,但应在一两天内进行整理,以免遗忘或混乱。

  2. 文献笔记:无论是读书还是阅读文献,随时记录下不想忘记的内容,尤其是那些可能对未来的思考或写作有用的知识点。这类笔记应尽量用自己的语言进行总结,并保留参考文献信息,以便后续引用。

  3. 永久笔记:这是基于闪念笔记和文献笔记的深度思考和凝练。这类笔记力求简洁,并在卡片之间建立链接,以便于构建系统化的知识网络。

通过这些笔记的链接和积累,卢曼的卡片盒系统能够自然生长,逐渐发展出完整的主题和观点。这种有机生长使得知识网络不仅在规模上扩展,还在深度和广度上不断完善。这种系统让笔记不再是孤立的存在,而是一张由想法交织而成的知识网络。它在促进创意生成、增强记忆和理解、以及支持长期项目管理方面,展现了独特的优势。

卡片盒是卢曼笔记法的基础工具。这一系统强调笔记的独立性和相互之间的连接性,而非传统的线性笔记方式。每条笔记被视为一个原子化的知识单元,能够随时被重新组合和调用。通过这些连接,知识逐步沉淀,并从下而上形成主题,帮助用户进行深度的思考和写作。

卡片盒系统的要素有下面几个要素:

  • 原子性:每条笔记仅包含一个清晰的、独立的想法(thought)。这样的笔记结构便于信息的重新组合,并且能够灵活地应用于不同的主题。
  • 高可见性:通过笔记之间的相互连接,卡片盒系统能够迅速复现信息。每一个想法都嵌入了丰富的上下文,便于用户在需要时迅速找到相关信息。
  • 可传递性:卡片盒笔记系统中的每条笔记不仅是对信息的简单记录,它们经过深度加工,具备高度的实用性和复用性。通过打破传统笔记的“存完即弃”困境,它为未来的思考和写作提供了持续的支撑。

卡片盒系统强调非线性的关联性,这使得看似不相关的想法能够通过笔记的连接生成新的见解和创意。每条笔记独立存在,要求用户在记录和关联过程中进行深度思考。这种思考有助于增强对信息的理解和记忆。卡片盒系统特别适合处理需要长期积累的项目。用户可以随着时间的推移不断补充和完善笔记,最终形成系统化的知识成果。

卡片盒系统通过这种有机的知识生长能够在解决具体问题时,自动调整规模和结构,逐步呈现出一个自洽的思想体系。

使用卡片盒系统的流程如下:

  1. 创建笔记:每条笔记应简洁明了,并且包含一个独立的想法。为每条笔记分配一个唯一的标识符,便于后续引用和关联。

  2. 建立关联:将相关的笔记通过链接相互连接,形成网络结构。关联的方式可以是通过标识符引用,也可以是索引卡片。

  3. 维护与扩展:定期复查笔记,更新或者删除不再相关的内容。随时添加新笔记,并在已有的知识网络中寻找新的关联。

每条笔记都有其独特的结构:

  • 唯一标识符:为每条笔记提供唯一的引用地址。
  • 笔记正文:简短而独立的思想记录。
  • 参考文献:如果笔记来源于外部,需在底部注明引用来源。

卢曼在《How to take smart notes》一书中描述了他的笔记流程:
他首先记录下临时的闪念笔记,随后根据文献笔记进行深度思考,并最终撰写永久笔记。在撰写永久笔记前,卢曼会先回顾已有的相关笔记,判断是否需要撰写新的笔记或更新已有内容。这一过程本身就是信息的综合和创新的过程。

卢曼卡片盒笔记法的成功并不仅仅依赖于其系统本身,更重要的是长期的坚持与智性交流的投入。

随着 Roam Research 的发布,卢曼卡片盒笔记法在全球范围内再度引发关注。如今,Obsidian、Notion、Logseq 等众多笔记应用纷纷将卡片盒笔记法引入其中,并成为知识管理领域的重要工具。这些工具通过双向链接和图谱化笔记展示,延续并扩展了卢曼的思想之网。

尽管现代软件让笔记系统的操作更加便利,卢曼的成就依然更多源自他长期的智性投入,而非工具本身。卡片盒笔记法为他提供了持续智性交流的基础,但真正驱动这一切的是卢曼对知识的持续探索和真诚对话。

P.A.R.A.

在《Building a Second Brain》一书中,Tiago Forte 提出了一个高效的笔记组织框架——P.A.R.A.,即 Projects(项目)、Areas(领域)、Resources(资源)和 Archives(档案)。P.A.R.A. 框架为笔记提供了一个清晰的组织结构,确保我们可以系统性地管理和利用自己的知识储备。通过将笔记分类到 Projects、Areas、Resources 和 Archives,我们不仅能够有效管理当前的工作,还能为未来的项目做好充分准备。这种方法让我们不仅着眼于眼前,还能在长远的时间跨度中,更高效地利用笔记资源。P.A.R.A. 也是指导我打标签的一个很好的理论依据。

Projects:正在进行的项目

Projects 是指当前正在进行的具体项目或任务。例如,你正在学习的一门课程,或是开发中的某个产品,甚至是工作中需要处理的某项任务。任何具有明确目标和时间限制的活动都可以被归为项目。它们是动态的,随时会有进展,也会在某个时间点结束。因此,将与项目相关的笔记进行专门整理,可以让我们时刻聚焦在当下的工作目标上,避免混乱。

Areas:长期关注的领域

与项目不同,Areas 是长期关注的领域或兴趣点,这些领域没有特定的终点,而是贯穿个人发展和生活的各个阶段。例如,理财、编程、职业规划、商业等,都可以视为 Areas。每个领域下可能会有多个项目,但它们共同的特点是长期存在且没有明确的时间限制。通过将笔记归类到不同的领域,我们可以更好地跟踪和维护个人的长期兴趣与发展,确保在这些方面的知识积累和成长。

Resources:潜在的参考资料

Resources 是指那些在未来可能会用到的参考资料。它们是为了长远的项目和兴趣领域做准备,具有较高的参考价值,但并不一定会立即用到。例如,虽然你目前不从事画画相关的工作,但你可能会意识到这是一个未来会从事的领域,因此可以将与画画相关的资料和笔记归类到 Resources 中,方便将来需要时快速查阅。

这种资源型笔记的核心价值在于为未来的工作和学习打下基础。尽管当前未必立刻能用到,但这些知识储备可以帮助你在将来更快速地找到所需的信息,避免被大量无关内容所淹没。

Archives:已完成的项目

Archives 是指那些已经完成或不再活跃的项目。这些笔记虽然不再是工作重点,但它们的价值在于历史记录和参考。如果未来某个新项目需要借鉴之前的经验或成果,我们可以迅速从 Archives 中提取出有用的信息。将完成的项目归档有助于清晰区分当前的任务和过去的成果,避免无关内容干扰当前工作。

P.A.R.A. 框架中有一个核心理念:笔记应保持独立性,不应过度依赖于特定项目。也就是说,尽管笔记可能源自某个项目,它们应该具备跨项目的适用性。这种独立性意味着每一条笔记都有可能在未来的不同情境下重新发挥价值,而不仅仅局限于当前的使用场景。

知识的挑战在于其跨时间的应用。我们记录笔记,不仅是为了当前的需求,更是为未来的自己铺路。通过精心的标签和分类,我们能够为未来的项目储备知识,并迅速从过往的笔记中提取信息,避免重复劳动。

笔记管理的目标,不仅是积累知识,更是为了在需要时迅速找到相关内容。通过 P.A.R.A. 的结构化管理,我们为笔记增加了跨时空的联系。即便某条笔记当前无法用于手头的工作,它也可能在未来某个项目中发挥重要作用。而得益于 P.A.R.A. 框架的有序分类,我们可以轻松地从相关领域或资源中找到这些笔记,从而提升工作效率和知识处理的深度。

费曼学习法

费曼学习法,又称为费曼技巧(Feynman Technique),起源于诺贝尔物理学奖得主理查德·费曼(Richard Feynman)的教学风格和学习方法。费曼以其通俗易懂、深入浅出的讲解风格闻名,被誉为“伟大的解释者”。费曼学习法的精髓在于简化复杂、输出内化。它并不是简单地重复记忆,而是通过解释和简化来发现知识中的盲点并弥补这些不足,最终达到真正掌握的目标。通过这种不断迭代的学习方法,学习者不仅可以在短期内取得良好的学习效果,还能够在长期项目中构建起更加深厚的知识基础。这种方法对任何学习领域都适用,帮助我们将所学知识融会贯通,真正掌握其核心内容。

费曼能够在多个领域取得显著成就,这与他独特的学习方法密不可分。费曼曾分享了他在学习和思考中的一个技巧:他会在心中存有十几个自己感兴趣的问题。尽管这些问题不会时刻占据他的思维,但每当他看到新的研究成果或发现时,都会主动将其与自己关心的问题联系起来,看看这些新的知识能否为自己已有的问题提供答案。此外,他会将学习过程中的笔记归类到这些问题中,日积月累地丰富自己的知识体系。随着时间的推移,他的问题下的笔记越来越多,不同的知识点之间也逐渐建立起联系,这大大加深了他对问题的理解。

费曼学习法的核心思想是通过简化和输出知识来促进深度理解,避免“假学习”带来的错觉。具体而言,这一方法可以分为以下四个步骤:

  1. 确定目标并开始学习:首先,明确要学习的知识点或概念,并将其写在纸的最上方。此时,学习者应该尽可能主动地获取相关信息,学习新知识,并将其记录下来。这里的记录不仅仅是机械地抄写,而是要通过自己的语言对知识进行总结和梳理,确保内容能够被自己清楚理解。
  2. 教别人:费曼学习法的核心在于输出。为了检验自己是否真正掌握所学内容,学习者应该尝试将知识讲解给他人。最好选择一个知识储备较少的对象,比如一名小学生或者没有相关领域背景的人,确保讲解时使用简单的语言和易懂的表达。如果能让对方听懂并理解,说明自己对这部分知识的掌握已经相对深入。
  3. 遇到问题,返回学习:在讲解的过程中,学习者可能会遇到无法清楚解释或讲解的问题。这通常意味着对该部分知识的理解并不透彻。此时需要返回第一步,重新学习这一部分内容,找到理解中的盲点,并弥补知识上的缺漏,直到能够顺畅地解释。
  4. 不断简化:一旦能够顺畅讲解,学习者需要进行进一步的精简。目标是使用最简单的语言和最直观的表达方式来描述复杂的概念。通过不断简化和优化表达,学习者不仅能加深对知识的理解,还能内化这些知识,形成自己的认知框架。

费曼技巧不仅适用于短期的学习项目,也可以应用于长期、深度学习的过程中。具体步骤如下:

  1. 目标设定:首先,确定学习对象和学习目标,明确自己想要掌握的知识领域,并通过设定目标来增强专注力。
  2. 知识理解与分类:在学习过程中,将所学知识进行归类、结构化,尝试对不同的知识点进行对比、筛选,找到其中的关联性和关键点。这个过程可以帮助学习者建立一个更加系统的知识结构,提升对学习材料的掌握程度。
  3. 输出和解释:费曼特别强调了“输出”的重要性。在这里,学习者可以尝试用最简单的语言将复杂的概念解释给一个词汇量和认知水平较低的人,例如一个六年级的学生。六年级的学生只是一个象征,指的是讲解对象需要有基本的理解能力,但不能过于复杂。通过简化语言来解释知识,有助于学习者发现自己对概念的掌握是否足够深刻。
  4. 回顾和反思:在解释的过程中,学习者可能会发现一些自己没有理解透彻的地方。这时,需要对这些问题进行回顾和反思,进一步深入学习,确保对所有知识点都有透彻的理解。
  5. 内化知识:简化和反思的最终目标是内化知识,形成自己的知识体系。内化不仅仅是记住知识点,而是能够自如地运用这些知识,并灵活应对不同的情境。通过不断迭代和优化,学习者可以将复杂的概念融入自己的认知框架,达到深度掌握的状态。

CODE 外脑扩展的有效方法论

CODE(Capture, Organize, Distill, Express)方法论通过四个步骤帮助我们构建外脑(ExoBrain),将海量的外部信息转化为有价值的内部知识。

这些步骤是帮助我们管理、整理和应用外部信息的关键工具。

  1. 捕获(Capture)
    捕获阶段的核心目标是收集一切引发共鸣、激发灵感的信息。无论是阅读的文章、听到的播客、看到的有趣图片,还是日常生活中的随想,这些信息都值得被记录和保留。通过有效的捕获,我们可以避免有价值的信息流失,建立起一个丰富的知识库,随时准备后续的进一步处理。

  2. 组织(Organize)
    捕获的信息一旦过多,如果不加以整理,就会形成混乱的信息堆积。组织步骤正是为了将这些杂乱的信息进行分类、存储和管理。PARA 模型(Projects, Areas, Resources, Archives)是这一阶段的重要工具,它将信息按照项目、职责领域、参考资料和档案等四个类别进行归类。通过这种方式,知识库变得有条理,便于日后检索和使用。

  3. 提炼(Distill)
    捕获和组织后的信息并非最终形态。提炼阶段的目标是将这些信息进一步筛选、精简,提取其中最有价值的部分,形成知识的核心。提炼并不仅仅是简单地总结,而是通过对信息的深度理解,找到最为精华的内容,以便未来在不同的应用场景中高效使用。

  4. 表达(Express)
    知识的真正价值不仅在于被吸收,更在于被运用。表达步骤是整个流程的最后一个环节,也是至关重要的一步。如果我们仅仅停留在被动的输入阶段,而没有主动去使用这些知识,捕获、组织和提炼的工作都将是徒劳。表达可以通过写作、教学、项目实践等形式实现,只有在实际应用中,知识才能在大脑中内化,真正转化为自己的认知体系。

CODE方法的核心目标是增强创造力。

CODE 帮助我们通过结构化的捕获和组织,避免无序的信息输入,减轻信息过载的压力。这样,我们能够在有条理的环境中,逐步消化和处理信息,而不是被无用信息淹没。我们不需要记住每一个细节,CODE 允许我们保存所有接触到的有价值的资源,如文章、播客、书籍等,并能够在需要时快速检索这些信息。这不仅节省了我们大脑的记忆容量,也提高了信息获取的效率。通过组织和提炼,我们可以快速找到之前阅读、学习或思考过的内容。这使得信息的重复利用变得更加高效,无需浪费时间在无止境的搜索和重复整理上。CODE 的逻辑化结构不仅使信息存储更加高效,同时也支持我们进行创意工作。因为我们将更多的时间花在了想法的连接和发散上,而不是信息的整理和寻找上,这就为创新提供了更多的空间。通过表达环节不断实践,这些外部信息会逐渐内化,形成我们独特的洞见。这一过程也是费曼技巧中强调的“通过教学强化学习”的重要体现。

按网状和树状整理笔记

如何有效地组织和分类笔记内容一直是一个重要的课题。不同工具提供了不同的解决方案,从传统的文件夹结构到更加灵活的标签和链接连接方式,各自都有其优势与适用场景。

目前,笔记的连接主要有三种方式:

  1. 文件夹连接:通过树状结构将笔记放入预定义的文件夹中,适合用于管理层次清晰的内容。
  2. 标签连接:通过为笔记添加标签,将相关的内容进行分类。BearObsidian 等工具都依赖这种方式来组织笔记。
  3. 链接连接:通过双向链接建立笔记之间的直接关联,网状结构中这种连接方式尤其常见,Roam Research 等工具对此提供了强大的支持。

传统的笔记管理工具大多依赖文件夹系统,将笔记按照预设的类别和层级进行组织。然而,这种“树状结构”在面对动态、未确定的内容时往往显得过于僵化。许多用户在初期使用这类工具时会陷入这样的困境:为了将信息放置在“正确”的位置,他们不得不花费大量时间去思考如何分类,而这种预设的分类标准往往会随着知识的增加而变得不再适用。正如“即兴挥毫卡片乐,后续整理泪成河,整理之时心焦灼,犹如置身火中燎。”,过早地进行结构化可能会导致后期的整理变得困难且低效。

Roam Research 为代表的工具取消了传统的文件夹体系,允许用户在记录初期不必考虑如何归类,直接创建笔记并通过“链接”在笔记之间建立联系。这种网状结构的设计让用户在不确定分类标准的情况下也能高效地记录想法,随着时间的推移,笔记之间的关联和结构自然浮现。这种灵活性极大地提高了知识积累的效率,特别是在应对碎片化信息时显得尤为突出。

工具如 The Archive 也遵循相似的理念,主张通过建立笔记之间的相互联系来代替传统的文件夹分类。Bear 更是完全放弃了文件夹系统,转而依赖标签来组织内容。

尽管网状结构适合存储那些零散、非线性的思维碎片,例如阅读论文或文章时产生的想法,然而对于某些特定学科的系统化学习,如学习一门编程语言或阅读某本书时所产生的笔记,树状结构仍然具有重要的作用。树状结构提供了更清晰的层级和逻辑顺序,能够帮助用户按主题或学科对知识进行归类。这种结构对于那些需要线性逻辑的知识管理尤其适用,例如教程、课程笔记或研究项目等。

通过结合网状结构和树状结构,用户可以根据内容的特点选择适当的组织方式。碎片化的知识可以通过链接或标签形成灵活的网络,而系统化的知识则可以通过文件夹或目录形成层次分明的树状结构。

CraftObsidian 依然保留了文件夹体系,以帮助用户按照传统方式组织内容。Craft 甚至支持将整个文件夹导出,并将附件单独储存在对应的文件夹中,便于离线访问。

为了在网状和树状结构之间实现平衡,维护子级目录和索引是一个有效的策略。用户可以自下而上建立目录,随着笔记数量的增加,不断更新目录,确保内容的逻辑性和条理性。同时,可以通过整理相同主题下的笔记链接,建立专题索引,便于后期的高效查阅。通过这种方法,网状结构和树状结构可以有机结合,帮助用户既保留灵活的笔记结构,又能够确保内容的系统化管理。

消化

收藏是信息管理的起点,但许多人却将其误认为终点。我们习惯于囤积信息,却很少真正去消化和利用它。好比看到美食时,我们不会只将其拍照收藏,而是选择食用。而对于信息,我们却常常囤积,而不是将其内化为知识。这样的行为,像是画饼充饥,最终只能带来焦虑和无用的负担。

现代人越来越依赖于快速信息消费,如短视频、播客解说等,试图在有限时间内处理更多内容。然而,这种方式并未真正提升信息的有效处理,反而带来了更多纷扰和焦虑。大量信息快速流过,反而让我们感到空虚与不满足。信息的快速获取并不等于知识的积累。人类大脑有遗忘机制,唯有通过理解和重复,才能将信息真正转化为知识。尝试通过工具和方法论快速清理信息,只会加剧内心的焦虑感,真正的解决之道在于深度学习和反思,而非追求广度和数量。通过深入思考和识别真正的需求,才能突破信息积压。思考的过程需要时间,一周、一个月甚至一年,都是值得的。一旦想清楚了,不仅可以解决信息囤积,还可以释放心理负担,带来身心的轻松和通透。

真正的难题并不是将想法写出来或做笔记,而是如何有效地思考。思考是一种能力,需要通过控制思维形式和内容来加以锻炼。人类大脑有过滤机制,自动忽略与我们现有观念相悖的信息。这种选择性过滤是大脑的局限性,然而,它也阻碍了认知的扩展。因此,真正有效的知识管理,不只是收集信息,而是通过思考来内化新的认知。

心理学家科特勒提出,大脑的认知系统可以分为三大网络:

  1. 注意网络(Central Executive Network):专注于当前任务,帮助锁定思考的焦点,如同聚光灯一样引导我们的注意力。
  2. 想象网络(Default Mode Network):负责发散思维,产生创造力的来源。这一网络允许我们随机浮现想法,将分散的概念连接在一起。
  3. 突显网络(Salience Network):监控和评估各种想法,决定哪些值得进一步深入思考。强大的突显网络能帮助发现其他人未注意到的灵感。

这些网络的协同工作,决定了我们的创造力和思维质量。

法国社会学家布鲁诺·拉图尔指出,“不加质疑地相信或使用某个事实,会强化其合理性。”这与心理学家丹尼尔·卡尼曼的观点一致:人类大脑会将熟悉度等同于合理性,重复的观点更容易被大脑认同。商家利用这一特性进行广告营销,而我们可以通过反复引用和巩固记忆来更好地内化知识。

20世纪50年代,芝加哥大学教授布鲁姆提出的教育目标分类理论,将知识的认知过程分为六个层次:记忆、理解、应用、分析、评价和创造。学习的最终目标在于达到最高层次的“创造”,即通过对已有知识的重组,产生新的想法和模式。

第二大脑的概念,可以理解为一个外置的存储系统,帮助大脑减少负担。大脑就像计算机的CPU,而第二大脑则类似于内存和硬盘。它存储那些我们暂时不需要处理的内容,从而让第一大脑专注于当前任务。有效使用第二大脑,能让我们更高效地管理知识,提升思维的深度和广度。

有效的记忆策略,如间隔重复(Spaced Repetition),能够显著提升记忆效果。基于艾宾浩斯遗忘曲线的理念,使用如SuperMemo和Anki等工具进行渐进式阅读,能够帮助信息的有效吸收和转化。

位置记忆法(也称为记忆宫殿法)是一种古老且有效的记忆技巧。通过将记忆对象与特定空间位置进行关联,能够大幅提升记忆效果。这种方法可以追溯到古希腊时期,现如今被广泛应用于各种学习和知识管理场景中。

输出

在我们日常学习中,做笔记的根本目的,是为了提升产出力。而写作作为一种极其重要的产出形式,必须遵循一套清晰且结构化的流程。这个流程并不仅仅局限于文字的创作,它还包括了如何将生活中的灵感、阅读中的思考转化为最终的成果。

写作是一项有条不紊的工作,通常可以分为六个关键步骤:

  1. 灵感捕捉:随时随地收集日常生活中的写作灵感。这是写作的源头,灵感往往来自我们对周围环境的观察、对问题的思考,甚至是一些闪念。

  2. 阅读与内化:通过阅读他人的作品,获取知识,并将其转化为属于自己的永久笔记。这些笔记并非简单地记下原始信息,而是经过自己的思考和理解之后的产物。

  3. 思想碰撞:将新获得的知识与已有的认知相结合,在思考过程中产生新的想法。这种思想的碰撞有助于形成新的主题或独特的观点。

  4. 选题与规划:从众多灵感中筛选出可行的主题,并根据主题构思大纲,整合可以使用的素材和信息。

  5. 起草初稿:开始写作时,要允许灵感在过程中随时出现并调整原有的大纲。这是创作的灵活性所在。写作是动态的过程,初稿的产生往往伴随着对原有思路的修改和丰富。

  6. 校对与定稿:以读者和批评家的视角审视作品,修改错别字、调整不通顺的句子,并对不合理的地方进行修正和补充,最终形成定稿。

预写:从构想到初稿。

写作不只是动笔的瞬间,它的准备工作——预写,同样至关重要。Donald M. Murray 在其著作《Write before Writing》中提出,预写是正式写作之前的思维积累过程,虽然这种准备工作常常被忽视,但它实际上占据了作家大量的时间和精力。预写阶段的思考可以发生在脑海中,也可以记录在各种手稿和笔记中,这为正式写作打下了基础。

常见的预写策略包括:

  • 头脑风暴(brainstorming):快速列出可能的写作主题或思路。
  • 列提纲(outlining):框定文章的整体结构。
  • 自由写作(focused free-writing):不拘形式地写下与主题相关的想法。
  • 日志写作(journaling):通过记录日常的感悟,为未来的写作提供素材。
  • 思维导图(clustering/mapping):以图表形式呈现主题和关联内容,便于梳理逻辑。
  • 5W1H 分析法:通过“谁、什么、何时、何地、为什么、如何”来分析主题。

这些预写策略帮助作家将零散的灵感和笔记转化为连贯的写作思路,它们是从想法到初稿的桥梁。

重写:雕琢作品的过程。

写作并不只是一次性的输出,重写是其中不可或缺的部分。米开朗琪罗曾形容他雕刻《大卫》像的过程是“去掉多余的石头”,写作的重写过程亦然。重写意味着在已有的草稿基础上,进行内容的修改、优化和精炼。这是一个从粗糙到精细的过程,通过不断打磨,最终形成符合预期的成品。

写作并没有固定的模式,不同的写作方法适用于不同的创作需求。比如:

  • 雪花写作法:从一个简单的概念开始,逐步扩展和细化。
  • 分轨写作法:将不同内容模块分开处理,再合并为一个整体。
  • 细胞写作法:将文章拆分为若干个独立的细胞,再将这些细胞组织成文章。
  • 九宫格写作法:通过图表形式列出不同主题或想法,并在其中找到最具潜力的部分展开写作。
  • 卡片写作法:利用卡片记录灵感和笔记,并通过组合和重新排列形成新的写作思路。
  • 渐进式写作:逐步完善文章,从初稿到定稿,逐层递进。

无论采用哪种写作方法,最重要的理念是“写在当下”:立即写、随时写、多写。通过这种不断的输出,灵感才能真正转化为文字,积累为知识,最终实现有价值的产出。

现代科技提供了多种强大的工具来辅助写作和组织想法。其中常见的有:

  • Workflowy:这是一款列出和整理想法的工具,支持双向链接和镜像功能,非常适合处理灵感和构思阶段的内容。
  • OmniOutliner:稳定且功能强大的大纲编辑软件,尤其适用于构建工作相关的文件或复杂的文档结构。
  • iA Writer & Ulysses:这两款文字处理器适合撰写复杂的项目文件,iA Writer 因其对 Markdown 格式的完美支持、打字机滚动和专注模式,成为众多作家的首选工具。
  • PDF Expert:一款功能强大的 PDF 处理软件,支持阅读、注释、重点标记及文件处理功能,适合在写作过程中处理文档和参考资料。

特别是 iA Writer 的稳定性、开放性以及对 Markdown 格式的支持,使其在众多候选者中脱颖而出。通过直接在 Obsidian 的库路径中建立写作文件夹,并在 iA Writer 中打开,可以轻松实现写作和文件管理的统一。

以最终目的为导向的工作流

由于每个人的目标不同,所需的工作流模式也会有所区别。学生、研究者和职场人士的目标各不相同,因此他们的工作流系统对笔记和信息管理的需求也截然不同。

学生以考试为目标,强调记忆系统和概念结构的构建。他们更关注如何将信息分门别类地整理,以便在短时间内高效记忆。研究者以发表论文为目标,重点在于文献引用和观点交互,确保能够基于已有的理论框架创新和推进学术讨论。职场人士以推进工作进展为目标,重视的是决策过程中的思考和项目行动的有效推进,他们需要快速将信息转化为可执行的项目方案。

在知识管理领域,不断有新的概念和工具被创造出来,诸如常青笔记、PARA体系、卡片笔记法、双链系统、MOC等。然而,真正有效的方案并非盲目追随这些技术名词,而是在更高的层面上,独立思考自己的需求,创建一个稳定可靠的个人工作流。

笔记方法和工具都是锦上添花的辅助工具,真正重要的是我们内心对于目标的驱动力,即对自己的incentivepassion的认知。这是任何外在工具都无法给予的核心动力。正因为如此,很多人对工具的无用论产生共鸣:当一个人内心目标明确坚定时,无论使用何种工具或方法,他们都会找到实现目标的途径。对我而言,正是在明确了自己未来职业发展的目标之后,才真正有了针对性地创造自己的工作方法。这就是独立的意义所在。

因此,当你陷入迷茫,不确定工具或方法是否对你有帮助时,不妨停下来问问自己:我究竟想要做什么? 一旦你明确了这个问题,工具和方法的选择将会变得清晰无比。它们将自然而然地围绕你的目标,帮助你在各个环节实现最优效果。卢曼的卡片笔记法是一个典型的例子。在他的体系中,卡片笔记充当了项目和原始材料之间的中间层。如果没有具体的项目作为目标,笔记本身也就没有意义。笔记只有在与现实中的问题和目标相关联时,才会发挥出它的价值。

二刷 iOS 性能与编译,简单点说

作者 戴铭
2024年9月5日 16:36

本文主要想说说 iOS 的性能问题的原因,如何监控发现问题,以及如何预防和解决这些问题。

为啥要说是二刷呢,因为以前我也写过好几篇性能相关的文章。有性能优化的深入剖析 iOS 性能优化,包体积相关的GMTC 上分享滴滴出行 iOS 端瘦身实践的 Slides用 Swift 编写的工程代码静态分析命令行工具 smck使用Swift3开发了个macOS的程序可以检测出objc项目中无用方法,然后一键全部清理使用 LLVM使用 LLVM 分享的幻灯片。还有启动速度相关的App 启动提速实践和一些想法如何对 iOS 启动阶段耗时进行分析。编译相关的深入剖析 iOS 编译 Clang / LLVM

这次我尽量绕开以前谈的,只简单提提,着重说些以前没提或者说的少的。来个互补吧。也加了些前段时间去深圳给平安做分享的内容。

这次内容也整理进了小册子方便下载后按目录日常查阅,小册子程序本身也是开源的,欢迎 Clone 查看。

由于 iOS 性能问题涉及面很多,我先做个分类,这样好一个一个的说。大概顺序是会先从造成用户体验损失最大的卡顿、内存爆掉来开头,然后说下启动和安装包体积怎么优化,说说性能分析的工具和方案,最后讲讲怎么使用 Bazel 提速编译。

卡顿

先了解下 iOS 视图和图像的显示原理。

介绍

我们了解的 UIKit 和 SwiftUI 都是提供了高层次的管理界面元素的 API。另外还有 ImageView 是专门用来显示图像的类。底层是 Core Graphics,也可以叫做 Quartz,这是 iOS 的 2D 绘图引擎,直接和硬件交互。Core Animation 是处理动画和图像渲染的框架,将图层内容提交到屏幕,并处理图层之间的动画。

底层图形渲染管线 iOS 用的是 Metal。Core Animation 会将要渲染的图层内容转换成 GPU 可以理解的命令,然后让 Metal 渲染到屏幕上。

大图

最容易造成掉帧的原因就是大图。由于大图数据量较大,对应渲染指令就比较多,会影响渲染的时间,造成卡顿。可以在显示大图前,先加载并显示较小尺寸的缩略图,等用户确实需要查看高清版本时,再加载完整图片。

举个例子:

import SwiftUIstruct ThumbnailImageView: View {    let thumbnailImage: UIImage    let fullSizeImageURL: URL        @State private var fullSizeImage: UIImage? = nil    var body: some View {        ZStack {            if let fullSizeImage = fullSizeImage {                Image(uiImage: fullSizeImage)                    .resizable()                    .scaledToFit()            } else {                Image(uiImage: thumbnailImage)                    .resizable()                    .scaledToFit()                    .onAppear(perform: loadFullSizeImage)            }        }    }    private func loadFullSizeImage() {        DispatchQueue.global().async {            if let data = try? Data(contentsOf: fullSizeImageURL),               let image = UIImage(data: data) {                DispatchQueue.main.async {                    self.fullSizeImage = image                }            }        }    }}

在加载大图时使用 CGImageSource 逐步解码图片,在低分辨率时减少内存占用。

import UIKitfunc loadImageWithLowMemoryUsage(url: URL) -> UIImage? {    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {        return nil    }        let options: [NSString: Any] = [        kCGImageSourceShouldCache: false, // 避免直接缓存到内存        kCGImageSourceShouldAllowFloat: true    ]        return CGImageSourceCreateImageAtIndex(source, 0, options as CFDictionary).flatMap {        UIImage(cgImage: $0)    }}

异步绘制

系统资源方面,CPU 主要是计算视图层次结构,布局、文本的绘制、图像解码以及 Core Graphics 绘制。GPU 是处理图层合并、图像渲染、动画和 Metal 绘制。CPU 负责准备数据,GPU 负责渲染这些数据。

因此,CPU 方面需要注意过多的子视图会让 CPU 很累,需要简化视图层次。setNeedsDisplay 或 layoutSubviews 也不易过多调用,这样会让重新绘制不断发生。图像解码也不要放主线程。GPU 方面就是图片不要过大,主要是要合适,保持图片在一定分辨率下清晰就好,另外就是可以采用上面提到的大图优化方式让界面更流畅。

UIView 是界面元素的基础,用于响应用户输入,绘制流程是当视图内容或大小变化时会调用 setNeedsDisplay 或 setNeedsLayout 标记为要更新状态,下个循环会调用 drawRect: 进行绘制。绘制是 Core Graphics,也就是 CPU,显示靠的是 Core Animation,用的是 GPU。异步绘制就是将 Core Graphics 的动作放到主线程外,这样主线程就不会收到绘制计算量的影响。

Core Graphics 的异步绘制是使用 UIGraphicsBeginImageContextWithOptions 函数在后台线程中创建一个 CGContext。使用 GCD 或 NSOperationQueue 来在后台线程中进行绘制操作。完成绘制后,将结果返回主线程以更新 UI。

下面是一个异步绘制的示例代码:

import UIKitclass AsyncDrawingView: UIView {        private var asyncImage: UIImage?        override func draw(_ rect: CGRect) {        super.draw(rect)                // 如果有异步绘制的图片,直接绘制它        asyncImage?.draw(in: rect)    }        func drawAsync() {        Task {            // 创建图形上下文            let size = self.bounds.size            UIGraphicsBeginImageContextWithOptions(size, false, 0.0)            guard let context = UIGraphicsGetCurrentContext() else { return }                        // 进行绘制操作            context.setFillColor(UIColor.blue.cgColor)            context.fill(CGRect(x: 0, y: 0, width: size.width, height: size.height))                        // 获取绘制结果            let image = UIGraphicsGetImageFromCurrentImageContext()            UIGraphicsEndImageContext()                        // 更新 UI,回到主线程            await MainActor.run {                self.asyncImage = image                self.setNeedsDisplay() // 触发 draw(_:) 方法重新绘制            }        }    }}

对于复杂的异步绘制,特别是涉及 UIView 的情况下,可以考虑这两个方法。首先是自定义 CALayer 并实现其 draw(in:) 方法来进行异步绘制。其次是使用 UIView 的 draw(:) 方法,在子类中重写 draw(:) 方法,并结合异步操作来更新绘制内容。

import UIKitclass AsyncDrawingLayer: CALayer {        override func draw(in ctx: CGContext) {        super.draw(in: ctx)                Task {            // 在子线程中执行绘制操作            await withCheckedContinuation { continuation in                Task.detached {                    // 执行绘制操作                    ctx.setFillColor(UIColor.red.cgColor)                    ctx.fill(self.bounds)                                        // 完成绘制操作后继续                    continuation.resume()                }            }                        // 回到主线程更新 UI            await MainActor.run {                self.setNeedsDisplay() // 触发 draw(in:) 重新绘制            }        }    }}

离屏渲染也容易掉帧,应该尽量的避免复杂的圆角、阴影效果,或者使用更简单的图形操作。如可能,减少对 layer 的属性设置,尤其是那些可能引起离屏渲染的属性。

运算转移到 GPU

CPU主要负责用户交互的处理,如果能够将运算转移到 GPU 上,就可以给 CPU 减压了。

以下是一些常见的方法和技术,可以在iOS中将计算任务从CPU转移到GPU:

通过Metal的计算管线(Compute Pipeline),可以编写计算着色器(Compute Shaders)在GPU上执行大量并行计算任务,如物理模拟、数据分析等。

// 使用Metal进行简单的计算操作let device = MTLCreateSystemDefaultDevice()let commandQueue = device?.makeCommandQueue()let shaderLibrary = device?.makeDefaultLibrary()let computeFunction = shaderLibrary?.makeFunction(name: "computeShader")let computePipelineState = try? device?.makeComputePipelineState(function: computeFunction!)

Core Image 是一个强大的图像处理框架,内置了许多优化的滤镜(Filters),并能够自动将图像处理任务分配到GPU上执行。

let ciImage = CIImage(image: inputImage)let filter = CIFilter(name: "CISepiaTone")filter?.setValue(ciImage, forKey: kCIInputImageKey)filter?.setValue(0.8, forKey: kCIInputIntensityKey)let outputImage = filter?.outputImage

Core Animation 是iOS的高效动画框架,它会将大部分动画的执行过程自动转移到GPU上。这包括视图的平移、缩放、旋转、淡入淡出等基本动画效果。通过使用CALayer和各种动画属性(如position、transform等),你可以创建平滑的动画,这些动画将在GPU上硬件加速执行。

let layer = CALayer()layer.position = CGPoint(x: 100, y: 100)let animation = CABasicAnimation(keyPath: "position")animation.toValue = CGPoint(x: 200, y: 200)animation.duration = 1.0layer.add(animation, forKey: "positionAnimation")

SpriteKit 和 SceneKit 是两个高层次的框架,分别用于2D和3D游戏开发。它们内部利用GPU进行图形渲染和物理模拟,极大地减少了CPU的负担。

let scene = SKScene(size: CGSize(width: 1024, height: 768))let spriteNode = SKSpriteNode(imageNamed: "Spaceship")spriteNode.position = CGPoint(x: scene.size.width/2, y: scene.size.height/2)scene.addChild(spriteNode)

线程死锁

线程操作稍不留神就会让主线程卡死,比如dispatch_once中同步访问主线程导致的死锁。子线程占用锁资源导致主线程卡死。dyld lock、selector lock和OC runtime lock互相等待。

同步原语(synchronization primitive)会阻塞读写任务执行。iOS 中常用的会阻塞读写任务执行的同步原语有 NSLock、NSRecursiveLock、NSCondition、NSConditionLock、信号量(Dispatch Semaphore)、屏障(Dispatch Barrier)、读写锁(pthread_rwlock_t)、互斥锁(pthread_mutex_t)、@synchronized 指令os_unfair_lock、原子性属性(Atomic Properties)、NSOperationQueue 和 操作依赖(Dependencies)、Actors。

这些同步原语各有优缺点,选择合适的同步机制取决于具体的应用场景。例如,pthread_rwlock_t适用于读多写少的情况,而NSLock或@synchronized则适用于简单的互斥需求。GCD的信号量和屏障则提供了更高层次的并发控制手段。因此在使用同步原语时要特别注意了。检测卡死情况也要重点从同步原语来入手。

IO 过密

磁盘操作通常是阻塞性的,可以将磁盘 IO 操作放到后台线程中执行。

import SwiftUIstruct ContentView: View {    @State private var data: String = "Loading..." // `data` 用于存储从磁盘读取的数据,并在 UI 中显示。        var body: some View {        VStack {            Text(data)                .padding()            Button("Load Data") {                loadData()            }        }    }        func loadData() {        // 通过 `Task` 创建一个并发上下文来运行异步代码块。在这个代码块中执行耗时的磁盘 IO 操作。        Task {            // 在后台执行磁盘 IO 操作            let loadedData = await performDiskIO()            // 在主线程更新 UI            await MainActor.run {                data = loadedData            }        }    }        // 模拟一个磁盘 IO 操作,可能是从文件中读取大数据    func performDiskIO() async -> String {        // 模拟磁盘操作耗时        try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds delay                // 这里可以进行实际的磁盘读取操作        // 例如读取文件内容:        // let fileURL = ...        // let data = try? String(contentsOf: fileURL)                return "Data Loaded Successfully!"    }}@mainstruct DiskIOApp: App {    var body: some Scene {        WindowGroup {            ContentView()        }    }}

跨进程通信导致卡顿

进程间通信(IPC)是一种重要的机制,它允许不同的进程或应用程序之间交换信息。然而,某些系统API的调用可能会导致卡顿或性能问题,特别是在以下几种情况下:

  • CNCopyCurrentNetworkInfo 获取 WiFi 信息
  • 设置系统钥匙串 (Keychain) 中的值
  • NSUserDefaults 调用写操作
  • CLLocationManager 获取当前位置权限状态
  • UIPasteboard 设置和获取值
  • UIApplication 通过 openURL 打开其他应用

在执行以上操作时,心理上是要有预期的。能有替代方案的话那是最好的了。

卡顿监控

监控原理是注册runloop观察者,检测耗时,记录调用栈,上报后台分析。长时间卡顿后,若未进入下一个活跃状态,则标记为卡死崩溃上报。

以下是一个 iOS 卡死监控的代码示例:

#import <Foundation/Foundation.h>#import <UIKit/UIKit.h>#import <execinfo.h>#import <sys/time.h>// 定义 Runloop 模式的枚举typedef enum {    eRunloopDefaultMode,  // 默认模式    eRunloopTrackingMode  // 追踪模式} RunloopMode;// 全局变量,用于记录 Runloop 的活动状态和模式static CFRunLoopActivity g_runLoopActivity;static RunloopMode g_runLoopMode;static BOOL g_bRun = NO;  // 标记 Runloop 是否在运行static struct timeval g_tvRun;  // 记录 Runloop 开始运行的时间// HangMonitor 类,用于监控卡死情况@interface HangMonitor : NSObject@property (nonatomic, assign) CFRunLoopObserverRef runLoopBeginObserver;  // Runloop 开始观察者@property (nonatomic, assign) CFRunLoopObserverRef runLoopEndObserver;    // Runloop 结束观察者@property (nonatomic, strong) dispatch_semaphore_t semaphore;  // 信号量,用于同步@property (nonatomic, assign) NSTimeInterval timeoutInterval;  // 超时时间- (void)addRunLoopObserver;  // 添加 Runloop 观察者的方法- (void)startMonitor;  // 启动监控的方法- (void)logStackTrace;  // 记录调用栈的方法- (void)reportHang;  // 上报卡死的方法@end@implementation HangMonitor// 单例模式,确保 HangMonitor 只有一个实例+ (instancetype)sharedInstance {    static HangMonitor *instance;    static dispatch_once_t onceToken;    dispatch_once(&onceToken, ^{        instance = [[HangMonitor alloc] init];    });    return instance;}// 初始化方法- (instancetype)init {    self = [super init];    if (self) {        _timeoutInterval = 6.0;  // 设置超时时间为6秒        _semaphore = dispatch_semaphore_create(0);  // 创建信号量        [self addRunLoopObserver];  // 添加 Runloop 观察者        [self startMonitor];  // 启动监控    }    return self;}// 添加 Runloop 观察者的方法- (void)addRunLoopObserver {    NSRunLoop *curRunLoop = [NSRunLoop currentRunLoop];  // 获取当前 Runloop    // 创建第一个观察者,监控 Runloop 是否处于运行状态    CFRunLoopObserverContext context = {0, (__bridge void *) self, NULL, NULL, NULL};    CFRunLoopObserverRef beginObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, LONG_MIN, &myRunLoopBeginCallback, &context);    CFRetain(beginObserver);  // 保留观察者,防止被释放    self.runLoopBeginObserver = beginObserver;    // 创建第二个观察者,监控 Runloop 是否处于睡眠状态    CFRunLoopObserverRef endObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, LONG_MAX, &myRunLoopEndCallback, &context);    CFRetain(endObserver);  // 保留观察者,防止被释放    self.runLoopEndObserver = endObserver;    // 将观察者添加到当前 Runloop 中    CFRunLoopRef runloop = [curRunLoop getCFRunLoop];    CFRunLoopAddObserver(runloop, beginObserver, kCFRunLoopCommonModes);    CFRunLoopAddObserver(runloop, endObserver, kCFRunLoopCommonModes);}// 第一个观察者的回调函数,监控 Runloop 是否处于运行状态void myRunLoopBeginCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {    HangMonitor *monitor = (__bridge HangMonitor *)info;    g_runLoopActivity = activity;  // 更新全局变量,记录当前的 Runloop 活动状态    g_runLoopMode = eRunloopDefaultMode;  // 更新全局变量,记录当前的 Runloop 模式    switch (activity) {        case kCFRunLoopEntry:            g_bRun = YES;  // 标记 Runloop 进入运行状态            break;        case kCFRunLoopBeforeTimers:        case kCFRunLoopBeforeSources:        case kCFRunLoopAfterWaiting:            if (g_bRun == NO) {                gettimeofday(&g_tvRun, NULL);  // 记录 Runloop 开始运行的时间            }            g_bRun = YES;  // 标记 Runloop 处于运行状态            break;        case kCFRunLoopAllActivities:            break;        default:            break;    }    dispatch_semaphore_signal(monitor.semaphore);  // 发送信号量}// 第二个观察者的回调函数,监控 Runloop 是否处于睡眠状态void myRunLoopEndCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {    HangMonitor *monitor = (__bridge HangMonitor *)info;    g_runLoopActivity = activity;  // 更新全局变量,记录当前的 Runloop 活动状态    g_runLoopMode = eRunloopDefaultMode;  // 更新全局变量,记录当前的 Runloop 模式    switch (activity) {        case kCFRunLoopBeforeWaiting:            gettimeofday(&g_tvRun, NULL);  // 记录 Runloop 进入睡眠状态的时间            g_bRun = NO;  // 标记 Runloop 进入睡眠状态            break;        case kCFRunLoopExit:            g_bRun = NO;  // 标记 Runloop 退出运行状态            break;        case kCFRunLoopAllActivities:            break;        default:            break;    }    dispatch_semaphore_signal(monitor.semaphore);  // 发送信号量}// 启动监控的方法- (void)startMonitor {    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{        while (YES) {            long result = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, self.timeoutInterval * NSEC_PER_SEC));            if (result != 0) {                if (g_runLoopActivity == kCFRunLoopBeforeSources || g_runLoopActivity == kCFRunLoopAfterWaiting) {                    [self logStackTrace];  // 记录调用栈                    [self reportHang];  // 上报卡死                }            }        }    });}// 记录调用栈的方法- (void)logStackTrace {    void *callstack[128];    int frames = backtrace(callstack, 128);    char **strs = backtrace_symbols(callstack, frames);    NSMutableString *stackTrace = [NSMutableString stringWithString:@"\n"];    for (int i = 0; i < frames; i++) {        [stackTrace appendFormat:@"%s\n", strs[i]];    }    free(strs);    NSLog(@"%@", stackTrace);}// 上报卡死的方法- (void)reportHang {    // 在这里实现上报后台分析的逻辑    NSLog(@"检测到卡死崩溃,进行上报");}@end// 主函数,程序入口int main(int argc, char * argv[]) {    @autoreleasepool {        HangMonitor *monitor = [HangMonitor sharedInstance];  // 获取 HangMonitor 单例        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));  // 启动应用程序    }}

以上代码中 HangMonitor 类会在主线程的 RunLoop 活动中检测是否有长时间的卡顿,并在检测到卡顿时记录调用栈并上报后台进行分析。超时时间设定为 6 秒,以覆盖大部分用户感知场景并减少性能损耗。

内存

引用计数

iOS 中用引用计数(ARC)来管理对象的生命周期。在ARC之前,开发者需要手动管理对象的内存,通过retain、release、autorelease等方法来控制对象的生命周期。SideTables 是一个包含8个 SideTable 的哈希数组,用于存储对象的引用计数和弱引用信息。每个 SideTable 对应多个对象。SideTable 包含三个主要成员:自旋锁(spinlock_t)、引用计数表(RefcountMap)、弱引用表(weak_table_t)。自旋锁用于防止多线程访问冲突,引用计数表存储对象的引用计数,弱引用表存储对象的弱引用信息。weak_table_t 是一个存储弱引用信息的哈希表,其元素是 weak_entry_t 类型。weak_entry_t 存储了弱引用该对象的指针的指针,即objc_object new_referrer。当对象被销毁时,weak引用的指针会被自动置为nil,防止野指针的出现。

当两个类互相持有对方的强引用时,会导致循环引用问题,导致内存无法正确释放,这会造成内存不断的增多。这类问题通常发生在闭包与类实例之间。为了打破这种循环引用,可以在闭包中使用捕获列表(capture list)将闭包中的引用声明为弱引用或无主引用。

import SwiftUIclass Element {    let title: String    let description: String?        lazy var convertToWeb: () -> String = { [unowned self] in        if let description = self.description {            return "<div class='line'><h2>\(self.title)</h2><p>\(description)</p></div>"        } else {            return "<div class='line'><h2>\(self.title)</h2></div>"        }    }        init(title: String, description: String? = nil) {        self.title = title        self.description = description    }        deinit {        print("\(title) is being deinitialized")    }}struct ContentView: View {    @State private var elm: Element? = Element(title: "Inception", description: "A mind-bending thriller by Christopher Nolan.")        var body: some View {        VStack {            if let html = elm?.convertToWeb() {                Text(html)                    .padding()                    .background(Color.yellow)                    .cornerRadius(10)            }            Button("Clear") {                elm = nil            }            .padding()            .background(Color.red)            .foregroundColor(.white)            .cornerRadius(10)        }        .padding()    }}

在这个示例中,convertToWeb 是一个闭包,使用了 [unowned self] 捕获列表,以避免闭包与 Element 实例之间的强引用循环。

Swift 通常通过引用计数和内存自动管理来保证内存安全,然而在某些高性能或特定底层操作中,开发者可能需要直接操作内存。这时就需要使用到 Swift 的 Unsafe 系列指针类型,例如 UnsafeMutablePointerUnsafePointerUnsafePointer 是一个指向某种类型的指针,它允许只读访问内存地址上的数据。这意味着你可以读取该地址的数据但不能修改它。相反,UnsafeMutablePointer 允许你修改指针指向的内存区域内的数据。使用 UnsafeMutablePointer 修改内存时,必须确保内存已经正确地分配且不会被其他代码同时访问。否则,可能会导致程序崩溃或出现难以调试的问题。Swift 提供的一些辅助工具 withUnsafePointer(to:_:)withUnsafeMutablePointer(to:_:),它们可以在有限的范围内确保内存操作的安全性。这些函数的使用可以帮助开发者避免一些常见的错误,确保指针的生命周期和作用域受到控制。

OOM

内存泄漏,难以监控。内存泄漏是指程序在运行过程中,由于设计错误或者代码实现不当,导致程序未能释放已经不再使用的内存,从而造成系统内存的浪费,严重的会导致程序崩溃。内存泄漏是一个非常严重的问题,因为它会导致程序运行速度变慢,甚至会导致程序崩溃。因此,我们在开发过程中,一定要注意内存泄漏的问题。

OOM(Out Of Memory)指的是iOS设备上应用因内存占用过高被系统强制终止的现象。iOS通过Jetsam机制管理内存资源,当设备内存紧张时,会终止优先级低或内存占用大的进程。分为FOOM(前台OOM)和BOOM(后台OOM),FOOM对用户体验影响更大。

Jetsam日志

包括pageSize(内存页大小)、states(应用状态)、rpages(占用的内存页数)、reason(终止原因)。通过pageSize和rpages可计算出应用崩溃时占用的内存大小。

在现代操作系统中,内存管理是一项关键任务。随着移动设备和桌面系统的复杂性增加,内存资源的高效使用变得更加重要。iOS和macOS通过引入“内存压力”(Memory Pressure)机制来优化内存管理,取代了传统的基于虚拟内存分页的管理方法。

虚拟内存系统允许操作系统将物理内存(RAM)和磁盘存储结合使用,以便在内存不足时将不常用的数据移至磁盘。分页(paging)是虚拟内存管理中的一种技术,它将内存划分为小块(页面),并根据需要将它们从物理内存交换到磁盘。然而,分页存在性能瓶颈,尤其是在存储访问速度远低于内存的情况下。

随着设备硬件的变化和用户体验要求的提高,苹果公司在iOS和macOS中引入了“内存压力”机制。内存压力是一种动态监测内存使用情况的技术,它能够实时评估系统内存的使用状态,并根据不同的压力级别采取相应的措施。

内存压力机制通过系统级别的反馈来管理内存。系统会监测内存的使用情况,并将压力分为四个级别:无压力(No Pressure)、轻度压力(Moderate Pressure)、重度压力(Critical Pressure)和紧急压力(Jetsam)。

压力级别的定义与响应:

  • 无压力(No Pressure):系统内存充足,没有特别的内存管理措施。
  • 轻度压力(Moderate Pressure):系统内存开始紧张,操作系统会建议应用程序释放缓存或非必要的资源。
  • 重度压力(Critical Pressure):系统内存非常紧张,操作系统可能会暂停后台任务或终止不活跃的应用程序。
  • 紧急压力(Jetsam):这是最严重的内存压力状态,系统可能会直接强制关闭占用大量内存的应用程序,以释放资源确保系统的稳定性。

系统对内存压力的应对措施

为了应对不同的内存压力,iOS和macOS系统采取了多种策略,包括:

  • 缓存管理:系统会首先清除可丢弃的缓存数据,以减轻内存负担。
  • 后台任务管理:在压力增加时,操作系统会优先暂停或终止低优先级的后台任务。
  • 应用程序终止:在紧急情况下,系统会选择性地关闭那些占用大量内存且当前不活跃的应用程序,这一过程被称为“Jetsam”。

使用系统提供的工具(如vm_statmemory_pressure等)监测应用程序的内存使用情况。这些工具可以帮助开发者识别内存泄漏、过度的缓存使用等问题。开发者可以通过这些机制感知内存压力的变化。例如,当系统发出UIApplicationDidReceiveMemoryWarningNotification通知时,应用程序应立即释放不必要的资源。

查看内存使用情况

在 iOS 中,可以使用 mach_task_basic_info 结构体来查看应用的实际内存使用情况。mach_task_basic_info 是一个 task_info 结构体的子集,它提供了关于任务(进程)的基本信息,包括内存使用情况。特别地,你可以通过 phys_footprint 字段来获取应用程序实际占用的物理内存量。

import Foundationfunc getMemoryUsage() -> UInt64? {    var info = mach_task_basic_info()    var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4        let kret = withUnsafeMutablePointer(to: &info) { infoPtr in        infoPtr.withMemoryRebound(to: integer_t.self, capacity: 1) { intPtr in            task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), intPtr, &count)        }    }        if kret == KERN_SUCCESS {        return info.phys_footprint    } else {        print("Failed to get task info with error code \(kret)")        return nil    }}// Usageif let memoryUsage = getMemoryUsage() {    print("Memory usage: \(memoryUsage / 1024 / 1024) MB")}

在这个示例中,mach_task_basic_info 结构体用于存储基本信息,task_info() 函数用来填充这些信息,phys_footprint 字段提供了物理内存占用的实际数据。使用这些底层 API 需要适当的权限,有时可能无法在应用程序的沙盒环境中访问所有内存信息。

在 iOS 中,NSProcessInfophysicalMemory 属性可以用来获取设备的总物理内存大小。这个属性返回一个 NSUInteger 类型的值,表示物理内存的大小(以字节为单位)。这个方法在 iOS 9 及更高版本中可用。

import Foundationfunc getPhysicalMemorySize() -> UInt64 {    let physicalMemory = ProcessInfo.processInfo.physicalMemory    return physicalMemory}// Usagelet memorySize = getPhysicalMemorySize()print("Total physical memory: \(memorySize / 1024 / 1024) MB")

vm_statistics_data_t 是一个与虚拟内存相关的数据结构,它提供了关于虚拟内存的统计信息,包括系统的内存使用情况。虽然它不能直接提供应用程序使用的内存,但它可以提供有关整个系统的虚拟内存状态的信息。使用 vm_statistics_data_t 可以获取有关系统内存的更详细的统计数据。

import Foundationimport MachOfunc getVMStatistics() -> (freeMemory: UInt64, usedMemory: UInt64)? {    var vmStats = vm_statistics_data_t()    var count = mach_msg_type_number_t(MemoryLayout<vm_statistics_data_t>.size) / 4    var hostPort: mach_port_t = mach_host_self()        let result = withUnsafeMutablePointer(to: &vmStats) { vmStatsPtr in        vmStatsPtr.withMemoryRebound(to: integer_t.self, capacity: 1) { intPtr in            // 用于获取主机的统计信息。通过指定 `HOST_VM_INFO`,可以获取虚拟内存相关的数据。            host_statistics(hostPort, HOST_VM_INFO, intPtr, &count)        }    }        if result == KERN_SUCCESS {        let pageSize = vm_kernel_page_size // 系统的页面大小(通常为 4096 字节)。        let freeMemory = UInt64(vmStats.free_count) * UInt64(pageSize)        let usedMemory = (UInt64(vmStats.active_count) + UInt64(vmStats.inactive_count) + UInt64(vmStats.wire_count)) * UInt64(pageSize)        return (freeMemory, usedMemory)    } else {        print("Failed to get VM statistics with error code \(result)")        return nil    }}// Usageif let vmStats = getVMStatistics() {    print("Free memory: \(vmStats.freeMemory / 1024 / 1024) MB")    print("Used memory: \(vmStats.usedMemory / 1024 / 1024) MB")}

vm_statistics_data_t 数据结构包含了有关虚拟内存的统计信息,如 free_count(自由页数)、active_count(活跃页数)、inactive_count(非活跃页数)和 wire_count(被锁定的页数)。

获取可用内存的方法如下:

import Foundationimport MachOfunc getAvailableMemory() -> UInt64? {    var vmStats = vm_statistics_data_t()    var count = mach_msg_type_number_t(MemoryLayout<vm_statistics_data_t>.size) / 4    var hostPort: mach_port_t = mach_host_self()        let result = withUnsafeMutablePointer(to: &vmStats) { vmStatsPtr in        vmStatsPtr.withMemoryRebound(to: integer_t.self, capacity: 1) { intPtr in            host_statistics(hostPort, HOST_VM_INFO, intPtr, &count)        }    }        if result == KERN_SUCCESS {        let pageSize = vm_kernel_page_size        let freeMemory = UInt64(vmStats.free_count) * UInt64(pageSize)        let inactiveMemory = UInt64(vmStats.inactive_count) * UInt64(pageSize)        return freeMemory + inactiveMemory    } else {        print("Failed to get VM statistics with error code \(result)")        return nil    }}// Usageif let availableMemory = getAvailableMemory() {    print("Available memory: \(availableMemory / 1024 / 1024) MB")}

free_count 表示系统中未使用的空闲内存页数。inactive_count 表示系统中未使用但可能会重新使用的内存页数。可用内存可以通过将空闲内存和非活跃内存的页数乘以页面大小来计算得到。

造成内存泄漏的常见原因

内存泄漏指的是程序中已动态分配的堆内存由于某些原因未能释放或无法释放,导致系统内存浪费,程序运行速度变慢甚至系统崩溃。

  • 循环引用:对象A强引用对象B,对象B又强引用对象A,或多个对象互相强引用形成闭环。使用Weak-Strong Dance、断开持有关系(如使用__block关键字、将self作为参数传入block)。
  • Block导致的内存泄漏:Block会对其内部的对象强引用,容易形成循环引用。使用Weak-Strong Dance、断开持有关系(如将self作为参数传入block)。
  • NSTimer导致的内存泄漏:NSTimer的target-action机制容易导致self与timer之间的循环引用。在合适的时机销毁NSTimer、使用GCD的定时器、借助中介者(如NSObject对象或NSProxy子类)断开循环引用、使用iOS 10后提供的block方式创建timer。
  • 委托模式中的内存泄漏:UITableView的delegate和dataSource、NSURLSession的delegate。根据具体场景选择使用weak或strong修饰delegate属性,或在请求结束时手动销毁session对象。
  • 非OC对象的内存管理:CoreFoundation框架下的对象(如CI、CG、CF开头的对象)和C语言中的malloc分配的内存。使用完毕后需手动释放(如CFRelease、free)。

Metrics

Metrics 和 XCTest 中的 memgraph 了解和诊断 Xcode 的内存性能问题。

内存泄漏检测工具原理

内存泄漏指的是程序在运行过程中,分配的内存未能及时释放,导致程序占用的内存持续增加。内存泄漏检测工具的基本原理是监控和管理对象的生命周期,检测那些在生命周期结束后仍未被释放的对象。

FBRetainCycleDetector

FBRetainCycleDetector 是由 Facebook 开源的一个用于检测 iOS 应用中的内存泄漏的工具。内存泄漏通常是由于对象之间的强引用循环导致的,FBRetainCycleDetector 的工作原理就是检测对象图中的强引用循环,进而帮助开发者识别和修复这些泄漏。

FBRetainCycleDetector 的核心思想是通过分析对象之间的引用关系来识别可能的循环引用。它通过以下步骤实现这一点:

  • 对象图构建FBRetainCycleDetector 首先会从一个指定的对象开始,递归地遍历该对象的所有属性和关联对象,构建一个引用图。这个图的节点是对象,边是对象之间的强引用。
  • **深度优先搜索 (DFS)**:在构建完对象图之后,FBRetainCycleDetector 会对图进行深度优先搜索,寻找从起始对象到自身的循环路径。换句话说,它会查找路径起始和终止于同一个对象的闭环。
  • 循环检测:当找到一个循环路径时,FBRetainCycleDetector 就会将其标记为潜在的内存泄漏。检测到的循环会以易于理解的方式输出,帮助开发者定位和解决问题。

为了避免不必要的检测,FBRetainCycleDetector 允许开发者定义一些属性过滤规则,忽略一些不会导致泄漏的引用。例如,可以跳过一些不可见的系统属性或自定义的非持有性引用。工具能够识别并忽略弱引用(weakunowned),因为这些引用不会导致内存泄漏。FBRetainCycleDetector 具有较高的灵活性,开发者可以通过扩展和定制对象图的遍历规则,使其适应不同的应用场景和复杂对象结构。由于对象图的遍历和循环检测可能会带来性能开销,FBRetainCycleDetector 主要用于开发和调试阶段,而不建议在生产环境中长期使用。

通常,FBRetainCycleDetector 会在调试时被使用。开发者可以通过简单的代码调用,检测指定对象是否存在循环引用。例如:

FBRetainCycleDetector *detector = [FBRetainCycleDetector new];[detector addCandidate:someObject];NSSet *retainCycles = [detector findRetainCycles];

通过以上代码,可以查找someObject 是否存在循环引用,并返回检测到的循环路径。

在实际应用中,FBRetainCycleDetector 被广泛用于检测复杂的对象之间的引用关系,特别是在自定义控件、大型视图控制器、网络回调等场景下,容易产生强引用循环的问题。通过早期检测和解决这些循环引用,可以大大提高应用的内存管理效率,减少内存泄漏带来的问题。

MLeaksFinder

MLeaksFinder 是一款由腾讯 WeRead 团队开源的 iOS 内存泄漏检测工具,其原理主要基于对象生命周期的监控和延迟检测机制。

MLeaksFinder 通过为基类 NSObject 添加一个 -willDealloc 方法来监控对象的生命周期。当对象应该被释放时(例如,ViewController 被 pop 或 dismiss 后),该方法被调用。在 -willDealloc 方法中,MLeaksFinder 使用一个弱指针(weak pointer)指向待检测的对象,以避免因为对象已经被释放而导致的野指针访问问题。MLeaksFinder 通过检查视图控制器的生命周期来检测内存泄漏。每个 UIViewController 都有一个 viewDidDisappear 方法,这个方法会在视图控制器从屏幕上消失时被调用。MLeaksFinder 通过在 viewDidDisappear 被调用时,检测该视图控制器是否已经被释放,如果没有被释放则认为存在内存泄漏。对于视图 (UIView),MLeaksFinder 会在视图被从其父视图中移除时(即 removeFromSuperview 调用后)检查视图是否已经被释放。如果视图没有被释放,则认为存在内存泄漏。MLeaksFinder 通过扩展 NSObject 的功能(即为 NSObject 添加一个 Category)来追踪对象的生命周期。当对象的 dealloc 方法没有在预期的时间内被调用时,就可以判断该对象是否泄漏。

-willDealloc 方法中,MLeaksFinder 使用 dispatch_after 函数在 GCD(Grand Central Dispatch)的主队列上设置一个延迟(通常是2到3秒)执行的 block。这个 block 在延迟时间后执行,尝试通过之前设置的弱指针访问对象。如果对象已经被释放(即弱指针为 nil),则认为没有内存泄漏;如果对象仍然存活,则认为存在内存泄漏。MLeaksFinder 通过将对象的检测任务加入到下一个 Runloop 中执行,从而避免在当前线程中直接执行检测操作。这种方式确保了不会影响主线程的性能,同时能在适当的时间进行内存泄漏的检测。

如果在延迟时间后对象仍然存活,MLeaksFinder 会执行相应的检测逻辑,并可能通过断言(assertion)中断应用(具体行为可能根据配置和版本有所不同)。MLeaksFinder 会在应用运行时自动检测内存泄漏,不需要开发者手动触发。检测到内存泄漏后,MLeaksFinder 通常会弹出警告框(alert)或通过日志(log)输出相关信息,帮助开发者定位和解决内存泄漏问题。

MLeaksFinder 使用了方法交换技术替换如dismissViewControllerAnimated:completion:等方法,确保释放时触发检测。调用willDealloc方法,设置延时检查对象是否已释放。若未释放,则进入assertNotDealloc方法,中断言提醒开发者。

当 MLeaksFinder 检测到潜在的内存泄漏时,它还可以打印堆栈信息,帮助开发者找出导致对象无法释放的具体代码路径。通过willReleaseChildwillReleaseChildren方法构建子对象的释放堆栈信息。这通常通过递归遍历子对象,并将父对象和子对象的类名组合成视图堆栈(view stack)来实现。

MLeaksFinder 还可能集成了循环引用检测功能,使用如 Facebook 的 FBRetainCycleDetector 这样的工具来找出由 block 等造成的循环引用问题。MLeaksFinder 提供了一种白名单机制,允许开发者将一些特定的对象排除在泄漏检测之外。这在某些对象确实需要持久存在的场景下非常有用。MLeaksFinder 非常轻量,不会显著影响应用的性能。集成简单,自动化检测,极大地方便了开发者发现内存泄漏问题。在某些复杂的情况下,可能会有误报(即认为对象泄漏了,但实际上没有)。

PLeakSniffer

PLeakSniffer是一个用于检测iOS应用程序中内存泄漏的工具。PLeakSniffer的基本工作原理:通过对控制器和视图对象设置弱引用,并使用单例对象周期性地发送ping通知,如果对象在控制器已释放的情况下仍然响应通知,则可能存在内存泄漏。

PLeakSnifferCitizen协议的设计及其在NSObjectUIViewControllerUINavigationControllerUIView中的实现。每个类都通过实现prepareForSniffer方法来挂钩适当的生命周期方法(如viewDidAppearpushViewController等),在适当的时机调用markAlive方法,将代理对象附加到被监测的对象上,以便后续的ping操作能够检测到对象的存活状态。

代理对象PObjectProxy的功能,它主要负责接收ping通知并检查宿主对象是否应当被释放,如果检测到可能的内存泄漏,就会触发警报或打印日志。通过这种方式,PLeakSniffer能够在运行时检测到iOS应用中可能存在的内存泄漏问题。

其他内存泄漏检测工具

hook malloc方法

要在 iOS 上 hook malloc 方法可以监控内存分配。可以使用函数拦截技术。以下是一个示例,展示如何使用 Fishhook 库来 hook malloc 方法。

将 Fishhook 库添加到你的项目中。你可以通过 CocoaPods 或手动添加 Fishhook 源代码。

#import <Foundation/Foundation.h>#import <malloc/malloc.h>#import "fishhook.h"// 原始 malloc 函数指针static void* (*original_malloc)(size_t size);// 自定义 malloc 函数void* custom_malloc(size_t size) {    void *result = original_malloc(size);    NSLog(@"Allocated %zu bytes at %p", size, result);    return result;}// Hook 函数void hookMalloc() {    // 重新绑定 malloc 函数    rebind_symbols((struct rebinding[1]){{"malloc", custom_malloc, (void *)&original_malloc}}, 1);}int main(int argc, const char * argv[]) {    @autoreleasepool {        // Hook malloc        hookMalloc();                // 测试 malloc 和 free        void *ptr = malloc(1024);        free(ptr);    }    return 0;}

在实际项目中使用时,注意性能开销和日志记录的影响。

malloc logger

malloc_logger 是 iOS 和 macOS 中用于内存分配调试的一个工具。它允许开发者设置一个自定义的日志记录器函数,以便在内存分配和释放操作发生时记录相关信息。通过使用 malloc_logger,开发者可以更容易地检测和诊断内存问题,如内存泄漏、过度分配等。

以下是一个使用 Objective-C 实现的示例,展示如何设置和使用 malloc_logger

#import <Foundation/Foundation.h>#import <malloc/malloc.h>// 定义自定义的 malloc logger 函数void custom_malloc_logger(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t result, uintptr_t num_bytes) {    switch (type) {        case MALLOC_LOG_TYPE_ALLOCATE:            NSLog(@"Allocated %lu bytes at %p", (unsigned long)num_bytes, (void *)result);            break;        case MALLOC_LOG_TYPE_DEALLOCATE:            NSLog(@"Deallocated memory at %p", (void *)arg1);            break;        case MALLOC_LOG_TYPE_HAS_ZONE:            NSLog(@"Memory operation with zone at %p", (void *)arg1);            break;        default:            break;    }}// 设置自定义的 malloc loggervoid setCustomMallocLogger() {    malloc_logger = custom_malloc_logger;}int main(int argc, const char * argv[]) {    @autoreleasepool {        // 设置自定义 malloc logger        setCustomMallocLogger();                // 测试 malloc 和 free        void *ptr = malloc(1024);        free(ptr);    }    return 0;}

在这个示例中,我们定义了一个自定义的 malloc_logger 函数 custom_malloc_logger,并在 setCustomMallocLogger 函数中将其设置为当前的 malloc_logger。然后,在 main 函数中,我们测试了内存的分配和释放操作,并通过日志记录器记录这些操作的信息。

通过这种方式,开发者可以在内存分配和释放时记录相关信息,从而更好地理解和优化应用程序的内存使用情况。

内存快照检测方案

扫描进程中所有Dirty内存,建立内存节点之间的引用关系有向图,用于内存问题的分析定位。

在 iOS 中,可以使用 vm_region_recurse_64 函数来获取所有内存区域的信息。

#include <stdio.h>  #include <stdlib.h>  #include <mach/mach.h>  #include <mach/vm_map.h>    int main(int argc, const char * argv[]) {      mach_port_t task = mach_task_self();      vm_address_t address = VM_MIN_ADDRESS;      vm_size_t size = VM_MAX_ADDRESS - VM_MIN_ADDRESS;      vm_region_basic_info_data_64_t info;      mach_msg_type_number_t info_count = VM_REGION_BASIC_INFO_COUNT_64;      memory_object_name_t object_name;      mach_port_t object_handle;        kern_return_t kr;        while (size > 0) {          kr = vm_region_recurse_64(task, &address, &size, VM_REGION_BASIC_INFO,                                    (vm_region_info_t)&info, &info_count, &object_name,                                    &object_handle);            if (kr != KERN_SUCCESS)              break;            printf("Address: 0x%llx, Size: 0x%llx, Protection: 0x%x, In Use: %s\n",                 (unsigned long long)info.protection,                 (unsigned long long)info.size,                 (unsigned int)info.protection,                 info.is_submap ? "Yes" : "No");            address += info.size;          size -= info.size;      }        if (kr != KERN_SUCCESS) {          char *err = mach_error_string(kr);          fprintf(stderr, "vm_region_recurse_64 failed: %s\n", err);          free(err);      }        return 0;  }

在iOS中,可以使用libmalloc库提供的malloc_get_all_zones函数来获取所有内存区域(zone)的信息。malloc_get_all_zones可以遍历所有的内存区域,并为每个区域执行一个回调函数,从而获取详细的内存分配信息。

以下是一个简单的代码示例,展示如何使用malloc_get_all_zones来获取并打印内存区域的信息:

#import <malloc/malloc.h>#import <mach/mach.h>// 自定义的回调函数,用于处理每个内存区域的块。该函数用于处理每个zone中的内存块,在这个例子中,它简单地打印出每个内存块的地址和大小。void my_zone_enumerator(task_t task, void *context, unsigned type_mask, vm_range_t *ranges, unsigned range_count) {    for (unsigned i = 0; i < range_count; i++) {        printf("Memory range: 0x%llx, Size: %llu\n", ranges[i].address, ranges[i].size);    }}void print_all_zones() {    // 获取当前任务的mach port。用于获取当前任务的Mach端口,这对于与Mach内核通信是必需的。    task_t task = mach_task_self();    unsigned int count;    // 这是`libmalloc`库中的一个结构体,表示内存区域。通过调用其`introspect`属性下的`enumerator`函数,可以遍历该zone中的所有内存块。    malloc_zone_t **zones = NULL;    // 获取所有的内存区域。这个函数返回当前任务的所有内存区域(zone),这些zone通常对应于不同的分配器或内存池。    kern_return_t kr = malloc_get_all_zones(task, NULL, &zones, &count);    if (kr != KERN_SUCCESS) {        fprintf(stderr, "Error: Unable to get all zones\n");        return;    }    // 遍历所有的zone    for (unsigned int i = 0; i < count; i++) {        malloc_zone_t *zone = zones[i];        if (zone != NULL) {            printf("Zone name: %s\n", zone->zone_name);            // 枚举zone中的内存块            zone->introspect->enumerator(task, NULL, MALLOC_PTR_IN_USE_RANGE_TYPE, (vm_address_t)zone, my_zone_enumerator);        }    }}int main(int argc, const char * argv[]) {    print_all_zones();    return 0;}

使用单独的 malloc_zone 管理采集模块的内存使用,减少非法内存访问。遍历进程内所有VM Region(虚拟内存区域),获取Dirty和Swapped内存页数。重点关注libmalloc管理的堆内存,获取存活内存节点的指针和大小。

为内存节点赋予详细的类型名称,如Objective-C/Swift/C++实例类名等。通过运行时信息和mach-o、C++ ABI文档获取C++对象的类型信息。遍历内存节点,搜索并确认节点间的引用关系。对栈内存和Objective-C/Swift堆内存进行特殊处理,获取更详细的引用信息。

后台线程定时检测内存占用,超过设定的危险阈值后触发内存分析。内存分析过程中,对内存节点进行引用关系分析,生成内存节点之间的引用关系有向图。通过图算法,找到内存泄漏的根原因。

libmalloc 内存日志分析

通过代码控制内存日志开关,可以在内存泄漏发生时,输出内存日志。内存日志包括内存分配、释放、引用计数变化等信息,用于分析内存泄漏的原因。

在 iOS 开发中,libmalloc 提供了 turn_on_stack_loggingturn_off_stack_logging 方法,用于启用和禁用堆栈日志记录。这些方法可以帮助开发者在调试和分析内存问题时记录内存分配的堆栈信息。以下是一个使用这些方法的代码示例:

#import <Foundation/Foundation.h>#import <malloc/malloc.h>#import <mach/mach.h>#import <mach/mach_init.h>#import <mach/mach_vm.h>// 启用堆栈日志记录void enableStackLogging() {    turn_on_stack_logging(1);    NSLog(@"Stack logging turned on");}// 禁用堆栈日志记录void disableStackLogging() {    turn_off_stack_logging();    NSLog(@"Stack logging turned off");}// 获取堆栈日志记录void getStackLoggingRecords() {    // 获取当前任务    task_t task = mach_task_self();        // 获取所有堆栈日志记录    mach_vm_address_t *records;    uint32_t count;    kern_return_t kr = __mach_stack_logging_enumerate_records(task, &records, &count);        if (kr != KERN_SUCCESS) {        NSLog(@"Failed to enumerate stack logging records: %s", mach_error_string(kr));        return;    }        for (uint32_t i = 0; i < count; i++) {        mach_vm_address_t record = records[i];        NSLog(@"Record %u: %p", i, (void *)record);                // 定义堆栈帧数组        uint64_t frames[128];        // 获取堆栈帧信息        uint32_t frameCount = __mach_stack_logging_frames_for_uniqued_stack(task, record, frames, 128);                // 遍历堆栈帧,每次循环中,获取当前堆栈帧地址并打印地址信息        for (uint32_t j = 0; j < frameCount; j++) {            NSLog(@"Frame %u: %p", j, (void *)frames[j]);        }    }        // 释放记录数组    vm_deallocate(task, (vm_address_t)records, count * sizeof(mach_vm_address_t));}// 示例函数,分配一些内存void allocateMemory() {    void *ptr1 = malloc(1024);    void *ptr2 = malloc(2048);    free(ptr1);    free(ptr2);}// 主函数int main(int argc, const char * argv[]) {    @autoreleasepool {        // 启用堆栈日志记录        enableStackLogging();                // 分配内存        allocateMemory();                // 获取堆栈日志记录        getStackLoggingRecords();                // 禁用堆栈日志记录        disableStackLogging();    }    return 0;}

在这个示例中,我们首先调用 turn_on_stack_logging 方法来启用堆栈日志记录,然后进行一些内存分配和释放操作。接着,我们调用 __mach_stack_logging_enumerate_records 方法获取所有堆栈日志记录,并使用 __mach_stack_logging_frames_for_uniqued_stack 方法解析每个日志记录以获取堆栈帧信息。最后,我们调用 turn_off_stack_logging 方法来禁用堆栈日志记录。

通过这种方式,开发者可以在需要时启用和禁用堆栈日志记录,并解析这些日志记录以获取详细的堆栈信息。需要注意的是,这些函数在实际项目中使用时,需要确保在合适的时机启用和禁用堆栈日志记录,以避免性能开销和不必要的日志记录。

IO 性能

文件写操作常见但易出错。常见问题包括数据不一致、数据丢失、性能波动等。

读写的 API

文件读写系统调用的 API 有 read()write()read()从文件读取数据到应用内存。write()将数据从应用内存写入文件到内核缓存,但不保证立即写入磁盘。mmap()将文件映射到应用内存,直接访问,但写操作同样先进入内核缓存。fsync()fcntl(F_FULLSYNC) 会强制将文件写入磁盘。c标准库提供的文件读写 API 是 fwrite(buffer, sizeof(char), size, file_pointer)fflush(file_pointer)

iOS 提供了 NSFileManagerreplaceItemAtURL:withItemAtURL:backupItemName:options:resultingItemURL:error: 方法,可以实现原子性操作。

flockfcntl 使用文件锁防止多个进程或线程同时写入同一个文件,避免产生竞争条件,保证数据一致性。

iOS 提供了 NSFileManagerNSData 的封装方法,通常比直接使用 POSIX API 更安全和高效。

测试文件I/O性能时,应通过 fcntl(fd, F_NOCACHE, 1) 禁用统一缓冲缓存(UBC),以避免缓存影响测试结果。

文件缓存

文件缓存可以帮助优化应用性能、减少网络请求和延长电池续航。

iOS 提供了多个文件存储目录,选择合适的目录有助于管理缓存文件的生命周期。包括Caches 目录和tmp 目录。Caches 目录适合存储缓存文件。系统可能会在磁盘空间紧张时清除这个目录下的文件,因此不应存储重要数据。可以通过 NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true) 获取路径。tmp 目录适用于临时文件。系统重启或应用未运行时,可能会清除这个目录下的文件。可以通过 NSTemporaryDirectory() 获取路径。

根据数据的重要性和更新频率,制定缓存策略。为缓存数据设置时间戳或过期时间。每次读取缓存时检查数据是否过期,及时更新。实现 LRU 算法,定期清理最久未使用的缓存文件。

为缓存文件生成唯一标识符(如使用哈希值),避免文件名冲突。可以将 URL 的 MD5 或 SHA1 哈希值作为缓存文件名。将缓存文件按类别或特定属性进行分类存储,方便管理。例如,将图片和JSON数据分别存储在不同的子目录中。

对于大型缓存数据,可以在写入文件时使用 GZIP 等压缩技术,减少存储空间占用。iOS 的 NSDataNSFileManager 支持数据的压缩和解压缩。避免在主线程上执行缓存读写操作,使用 Swift Concurrency 将缓存操作移到后台,保持 UI 的流畅性。减少频繁的写入操作,可以将多次写入合并为一次批量操作。

对于敏感数据(如用户信息),应在缓存时进行加密处理。iOS 提供了 Keychain 进行安全存储,也可以使用 CommonCrypto 框架进行自定义加密。

定期清理过期或不再使用的缓存文件,避免占用过多磁盘空间。可以使用 iOS 的 NSURLCache 设置缓存大小限制,自动管理缓存清理。提供手动清理缓存的选项,允许用户在应用内清理缓存数据。根据数据更新频率设置缓存失效时间,确保用户获得最新数据。可以通过 ETag 或 Last-Modified HTTP 头实现增量更新,避免每次都下载完整数据。尽量利用 iOS 自带的缓存机制,例如 NSURLCache,它自动管理 HTTP 请求的缓存,支持内存和磁盘缓存。对于图片缓存,使用 NSCache 或者第三方库,可以在内存和磁盘之间自动管理图片的缓存。

mmap

mmap 是一种内存映射文件的机制,允许用户态的程序像操作内存一样直接操作磁盘文件。通过 mmap,文件的内容被映射到进程的地址空间中,程序可以直接读写这段地址空间,操作系统会在背后处理实际的磁盘读写操作。标准IO(如read/write)涉及系统调用和内存拷贝开销,数据需要在内核态和用户态之间来回拷贝。mmap 避免了这些开销,因为它直接在用户态的内存中操作,操作系统只在需要时(如缺页中断)介入处理磁盘读写。

对于超过物理内存大小的大文件,mmap 可以利用虚拟内存的特性,在有限的物理内存中处理大文件。多个进程可以映射同一个文件到各自的地址空间,实现内存共享,这在动态链接库等场景中非常有用。在某些场景下,mmap 可以提供更好的性能,因为它减少了系统调用和内存拷贝的次数。但具体性能取决于应用场景和操作系统实现。在处理大文件时,mmap 可以避免频繁的内存拷贝和磁盘I/O操作。多个进程可以共享同一个动态链接库,节省内存和磁盘空间。可用于实现高效的内存文件交换,如数据库中的内存映射文件。

mmap 也有些问题需要注意。当访问的页面不在物理内存中时,会发生缺页中断,这会有一定的性能开销。为了维护地址空间与文件的映射关系,内核需要额外的数据结构,这也会带来一定的性能开销。

我们使用 mmap 将文件映射到内存中,并读取文件内容。示例如下:

#import <Foundation/Foundation.h>#import <sys/mman.h>#import <fcntl.h>#import <unistd.h>void mmapExample() {    // 文件路径    NSString *filePath = @"/path/to/your/file.txt";        // 打开文件    int fd = open([filePath UTF8String], O_RDONLY);    if (fd == -1) {        NSLog(@"Failed to open file");        return;    }        // 获取文件大小    off_t fileSize = lseek(fd, 0, SEEK_END);    if (fileSize == -1) {        NSLog(@"Failed to get file size");        close(fd);        return;    }        // 将文件映射到内存    void *mappedFile = mmap(NULL, fileSize, PROT_READ, MAP_PRIVATE, fd, 0);    if (mappedFile == MAP_FAILED) {        NSLog(@"Failed to map file");        close(fd);        return;    }        // 关闭文件描述符    close(fd);        // 读取文件内容    NSData *fileData = [NSData dataWithBytes:mappedFile length:fileSize];    NSString *fileContent = [[NSString alloc] initWithData:fileData encoding:NSUTF8StringEncoding];    NSLog(@"File content: %@", fileContent);        // 解除文件映射    if (munmap(mappedFile, fileSize) == -1) {        NSLog(@"Failed to unmap file");    }}int main(int argc, const char * argv[]) {    @autoreleasepool {        mmapExample();    }    return 0;}

MMKV 是腾讯开源的一个高性能通用键值对存储库,基于 mmap 内存映射机制,它提供了简单易用的接口,支持高效的读写操作,并且支持数据加密。

以下是一个在 iOS 项目中使用 MMKV 的示例代码:

import UIKitimport MMKV@UIApplicationMainclass AppDelegate: UIResponder, UIApplicationDelegate {    var window: UIWindow?    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {        // 初始化 MMKV        MMKV.initialize(rootDir: MMKV.defaultMMKVPath)        return true    }}

使用 MMKV 存储和读取数据

import MMKVfunc mmkvExample() {    // 获取默认的 MMKV 实例    let mmkv = MMKV.default()    // 存储数据    mmkv?.set("Inception", forKey: "movieTitle")    mmkv?.set(8.8, forKey: "movieRating")    // 读取数据    if let movieTitle = mmkv?.string(forKey: "movieTitle") {        print("Movie Title: \(movieTitle)")    }    let movieRating = mmkv?.double(forKey: "movieRating")    print("Movie Rating: \(movieRating ?? 0.0)")}mmkvExample()

NSData 提供了三个与 mmap 相关的读取选项,它们分别是:

  • NSDataReadingUncached:这个选项表示不要缓存数据,如果文件只需要读取一次,使用这个选项可以提高性能。这个选项与 mmap 没有直接关系,因为它不涉及内存映射。
  • NSDataReadingMappedIfSafe:这个选项表示在保证安全的前提下,如果条件允许,则使用 mmap 进行内存映射。这意味着如果文件位于固定磁盘(非可移动磁盘或网络磁盘),则可能会使用 mmap 来优化读取性能。
  • NSDataReadingMappedAlways:这个选项表示总是使用 mmap 进行内存映射,不考虑文件的具体存储位置。但是,在 iOS 上,由于所有应用都运行在沙盒中,对 iOS 而言,NSDataReadingMappedIfSafeNSDataReadingMappedAlways 通常是等价的,因为 iOS 设备上的文件存储通常都是在固定磁盘上。

当你需要读取一个较大的文件,但又不想一次性将整个文件加载到内存中时,可以使用 NSDatadataWithContentsOfFile:options:error: 方法,并传入上述与 mmap 相关的选项之一。以下是一个示例代码,展示了如何使用 NSDataReadingMappedIfSafe 选项来读取文件:

NSError *error = nil;NSData *data = [NSData dataWithContentsOfFile:filePath options:NSDataReadingMappedIfSafe error:&error];if (data == nil) {    // 处理错误    NSLog(@"Error reading file: %@", error.localizedDescription);} else {    // 成功读取文件,可以处理 data}

在这个例子中,filePath 是你想要读取的文件的路径。通过使用 NSDataReadingMappedIfSafe,系统会在可能的情况下使用 mmap 来映射文件,这样就不需要在内存中为整个文件分配空间,从而减少了内存的使用。然而,需要注意的是,虽然 mmap 减少了物理内存的使用,但它仍然需要消耗虚拟内存地址空间。

在用 mmap 时要注意如果使用 mmap 映射了文件,那么在 NSData 的生命周期内,你不能删除或修改对应的文件,因为这可能会导致内存映射失效,进而引发不可预见的错误。mmap 适用于那些需要频繁读取、但不需要同时读取整个文件内容的场景,如视频加载、大日志文件读取等。mmap 映射的区域大小会占用相应大小的虚拟内存地址空间,因此对于非常大的文件,可能不适合将整个文件映射到内存中。

CPU

CPU 的高占用,会让手机耗电变快。

[NSProcessInfo processInfo].activeProcessorCount 可以获取 CPU 核数。获取 CPU 类型的方法有 sysctl、uname、hw.machine 和 NXArchInfo 几种方法。

怎么获取 CPU 使用率呢?

在 iOS 的 Mach 层中,thread_basic_info 结构体用于提供有关线程的一些基本信息,其中就有线程CPU使用率。这个结构体定义在 <mach/thread_info.h> 头文件中,其包含的字段提供了关于线程运行状态、执行时间和其他统计信息的基本数据。以下是 thread_basic_info 结构体的详细定义及其各字段的解释:

struct thread_basic_info {    time_value_t    user_time;       // 用户模式下线程运行的总时间    time_value_t    system_time;     // 内核模式下线程运行的总时间    integer_t       cpu_usage;       // CPU 使用率,以百分之一为单位    policy_t        policy;          // 调度策略(例如FIFO、Round Robin等)    integer_t       run_state;       // 线程的运行状态    integer_t       flags;           // 线程的标志位(例如是否正在被调度)    integer_t       suspend_count;   // 线程被挂起的次数    integer_t       sleep_time;      // 线程的睡眠时间};

字段解释

  • user_time: 该字段表示线程在用户模式下(即执行用户空间的代码)运行的总时间。time_value_t 是一个结构体,通常表示为秒和微秒。
  • system_time: 该字段表示线程在系统模式下(即执行内核空间的代码)运行的总时间。
  • cpu_usage: 该字段表示线程的 CPU 使用率,以百分之一为单位。例如,如果值为 100,表示线程使用了 1% 的 CPU 时间。
  • policy: 该字段表示线程的调度策略,如固定优先级调度(FIFO)或轮转调度(Round Robin)等。
  • run_state: 该字段表示线程当前的运行状态。可能的值包括:
    • TH_STATE_RUNNING: 正在运行
    • TH_STATE_STOPPED: 已停止
    • TH_STATE_WAITING: 正在等待资源
    • TH_STATE_UNINTERRUPTIBLE: 不可中断的等待
    • TH_STATE_HALTED: 已终止
  • flags: 该字段包含一些线程的标志位,用来表示线程的某些状态特性。例如,线程是否正在被调度等。
  • suspend_count: 该字段表示线程当前被挂起的次数。挂起次数大于 0 时,线程不会被调度执行。
  • sleep_time: 该字段表示线程处于睡眠状态的时间。

这些信息对于性能分析、调试以及获取系统中线程的运行状况非常有用。通过使用 thread_info 函数,可以获取到某个特定线程的 thread_basic_info 结构体实例。

要获取当前应用的 CPU 占用率,可以通过遍历当前应用的所有线程,利用 thread_info 函数获取每个线程的 CPU 使用情况。然后,将所有线程的 CPU 使用率汇总,就能得到整个应用的 CPU 占用率。

下面是一个使用 Objective-C 编写的示例代码,展示了如何获取当前应用的 CPU 占用率:

#import <mach/mach.h>#import <assert.h>float cpu_usage() {    kern_return_t kr;    thread_array_t thread_list;    mach_msg_type_number_t thread_count;    thread_info_data_t thread_info_data;    mach_msg_type_number_t thread_info_count;        // 获取当前任务    task_t task = mach_task_self();        // task_threads 这个函数用于获取当前任务的所有线程。`thread_list` 包含了所有线程的 ID,`thread_count` 是线程的数量。    kr = task_threads(task, &thread_list, &thread_count);    if (kr != KERN_SUCCESS) {        return -1;    }        float total_cpu = 0;        // 遍历所有线程    for (int i = 0; i < thread_count; i++) {        thread_info_count = THREAD_INFO_MAX;                // 通过 thread_info 获取每个线程的 `thread_basic_info`,其中包含了线程的 CPU 使用信息。        kr = thread_info(thread_list[i], THREAD_BASIC_INFO, (thread_info_t)thread_info_data, &thread_info_count);        if (kr != KERN_SUCCESS) {            return -1;        }                thread_basic_info_t thread_info = (thread_basic_info_t)thread_info_data;                if (!(thread_info->flags & TH_FLAGS_IDLE)) {            // 通过 `thread_basic_info` 结构体中的 `cpu_usage` 字段获取每个线程的 CPU 使用率,并将它们相加以得到整个应用的 CPU 使用率。            total_cpu += thread_info->cpu_usage / (float)TH_USAGE_SCALE * 100.0;        }    }        // 用于释放之前分配的线程列表内存。    kr = vm_deallocate(task, (vm_address_t)thread_list, thread_count * sizeof(thread_t));    assert(kr == KERN_SUCCESS);        return total_cpu;}

CPU 占用率是一个瞬时值,通常会波动,因此在实际应用中,可能需要多次采样并取平均值来得到更稳定的结果。这个方法会占用一定的 CPU 资源,尤其是在应用包含大量线程时,所以建议在非主线程或低优先级任务中执行这类操作。

对于总 CPU 占用率,使用 host_statistics 函数获取 host_cpu_load_info 结构体中的 cpu_ticks 值来计算总的 CPU 占用率。cpu_ticks 是一个数组,包含了 CPU 在各种状态(如用户模式、系统模式、空闲、Nice 等)下运行的时钟脉冲数量。通过计算这些脉冲数量的变化,可以得出总的 CPU 占用率。

以下是一个完整的示例代码,展示了如何使用 host_statistics 函数来计算总的 CPU 占用率:

#import <mach/mach.h>#import <stdio.h>float cpu_usage() {    // 获取 host 的 CPU load 信息    host_cpu_load_info_data_t cpuInfo;    mach_msg_type_number_t count = HOST_CPU_LOAD_INFO_COUNT;    // `host_statistics` 这是一个用于获取主机统计信息的函数。通过传递 `HOST_CPU_LOAD_INFO` 作为参数,可以获取 `host_cpu_load_info_data_t` 结构体,该结构体包含了 CPU 在不同状态下的时钟脉冲数。    kern_return_t kr = host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, (host_info_t)&cpuInfo, &count);        if (kr != KERN_SUCCESS) {        return -1;    }    // 获取各个状态下的 CPU 时钟脉冲数。通过将 `cpu_ticks` 数组中的所有值相加,得到 CPU 所有状态下运行的总时钟脉冲数。    unsigned long long totalTicks = 0;    for (int i = 0; i < CPU_STATE_MAX; i++) {        totalTicks += cpuInfo.cpu_ticks[i];    }    // 计算 CPU 占用率    unsigned long long idleTicks = cpuInfo.cpu_ticks[CPU_STATE_IDLE]; // `cpu_ticks[CPU_STATE_IDLE]` 表示 CPU 在空闲状态下的时钟脉冲数。    float cpuUsage = (1.0 - ((float)idleTicks / (float)totalTicks)) * 100.0;    return cpuUsage;}

这种方法计算的是整个系统的 CPU 占用率,而不是某个具体应用的 CPU 占用率。如果需要获取具体应用的 CPU 使用情况,应该使用 thread_info 等方法。

启动优化

移动应用的启动时间是影响用户体验的重要方面。

启动时间

识别启动阶段各个步骤的耗时情况。

启动分为以下三种:

  • Cold Launch:应用完全从零开始加载,最耗时。
  • Warm Launch:应用仍在内存中,但由于系统资源紧张,部分内容可能被清理,需要重新加载。
  • Hot Launch:应用仍在后台,只需快速恢复。

治理主要是针对 Cold Landch。

示例:

import UIKitclass AppDelegate: UIResponder, UIApplicationDelegate {    var window: UIWindow?    var launchTime: CFAbsoluteTime?    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {        // 记录应用启动的时间        launchTime = CFAbsoluteTimeGetCurrent()                // 在主线程完成所有启动任务后,计算应用启动时间        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {            if let launchTime = self.launchTime {                let launchDuration = CFAbsoluteTimeGetCurrent() - launchTime                print("App launch time: \(launchDuration) seconds")            }        }                return true    }}

另外也可获取完整加载使用时间。使用 DispatchQueue.main.asyncAfter 延迟执行,以确保所有启动任务(如 UI 渲染、网络请求等)已经完成。然后再使用 CFAbsoluteTimeGetCurrent() 获取当前时间,与记录的启动时间相减,得到启动耗时。

使用 mach_absolute_time() 来计算时间:

static uint64_t startTime;static uint64_t endTime = -1;static mach_timebase_info_data_t timebaseInfo;static inline NSTimeInterval MachTimeToSeconds(uint64_t machTime) {    return ((machTime / 1e9) * timebaseInfo.numer) / timebaseInfo.denom;}@implementation DurationTracker+ (void)load {    startTime = mach_absolute_time();    mach_timebase_info(&timebaseInfo);        @autoreleasepool {        __block id<NSObject> observer;        observer = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification                                                                object:nil queue:nil                                                            usingBlock:^(NSNotification *note) {            dispatch_async(dispatch_get_main_queue(), ^{                endTime = mach_absolute_time();                NSLog(@"StartupMeasurer: it took %f seconds until the app could respond to user interaction.", MachTimeToSeconds(endTime - startTime));            });            [[NSNotificationCenter defaultCenter] removeObserver:observer];        }];    }}

启动治理思路

减少初始加载的工作量主要有延迟初始化、按需加载数据和优化依赖注入。减少不必要的资源加载的方式有移除未使用的资源和使用延迟加载。减少动态库的数量,避免在启动时过度使用复杂的泛型或协议扩展,因为这些特性可能会增加编译器在运行时的解析开销。使用 Swift Concurrency 将耗时操作异步化,以并行处理更多任务,减少主线程的压力。减少初始界面上的复杂视图层次结构,优先加载并显示关键内容,延迟非关键内容的加载。在启动时尽量减少复杂的动画过渡,以提升首屏的渲染速度。

打法上:

  • 删:出最小集,减任务
  • 延:按需,延到首页后
  • 并:统一管理,编排,充分利用多核
  • 快:减 I/O,少并发,少计算(缓存)

经验:

  • 动态库转静态库
  • 不链用不到的系统库
  • 懒加载动态库,动态取类,dlopen 动态库
  • +load 里任务挪地
  • 减少视图数,少层级,懒加载
  • 主线程等待的子线程设高优先级
  • 子线程预加载
  • 文件大拆小,碎合并
  • 统计高频调用方法
  • 警惕隐藏的全局锁

包体积

影响和手段

包体积优化的必要性:

  • 下载转化率下降:每增加6M,应用下载转化率下降1%。
  • App Store限制:超过200MB的包,iOS 13以下用户无法通过蜂窝数据下载,iOS 13及以上用户需手动设置。
  • 磁盘占用:大包体积占用更多存储空间,影响低存储用户。
  • 用户下载意愿:大包体积减少用户下载意愿,尤其在蜂窝数据低数据模式下。
  • 性能影响:包体积大增加启动时间和SIGKILL风险,降低基础体验。

技术方案主要是以下几种:

  • 资源优化:优化大块资源、无用配置文件和重复资源。
  • 工程架构优化:建立体积检测流水线,控制体积增长。
  • 图片优化:无用图片优化、Asset Catalog优化、HEIC和WebP压缩优化、TinyPng压缩。
  • 编译器优化:使用LLVM编译选项,进行OC、C++、Swift等语言的编译优化。
  • 代码优化:无用类、方法、模块瘦身,精简重复代码,AB实验固化。

效果上讲,工程方向优化大于资源优化,资源优化大于代码优化。

系统提供的方式有

  • App Thinning:利用Apple提供的App Thinning功能,根据用户的设备自动下载适合该设备的资源包,有助于减少初装包的大小。
  • 按需下载资源:使用On-Demand Resources来按需下载资源,只下载用户实际需要的部分,从而减小初始安装包的大小。

包分析

iOS端安装包组成部分有:

  • Mach-O文件:iOS系统上的可执行文件。
  • Watch APP:带有小组件功能的WatchApp。
  • 自定义动态库:动态库推迟到运行时加载,节省代码段空间。
  • Swift系统库:高版本iOS系统自带,低版本需iPA包中自带。
  • Assets资源:Assets.car文件,包含图片资源。
  • 根目录下图片资源:直接添加进工程的图片文件。
  • bundle资源:管理图片和其他配置文件。
  • 其他配置文件:如plist、js、css、json等。

Mach-O是Mach Object文件格式的缩写,用于记录Mac及iOS系统上的可执行文件、目标代码、动态库和内存转储。使用MachOView和otool命令查看Mach-O文件信息,以及通过file和lipo命令查看文件格式和架构。Mach-O文件有Header、LoadCommands和Data部分,特别是LoadCommands中的关键cmd类型如LC_SEGMENT_64,及其段(__PAGEZERO、__TEXT、__DATA、__LINKEDIT)

APPAnalyze 是一款用于分析iOS ipa包的脚本工具,能够自动扫描并发现可修复的包体积问题,同时生成包体积数据用于查看。

资源优化

资源优化方案有图片压缩、资源清理、动态加载资源、使用 Assets.xcassets 等。

Asset Catalog是Xcode提供的资源管理工具,用于集中管理项目中的图片等资源。通过Xcode自带工具actool生成Assets.car文件,可使用assetutil工具分析文件内容。开发者在图片放入Asset Catalog前不要做无损压缩,因为actool会重新进行压缩处理。

Asset Catalog 的优点有:

  • 包体积瘦身:根据不同设备下载匹配的图片资源,减少下载包大小。
  • 统一的图片无损压缩:采用Apple Deep Pixel Image Compression技术,提高压缩比。
  • 便利的资源管理:将图片资源统一压缩成Assets.car文件,便于管理。
  • 高效的I/O操作:图片加载耗时减少两个数量级,提升应用性能。

代码优化

方案有:

  • 移除未使用的代码:查找并删除未使用的类、方法、变量等。审查业务逻辑,删除不再使用或已被废弃的代码模块。
  • 重构代码:对重复的代码进行重构,使用函数、类等方法来减少代码冗余。优化数据结构,减少内存占用和CPU消耗。
  • 编译策略调整:修改编译策略,如启用LTO(链接时优化)来优化跨模块调用代码。剥离符号表(Strip Linked Product),删除未引用的C/C++/Swift代码。精简编译产物,只保留必要的符号和导出信息。
  • 代码组件化:将常用代码文件打包成静态库,切断不同业务代码之间的依赖,减少每次编译的代码量。
  • 减少文件引用:能使用@class就使用@class,尽量减少文件之间的直接引用关系。
  • 减少Storyboard和XIB文件的使用:尽量使用代码布局,减少Storyboard和XIB文件的使用,这些文件在编译时会增加包体积。
  • 清理未使用的资源:清理项目中未使用的图片、音频等资源文件,以及未使用的类和合并重复功能的类。
  • 模块化设计:将App拆分成多个模块,每个模块独立编译和打包,可以根据需要动态加载或更新模块,减少主包的体积。
  • 依赖管理:合理使用CocoaPods、Carthage等依赖管理工具,管理项目的第三方库依赖,避免不必要的库被包含进最终的包中。

Periphery 是一个用于识别 Swift 项目中未使用代码的工具。Periphery 能够清除的无用代码种类有未使用的函数和方法,变量和常量,类或结构体,协议,枚举,全局和静态变量,导入语句和扩展。

需要注意的是,Periphery 可能会因为项目的特殊配置或动态特性(如反射、运行时类型检查等)而错过一些实际上在使用中的代码。

Periphery 不能自动清除或处理的代码有被间接引用的代码,未来可能使用的代码,跨项目共享的代码,特定构建配置下的使用,编译器特性或优化相关的代码。

Periphery 主要使用静态代码分析技术来识别 Swift 项目中未使用的代码。这种技术允许它在不实际运行代码的情况下,通过扫描代码库来查找潜在的问题,如未使用的变量、废弃的函数等。

Periphery 首先使用 xcodebuild 构建指定的 Xcode 工作区或项目,并通过 --schemes--targets 选项指定要构建的方案和目标。它索引这些目标中所有文件的声明和引用,生成一个包含这些信息的图形。在图形构建完成后,Periphery 对其执行大量的变异操作,并通过分析这些变异来识别未使用的声明。这些声明可能包括类、结构体、协议、函数、属性、构造函数、枚举、类型别名或关联类型等。Periphery 能够执行更高级的分析,例如识别协议函数中未使用的参数,但这需要在所有实现中也未使用时才会报告。类似地,重写函数的参数也只有在基函数和所有重写函数中也未使用时才会被报告为未使用。允许用户通过 YAML 配置文件来自定义排除规则,以避免误报。用户可以根据项目的需求,设置特定的排除路径或模式。可以与各种 CI/CD 工具集成,如 GitHub Actions、Jenkins 和 GitLab CI/CD,实现持续集成中的静态代码分析。通过自动运行代码扫描,Periphery 可以帮助团队在每次提交或拉取请求时发现和解决潜在的问题。Periphery 提供了两种扫描命令:scanscan-syntaxscan-syntax 命令只执行语法分析,因此速度更快,但可能无法提供与 scan 命令相同水平的准确性。用户可以根据项目的具体需求选择合适的命令。

Swift 代码静态分析的开源项目还有 SwiftLint 和 SourceKitten。

接下来具体说下运行时无用类检测方案。

静态检测,通过分析Mach-O文件中的__DATA __objc_classlist__DATA __objc_classrefs段,获取未使用的类信息。但存在无法检测反射调用类及方法的缺点。

动态检测的方法。在Objective-C(OC)中,每个类结构体内部都含有一个名为isa的指针,这个指针非常关键,因为它指向了该类对应的元类(meta-class)。元类本身也是一个类,用于存储类方法的实现等信息。

通过对元类(meta-class)的结构体进行深入分析,我们可以找到class_rw_t这样一个结构体,它是元类内部结构的一部分。在class_rw_t中,存在一个flag标志位,这个标志位用于记录类的各种状态信息。

通过检查这个flag标志位,我们可以进行一系列的计算或判断,从而得知当前类在运行时(runtime)环境中是否已经被初始化过。这种机制是Objective-C运行时系统的一个重要特性,它允许开发者在运行时动态地获取类的信息,包括类的初始化状态等。

也就是通过isa指针找到元类,再分析元类中的class_rw_t结构体中的flag标志位,我们可以得知OC中某个类是否已被初始化。

// class is initialized#define RW_INITIALIZED        (1<<29)struct objc_class : objc_object {    bool isInitialized() {    return getMeta()->data()->flags & RW_INITIALIZED;    }};

在Objective-C的运行时(runtime)机制中,类的内部结构和状态通常是由Objective-C运行时库管理的,而不是直接暴露给开发者在应用程序代码中调用的。不过,你可以通过Objective-C的runtime API来间接地获取这些信息。

关于类是否已被初始化的问题,通常不是直接通过objc_class结构体中的某个函数来判断的,因为objc_class结构体(及其元类)的细节和具体实现是私有的,并且不推荐开发者直接操作。然而,Objective-C运行时确实提供了一些工具和API来检查类的状态和行为。

为了检查一个类是否在当前应用程序的生命周期中被使用过(即“被初始化过”),开发者可能会采用一些间接的方法,而不是直接操作类结构体的内部函数。以下是一个简化的说明:

由于不能直接访问类的内部结构,开发者可能会通过其他方式来跟踪类的使用情况。例如,可以在类的初始化方法中设置一个静态标志位或计数器,以记录类是否已被初始化或实例化的次数。虽然不能直接调用objc_class结构体中的函数,但开发者可以使用Objective-C的runtime API(如objc_getClassclass_getInstanceSize等)来获取类的元信息和执行其他操作。然而,对于直接检查类是否“被初始化过”的需求,这些API可能并不直接提供所需的功能。在实际应用中,可能并不需要直接检查类是否“被初始化过”,而是可以通过检查该类的实例是否存在、类的某个特定方法是否被调用过等间接方式来判断。自定义与系统类相同的结构体并实现isInitialized()函数可能是一种模拟或抽象的方式。然而,在实际Objective-C开发中,这样的做法是不必要的,因为直接操作类的内部结构是违反封装原则且容易出错的。相反,开发者应该利用Objective-C提供的runtime API和其他设计模式来达成目标。提到通过赋值转换获取meta-class中的数据,这通常指的是利用Objective-C的runtime机制来查询类的元类信息。然而,直接“判断指定类是否在当前生命周期中是否被初始化过”并不是通过简单地查询元类数据就能实现的,因为这需要跟踪类的实例化过程,而不是仅仅查看元类的结构。

获取类结构体里面的数据

struct mock_objc_class : lazyFake_objc_object {    mock_objc_class* metaClass() {        #if __LP64__            return (mock_objc_class *)((long long)isa & ISA_MASK);        #else            return (mock_objc_class *)((long long)isa);        #endif    }    bool isInitialized() {        return metaClass()->data()->flags & RW_INITIALIZED;    }};

所有 OC 自定义类

Dl_info info;dladdr(&_mh_execute_header, &info);classes = objc_copyClassNamesForImage(info.dli_fname, &classCount);

是否初始化

struct mock_objc_class *objectClass = (__bridge struct mock_objc_class *)cls;BOOL isInitial = objectClass->isInitialized();

最后通过无用类占比指标(无用类数量/总类数量*100%)快速识别不再被使用的模块。对于无用类占比高的模块,进行下线或迁移处理,减少组件数量。

更细粒度无用方法检测方案有:

编译器优化

Xcode 14的编译器可能通过更智能的分析,识别并消除不必要的Retain和Release调用。这些调用在内存管理中是必要的,但在某些情况下,它们可能是多余的,因为对象的生命周期管理可以通过其他方式更有效地实现。在Objective-C的运行时层面,Xcode 14可能引入了更高效的内存管理策略。这些策略可能包括更快的对象引用计数更新、更智能的对象生命周期预测等,从而减少了Retain和Release操作的执行次数和开销。剥离了未使用的代码和库,包括那些与Retain和Release操作相关的部分。这种优化可以减少最终生成的二进制文件的大小。

一些配置对包体积的优化:

  • Generate Debug Symbols:在Levels选项内,将Generate Debug Symbols设置为NO,这可以减小安装包体积,但需要注意,这样设置后无法在断点处停下。
  • 舍弃老旧架构:舍弃不再支持的架构,如armv7,以减小安装包体积。
  • 编译优化选项:在Build Settings中,将Optimization Level设置为Fastest, Smallest [-Os],这个选项会开启那些不增加代码大小的全部优化,并让可执行文件尽可能小。同时,将Strip Debug Symbols During Copy和Symbols Hidden by Default在release版本设为yes,可以去除不必要的调试符号。
  • 预编译头文件:将Precompile Prefix Header设置为YES,预编译头文件可以加快编译速度,但需要注意,一旦PCH文件和引用的头文件内容发生变化,所有引用到PCH的源文件都需要重新编译。
  • 仅编译当前架构:在Debug模式下,将Build Active Architecture Only设置为YES,这样只编译当前架构的版本,可以加快编译速度。但在Release模式下,需要设置为NO以确保兼容性。
  • Debug Information Format:设置为DWARF,减少dSYM文件的生成,从而减少包体积。
  • Enable Index-While-Building Functionality:设置为NO,关闭Xcode在编译时建立代码索引的功能,以加快编译速度。

另外

还可以使用 -why_load 链接器标志来减少 iOS 应用程序的二进制文件大小, -why_load 标志的作用:它可以帮助开发者识别最终二进制文件中包含的不必要符号。

在 iOS 开发中,链接器负责将代码、库和资源结合成一个最终的可执行文件。在此过程中,可能会有一些不必要的代码被包含进去,例如未使用的库、重复的符号或模块。这些多余的代码会导致应用程序的二进制文件增大,进而影响应用的下载速度、安装时间以及设备的存储空间。

-ObjC 标志,它通常用于强制链接所有 Objective-C 代码到最终的二进制文件中。这在某些情况下是必要的,例如使用了某些需要反射的 Objective-C 代码时,但是它也会导致未使用的代码被包含进去。通过 -why_load,开发者可以识别出哪些代码是多余的,并通过删除 -ObjC 标志来减少文件大小。

性能分析

有些开源的工具可以直接用于性能分析。

  • XCTest XCTest 是 Apple 官方的单元测试框架,支持性能测试。开发者可以通过 measure 方法来衡量代码块的执行时间,从而发现性能瓶颈。适合需要在单元测试中添加性能测试的场景。
  • KSCrash KSCrash 是一个强大的崩溃报告框架,它不仅能够捕获崩溃信息,还能提供应用程序的性能数据,例如内存使用和 CPU 使用情况。适合需要深入了解崩溃原因并监控相关性能数据的场景。
  • GT (GDT, GodEye) GodEye 是一个开源的 iOS 性能监控工具包,提供了多种监控功能,包括 FPS、内存使用、CPU 使用率、网络请求、崩溃日志等。它有一个方便的 UI,可以实时显示性能数据。适合在开发过程中嵌入应用进行实时性能监控。
  • libimobiledevice libimobiledevice 是一个开源的库,提供了与 iOS 设备交互的 API,可以用来监控设备状态和性能,特别是对非越狱设备进行操作。

常用的 In-app Debug 工具有:

  • Flex 是一个功能强大的 In-app Debug 工具,允许开发者在应用内实时查看和修改视图层次结构、网络请求、用户默认设置等。它还支持动态调整 UI 以及调试其他 app 内部逻辑。无需重新编译代码即可直接调试;可以修改内存中的值来观察变化。
  • Chisel 是 Facebook 开发的一组 LLDB 命令集,专门用于在调试时提供更方便的操作。它能帮助开发者快速检查视图层次结构、查看控件信息等。与 Xcode LLDB 无缝集成,通过命令行调试视图、打印出布局相关信息等。
  • Reveal 是一个图形化的 In-app Debug 工具,它允许开发者在运行中的应用中实时查看和编辑视图层次结构,支持 2D 和 3D 的视图展示。提供直观的 UI 调试界面,可以轻松地查看和修改视图属性;支持 iOS 和 tvOS。
  • Lookin 是一个开源的 iOS 视觉调试工具,专门用于分析和检查 iOS 应用的界面结构。它提供类似于 Xcode 的 View Debugging 功能,但更加灵活和强大,尤其是在复杂 UI 布局的分析上。通过 Lookin,你可以轻松地获取 iOS 应用中的界面层级、布局信息,并进行实时的 UI 调试和调整。可以称之为开源版的 Reveal。

Bazel

介绍

Polyrepo(多仓库)在代码量不断增加,开发团队扩大后,会觉得不合适,比如配置 CI 工具的繁琐,容易出现冗余代码,构建出现问题滞后等。Monorepo 指的是将多个模块化的 package 或 library 放在一个高度模块化且可管理的单一代码仓库中。谷歌的 Blaze、Bazel,以及微软的 Lage 和 Rush 等工具都是典型的 Monorepo 工具。Bazel 是一个现代化的多语言构建和测试工具。

你可以理解为是现代化的 Make 工具,但更加强大。

Bazel 通过缓存和增量构建机制,可以有效减少重复构建时间。支持并行构建,能够利用多核处理器提高构建速度。这两个点应该就是最吸引人的地方了。

另外它还允许用户定义自己的构建规则。因此,Bazel 是很适合大型的项目,还有容器化的应用。

接下来我就详细的说下 Bazel 是怎么使用的。

Bazel 组织 iOS 工程结构的方式具有高度的模块化和可管理性。

  • WORKSPACE 文件:根目录的核心文件。每个使用 Bazel 的项目都会在项目根目录中包含一个 WORKSPACE 文件,这个文件定义了项目的整体环境和依赖项。它类似于项目的“入口点”,Bazel 通过它知道如何构建整个项目。
  • BUILD 文件:模块的定义。在 Bazel 中,每个独立的模块(如一个应用、库、测试等)都需要一个 BUILD 文件,这个文件定义了该模块的构建规则。通过 BUILD 文件,开发者可以指定模块的依赖项、构建方式(如编译源代码、生成静态库等),以及测试配置。
  • Targets(目标):构建单元。BUILD 文件中定义的每个构建任务被称为“Target”(目标),可以是一个 iOS 应用程序、一个静态库、或单元测试等。目标可以依赖其他目标,这样可以构建出复杂的依赖图,确保模块间的依赖关系被正确处理。
  • 模块化组织:模块隔离与复用。Bazel 鼓励将代码分解成多个模块,每个模块都可以独立构建和测试。这种模块化结构提高了代码的可复用性,也简化了依赖管理。
  • 依赖管理:声明式依赖。Bazel 使用声明式依赖管理,即通过 BUILD 文件明确指定每个模块依赖哪些其他模块。这种方式有助于避免传统 iOS 项目中常见的依赖冲突和版本管理问题。
  • 跨语言支持:对于使用多种编程语言的项目,Bazel 提供了原生支持。对于 iOS 工程,Bazel 既支持 Objective-C 和 Swift 的构建,也支持与其他语言(如 C++、Java)的集成。
  • 并行构建与缓存:增量构建和缓存。Bazel 的构建系统支持并行构建和缓存。它能够有效地重用已经构建的模块,避免重复构建,从而大幅缩短构建时间。
  • Xcode 集成:与 Xcode 协作。虽然 Bazel 可以独立执行构建任务,但它也提供了与 Xcode 的集成,开发者可以在 Xcode 中进行代码编辑和调试,同时使用 Bazel 进行构建和测试。

WORKSPACE 文件

WORKSPACE 文件是定义项目根目录的关键文件,它告诉 Bazel 项目依赖了哪些外部库和资源,并为整个构建过程提供了基础配置。下面是一个典型的 WORKSPACE 文件的结构和示例代码:

一个典型的 WORKSPACE 文件包括以下部分:

  • 加载 Bazel 提供的 iOS 相关规则集,如 rules_applerules_swift
  • 声明项目中使用的第三方库,通常使用 http_archivegit_repository 来加载外部依赖。
  • 配置目标平台、构建工具链等。
# WORKSPACE 文件的开头,定义需要加载的规则集# 引入苹果生态系统的 Bazel 规则load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")# 加载苹果的构建规则 (rules_apple)http_archive(    name = "build_bazel_rules_apple",    url = "https://github.com/bazelbuild/rules_apple/releases/download/1.0.0/rules_apple.1.0.0.tar.gz",    strip_prefix = "rules_apple-1.0.0",)# 加载 Swift 的构建规则 (rules_swift)http_archive(    name = "build_bazel_rules_swift",    url = "https://github.com/bazelbuild/rules_swift/releases/download/0.24.0/rules_swift.0.24.0.tar.gz",    strip_prefix = "rules_swift-0.24.0",)# 使用 rules_apple 提供的默认设置load("@build_bazel_rules_apple//apple:repositories.bzl", "apple_rules_dependencies")apple_rules_dependencies()# 使用 rules_swift 提供的默认设置load("@build_bazel_rules_swift//swift:repositories.bzl", "swift_rules_dependencies")swift_rules_dependencies()# 加载 CocoaPods 规则(如果项目中使用了 CocoaPods)http_archive(    name = "bazel_pod_rules",    url = "https://github.com/pinterest/PodToBUILD/releases/download/0.1.0/PodToBUILD.tar.gz",    strip_prefix = "PodToBUILD-0.1.0",)# 声明 Xcode 版本和 SDK 的目标设置(可选)load("@build_bazel_rules_apple//apple:config.bzl", "apple_common")apple_common.xcode_config(    name = "xcode_config",    default_ios_sdk_version = "14.5",    default_macos_sdk_version = "11.3",    default_watchos_sdk_version = "7.4",    default_tvos_sdk_version = "14.5",)# 声明项目中使用的第三方库(例如使用 gRPC 或其他库)load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")git_repository(    name = "com_github_grpc_grpc",    commit = "your_commit_hash",    remote = "https://github.com/grpc/grpc.git",)# 声明额外的外部依赖(例如 Swift Package Manager 包)load("@build_bazel_rules_swift//swift:repositories.bzl", "swift_package")swift_package(    name = "swift_lib_example",    repository = "https://github.com/apple/swift-argument-parser",    revision = "0.4.4",)# 配置 BUILD.bazel 文件所在目录中的第三方依赖load("@bazel_pod_rules//:defs.bzl", "new_pod_repository")new_pod_repository(    name = "AFNetworking",    url = "https://github.com/AFNetworking/AFNetworking.git",    tag = "4.0.1",)

rules_applerules_swift 是 Bazel 提供的官方规则集,用于构建 iOS 和 Swift 项目。通过 http_archive 你可以指定需要的规则集版本。http_archivegit_repository 用于加载第三方库或工具集成。new_pod_repository 是专门为 CocoaPods 提供的规则,用于管理 iOS 项目中的 CocoaPods 依赖。apple_common.xcode_config 用于指定 iOS SDK 版本、Xcode 版本等,可以确保项目在正确的环境下构建。

BUILD 文件

编写 iOS 程序的 BUILD 文件时,需要使用 Bazel 提供的专门规则来构建 iOS 应用、库和测试。这些规则可以帮助你定义目标、依赖项和其他构建配置。

基本概念

  • ios_application: 用于定义一个 iOS 应用的目标。
  • objc_library: 用于定义一个 Objective-C 或 Swift 库。
  • ios_unit_testios_ui_test: 用于定义 iOS 的单元测试和 UI 测试目标。
  • apple_binary: 用于定义一个包含所有依赖的 iOS 可执行文件,通常与 ios_application 一起使用。

假设我们有一个简单的 iOS 项目,它包含一个应用和一个静态库,项目结构如下:

项目结构

my_ios_project/├── WORKSPACE├── BUILD├── App/│   ├── BUILD│   ├── AppDelegate.swift│   ├── ViewController.swift│   ├── Assets.xcassets│   └── Main.storyboard└── Libs/    ├── BUILD    ├── MyLib.swift    └── MyLib.h

Libs/BUILD 文件

首先,定义一个 Objective-C/Swift 库,这个库将在应用中使用:

# 用于定义一个 Objective-C 或 Swift 的库。objc_library(    name = "MyLib", # 库目标的名称。    srcs = ["MyLib.swift"], # 源文件列表(包括 Swift 和 Objective-C 文件)。    hdrs = ["MyLib.h"], // 头文件列表(如果有 Objective-C 文件)。    visibility = ["//visibility:public"],  # 公开可见,以供其他目标使用)

接下来,定义 iOS 应用目标,并指定它依赖于上面定义的库:

# 用于定义一个 iOS 应用目标。ios_application(    name = "MyApp", # 应用目标的名称。    bundle_id = "com.example.MyApp", # 应用的唯一标识符。    families = ["iphone", "ipad"], # 目标设备类型(如 iPhone 和 iPad)。    infoplists = ["Info.plist"], # 应用的 `Info.plist` 文件。    srcs = ["AppDelegate.swift", "ViewController.swift"], # 应用的源文件列表(Swift 和 Objective-C)。    storyboards = ["Main.storyboard"],     resources = glob(["Assets.xcassets/**/*"]), # 应用的资源文件,如图像、音效等,使用 `glob` 语法可以方便地将多个资源文件包含在 `BUILD` 文件中。    deps = ["//Libs:MyLib"],  # 依赖于 MyLib 库。 `deps` 参数用来定义该目标依赖的其他库或目标,Bazel 会自动处理这些依赖关系并确保它们的构建顺序正确。)

通常在项目的根目录也会有一个 BUILD 文件来聚合或定义一些全局目标,或仅作为入口文件:

# 设置包的默认可见性,这里设置为对所有目标公开可见。package(default_visibility = ["//visibility:public"]) # 创建别名,方便从顶层访问应用目标。alias(    name = "app",    actual = "//App:MyApp",)

Starlark 语言

Starlark 是一种由 Bazel 使用的嵌入式编程语言,用于定义构建规则和操作构建文件。它类似于 Python,专门设计用于 Bazel 的构建系统,允许用户扩展 Bazel 的功能。在 iOS 工程构建中,Starlark 主要用于编写自定义的规则、宏和函数。

Starlark 基础语法

Starlark 的语法类似 Python,包括变量、函数、条件、循环等基本结构。

变量与函数

# 定义变量message = "Hello, Starlark!"# 定义函数def greet(name):    return "Hello, " + name + "!"

条件与循环

# 条件语句def is_even(x):    if x % 2 == 0:        return True    else:        return False# 循环语句def sum_of_evens(limit):    sum = 0    for i in range(limit):        if is_even(i):            sum += i    return sum

使用 Starlark 自定义 iOS 构建

假设你想要定义一个自定义的 iOS 静态库规则,它能够简化库的定义并统一管理依赖。

项目结构

my_ios_project/├── WORKSPACE├── BUILD├── app/│   ├── BUILD│   ├── AppDelegate.swift│   └── ViewController.swift└── libs/    ├── BUILD    ├── mylib.swift    └── lib.bzl

编写 lib.bzl 文件

libs/ 目录下创建一个 lib.bzl 文件,定义自定义的 iOS 静态库规则。

# 这是一个宏,用于简化 `objc_library` 规则的定义。通过这种方式,你可以统一管理 ARC 选项、依赖等设置。def ios_static_library(name, srcs, hdrs = [], deps = []):    objc_library(        name = name,        srcs = srcs,        hdrs = hdrs,        deps = deps,        copts = ["-fobjc-arc"],  # 指定编译选项,如在此处启用 ARC。    )

使用 lib.bzl 文件中的宏

libs/BUILD 文件中使用上面定义的宏来创建一个 iOS 静态库。

# 用于加载 Starlark 文件中的宏或函数。在此例中,`//libs:lib.bzl` 表示加载 `libs` 目录中的 `lib.bzl` 文件。load("//libs:lib.bzl", "ios_static_library")# `ios_static_library` 宏会被调用来定义一个名为 `mylib` 的 iOS 静态库。ios_static_library(    name = "mylib",    srcs = ["mylib.swift"],)

app/BUILD 文件中,定义一个 iOS 应用目标,并依赖于上述的静态库:

ios_application(    name = "MyApp",    bundle_id = "com.example.MyApp",    families = ["iphone", "ipad"],    infoplists = ["Info.plist"],    srcs = ["AppDelegate.swift", "ViewController.swift"],    deps = ["//libs:mylib"],)

自定义 iOS Framework 构建的示例

你可以使用 Starlark 编写更复杂的规则,例如为 iOS 定制一个 Framework 的构建规则:

# 这是一个 Bazel 的内置规则,用于创建 iOS Framework。自定义的 `ios_framework` 宏将静态库打包成一个 Framework,简化了应用与库之间的集成。def ios_framework(name, srcs, hdrs = [], deps = [], bundle_id = None):    objc_library(        name = name + "_lib",        srcs = srcs,        hdrs = hdrs,        deps = deps,    )    apple_framework(        name = name,        bundle_id = bundle_id,        infoplists = ["Info.plist"],        deps = [":" + name + "_lib"],    )

运行

在终端中运行以下命令来构建 iOS 应用。

构建应用

bazel build //App:MyApp

运行应用

bazel run //App:MyApp

测试应用

bazel test //App:MyAppTests

rules_xcodeproj 生成 Xcode 工程

rules_xcodeproj 是一个用于生成 Xcode 工程文件 (.xcodeproj) 的 Bazel 插件。它允许你在使用 Bazel 构建系统的同时,仍然能够使用 Xcode 进行开发和调试。它目前支持两种主要的构建模式:BwB (Build with Bazel) 和 **BwX (Build with Xcode)**。
BwB 模式是将 Bazel 作为主要的构建工具,Xcode 项目仅用于 IDE 支持,而实际的构建过程完全由 Bazel 管理。BwX 模式官方后续支持会变弱,不建议使用。

首先,在你的 WORKSPACE 文件中添加 rules_xcodeproj 规则的依赖项。

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")http_archive(    name = "build_bazel_rules_xcodeproj",    sha256 = "<SHA256>",    url = "https://github.com/buildbuddy-io/rules_xcodeproj/releases/download/<version>/rules_xcodeproj-<version>.tar.gz",)load("@build_bazel_rules_xcodeproj//:workspace_setup.bzl", "rules_xcodeproj_workspace_setup")rules_xcodeproj_workspace_setup()

你需要替换 <SHA256><version> 为相应的值,可以从 rules_xcodeproj 的发布页面 获取。

在项目的 BUILD.bazel 文件中,使用 xcodeproj 规则生成 .xcodeproj 文件。例如:

load("@build_bazel_rules_xcodeproj//:defs.bzl", "xcodeproj")xcodeproj(    name = "MyApp_xcodeproj", # 定义生成的 `.xcodeproj` 的目标名称。    project_name = "MyApp", # 定义 Xcode 工程的名称。    targets = ["//app:MyApp"], # 指定 Bazel 中需要包含在 Xcode 工程中的目标。)

在命令行中,运行以下命令生成 Xcode 工程文件:

bazel run //:MyApp_xcodeproj

这将生成一个名为 MyApp.xcodeproj 的文件,位于你运行命令的目录中。你可以用 Xcode 打开这个工程文件,并在 Xcode 中调试和开发你的应用。

rules_xcodeproj 提供了多种配置选项,你可以根据需要进行自定义。例如,可以配置生成的 Xcode 工程中的编译设置、构建配置等。以下是一些常用的配置:

xcodeproj(    name = "MyApp_xcodeproj",    project_name = "MyApp",    targets = ["//app:MyApp"],    build_settings = {        "SWIFT_VERSION": "5.0",        "CODE_SIGN_IDENTITY": "",    }, # 指定 Xcode 工程的编译设置,例如 Swift 版本、代码签名等。    extra_generated_files = ["//path/to/resource"], #指定额外的生成文件,可能包括资源文件等。)

Build with Proxy 模式

rules_xcodeproj 新推出的 Build with Proxy 模式,是一种新的构建模式。在 “Build with Proxy” 模式下,Bazel 通过 XCBBuildServiceProxy 完全接管了整个构建过程。Xcode 在这个模式下只作为一个前端界面,所有的构建逻辑和执行都由 Bazel 来完成。在 “Build with Bazel” 模式下,Xcode 依然是主导构建过程的工具,但它在构建的关键步骤(如编译和链接)上调用 Bazel 来完成实际的工作。Xcode 会生成编译任务并将其委托给 Bazel,同时保持对构建过程的部分控制权。

流程是,当开发者在 Xcode 中触发构建时,XCBBuildServiceProxy 拦截 Xcode 的构建请求。构建请求被重定向到 Bazel,由 Bazel 完全控制构建过程,包括依赖管理、编译、链接等。构建结果通过 XCBBuildServiceProxy 返回给 Xcode,Xcode 仅作为显示界面。

Bazel 完全控制构建过程,提供更高效的构建性能和更一致的结果。由于 Xcode 不再控制构建过程,调试和查看构建日志可能需要适应 Bazel 的方式,还有更高的初始配置成本。

首先,你需要在 Bazel 的 WORKSPACE 文件中引入 rules_xcodeproj

http_archive(    name = "build_bazel_rules_xcodeproj",    url = "https://github.com/buildbuddy-io/rules_xcodeproj/releases/download/{version}/release.tar.gz",    sha256 = "{sha256}",)load("@build_bazel_rules_xcodeproj//xcodeproj:workspace.bzl", "xcodeproj_dependencies")xcodeproj_dependencies()

接着,在你的 BUILD 文件中配置 Xcode 项目生成规则,并启用 “Build with Proxy” 模式:

load("@build_bazel_rules_xcodeproj//xcodeproj:xcodeproj.bzl", "xcodeproj")xcodeproj(    name = "MyAppProject",    targets = ["//App:MyApp"],    build_mode = "build_with_proxy",  # 启用 "Build with Proxy" 模式    minimum_xcode_version = "14.0",    # 其他配置...)

生成 Xcode 项目文件:

bazel run //:MyAppProject

生成的 .xcodeproj 文件将会配置为使用 Bazel 进行构建。

XCBBuildServiceProxy 是核心代理组件,它通过拦截 Xcode 的构建请求并将其转发给 Bazel 进行处理。在 “Build with Proxy” 模式下,Xcode 的构建流程大致如下:

# 当你在 Xcode 中点击“构建”时,Xcode 会调用 XCBBuildServiceProxy。# XCBBuildServiceProxy 会将构建请求转发给 Bazel。bazel build //App:MyApp# Bazel 处理所有构建任务,包括编译、链接等。# 构建完成后,Bazel 将结果返回给 XCBBuildServiceProxy。# XCBBuildServiceProxy 将结果反馈给 Xcode,Xcode 显示构建输出。

为了确保 Xcode 在构建时使用 Bazel,你需要配置项目的 Scheme。在生成的 .xcodeproj 文件中,确保构建 Scheme 设置为使用 XCBBuildServiceProxy 调用 Bazel。

生成 IPA 包的过程

当你运行 bazel build //App:MyApp 这条命令时,Bazel 会从指定的目标 //App:MyApp 开始,递归解析其依赖树,执行构建过程,最终生成一个 IPA 文件。

//App:MyApp 是一个 Bazel 目标,它指向一个定义在 App/BUILD.bazel 文件中的构建规则。Bazel 首先会解析这个目标并确定其直接依赖项。

假设在 App/BUILD.bazel 文件中定义了一个 ios_application 规则:

ios_application(    name = "MyApp",    bundle_id = "com.example.myapp",    families = ["iphone", "ipad"],    infoplists = ["Info.plist"],    entitlements = "MyApp.entitlements",    provisioning_profile = "//:MyAppProfile",    app_icon = "AppIcon",    launch_images = ["LaunchImage"],    deps = [        "//App/Core:core_lib",        "//App/UI:ui_lib",    ],)

在这个例子中,MyApp 依赖于两个库 core_libui_lib

Bazel 会递归地解析 deps 字段中的依赖项,从而构建整个依赖树。在上面的例子中,Bazel 会进一步解析 //App/Core:core_lib//App/UI:ui_libBUILD.bazel 文件。

假设 core_libui_lib 是通过 objc_library 规则定义的:

# App/Core/BUILD.bazelobjc_library(    name = "core_lib",    srcs = ["CoreLib.m"],    hdrs = ["CoreLib.h"],    deps = [        "//third_party/some_lib:some_lib",    ],)
# App/UI/BUILD.bazelobjc_library(    name = "ui_lib",    srcs = ["UILib.m"],    hdrs = ["UILib.h"],    deps = [        "//App/Core:core_lib",    ],)

在这里,ui_lib 依赖于 core_lib,而 core_lib 依赖于一个第三方库 some_lib

在解析完依赖树后,Bazel 开始实际的构建过程。这包括编译源文件、链接目标文件、处理资源文件,并最终打包为一个 IPA 文件。

Bazel 会首先编译 objc_library 目标。比如,将 CoreLib.mUILib.m 文件编译为 .o 对象文件,并处理相应的头文件。之后,Bazel 将链接这些编译后的对象文件,生成静态库或可执行文件。Bazel 将所有编译结果(如可执行文件、静态库)、资源文件(如 Info.plist、图标)打包为一个 .app 目录。最后,Bazel 使用 ios_application 规则的配置,将 .app 目录压缩并签名为一个 IPA 文件。

Bazel 通过其强大的缓存和增量构建机制,只重新构建那些发生变化的目标。例如,如果只修改了 UILib.m 文件,那么 Bazel 只会重新编译 ui_lib 相关的目标,而不需要重新构建整个应用。

生成的 IPA 文件通常会保存在 bazel-bin 目录中,路径类似于 bazel-bin/App/MyApp.ipa

依赖分析

Bazel 的依赖分析(dependency analysis)是其构建系统中关键的一部分,用于决定哪些文件或目标需要重新构建,以及哪些可以重用之前的构建结果。这一过程高度依赖于 Bazel 的增量构建和缓存机制。

Bazel 依赖分析的核心步骤

  • 目标(Target)定义与依赖图:Bazel 使用 BUILD 文件定义构建目标(如库、应用、测试等)以及这些目标之间的依赖关系。这些依赖关系形成了一个有向无环图(DAG),用于描述项目的依赖结构。
  • 文件和目标的输入输出(Input/Output)追踪:Bazel 追踪每个目标的输入(源文件、依赖项)和输出(编译后的二进制文件、对象文件等)。任何影响输入的更改都会触发相应目标的重新构建。
  • 哈希校验与缓存:Bazel 对每个目标的输入文件进行哈希校验(如 MD5 或 SHA-256),并将其存储在缓存中。如果同一目标的输入哈希值未发生变化,则 Bazel 直接使用缓存中的构建结果,而不需要重新构建。
  • 增量构建:当 Bazel 发现输入文件发生了变化,它会自动标记该目标以及依赖于该目标的所有下游目标为“脏”(dirty),这些目标将在下一次构建时重新编译。
  • 依赖分析的递归性:Bazel 的依赖分析是递归进行的。如果一个目标的依赖发生变化,Bazel 将递归地检查其所有上游目标是否需要重建。

以下是一个简单的 Bazel 项目结构示例,展示了 Bazel 的依赖分析过程:

项目结构

my_project/├── WORKSPACE├── BUILD├── main/│   ├── BUILD│   ├── main.m│   └── AppDelegate.m└── libs/    ├── BUILD    ├── libA.m    ├── libA.h    ├── libB.m    └── libB.h

项目根目录的 BUILD 文件:

# 根目录下的 BUILD 文件ios_application(    name = "MyApp",    srcs = ["main/main.m", "main/AppDelegate.m"],    deps = [        "//libs:libA",        "//libs:libB",    ],)

libs/ 目录的 BUILD 文件:

# libs 目录下的 BUILD 文件objc_library(    name = "libA",    srcs = ["libA.m"],    hdrs = ["libA.h"],)objc_library(    name = "libB",    srcs = ["libB.m"],    hdrs = ["libB.h"],    deps = [":libA"],  # libB 依赖于 libA)

Bazel 的依赖分析过程

  • 依赖图的生成:MyApp 依赖于 libAlibB,而 libB 又依赖于 libA。Bazel 会根据这些依赖关系生成一个依赖图。
  • 输入输出追踪与哈希校验:在每次构建时,Bazel 会对 libA.mlibB.mmain.m 等输入文件进行哈希校验,并将结果与上次构建时的哈希值进行比较。例如,如果 libA.m 发生了变化,Bazel 会检测到其哈希值发生了变化,从而标记 libA 及依赖于它的 libBMyApp 为“脏”。
  • 增量构建:由于 libA.m 发生了变化,Bazel 将重新构建 libA,然后递归地重新构建依赖它的 libB,最终重新构建 MyApp
  • 缓存与重用:如果 libB.mmain.m 没有变化,Bazel 可以重用它们之前的编译结果(缓存),只需要重新构建那些受影响的目标。
  • 输出结果:最终,Bazel 生成一个新的 MyApp 二进制文件,包含了最新的代码改动,并保证所有依赖关系都得到了正确的处理。

Bazel 使用哈希校验来精确判断哪些输入文件发生了变化。只有当输入文件的哈希值变化时,才会触发相应目标的重新构建,这样可以最大程度地重用已有的构建结果,减少不必要的编译时间。Bazel 的依赖分析是递归的,这意味着任何下游依赖的变化都会向上递归地影响依赖它的所有目标。这确保了每次构建的结果都是一致且正确的。由于 Bazel 精确地追踪了目标的依赖关系和输入输出变化,它能够有效地执行增量构建,只重新编译那些受影响的模块。

不会影响依赖分析缓存的代码改动有哪些呢?

在 Bazel 中,构建系统的性能很大程度上依赖于其增量构建和缓存机制。Bazel 使用依赖分析(dependency analysis)来决定哪些部分的代码需要重新构建,哪些部分可以使用缓存结果。

以下是一些不会影响依赖分析缓存的代码改动类型,这些改动不会导致 Bazel 重新构建依赖的目标,因为它们不会改变编译输出或依赖图:

  • 注释的更改:添加、删除或修改代码中的注释不会影响构建输出,因为注释不参与代码编译。
  • 代码格式化:仅涉及代码格式(如缩进、空格、换行)的改动不会影响构建结果,格式化不会改变编译后的二进制文件。
  • 无实际影响的变量命名更改:在局部范围内(如函数内部)修改变量名称(而不影响函数签名)不会影响依赖分析缓存。
  • 无效或未使用代码的添加:添加从未使用的代码(如未调用的函数)在某些情况下不会触发 Bazel 的重构建,特别是在这些代码片段与已构建目标无关时。
  • 函数内部的逻辑更改:在某些情况下,对函数内部进行的改动可能不会影响其他模块的构建,具体取决于目标间的依赖关系和可见性(例如,私有函数内部的更改)。

以下是一个具体的代码示例,展示了不会影响 Bazel 依赖分析缓存的几种改动:

# 示例 BUILD 文件# 定义一个简单的 iOS 应用程序目标ios_application(    name = "MyApp",    srcs = ["main.m", "AppDelegate.m"],    deps = [":MyLibrary"],)objc_library(    name = "MyLibrary",    srcs = ["MyLibrary.m"],    hdrs = ["MyLibrary.h"],)

假设我们有以下 Objective-C 代码:

// MyLibrary.m#import "MyLibrary.h"// 1. 注释的改动// 添加一些注释,不会影响 Bazel 的依赖分析缓存// 例如:以下注释不会触发重新构建// This is a utility function@implementation MyLibrary// 2. 变量名更改(局部范围)。在函数内部修改变量名称不会影响其他目标或模块的编译结果,只要变量名的改变不影响接口或其他模块的依赖。- (void)performTask {    int localVar = 5;  // 如果将 localVar 改为 anotherVar,这不会触发重新构建    NSLog(@"Task performed");}// 3. 代码格式改动。如添加空行、调整缩进或更改代码对齐方式等纯粹的格式改动,不会改变源代码的语义,因此不会触发重新编译。- (void)doSomething {    int a = 10;    int b = 20;  // 对齐方式或空格的改变不会触发重新构建    NSLog(@"Sum: %d", a + b);}// 4. 添加未使用的代码。如果添加的代码从未被调用或引用,Bazel 可能不会重新构建该模块,尤其是在该代码片段没有影响编译输出时。- (void)unusedFunction {    NSLog(@"This function is never called.");}@end

在 Bazel 的构建过程中,操作图(Action Graph)是一个关键的概念,它定义了构建任务之间的依赖关系,并确保这些任务能够按照正确的顺序并行执行。Baziel 使用操作图来确定哪些任务可以并行执行,哪些任务需要依赖其他任务的结果。

操作图是一个有向无环图(DAG),其中每个节点代表一个操作(Action),每个边代表操作之间的依赖关系。操作可能包括编译源文件、链接对象文件、打包资源文件等。

操作图中的节点和边的关系如下:

  • 节点(Action):一个构建任务,如编译、链接或打包。
  • 边(Dependency): 表示一个操作依赖于另一个操作的输出。

Bazel 从指定的构建目标(如 bazel build //App:MyApp)开始,递归地解析 BUILD 文件中定义的目标和依赖关系,生成操作图。具体步骤如下:

  1. Bazel 解析 BUILD 文件,找到指定目标和其依赖项。
  2. 每个构建规则(如 objc_library, ios_application)会生成一组操作。这些操作可能包括编译源文件、链接目标文件等。
  3. Bazel 将生成的操作按照依赖关系连接起来,形成操作图。

Bazel 确保操作图中的操作按正确的顺序并行运行,遵循以下原则:

  • 一个操作只能在它所有的依赖操作完成后才能运行。
  • Bazel 会并行执行那些没有依赖关系或者依赖已经满足的操作。

假设我们有一个简单的项目,其中包含两个库和一个应用程序。每个库都有自己的源文件和头文件,应用程序依赖于这两个库。以下是 BUILD 文件的定义:

# App/Core/BUILD.bazelobjc_library(    name = "core_lib",    srcs = ["CoreLib.m"],    hdrs = ["CoreLib.h"],)# App/UI/BUILD.bazelobjc_library(    name = "ui_lib",    srcs = ["UILib.m"],    hdrs = ["UILib.h"],    deps = [        "//App/Core:core_lib",    ],)# App/BUILD.bazelios_application(    name = "MyApp",    bundle_id = "com.example.myapp",    families = ["iphone", "ipad"],    infoplists = ["Info.plist"],    deps = [        "//App/Core:core_lib",        "//App/UI:ui_lib",    ],)

对于上述项目,Bazel 会生成如下操作图:

  1. 编译操作:

    • CoreLib.m -> CoreLib.ocore_lib 的编译操作)
    • UILib.m -> UILib.oui_lib 的编译操作)
  2. 链接操作:

    • core_lib 编译完成后,可以立即编译 ui_lib,因为 ui_lib 依赖于 core_lib
    • core_libui_lib 都编译完成后,可以将它们链接到一起,生成 MyApp 的可执行文件。
  3. 打包操作:

    • 在所有链接操作完成后,将生成的二进制文件与资源文件(如 Info.plist)打包为 .app 目录,然后进一步打包为 IPA 文件。

在这个操作图中,CoreLib.oUILib.o 的编译操作可以并行执行,因为它们没有依赖关系。链接操作则需要等待所有编译操作完成后才能执行。

Bazel 在内部使用操作图来调度这些任务。通过分析操作图,Bazel 能够确定哪些任务可以并行执行,哪些任务需要等待依赖完成,从而最大化利用多核 CPU 的能力,加速构建过程。

query指令找依赖关系

Bazel 的 query 命令是一种强大的工具,用于在 Monorepo(单体代码库)中查找和分析目标之间的依赖关系。通过 query,你可以获取关于构建目标的详细信息,包括它们的依赖关系、反向依赖、测试等。

bazel query 命令的一般语法如下:

bazel query '<expression>'

<expression> 是你想要查询的表达式。Bazel 提供了一系列表达式来帮助你查找所需的信息。

以下是常见的 Bazel Query 表达式

列出工作区中所有可用的构建目标:

bazel query '//...'

//... 表示从当前工作区的根目录开始递归查找所有目标。

查找某个目标的所有直接和间接依赖:

bazel query 'deps(<target>)'

例如,查找 //app:main 目标的所有依赖:

bazel query 'deps(//app:main)'

查找哪些目标依赖于某个特定目标(即反向依赖):

bazel query 'rdeps(<scope>, <target>)'

例如,查找工作区中哪些目标依赖于 //lib:my_library

bazel query 'rdeps(//..., //lib:my_library)'

例如,列出所有的测试目标:

bazel query 'kind(test, //...)'

kind(test, //...) 将查找工作区中的所有测试目标。

如果只想查找目标的直接依赖而非递归依赖,可以使用:

bazel query 'deps(<target>, 1)'

例如:

bazel query 'deps(//app:main, 1)'

使用 attr 过滤带有特定属性的目标。例如,查找所有带有特定标签的目标:

bazel query 'attr(tags, "my_tag", //...)'

假设你有以下项目结构:

workspace/├── app/│   ├── BUILD│   ├── main.swift│   └── AppDelegate.swift├── lib/│   ├── BUILD│   ├── util.swift│   └── helper.swift└── third_party/    ├── BUILD    └── external_lib.swift

app/BUILD 文件中,你定义了一个 ios_application 目标:

ios_application(    name = "MyApp",    bundle_id = "com.example.MyApp",    srcs = ["main.swift", "AppDelegate.swift"],    deps = ["//lib:util"],)

lib/BUILD 文件中定义了一个 swift_library 目标:

swift_library(    name = "util",    srcs = ["util.swift", "helper.swift"],    deps = ["//third_party:external_lib"],)

你可以运行以下命令来查找 MyApp 的所有直接和间接依赖:

bazel query 'deps(//app:MyApp)'

这将输出:

//app:MyApp//lib:util//third_party:external_lib

查找依赖于 external_lib 的所有目标

你可以使用以下命令来查找反向依赖:

bazel query 'rdeps(//..., //third_party:external_lib)'

这将列出所有依赖于 external_lib 的目标,比如 //lib:util

你还可以生成图形化的依赖关系图,使用 dot 格式输出:

bazel query 'deps(//app:MyApp)' --output graph > graph.dot

然后使用 Graphviz 等工具将 graph.dot 文件转换为图形文件。

query 指令是理解和管理 Monorepo 中依赖关系的关键工具。它提供了多种强大的表达式,帮助你轻松地查找目标的依赖关系、反向依赖、过滤目标等。在大型代码库中,使用 query 可以大大简化依赖关系的管理,并且可以帮助你识别不必要的依赖或者循环依赖。

远程缓存

Bazel 的远程缓存功能允许你在不同的开发环境、构建机器或 CI 系统之间共享构建产物。这可以显著加快构建速度,因为已经构建好的产物可以被重复使用,而不需要重新编译。

Bazel 的远程缓存功能可以将构建产物(如编译后的二进制文件、对象文件等)存储在一个远程存储系统中。当你在不同环境或机器上构建同一个项目时,Bazel 会检查远程缓存,并下载已存在的构建产物,而不必重新构建。

Bazel 支持多种远程缓存后端,包括:

  • HTTP/HTTPS 服务器:可以使用支持 HTTP 的远程服务器作为缓存。
  • 云存储:如 Google Cloud Storage (GCS) 或 Amazon S3。
  • gRPC 缓存服务:可以通过 gRPC 接口进行缓存和检索。

在你的项目中,可以通过 ~/.bazelrc 文件或项目级别的 .bazelrc 文件来配置远程缓存。以下是如何配置不同类型远程缓存的示例。

配置 HTTP 远程缓存

build --remote_cache=http://my-cache-server.com/cache/

如果你使用 Google Cloud Storage (GCS) 作为远程缓存,你可以这样配置:

build --remote_cache=grpc://gcs.example.com/bucket-namebuild --google_credentials=/path/to/credentials.json

在这个例子中,grpc://gcs.example.com/bucket-name 是 GCS 的地址,/path/to/credentials.json 是你的 GCS 凭证文件。

配置 gRPC 远程缓存

build --remote_cache=grpc://my-grpc-cache-server.com

你可以使用 gRPC 缓存服务器,如 BuildBarn 或 BuildGrid 来搭建自己的 gRPC 远程缓存服务。

有些远程缓存服务需要身份认证,如 GCS 或 Amazon S3。对于 GCS,你可以配置 google_credentials 选项,或者使用 gcloud auth 命令登录:

gcloud auth application-default login

对于需要 AWS 认证的服务,你可以配置 AWS CLI,然后通过环境变量传递认证信息:

export AWS_ACCESS_KEY_ID="your-access-key-id"export AWS_SECRET_ACCESS_KEY="your-secret-access-key"

配置完成后,Bazel 会自动使用远程缓存。在运行构建命令时,如:

bazel build //App:MyApp

Bazel 会:

  1. 首先检查远程缓存,是否有匹配当前源代码和构建配置的缓存。
  2. 如果找到匹配的缓存,直接下载使用,而不重新编译。
  3. 如果没有找到匹配的缓存,正常编译并将结果上传到远程缓存,以便下次使用。

注意远程缓存和远程执行是不同的概念。远程缓存仅共享构建产物,而远程执行允许你在远程机器上执行整个构建过程。你可以根据需要选择合适的方案。

以下是一个项目级别的 .bazelrc 文件示例,它配置了远程缓存到一个 HTTP 服务器:

# .bazelrcbuild --remote_cache=http://cache.example.com/cache/build --disk_cache=/path/to/local/cachebuild --google_default_credentials

远程执行配置

Bazel 的远程执行功能允许你在远程服务器或集群上分布式执行构建任务,而不是在本地机器上执行。这种能力特别适用于大规模的项目,可以显著缩短构建时间,因为它利用了多台机器的计算资源。

远程执行让 Bazel 在远程执行环境中运行构建任务,例如编译、链接、测试等。Bazel 将构建任务分发到一个或多个远程执行节点,这些节点并行处理任务并将结果返回给本地 Bazel 客户端。

一个典型的远程执行环境由以下组件组成:

  • 远程执行服务器:处理来自 Bazel 的任务,并将它们分发给执行节点。
  • 远程工作节点:这些节点执行实际的构建任务。
  • Remote Cache(远程缓存):存储构建产物以便重复使用,避免重新执行相同任务。

要启用 Bazel 的远程执行功能,你需要配置 Bazel 来连接远程执行服务。配置通常在 .bazelrc 文件中完成。

假设你有一个远程执行服务器,它的地址是 remotebuild.example.com。你可以通过以下配置启用远程执行:

# .bazelrcbuild --remote_executor=grpc://remotebuild.example.com:443build --remote_cache=grpc://remotebuild.example.com:443build --remote_timeout=300build --spawn_strategy=remotebuild --strategy=Javac=remotebuild --strategy=CppCompile=remotebuild --strategy=Objc=remote
  • --remote_executor:指定远程执行服务器的地址。
  • --remote_cache:配置远程缓存的地址,这里可以和远程执行服务器一致。
  • --remote_timeout:设置远程执行的超时时间。
  • --spawn_strategy=remote:告诉 Bazel 使用远程策略执行所有构建任务。
  • --strategy=Javac=remote 等:为特定类型的任务指定使用远程执行。

如果远程执行服务器需要身份验证,你可能需要配置凭据。对于 Google Cloud Remote Build Execution (RBE) 服务,典型的配置如下:

build --google_credentials=/path/to/credentials.json

使用 gcloud 工具登录:

gcloud auth application-default login

设置远程执行服务(如 BuildFarm、BuildGrid 或 Google 的 Remote Build Execution (RBE))通常涉及以下步骤:

  1. 安装和配置 Remote Execution Server:这包括配置服务器的计算资源、执行策略等。
  2. 配置 Remote Workers:确保工作节点能够连接到服务器,并具备执行构建任务所需的环境和依赖。
  3. 配置 Remote Cache:搭建和配置远程缓存,以便存储和共享构建产物。

配置完成后,你可以运行 Bazel 命令进行远程执行,例如:

bazel build //App:MyApp

在这个过程中,Bazel 会:

  1. 将构建请求发送到远程执行服务器。
  2. 服务器将任务分发到远程工作节点,并行执行。
  3. 远程节点完成任务后,将结果和构建产物返回到本地。
  4. 本地 Bazel 客户端将最终产物(如可执行文件或 IPA 文件)生成。

使用远程执行的好处

  • 通过分布式构建,可以显著缩短构建时间。
  • 充分利用远程集群的计算资源,而不是依赖本地机器的性能。
  • 确保所有开发人员、CI/CD 系统在相同的环境中执行构建,减少“在我机器上正常”的问题。

假设你有一个项目 App,其中包括一个 BUILD 文件。以下是如何在远程执行环境中构建这个项目的完整配置。

.bazelrc 文件:

build --remote_executor=grpc://remotebuild.example.com:443build --remote_cache=grpc://remotebuild.example.com:443build --google_credentials=/path/to/credentials.jsonbuild --spawn_strategy=remotebuild --strategy=CppCompile=remotebuild --strategy=Javac=remotebuild --strategy=Objc=remote

然后你可以执行以下命令:

bazel build //App:MyApp

自定义构建规则

Bazel 的可扩展性是其强大功能之一,它允许开发者为尚未支持的编程语言或构建工具创建自定义的构建规则。通过编写自定义规则,你可以让 Bazel 识别、编译、链接特定语言的代码,并将它们集成到现有的 Bazel 构建系统中。

在自定义规则中,你可以指定输入、输出、依赖关系以及构建过程中的具体操作。

一个自定义的 Bazel 构建规则通常包括以下部分:

  • 规则定义:描述构建过程的逻辑和依赖关系。
  • 构建步骤:实际执行的命令,比如编译或链接操作。
  • 规则调用:在 BUILD 文件中调用自定义规则来应用于实际项目。

假设我们要为一个尚未被官方支持的编程语言 MyLang 创建一个简单的构建规则,该规则能够将 .mylang 源文件编译为可执行文件。

首先,在项目的根目录下创建一个 mylang_rules.bzl 文件,用于定义 MyLang 的构建规则。

# mylang_rules.bzldef _mylang_binary_impl(ctx):    # 输入文件    source = ctx.file.src        # 输出文件 (可执行文件)    output = ctx.actions.declare_file(ctx.label.name)        # 编译命令    ctx.actions.run(        inputs=[source],        outputs=[output],        arguments=[source.path, "-o", output.path],        executable="path/to/mylang_compiler",    )    return DefaultInfo(        executable=output,    )# 定义 mylang_binary 规则mylang_binary = rule(    implementation=_mylang_binary_impl,    attrs={        "src": attr.label(allow_single_file=True),  # 单个源文件    },    executable=True,  # 生成可执行文件)

_mylang_binary_impl 实现了 mylang_binary 规则的逻辑,它使用 Bazel 的 ctx.actions.run 来定义编译过程。mylang_binary定义了一个新的构建规则,允许我们在 BUILD 文件中使用 mylang_binary 规则来处理 MyLang 源文件。

在你的项目中,使用自定义的 mylang_binary 规则。比如,在 my_project/BUILD 文件中:

# my_project/BUILDload("//:mylang_rules.bzl", "mylang_binary")mylang_binary(    name = "my_program",    src = "main.mylang",)

这个 BUILD 文件表示使用 mylang_binary 规则编译 main.mylang 文件,并生成一个名为 my_program 的可执行文件。

你可以通过 Bazel 构建这个项目:

bazel build //my_project:my_program

这将使用 MyLang 编译器将 main.mylang 编译为 my_program 可执行文件。

自定义规则的功能可以进一步扩展。例如,你可以添加支持多个源文件、库依赖、资源文件等。如果你希望 mylang_binary 支持多个源文件,可以修改规则定义:

# mylang_rules.bzldef _mylang_binary_impl(ctx):    sources = ctx.files.srcs    output = ctx.actions.declare_file(ctx.label.name)    # 假设 mylang_compiler 能够接受多个源文件    args = [source.path for source in sources] + ["-o", output.path]    ctx.actions.run(        inputs=sources,        outputs=[output],        arguments=args,        executable="path/to/mylang_compiler",    )    return DefaultInfo(        executable=output,    )mylang_binary = rule(    implementation=_mylang_binary_impl,    attrs={        "srcs": attr.label_list(allow_files=True),  # 支持多个源文件    },    executable=True,)

BUILD 文件中:

# my_project/BUILDmylang_binary(    name = "my_program",    srcs = ["main.mylang", "utils.mylang"],)

通过创建自定义规则,你可以将 MyLang 与 Bazel 的其他功能(如远程缓存、远程执行、增量构建等)集成在一起。你还可以通过将规则打包为 Bazel 模块,供其他项目复用。

海贼王之感人名场面

作者 戴铭
2024年8月18日 21:24

这次播客是我,柠檬和田阳一起聊了下空岛篇以及之前的故事。田师傅渊博的海贼王知识非常值得一听。可在小宇宙收听,或扫码下图。

海贼王为什么有那么多令人感动的看一次哭一次的关于友情的名场面,我想可能是因为这些都是生活中所难以获得的美好与渴望吧。

下面让我看一次感动一次的名场面。

第一个是香克斯为救路飞丢掉一只手臂,让我感动的是香克斯的气度,也是因为他的气度一只影响着路飞后面走的路。

接下来是索隆的回忆,回忆中他的竞争对手,榜样和目标霜月克伊娜向他展露了柔软的一面,可能索隆还小不能理解她,但却让索隆因此更加坚定了自己的目标,同时带上了克伊娜的那份。

只是一只小狗,没有能力守护什么,却不言放弃,还默默接受着结果。这感动了路飞,也让我了解到了海贼王的意义。

一个人几十年一直在追求追寻自己想得到的宝箱,在得到后发现是虚无。他可能不会埋怨了,只会感谢珍惜身边那些充满善意,善良的人吧。

路飞和黑猫队长对战,发现黑猫队长是个只顾自己,只会利用伙伴的人,于是发出感叹,说他能力再强又怎样,还不如乌索普。这是因为什么呢?是气度,乌索普志气是保护身边的人,远望着无边无际的大海,有了这样的气度才能够有远航,冒险和称霸海洋的权利。

仪式感的解散,代表着乌索普和孩童时代的告别,告别舒适、安全,去面对挫折,煎熬。还好有着相互信赖的伙伴。

当两个人面对未知未来的时,是自保还是成全对方,这是对人性的考验。哲夫的选择换来了山治的感恩。

成年后的山治表面上看着吊儿郎当,但对哲夫的第二次施恩,却是发至肺腑的感激。哲夫真切的关心,和山治的下跪,让我深深感到他们之间的羁绊有多深。

不管娜美如何为了自己村子而背叛了路飞他们,路飞都是一只相信着她,娜美一只都不敢轻易去信任别人,直到最后无路可走时,才向路飞求助,路飞一如既往的相信着娜美,因为他从一开始就知道娜美是善良的。这种无条件的付出,赢得了娜美的信任。

在和路飞分别时的这段非常感人。海军的迫近,让此时无声更胜有声,这就是伙伴的标记。

当罗宾下决心要通过牺牲自己来保护大家,但大家全然不顾的追来了,她的身世决定她的未来是暗淡的,她也担心未来大家会抛弃她,于是她想死。但是与世界为敌又怎样,一直以来都是牺牲小我成全大我,但是路飞和乌索普的行为让罗宾明白,再渺小的人为了珍爱的人也可以和世界为敌。

有一群愿意和你一起走下去的伙伴,那么大海也就是你的。这一段是看一遍哭一遍,令人难忘。这部漫画在我心中永存。

鸟山明和他的龙珠

作者 戴铭
2024年8月10日 08:05

前言

上次录了期圣斗士的播客,还写了篇文章。这次录了期龙珠话题的播客,聊下龙珠,说下龙珠的故事,鸟山明与龙珠的那些事,比如他的助手、编辑以及他平日的一些爱好。作为一个老的漫画爱好者,我还会说说以前海南版七龙珠和画书大王杂志的一些事。本期播客地址,或扫码下图中二维码收听。铭莉双收播客已经有了 RSS 连接,可以通过泛用型客户端收听,也可以直接在苹果 Podcast 里搜索“铭莉双收”订阅收听,记得五星好评哦。以下内容是对播客内容的一点补充。

龙珠的影响力

龙珠的影响力不言而喻,下面用一些排行数据来直观感受下。

日本漫画全球销量排行榜,龙珠排在第二,第一是海贼王。火影第四,柯南第五。日本NHK官方排名前5的动漫,龙珠也是第二,第一是千与千寻,第三是海贼王。火影第四。日本最受欢迎漫画家排行榜中鸟山明位居第二,第一是手冢治虫。宫崎骏是第三。日本动漫协会民调评出最经典35部动漫中,龙珠是第一,海贼王第二,火影是第四。

可见龙珠在日本动漫界地位。

我所看的是情况是谁家要是有全套海南版七龙珠,一定会被全班羡慕,去他家蹭着看。因为七龙珠的火爆,一些其他的漫画也会被改成以七开头的,比如乱马就被改成七笑拳,连作者高桥留美子都被改成了鸟山明。同期还有很多漫画比如《哆啦A梦》、《圣斗士星矢》、《侠探寒羽良》,但是看得最多的还是龙珠。

龙珠和西游记的关系

鸟山明当初想创作一部带有《西游记》风格的冒险故事。

龙珠开始有点西游记冒险的那味,也用了一些西游记中元素,比如主角的名字,如意棒,筋斗云。还有乌龙的形象就是借用了猪八戒。

《西游记》最初是通过说书人和民间智慧口头流传下来的,后来由吴承恩在明朝将这些故事集结成书。到了民国时期,考证出吴承恩是《西游记》的整理者。几百年前写的《西游记》没有版权,即使是现代小说,在我国超过50年也进入公共领域,不再享有版权保护。进入公共领域意味着任何人都可以改编和商业使用这部作品,无需支付原作者费用。因此龙珠使用西游记中的元素也是没有问题的。

龙珠角色名字来源

因为龙珠流通的版本较多,本文主要取了个中,没有用最早的海南版和最新的台版,而是用的中国少年儿童出版社的翻译名,比如库林就是海南版中的小林,比克是短笛,亚姆查就是乐平。如有地方错了,可能是我看的版本太多导致混淆的缘故,请见谅。

下面是鸟山明怎么给龙珠里的人物命名的。基本都是根据生活中常见吃穿物品的音来起名的。

孙悟空以及初期冒险碰到的人都是有中国特色的,亚姆查是饮茶日语 yamcha 的发音。乌龙、普尔、天津饭、饺子、鹤仙人、桃白白等。悟饭的日语发音是 gouhan,在日语中也就是饭的意思。皮拉夫看起来没有中国味,但是确实中式杂烩炒饭的法语发音,他的手下阿修和小舞的是烧卖 shyumai 的发音。

布尔玛日语是ブルマ,读音 bulma,日语意思是女生短裤。布尔玛父亲布利夫日语意思是男性内裤,儿子特兰克斯意思是男性短裤,女儿布拉(Bra)是胸罩的意思。他们一家主题是内衣。

牛魔王的女儿琪琪的名字来自日语牛的读音 chichi。

库林的日语发音是 kuri,是光头的意思。小林女儿马萝是光头的法语发音。

红绸军里的人都是按照颜色来的,蓝将军、白将军、银大佐、紫曹长、紫罗兰大佐、黑副官、红总帅

比克大魔王是短笛的发音,手下都是乐器,皮亚诺、辛巴鲁、坦巴利、多拉姆分别对应钢琴 piano,钹 cymbal,铃鼓 tambourine,鼓 drum。

赛亚人 saiya 是日语蔬菜发音 yasai 倒着发。贝吉塔是蔬菜阴雨 vegetable 的前几个字母 vegeta 的发音。那巴是菜叶日语 nappa 发音。卡卡罗特是胡萝卜的英语发音 carotte,他衣服颜色也是胡萝卜的颜色,哥哥拉蒂兹是英语萝卜 radish 的发音,父亲巴达克是牛蒡英语 burdock 的发音。可以看出赛亚人都是蔬菜。

那美克星篇中弗利萨是能够装所有蔬菜的冰箱 freezer 的发音,他的父亲格尔多大王是 kingcold,也就是冷王的意思。冰箱还能够装水果,所以弗利萨的手下都是水果,比如第一个被贝吉塔杀掉的是丘夷是水果 kiwi,也就是猕猴桃的意思。萨博是日本水果朱栾 zabon 的发音,多多利亚是榴莲 durian 的发音。

基纽是牛奶日语 ginyu 发音,古尔多是酸奶德文 yoghurt 后半音,吉斯是奶酪英语 cheese 的发音,巴特是黄油英语 butter 的发音。简直就是奶制品特种部队。

人造人基本就简略到只用编号来表示了。

悟饭读高中时碰到的同学名字都是以学习用具命名的,比如莎普是铅笔刀英语 sharpener 的发音,伊雷莎是橡皮 eraser 的发音。

布欧篇更敷衍了,直接将一段灰姑娘电影中的咒语“比比迪巴比迪布欧”拆成不同人的名字,比比迪,巴菲迪,布欧。欧布就是布欧倒过来念。

下面按照时间线说说龙珠的内容。

龙珠时间线

正传前

宇宙中有个恐怖的魔物叫魔罗,它能吸收生命能量并用来增强自己,还能攻击敌人。魔罗摧毁了很多星球,最终大界王神耗费大量神力封印了它的魔力。魔导师比比迪创造了布欧,布欧在数年间摧毁了成千上万颗行星,并消灭了五位界王神中的四位,吸收了大界王神后变成肥布欧,西界王神艰难地将布欧封印在蛋壳内并抛入太空。

杏仁开始管理五行山上的八卦炉。小加力古的祖先从魔境星来到地球。邪道神化身为蛇道的公主并在路旁建了一座宫殿。占卜婆婆开始掌管人们的命运。阎魔大王穿越蛇道得到北界王的教导进行修炼,期间遇到蛇公主,蛇公主对他产生好感。龟仙人出生。比克在天神门下修炼。龟仙人18岁时在师父武泰斗的教导下修炼,并迷恋上少女芳芳。鹤仙人的兄弟桃白白出生。龟仙人在海滩捡到一颗三星龙珠。第一届天下第一武道会开幕。老孙悟饭出生。

那美克星遭遇可怕的风暴,许多娜美克星人丧生。卡达祖把儿子比克送上太空以保全性命,但太空船迫降地球。加力古卷土重来,企图夺取天神之位,天神被驱逐后,比克担任天神,但他的邪念分离出来,成为比克大魔王。大魔王开始破坏大地,但不久后被武泰斗用魔封波封印在电饭煲内,武泰斗也因此牺牲。

达普拉对地球进行了调研,为魔人布欧的苏醒做准备,但调研得太早了。

传说中的超级赛亚人在宇宙间大肆破坏。赛亚人集结占领了普兰特星,将其改名为贝吉塔星。祖福鲁族科学家赖知博士被杀,祖福鲁人被灭族,他们的科技被赛亚人夺走。赛亚人开始在宇宙间航行,与异星人接触并发展贸易。赛亚人通过占领星球并将资源卖给异星人,与弗利萨建立了伙伴关系。贝吉塔王与皇后结婚。贝吉塔王子、亚姆查、布尔玛、天津饭先后出生,地球新国王诞生。弗利萨开始对赛亚人感到不安,库林出生。弗利萨进攻贝吉塔星球,贝吉塔行星毁灭,孙悟空被父亲巴达克送往地球。巴达克试图挑战弗利萨但失败,卡卡罗特被送往地球。

卡卡罗特出生。老孙悟饭在竹林中发现赛亚人飞行器中的婴儿并收养了他,取名孙悟空。悟空不小心从山崖摔下,头部受伤后变得活泼开朗。龟仙人的不死鸟因食物中毒死去,他在海滨发现了一只小海龟。晚上悟空出去撒尿,看见圆月,第一次变身巨猿,无意中踩死了爷爷。

牛魔王之女琪琪出生,她的母亲不久后去世。牛魔王和琪琪在山脚野餐时,火焰让他们的城堡和整座山变为火海,他们迁居山脚,并将此山改名为火焰山。

悟空历险

艾纪748年(孙悟空11岁)

龙珠正传开始

布尔玛和孙悟空的命运相遇,开始了寻找七颗龙珠的冒险旅程。

布尔玛在家里整理仓库时发现了二星龙珠,接着在北方山洞里找到了五星龙珠,并决定利用暑假寻找剩下的龙珠。

她在路上遇见了悟空,发现悟空的爷爷遗物是四星龙珠,于是邀请他一起冒险。接下来,悟空发现了男女的不同。途中,小悟空救了深山里的老乌龟,将它送回海边,老乌龟带来老龟仙人,布尔玛用内裤从龟仙人那换来了三星龙珠,悟空还获得了筋斗云。

途中,悟空制服了变成怪物的乌龙,村里的老妇人给了他们六星龙珠。悟空还和亚姆查交手,最终成了平局。后来,悟空打败了亚姆查,在牛魔王的村子里遇见了琪琪。龟仙人用龟派气功扑灭了火焰山的大火,但也把大山吹走了,他们在废墟中找到了七星龙珠。在火焰山,悟空学会了龟派气功,战斗力达到10。去龟仙岛的路上,悟空和琪琪定下了婚约。

之后,兔子团长被送上月球做糖果,皮拉夫的手下舞和修偷走了龙珠。皮拉夫许愿时,乌龙抢先索要了一条女式内裤,悟空变成巨猿,把皮拉夫的城堡夷为平地。

同年,悟空拜龟仙人为师,结识了库林,一起习武。布尔玛和亚姆查开始了他们的恋爱长跑。

艾纪749年(12岁)

孙悟空和库林在龟仙人的安排下参加了“天下第一武道会”。库林在四强赛中输给了龟仙人化名的程龙,孙悟空也在决赛中惜败于他,获得亚军。比赛中,悟空因满月变成大猩猩,程龙无奈之下摧毁了月亮。

同年,孙悟空在寻找爷爷的四星龙珠时,顺手摧毁了“红绸军团”,阻止了首领瑞德想要变高的愿望,维护了地球治安。在这个过程中,悟空结识了斯诺、人造人8号、乌帕父子和卡林仙人,还击败了杀手桃白白。

在寻找最后一颗龙珠时,孙悟空得到占星婆婆的帮助,再次见到了已故的爷爷孙悟饭,完成了他的遗愿,看到了孙悟空的成长。同年,亚姆查拜入龟仙人门下。

第一次参加武道会

艾纪749

悟空和库林开始在龟仙人门下修炼,带来了可爱的兰琪。他们在小岛上接受了8个月的特训。

一天,他们吃了有毒的河豚鱼,只能躺在床上休息。几天后,悟空和库林正式开始训练,直到下一届天下第一武道会。

在训练期间,他们进行了负重40公斤的锻炼。红绸子军银司令也在寻找龙珠。

小悟空和库林练了8个月的基本功,然后直接参加了武道会。

有人认为几个月就能速成拿好名次,是爽文套路,取悦读者; 有人觉得龟仙人不肯教招式,是怕教会徒弟饿死师父,传武老毛病; 有人说龟仙人的理念是无招胜有招,基本功最重要,招式反而不重要; 也有人认为只靠日常劳作就能强身健体、逆袭,是投机取巧,老港片里常用的套路,不是因为合理,而是接地气,容易让观众代入。

不得不承认,龙珠确实用了不少爽文套路。8个月备战,两个十三四岁的孩子能顺利进入世界大赛四强,这有点离谱。打桃白白时更夸张,只用3天,通过卡林塔的攀爬和追逐训练,就全面碾压桃白白。比克大魔王篇更是充满武侠味,复仇、开挂、爽文元素全都有。

最终,龟仙人、悟空和库林离开小岛参加天下第一武道会。龟仙人化身“程龙”获得了冠军,悟空屈居第二。

红绸军

艾纪750

皮拉夫、红绸军和悟空同时开始找龙珠。红绸军在皮拉夫的地下城堡很快就找到了两颗龙珠。第二天,布尔玛给悟空修好了龙珠雷达。悟空和蓝将军在半空中打到了企鹅村上空,结果被阿拉蕾一记头槌打败。这也是悟空第一次来企鹅村,遇到了阿拉蕾。同一天,桃白白用舌头亲自杀了蓝将军。悟空在卡林塔下惨败给桃白白,决定爬上卡林塔找仙人帮忙。

过了两天,悟空爬上了卡林塔顶,得到了卡林仙人的引导,仙人给了他“超圣水”,其实就是普通的自来水。原来,真正的提升能力过程在于和卡林仙人的较量。又过了两天,经过三天的训练,悟空再战桃白白,取得了胜利,还一举歼灭了红绸军。这一天,悟空不仅为世界除了大害,还在水晶婆婆的格斗场上看到了已经去世的爷爷孙悟饭。

第二次武道会,天津饭

艾纪752年(15岁)

孙悟空在世界各地修炼之后,回来参加了这届天下第一武道会。鹤仙流也派人来参加了,龟仙流的亚姆查和鹤仙流的天津饭打了一场。但因为实力差太多,亚姆查被打败了,腿还受了伤,没能进八强。

程龙三年前因为打碎了月亮,导致人狼没法变回人,所以这次人狼来参加武道会想报仇。但程龙还是轻松地打败了他,还用库林的亮脑门和催眠术把人狼变回人了。

库林和饺子比赛时,库林这个数学天才靠着数数赢了饺子这个数学鬼才,给龟仙流挣回了面子。

孙悟空更是一招就打败了两个世界冠军的巴普特,轻松进了四强。四强战里,程龙和天津饭打得很激烈,但程龙想感化天津饭,所以主动认输了。孙悟空和库林打了一场,库林虽然尽力了,但还是输了。

总决赛是天津饭和孙悟空打,天津饭在战斗中觉醒了武道正义感,变成了好人。但最后因为运气稍差,孙悟空又得了亚军。

武道会结束后,库林在武道馆被神秘人杀了,孙悟空愤怒地去追,但因为体力耗尽,没能打过对方。龟仙人根据现场的东西,推断出大魔王又出现了。

孙悟空结识了亚奇洛贝,他们一起杀了几个魔族战士,结果引来了大魔王。孙悟空打不过,被打得晕了过去。龟仙人、天津饭、饺子在收集龙珠时,也遇到了大魔王。龟仙人挺身而出,想使用魔封波封印他,但失败了,龟仙人战死了。神龙出现后,饺子也战死了,大魔王恢复年轻,开始了他的恐怖统治。

天津饭想修炼魔封波来封印大魔王,但把封具弄坏了。孙悟空在亚奇洛贝的帮助下,再次见到了卡林仙人。他喝下蕴含剧毒的超神水,以命赌力,终于成功突破了!

突破后的孙悟空在国王宫殿旁击败了大魔王,再次拯救了地球!大魔王死前留下孙家保姆,说“魔族永远不会灭绝”。击败大魔王的孙悟空获得了面见天神的机会,他通过如意棒前往天界!在天界与波波交手之后,天神闪亮登场,一手弹开孙悟空,秀得一手好操作!天神复活神龙之后,库林、龟仙人、饺子等人也复活了。孙悟空则留在天界继续修行!

比克大魔王

艾纪753

第22届天下第一武道会正式开始了,最后天津饭赢了,悟空又得了亚军。可就在这时候,库林突然被杀,比克大魔王又出来了。后来,在地球国王掌政20周年纪念的第二天,悟空打败了比克大魔王,地球又恢复了和平,比克二代也出现了。然后,悟空就去了天神神殿,开始了神仙和波波先生指导下的三年修炼。

第三次武道会,比克大魔王

艾纪755年(18岁)

这段时间发生了好多大事!先是刚满18岁的孙悟空和琪琪完成了他们儿时的约定,结婚了。然后比克大魔王也来参加武道会,还打败了天神,嚣张得不行。不过最后孙悟空还是艰难地赢了他,粉碎了他想统治世界的野心。接着,孙悟空终于拿到了天下第一武道会的冠军,和琪琪一起在包子山定居了。哦对了,兰斯追着天津饭不知道跑到哪里去了。

再说说后来吧,第23届天下第一武道会又开始了,这次悟空打败了比克二代,终于第一次夺得了武道会的桂冠。比赛结束后,他们还订婚了。然后,艾纪756年,孙悟空19岁的时候,他们的儿子孙悟饭出生了。同年,孙悟饭的未来妻子比迪丽也出生了。这时间过得真快,事情也真多,真是让人感慨万千!

赛亚人地球战

艾纪760年(23岁)

有个外星神秘战士叫拉蒂兹的跑到地球来了,他一来就揭露了孙悟空的身世,原来孙悟空是赛亚人,这下赛亚人正式进入龙珠历史了。

拉蒂兹还想利用孙悟饭,拉拢孙悟空入伙。不过,比克和孙悟空联手对战拉蒂兹,结果也不占优势。

危急时刻,孙悟饭怒气爆发,竟然击伤了拉蒂兹。

拉蒂兹是孙悟空的亲哥哥,作为一名上级赛亚人战士,他来到地球寻找孙悟空,目的是拉拢孙悟空加入他们的行列,共同征服世界。然而,孙悟空拒绝了拉蒂兹的要求,这导致了双方的激烈冲突。

在与孙悟空和比克的战斗中,拉蒂兹展现了强大的实力,一度占据上风。然而,孙悟空在战斗中逐渐找到了拉蒂兹的弱点,并利用这一点展开反击。

在危急关头,孙悟空紧紧地抱住了拉蒂兹,使其无法动弹。同时,他请求比克使用绝招“魔贯光杀炮”来终结战斗。

比克听从了孙悟空的请求,发射出了强大的“魔贯光杀炮”,这一击直接贯穿了孙悟空和拉蒂兹的身体。

龙珠的信息也泄露了,听说更强的赛亚人一年后就要入侵地球。

比克赶紧带走孙悟饭去修练了,天津饭、库林、饺子、亚姆查、亚奇洛贝也跑到天界去修练了。

至于孙悟空嘛,他跑到阴间跟界王学习去了!这龙珠世界真是越来越热闹了!

艾纪761年

贝吉塔和那巴这俩人,他们降落到了一个叫阿鲁尼亚的星球上,结果一看,觉得没啥用,就直接把它给炸了。炸完之后,他俩就进入冬眠状态了,说是要睡一年,等到抵达地球的时候再醒过来。

再说孙悟空,他这时候也复活了,从界王星赶回了阎王殿。

后来,库林、天津饭、饺子、亚姆查和弥次郎兵卫这几个人,他们都跑到天神神殿去修行了。

悟空呢,他也没闲着,他到了北界王星,还在巨大的重力环境下抓住了巴布鲁斯。然后,他得用槌子敲中格雷哥利的脑袋,这事儿可不容易。不过呢,悟空最后还是做到了,用槌子打中了格雷哥利。

还有天津饭、亚姆查、库林和饺子他们,他们几个人还跑到“过去”的“贝吉塔星”上,跟赛亚人打了起来。结果呢,两个低等战士就差点把他们给“干掉”了。回到自己的身体后,他们发誓要发奋图强,一定要变得更强。最后,悟空也从界王那里完成了修业,重生了。

艾纪762年

贝吉塔跟那巴来到地球后,悟饭、比克、天津饭、库林、亚姆查还有饺子他们一起对抗这两个赛亚人。亚姆查这家伙,身先士卒,冲在最前面,结果第一个战死,算是博了个头筹吧。饺子也不甘示弱,紧跟着也自爆身亡了,真是惨烈!天津饭那时候说,不求同生,但求同死,双宿双栖,也是让人感慨万千。最后呢,悟空跟贝吉塔打了个平手,这场打斗才结束。贝吉塔乘坐太空船18日回到弗利萨星球。也是在那一天,悟空终于知道了他爷爷是怎么死的。同时,有个叫格罗博士的人,他派了个小机器人在这附近收集那些强大战士的细胞,想做个超强的生命体出来。

本篇中拉蒂兹战斗力情况如下:

  • 拉蒂兹:战斗力1500
  • 孙悟空:常态416,龟派神功924
  • 比克:战斗力408,魔贯光杀炮1330
  • 孙悟饭:愤怒1307

本篇中贝吉塔战斗力情况如下:

  • 天津饭:1830
  • 栽培人:1200
  • 那巴:4000,聚气8000
  • 亚姆查:1480
  • 饺子:610
  • 比克:3500,为打倒赛亚人提升
  • 库林:1770
  • 孙悟饭:愤怒2800
  • 孙悟空:常态8000,2倍界王拳1.6万,3倍2.4万,4倍界王拳3.2万
  • 贝吉塔:常态1.8万,闪光炮2.4万,虚弱7000,巨猿化7万

那美克星

艾纪762年

库林、悟饭和悟空打完架,伤得不轻,都被送去医院治伤了。过了没几天,库林和悟饭就康复出院了。紧接着,天神的飞船也修好了,速度快得很。

然后,布尔玛真是个天才,没几天就学会了娜美克星人的语言,厉害得不得了。她拉上库林和悟饭,三个人开着飞船就直奔娜美克星去了,用了34天时间,如果使用地球技术飞船那是需要四千年才能到。

另一边呢,贝吉塔在弗利萨星球的79区也受了重伤,但他恢复得也快,没多久就完全好了。一出疗养舱,他也二话不说,直接就往娜美克星赶。这几个人目标都是一样的,都奔着娜美克星去了。

布尔玛、孙悟饭、库林和贝吉塔一起降落到了娜美克星。同时,悟空也康复出院,急匆匆地赶往娜美克星。丹迪和库林去见大长老,而天津饭、亚姆查、饺子和比克则去了北界王星特训。

那美克星这边的代表是年纪小但能力超强的丹迪。这地方势力可复杂了,有像万年打野的贝吉塔,单人成团到处跑;有弗利萨主宰着,还带着一群手下;还有一群温和的那美克星人;最后就是咱们的主角们像防御塔一样守着各个地方。

贝吉塔先杀了多多利亚

后来,萨波击败了贝吉塔,把他带到了弗利萨的飞船里。赛亚人有个能力,濒死复活能增加战斗力。但贝吉塔不甘示弱,再次挑战萨波,终于把他干掉了。这时,库林和孙悟饭也见到了大长老。

另一方面,基纽战队原本要去雅路达星,但计划有变,他们被派去娜美克星协助弗利萨,他们乘坐飞船用了5天到达那美克星。而悟空乘坐了布尔玛父亲做的太空船在一百倍重力的环境下修炼,终于适应了那种极端条件。孙悟空在一百倍重力室修炼后,实力大涨,飞船用了6天时间到达那美克星,悟空打败了基纽特战队。但基纽战队的队友会换身技能,把孙悟空弄伤得很严重,他只好进疗养仓了,弗利萨星球疗伤技术很高,遍体鳞伤也只需要一个小时痊愈,如果是地球医疗技术的话,四个月也难痊愈。

趁着贝吉塔休息,库林、孙悟饭还有丹迪偷偷召唤神龙,救了比克,还把比克送到那美克星。第三个愿望还没许,大长老寿命到了,神龙就消失了。

弗利萨知道神龙死了,气坏了。贝吉塔、比克、库林、孙悟饭、丹迪五个人联手,还是打不过弗利萨,贝吉塔还战死了。

孙悟空养好伤回来,战斗力飙升,跟弗利萨苦战。后来库林被弗利萨杀了,悟空愤怒至极,他第一次变身成超级赛亚人,最终击败了弗利萨。

那美克星人被地球神龙复活,又由波仑伽(大长老复活短时间)传送到地球。最后那美克星爆炸,孙悟空坐着基纽特战队的飞船去雅德拉克星学瞬间移动了。

此阶段战斗力情况如下:

  • 库林:1500,大长老激发潜力1.3万
  • 悟饭:1500,大长老激发潜力1.4万,对弗利萨第三形态愤怒200万
  • 丘夷:1.8万
  • 贝吉塔:常态2.4万,萨博击败疗伤后3万,再次大难不死后12万,对弗利萨25万,对弗利萨第四形态250万
  • 多多利亚:2.2万
  • 萨博:2.2万,变身2.8万
  • 古尔多:1万,有超能力
  • 利库姆:4.2万
  • 巴特:4.5万,速度宇宙第一
  • 孙悟空:9万,2倍界王拳18万,重伤复活300万,10倍界王拳3000万,20倍界王拳6000万,超级赛亚人1.5亿
  • 吉斯:4.5万
  • 基纽:12万
  • 内鲁:4.2万
  • 比克:和内鲁同化后150万,胜于弗利萨第二形态
  • 弗利萨:第一形态53万,第二形态120万,第三形态220完胜比克,弗利萨最终形态1.2亿

特兰克斯

库林和亚姆查,这两个家伙,后来靠着那美克星那条神奇的神龙,嗖的一下,就从哪儿来回到地球了。

没过多久,饺子和天津饭也回来了,好事成双。那些剩下的娜美克星人,神龙也帮忙找了个新家,新娜美克星,听起来就不错。

然后沙鲁杀了未来的特兰克斯,偷偷搭了特兰克斯的时光机,成为一个蛋,进入地下孵化。在地底下猫了好多年,自己悄悄地吸收能量,准备搞事呢。

还有,弗利萨,也给修好了,没完没了。

转眼第二年,年初那会儿,贝吉塔可拼了,整天泡在重力室里修炼,就想变成超级赛亚人。练过头了,就会受伤,经布尔玛精心照料,两人就好上了,感情升温得跟坐火箭似的。

艾纪764年
弗利萨父子跑到地球来了。这时候未来特兰克斯出现啦!他是从 20 年后的未来来的,也就是艾纪 784 年。特兰克斯把弗利萨父子打败了,还给孙悟空带来了治心脏病的特效药,跟孙悟空说了人造人的那些事儿。

此阶段战斗力情况如下:

  • 机械弗利萨:1.4亿
  • 库尔德王:8000万,弗利萨的父亲
  • 特兰克斯:6亿
  • 孙悟空:8亿,亚德拉特星修炼结果,心脏病4亿以下
  • 人造人19号:4.2亿
  • 人造人20号:4.2亿
  • 贝吉塔:10亿
  • 比克:5亿,和天神合体15亿
  • 人造人18号:12亿
  • 人造人17号:15亿
  • 人造人16号:19亿

沙鲁

艾纪767年

弗利萨父子进攻地球的时候,遇到了来自未来的特兰克斯,这小伙子真不简单,一下子就把弗利萨父子给打败了。可紧接着,人造人出现了,悟空跟人造人19号、20号打起来,可是心脏病毒让他受不了,只好停下来。这时候,贝吉塔以超级赛亚人的身份出现了,真是帅啊!

没过多久,特兰克斯又来了,这次他从更远的未来回来,他发现在他那个时代的三年后,有个叫沙鲁的人造人出现了。哎呀,时间线就这么对上了。

库林和未来的特兰克斯找到了格罗博士的实验室,把那个还没完全成型的沙鲁给干掉了。

然后,人造人16、17、18号都冒出来了,沙鲁还吸收了17、18号,变得更强了。

孙悟空他们这边也不甘示弱,比克和天神合体,龙珠失效后,过了两天,悟空吃了特兰克斯带来的药,慢慢好起来了。孙悟空立马瞬移到新那美克星找新任天神丹迪,把龙珠给复活了。

贝吉塔和特兰克斯父子俩也进入精神时光屋去修炼了。

贝吉塔也突破了自己的极限,可惜还是没能打败完全体的沙鲁。特兰克斯也一样,虽然开发出了超级赛亚人一第三阶,但还是败给了沙鲁。

沙鲁变得更强了。然后悟空和悟饭也去精神时光屋修炼了。

后来沙鲁还搞了个沙鲁游戏,真是嚣张。孙悟空和孙悟饭进入精神时光屋修炼,出来后孙悟空虽然变强了,但还是没能打败沙鲁。最后,孙悟饭在沙鲁、16号和撒旦的帮助下,终于突破极限,变成了超级赛亚人二,成功打败了沙鲁。但因为骄傲自大,导致悟空又牺牲了,不过最后还是悟饭愤怒的一击,终于把沙鲁给消灭了,拯救了地球。可惜的是,孙悟空在这场战斗中牺牲了。

鸟山明曾说,第二阶段的沙鲁是他最喜欢的角色。大家都笑他是不是傻,尤其是编辑近藤裕。近藤以前做少女漫画杂志,看惯了帅哥,怎么能接受这种形象?不过这只是开玩笑,近藤其实很有深度。

近藤裕非常擅长设计人物形象,而且他总是根据大众心理来设计,效果往往非常好。比如弗利萨的形象,就是他提出要塑造成一个宇宙地产商的点子——在泡沫经济鼎盛时期,炒地皮的最招人恨。

近藤不喜欢二阶沙鲁,催着鸟山明赶快画出新形态,是因为他考虑到未来的胜负和故事整体。如果敌人太丑,大家会觉得主角赢是理所当然的结果;而敌人是帅哥的话,读者才会担心悟空能不能赢。事实证明,近藤的意见是对的——大部分人都认为完美沙鲁稳压悟空一头,沙鲁几乎是不可战胜的,这场决斗的胜负归属一直是龙珠中的热门话题。

那鸟山明为什么最喜欢二阶沙鲁呢?他的审美真的这么特别吗?一方面是因为他喜欢日本特摄剧,比起动漫,他更喜欢奥特曼、哥斯拉、超级战队这些特摄剧,并且在作品中致敬过。那么假面骑士怎么能落下呢?鸟山明在采访中表示,自己最喜欢的动物之一就是飞蝗(好在他没真在家里养这个),沙鲁的昆虫原型就是受到假面骑士的影响而诞生的。斑点难画?他心里乐意着呢。而且画斑点明显是助手的工作,鸟山明自嘲说斑点麻烦,只是习惯性地凡尔赛罢了。

此阶段战斗力情况如下:

  • 特兰克斯:超级赛亚人第二阶段150亿,超级赛亚人第三阶段225亿,超级赛亚人全功率250亿
  • 孙悟空:超级赛亚人全功率350亿
  • 贝吉塔:超级赛亚人第二阶段150亿,超级赛亚人全功率250亿
  • 沙鲁:第一形态11亿,第一形态吸收人类精华19亿和16号持平,第二形态95亿,沙鲁完全体初登场200亿,后期380亿,拳力500亿,闪电沙鲁1000亿
  • 小沙鲁:250亿
  • 孙悟饭:超级赛亚人全功率330亿,超级赛亚人全功率愤怒450亿,超级赛亚人2战斗力900亿

布欧

悟空的葬礼上,大家都参加了。之后,未来的特兰克斯回到自己的时代,消灭了未来的17号、18号和沙鲁。

接下来几个月,有一部关于撒旦先生的电视特别节目,详细介绍了他的生平,但没有提到沙鲁之战。撒旦被认为是地球上最强的格斗家。

库林和18号结婚了,他们的女儿玛伦也出生了。界王神和杰比特来到地球,寻找魔人布欧的蛋壳。悟饭升上了橙星高中的一年级。

一位金发战士保卫撒旦市的故事传遍了大街小巷。悟饭在橙星高中认识了撒旦的女儿比迪丽。下午三点左右,悟饭请布尔玛为他制作一件战斗服,布尔玛同意了。大约五点,超级赛亚蒙面超人诞生了。

比迪丽发现赛亚蒙面超人其实就是悟饭。接着,悟饭教比迪丽飞行,南界王见识到悟空惊人的修炼方式。小特兰克斯在他父亲面前第一次变成超级赛亚人。比迪丽终于掌握了舞空术。

第25届天下第一武道会召开,悟空获准离开阴间一天来参加比赛。贝吉塔通过魔导师巴比迪的控制,变身超级赛亚人2,并与悟空打斗,导致魔人布欧在地球上苏醒。悟饭拔出界王神剑,贝吉塔为了亲人自爆拯救了地球。胖布欧杀死了魔导师巴比迪。悟空展示超级赛亚人3的威力后提前返回阴间。悟天和特兰克斯开始练习合体,第三次才成功。悟饭把界王神剑折断,释放了老界王神,老界王神决定为悟饭引发出更深藏的潜能。

胖布欧在撒旦的感化下逐渐平静,但撒旦被杀后,胖布欧释放出瘦布欧,吃了胖布欧后变成大布欧。大布欧在精神时光屋与悟天克斯混战,打到下界。大布欧吸收了悟饭、悟天、特兰克斯和比克。在大布欧要杀光地球所有人的紧急关头,老界王神将他的性命送给悟空,让他再返人间作战。贝吉塔在水晶婆婆的协助下也回到了地球。两人第一次用耳环合体,变身为贝吉特,成功进入大布欧体内救出众人,使大布欧恢复成小布欧。小布欧炸毁了地球,但那美克星神龙让地球恢复原样。贝吉塔也因地球人全体复活而重生。悟空用一枚特大元气弹消灭了布欧。

神龙将人们心中对布欧的记忆完全抹掉。几年后,第26届天下第一武道会举行,撒旦先生夺得第一,胖布欧第二。

此阶段战斗力情况如下:

  • 孙悟天:19亿
  • 特兰克斯:20亿
  • 悟天克斯:超3战斗力8000亿
  • 悟饭:神秘悟饭1.2兆
  • 胖布欧:5000亿
  • 瘦布欧:3500亿
  • 大布欧:7000亿

龙珠超

破坏神比鲁斯来袭,悟空拼尽全力抵消了比鲁斯的“灼热弹”,比鲁斯很赞赏悟空,并决定不再破坏地球。

小芳出生。同年,弗利萨复活,修炼出金色形态,带军队来地球复仇。悟空击败弗利萨,弗利萨再次被送回“地狱”。第七宇宙和第六宇宙的破坏神比武大会开始,第七宇宙获胜。未来的扎马斯入侵,特兰克斯回到过去求助,最后合体扎马斯被未来全王消灭,未来全王和现世全王成为朋友。

布拉出生。同年,全王举办力之大会,失败的宇宙将被清除。悟空、贝吉塔、悟饭等十人代表第七宇宙参赛,悟空在大会上首次达成自在极意,第七宇宙获胜。人造人17号用超级龙珠许愿复活被清除的宇宙,大家回到正常生活。弗利萨的两个小兵在万帕星找到布罗利父子,悟空一伙寻找龙珠时遇到弗利萨军,悟空和达尔与布罗利交战,最后用美达摩融合术压制布罗利,但在消灭他的一瞬间,布罗利被神龙传送到万帕星。悟空送给布罗利物资,并表示想通过与他对战变强以超越比鲁斯。实习天使梅尔斯因违背天使中立准则而消失,孙悟空完全掌握自在极意,梅尔斯因大神官转生成人类而复活。

第27届天下第一武道会举行,撒旦先生夺得第一,“胖布欧”第二。第28届天下第一武道会举行,悟空在比赛场上将布欧的转世——欧布带走修炼。

龙珠GT

悟空被皮拉夫用黑星龙珠变成了8岁。悟空、小芳和特兰克斯乘宇宙飞船出发寻找龙珠。九个月后,他们遇上了贝比。贝比被击败后,潜入地球控制了几乎所有人,并变得更强大。贝比用黑星龙珠重建了祖福鲁星。悟空变身超级赛亚人4,与贝比展开大战,最终贝比被灭,祖福鲁星人也因地球人被治愈而灭绝。

黑星龙珠的诅咒导致地球爆炸,所有生命迁往祖福鲁星。那美克星龙珠将地球复原后,大家迁回地球。第30届天下第一武道会举行,撒旦再次获得冠军。

超级17号进化完成,但被悟空和18号联手击败。龙珠出现裂痕,邪恶龙肆虐世界。悟空最终击败一星龙,神龙重现并带走悟空,七颗龙珠融入悟空的身体,悟空离开了一百年。

悟空的玄孙出生,为纪念祖先取名小孙悟空。小悟空在独自寻找龙珠的冒险中激发了超级赛亚人潜能。在祖居门前,小孙悟空与显灵的祖父见面。

第63届天下第一武道会举行,小孙悟空与贝吉塔的玄孙角逐少年组决赛桂冠,结果未明。110岁的小芳在观众席上看到祖父悟空的身影,但未能追上。悟空和龙珠的故事到此画上完美的休止符。

龙珠中的道具清单

  • 龙珠:收集七颗龙珠能够实现一个愿望,丹迪制作的可以实现三个,可一次复活多人。只有那美克星龙族才能制作龙珠。那美克星大长老制作的龙珠能实现三个愿望,但是一次只能复活一个人,后期新的可以让多人复活。龙珠实现的愿望中,最让人感动的是库林让神龙拆掉18号和17号体内的爆炸装置。
  • 筋斗云:只有心灵纯洁的人才能坐上去。曾经坐上去的人有,孙悟空、悟饭、人鱼(悟空按龟仙人要求找的人)、琪琪、兰奇、欧布等。
  • 神奇胶囊:可以将房子汽车等物品装到一个瓶子里。打开瓶盖就能还原。由布尔玛父亲发明。
  • 如意棒:可以随意伸长,悟空就是用它直接伸到神殿。
  • PP糖:吃下的人听到PP就会拉肚子,效果会持续一个月,布尔玛用在了乌龙身上。
  • 龟壳:龟仙人所背,重量大,也用于悟空库林的训练。
  • 仙豆:重伤可痊愈。每次不可大量种植,数量很有限。但是仙豆不能治疗疾病,比如悟空心脏病发作时,仙豆不起作用。
  • 超圣水:里面只是普通的水。
  • 超神水:有剧毒,但是如果有强大的体能和意志力就能够引出潜能。悟空是唯一喝过没死的人。
  • 战斗机器人:红绸军黑参谋对付悟空的可操作的机器人。
  • 皮拉夫的机器人:皮拉夫三人组每人一个可操作的机器人,还可以合体。
  • 比克大魔王的封印:用来封住比克大魔王的咒印。将其贴在瓶子上,使用魔封波就可以将比克大魔王封印住。
  • 侦查器:按一下就可以知道对手的战斗力,还可以进行星际对话。旧款上限是2.2万,新款没有上限。
  • 弗利萨军战斗服:超级橡胶制成,柔软性和防御力都很高,很有弹性,什么身材都能穿进去,几乎感觉不到重量。简直就是最理想的宅男服装。
  • 栽培人套装:赛亚人的科学生物,播种后滴上栽培液,就会诞生栽培人,战斗力还不错,但不会说话。
  • 魔法飞毯:波波使用的交通工具,一瞬间将布尔玛带到天神的宇宙飞船那。
  • 光束枪:弗利萨的士兵使用的枪,布尔玛库林悟饭一行到那美克星的飞船被光束枪一枪击毙。
  • 治疗机:弗利萨军的治疗装置,最多四十分钟就能痊愈。
  • 特兰克斯的剑:一剑砍死悟空打了几十页漫画的弗利萨,非常的耀眼。
  • 紧急停止控制器:用于停止17号和18号活动的装置。需要在10米内使用。
  • 变身服装和手表:悟饭高中时,做好事为了隐藏身份所穿,悟饭拜托布尔玛制作的。
  • 测拳机:第24届天下第一武道会上开始使用的,最高纪录时撒旦打出的139分。
  • 能量吸收器:用于解开布欧封印的能量吸收器。
  • 封印蛋:封印魔人布欧的蛋。
  • 终极之剑:传说一拔出来就能够提升力量的剑。原因是老界王神被封印在剑内,他会帮助拔剑人提升力量。
  • 卡先钢:全宇宙最坚硬的金属。使用终极之剑都砍不开。
  • 天界神珠:带上的两人可以合体,合体后力量提升。

龙珠世界全地图

龙珠世界氛围天界和宇宙,天界包括阎王殿、蛇道、界王星、地狱和天国。宇宙包含了地球、那美克星、弗利萨星和贝吉塔星等星球。

天界位于宇宙之上,里面有裁判死者的阎王殿、天国和地狱,是神管理世界的地方。好人会去天国,坏人去地狱,如果被魔族所杀灵魂只能在宇宙飘浮。阎王殿是死者灵魂的入口,蛇道连接着阎王殿和界王星,界王星很小,只有界王和他的宠物阿布住在上面,重力是地球的十倍,界王的生活很简单很舒适,数数草,眺望天空,还可以看小便撒的多远。

那美克星科技是先进的,可以造出超光速宇宙飞船,但是那美克星人却因为天气问题几乎灭绝,为了恢复星球,仅存的那美克星人开始了种植花花草草的安稳生活。那美克星人分为龙族和战士两个,那美克星人没有性别,通过口中吐蛋进行繁殖。

贝吉塔星住着赛亚人,他们大部分都是弗利萨的雇佣兵,会去侵略其他星球。最后弗利萨害怕赛亚星会出现传说中的超级赛亚人,于是将贝吉塔星摧毁,赛亚人几乎灭绝。赛亚人是好战的名族,从小被灌输战斗的思想,使得他们天生就很享受战斗。

龙珠世界的地球只有一个国家,由国王统治,全国有四十三个区。地球的科技主要是胶囊公司带来的,也就是布尔玛他们家的公司。反重力装置交通工具很普及,地球人有人类,比如库林,动物类,比如乌龙,还有怪物类,比如皮拉夫。

地球北部地区有中都、东都、牧场、吉古鲁村、红绸军白队基地等地方。中都是国王的都城,比克大魔王就是在这里让国王屈服的。东都是东北部最大城市,贝吉塔和那巴就是在这里着陆的。拉蒂兹是在牧场着陆的。吉古鲁村是常年被大雪覆盖的城市,那里的人淳朴热情,小悟空帮他们打败了红绸军白队基地。

地球东部有兔子军团镇、乌龙的村庄、亚姆查的住处、皮拉夫城堡和海盗洞穴。

西部有圣地卡林,卡林塔、悟饭修行地、西都、胶囊公司和红绸军总部。卡林塔居住着卡林仙人,悟饭修行地是比克为了对抗赛亚人专门培训悟饭的地方。西都是地球科技最发达的地方,胶囊公司也在西部。

南部有龟仙人的小屋、企鹅村、火焰山、天下第一武道馆、占星婆婆宫殿。

鸟山明

家乡

鸟山明先生,家住 爱知县 名古屋市清州。名古屋是日本重工业基地,飞机汽车很繁荣,也导致鸟山明很喜欢一些机械的东西。但是清州很偏,导致鸟山明一直都只有一个助手,几乎所有创作和作画都是一个人完成。

1983年,他连载《阿拉蕾Q》时,创下“六亿四千七百四十五万日元”的漫画家纳税最高记录。阿拉蕾结束时鸟山明本打算去过自由自在的生活,游泳、赛车、玩模型和旅游,但是被鸟岛和彦告知业界残酷,让他很快回归业界,这才有了龙珠。

画画方法

作者通常会先画出NAME给责任编辑看,双方讨论后再修改细节。然后作者在稿纸上打草稿、勾线,助手负责涂黑、涂白和贴网点。

NAME可以画在普通笔记本上,只要有大概的分格和轮廓,作者和编辑能看懂就行。为了省力,画得潦草是正常的。

鸟山明连载时不画NAME,直接从底稿开始,改动很少。他曾说过:“为了少做修改,我会把稿子拖到最后一刻再交给编辑,实在没办法编辑也只能认了。”这种拖延战术我们都懂,但不是每个人都能像他一样一出手就是高质量底稿。

说到画画风格,龙珠的舞台总是很荒凉,鸟山明是觉得画街巷太复杂,阿拉蕾的背景也是用圈圈状的山和树木这种省事的方式来糊弄过去。因为住在乡村买网点纸很麻烦,也就用黑白做了基调。

鸟山明不会偷懒,他扎实地练习场景透视、人体比例、情节节奏和人物塑造。透视不过关,他就堆细节,用花纹和建筑填满画面,多分格,少画全景。人物比例画不好,就贴网点或用声效字遮挡,甚至用无意义的破格吸引读者。

人物成长和互动复杂,容易出错,他设计不同的价值观让角色自己动起来,而不是贴现成的性格标签。住在乡下,只有一名助手,背景不画建筑,头发不用涂黑,减轻助手负担。作画工具不高档,他只能大刀阔斧地画,时间有限,能推掉彩页就推掉,不讲与主线无关的故事,不乱埋伏笔,不无限拓展剧情。

鸟山明画漫画也不是一开始有很厉害的,周刊少年编辑鸟岛和彦说他第一次收到鸟山明漫画是临摹星球大战的作品,这类作品是不可刊登的,他是对漫画中的文字绘画感觉新鲜,这才有去联系鸟山明。

日本坚持黑白漫画,因为彩色漫画虽然好看,但成本高。JUMP每期有几页彩页和拉页海报,但大部分是黑白的,用的是便宜的纸张,定价低,小孩子都能买得起。

集英社的全彩版龙珠只关注色彩,不重视黑白基调,效果不好。鸟山明最初用普通的透明水彩,后来用签字笔融水涂色,效果不错。1981年,他在《りぼん》杂志的访谈中了解到彩色墨水的使用方法,后来常用Luma牌墨水。他还向动画导演和制作人员学习上色技巧。

助手

一个人又要拼命想故事,还得小心翼翼别踩坑,画技还得天天磨,连载的压力大得跟山似的,还得想着怎么快点火起来。能按时交稿,质量还不差,那已经是超人水平了!这时候再让他每周都给画上色,还没人帮忙分担,换谁不崩溃啊?

田中久志

说回鸟山明,他那时候可惨了,啥都得自己干,连个助手影儿都没有。可能他之前都不知道还有漫画助手这职业呢,毕竟以前都是画短篇的。他家那地儿偏得要命,想找人都找不着。要是在东京,鸟嶋和彦那哥们儿肯定能帮上忙,但鸟山明非要在家搞创作,那就只能自己想办法了。好在,他加入了个小圈子,里面有个叫田中久志的,后来成了他的第一任助手,不过这家伙一周才来一趟,帮不了太多忙。但人家可是厉害角色,参加过比赛还拿过亚军呢,现在都成大学教授了。

谷上

再来说说谷上,这位是东京来的机械天才,鸟山明都夸他。但不知道为啥,没多久就走了,存在感超低。那时候龙珠火得不行,如果是在大城市,来应征的人得挤破门,但鸟山明家那地儿太偏了。

松山孝司

还有松山孝司,这家伙跟鸟山明那叫一个默契,俩人兴趣相投,简直就是灵魂伴侣。松山不仅是助手,还是模型手办的高手,拿奖拿到手软。他从阿拉蕾后期就开始跟着鸟山明,一直到龙珠结束,整整12年!鸟山明还特地为松山减负,超级赛亚人的头发都不涂黑了,就是为了让他轻松点。俩人工作之余还一起抽烟、聊电影、打游戏、骑摩托,简直不要太爽!

松山结婚的时候,鸟山明还特地留言说以后不让他加班太晚,可见两人关系多铁了。总之,鸟山明能画出那么火的漫画,松山孝司功不可没!

一边要绞尽脑汁创作故事、一边要避免踩坑、一边要打磨画技、一边要适应连载的工作强度、一边还要争取尽快积攒人气,能保质保量地按时交稿就不错了,这时候再让他们每周都上色,又找不到人分担压力,换谁不得崩溃?

历任编辑

鸟岛和彦

鸟岛和彦这个人,大家可能不太熟悉,但说到龙珠里的马西利特博士和比克大魔王,动漫迷们应该都知道。他们的原型其实就是鸟岛和彦,他不仅是鸟山明的第一任编辑,还是个厉害的角色。80年代的时候,他就开始搞游戏业务,还推动了漫画和游戏的关系。就像那个《达伊大冒险》,其实就是为了展示游戏和漫画的紧密联系。他甚至想让鸟山明把龙珠的故事扩展到宇宙,还想让鸟山明和高桥留美子一起设计个RPG游戏。这哥们儿自己也创办了本超火的游戏杂志V-Jump,后来还当上了少年JUMP的主编,开始大刀阔斧地改革。

近藤裕则

然后说说近藤裕则,他是鸟山明的第二代编辑,也是弗利萨的原型。别看他彬彬有礼,严厉程度可不低。他喜欢帅哥,所以老是催鸟山明赶紧让沙鲁完全体出场。但鸟山明其实更喜欢画异形和昆虫这类的东西,像蓝将军和萨博这样的帅哥他画得并不多。

武田冬门

最后来聊聊武田冬门,这哥们儿是鸟山明的第三代编辑,也是胖布欧的原型。他可是鸟山明的超级粉丝,对《布欧篇》的创作,他基本上就是:“哥,你随便画,我都爱看。”所以,鸟山明就创造出了那个超可爱的天真胖布欧。

爱好模型

鸟山明工作房间有个大桌子用来拼模型,身后的另一个大桌子也是拼模型用的,比他在角落里画画的桌面大了三四倍不止。漫画只是糊口的手艺,模型才是真爱啊。

爱好摩托车

据说鸟山明的父亲曾参加过摩托车比赛,拥有一家汽车维修公司,鸟山明说自己一有空就会去摩托车改装店。

鸟山明还透露过他除了设计游戏还设计汽车,但因为保密协议,他没有透入是为哪家公司设计汽车,他觉得能够以门外汉的身份去设计可以好好乘坐的汽车是他生活的意义。

爱好游戏

鸟山明曾说自己是做事比较认真、一旦钻进去就很投入的那种人——从他的模型上就能看得出来——结果有了红白机之后,一下子迷上电子游戏,难以自拔,他说:“本来只是想随便买个红白机玩玩,结果却上瘾了,玩得手指都疼啦。甭管工作多么辛苦,我的手指从没长过茧。奇怪的是,一玩起红白机来,手上竟然长出了茧。”

老师曾说:“我的假期全都耗在《勇者斗恶龙III》上了。既高兴,又难过。但游戏本身确实非常有趣。白天去游泳池,晚上沉迷红白机上的《母亲》游戏,最后只能半夜工作。”

后来他还与堀井雄二、坂口博信这个「梦幻团队」开发的《时空之轮》。

爱好养宠物

鸟山明特别喜欢养小动物,尤其是狗和鸟。

他养的第一只狗叫“涡轮丸”,挺有个性的名字吧。后来呢,他又买了只哈士奇,给取了个名字叫“马特”,哦对,原名是“俄罗斯套娃”,但叫“马特”更亲切些。

再后来,他又看上了柯基这种狗,于是就把“马特”这两个字颠倒一下,叫“托马”了。

不仅仅是家里养的小动物,鸟山明家里还常常有些“不请自来”的客人,比如野猫、蜥蜴、乌龟、老鼠,甚至还有蛇!

野猫经常来找家里的锅巴打架,也是挺有趣的。蜥蜴和乌龟他最后都放生了,也没看到它们回来报答他。说到老鼠,那可真是让他头疼,晚上一过街老鼠都能把他吓得跳进田里。家里的老鼠更是麻烦,最后用了“灭鼠110”才解决。他家的猫还特别有趣,每次抓到老鼠都送到他面前,真是让人哭笑不得。

哦对了,还有毒蛇呢,像日本蝮、响尾蛇这种,他都好几次死里逃生,真是命大!不过说实话,他更烦那些到处跑的蟑螂和永远打不完的苍蝇蚊子。

这周边的小动物实在是太多了,有时候真的吵得他都没法专心画画。

作品

鸟山明出来长篇IQ博士和龙珠外还有很多短篇漫画,以下是按年排列的作品列表。

  • 1978年:「神秘的rainiack」完成后,「Awawaworld」角逐 Young Jump 新人赏,「WONDER ISLAND」「WONDER ISLAND2」(刊於WJ増1/25)
  • 1979年:「本日的HIRI岛」,「GAL刑事TOMATO」
  • 1980年:「IQ博士」
  • 1981年:「POLA & ROID」,「ESCAPE」
  • 1982年:「MAD MATIC」,「HETAPPI漫画研究所」,「PINK」
  • 1983年:「CНОВІТ」,「CHOBIT2」,「骑龙少年 其壹」,「骑龙少年 其贰」,「东风大冒险」
  • 1984年:「龙珠」
  • 1986年:「MI Hoo」
  • 1987年:「LADY RED」,「剑之介大人」
  • 1988年:「SONCHOH」,「豆次郎」
  • 1989年:「小忍者空丸」
  • 1990年:「WOLF」,「CASHMAN」
  • 1992年:「TRUNKS THE STORY 唯一的战士」,「DUB & PETER1」
  • 1993年:「GO!GOIACKMAN」
  • 1996年:「宇宙人PEKE」,「TOKIMECHA」
  • 1997年:「魔人村的BUBUL」,「COWAI」
  • 1998年:「河鹿」,「肺魚鯕鰍」
  • 1999年:「猫魔人在此」,「猫魔人在此2」
  • 2000年:「HYOUTAMU」,「SANDLAND」
  • 2001年:「猫魔人Z」
  • 2003年:「TOCCIO THE ANGEL」,「猫魔人Z2」,「三色猫魔人」

海南版七龙珠

海南摄影美术出版社,它是由海南省新闻出版局花了11万块钱建立起来的,是个挺正式的省级单位。刚开张那会儿,他们也不知道出啥书好,就尝试搞了些人体写真、美女挂历啊,还有些壮阳秘籍、鬼怪故事、养生菜谱之类的,当然也包括了介绍海南风情的画册和连环画。不过,这些书受众面可比漫画广多了。

说到漫画,海南版的《龙珠》那可是真牛,品质高,速度快,其他出版社都追不上,只能跟在后面抄作业。1991年1月,第一本《龙珠》就问世了,接着3月份第一卷就全套上架了。到了1992年2月,故事都快讲到人造人和未来战士那段了,一年之内就出了10卷,销量也是噌噌往上涨,最火的时候一卷能卖到12万册呢!

但是,从第11卷开始,出版速度就慢下来了,中间还隔了大半年才继续出。为啥呢?因为海南版快追上原作者鸟山明的进度了,他们不能这么赶了。这时候,市场上还出现了单本卖的情况,以前都是成套卖的。

到了1993年,海南社这边的情况就不太清楚了,反正沙鲁篇拖到了1994年才出完。那段时间,读者们可急坏了,等得花儿都谢了。

还有件事得提,1992年中国加入了《伯尔尼公约》,版权这事儿就严起来了。之前海南社出的那些日漫,其实算是打了个擦边球,不算完全的盗版。但新政一出,规矩就来了。

1994年,央视《新闻联播》连着三天讲打击盗版的事儿,还专门点名了日本漫画,说它们内容不健康,影响青少年。这下子,家长们都紧张了,孩子们看《龙珠》都得偷偷摸摸的。

最后,海南社还是没能继续出漫画了,不管读者怎么盼,后面的故事都只能留在想象中了。到了1997年,新闻出版署查了他们,发现违规出版了不少书,管理也乱,还被吊销了出版资格。从此,海南摄影美术出版社和《龙珠》的故事就告一段落了。

画书大王

记得94年那会儿,《画书大王》,大家都叫它“画王”,咱心里的一道光!那感觉,就像是大家坐一块儿,平等交流,一块儿进步,全靠一腔热爱撑着。这杂志虽然薄薄的,不到百页,但里头啥都有,从鸟山明、北条司到高桥留美子这些国际大咖的作品,再到咱们国内第一代漫画家王庸声、谭晓春、陈翔他们的原创故事,那叫一个丰富多彩。

最让我开眼界的是,它不光有漫画看,还时不时来点法国漫画、纸雕漫画,世界各地的风格都能在这上面找到,学校里那美术课本可没这待遇!

画王连载漫画的同时,还教你咋画漫画,有套连载叫《漫画研究所》,鸟山明亲自上阵,从零开始,一步步教你。虽然最后几页讲怎么给集英社投稿,对国内来说用处不大,但人家还是完完整整给咱们搬来了,这份诚意,没得说!

我看画书大王的起因主要是因为杂志里面有刊登打败沙鲁之后的内容。每期我都有跟,同时也看到了更多的漫画,以及漫画背后作者画漫画的事情,这些点燃了我对漫画这个行业的热爱。

杂志是王庸声老师创办,初心是为了中国漫画的未来。可惜,画王只坚持了两年,就赶上那时候对漫画的“风波”,没了它,中国漫画就像突然被掐了脖子,艰难前行。就算有其他杂志接着干,但总感觉少了那么一股子劲儿。

那时,总会有那么群人,带着纯纯的热爱,艰难却很快乐的坚守者这座小城堡。

圣斗士星矢的前世今生,车田正美的坚持,城户光政的阴谋

作者 戴铭
2024年7月20日 07:33

前言

我最近和家人们一起做了一个播客,名叫《铭莉双收》,本文内容是对最新一期播客“还有人看圣斗士星矢吗?”的一个补充,欢迎大家订阅收听。

提到圣斗士星矢,大部分人都是通过90年央视播出的《圣斗士星矢》这部动画片看到的。后来200多个地方电台每年轮番播放,我也是那时看了一遍又一遍。再后来圣斗士的风潮就结束了,被龙珠和灌篮高手等动画片所替代。

我相对更铁粉些,后来还看了圣斗士星矢的漫画,工作后还买了车田正美授权的手代木史织画的冥王神话LC。

圣斗士星矢在生活中的影响随处可见,像小宇宙爆发、天马流星拳、庐山升龙霸这样的词总是声声入耳。B站年会圣斗士主题演出也是常客。日本还有真人舞台音乐剧。在法国圣斗士也是非常流行,几个法国网友自制了一部十分钟左右的动画短片,这个动画在法国引起了轰动,动画还传到东映高层那,这是发生在圣斗士动画结束十年后的事情,东映因此重启了冥王篇动画。最近22年,法国还举办了一场圣斗士星矢的音乐会。在一些电视剧和电影中也会用到这些词。《爱情公寓》里关谷神奇总是会变身“圣斗士关谷”。韩寒的《飞驰人生》电影中,车手摸到以前赛车时将其比作打开圣衣箱的瞬间。

就算圣斗士星矢这个 IP 还一直人气尚在,但自从漫画冥王篇人气下滑,动画海皇篇收视率下降,还有后续的作品一直无法再续辉煌,即便是车田正美老师本人在五十多岁再次持笔续篇《圣斗士星矢NEXT DIMENSION 冥王神话》也没法重塑辉煌。如今 ND 刚完结,迎来最终回,官方随书赠了再多纪念品也没有破圈传播出来。

这也可以看出《周刊少年Jump》这个舞台的残酷,也正是有了这样的舞台,才会不断诞生出新的神作。

但对于车田正美来说他为圣斗士星矢搭建的巨大世界观还远没完成,关于漫画和动画为何双双落败,车田正美和周刊少年Jump还有东映动画之间发生了什么问题,车田正美到底是个什么样的人呢?

车田正美

车田正美出生在建筑工人家庭,生日是1953年12月6日,今年他已经71岁,是射手座,所以知道为什么星矢是射手座候选人了吧。小时候车田正美就是暴走族的一员,他和其他不良少年不同的是他高中时特别喜欢本宫宏志的《男儿当大将》,决定当像本宫宏志那样的漫画家。另外大家熟悉本宫宏志的作品是《吞食天地》,就是街机上那个三国志游戏的漫画原著。

车田正美高三时给周刊少年Jump的新人奖比赛投稿,结果入围未果,安慰奖中他的名字都被写错,写成了东田正美。还是不良少年的他直接跑到杂志社问责,结果杂志编辑为了安息民愤,给了他一个当本宫宏志助手的机会,本宫宏志可是他的偶像啊。可想当时车田正美的杀气有多大。

有了当漫画家的觉悟,学习起来是飞快的。车田正美很快就开始在周刊少年Jump上连载漫画了,《女强风暴》、《拳王创世纪》、《风魔小次郎》和《男坂》等作品不断推出。《风魔小次郎》我小时候看过,感觉和圣斗士的风格很像,只是少了圣衣。缺少了圣衣加持,里面的人物更难和圣斗士中人物区分开了。

这些作品中,《男坂》由于题材还是一群小混混对付黑道,已经过时,人气不断降低,最后被迫完结。但是车田正美本人和他作品中的热血男儿一样,不轻言放弃,于是在完结最后一页还写着未完二字。这种不放弃就是三十年,2014年《男坂》重开连载。

当时《男坂》的被迫停载让车田正美的小宇宙终于得到爆发,他曾表示,如果下部作品不能红他金盆洗手不干这行了。为了达成这个目标,他低下了他高傲的头颅,将他信奉的拳击和小混混题材放弃掉,给他们包上一层商业化的圣衣,侵泡在希腊神话中,拿出来的就是《圣斗士星矢》。

当年《圣斗士星矢》火到集英社大楼都被称为车田大楼,上一次被这么叫的还是鸟山明的《阿拉蕾》。东映动画制作了《圣斗士星矢》的TV动画,接着就是手办的热卖,手办火热程度一直持续到现在。车田正美当时在文化类纳税是排名第一的,他买了很多豪车,生活也过的豪起来了。

好景不长,在海皇篇时,他和编辑理念出现分歧,还打算用以前成功时的闯宫套路,小强们不升级,黄金圣斗士还是最厉害的,如今的他似乎更有底气,于是不再听从于编辑提出的人物成长,新对手更强的Jump成功学。到了冥王篇读者终于开始厌倦,车田正美的故事编排能力不足的缺陷也更加突出了。于是车田正美自断双臂,大量删减了冥王篇的内容,使其能很快的完结。同样的情景也发生在动画这,《剧场版 天界篇·序奏》里,编剧和车田正美的想法也出现了很大的冲突,这部动画口碑非常差,TV动画海皇篇收视率也出现了滑铁卢。

虽然漫画冥王篇中被删减的内容在续作《圣斗士星矢NEXT DIMENSION 冥王神话》中得到了补全,但圣斗士的故事还是没能回到公众视野中来。

自车田正美三十多岁完结《圣斗士星矢》后,他还一直在画新的漫画,包括《静斗士翔》、《魔矢》、《钢铁神兵》、《青鸟的神话》以及自传漫画《蓝之时代》。这些作品都没有流行起来。

流行就是这样,大家都在追求新鲜感的东西,东西再好,看多了就无趣了。即使是手冢治虫,鸟山明这样的顶流漫画家,后期的作品也难流行开来,但是这也不会妨碍他们成为经典。曾经流行过能够成为一段回忆,经典的作品却能够一直被关注,价值会更高些呢。

在车田正美画续作ND时,他已经五十多了,现在才完结,这一画就是十八年。老爷子真的和他笔下的角色一样,为了自己的理想,一直坚持着,努力着。

说完车田正美,接下来,我会说一说圣斗士星矢到底是个什么样的故事,还会包括圣斗士神话的起源,也就是车田正美创作的超神话。还有正篇中提到的前圣战的故事。

故事起源

圣斗士星矢的世界观是车田正美独创的超神话,和我们知道的希腊神话不一样,只是借鉴了希腊神话、印度和中国的一些神话故事。

起源要从大爆炸说起,大爆炸释放出众神意志,众神意志诞生出大地、天空、海洋和人,有些人会觉醒众神意志。最开始有三个人,分别是掌管天地的宙斯、冥界的哈迪斯和海洋的波塞冬。

人的欲望不断膨胀,掠夺、侵占,到处是罪恶。宙斯无法忍受,于是发起了大洪水作为惩罚。后来宙斯把大地交给了自己的女儿雅典娜,然后消失了。

波塞冬为了夺取雅典娜的大地,创建了亚特兰蒂斯和海斗士军团,使用特殊材料制作了鳞衣给海斗士。由于雅典娜不喜欢武器,所以大地斗士很难伤害身穿鳞衣的海斗士,还很容易被杀。雅典娜让穆大陆的炼金术士使用銀星砂等材料制作出圣衣,以保护这些斗士,这些斗士也被称为圣斗士。由于特殊材质,这些圣衣如果遇到小伤害放进圣衣箱里是会自修复的。天空的88个星座是雅典娜对圣衣做的设计图,因此斗士只能穿上和自己守护星座相对应的圣衣。

有了圣斗士,海皇落败,返回亚特兰蒂斯,利用神力发动洪水和地震,于是雅典娜让圣斗士将亚特兰蒂斯破坏,并把波塞冬封印于北极。常年看守的圣斗士后来成为冰战士。

神之间的战争叫圣战。第一次圣战后雅典娜创建了雅典娜神殿和十二宫,这片区域叫做圣域。这之后雅典娜和圣斗士还遭遇了巨人族的侵犯,战争结束后圣衣产地穆大陆沉没,很多炼金术士也死于这场战争。制作圣衣的技术失传,只剩下少数可以修复圣衣的人,比如牡羊座的穆。

后面还发生了很多次圣战,看起来大多数神是不爱和平的,这点还是比不上人类。最残暴的神是战神阿瑞斯,他还会煽动人类发动战争。他的斗士被叫做狂斗士。阿瑞斯发起的战争导致大量亡民成了哈迪斯的子民。阿瑞斯和雅典娜的圣战中,圣斗士不断死于狂斗士手下,于是雅典娜允许天秤座圣斗士可以使用武器,让他对付狂斗士,致使阿瑞斯败北。

前圣战

最近的圣战,也就是圣斗士星矢正篇里提到的前圣战,发生在两百多年前。和前圣战相关的作品有《圣斗士星矢NEXT DIMENSION 冥王神话》(后面简称 ND)和《圣斗士星矢 THE LOST CANVAS 冥王神话》(后面简称 LC)

ND 是继续着正篇讲的,里面纱织穿越到了前圣战,目的是毁掉哈迪斯之剑,以拯救被哈迪斯之剑伤害的星矢。LC 完全是讲的发生在正篇之前的事情,但是人物和 ND 不完全一样。

正篇前发生的事情

正篇主要分为以下部分:

  • 银河战争篇
  • 暗黑圣斗士
  • 白银圣斗士
  • 圣域十二宫
  • 北欧篇
  • 海王篇
  • 冥王十二宫
  • 冥王冥界篇

在银河战争篇之前,女神雅典娜降生于圣域,老教皇史昂在宣布雅典娜降生这个消息后准备退位给撒加和艾欧罗斯,但是艾欧罗斯被选中的可能性更高些,于是撒加的弟弟加隆提议杀掉雅典娜和老教皇,撒加将加隆关到舒尼恩岬牢狱内。

住在德国灵根的潘多拉家中一个有封条的盒子,使得睡眠之神修普诺斯和死亡之神塔纳托斯复活了。瞬和哈迪斯的灵魂同时出生,哈迪斯的灵魂是借由潘多拉的母亲生出,潘多拉家族城堡里的人在哈迪斯灵魂诞生之时全部死去,只留下潘多拉一人,这座城堡后来就是哈迪斯城堡。

加隆在牢狱中发现了海皇波塞冬的封印,他让海皇波塞冬附身在希腊船王家的继承人-朱利安·索罗体内,波塞冬再次进入沉睡。

在老教皇宣布艾欧罗斯为继承人后,那夜撒加杀害了老教皇。在撒加要杀雅典娜时被艾欧罗斯发现,艾欧罗斯带上雅典娜打算逃出圣域,撒加以教皇名义说艾欧里亚绑架雅典娜,是叛徒,于是一路被其他黄金圣斗士阻拦,最后垂死的艾欧里亚碰到来希腊旅游的财阀城户光政,并将雅典娜托付给了他。

接着城户光政开始策划“百子祭奠神”。

正篇

城户光政为了保护雅典娜,将100名孤儿派到世界各地进行修行,最后只有十个人成为了圣斗士。在银河擂台赛中,胜出的前四人是星矢、冰河、紫龙和瞬,后来经历由瞬的哥哥一辉领导的暗黑圣斗士之战后,一辉最终被四小强的友谊所感动,从而加入了他们,成为了五小强。五小强战胜了白银圣斗士,勇闯了黄金十二宫,铲除了海斗士和海皇波塞冬,死磕了冥王哈迪斯。

正篇中最出彩是黄金圣斗士和冥斗士,这也是圣斗士星矢IP最核心的部分,直到现在,即便圣斗士新作品无人问津,但是黄金圣斗士和冥斗士的手办依然火爆。


黄金圣斗士排名

另外,黄金圣斗士中谁更厉害也是永远是最受关注的话题。由于圣斗士星矢的作品太多,内容相互冲突,这里只限于车田正美自己的两部作品里的角色来比较。SS 代表正篇,ND 代表续作。

第一梯队有

  • SS的撒加,SS加隆,SS童虎,SS史昂
  • ND双子座的该隐和亚伯,ND狮子座凯撒

第二梯队有

  • SS沙加,SS穆
  • ND山羊座以藏,ND处女座释静摩,ND水瓶座米斯托利亚,ND天蝎座艾卡拉特

第三梯队有

  • SS米罗,SS卡妙,SS艾欧里亚,SS修罗
  • ND的射手座格式塔,ND天秤座童虎,ND白羊座史昂,ND巨蟹座迪斯托尔,ND金牛奥克斯

第四梯队有

  • SS金牛阿鲁迪巴
  • ND双鱼座卡迪纳尔

最差的

  • SS双鱼座阿布罗狄,SS巨蟹座迪斯

城户光政是谁

看了圣斗士星矢的人都会觉得剧情漏洞太多。但这里做一个假设,所有圣斗士星矢的剧情漏洞就都填上了。

这个假设是城户光政就是将大地交给雅典娜后就消失的宙斯。从头按照这个假设再看看剧情,宙斯应该是在每次圣战中按照帮雅典娜的人,不然为什么每次雅典娜都能够赢得圣战。正篇中,宙斯下凡投胎成为城户光政,以神力成为了大富豪,并在两年内在全世界到处留种,生了一百个孩子。一般人到了城户光政这个年龄是无法办到的。因此这只能是使用了神力。并且他没有将遗产给自己的孩子而是给了一个捡来的娃娃,这并不是人类的思维。

艾欧里亚将雅典娜交给城户光政这段,是不是和赵子龙救阿斗,交给孩子亲爹刘备一模一样。宙斯一定是将自己的身份告知了艾欧里亚,不然他怎么会把雅典娜交给他呢,宙斯将艾欧里亚的灵魂附着到射手座圣衣上,这样圣衣可以继续保护雅典娜。你看后来冥界篇中黄金圣斗士复活时怎么就没有艾欧里亚呢,这是因为艾欧里亚的灵魂并没有到冥界啊。

救回雅典娜后,城户光政又生了两个最关键的孩子,一个是星矢,一个是瞬,星矢是弑神者,瞬是给冥王作为转世体用。随后几年中宙斯将这些孩子的母亲都害死,致使这些孩子成为孤儿。

6年后,城户光政将一百个孩子聚到一起,和撒加达成协议,提供一百个人给他培训,并提供大量资金支持。撒加欣然答应了。为了表达感谢,撒加额外将几十万年都没用过的天鹅座和天龙座奉献了出来,并配备了黄金圣斗士卡妙和童虎作为导师,看来紫龙和冰河是内定的啊。撒加知道下面要面对对付神的战斗,于是将弑神者星矢叫到希腊重点培养。你看,星矢成为圣斗士时,教皇还亲自到场祝贺。

宙斯布好局,就到天上等着看好戏了。

成为冥王的转世体的条件是世界上最纯洁善良的人,这种人不会主动进入冲突,降低了被伤害的概率,但是一旦遇到危险难于自保,因此需要一个能够随时保护转世体的人。于是冥王就在死亡岛安排了一个导师,这个导师没有身份、圣衣和实体,只有一个面具,存在的目的就培养一个能够保护冥王转世体的人,将这个人培养成死不了,能够穿越生死空间,还越战越猛的人。能够让人不死和自由穿越空间的只有冥王。这样每次瞬遇到危险,一辉都能瞬间感到,无视雅典娜结界,或者直接降到冥界第四狱。一辉的导师很可能是前面某个双子座圣斗士的灵魂,凤翼天翔加幻魔拳招式和效果与双子座的银河星爆加幻朧魔皇拳如出一辙。

最终宙斯的阴谋得逞,他的儿子们和他的女儿一同战胜了波塞冬,能够弑神的儿子星矢最终帮女儿弑掉了总是威胁她的大爷冥王哈迪斯。

最后海皇篇主题曲有段歌词可以作为这段猜想的印证,“正如被选中的神之子”。

一些八卦图个乐

魔玲和星矢有着超越师徒的关系,这关系有点像杨过和小龙女。用漫画中魔玲自己说的话作证,“星矢一直把我当作他的姐姐,而且我们彼此问维持着超越师徒间的感情。如果可以的话,最后还是想再让他看一次我的真面目呢。”,待星矢背上圣衣走的时候,魔玲揭开了自己的面具。

邪武这个舔狗,给雅典娜做牛做马,雅典娜还是喜欢不服管教的星矢,这情节是不是也很熟悉,车田正美妥妥的看过神雕侠侣。

冰河恋母,成为圣斗士的动力就是为了下海。

死亡岛的斗士都是没有正式编制的,他们的圣衣是自产的,没被雅典娜采用的,黑暗斗士的招式是自己琢磨的,不像圣斗士都有名师指点。这妥妥的山寨工厂。

童虎的设定是中国人,童虎是从乾隆时一直到90年代,那么肯定经历了抗日战争,日本人在庐山烧杀抢掠时童虎无动于衷么,难道是因为超过了参军年龄。

紫龙的父亲是城户光政,母亲是庐山人,应该是叫照香炉,紫龙从小跟随母亲,随母姓,人称庐山照紫龙,他还有个妹妹叫紫烟,有诗为证:
日照香炉生紫烟,遥看瀑布挂前川。
飞流直下三千尺,疑是银河落九天。

梵高灵感的来自江户浮世绘,颜气江户APP来自江户的色彩

作者 戴铭
2024年7月4日 05:48

前言

看过我前面博客的同学会发现我很喜欢自己画些配图,每次给画配色都会有些新的体会,却不知道是什么,于是每次都要经过一轮新的摸爬滚打才能调到自己满意的颜色,最近我为了能够提高自己的配色效率,就专门的看了一些色彩相关的书籍,学习了点色彩的理论,还专门的手写了一款 APP,提高了上色的效率呢。这个 APP 叫颜气江户,我已经发到苹果商店(28元),iOS、iPad 和 macOS 商店都可以找到,搜索“颜气江户”。下面我会聊一点色彩的理论,以及怎么将其融入到 APP 中的。

颜气江户 APP 有一些色彩 APP 的基本功能,200个江户色卡,色卡配图和配色表,以及识别图片配色等功能。

舒服的配色

首先,无论你是否画画,你都会觉得一些配色看起来很舒服,也会觉得有些配色看起来很丑。这些其实是有一定规律可循的。你的眼睛会寻求中等灰色才会达到一种平衡状态,互补色调合后会变成灰色,所以灰色和互补色很容易让配色舒服。因此苹果笔记本常年都是主打银灰色,macOS 操作系统的窗口和 Sidebar 一直以来也都是灰色的,非常耐看。有颜色的手机,单个不耐看,放在一起看着就很舒服,也是这个道理。

色彩即是物理的、化学的,还是生理的和心理的。物理主要是关于光谱和色彩光波,化学就是染色和载色剂相关的东西,对于艺术美学来说,主要是生理和心理方面的作用,看着舒服就是生理方面的,生理学家埃瓦尔德·赫林就说过眼睛和大脑需要中等灰色,没有的话大脑会处于不安状态。

这种灰色是可以通过色彩调合而成,最简单的调合方式就是互补色加白色。

互补色

那么什么是互补色呢。灰色好理解,互补色可能就比较复杂些了。严谨点的理论上通过色轮可以获取不同数量的互补颜色组合。比如双色互补是色轮上的直线段两端的颜色,三色互补是色轮上构成等边三角形火等腰三角形的三种色彩,四色互补是色轮上构成正方形或长方形的四种色彩。

虽然互补色会让画面配色看起来和谐舒服,但是有些艺术作品是需要有冲击性的,又或者有鲜明个性的,不然就不好玩了。因此创作时是不用严格按照互补色来配色,根据个人主观喜好来用色就好,当觉得配的不好时再运用互补色的理论调整好了。

对比色

另外,通过对比色可以突出你想突出的内容,有五种常用的色彩对比方法

  • 色相对比:十二色轮的主色
  • 明暗对比:黄色最亮,紫色最暗
  • 冷暖对比:红色和橙色最暖,蓝绿色最冷,风景画中远处色彩较冷
  • 补色对比:调合后成灰色的颜色对比
  • 面积对比:颜色所占比例对比

色彩的意义

色彩的组合其实和音乐的音阶一样,了解音阶的理论并不能创作出美妙的旋律,了解配色原理和每个颜色也不并能创作出美丽的作品。

了解色彩只是一切美好的基础,也是好的开端。

同时色彩的变迁和演变也伴随着历史、文化和艺术的演变,了解更多色彩的知识也能够探索到更多未知的领域,让日子意义感更足些呢。

江户色

19世纪50年代,江户时期的艺术品传入欧洲,在欧洲流行起来,巴黎一些前卫的艺术家很喜欢这些艺术品。1886年梵高来到巴黎见到江户浮世绘后对其产生了极大兴趣。

梵高起初收集浮世绘作品是想在咖啡馆里卖这些作品,结果没人买。梵高作品《铃鼓咖啡馆里的阿戈斯蒂娜·塞迦陀利》中可以看到咖啡馆上挂着浮世绘艺伎图。

虽然梵高没有从浮世绘中获取收入,却从其中获取了创作灵感。他临摹了歌川广重的《开花的李树园》和《大桥安宅骤雨》,还有溪斋英泉的《身穿云龙打挂的花魁》。

梵高《包扎着耳朵的自画像》这张自画像中墙上挂着的是佐藤虎清的《艺者与富士》。梵高曾表示自己很喜欢葛饰北斋的《神奈川冲浪里》,而后创作了最著名的《星月夜》,其中旋转的星云和神奈川冲浪里的浪非常神似。

梵高曾经说过:“如果你研究日本艺术,你看到的是一个充满智慧、哲思和悟性的人将时间用来做什么呢?研究地球与月亮的距离?不。研究俾斯麦政策?不。他研究一片草叶。这难道不是日本人教给我们的真正的宗教吗?他们简单地生活在自然之中如同花儿一样。”

我喜欢梵高的配色,同时也喜欢江户时代浮世绘画中的颜色,因此我打算先从江户时代的颜色入手,先做好研究。

我找了200种当时流行的颜色,颜气江户 APP 目前就内置了这些颜色,并加入了颜色的名字的来源。江户色名主要取自植物、动物、自然现象以及歌舞伎演员等。透过这些色名的来源,我发现还能够了解到江户时期的人们生活文化自然的方方面面。

举几个由歌舞伎演员名命名的颜色的例子,比如団十郎茶、梅幸茶、路考茶、芝翫茶、璃寛茶。

団十郎茶是歌舞伎役者市川團十郎爱使用的颜色,成田屋的标志,是一种偏红的棕色。艺荒事(武戏)代表剧“暂”里所用的服装颜色,还有温泉猴子的脸和柿饼也是这种颜色。

梅幸茶是歌舞伎大师初代尾上菊五郎最喜欢的颜色,颜色名来自菊五郎的俳句名梅幸。

路考茶是江戸的歌舞伎役者二代目瀬川菊之丞襲名王子路考喜欢的颜色,江户各地女性争相模仿,浮世絵美人衣服很多用的也是这个颜色。

芝翫茶是大阪花形役者三代目中村歌右衛門喜欢的颜色,呈偏淡的红棕色,芝翫是他的俳名。

璃寛茶是歌舞伎演员二世嵐吉三郎最喜欢的舞台衣裳的颜色,他的俳句叫瑠寛。虎河豚是一种高级鱼,就是璃寛茶色。

歌舞伎的演员成了明星和时尚的领导者,庶民喜欢把歌舞伎演员喜欢的颜色叫做役者色,将这些颜色衣裳穿在自己身上,会觉得自己很时尚。

另外,江户时代颜色有个最显著的特色,那就是茶色和鼠色很多,统称四十八茶百鼠。

江户中期富裕的商人越来越多,生活变得奢侈起来,更多钱用在了华丽颜色的服饰上,幕府为了避免攀比和浪费,因此提倡节俭,并颁布了奢侈禁止令,让更多的钱用在江户的城市发展上。这样一些以前代表低身份的茶色和鼠色被玩出了花,出现了很多新的颜色,这些颜色变成了流行时尚色。

小册子之 SwiftUI 动画

作者 戴铭
2024年5月25日 19:20

以下内容已整理到小册子中,本文会随着系统更新和我更多的实践而新增和更新,你可以下载“戴铭的开发小册子”应用,来跟踪查看本文内容新增和更新。小册子应用的代码可以在 Github 上查看。

本文属于小册子系列中的一篇,已发布系列文章有:

SwiftUI动画

SwiftUI 里实现动画的方式包括有 .animation 隐式动画、withAnimation 和 withTransaction 显示动画、matchedGeometryEffect Hero 动画和 TimelineView 等。

示例代码如下:

struct PlayAnimation: View {    @State private var isChange = false    private var anis:[String: Animation] = [        "p1": .default,        "p2": .linear(duration: 1),        "p3": .interpolatingSpring(stiffness: 5, damping: 3),        "p4": .easeInOut(duration: 1),        "p5": .easeIn(duration: 1),        "p6": .easeOut(duration: 1),        "p7": .interactiveSpring(response: 3, dampingFraction: 2, blendDuration: 1),        "p8": .spring(),        "p9": .default.repeatCount(3)    ]    @State private var selection = 1        var body: some View {        // animation 隐式动画和 withAnimation 显示动画        Text(isChange ? "另一种状态" : "一种状态")            .font(.headline)            .padding()            .animation(.easeInOut, value: isChange) // 受限的隐式动画,只绑定某个值。            .onTapGesture {                // 使用 withAnimation 就是显式动画,效果等同 withTransaction(Transaction(animation: .default))                withAnimation {                    isChange.toggle()                }                // 设置 Transaction。和隐式动画共存时,优先执行 withAnimation 或 Transaction。                var t = Transaction(animation: .linear(duration: 2))                t.disablesAnimations = true // 用来禁用隐式动画                withTransaction(t) {                    isChange.toggle()                }            } // end onHover                LazyVGrid(columns: [GridItem(.adaptive(minimum: isChange ? 60 : 30), spacing: 60)]) {            ForEach(Array(anis.keys), id: \.self) { s in                Image(s)                    .resizable()                    .scaledToFit()                    .animation(anis[s], value: isChange)                    .scaleEffect()            }        }        .padding()        Button {            isChange.toggle()        } label: {            Image(systemName: isChange ? "pause.fill" : "play.fill")                .renderingMode(.original)        }                // matchedGeometryEffect 的使用        VStack {            Text("后台")                .font(.headline)            placeStayView            Text("前台")                .font(.headline)            placeShowView        }        .padding(50)                // 通过使用相同 matchedGeometryEffect 的 id,绑定两个元素变化。        HStack {            if isChange {                Rectangle()                    .fill(.pink)                    .matchedGeometryEffect(id: "g1", in: mgeStore)                    .frame(width: 100, height: 100)            }            Spacer()            Button("转换") {                withAnimation(.linear(duration: 2.0)) {                    isChange.toggle()                }            }            Spacer()            if !isChange {                Circle()                    .fill(.orange)                    .matchedGeometryEffect(id: "g1", in: mgeStore)                    .frame(width: 70, height: 70)            }            HStack {                Image("p1")                    .resizable()                    .scaledToFit()                    .frame(width: 50, height: 50)                if !isChange {                    Image("p19")                        .resizable()                        .scaledToFit()                        .frame(width: 50, height: 50)                        .matchedGeometryEffect(id: "g1", in: mgeStore)                }                Image("p1")                    .resizable()                    .scaledToFit()                    .frame(width: 50, height: 50)            }        }        .padding()                // 使用 isSource,作为移动到相同 matchedGeometryEffect id 的方法。        HStack {            Image("p19")                .resizable()                .scaledToFit()                .frame(width: isChange ? 100 : 50, height: isChange ? 100 : 50)                .matchedGeometryEffect(id: isChange ? "g2" : "", in: mgeStore, isSource: false)                        Image("p19")                .resizable()                .scaledToFit()                .frame(width: 100, height: 100)                .matchedGeometryEffect(id: "g2", in: mgeStore)                .opacity(0)        }                                // 点击跟随的效果        HStack {            ForEach(Array(1...4), id: \.self) { i in                Image("p\(i)")                    .resizable()                    .scaledToFit()                    .frame(width: i == selection ? 200 : 50)                    .matchedGeometryEffect(id: "h\(i)", in: mgeStore)                    .onTapGesture {                        withAnimation {                            selection = i                        }                    }                    .shadow(color: .black, radius: 3, x: 2, y: 3)            }        }        .background(            RoundedRectangle(cornerRadius: 8).fill(.pink)                .matchedGeometryEffect(id: "h\(selection)", in: mgeStore, isSource: false)        )                // matchedGeometryEffect 还可以应用到 List 中,通过 Array enumerated 获得 index 作为 matchedGeometryEffect 的 id。右侧固定按钮可以直接让对应 id 的视图滚动到固定按钮的位置                        // TimelineView        TimelineView(.periodic(from: .now, by: 1)) { t in            Text("\(t.date)")            HStack(spacing: 20) {                let e = "p\(Int.random(in: 1...30))"                Image(e)                    .resizable()                    .scaledToFit()                    .frame(height: 40)                    .animation(.default.repeatCount(3), value: e)                                TimelineSubView(date: t.date) // 需要传入 timeline 的时间给子视图才能够起作用。                                }            .padding()        }                // matchedGeometryEffect        /// TimelineScheduler 的使用,TimelineScheduler 有以下类型        /// .animation:制定更新的频率,可以控制暂停        /// .everyMinute:每分钟更新一次        /// .explicit:所有要更新的放到一个数组里        /// .periodic:设置开始时间和更新频率        /// 也可以自定义 TimelineScheduler        TimelineView(.everySecond) { t in            let e = "p\(Int.random(in: 1...30))"            Image(e)                .resizable()                .scaledToFit()                .frame(height: 40)        }                // 自定义的 TimelineScheduler        TimelineView(.everyLoop(timeOffsets: [0.2, 0.7, 1, 0.5, 2])) { t in            TimelineSubView(date: t.date)        }    }        // MARK: - TimelineSubView    struct TimelineSubView: View {        let date : Date        @State private var s = "let's go"        // 顺序从数组中取值,取完再重头开始        @State private var idx: Int = 1        func advanceIndex(count: Int) {            idx = (idx + 1) % count            if idx == 0 { idx = 1 }        }                var body: some View {            HStack(spacing: 20) {                Image("p\(idx)")                    .resizable()                    .scaledToFit()                    .frame(height: 40)                    .animation(.easeIn(duration: 1), value: date)                    .onChange(of: date) { newValue in                        advanceIndex(count: 30)                        s = "\(date.hour):\(date.minute):\(date.second)"                    }                    .onAppear {                        advanceIndex(count: 30)                    }                                    Text(s)            }        }    }        // MARK: - 用 matchedGeometryEffect 做动画    /// matchedGeometryEffect 可以无缝的将一个图像变成另外一个图像。    @State private var placeStayItems = ["p1", "p2", "p3", "p4"]    @State private var placeShowItems: [String] = []        @Namespace private var mgeStore        private var placeStayView: some View {        LazyVGrid(columns: [GridItem(.adaptive(minimum: 30), spacing: 10)]) {            ForEach(placeStayItems, id: \.self) { s in                Image(s)                    .resizable()                    .scaledToFit()                    .matchedGeometryEffect(id: s, in: mgeStore)                    .onTapGesture {                        withAnimation {                            placeStayItems.removeAll { $0 == s }                            placeShowItems.append(s)                        }                    }                    .shadow(color: .black, radius: 2, x: 2, y: 4)            } // end ForEach        } // end LazyVGrid    } // private var placeStayView        private var placeShowView: some View {        LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)]) {            ForEach(placeShowItems, id: \.self) { s in                Image(s)                    .resizable()                    .scaledToFit()                    .matchedGeometryEffect(id: s, in: mgeStore)                    .onTapGesture {                        withAnimation {                            placeShowItems.removeAll { $0 == s }                            placeStayItems.append(s)                        }                    }                    .shadow(color: .black, radius: 2, x: 0, y: 2)                    .shadow(color: .white, radius: 5, x: 0, y: 2)            } // end ForEach        } // end LazyVGrid    } // end private var placeShowView    } // end struct PlayAnimation// MARK: - 扩展 TimelineScheduleextension TimelineSchedule where Self == PeriodicTimelineSchedule {    static var everySecond: PeriodicTimelineSchedule {        get {            .init(from: .now, by: 1)        }    }}// MARK: - 自定义一个 TimelineSchedule// timeOffsets 用完,就会再重头重新再来一遍struct PCLoopTimelineSchedule: TimelineSchedule {    let timeOffsets: [TimeInterval]        func entries(from startDate: Date, mode: TimelineScheduleMode) -> Entries {        Entries(last: startDate, offsets: timeOffsets)    }        struct Entries: Sequence, IteratorProtocol {        var last: Date        let offsets: [TimeInterval]        var idx: Int = -1        mutating func next() -> Date? {            idx = (idx + 1) % offsets.count            last = last.addingTimeInterval(offsets[idx])            return last        }    } // end Struct Entries}// 为自定义的 PCLoopTimelineSchedule 做一个 TimelineSchedule 的扩展函数,方便使用extension TimelineSchedule where Self == PCLoopTimelineSchedule {    static func everyLoop(timeOffsets: [TimeInterval]) -> PCLoopTimelineSchedule {        .init(timeOffsets: timeOffsets)    }}

contentTransition

.contentTransition(.numericText()) 修饰符用于在视图内容发生变化时,以数字动画的方式进行过渡。

struct ContentView: View {    @State private var filmNumber: Int = 0        var body: some View {        VStack(spacing: 20) {            Text("\(filmNumber)")                .contentTransition(.numericText())                .animation(.easeIn, value: filmNumber)            Stepper("电影数量", value: $filmNumber, in: 0...100)        }        .font(.largeTitle)        .foregroundColor(.indigo)    }}

animation修饰符

基本用法

在 SwiftUI 中,创建一个动画需要以下三个组成部分:

  • 一个时间曲线函数
  • 一个声明将状态(或特定的依赖项)与该时间曲线函数关联起来
  • 一个依赖于该状态(或特定的依赖项)的可动画组件

动画的接口定义为 Animation(timingFunction:property:duration:delay)

  • timingFunction 是时间曲线函数,可以是线性、缓动、弹簧等
  • property 是动画属性,可以是颜色、大小、位置等
  • duration 是动画持续时间
  • delay 是动画延迟时间

三种写法

  • withAnimation(_:_:) 全局应用
  • animation(_:value:) 应用于 View
  • animation(_:) 应用于绑定的变量

第一种

withAnimation(.easeInOut(duration: 1.5).delay(1.0)) {    myProperty = newValue}

第二种

View().animation(.easeInOut(duration: 1.5).delay(1.0), value: myProperty)

第三种

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        PosterView(scale: $scale.animation(.linear(duration: 1)))    }}struct PosterView: View {    @Binding var scale: CGFloat    var body: some View {        Image("evermore")            .resizable()            .scaledToFit()            .scaleEffect(scale)            .onAppear {                scale = 1.5            }    }}

在这个示例中,我们创建了一个 MovieView,它有一个状态变量 scale。当 scale 的值改变时,PosterView 中的海报图片会以线性动画的方式进行缩放。当 PosterView 出现时,scale 的值会改变为 1.5,因此海报图片会以线性动画的方式放大到 1.5 倍。

在 SwiftUI 中,我们也可以创建一个自定义的 AnimatableModifier 来实现对图文卡片大小的动画处理。

struct ContentView: View {    @State private var isSmall = false    var body: some View {        VStack {            Image("evermore")                .resizable()                .aspectRatio(contentMode: .fit)                .clipShape(.rect(cornerSize: CGSize(width: 16, height: 16)))            Text("电影标题")                .font(.title)                .fontWeight(.bold)        }        .animatableCard(size: isSmall ? CGSize(width: 200, height: 300) : CGSize(width: 400, height: 600))        .onTapGesture {            withAnimation(.easeInOut(duration: 1)){                isSmall.toggle()            }        }    }}struct AnimatableCardModifier: AnimatableModifier {    var size: CGSize    var color: Color = .white        var animatableData: CGSize.AnimatableData {        get { CGSize.AnimatableData(size.width, size.height) }        set { size = CGSize(width: newValue.first, height: newValue.second) }    }        func body(content: Content) -> some View {        content            .frame(width: size.width, height: size.height)            .background(color)            .cornerRadius(10)    }}extension View {    func animatableCard(size: CGSize,                        color: Color = .white) -> some View {        self.modifier(AnimatableCardModifier(size: size,                                             color: color))    }}

SwiftUI 内置了许多动画过渡函数,主要分为四类:

  • 时间曲线动画函数
  • 弹簧动画函数
  • 高阶动画函数
  • 自定义动画函数

时间曲线动画函数

时间曲线函数决定了动画的速度如何随时间变化,这对于动画的自然感觉非常重要。

SwiftUI 提供了以下几种预设的时间曲线函数:

  • linear:线性动画,动画速度始终保持不变。
  • easeIn:动画开始时速度较慢,然后逐渐加速。
  • easeOut:动画开始时速度较快,然后逐渐减速。
  • easeInOut:动画开始和结束时速度较慢,中间阶段速度较快。

除此之外,SwiftUI 还提供了 timingCurve 函数,可以通过二次曲线或 Bézier 曲线来自定义插值函数,实现更复杂的动画效果。

以下是代码示例:

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("evermore")                .resizable()                .scaledToFit()                .scaleEffect(scale)        }        .onAppear {            withAnimation(.easeInOut(duration: 1.0)) {                scale = 1.5            }        }    }}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,海报的大小会以 easeInOut 的方式在 1 秒内放大到 1.5 倍。

弹簧动画函数

弹簧动画函数可以模拟物理世界中的弹簧运动,使动画看起来更加自然和生动。

SwiftUI 提供了以下几种预设的弹簧动画函数:

  • smooth:平滑的弹簧动画,动画速度逐渐减慢,直到停止。
  • snappy:快速的弹簧动画,动画速度快速减慢,然后停止。
  • bouncy:弹跳的弹簧动画,动画在结束时会有一些弹跳效果。

除此之外,SwiftUI 还提供了 spring 函数,可以自定义弹簧动画的持续时间、弹跳度和混合持续时间,实现更复杂的弹簧动画效果。

以下是代码示例:

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("evermore")                .resizable()                .scaledToFit()                .scaleEffect(scale)        }        .onAppear {            withAnimation(.spring(response: 0.5, dampingFraction: 0.5, blendDuration: 1)) {                scale = 1.5            }        }    }}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,海报的大小会以自定义的弹簧动画方式在 0.5 秒内放大到 1.5 倍。

高阶动画函数

高级动画函数可以在基础动画函数的基础上,添加延迟、重复、翻转和速度等功能,使动画效果更加丰富和复杂。

以下是这些函数的简单介绍:

  • func delay(TimeInterval) -> Animation:此函数可以使动画在指定的时间间隔后开始。
  • func repeatCount(Int, autoreverses: Bool) -> Animation:此函数可以使动画重复指定的次数。如果 autoreverses 参数为 true,则每次重复时动画都会翻转。
  • func repeatForever(autoreverses: Bool) -> Animation:此函数可以使动画无限次重复。如果 autoreverses 参数为 true,则每次重复时动画都会翻转。
  • func speed(Double) -> Animation:此函数可以调整动画的速度,使其比默认速度快或慢。

以下是代码示例:

struct MovieView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("movie_poster")                .resizable()                .scaledToFit()                .scaleEffect(scale)        }        .onAppear {            withAnimation(Animation.easeInOut(duration: 1.0).delay(0.5).repeatCount(3, autoreverses: true).speed(2)) {                scale = 1.5            }        }    }}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,海报的大小会以 easeInOut 的方式在 1 秒内放大到 1.5 倍,然后在 0.5 秒后开始,重复 3 次,每次重复都会翻转,速度是默认速度的 2 倍。

自定义动画函数

SwiftUI 可以通过实现 CustomAnimation 协议来完全自定义插值算法。

以下是一个简单的 Linear 动画函数的实现:

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("evermore")                .resizable()                .scaledToFit()                .scaleEffect(scale)                .animation(.myLinear(duration: 1), value: scale) // use myLinear animation        }        .onAppear {            scale = 1.5        }    }}struct MyLinearAnimation: CustomAnimation {  var duration: TimeInterval  func animate<V>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V? where V : VectorArithmetic {    if time <= duration {      value.scaled(by: time / duration)    } else {      nil    }  }  func velocity<V: VectorArithmetic>(    value: V, time: TimeInterval, context: AnimationContext<V>  ) -> V? {    value.scaled(by: 1.0 / duration)  }  func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool where V : VectorArithmetic {    true  }}extension Animation {  public static func myLinear(duration: TimeInterval) -> Animation { // define function like linear    return Animation(MyLinearAnimation(duration: duration))  }}

Transaction

Transaction 使用指南

这段内容主要介绍了 SwiftUI 中的 TransactionwithTransactionTransaction 是 SwiftUI 中用于控制动画的一种方式,它可以用来定义动画的详细参数,如动画类型、持续时间等。withTransaction 是一个函数,它接受一个 Transaction 实例和一个闭包作为参数,闭包中的代码将在这个 Transaction 的上下文中执行。

以下是一个使用 TransactionwithTransaction 的代码示例:

struct ContentView: View {    @State private var scale: CGFloat = 1.0    var body: some View {        VStack {            Text("电影标题")                .font(.title)                .padding()            Image("evermore")                .resizable()                .scaledToFit()                .scaleEffect(scale)        }        .onAppear {            let transaction = Transaction(animation: .easeInOut(duration: 1))            withTransaction(transaction) {                scale = 1.5            }        }    }}

在这个示例中,我们创建了一个 MovieView,它包含一个电影标题和一个电影海报。当视图出现时,我们创建了一个 Transaction,并设置了动画类型为 easeInOut,持续时间为 1 秒。然后我们在 withTransaction 的闭包中改变 scale 的值,这样海报的大小就会以 easeInOut 的方式在 1 秒内放大到 1.5 倍。

使用 TransactionwithTransaction

SwiftUI 中 TransactiondisablesAnimationsisContinuous 属性,以及 transaction(_:) 方法怎么使用?

disablesAnimations 属性可以用来禁止动画,isContinuous 属性可以用来标识一个连续的交互(例如拖动)。transaction(_:) 方法可以用来创建一个新的 Transaction 并在其闭包中设置动画参数。

以下是一个使用这些特性的代码示例:

struct ContentView: View {    @State var size: CGFloat = 100    @GestureState var dragSize: CGSize = .zero    var body: some View {        VStack {            Image("fearless")                .resizable()                .scaledToFit()                .frame(width: size, height: size) // 使用 size 控制尺寸,而非位置                .animation(.spring(), value: size) // 使用弹簧动画                .transaction {                    if $0.isContinuous {                        $0.animation = nil // 拖动时,不设置动画                    } else {                        $0.animation = .spring() // 使用弹簧动画                    }                }                .gesture(                    DragGesture()                        .updating($dragSize, body: { current, state, transaction in                            state = .init(width: current.translation.width, height: current.translation.height)                            transaction.isContinuous = true // 拖动时,设置标识                        })                )            Stepper("尺寸: \(size)", value: $size, in: 50...200) // 使用 Stepper 替代 Slider            Button("开始动画") {                var transaction = Transaction()                if size < 150 { transaction.disablesAnimations = true }                withTransaction(transaction) {                    size = 50                }            }        }    }}

在这个示例中,当 size 小于 150 时,我们禁用动画。通过 .isContinuous 属性,我们可以标识一个连续的交互(例如拖动)。在这个示例中,当拖动时,我们禁用动画。通过 transaction(_:) 方法,我们可以创建一个新的 Transaction 并在其中设置动画参数。

用于视图组件

大部分 SwiftUI 视图组件都有 transaction(_:) 方法,可以用来设置动画参数。比如 NavigationStack, Sheet, Alert 等。

Transaction 也可以用于 BindingFetchRequest

看下面的例子:

struct ContentView: View {    @State var size: CGFloat = 100    @State var isBold: Bool = false    let animation: Animation? = .spring    var sizeBinding: Binding<CGFloat> {        let transaction = Transaction(animation: animation)        return $size.transaction(transaction)    }    var isBoldBinding: Binding<Bool> {        let transaction = Transaction(animation: animation)        return $isBold.transaction(transaction)    }    var body: some View {        VStack {            Image(systemName: "film")                .resizable()                .scaledToFit()                .frame(width: size, height: size) // 使用 size 控制尺寸,而非位置                .font(.system(size: size, weight: isBold ? .bold : .regular)) // 使用 isBold 控制粗细            Stepper("尺寸: \(size)", value: sizeBinding, in: 50...200)            Toggle("粗细", isOn: isBoldBinding)        }        .padding()    }}

传播行为

Transaction 可以用于控制动画的传播行为。在 SwiftUI 中,动画可以在视图层次结构中传播,这意味着一个视图的动画效果可能会影响到其子视图。Transaction 可以用来控制动画的传播行为,例如禁用动画、设置动画类型等。

以下是一个使用 Transaction 控制动画传播行为的代码示例:

enum BookStatus {    case small, medium, large, extraLarge}extension View {    @ViewBuilder func debugAnimation() -> some View {        transaction {            debugPrint($0.animation ?? "")        }    }}struct ContentView: View {    @State var status: BookStatus = .small    var animation: Animation? {        switch status {        case .small:            return .linear        case .medium:            return .easeIn        case .large:            return .easeOut        case .extraLarge:            return .spring()        }    }    var size: CGFloat {        switch status {        case .small:            return 100        case .medium:            return 200        case .large:            return 300        case .extraLarge:            return 400        }    }    var body: some View {        VStack {            Image(systemName: "book")                .resizable()                .scaledToFit()                .frame(width: size, height: size)                .debugAnimation() // 查看动画变化信息            Button("改变状态") {                var transaction = Transaction(animation: animation)                withTransaction(transaction) {                    switch self.status {                    case .small:                        self.status = .medium                    case .medium:                        self.status = .large                    case .large:                        self.status = .extraLarge                    case .extraLarge:                        self.status = .small                    }                }            }        }    }}

这个示例中,我们创建了一个 BookView,它包含一个书籍图标。我们通过 BookStatus 枚举来控制书籍的大小,通过 animation 计算属性来根据状态返回不同的动画类型。在 withTransaction 中,我们根据状态创建一个新的 Transaction,并在其中设置动画类型。通过 debugAnimation 修饰符,我们可以查看动画的变化信息。

TransactionKey

TransactionKey 是一种在 SwiftUI 的视图更新过程中传递额外信息的机制,它可以让你在不同的视图和视图更新之间共享数据。

struct ContentView: View {    @State private var store = MovieStore()    var body: some View {        VStack {            Image("evermore")                .resizable()                .scaledToFit()                .frame(width: 300, height: 300)                .saturation(store.isPlaying ? 1 : 0) // 滤镜变化                .transaction {                    $0.animation = $0[StatusKey.self].animation                }            PlayView(store: store)            PauseView(store: store)        }    }}struct PlayView: View {    let store: MovieStore    var body: some View {        Button("播放") {            withTransaction(\.status, .playing) {                store.isPlaying.toggle()            }        }    }}struct PauseView: View {    let store: MovieStore    var body: some View {        Button("暂停") {            withTransaction(\.status, .paused) {                store.isPlaying.toggle()            }        }    }}@Observableclass MovieStore {    var isPlaying = false}enum MovieStatus {    case playing    case paused    case stopped    var animation: Animation? {        switch self {        case .playing:            Animation.linear(duration: 2)        case .paused:            nil        case .stopped:            Animation.easeInOut(duration: 1)        }    }}struct StatusKey: TransactionKey {    static var defaultValue: MovieStatus = .stopped}extension Transaction {    var status: MovieStatus {        get { self[StatusKey.self] }        set { self[StatusKey.self] = newValue }    }}

以上代码中,我们创建了一个 MovieStore 类,用于存储电影播放状态。我们通过 PlayViewPauseView 分别创建了播放和暂停按钮,点击按钮时,我们通过 withTransaction 函数改变了 MovieStoreisPlaying 属性,并根据状态设置了动画类型。在 ContentView 中,我们通过 transaction 修饰符设置了动画类型为 MovieStatus 中的动画类型。

AnyTransition

AnyTransition 是一个用于创建自定义过渡效果的类型,它可以让你定义视图之间的过渡动画。你可以使用 AnyTransitionmodifier 方法将自定义过渡效果应用到视图上。

struct ContentView: View {        @StateObject var musicViewModel = MusicViewModel()        var body: some View {        VStack {            ForEach(musicViewModel.musicNames, id: \.description) { musicName in                if musicName == musicViewModel.currentMusic {                    Image(musicName)                        .resizable()                        .frame(width: 250, height: 250)                        .ignoresSafeArea()                        .transition(.glitch.combined(with: .opacity))                }            }                        Button("Next Music") {                musicViewModel.selectNextMusic()            }            .buttonStyle(.borderedProminent)            .padding()            .background(Color.blue)            .foregroundColor(.white)            .cornerRadius(10)        }    }}struct MyTransition: ViewModifier {    let active: Bool    func body(content: Content) -> some View {        content            .rotationEffect(active ? .degrees(Double.random(in: -10...10)) : .zero)            .offset(x: active ? CGFloat.random(in: -10...10) : 0, y: active ? CGFloat.random(in: -10...10) : 0)    }}extension AnyTransition {    static var glitch: AnyTransition {        AnyTransition.modifier(            active: MyTransition(active: true),            identity: MyTransition(active: false)        )    }}class MusicViewModel: ObservableObject {    @Published var currentMusic = ""        let musicNames = ["fearless", "evermore", "red", "speaknow", "lover"]        init() {        currentMusic = musicNames.first ?? "fearless"    }        func selectNextMusic() {        guard let currentIndex = musicNames.firstIndex(of: currentMusic) else {            return        }                let nextIndex = currentIndex + 1 < musicNames.count ? currentIndex + 1 : 0                withAnimation(.easeInOut(duration: 2)) {            currentMusic = musicNames[nextIndex]        }    }}

以上代码中,我们创建了一个 MusicViewModel 类,用于存储音乐播放状态。我们通过 MyTransition 自定义了一个过渡效果,通过 AnyTransitionmodifier 方法将自定义过渡效果应用到视图上。在 ContentView 中,我们通过 transition 修饰符设置了过渡效果为 glitch,并在点击按钮时切换音乐。

Matched Geometry Effect

位置变化

Matched Geometry Effect 是一种特殊的动画效果。当你有两个视图,并且你想在一个视图消失,另一个视图出现时,创建一个平滑的过渡动画,你就可以使用这个效果。你只需要给这两个视图添加同样的标识符和命名空间,然后当你删除一个视图并添加另一个视图时,就会自动创建一个动画,让一个视图看起来像是滑动到另一个视图的位置。

示例代码如下:

struct ContentView: View {    @StateObject var viewModel = ViewModel()    @Namespace var musicSelectionNamespace    var body: some View {        VStack {            HStack {                ForEach(viewModel.topMusic) { item in                    Button(action: { viewModel.selectTopMusic(item) }) {                        ZStack {                            Image(item.name)                                .resizable()                                .frame(width: 60, height: 60)                            Text(item.name)                                .fontDesign(.rounded)                                .foregroundColor(.white)                                .shadow(radius: 10)                        }                    }                    .matchedGeometryEffect(id: item.id, in: musicSelectionNamespace)                }            }            .frame(minHeight: 150)            Spacer()                .frame(height: 250)            HStack {                ForEach(viewModel.bottomMusic) { item in                    Button(action: { viewModel.selectBottomMusic(item) }) {                        ZStack {                            Image(item.name)                                .resizable()                                .frame(width: 90, height: 90)                            Text(item.name)                                .font(.title3)                                .fontWeight(.bold)                                .foregroundColor(.white)                                .shadow(radius: 10)                        }                    }                    .matchedGeometryEffect(id: item.id, in: musicSelectionNamespace)                }            }            .frame(minHeight: 150)        }    }}

以上代码中,我们创建了一个 ContentView 视图,其中包含两个 HStack 视图,分别展示了 viewModel 中的 topMusicbottomMusic 数组。我们为每个 topMusicbottomMusic 元素创建了一个 Button 视图,当用户点击按钮时,会调用 viewModel 中的 selectTopMusicselectBottomMusic 方法。我们使用 matchedGeometryEffect 修饰符为每个 Button 视图添加了一个标识符,这样当用户点击按钮时,就会自动创建一个动画,让一个视图看起来像是滑动到另一个视图的位置。

大小变化

Matched Geometry Effect 在大小和位置上都可以进行动画过渡,这样可以让你创建更加复杂的动画效果。

以下是一个视图大小切换的示例:

struct ContentView: View {    @State var isExpanded: Bool = false        private var albumId = "Album"        @Namespace var expansionAnimation        var body: some View {        VStack {            albumView(isExpanded: isExpanded)        }        .padding()        .onTapGesture {            withAnimation {                isExpanded.toggle()            }        }    }        @ViewBuilder    func albumView(isExpanded: Bool) -> some View {        let imageSize = isExpanded ? CGSize(width: 300, height: 450) : CGSize(width: 100, height: 150)        Image(isExpanded ? "evermore" : "fearless")            .resizable()            .aspectRatio(contentMode: .fill)            .frame(width: imageSize.width, height: imageSize.height)            .clipped()            .matchedGeometryEffect(id: albumId, in: expansionAnimation)            .overlay {                Text("Taylor Swift")                    .font(isExpanded ? .largeTitle : .headline)                    .fontDesign(.monospaced)                    .fontDesign(.rounded)                    .foregroundStyle(.white)            }    }}

内容位置变化

内容位置变化的动画效果。以下是一个内容位置变化的示例:

struct ContentView: View {    @State var show = false    @Namespace var placeHolder    @State var albumCoverSize: CGSize = .zero    @State var songListSize: CGSize = .zero    var body: some View {        ZStack {            VStack {                Text("Taylor Swift,1989年12月13日出生于美国宾夕法尼亚州,美国乡村音乐、流行音乐女歌手、词曲创作人、演员、慈善家。")                    .font(.title)                    .fontDesign(.monospaced)                    .fontDesign(.rounded)                    .padding(20)                Spacer()            }            Color.clear                // AlbumCover placeholder                .overlay(alignment: .bottom) {                    Color.clear // AlbumCoverView().opacity(0.01)                        .frame(height: albumCoverSize.height)                        .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: .bottom, isSource: true)                        .matchedGeometryEffect(id: "top", in: placeHolder, anchor: .top, isSource: true)                }                .overlay(                    AlbumCoverView()                        .sizeInfo($albumCoverSize)                        .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: show ? .bottom : .top, isSource: false)                )                .overlay(                    SongListView()                        .matchedGeometryEffect(id: "top", in: placeHolder, anchor: show ? .bottom : .top, isSource: false)                )                .animation(.default, value: show)                .ignoresSafeArea()                .overlayButton(show: $show)        }    }}struct AlbumCoverView: View {    var body: some View {        Image("evermore")            .resizable()            .aspectRatio(contentMode: .fill)    }}struct SongListView: View {    var body: some View {        List {            Text("Fearless")            Text("Speak Now")            Text("Red")            // ...        }    }}extension View {    func overlayButton(show: Binding<Bool>) -> some View {        self.overlay(            Button(action: {                withAnimation {                    show.wrappedValue.toggle()                }            }) {                Image(systemName: "arrow.up.arrow.down.square")                    .font(.largeTitle)                    .padding()                    .background(Color.white.opacity(0.75))                    .clipShape(Circle())            }            .padding()            , alignment: .topTrailing        )    }        func sizeInfo(_ size: Binding<CGSize>) -> some View {        self.background(            GeometryReader { geometry in                Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)            }        )        .onPreferenceChange(SizePreferenceKey.self) { size.wrappedValue = $0 }    }}struct SizePreferenceKey: PreferenceKey {    static var defaultValue: CGSize = .zero    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {        value = nextValue()    }}

我们使用 matchedGeometryEffect 修饰符为 AlbumCoverViewSongListView 添加了一个标识符,这样当用户点击按钮时,就会自动创建一个动画,让 AlbumCoverViewSongListView 看起来像是从一个位置切换到另一个位置。

点击显示详细信息

点击显示详细信息的动画效果。

struct ContentView: View {    @Namespace var animation    @State var showDetail = false            var body: some View {        ZStack {            if (!showDetail) {                VStack {                    Text("Taylor Swift")                            .matchedGeometryEffect(id: "artist", in: animation)                            .font(.largeTitle.bold())                            .foregroundColor(Color.white)                                        Text("美国歌手")                        .matchedGeometryEffect(id: "description", in: animation)                        .font(.title3.bold())                        .foregroundColor(Color.white)                }                .padding(30)                .background(                    Rectangle().fill(.black.gradient)                        .matchedGeometryEffect(id: "background", in: animation)                )            } else {                SingerView(animation: animation)            }        }        .onTapGesture {            withAnimation {                showDetail.toggle()            }        }    }}struct SingerView: View {    var animation: Namespace.ID    var body: some View {        VStack{            Text("Taylor Swift")                    .matchedGeometryEffect(id: "artist", in: animation)                    .font(.largeTitle.bold())                    .foregroundColor(Color.white)                        Text("美国歌手")                .matchedGeometryEffect(id: "description", in: animation)                .font(.title3.bold())                .foregroundColor(Color.white)            Spacer()                .frame(height: 30)            Text("泰勒·阿利森·斯威夫特(Taylor Swift),1989年12月13日出生于美国宾夕法尼亚州,美国乡村音乐、流行音乐女歌手、词曲创作人、演员、慈善家。")                .font(.subheadline.bold())                .foregroundColor(Color.white)                        Spacer()                .frame(height: 30)            Image("evermore")                .resizable()                .scaledToFit()                .clipShape(.rect(cornerSize: CGSize(width: 16, height: 16)))                        Text("Evermore 是 Taylor Swift 的最新专辑,这是她在 2020 年的第二张专辑,也是她的第九张录音室专辑。")                .font(.subheadline.bold())                .foregroundColor(Color.white)                    }        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)        .padding(.all, 20)        .background(            Rectangle().fill(.black.gradient)                .matchedGeometryEffect(id: "background", in: animation)                .ignoresSafeArea(.all)        )    }}

导航动画

以下是一个导航动画的示例:

struct ContentView: View {    @Namespace var animation    @State var selectedManga: String? = nil            var body: some View {        ZStack {            if (selectedManga == nil) {                MangaListView(animation: animation, selectedManga: $selectedManga)            } else {                MangaDetailView(selectedManga: $selectedManga, animation: animation)            }        }    }}struct MangaDetailView: View {    @Binding var selectedManga: String?    var animation: Namespace.ID        var body: some View {        VStack {            Text( "\(selectedManga ?? "")")                    .matchedGeometryEffect(id: "mangaTitle", in: animation)                    .font(.title3.bold())                    .foregroundColor(Color.black)                        Spacer()                .frame(height: 50)            Button(action: {                withAnimation {                    selectedManga = nil                }            }, label: {                Text( "返回")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.red)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.white.gradient)            )        }        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)        .padding(.all, 20)        .background(            Color(UIColor.systemTeal)                .matchedGeometryEffect(id: "background", in: animation)                .ignoresSafeArea(.all)        )    }}struct MangaListView: View {    var animation: Namespace.ID    @Binding var selectedManga: String?    var body: some View {        VStack {            Button(action: {                withAnimation {                    selectedManga = "海贼王"                }            }, label: {                Text( "海贼王")                    .matchedGeometryEffect(id: "mangaTitle", in: animation)                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.teal)            )                        Button(action: {                withAnimation {                    selectedManga = "火影忍者"                }            }, label: {                Text( "火影忍者")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.mint)                    .matchedGeometryEffect(id: "background", in: animation)            )            Button(action: {                withAnimation {                    selectedManga = "进击的巨人"                }            }, label: {                Text( "进击的巨人")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.orange)            )            Button(action: {                withAnimation {                    selectedManga = "鬼灭之刃"                }            }, label: {                Text( "鬼灭之刃")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.purple)            )            Button(action: {                withAnimation {                    selectedManga = "我的英雄学院"                }            }, label: {                Text( "我的英雄学院")                    .font(.title3.bold())                    .foregroundColor(Color.black)            })            .foregroundColor(Color.black)            .padding(.all, 8)            .background(                RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))                    .fill(Color.green)            )        }    }}

geometryGroup

.geometryGroup() 主要用于处理一组视图动画变化时不协调的问题。如果你有一组视图,它们的位置和大小会随着动画变化,你可以使用 .geometryGroup() 修饰符来确保它们的位置和大小保持一致。

PhaseAnimator

PhaseAnimator

以下代码示例演示了如何使用 PhaseAnimator 视图修饰符创建一个动画,该动画通过循环遍历所有动画步骤来连续运行。在这个例子中,我们使用 PhaseAnimator 来创建一个简单的动画,该动画通过循环遍历所有动画步骤来连续运行。当观测值发生变化时,动画会触发一次。

enum AlbumAnimationPhase: String, CaseIterable, Comparable {    case evermore, fearless, folklore, lover, midnights, red, speaknow    static func < (lhs: AlbumAnimationPhase, rhs: AlbumAnimationPhase) -> Bool {        lhs.rawValue < rhs.rawValue    }}struct ContentView: View {    @State var animate: Bool = false    var body: some View {        ScrollView {            PhaseAnimator(                AlbumAnimationPhase.allCases,                trigger: animate,                content: { phase in                    VStack {                        ForEach(AlbumAnimationPhase.allCases, id: \.self) { album in                            if phase >= album {                                VStack {                                    Image(album.rawValue)                                        .resizable()                                        .frame(width: 100, height: 100)                                    Text(album.rawValue.capitalized)                                        .font(.title)                                }                                .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)))                            }                        }                    }                    .padding()                }, animation: { phase in                    .spring(duration: 0.5)                }            )        } // end ScrollView        Button(action: {            animate.toggle()        }, label: {            Text("开始")                .font(.largeTitle)                .bold()        })    }}

在上面的代码中,我们首先定义了一个枚举类型 AlbumAnimationPhase,用于表示专辑的不同阶段。然后,我们在 ContentView 视图中创建了一个 PhaseAnimator 视图修饰符,该修饰符接受一个观测值 trigger,用于触发动画。在 content 闭包中,我们遍历所有专辑,并根据当前阶段 phase 来决定是否显示专辑。在 animation 闭包中,我们使用 .spring(duration: 0.5) 创建了一个弹簧动画效果。

KeyframeAnimator

KeyframeAnimator是一个在SwiftUI中创建关键帧动画的工具。关键帧动画是一种动画类型,其中定义了动画开始和结束的关键帧,以及可能的一些中间关键帧,然后动画系统会在这些关键帧之间进行插值以创建平滑的动画。

KeyframeAnimator接受一个初始值,一个内容闭包,以及一个关键帧闭包。初始值是一个包含了动画所需的所有属性的结构(在这个例子中是scalerotationoffset)。内容闭包接受一个这样的结构实例,并返回一个视图。这个视图将使用结构中的值进行配置,以便它可以根据这些值进行动画。关键帧闭包接受一个这样的结构实例,并定义了一系列的关键帧轨道。每个轨道都对应于结构中的一个属性,并定义了一系列的关键帧。每个关键帧都定义了一个值和一个时间点,动画系统将在这些关键帧之间进行插值。

此外,SwiftUI提供了四种不同类型的关键帧:LinearKeyframeSpringKeyframeCubicKeyframeMoveKeyframe。前三种关键帧使用不同的动画过渡函数进行插值,而MoveKeyframe则立即跳转到指定值,无需插值。

KeyframeAnimator可以用于创建各种复杂的动画效果,例如根据滚动位置调整关键帧驱动的效果,或者根据时间进行更新。

struct ContentView: View {    @State var animationTrigger: Bool = false    var body: some View {        VStack {            KeyframeAnimator(                initialValue: AnimatedMovie(),                content: { movie in                    Image("evermore")                        .resizable()                        .frame(width: 100, height: 150)                        .scaleEffect(movie.scaleRatio)                        .rotationEffect(movie.rotationAngle)                        .offset(y: movie.verticalOffset)                }, keyframes: { movie in                    KeyframeTrack(\.scaleRatio) {                        LinearKeyframe(1.0, duration: 0.36)                        SpringKeyframe(1.5, duration: 0.8, spring: .bouncy)                        SpringKeyframe(1.0, spring: .bouncy)                    }                    KeyframeTrack(\.rotationAngle) {                        CubicKeyframe(.degrees(-30), duration: 1.0)                        CubicKeyframe(.zero, duration: 1.0)                    }                    KeyframeTrack(\.verticalOffset) {                        LinearKeyframe(0.0, duration: 0.1)                        SpringKeyframe(20.0, duration: 0.15, spring: .bouncy)                        CubicKeyframe(-60.0, duration: 0.2)                        MoveKeyframe(0.0)                    }                }            )        }    }}struct AnimatedMovie {    var scaleRatio: Double = 1    var rotationAngle = Angle.zero    var verticalOffset: Double = 0}

以上代码中,我们首先定义了一个AnimatedMovie结构,它包含了动画所需的所有属性。然后,我们在ContentView视图中创建了一个KeyframeAnimator,该修饰符接受一个观测值animationTrigger,用于触发动画。在content闭包中,我们使用Image视图创建了一个电影海报,并根据AnimatedMovie结构中的值对其进行配置。在keyframes闭包中,我们为每个属性定义了一系列的关键帧轨道。例如,我们为scaleRatio属性定义了三个关键帧,分别使用LinearKeyframeSpringKeyframe进行插值。我们还为rotationAngleverticalOffset属性定义了两个关键帧轨道,分别使用CubicKeyframeMoveKeyframe进行插值。

也可以使用 .keyframeAnimator 修饰符来创建关键帧动画。以下是一个示例,演示了如何使用 .keyframeAnimator 修饰符创建一个关键帧动画,该动画在用户点击时触发。

struct ContentView: View {    @State var animationTrigger: Bool = false        var body: some View {        Image("evermore")            .resizable()            .frame(width: 100, height: 150)            .scaleEffect(animationTrigger ? 1.5 : 1.0)            .rotationEffect(animationTrigger ? .degrees(-30) : .zero)            .offset(y: animationTrigger ? -60.0 : 0.0)            .keyframeAnimator(initialValue: AnimatedMovie(),                              trigger: animationTrigger,                              content: { view, value in                view                    .scaleEffect(value.scaleRatio)                    .rotationEffect(value.rotationAngle)            },                              keyframes: { value in                KeyframeTrack(\.scaleRatio) {                    LinearKeyframe(1.5, duration: 0.36)                    SpringKeyframe(1.0, duration: 0.8, spring: .bouncy)                    SpringKeyframe(1.5, spring: .bouncy)                }                                KeyframeTrack(\.rotationAngle) {                    CubicKeyframe(.degrees(-30), duration: 1.0)                    CubicKeyframe(.zero, duration: 1.0)                }                                KeyframeTrack(\.verticalOffset) {                    LinearKeyframe(-60.0, duration: 0.1)                    SpringKeyframe(0.0, duration: 0.15, spring: .bouncy)                    CubicKeyframe(-60.0, duration: 0.2)                    MoveKeyframe(0.0)                }            })                    .onTapGesture {                withAnimation {                    animationTrigger.toggle()                }            }    }}struct AnimatedMovie {    var scaleRatio: Double = 1    var rotationAngle = Angle.zero    var verticalOffset: Double = 0}

在这个例子中,我们创建了一个 AnimatedMovie 结构,它包含了动画所需的所有属性。然后,我们在 ContentView 视图中创建了一个 KeyframeAnimator,该修饰符接受一个观测值 animationTrigger,用于触发动画。在 content 闭包中,我们使用 Image 视图创建了一个电影海报,并根据 AnimatedMovie 结构中的值对其进行配置。在 keyframes 闭包中,我们为每个属性定义了一系列的关键帧轨道。例如,我们为 scaleRatio 属性定义了三个关键帧,分别使用 LinearKeyframe 和 SpringKeyframe 进行插值。我们还为 rotationAngle 和 verticalOffset 属性定义了两个关键帧轨道,分别使用 CubicKeyframe 和 MoveKeyframe 进行插值。

布局动画

import SwiftUIstruct AnimateLayout: View { @State var changeLayout: Bool = true @Namespace var namespace var body: some View {  VStack(spacing: 30) {   if changeLayout {    HStack { items }   } else {    VStack { items }   }   Button("切换布局") {    withAnimation { changeLayout.toggle() }   }  }  .padding() } @ViewBuilder var items: some View {  Text("one")   .matchedGeometryEffect(id: "one", in: namespace)  Text("Two")   .matchedGeometryEffect(id: "Two", in: namespace)  Text("Three")   .matchedGeometryEffect(id: "Three", in: namespace) }}

动画-例子

动画的例子有很多。准备中… 请期待。

小册子之 Form、Picker、Toggle、Slider 和 Stepper 表单相关 SwiftUI 视图

作者 戴铭
2024年5月18日 10:24

以下内容已整理到小册子中,本文会随着系统更新和我更多的实践而新增和更新,你可以下载“戴铭的开发小册子”应用,来跟踪查看本文内容新增和更新。小册子应用的代码可以在 Github 上查看。

本文属于小册子系列中的一篇,已发布系列文章有:

Form

控件视图 说明 Style
Button 触发操作的按钮 .bordered, .borderless, .borderedProminent, .plain
Picker 提供多选项供选择 .wheel, .inline, .segmented, .menu, .radioGroup
DatePicker and MultiDatePicker 选择日期的工具 .compact, .wheel, .graphical
Toggle 切换两种状态的开关 .switch, .botton, .checkbox
Stepper 调整数值的步进器 无样式选项
Menu 显示选项列表的菜单 .borderlessButton, .button

Form 有 ColumnFormStyle 还有 GroupedFormStyle。使用 buttonStyle 修饰符:

Form {   ...}.formStyle(.grouped)

Form 新版也得到了增强,示例如下:

struct SimpleFormView: View {    @State private var date = Date()    @State private var eventDescription = ""    @State private var accent = Color.red    @State private var scheme = ColorScheme.light    var body: some View {        Form {            Section {                DatePicker("Date", selection: $date)                TextField("Description", text: $eventDescription)                    .lineLimit(3)            }                        Section("Vibe") {                Picker("Accent color", selection: $accent) {                    ForEach(Color.accentColors, id: \.self) { color in                        Text(color.description.capitalized).tag(color)                    }                }                Picker("Color scheme", selection: $scheme) {                    Text("Light").tag(ColorScheme.light)                    Text("Dark").tag(ColorScheme.dark)                }            }        }        .formStyle(.grouped)    }}extension Color {    static let accentColors: [Color] = [.red, .green, .blue, .orange, .pink, .purple, .yellow]}

Form 的样式除了 .formStyle(.grouped) 还有 .formStyle(..columns)

关于 Form 字体、单元、背景颜色设置,参看下面代码:

struct ContentView: View {    @State private var movieTitle = ""    @State private var isWatched = false    @State private var rating = 1    @State private var watchDate = Date()    var body: some View {        Form {            Section {                TextField("电影标题", text: $movieTitle)                LabeledContent("导演", value: "克里斯托弗·诺兰")            } header: {                Text("关于电影")            }            .listRowBackground(Color.gray.opacity(0.1))            Section {                Toggle("已观看", isOn: $isWatched)                Picker("评分", selection: $rating) {                    ForEach(1...5, id: \.self) { number in                        Text("\(number) 星")                    }                }            } header: {                Text("电影详情")            }            .listRowBackground(Color.gray.opacity(0.1))                        Section {                DatePicker("观看日期", selection: $watchDate)            }            .listRowBackground(Color.gray.opacity(0.1))                        Section {                Button("重置所有电影数据") {                    resetAllData()                }            }            .listRowBackground(Color.white)        }        .foregroundColor(.black)        .tint(.indigo)        .background(Color.yellow)        .scrollContentBackground(.hidden)        .navigationBarTitle("电影追踪器")    }        private func resetAllData() {        movieTitle = ""        isWatched = false        rating = 1        watchDate = Date()    }}struct LabeledContent: View {    let label: String    let value: String    init(_ label: String, value: String) {        self.label = label        self.value = value    }    var body: some View {        HStack {            Text(label)            Spacer()            Text(value)        }    }}

Picker选择器

Picker

SwiftUI 中的 Picker 视图是一个用于选择列表中的一个选项的用户界面元素。你可以使用 Picker 视图来创建各种类型的选择器,包括滚动选择器、弹出菜单和分段控制。

示例代码如下:

struct PlayPickerView: View {    @State private var select = 1    @State private var color = Color.red.opacity(0.3)        var dateFt: DateFormatter {        let ft = DateFormatter()        ft.dateStyle = .long        return ft    }    @State private var date = Date()        var body: some View {                // 默认是下拉的风格        Form {            Section("选区") {                Picker("选一个", selection: $select) {                    Text("1")                        .tag(1)                    Text("2")                        .tag(2)                }            }        }        .padding()                // Segment 风格,        Picker("选一个", selection: $select) {            Text("one")                .tag(1)            Text("two")                .tag(2)        }        .pickerStyle(SegmentedPickerStyle())        .padding()                // 颜色选择器        ColorPicker("选一个颜色", selection: $color, supportsOpacity: false)            .padding()                RoundedRectangle(cornerRadius: 8)            .fill(color)            .frame(width: 50, height: 50)                // 时间选择器        VStack {            DatePicker(selection: $date, in: ...Date(), displayedComponents: .date) {                Text("选时间")            }                        DatePicker("选时间", selection: $date)                .datePickerStyle(GraphicalDatePickerStyle())                .frame(maxHeight: 400)                        Text("时间:\(date, formatter: dateFt)")        }        .padding()    }}

上面的代码中,有三种类型的 Picker 视图:

  1. 默认的下拉风格 Picker 视图。这种类型的 Picker 视图在 Form 中使用,用户可以点击选择器来打开一个下拉菜单,然后从菜单中选择一个选项。
Form {    Section("选区") {        Picker("选一个", selection: $select) {            Text("1")                .tag(1)            Text("2")                .tag(2)        }    }}
  1. 分段控制风格 Picker 视图。这种类型的 Picker 视图使用 SegmentedPickerStyle() 修饰符,它将选择器显示为一组水平排列的按钮,用户可以点击按钮来选择一个选项。
Picker("选一个", selection: $select) {    Text("one")        .tag(1)    Text("two")        .tag(2)}.pickerStyle(SegmentedPickerStyle())
  1. ColorPickerDatePicker 视图。这两种类型的视图是 Picker 视图的特殊形式,它们分别用于选择颜色和日期。
ColorPicker("选一个颜色", selection: $color, supportsOpacity: false)DatePicker("选时间", selection: $date)    .datePickerStyle(GraphicalDatePickerStyle())

在所有这些 Picker 视图中,你都需要提供一个绑定的选择状态,这个状态会在用户选择一个新的选项时更新。你还需要为每个选项提供一个视图和一个唯一的标签。

文字Picker

基本使用

文字 Picker 示例:

struct StaticDataPickerView: View {    @State private var selectedCategory = "动作"    var body: some View {        VStack {            Text("选择的类别: \(selectedCategory)")            Picker("电影类别",                 selection: $selectedCategory) {                Text("动作")                    .tag("动作")                Text("喜剧")                    .tag("喜剧")                Text("剧情")                    .tag("剧情")                Text("恐怖")                    .tag("恐怖")            }        }    }}

使用枚举

使用枚举来创建选取器的示例:

enum MovieCategory: String, CaseIterable, Identifiable {    case action = "动作"    case comedy = "喜剧"    case drama = "剧情"    case horror = "恐怖"    var id: MovieCategory { self }}struct MoviePicker: View {   @State private var selectedCategory: MovieCategory = .action  var body: some View {     Picker("电影类别", selection: $selectedCategory) {        ForEach(MovieCategory.allCases) { category in             Text(category.rawValue).tag(category)       }     }   }}

样式

SwiftUI 提供了多种内置的 Picker 样式,以改变 Picker 的外观和行为。以下是一些主要的 Picker 样式及其使用示例:

  • DefaultPickerStyle:根据平台和环境自动调整样式。这是默认的 Picker 样式。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}
  • WheelPickerStyle:以旋转轮的形式展示选项。在 iOS 上,这种样式会显示一个滚动的选择器。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}.pickerStyle(WheelPickerStyle())
  • SegmentedPickerStyle:将选项以分段控件的形式展示。这种样式会显示一个分段控制,用户可以在其中选择一个选项。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}.pickerStyle(SegmentedPickerStyle())
  • InlinePickerStyle:在列表或表格中内联展示选项。这种样式会在 FormList 中显示一个内联的选择器。
Form {    Picker("Label", selection: $selection) {        ForEach(0..<options.count) {            Text(self.options[$0])        }    }    .pickerStyle(InlinePickerStyle())}
  • MenuPickerStyle:点击时以菜单的形式展示选项。这种样式会显示一个菜单,用户可以在其中选择一个选项。
Picker("Label", selection: $selection) {    ForEach(0..<options.count) {        Text(self.options[$0])    }}.pickerStyle(MenuPickerStyle())
  • .navigationLink:在 iOS 16+ 中,点击后进入下一个页面。这种样式会显示一个导航链接,用户可以点击它来打开一个新的视图。
  • .radioGrouped:仅在 macOS 中可用,以单选按钮组的形式展示选项。这种样式会显示一个单选按钮组,用户可以在其中选择一个选项。

ColorPicker

ColorPicker 是一个允许用户选择颜色的视图。以下是一个 ColorPicker 的使用示例:

import SwiftUIstruct ContentView: View {    @State private var selectedColor = Color.white    var body: some View {        VStack {            ColorPicker("选择一个颜色", selection: $selectedColor)            Text("你选择的颜色")                .foregroundColor(selectedColor)        }    }}

在这个示例中,我们创建了一个 ColorPicker 视图,用户可以通过这个视图选择一个颜色。我们使用 @State 属性包装器来创建一个可以绑定到 ColorPickerselectedColor 状态。当用户选择一个新的颜色时,selectedColor 状态会自动更新,Text 视图的前景色也会相应地更新。

DatePicker

基本使用

struct ContentView: View {    @State private var releaseDate: Date = Date()    var body: some View {        VStack(spacing: 30) {            DatePicker("选择电影发布日期", selection: $releaseDate, displayedComponents: .date)            Text("选择的发布日期: \(releaseDate, formatter: DateFormatter.dateMedium)")        }        .padding()    }}

选择多个日期

在 iOS 16 中,您现在可以允许用户选择多个日期,MultiDatePicker 视图会显示一个日历,用户可以选择多个日期,可以设置选择范围。示例如下:

struct PMultiDatePicker: View {    @Environment(\.calendar) var cal    @State var dates: Set<DateComponents> = []    var body: some View {        MultiDatePicker("选择个日子", selection: $dates, in: Date.now...)        Text(s)    }    var s: String {        dates.compactMap { c in            cal.date(from:c)?.formatted(date: .long, time: .omitted)        }        .formatted()    }}

指定日期范围

指定日期的范围,例如只能选择当前日期之后的日期,示例如下:

DatePicker(    "选择日期",    selection: $selectedDate,    in: Date()...,    displayedComponents: [.date]).datePickerStyle(WheelDatePickerStyle()).labelsHidden()

在这个示例中:

  • selection: $selectedDate 表示选定的日期和时间。
  • in: Date()... 表示可选日期的范围。在这个例子中,用户只能选择当前日期之后的日期。你也可以使用 ...Date() 来限制用户只能选择当前日期之前的日期,或者使用 Date().addingTimeInterval(86400*7) 来限制用户只能选择从当前日期开始的接下来一周内的日期。
  • displayedComponents: [.date] 表示 DatePicker 应该显示哪些组件。在这个例子中,我们只显示日期组件。你也可以使用 .hourAndMinute 来显示小时和分钟组件,或者同时显示日期和时间组件。
  • .datePickerStyle(WheelDatePickerStyle()) 表示 DatePicker 的样式。在这个例子中,我们使用滚轮样式。你也可以使用 GraphicalDatePickerStyle() 来应用图形样式。
  • .labelsHidden() 表示隐藏 DatePicker 的标签。

PhotoPicker

PhotoPicker 使用示例

import SwiftUIimport PhotosUIstruct ContentView: View {    @State private var selectedItem: PhotosPickerItem?    @State private var selectedPhotoData: Data?    var body: some View {        NavigationView {            VStack {                if let item = selectedItem, let data = selectedPhotoData, let image = UIImage(data: data) {                    Image(uiImage: image)                        .resizable()                        .scaledToFit()                } else {                    Text("选择电影海报")                }            }            .navigationTitle("电影海报")            .toolbar {                ToolbarItem(placement: .navigationBarTrailing) {                    PhotosPicker(selection: $selectedItem, matching: .images) {                        Label("选择照片", systemImage: "photo")                    }                    .tint(.indigo)                    .controlSize(.extraLarge)                    .buttonStyle(.borderedProminent)                }            }            .onChange(of: selectedItem, { oldValue, newValue in                Task {                    if let data = try? await newValue?.loadTransferable(type: Data.self) {                        selectedPhotoData = data                    }                }            })        }    }}

限制选择媒体类型

我们可以使用 matching 参数来过滤 PhotosPicker 中显示的媒体类型。这个参数接受一个 PHAssetMediaType 枚举值,可以是 .images.videos.audio.any 等。

例如,如果我们只想显示图片,可以这样设置:

PhotosPicker(selection: $selectedItem, matching: .images) {    Label("选择照片", systemImage: "photo")}

如果我们想同时显示图片和视频,可以使用 .any(of:) 方法:

PhotosPicker(selection: $selectedItem, matching: .any(of: [.images, .videos])) {    Label("选择照片", systemImage: "photo")}

此外,我们还可以使用 .not(_:) 方法来排除某种类型的媒体。例如,如果我们想显示所有的图片,但是不包括 Live Photo,可以这样设置:

PhotosPicker(selection: $selectedItem, matching: .any(of: [.images, .not(.livePhotos)])) {    Label("选择照片", systemImage: "photo")}

这些设置可以让我们更精确地控制 PhotosPicker 中显示的媒体类型。

选择多张图片

以下示例演示了如何使用 PhotosPicker 选择多张图片,并将它们显示在一个 LazyVGrid 中:

import SwiftUIimport PhotosUIstruct ContentView: View {    @State private var selectedItems: [PhotosPickerItem] = [PhotosPickerItem]()    @State private var selectedPhotosData: [Data] = [Data]()    var body: some View {        NavigationStack {            ScrollView {                LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {                    ForEach(selectedPhotosData, id: \.self) { photoData in                        if let image = UIImage(data: photoData) {                            Image(uiImage: image)                                .resizable()                                .scaledToFit()                                .cornerRadius(10.0)                                .padding(.horizontal)                        }                    }                }            }            .navigationTitle("书籍")            .toolbar {                ToolbarItem(placement: .navigationBarTrailing) {                    PhotosPicker(selection: $selectedItems, maxSelectionCount: 5, matching: .images) {                        Image(systemName: "book.fill")                            .foregroundColor(.brown)                    }                    .onChange(of: selectedItems, { oldValue, newValue in                        for newItem in newValue {                            Task {                                if let data = try? await newItem.loadTransferable(type: Data.self) {                                    selectedPhotosData.append(data)                                }                            }                        }                    })                }            }        }    }}

以上示例中,我们使用了 PhotosPickermaxSelectionCount 参数来限制用户最多只能选择 5 张图片。当用户选择图片后,我们将图片数据保存在 selectedPhotosData 数组中,并在 LazyVGrid 中显示这些图片。

字体Picker

这段代码实现了一个字体选择器的功能,用户可以在其中选择和查看自己喜欢的字体。

struct ContentView: View {    @State private var fontFamily: String = ""    var body: some View {        VStack {            Text("选择字体:")            FontPicker(fontFamily: $fontFamily)                .equatable()        }    }}struct FontPicker: View, Equatable {    @Binding var fontFamily: String    var body: some View {        VStack {            Text("\(fontFamily)")                .font(.custom(fontFamily, size: 20))            Picker("", selection: $fontFamily) {                ForEach(NSFontManager.shared.availableFontFamilies, id: \.self) { family in                    Text(family)                        .tag(family)                }            }            Spacer()        }        .padding()    }    static func == (l: FontPicker, r: FontPicker) -> Bool {        l.fontFamily == r.fontFamily    }}

WheelPicker

本示例是一个可折叠的滚轮选择器 CollapsibleWheelPicker。这个选择器允许用户从一组书籍中选择一本。

struct ContentView: View {  @State private var selection = 0  let items = ["Book 1", "Book 2", "Book 3", "Book 4", "Book 5"]  var body: some View {    NavigationStack {      Form {        CollapsibleWheelPicker(selection: $selection) {          ForEach(items, id: \.self) { item in            Text("\(item)")          }        } label: {          Text("Books")          Spacer()          Text("\(items[selection])")        }      }    }  }}struct CollapsibleWheelPicker<SelectionValue, Content, Label>: View where SelectionValue: Hashable, Content: View, Label: View {    @Binding var selection: SelectionValue    @ViewBuilder let content: () -> Content    @ViewBuilder let label: () -> Label    var body: some View {        CollapsibleView(label: label) {            Picker(selection: $selection, content: content) {                EmptyView()            }            .pickerStyle(.wheel)        }    }}struct CollapsibleView<Label, Content>: View where Label: View, Content: View {  @State private var isSecondaryViewVisible = false  @ViewBuilder let label: () -> Label  @ViewBuilder let content: () -> Content  var body: some View {    Group {      Button(action: { isSecondaryViewVisible.toggle() }, label: label)        .buttonStyle(.plain)      if isSecondaryViewVisible {        content()      }    }  }}

ContentView 中,我们创建了一个 CollapsibleWheelPicker 视图。这个视图包含一个滚轮样式的选择器,用户可以从中选择一本书。选择的书籍会绑定到 selection 变量。

CollapsibleWheelPicker 视图是一个可折叠的滚轮选择器,它接受一个绑定的选择变量、一个内容视图和一个标签视图。内容视图是一个 Picker 视图,用于显示可供选择的书籍。标签视图是一个 Text 视图,显示当前选择的书籍。

Toggle

示例

使用示例如下

struct PlayToggleView: View {    @State private var isEnable = false    var body: some View {        // 普通样式        Toggle(isOn: $isEnable) {            Text("\(isEnable ? "开了" : "关了")")        }        .padding()                // 按钮样式        Toggle(isOn: $isEnable) {            Label("\(isEnable ? "打开了" : "关闭了")", systemImage: "cloud.moon")        }        .padding()        .tint(.pink)        .controlSize(.large)        .toggleStyle(.button)                // Switch 样式        Toggle(isOn: $isEnable) {            Text("\(isEnable ? "开了" : "关了")")        }        .toggleStyle(SwitchToggleStyle(tint: .orange))        .padding()                // 自定义样式        Toggle(isOn: $isEnable) {            Text(isEnable ? "录音中" : "已静音")        }        .toggleStyle(PCToggleStyle())            }}// MARK: - 自定义样式struct PCToggleStyle: ToggleStyle {    func makeBody(configuration: Configuration) -> some View {        return HStack {            configuration.label            Image(systemName: configuration.isOn ? "mic.square.fill" : "mic.slash.circle.fill")                .renderingMode(.original)                .resizable()                .frame(width: 30, height: 30)                .onTapGesture {                    configuration.isOn.toggle()                }        }    }}

样式

Toggle 可以设置 toggleStyle,可以自定义样式。

下表是不同平台支持的样式

  • DefaultToggleStyle:iOS 表现的是 Switch,macOS 是 Checkbox
  • SwitchToggleStyle:iOS 和 macOS 都支持
  • CheckboxToggleStyle:只支持 macOS

纯图像的 Toggle

struct ContentView: View {    @State private var isMuted = false    var body: some View {        Toggle(isOn: $isMuted) {            Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.fill")                .font(.system(size: 50))        }        .tint(.red)        .toggleStyle(.button)        .clipShape(Circle())    }}

自定义 ToggleStyle

做一个自定义的切换按钮 OfflineModeToggleStyle。这个切换按钮允许用户控制是否开启离线模式。代码如下:

struct ContentView: View {    @State private var isOfflineMode = false    var body: some View {        Toggle(isOn: $isOfflineMode) {            Text("Offline Mode")        }        .toggleStyle(OfflineModeToggleStyle(systemImage: isOfflineMode ? "wifi.slash" : "wifi", activeColor: .blue))    }}struct OfflineModeToggleStyle: ToggleStyle {    var systemImage: String    var activeColor: Color    func makeBody(configuration: Configuration) -> some View {        HStack {            configuration.label            Spacer()            RoundedRectangle(cornerRadius: 16)                .fill(configuration.isOn ? activeColor : Color(.systemGray5))                .overlay {                    Circle()                        .fill(.white)                        .padding(2)                        .overlay {                            Image(systemName: systemImage)                                .foregroundColor(configuration.isOn ? activeColor : Color(.systemGray5))                        }                        .offset(x: configuration.isOn ? 8 : -8)                }                .frame(width: 50, height: 32)                .onTapGesture {                    withAnimation(.spring()) {                        configuration.isOn.toggle()                    }                }        }    }}

以上代码中,我们定义了一个 OfflineModeToggleStyle,它接受两个参数:systemImage 和 activeColor。systemImage 是一个字符串,表示图像的系统名称。activeColor 是一个颜色,表示激活状态的颜色。

动画化的 Toggle

以下是一个自定义的切换按钮 MuteToggleStyle。这个切换按钮允许用户控制是否开启静音模式。

struct ContentView: View {    @State private var isMuted = false    var body: some View {        VStack {            Toggle(isOn: $isMuted) {                Text("Mute Mode")                    .foregroundColor(isMuted ? .white : .black)            }            .toggleStyle(MuteToggleStyle())            .padding()        }        .frame(maxWidth: .infinity, maxHeight: .infinity)    }}struct MuteToggleStyle: ToggleStyle {    var onImage = "speaker.slash.fill"    var offImage = "speaker.2.fill"    func makeBody(configuration: Configuration) -> some View {        HStack {            configuration.label            Spacer()            RoundedRectangle(cornerRadius: 30)                .fill(configuration.isOn ? Color(.systemGray6) : .yellow)                .overlay {                    Image(systemName: configuration.isOn ? onImage : offImage)                        .resizable()                        .scaledToFit()                        .clipShape(Circle())                        .padding(5)                        .rotationEffect(.degrees(configuration.isOn ? 0 : 180))                        .offset(x: configuration.isOn ? 10 : -10)                }                .frame(width: 50, height: 32)                .onTapGesture {                    withAnimation(.easeInOut(duration: 0.2)) {                        configuration.isOn.toggle()                    }                }        }    }}extension ToggleStyle where Self == MuteToggleStyle {    static var mute: MuteToggleStyle { .init() }}

以上代码中,我们定义了一个 MuteToggleStyle,它接受两个参数:onImage 和 offImage。onImage 是一个字符串,表示激活状态的图像的系统名称。offImage 是一个字符串,表示非激活状态的图像的系统名称。

两个标签的 Toggle

以下是一个自定义的切换按钮,它有两个标签。这个切换按钮允许用户控制是否开启静音模式。

Toggle(isOn: $mute) {  Text("静音")  Text("这将关闭所有声音")}

Slider

简单示例

struct PlaySliderView: View {    @State var count: Double = 0    var body: some View {        Slider(value: $count, in: 0...100)            .padding()        Text("\(Int(count))")    }}

以下代码演示了如何创建一个自定义的 Slider 控件,用于调整亮度。

struct ContentView: View {    @State private var brightness: Double = 50    @State private var isEditing: Bool = false    var body: some View {        VStack {            Text("Brightness Control")                .font(.title)                .padding()            BrightnessSlider(value: $brightness, range: 0...100, step: 5, isEditing: $isEditing)            Text("Brightness: \(Int(brightness)), is changing: \(isEditing)")                .font(.footnote)                .padding()        }    }}struct BrightnessSlider: View {    @Binding var value: Double    var range: ClosedRange<Double>    var step: Double    @Binding var isEditing: Bool    var body: some View {        Slider(value: $value, in: range, step: step) {            Label("亮度", systemImage: "light.max")        } minimumValueLabel: {            Text("\(Int(range.lowerBound))")        } maximumValueLabel: {            Text("\(Int(range.upperBound))")        } onEditingChanged: {            print($0)        }    }}

以上代码中,我们创建了一个 BrightnessSlider 控件,它是一个自定义的 Slider 控件,用于调整亮度。BrightnessSlider 接受一个 value 绑定,一个 range 范围,一个 step 步长,以及一个 isEditing 绑定。在 BrightnessSlider 中,我们使用 Slider 控件来显示亮度调整器。我们还使用 Label 来显示亮度调整器的标题,并使用 minimumValueLabelmaximumValueLabel 来显示亮度调整器的最小值和最大值。最后,我们使用 onEditingChanged 修饰符来监听亮度调整器的编辑状态。

Stepper

Stepper 控件允许用户通过点击按钮来增加或减少数值。

struct ContentView: View {    @State private var count: Int = 2    var body: some View {        Stepper(value: $count, in: 2...20, step: 2) {            Text("共\(count)")        } onEditingChanged: { b in            print(b)        } // end Stepper    }}

ContentView 中,我们定义了一个状态变量 count,并将其初始化为 2。然后,我们创建了一个 Stepper 视图,并将其绑定到 count 状态变量。

Stepper 视图的值范围为 2 到 20,步进值为 2,这意味着每次点击按钮,count 的值会增加或减少 2。我们还添加了一个标签,显示当前的 count 值。

我们还添加了 onEditingChanged 回调,当 Stepper 的值改变时,会打印出一个布尔值,表示 Stepper 是否正在被编辑。

小册子之详说 Navigation、ViewThatFits、Layout 协议等布局 SwiftUI 组件

作者 戴铭
2024年5月18日 09:48

以下内容已整理到小册子中,本文会随着系统更新和我更多的实践而新增和更新,你可以下载“戴铭的开发小册子”应用,来跟踪查看本文内容新增和更新。小册子应用的代码可以在 Github 上查看。

本文属于小册子系列中的一篇,已发布系列文章有:

Navigation导航

Navigation

控制导航启动状态、管理 size class 之间的 transition 和响应 deep link。

Navigation bar 有新的默认行为,如果没有提供标题,导航栏默认为 inline title 显示模式。使用 navigationBarTitleDisplayMode(_:) 改变显示模式。如果 navigation bar 没有标题、工具栏项或搜索内容,它就会自动隐藏。使用 .toolbar(.visible) modifier 显示一个空 navigation bar。

参考:

NavigationStack 的示例:

struct PNavigationStack: View {    @State private var a = [1, 3, 9] // 深层链接    var body: some View {        NavigationStack(path: $a) {            List(1..<10) { i in                NavigationLink(value: i) {                    Label("第 \(i) 行", systemImage: "\(i).circle")                }            }            .navigationDestination(for: Int.self) { i in                Text("第 \(i) 行内容")            }            .navigationTitle("NavigationStack Demo")        }    }}

这里的 path 设置了 stack 的深度路径。

NavigationSplitView 两栏的例子:

struct PNavigationSplitViewTwoColumn: View {    @State private var a = ["one", "two", "three"]    @State private var choice: String?        var body: some View {        NavigationSplitView {            List(a, id: \.self, selection: $choice, rowContent: Text.init)        } detail: {            Text(choice ?? "选一个")        }    }}

NavigationSplitView 三栏的例子:

struct PNavigationSplitViewThreeColumn: View {    struct Group: Identifiable, Hashable {        let id = UUID()        var title: String        var subs: [String]    }        @State private var gps = [        Group(title: "One", subs: ["o1", "o2", "o3"]),        Group(title: "Two", subs: ["t1", "t2", "t3"])    ]        @State private var choiceGroup: Group?    @State private var choiceSub: String?        @State private var cv = NavigationSplitViewVisibility.automatic        var body: some View {        NavigationSplitView(columnVisibility: $cv) {            List(gps, selection: $choiceGroup) { g in                Text(g.title).tag(g)            }            .navigationSplitViewColumnWidth(250)        } content: {            List(choiceGroup?.subs ?? [], id: \.self, selection: $choiceSub) { s in                Text(s)            }        } detail: {            Text(choiceSub ?? "选一个")            Button("点击") {                cv = .all            }        }        .navigationSplitViewStyle(.prominentDetail)    }}

navigationSplitViewColumnWidth() 是用来自定义宽的,navigationSplitViewStyle 设置为 .prominentDetail 是让 detail 的视图尽量保持其大小。

SwiftUI 新加了个功能可以配置是否隐藏 Tabbar,这样在从主页进入下一级时就可以选择不显示底部标签栏了,示例代码如下:

ContentView().toolbar(.hidden, in: .tabBar)

相比较以前 NavigationView 增强的是 destination 可以根据值的不同类型展示不同的目的页面,示例代码如下:

struct PNavigationStackDestination: View {    var body: some View {        NavigationStack {            List {                NavigationLink(value: "字符串") {                    Text("字符串")                }                NavigationLink(value: Color.red) {                    Text("红色")                }            }            .navigationTitle("不同类型 Destination")            .navigationDestination(for: Color.self) { c in                c.clipShape(Circle())            }            .navigationDestination(for: String.self) { s in                Text("\(s) 的 detail")            }        }    }}

对 toolbar 的自定义,示例如下:

.toolbar(id: "toolbar") {    ToolbarItem(id: "new", placement: .secondaryAction) {        Button(action: {}) {            Label("New Invitation", systemImage: "envelope")        }    }}.toolbarRole(.editor)

以下是废弃的 NavigationView 的用法。

对应代码如下:

struct PlayNavigationView: View {    let lData = 1...10    var body: some View {        NavigationView {            ZStack {                LinearGradient(colors: [.pink, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)                    .ignoresSafeArea()                                List(lData, id: \.self) { i in                    NavigationLink {                        PNavDetailView(contentStr: "\(i)")                    } label: {                        Text("\(i)")                    }                }            }                        ZStack {                LinearGradient(colors: [.mint, .yellow], startPoint: .topLeading, endPoint: .bottomTrailing)                    .ignoresSafeArea()                                VStack {                    Text("一个 NavigationView 的示例")                        .bold()                        .font(.largeTitle)                        .shadow(color: .white, radius: 9, x: 0, y: 0)                        .scaleEffect(2)                }            }            .safeAreaInset(edge: .bottom) {                HStack {                    Button("bottom1") {}                    .font(.headline)                    Button("bottom2") {}                    Button("bottom3") {}                    Spacer()                }                .padding(5)                .background(LinearGradient(colors: [.purple, .blue], startPoint: .topLeading, endPoint: .bottomTrailing))            }        }        .foregroundColor(.white)        .navigationTitle("数字列表")        .toolbar {            // placement 共有 keyboard、destructiveAction、cancellationAction、confirmationAction、status、primaryAction、navigation、principal、automatic 这些            ToolbarItem(placement: .primaryAction) {                Button("primaryAction") {}                .background(.ultraThinMaterial)                .font(.headline)            }            // 通过 ToolbarItemGroup 可以简化相同位置 ToolbarItem 的编写。            ToolbarItemGroup(placement: .navigation) {                Button("返回") {}                Button("前进") {}            }            PCToolbar(doDestruct: {                print("删除了")            }, doCancel: {                print("取消了")            }, doConfirm: {                print("确认了")            })            ToolbarItem(placement: .status) {                Button("status") {}            }            ToolbarItem(placement: .principal) {                Button("principal") {                                    }            }            ToolbarItem(placement: .keyboard) {                Button("Touch Bar Button") {}            }        } // end toolbar    }}// MARK: - NavigationView 的目的页面struct PNavDetailView: View {    @Environment(\.presentationMode) var pMode: Binding<PresentationMode>    var contentStr: String    var body: some View {        ZStack {            LinearGradient(colors: [.purple, .blue], startPoint: .topLeading, endPoint: .bottomTrailing)                .ignoresSafeArea()            VStack {                Text(contentStr)                Button("返回") {                    pMode.wrappedValue.dismiss()                }            }        } // end ZStack    } // end body}// MARK: - 自定义 toolbar// 通过 ToolbarContent 创建可重复使用的 toolbar 组struct PCToolbar: ToolbarContent {    let doDestruct: () -> Void    let doCancel: () -> Void    let doConfirm: () -> Void        var body: some ToolbarContent {        ToolbarItem(placement: .destructiveAction) {            Button("删除", action: doDestruct)        }        ToolbarItem(placement: .cancellationAction) {            Button("取消", action: doCancel)        }        ToolbarItem(placement: .confirmationAction) {            Button("确定", action: doConfirm)        }    }}

toolbar 的位置设置可选项如下:

  • primaryAction:放置到最主要位置,macOS 就是放在 toolbar 的最左边
  • automatic:根据平台不同放到默认位置
  • confirmationAction:一些确定的动作
  • cancellationAction:取消动作
  • destructiveAction:删除的动作
  • status:状态变化,比如检查更新等动作
  • navigation:导航动作,比如浏览器的前进后退
  • principal:突出的位置,iOS 和 macOS 会出现在中间的位置
  • keyboard:macOS 会出现在 Touch Bar 里。iOS 会出现在弹出的虚拟键盘上。

NavigationStack

使用示例

假设我们有一个 TVShow 结构体和一个 Book 结构体,它们分别包含电视剧和书籍的名字。当用户点击一个电视剧或书籍的名字时,他们会被导航到相应的详细信息页面。

以下是一个例子:

struct TVShow: Hashable {    let name: String}struct Book: Hashable {    let name: String}struct ContentView: View {    @State var tvShows = [TVShow(name: "Game of Thrones"), TVShow(name: "Breaking Bad")]    @State var books = [Book(name: "1984"), Book(name: "To Kill a Mockingbird")]    var body: some View {        NavigationStack {            List {                Section(header: Text("Best TV Shows"))  {                    ForEach(tvShows, id: \.name) { show in                        NavigationLink(value: show, label: {                            Text(show.name)                        })                    }                }                Section(header: Text("Books"))  {                    ForEach(books, id: \.name) { book in                        NavigationLink(value: book, label: {                            Text(book.name)                        })                    }                }            }            .navigationDestination(for: TVShow.self) { show in                TVShowView(show: show)            }            .navigationDestination(for: Book.self) { book in                BookView(book: book)            }            .navigationTitle(Text("Media"))        }    }}struct TVShowView: View {    let show: TVShow    var body: some View {        Text("Details for \(show.name)")    }}struct BookView: View {    let book: Book    var body: some View {        Text("Details for \(book.name)")    }}

全局路由

先写个路由的枚举

enum Route: Hashable {    case all    case add(Book)    case detail(Book)}struct Book {    let name: String    let des: String}

在 App 中设置好全局路由

@mainstruct LearnNavApp: App {    var body: some Scene {        WindowGroup {            NavigationStack {                ContentView()                    .navigationDestination(for: Route.self) { route in                        switch route {                            case .all:                                Text("显示所有图书")                            case .create(let book):                                Text("添加书 \(book.name)")                            case .detail(let book):                                Text("详细 \(book.des)")                        }                    }            }                        }    }}

所有视图都可调用,调用方式如下:

NavigationLink("查看书籍详细说明", value: Route.detail(Book(name: "1984", des: "1984 Detail")))

NavigationPath

NavigationPath 是一个用于管理 SwiftUI 中导航路径的工具。它可以帮助你在 SwiftUI 中实现更复杂的导航逻辑。

在 SwiftUI 中,我们通常使用 NavigationLink 来实现导航。然而,NavigationLink 只能实现简单的前进导航,如果你需要实现更复杂的导航逻辑,例如后退、跳转到任意页面等,你就需要使用 NavigationPath

NavigationPath 的工作原理是,它维护了一个路径数组,每个元素代表一个页面。当你需要导航到一个新的页面时,你只需要将这个页面添加到路径数组中。当你需要后退时,你只需要从路径数组中移除最后一个元素。这样,你就可以实现任意复杂的导航逻辑。

看个例子

假设我们有一个 TVShow 结构体,它包含电视剧的名字。当用户点击一个电视剧的名字时,他们会被导航到这个电视剧的详细信息页面。

struct ContentView: View {    @State private var path = NavigationPath()    @State private var tvShows = [ TVShow(name: "Game of Thrones"), TVShow(name: "Breaking Bad"), TVShow(name: "The Witcher") ]    var body: some View {        NavigationStack(path: $path) {            List {                Text("Select a TV show to get started.")                    .font(.subheadline.weight(.semibold))                ForEach(tvShows, id: \.name) { show in                    NavigationLink(value: show, label: {                        Text(show.name)                            .font(.subheadline.weight(.medium))                    })                }                Button(action: showFriends) {                    Text("This isn't navigation")                }            }            .navigationDestination(for: TVShow.self, destination: { show in                TVShowView(onSelectReset: { popToRoot() }, show: show, otherShows: tvShows)            })            .navigationTitle(Text("Select your show"))        }        .onChange(of: path.count) { oldValue, newValue in            print(newValue)        }    }    func showFriends() {        let show = TVShow(name: "Friends")        path.append(show)    }        func popToRoot() {        path.removeLast(path.count)    }}struct TVShowView: View {    var onSelectReset: () -> Void    var show: TVShow    var otherShows: [TVShow]    var body: some View {        VStack {            Text(show.name)                .font(.title)                .padding(.bottom)            Button(action: onSelectReset) {                Text("Reset Selection")            }            List(otherShows, id: \.name) { otherShow in                Text(otherShow.name)            }        }        .padding()    }}struct TVShow: Hashable {    let name: String    let premiereDate: Date = Date.now    var description: String = "detail"}

代码中,NavigationPath 被用作一个 @State 变量,这意味着它会自动响应变化,并更新视图。当你修改 NavigationPath 中的路径数组时,视图会自动更新,显示新的页面。

NavigationSplitView

以下是一个基于 NavigationSplitView 的三栏视图的示例。这个示例包含了一个主视图,一个次级视图和一个详细视图。

struct ContentView: View {    @State var books: [Book] = [        Book(title: "Book 1", author: "Author 1", description: "Description 1"),        Book(title: "Book 2", author: "Author 2", description: "Description 2"),        Book(title: "Book 3", author: "Author 3", description: "Description 3")    ]    @State var selectedBook: Book?    @State var splitVisibility: NavigationSplitViewVisibility = .all    var body: some View {        NavigationSplitView(columnVisibility: $splitVisibility, sidebar: {            List(books) { book in                Button(action: { selectedBook = book }) {                    Text(book.title)                }            }        }, content: {            if let book = selectedBook {                Text("Author: \(book.author)")            } else {                Text("Select a Book")            }        }, detail: {            if let book = selectedBook {                Text(book.description)            } else {                Text("Book details will appear here")            }        })        .onChange(of: selectedBook) { oldValue, newValue in            //...        }    }}struct Book: Identifiable, Equatable {    var id = UUID()    var title: String    var author: String    var description: String}

示例中,sidebar 是主视图,它显示了一个图书列表。当用户选择一个图书时,content 视图会显示图书的作者,detail 视图会显示图书的详细信息。NavigationSplitView 会根据 splitVisibility 的值来决定显示哪些视图。

自定义导航栏

交互样式

使用 navigationSplitViewStyle(_:) 修饰符

改变标签栏背景色

.toolbarBackground(.yellow.gradient, for: .automatic).toolbarBackground(.visible, for: .automatic)

列宽

navigationSplitViewColumnWidth(_:) 修饰符用于指定列宽。

设置列的最小、最大和理想大小,使用 navigationSplitViewColumnWidth(min:ideal:max:)。可以修饰于不同的列上。

自定返回按钮

先通过修饰符隐藏系统返回按钮 .navigationBarBackButtonHidden(true)。然后通过 ToolbarItem(placement: .navigationBarLeading) 来添加自定义的返回按钮。

struct BookDetailView: View {    var book: Book    @Binding var isDetailShown: Bool    var body: some View {        VStack {            Text(book.title).font(.largeTitle)            Text("Author: \(book.author)").font(.title)            Text(book.description).padding()        }        .navigationBarBackButtonHidden(true)        .navigationTitle(book.title)        .toolbar {            ToolbarItem(placement: .navigationBarLeading) {                Button {                    isDetailShown = false                } label: {                    HStack {                        Image(systemName: "chevron.backward")                        Text("Back to Books")                    }                }            }        }    }}

Inspectors右侧多出一栏

Inspector 的示例

struct Book: Identifiable {    var id = UUID()    var title: String    var author: String    var description: String}struct ContentView: View {    @State var books: [Book] = [        Book(title: "Book 1", author: "Author 1", description: "Description 1"),        Book(title: "Book 2", author: "Author 2", description: "Description 2"),        Book(title: "Book 3", author: "Author 3", description: "Description 3")    ]    @State var selectedBook: Book?    @State var showInspector: Bool = false    @State var splitVisibility: NavigationSplitViewVisibility = .all        var body: some View {        NavigationSplitView(columnVisibility: $splitVisibility, sidebar: {            List(books) { book in                Button(action: { selectedBook = book }) {                    Text(book.title)                }            }        }, content: {            if let book = selectedBook {                Text("Author: \(book.author)")            } else {                Text("Select a Book")            }        }, detail: {            Button("Inspector 开关") {                showInspector.toggle()            }            if let book = selectedBook {                Text(book.description)            } else {                Text("Book details will appear here")            }        })        .inspector(isPresented: $showInspector) {            if let book = selectedBook {                InspectorView(book: book)            }        }    }}struct InspectorView: View {    var book: Book    var body: some View {        VStack {            Text(book.title).font(.largeTitle)            Text("Author: \(book.author)").font(.title)            Text(book.description).padding()        }        .inspectorColumnWidth(200)        .presentationDetents([.medium, .large])    }}

它显示了一个图书列表。当用户选择一个图书时,会显示 InspectorView,这是辅助视图,它显示了图书的详细信息。inspector 方法用于显示和隐藏 InspectorView,inspectorColumnWidth 方法用于设置辅助视图的宽度,presentationDetents 方法用于设置辅助视图的大小。

导航状态保存和还原

通过 SceneStorage 保存导航路径,程序终止时会持久化存储路径,重启时恢复路径。

protocol URLProcessor<RouteType> {    associatedtype RouteType: Hashable    func process(_ url: URL, mutating: inout [RouteType])}protocol UserActivityProcessor<RouteType> {    associatedtype RouteType: Hashable    func process(_ activity: NSUserActivity, mutating: inout [RouteType])}@Observable@MainActor final class RouteManager<RouteType: Hashable> {    var navigationPath: [RouteType] = []    private let jsonDecoder = JSONDecoder()    private let jsonEncoder = JSONEncoder()    private let urlProcessor: any URLProcessor<RouteType>    private let activityProcessor: any UserActivityProcessor<RouteType>    init(        urlProcessor: some URLProcessor<RouteType>,        activityProcessor: some UserActivityProcessor<RouteType>    ) {        self.urlProcessor = urlProcessor        self.activityProcessor = activityProcessor    }    func process(_ activity: NSUserActivity) {        activityProcessor.process(activity, mutating: &navigationPath)    }    func process(_ url: URL) {        urlProcessor.process(url, mutating: &navigationPath)    }}extension RouteManager where RouteType: Codable {    func toData() -> Data? {        try? jsonEncoder.encode(navigationPath)    }        func restore(from data: Data) {        do {            navigationPath = try jsonDecoder.decode([RouteType].self, from: data)        } catch {            navigationPath = []        }    }}

这段代码定义了一个名为 RouteManager 的类,它用于处理和管理导航路径。这个类使用了 SwiftUI 的 @MainActor@Observable 属性包装器,以确保它的操作在主线程上执行,并且当 navigationPath 发生变化时,会自动更新相关的 UI。

RouteManager 类有两个协议类型的属性:urlProcessoractivityProcessor。这两个属性分别用于处理 URL 和用户活动(NSUserActivity)。这两个处理器的任务是根据给定的 URL 或用户活动,更新 navigationPath

RouteManager 类还有两个方法:process(_ activity: NSUserActivity)process(_ url: URL)。这两个方法分别用于处理用户活动和 URL。处理的方式是调用相应的处理器的 process 方法。

此外,RouteManager 类还有一个扩展,这个扩展只适用于 RouteTypeCodable 的情况。这个扩展提供了两个方法:toData()restore(from data: Data)toData() 方法将 navigationPath 转换为 Datarestore(from data: Data) 方法则将 Data 转换回 navigationPath。这两个方法可以用于将 navigationPath 保存到磁盘,并在需要时从磁盘恢复。

struct MainView: View {    @SceneStorage("navigationState") private var navigationData: Data?    @State private var dataStore = DataStore()    @State private var routeManager = RouteManager<Route>(        urlProcessor: SomeURLProcessor(),        activityProcessor: SomeUserActivityProcessor()    )        var body: some View {        NavigationStack(path: $routeManager.navigationPath) {            SomeView(categories: dataStore.categories)                .task { await dataStore.fetch() }                .navigationDestination(for: Route.self) { route in                    // ...                }                .onOpenURL { routeManager.process($0) }        }        .task {            if let navigationData = navigationData {                routeManager.restore(from: navigationData)            }                        for await _ in routeManager.$navigationPath.values {                navigationData = routeManager.toData()            }        }    }}

@SceneStorage("navigationState") 是用来保存和恢复导航状态的。当应用程序被挂起时,它会自动将 navigationData 保存到磁盘,当应用程序重新启动时,它会自动从磁盘恢复 navigationData

@State private var dataStore = DataStore()@State private var routeManager = RouteManager<Route>(...) 是用来存储数据和路由管理器的。DataStore 是用来获取和存储数据的,RouteManager 是用来处理和管理导航路径的。

body 属性定义了视图的内容。它首先创建了一个 NavigationStack,然后在这个 NavigationStack 中创建了一个 SomeViewSomeView 使用了 dataStore.categories 作为它的参数,并且在被创建后立即执行 dataStore.fetch() 来获取数据。

body 属性还定义了一个任务,这个任务在视图被创建后立即执行。这个任务首先检查 navigationData 是否存在,如果存在,就使用 routeManager.restore(from: navigationData) 来恢复导航路径。然后,它监听 routeManager.$navigationPath.values,每当 navigationPath 发生变化时,就使用 routeManager.toData() 来将 navigationPath 转换为 Data,并将结果保存到 navigationData 中。

布局基础

布局-基础

基本元素样式

通过 .font(.title) 设置字体大小。

.stroke(Color.blue) 设置描边。举个例子:

struct ContentView: View {    var body: some View {        Rectangle()            .stroke(Color.orange, style: StrokeStyle(lineWidth: 10, lineCap: .round, dash: [30]))            .padding(30)    }}

StrokeStyle(lineWidth: 10, lineCap: .round, dash: [30]) 定义了描边的样式,其中 lineWidth: 10 表示线宽为 10,lineCap: .round 表示线帽样式为圆形,dash: [30] 表示虚线模式,数组中的数字表示虚线和间隙的交替长度。

frame

.frame(width: 200, height:100, alignment: .topLeading)

  • width: 200 表示视图的宽度为 200 点。
  • height: 100 表示视图的高度为 100 点。
  • alignment: .topLeading 表示视图的内容应该在视图的左上角对齐。.topLeading 是 SwiftUI 中的一个对齐方式,表示左上角对齐。

Stack

多个视图通过 Stack 视图进行对齐排列。这些 Stack 视图主要是:

  • ZStack:Z轴排列
  • VStack:垂直排列
  • HStack:横向排列

间隔

视图之间的间隔可以用 Space(),它可以在各种布局视图中使用。

布局-留白

Space

Spacer 是一个灵活的空间,它会尽可能地占用多的空间,从而将其周围的视图推向堆栈的两边。因此,第一个 Text 视图会被推到左边,第二个 Text 视图会被推到中间,第三个 Text 视图会被推到右边。

struct ContentView: View {    var body: some View {        HStack {            Text("左边")            Spacer()            Text("中间")            Spacer()            Text("右边")        }    }}

下面这个例子是用 Space() 让三个视图都居右。

struct ContentView: View {    var body: some View {        HStack {            Spacer()            Text("视图1")            Text("视图2")            Text("视图3")        }    }}

布局-对齐

frame 对齐

.frame(width: 100, height: 50, alignment: .topLeading)

可设置对齐的视图

在 SwiftUI 中,许多视图都接受 alignment 参数,用于控制其子视图的对齐方式。以下是一些常见的接受 alignment 参数的视图:

  • HStack(alignment: .bottom):水平堆栈视图,可以控制其子视图在垂直方向上的对齐方式。
  • VStack(alignment: .trailing):垂直堆栈视图,可以控制其子视图在水平方向上的对齐方式。
  • ZStack(alignment: .center):深度堆栈视图,可以控制其子视图在水平和垂直方向上的对齐方式。
  • GridRow(alignment: .firstTextBaseline):用于定义网格的行或列的大小,可以设置行或列中的内容的对齐方式。。

基线对齐

你可以使用 alignment 参数来设置视图的对齐方式,包括基线对齐。以下是一个例子:

HStack(alignment: .firstTextBaseline) {    Text("Hello")    Text("World").font(.largeTitle)}

在这个例子中,HStack 是一个水平堆栈视图,它会将其子视图水平排列。alignment: .firstTextBaseline 是一个参数,用于设置堆栈中的内容的对齐方式。.firstTextBaseline 表示所有文本视图都应该根据它们的第一行的基线对齐。基线是文本字符的底部线。

因此,这个 HStack 中的两个 Text 视图会根据它们的第一行的基线对齐,即使它们的字体大小不同。

布局-居中

在 SwiftUI 中,有多种方法可以使视图居中:

Spacer

使用 SpacerSpacer 是一个灵活的空间,它会尽可能地占用多的空间,从而将其周围的视图推向堆栈的两边。如果在一个视图的两边都放置一个 Spacer,那么这个视图就会被推到中间。

HStack {    Spacer()    Text("居中")    Spacer()}

alignment

使用 alignment 参数:许多 SwiftUI 视图都接受 alignment 参数,用于控制其子视图的对齐方式。例如,VStackHStack 都接受 alignment 参数。

VStack(alignment: .center) {    Text("居中")}

frame

使用 frame 方法:frame 方法可以设置视图的尺寸和对齐方式。如果你想让一个视图在其父视图中居中,你可以使用 frame(maxWidth: .infinity, maxHeight: .infinity) 来使视图尽可能地占用多的空间,然后使用 alignment: .center 来使视图在这个空间中居中。

Text("居中")    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)

布局-offset偏移

struct OffsetDemo: View {    @State var offset: CGFloat = 0    var body: some View {        VStack {            Text("Hello, World!")                .font(.largeTitle)                .offset(y: offset)            Slider(value: $offset, in: -100...100)                .padding()        }    }}

我们创建了一个 Text 视图和一个 SliderText 视图使用了 .offset(y: offset) 修饰符,这意味着它的 y 偏移量会根据 offset 的值改变。Slider 则用于改变 offset 的值。当你移动滑块时,Text 视图的位置也会相应地上下移动。

Safe Area

ignoresSafeArea 忽略安全区域

使用 .ignoresSafeArea() 可以忽略安全区域。默认是所有方向都忽略。

如果只忽略部分方向,可以按照下面方法做:

// 默认会同时包含 .keyboard 和 .container。.ignoresSafeArea(edges: .top).ignoresSafeArea(edges: .vertical).ignoresSafeArea(edges: [.leading, .trailing])// 可以对安全区域分别指定.ignoresSafeArea(.keyboard, edges: .top).ignoresSafeArea(.container, edges: [.leading, .trailing])

safeAreaInset

safeAreaInset 是 SwiftUI 中的一个属性,它允许你将视图放置在安全区域内。”安全区域”是指设备屏幕上的一块区域,这块区域不会被系统界面(如状态栏、导航栏、工具栏、Tab栏等)遮挡。

例如,你可以使用 safeAreaInset 将一个视图放置在屏幕底部的安全区域内,代码如下:

VStack {    Text("Hello, World!")}.safeAreaInset(edge: .bottom, spacing: 10) {    Button("Press me") {        print("Button pressed")    }}

在这个例子中,”Press me” 按钮会被放置在屏幕底部的安全区域内,而且距离底部有 10 个点的间距。

下面是更完整点的例子:

struct ContentView: View {    @State var tasks: [TaskModel] = (0...10).map { TaskModel(name: "Task \($0)") }    @State var taskName = ""    @State var isFocused: Bool = false    var body: some View {        NavigationView {            VStack {                List {                    ForEach(tasks) { task in                        Text(task.name)                    }                }                .listStyle(PlainListStyle())                .safeAreaInset(edge: .bottom) {                    HStack {                        TextField("Add task", text: $taskName, onCommit: {                            addTask()                        })                        .textFieldStyle(RoundedBorderTextFieldStyle())                        .padding(.leading, 10)                                                Button(action: {                            addTask()                        }) {                            Image(systemName: "plus")                        }                        .padding(.trailing, 10)                    }                    .padding(.bottom, isFocused ? 0 : 10)                    .background(Color.white)                }                .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in                    withAnimation {                        isFocused = true                    }                }                .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in                    withAnimation {                        isFocused = false                    }                }            }            .navigationBarTitle("Task List Demo")        }    }    func addTask() {        if !taskName.isEmpty {            withAnimation {                tasks.append(TaskModel(name: taskName))            }            taskName = ""        }    }}struct TaskModel: Identifiable {    let id = UUID()    let name: String}

用户可以在底部的输入框中输入任务名称,然后点击 “+” 按钮将任务添加到任务清单中。添加的任务会显示在屏幕的上方。当键盘出现或消失时,底部的输入框会相应地移动,以确保不会被键盘遮挡。

布局原理

SwiftUI 的布局系统是一个两阶段的协商过程,涉及到父视图和子视图之间的交互。

建议阶段:在这个阶段,父视图会向子视图提出一个建议尺寸。这个建议尺寸是父视图希望子视图的大小。例如,如果父视图是一个 VStack,那么它可能会向子视图提出一个具有明确高度、宽度未指定的建议尺寸。

需求阶段:在这个阶段,子视图会根据父视图的建议尺寸来确定自己的需求尺寸。子视图可以选择接受父视图的建议尺寸,也可以选择返回一个不同的尺寸。例如,一个 Text 视图可能会返回一个刚好能够容纳其文本的尺寸。

在这个过程中,父视图和子视图都有可能影响最终的布局结果。父视图可以通过调整建议尺寸来影响子视图的大小,而子视图可以通过返回不同的需求尺寸来影响自己的大小。

在一些复杂的布局场景中,可能需要进行多轮的协商才能得到最终的布局结果。例如,如果一个视图使用了 GeometryReader 来获取其在父视图中的位置和尺寸,那么 GeometryReader 可能会在布局稳定之前,多次向子视图发送新的几何信息。

总的来说 SwiftUI 它允许父视图和子视图之间进行协商,以达到最佳的布局效果。

布局进阶

AnyLayout

使用 AnyLayout 包装布局组件,可以在布局之间进行切换,同时保持动画效果。

struct WeatherLayout: View {    @State private var changeLayout = false    var body: some View {        let layout = changeLayout ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())        layout {            WeatherView(icon: "sun.max.fill", temperature: 25, color: .yellow)            WeatherView(icon: "cloud.rain.fill", temperature: 18, color: .blue)            WeatherView(icon: "snow", temperature: -5, color: .white)        }        .animation(.default, value: changeLayout)        .onTapGesture {            changeLayout.toggle()        }    }}struct WeatherView: View {    let icon: String    let temperature: Int    let color: Color    var body: some View {        VStack {            Image(systemName: icon)                .font(.system(size: 80))                .foregroundColor(color)            Text("\(temperature)°")                .font(.system(size: 50))                .foregroundColor(color)        }        .frame(width: 120, height: 120)    }}

代码中,我们创建了一个 WeatherView 视图,它包含一个天气图标和一个温度标签。然后,我们在 WeatherLayout 视图中使用 AnyLayout 来动态改变布局。用户可以通过点击视图来在水平布局和垂直布局之间切换。

ViewThatFits

ViewThatFits 是一个自动选择最适合当前屏幕大小的子视图进行显示的视图。它会根据可用空间的大小来决定如何布局和显示子视图。

ViewThatFits 是一个在 SwiftUI 中用于选择最适合显示的视图的组件。它的工作原理如下:

  • 首先,ViewThatFits 会测量在特定轴(水平或垂直)或两个轴(水平和垂直)上的可用空间。这是通过 SwiftUI 的布局系统来完成的,该系统提供了当前视图的大小和位置信息。

  • 接着,ViewThatFits 会测量第一个视图的大小。这是通过调用视图的 measure(in:) 方法来完成的,该方法返回一个包含视图理想大小的 CGSize 值。

  • 如果第一个视图的大小适合可用空间,ViewThatFits 就会选择并放置这个视图。放置视图是通过调用视图的 layout(in:) 方法来完成的,该方法接受一个 CGRect 值,该值定义了视图在其父视图中的位置和大小。

  • 如果第一个视图的大小不适合可用空间,ViewThatFits 会继续测量第二个视图的大小。如果第二个视图的大小适合可用空间,ViewThatFits 就会选择并放置这个视图。

  • 如果所有视图的大小都不适合可用空间,ViewThatFits 会选择并放置 ViewBuilder 闭包中的最后一个视图。ViewBuilder 是一个特殊的闭包,它可以根据其内容动态创建视图。

ViewThatFits(in: .horizontal) {    Text("晴天,气温25°") // 宽度在200到300之间        .font(.title)        .foregroundColor(.yellow)    Text("晴天,25°") // 宽度在150到200之间        .font(.title)        .foregroundColor(.gray)    Text("晴25") // 宽度在100到150之间        .font(.title)        .foregroundColor(.white)}.border(Color.green) // ViewThatFits所需的大小.frame(width:200).border(Color.orange) // 父视图提议的大小

在不同的宽度下,ViewThatFits 会选择不同的视图进行显示。在上面的示例中,当父视图的宽度在100到150之间时,ViewThatFits 会选择显示 “晴25” 这个视图。

通过 ViewThatFits 来确定内容是否可滚动。

struct ContentView: View {    @State var step: CGFloat = 3    var count: Int {        Int(step)    }    var body: some View {        VStack(alignment:.leading) {            Text("数量: \(count)")                .font(.title)                .foregroundColor(.blue)            Stepper("数量", value: $step, in: 3...20)            ViewThatFits {                content                ScrollView(.horizontal,showsIndicators: true) {                    content                }            }        }        .padding()    }    var content: some View {        HStack {            ForEach(0 ..< count, id: \.self) { i in                Rectangle()                    .fill(Color.green)                    .frame(width: 30, height: 30)                    .overlay(                        Text("\(i)")                            .font(.headline)                            .foregroundColor(.white)                    )            }        }    }}

Layout协议

通过实现 Layout 协议,创建一个水平堆栈布局,其中所有子视图的宽度都相等。

struct OptimizedEqualWidthHStack: Layout {  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {    if subviews.isEmpty { return .zero }    let maxSubviewSize = calculateMaxSize(subviews: subviews)    let totalSpacing = calculateSpacing(subviews: subviews).reduce(0, +)    return CGSize(width: maxSubviewSize.width * CGFloat(subviews.count) + totalSpacing, height: maxSubviewSize.height)  }  func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {    if subviews.isEmpty { return }    let maxSubviewSize = calculateMaxSize(subviews: subviews)    let spacings = calculateSpacing(subviews: subviews)    let placementProposal = ProposedViewSize(width: maxSubviewSize.width, height: maxSubviewSize.height)    var nextX = bounds.minX + maxSubviewSize.width / 2    for index in subviews.indices {      subviews[index].place(at: CGPoint(x: nextX, y: bounds.midY), anchor: .center, proposal: placementProposal)      nextX += maxSubviewSize.width + spacings[index]    }  }  private func calculateMaxSize(subviews: Subviews) -> CGSize {    return subviews.map { $0.sizeThatFits(.unspecified) }.reduce(.zero) { CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height)) }  }  private func calculateSpacing(subviews: Subviews) -> [CGFloat] {    return subviews.indices.map { $0 < subviews.count - 1 ? subviews[$0].spacing.distance(to: subviews[$0 + 1].spacing, along: .horizontal) : 0 }  }}

上面这段代码中 sizeThatFits 方法计算并返回布局容器需要的大小,以便排列其子视图。它首先检查子视图数组是否为空,如果为空则返回 .zero。然后,它计算子视图的最大尺寸和总间距,最后返回一个 CGSize 对象,其宽度等于最大子视图宽度乘以子视图数量加上总间距,高度等于最大子视图高度。

placeSubviews 方法将子视图放置在布局容器中。它首先检查子视图数组是否为空,如果为空则返回。然后,它计算子视图的最大尺寸和间距,然后遍历子视图数组,将每个子视图放置在布局容器中的适当位置。

calculateMaxSize 和 calculateSpacing 是两个私有方法,用于计算子视图的最大尺寸和间距。

GeometryReader

在 SwiftUI 中,有多种方法可以获取和控制视图的尺寸:

  • frame(width:60, height:60):这个方法会为子视图提供一个建议的尺寸,这里是 60 x 60。
  • fixedSize():这个方法会为子视图提供一个未指定模式的建议尺寸,这意味着视图会尽可能地大以适应其内容。
  • frame(minWidth: 120, maxWidth: 360):这个方法会将子视图的需求尺寸控制在指定的范围中,这里是宽度在 120 到 360 之间。
  • frame(idealWidth: 120, idealHeight: 120):这个方法会返回一个需求尺寸,如果当前视图收到为未指定模式的建议尺寸,那么它会返回 120 x 120 的尺寸。
  • GeometryReaderGeometryReader 会将建议尺寸作为需求尺寸直接返回,这意味着它会充满全部可用区域。你可以使用 GeometryReader 来获取其内容的尺寸和位置。

GeometryReader 可以获取其内容的尺寸和位置。在这个例子中,我们使用 GeometryReader 来获取视图的尺寸,然后打印出来。这对于理解 SwiftUI 的布局系统和调试布局问题非常有用。

extension View {    func logSizeInfo(_ label: String = "") -> some View {        background(            GeometryReader { proxy in                Color.clear                    .onAppear(perform: {                        debugPrint("\(label) Size: \(proxy.size)")                    })            }        )    }}struct ContentView: View {    var body: some View {        VStack {            Text("大标题")                .font(.largeTitle)                .logSizeInfo("大标题视图") // 打印视图尺寸            Text("正文")                .logSizeInfo("正文视图")        }    }}

这段代码首先定义了一个 View 的扩展,添加了一个 logSizeInfo(_:) 方法。这个方法接受一个标签字符串作为参数,然后返回一个新的视图。这个新的视图在背景中使用 GeometryReader 来获取并打印视图的尺寸。

然后,我们创建了一个 VStack 视图,其中包含一个 Text 视图。我们为 Text 视图调用了 logSizeInfo(_:) 方法,以打印其尺寸。

如何利用 GeometryReader 来绘制一个圆形?

struct CircleView: View {    var body: some View {        GeometryReader { proxy in            Path { path in                let radius = min(proxy.size.width, proxy.size.height) / 2                let center = CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)                path.addArc(center: center, radius: radius, startAngle: .zero, endAngle: .init(degrees: 360), clockwise: false)            }            .fill(Color.blue)        }    }}

在这个例子中,我们首先获取 GeometryReader 的尺寸,然后计算出半径和中心点的位置。然后,我们使用 PathaddArc(center:radius:startAngle:endAngle:clockwise:) 方法来添加一个圆形路径。最后,我们使用 fill(_:) 方法来填充路径,颜色为蓝色。

关于 GeometryReader 性能问题

GeometryReader 是 SwiftUI 中的一个工具,它可以帮助我们获取视图的大小和位置。但是,它在获取这些信息时,需要等待视图被评估、布局和渲染完成。这就好比你在装修房子时,需要等待墙壁砌好、油漆干燥后,才能测量墙壁的尺寸。这个过程可能需要等待一段时间,而且可能需要多次重复,因为每次墙壁的尺寸改变,都需要重新测量。

这就是 GeometryReader 可能会影响性能的原因。它需要等待视图完成一轮的评估、布局和渲染,然后才能获取到尺寸数据,然后可能需要根据这些数据重新调整布局,这就需要再次进行评估、布局和渲染。这个过程可能需要重复多次,导致视图被多次重新评估和布局。

但是,随着 SwiftUI 的更新,这个问题已经有所改善。现在,我们可以创建自定义的布局容器,这些容器可以在布局阶段就获取到父视图的建议尺寸和所有子视图的需求尺寸,这样就可以避免反复传递尺寸数据,减少了视图的反复更新。

alignmentGuide

alignmentGuide是SwiftUI中的一个修饰符,它允许你自定义视图的对齐方式。你可以使用它来调整视图在其父视图或同级视图中的位置。

当你在一个视图上应用alignmentGuide修饰符时,你需要提供一个对齐标识符和一个闭包。对齐标识符定义了你想要调整的对齐方式(例如,.leading.trailing.center等)。闭包接收一个参数,这个参数包含了视图的尺寸,你可以使用这个参数来计算对齐指南的偏移量。

举个例子:

struct ContentView: View {    var body: some View {        HStack(alignment: .top) {            CircleView()                .alignmentGuide(.top) { vd in                    vd[.top] + 50                }            CircleView()        }        .padding()        .border(Color.gray)    }    struct CircleView: View {        var body: some View {            Circle()                .fill(Color.mint)                .frame(width: 50, height: 50)        }    }}

在HStack中,第一个CircleView使用了.alignmentGuide修饰符,这使得它在顶部对齐时向下偏移了50个单位。

布局进阶-参考资料

WWDC

23

22

20

官方接口文档

Stack

Stack View 有 VStack、HStack 和 ZStack

struct PlayStackView: View {    var body: some View {        // 默认是 VStack 竖排                // 横排        HStack {            Text("左")            Spacer()            Text("右")        }        .padding()                // Z 轴排        ZStack(alignment: .top) {            Image("logo")            Text("戴铭的开发小册子")                .font(.title)                .bold()                .foregroundColor(.white)                .shadow(color: .black, radius: 1, x: 0, y: 2)                .padding()        }                Color.cyan            .cornerRadius(10)            .frame(width: 100, height: 100)            .overlay(                Text("一段文字")            )    }}

GroupBox

struct PlayGroupBoxView: View {    var body: some View {        GroupBox {            Text("这是 GroupBox 的内容")        } label: {            Label("标题一", systemImage: "t.square.fill")        }        .padding()                GroupBox {            Text("还是 GroupBox 的内容")        } label: {            Label("标题二", systemImage: "t.square.fill")        }        .padding()        .groupBoxStyle(PCGroupBoxStyle())    }}struct PCGroupBoxStyle: GroupBoxStyle {    func makeBody(configuration: Configuration) -> some View {        VStack(alignment: .leading) {            configuration.label                .font(.title)            configuration.content        }        .padding()        .background(.pink)        .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))    }}

叠加 GroupBox 颜色会有区分

GroupBox {    Text("电视剧名称: 人民的名义")    GroupBox {        Text("播放时间: 每周一至周五")    }}

最后,您还可以 GroupBox 使用 Label .将 Label 定位为 GroupBox 容器的标题。

GroupBox(label: Label("电视剧", systemImage: "tv")) {    HStack {        Text("播放时间: 每周一至周五")            .padding()        Spacer()    }}

GroupBox 也可以用于创建自定义的按钮组,如下所示:

struct TVShowCardView: View {    var body: some View {        VStack(alignment: .leading) {            // The header of the card            // - Photo, Show Name and Genre            HStack {                Circle()                    .frame(width: 40, height: 40)                    .foregroundColor(.gray)                VStack(alignment: .leading, spacing: 3) {                    Text("权力的游戏")                        .font(.headline)                        .fontWeight(.semibold)                    Text("奇幻剧")                        .font(.caption)                }                Spacer()            }                        Divider()                .foregroundColor(Color(uiColor: UIColor.systemGray6))                .padding([.top, .bottom], 8)                        // The description of the show in a few lines            Text("《权力的游戏》是一部改编自乔治·马丁的奇幻小说系列《冰与火之歌》的电视剧。")                .font(.body)                        // Buttons to watch, share or save the show            HStack {                actionGroupBox(imageName: "play.rectangle", actionName: "观看", action: { print("Watching...") })                actionGroupBox(imageName: "square.and.arrow.up", actionName: "分享", action: { print("Sharing...") })                actionGroupBox(imageName: "bookmark", actionName: "保存", action: { print("Saving...") })            }        }        .padding()        .background(Color.white)        .cornerRadius(10)    }        // A function to create a GroupBox for an action    func actionGroupBox(imageName: String, actionName: String, action: @escaping () -> Void) -> some View {        GroupBox {            VStack(spacing: 5) {                Image(systemName: imageName)                    .font(.headline)                Text(actionName)                    .font(.caption)            }            .foregroundColor(.red)            .frame(maxWidth: .infinity)        }.onTapGesture {            action()        }    }}struct ContentView: View {    var body: some View {        NavigationView {            ScrollView {                TVShowCardView()                                Spacer()            }            .padding()            .background(Color(UIColor.systemGray6))            .navigationTitle("电视剧")            .shadow(color: Color(.sRGB, red: 0, green: 0, blue: 0, opacity: 0.25), radius: 10, x: 0, y: 0)        }    }}

TabView

基本用法

struct PlayTabView: View {    @State private var selection = 0        var body: some View {        ZStack(alignment: .bottom) {            TabView(selection: $selection) {                Text("one")                    .tabItem {                        Text("首页")                            .hidden()                    }                    .tag(0)                Text("two")                    .tabItem {                        Text("二栏")                    }                    .tag(1)                Text("three")                    .tabItem {                        Text("三栏")                    }                    .tag(2)                Text("four")                    .tag(3)                Text("five")                    .tag(4)                Text("six")                    .tag(5)                Text("seven")                    .tag(6)                Text("eight")                    .tag(7)                Text("nine")                    .tag(8)                Text("ten")                    .tag(9)            } // end TabView                                    HStack {                Button("上一页") {                    if selection > 0 {                        selection -= 1                    }                }                .keyboardShortcut(.cancelAction)                Button("下一页") {                    if selection < 9 {                        selection += 1                    }                }                .keyboardShortcut(.defaultAction)            } // end HStack            .padding()        }    }}

.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) 可以实现 UIPageViewController 的效果,如果要给小白点加上背景,可以多添加一个 .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always)) 修改器。

添加提醒

struct ContentView: View {    @State private var bookVm: BooksViewModel        init() {        bookVm = BooksViewModel()    }        var body: some View {        TabView {            BookListView(bookVm: bookVm)                .tabItem {                    Image(systemName: "list.bullet.rectangle.fill")                    Text("Book List")                }            SelectedBooksView(bookVm: bookVm)                .badge(bookVm.selectedBooks.count)                .tabItem {                    Image(systemName: "book.fill")                    Text("Selected Books")                }        }    }}

自定义样式

iOS 14 和 macOS 11 开始可以使用 tabViewStyle 修饰符更改 TabView 样式。比如有页面指示器的水平滚动图片。

显示页面指示器:

.tabViewStyle(.page(indexDisplayMode: .always))

.tabViewStyle(.page(indexDisplayMode: .never)) 修饰符隐藏页面指示器。

水平滚动图片:

struct ContentView: View {    let images = ["pencil", "scribble", "highlighter"]    var body: some View {        VStack {            TabView {                ForEach(images, id: \.self) { imageName in                    Image(systemName: imageName)                        .resizable()                        .scaledToFit()                }            }            .tabViewStyle(.page(indexDisplayMode: .always))            .frame(height: 100)        }    }}

分页视图

struct OnboardingView: View {    var body: some View {        TabView {            OnboardingPageView(imageName: "figure.mixed.cardio",                               title: "Welcome",                               description: "Welcome to MyApp! Get started by exploring our amazing features.")            OnboardingPageView(imageName: "figure.archery",                               title: "Discover",                               description: "Discover new content and stay up-to-date with the latest news and updates.")            OnboardingPageView(imageName: "figure.yoga",                               title: "Connect",                               description: "Connect with friends and share your experiences with the community.")        }        .tabViewStyle(.page(indexDisplayMode: .always))        .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))    }}

.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always)) 修饰符添加了背景。这将在点周围添加一个背景,使其在任何背景下都更容易看到。

背景颜色

iOS 16 和 macOS 13 开始可以更改 TabView 的背景颜色。

struct MainScreen: View {    var body: some View {        TabView {            NavigationView {                BookListView()                    .navigationTitle("图书列表")                    .toolbarBackground(.yellow, for: .navigationBar)                    .toolbarBackground(.visible, for: .navigationBar)            }            .tabItem {                Label("图书", systemImage: "book.closed")            }            UserPreferencesView()                .tabItem {                    Label("设置", systemImage: "gearshape")                }            .toolbarBackground(.indigo, for: .tabBar)            .toolbarBackground(.visible, for: .tabBar)            .toolbarColorScheme(.dark, for: .tabBar)        }    }}struct BookListView: View {    var body: some View {        Text("这里是图书列表")    }}struct UserPreferencesView: View {    var body: some View {        Text("这里是用户设置")    }}

ControlGroup

struct PlayControlGroupView: View {    var body: some View {        ControlGroup {            Button {                print("plus")            } label: {                Image(systemName: "plus")            }            Button {                print("minus")            } label: {                Image(systemName: "minus")            }        }        .padding()        .controlGroupStyle(.automatic) // .automatic 是默认样式,还有 .navigation    }}

Advanced layout control

session Compose custom layouts with SwiftUI

提供了新的 Grid 视图来同时满足 VStack 和 HStack。还有一个更低级别 Layout 接口,可以完全控制构建应用所需的布局。另外还有 ViewThatFits 可以自动选择填充可用空间的方式。

Grid 示例代码如下:

Grid {    GridRow {        Text("One")        Text("One")        Text("One")    }    GridRow {        Text("Two")        Text("Two")    }    Divider()    GridRow {        Text("Three")        Text("Three")            .gridCellColumns(2)    }}

gridCellColumns() modifier 可以让一个单元格跨多列。

ViewThatFits 的新视图,允许根据适合的大小放视图。ViewThatFits 会自动选择对于当前屏幕大小合适的子视图进行显示。Ryan Lintott 的示例效果 ,对应示例代码 LayoutThatFits.swift

新的 Layout 协议可以观看 Swift Talk 第 308 期 The Layout Protocol

通过符合 Layout 协议,我们可以自定义一个自定义的布局容器,直接参与 SwiftUI 的布局过程。新的 ProposedViewSize 结构,它是容器视图提供的大小。 Layout.Subviews 是布局视图的子视图代理集合,我们可以在其中为每个子视图请求各种布局属性。

public protocol Layout: Animatable {  static var layoutProperties: LayoutProperties { get }  associatedtype Cache = Void  typealias Subviews = LayoutSubviews  func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews)  func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing  /// We return our view size here, use the passed parameters for computing the  /// layout.  func sizeThatFits(    proposal: ProposedViewSize,     subviews: Self.Subviews,     cache: inout Self.Cache // 👈🏻 use this for calculated data shared among Layout methods  ) -> CGSize    /// Use this to tell your subviews where to appear.  func placeSubviews(    in bounds: CGRect, // 👈🏻 region where we need to place our subviews into, origin might not be .zero    proposal: ProposedViewSize,     subviews: Self.Subviews,     cache: inout Self.Cache  )    // ... there are more a couple more optional methods}

下面例子是一个自定义的水平 stack 视图,为其所有子视图提供其最大子视图的宽度:

struct MyEqualWidthHStack: Layout {  /// Returns a size that the layout container needs to arrange its subviews.  /// - Tag: sizeThatFitsHorizontal  func sizeThatFits(    proposal: ProposedViewSize,    subviews: Subviews,    cache: inout Void  ) -> CGSize {    guard !subviews.isEmpty else { return .zero }    let maxSize = maxSize(subviews: subviews)    let spacing = spacing(subviews: subviews)    let totalSpacing = spacing.reduce(0) { $0 + $1 }    return CGSize(      width: maxSize.width * CGFloat(subviews.count) + totalSpacing,      height: maxSize.height)  }  /// Places the stack's subviews.  /// - Tag: placeSubviewsHorizontal  func placeSubviews(    in bounds: CGRect,    proposal: ProposedViewSize,    subviews: Subviews,    cache: inout Void  ) {    guard !subviews.isEmpty else { return }    let maxSize = maxSize(subviews: subviews)    let spacing = spacing(subviews: subviews)    let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height)    var nextX = bounds.minX + maxSize.width / 2    for index in subviews.indices {      subviews[index].place(        at: CGPoint(x: nextX, y: bounds.midY),        anchor: .center,        proposal: placementProposal)      nextX += maxSize.width + spacing[index]    }  }  /// Finds the largest ideal size of the subviews.  private func maxSize(subviews: Subviews) -> CGSize {    let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }    let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in      CGSize(        width: max(currentMax.width, subviewSize.width),        height: max(currentMax.height, subviewSize.height))    }    return maxSize  }  /// Gets an array of preferred spacing sizes between subviews in the  /// horizontal dimension.  private func spacing(subviews: Subviews) -> [CGFloat] {    subviews.indices.map { index in      guard index < subviews.count - 1 else { return 0 }      return subviews[index].spacing.distance(        to: subviews[index + 1].spacing,        along: .horizontal)    }  }}

自定义 layout 只能访问子视图代理 Layout.Subviews ,而不是视图或数据模型。我们可以通过 LayoutValueKey 在每个子视图上存储自定义值,通过 layoutValue(key:value:) modifier 设置。

private struct Rank: LayoutValueKey {  static let defaultValue: Int = 1}extension View {  func rank(_ value: Int) -> some View { // 👈🏻 convenience method    layoutValue(key: Rank.self, value: value) // 👈🏻 the new modifier  }}

然后,我们就可以通过 Layout 方法中的 Layout.Subviews 代理读取自定义 LayoutValueKey 值:

func placeSubviews(  in bounds: CGRect,  proposal: ProposedViewSize,  subviews: Subviews,  cache: inout Void) {  let ranks = subviews.map { subview in    subview[Rank.self] // 👈🏻  }  // ...}

要在布局之间变化使用动画,需要用 AnyLayout,代码示例如下:

struct PAnyLayout: View {    @State private var isVertical = false    var body: some View {        let layout = isVertical ? AnyLayout(VStack()) : AnyLayout(HStack())        layout {            Image(systemName: "star").foregroundColor(.yellow)            Text("Starming.com")            Text("戴铭")        }        Button("Click") {            withAnimation {                isVertical.toggle()            }        } // end button    } // end body}

同时 Text 和图片也支持了样式布局变化,代码示例如下:

struct PTextTransitionsView: View {    @State private var expandMessage = true    private let mintWithShadow: AnyShapeStyle = AnyShapeStyle(Color.mint.shadow(.drop(radius: 2)))    private let primaryWithoutShadow: AnyShapeStyle = AnyShapeStyle(Color.primary.shadow(.drop(radius: 0)))    var body: some View {        Text("Dai Ming Swift Pamphlet")            .font(expandMessage ? .largeTitle.weight(.heavy) : .body)            .foregroundStyle(expandMessage ? mintWithShadow : primaryWithoutShadow)            .onTapGesture { withAnimation { expandMessage.toggle() }}            .frame(maxWidth: expandMessage ? 150 : 250)            .drawingGroup()            .padding(20)            .background(.cyan.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))    }}

ContentUnavailableView

基本用法

struct ArchivedInfosView: View {    @Environment(\.modelContext) var modelContext    @Query var infos: [IOInfo]    ...        var body: some View {        List(selection: $selectInfo) {            ForEach(infos) { info in                ...            }        }        .overlay {            if infos.isEmpty {                ContentUnavailableView {                    Label("无归档", systemImage: "archivebox")                } description: {                    Text("点击下方按钮添加一个归档资料")                } actions: {                    Button("新增") {                        addInfo()                    }                }            }        }    }    ...}

搜索

struct ContentView: View {    @Bindable var vm: VModel    ...    var body: some View {        NavigationStack {            List(vm.items, id: \.self) { item in                ...            }            .navigationTitle("Products")            .overlay {                if vm.items.isEmpty {                    ContentUnavailableView.search(text: vm.query)                }            }            .searchable(text: $vm.query)        }        ...    }}

小册子之 List、Lazy 容器、ScrollView、Grid 和 Table 数据集合 SwiftUI 视图

作者 戴铭
2024年5月18日 09:06

以下内容已整理到小册子中,小册子代码在 Github 上,本文会随着系统更新和我更多的实践而新增和更新,你可以购买“戴铭的开发小册子”应用(98元),来跟踪查看本文内容新增和更新。

本文属于小册子系列中的一篇,已发布系列文章有:

ForEach

使用

在 SwiftUI 中,ForEach 是一个结构体,它可以创建一组视图,每个视图都有一个与数据集中的元素相对应的唯一标识符。这对于在列表或其他集合视图中显示数据非常有用。

以下视图集会用到 ForEach:

  • List
  • ScrollView
  • LazyVStack / LazyHStack
  • Picker
  • Grids (LazyVGrid / LazyHGrid)

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,你可以这样做:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(bookmarks) { bookmark in                Text(bookmark.name)            }        }    }}

ForEach 遍历 bookmarks 数组,并为每个 BookmarkModel 对象创建一个 Text 视图。bookmark 参数是当前遍历的 BookmarkModel 对象。

BookmarkModel 必须遵循 Identifiable 协议,这样 SwiftUI 才能知道如何唯一地标识每个视图。在你的代码中,BookmarkModel 已经有一个 id 属性,所以你只需要让 BookmarkModel 遵循 Identifiable 协议即可:

final class BookmarkModel: Identifiable {    // your code here}

使用索引范围进行编号

你可以使用 ForEach 结构体的另一个版本,它接受一个范围作为其数据源。这个范围可以是一个索引范围,这样你就可以为每个项目编号。

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,并在前面添加一个编号,你可以这样做:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(bookmarks.indices, id: \.self) { index in                Text("\(index + 1). \(bookmarks[index].name)")            }        }    }}

在这个例子中,ForEach 遍历 bookmarks 数组的索引,并为每个 BookmarkModel 对象创建一个 Text 视图。index 参数是当前遍历的索引。我们使用 \(index + 1). \(bookmarks[index].name) 来创建一个带有编号的文本视图。请注意,我们使用 index + 1 而不是 index,因为数组的索引是从 0 开始的,但我们通常希望编号是从 1 开始的。

使用 enumerated 编号

 enumerated() 

以下是一个例子:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(Array(bookmarks.enumerated()), id: \.element.id) { index, bookmark in                Text("\(index). \(bookmark.name)")            }        }    }}

我们使用 Array(bookmarks.enumerated()) 来创建一个元组数组,每个元组包含一个索引和一个 BookmarkModel 对象。然后,我们使用 ForEach 遍历这个元组数组,并为每个元组创建一个 Text 视图。index 参数是当前遍历的索引,bookmark 参数是当前遍历的 BookmarkModel 对象。

使用 zip 编号

zip(_:_:) 函数可以将两个序列合并为一个元组序列。你可以使用这个函数和 ForEach 结构体来为数组中的每个元素添加一个编号。

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,并在前面添加一个编号,你可以这样做:

struct ContentView: View {    var bookmarks: [BookmarkModel]    var body: some View {        List {            ForEach(Array(zip(1..., bookmarks)), id: \.1.id) { index, bookmark in                Text("\(index). \(bookmark.name)")            }        }    }}

写出扩展,方便调用

@dynamicMemberLookupstruct Numbered<Element> {    var number: Int    var element: Element        subscript<T>(dynamicMember keyPath: WritableKeyPath<Element, T>) -> T {        get { element[keyPath: keyPath] }        set { element[keyPath: keyPath] = newValue }    }}extension Sequence {    func numbered(startingAt start: Int = 1) -> [Numbered<Element>] {        zip(start..., self)            .map { Numbered(number: $0.0, element: $0.1) }    }}extension Numbered: Identifiable where Element: Identifiable {    var id: Element.ID { element.id }}

使用:

ForEach(bookmark.numbered()) { numberedBookmark in    Text("\(numberedBookmark.number). \(numberedBookmark.name)")}

Scroll视图

ScrollView

新增 modifier

ScrollView {    ForEach(0..<300) { i in        Text("\(i)")            .id(i)    }}.scrollDisabled(false) // 设置是否可滚动.scrollDismissesKeyboard(.interactively) // 关闭键盘.scrollIndicators(.visible) // 设置滚动指示器是否可见

ScrollViewReader

ScrollView 使用 scrollTo 可以直接滚动到指定的位置。ScrollView 还可以透出偏移量,利用偏移量可以定义自己的动态视图,比如向下向上滚动视图时有不同效果,到顶部显示标题视图等。

示例代码如下:

struct PlayScrollView: View {    @State private var scrollOffset: CGFloat = .zero        var infoView: some View {        GeometryReader { g in            Text("移动了 \(Double(scrollOffset).formatted(.number.precision(.fractionLength(1)).rounded()))")                .padding()        }    }        var body: some View {        // 标准用法        ScrollViewReader { s in            ScrollView {                ForEach(0..<300) { i in                    Text("\(i)")                        .id(i)                }            }            Button("跳到150") {                withAnimation {                    s.scrollTo(150, anchor: .top)                }            } // end Button        } // end ScrollViewReader                // 自定义的 ScrollView 透出 offset 供使用        ZStack {            PCScrollView {                ForEach(0..<100) { i in                    Text("\(i)")                }            } whenMoved: { d in                scrollOffset = d            }            infoView                    } // end ZStack    } // end body}// MARK: - 自定义 ScrollViewstruct PCScrollView<C: View>: View {    let c: () -> C    let whenMoved: (CGFloat) -> Void        init(@ViewBuilder c: @escaping () -> C, whenMoved: @escaping (CGFloat) -> Void) {        self.c = c        self.whenMoved = whenMoved    }        var offsetReader: some View {        GeometryReader { g in            Color.clear                .preference(key: OffsetPreferenceKey.self, value: g.frame(in: .named("frameLayer")).minY)        }        .frame(height:0)    }        var body: some View {        ScrollView {            offsetReader            c()                .padding(.top, -8)        }        .coordinateSpace(name: "frameLayer")        .onPreferenceChange(OffsetPreferenceKey.self, perform: whenMoved)    } // end body}private struct OffsetPreferenceKey: PreferenceKey {  static var defaultValue: CGFloat = .zero  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}}

固定到滚动视图的顶部

LazyVStack 有个参数 pinnedViews 可以用于固定滚动视图的顶部。

ScrollView {    LazyVStack(alignment: .leading, spacing: 10, pinnedViews: .sectionHeaders) {        Section {            ForEach(books) { book in                BookRowView(book: book)            }        } header: {            HeaderView(title: "小说")        }        ....    }}

滚动到特定的位置

scrollPostion 版本

scrollPositon(id:) 比 ScrollViewReader 简单,但是只适用于 ScrollView。数据源遵循 Identifiable,不用显式使用 id 修饰符

struct ContentView: View {    @State private var id: Int?    var body: some View {        VStack {            Button("Scroll to Bookmark 3") {                withAnimation {                    id = 3                }            }            Button("Scroll to Bookmark 13") {                withAnimation {                    id = 13                }            }            ScrollView {                ScrollViewReader { scrollView in                    LazyVStack {                        ForEach(Bookmark.simpleData()) { bookmark in                            Text("\(bookmark.index)")                                .id(bookmark.index)                        }                                            }                }            }            .scrollPosition(id: $id)            .scrollTargetLayout()        }    }        struct Bookmark: Identifiable,Hashable {        let id = UUID()        let index: Int                static func simpleData() -> [Bookmark] {            var re = [Bookmark]()            for i in 0...100 {                re.append(Bookmark(index: i))            }            return re        }    }}

scrollTargetLayout 可以获得当前滚动位置。锚点不可配,默认是 center。

ScrollViewReader 版本

ScrollViewReader 这个版本可以适用于 List,也可以配置锚点

你可以使用 ScrollViewReaderscrollTo(_:anchor:) 方法来滚动到特定的元素。以下是一个例子:

struct ContentView: View {    var bookmarks: [Int] = Array(1...100)    @State private var selectedBookmarkId: Int?    var body: some View {        VStack {            Button("Scroll to Bookmark 3") {                selectedBookmarkId = 3            }            Button("Scroll to Bookmark 13") {                selectedBookmarkId = 13            }            ScrollView {                ScrollViewReader { scrollView in                    LazyVStack {                        ForEach(bookmarks.indices, id: \.self) { index in                            Text("\(bookmarks[index])")                                .id(index)                        }                        .onChange(of: selectedBookmarkId) { oldValue, newValue in                            if let newValue = newValue {                                withAnimation {                                    scrollView.scrollTo(newValue, anchor: .top)                                }                            }                        }                    }                }            }        }    }}

在这个例子中,我们首先创建了一个 Button,当点击这个按钮时,selectedBookmarkId 的值会被设置为 3。然后,我们创建了一个 ScrollView,并在 ScrollView 中添加了一个 ScrollViewReader。我们在 ScrollViewReader 中添加了一个 LazyVStack,并使用 ForEach 遍历 bookmarks 数组的索引,为每个索引创建一个 Text 视图。我们使用 id(_:) 方法为每个 Text 视图设置了一个唯一的 ID。

我们使用 onChange(of:perform:) 方法来监听 selectedBookmarkId 的变化。当 selectedBookmarkId 的值改变时,我们会调用 scrollTo(_:anchor:) 方法来滚动到特定的元素。anchor: .top 参数表示我们希望滚动到的元素位于滚动视图的顶部。

scrollTargetBehavior分页滚动

按可视尺寸分页

.scrollTargetBehavior(.paging) 可以让 ScrollView 滚动,滚动一页的范围是 ScrollView 的可视尺寸。 

struct ContentView: View {    var body: some View {        ScrollView(.horizontal) {            LazyHStack {                ForEach(0...20, id: \.self) { i in                    colorView()                        .frame(width: 300, height: 200)                }            }        }        .scrollTargetBehavior(.paging)    }        @ViewBuilder    func colorView() -> some View {        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()    }}

按容器元素对齐分页

使用 .scrollTargetBehavior(.viewAligned) 配合 scrollTargetLayout。示例代码如下:

struct ContentView: View {    var body: some View {        ScrollView(.horizontal) {            LazyHStack {                ForEach(0...20, id: \.self) { i in                    colorView()                        .frame(width: 300, height: 200)                }            }            .scrollTargetLayout(isEnabled: true)        }        .scrollTargetBehavior(.viewAligned)    }        @ViewBuilder    func colorView() -> some View {        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()    }}

scrollTransition视觉效果

iOS 17 新推出 .scrollTransition,用于处理滚动时的动画。

.transition 用于视图插入和移除视图树时的动画。

.scrollTransition 会和滚动联合起来进行平滑的过渡动画处理。.scrollTransition 可以修改很多属性,比如大小,可见性还有旋转等。

.scrollTransition 可以针对不同阶段进行处理,目前有三个阶段:

  • topLeading: 视图进入 ScrollView 可见区域
  • identity: 在可见区域中
  • bottomTrailing: 视图离开 ScrollView 可见区域
struct ContentView: View {    var body: some View {        ScrollView(.horizontal) {            LazyHStack {                ForEach(0...20, id: \.self) { i in                    colorView()                        .frame(width: 300, height: 200)                        .scrollTransition { content, phase in                             content                                .scaleEffect(phase.isIdentity ? 1 : 0.4)                        }                }            }        }    }        @ViewBuilder    func colorView() -> some View {        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()    }}

使用阶段的值

.scrollTransition(.animated(.bouncy)) { content, phase in    content        .scaleEffect(phase.isIdentity ? 1 : phase.value)}

不同阶段的产生效果设置

.scrollTransition(    topLeading: .animated,    bottomTrailing: .interactive) { content, phase in    content.rotationEffect(.radians(phase.value))}

.rotation3DEffect 也是支持的。

.scrollTransition(.interactive) { content, phase in    content        .rotation3DEffect(            Angle.degrees(phase.isIdentity ? 0: 120),            axis: (x: 0.9, y: 0.0, z: 0.1))        .offset(x: phase.value * -300)}

ScrollView-参考资料

文档

WWDC

23

List列表

List

List 除了能够展示数据外,还有下拉刷新、过滤搜索和侧滑 Swipe 动作提供更多 Cell 操作的能力。

通过 List 的可选子项参数提供数据模型的关键路径来制定子项路劲,还可以实现大纲视图,使用 DisclosureGroup 和 OutlineGroup 可以进一步定制大纲视图。

使用 .listRowSeparator(.hidden, edges: .all) 可以隐藏分割线。

下面是 List 使用,包括了 DisclosureGroup 和 OutlineGroup 的演示代码:

struct PlayListView: View {    @StateObject var l: PLVM = PLVM()    @State private var s: String = ""        var outlineModel = [        POutlineModel(title: "文件夹一", iconName: "folder.fill", children: [            POutlineModel(title: "个人", iconName: "person.crop.circle.fill"),            POutlineModel(title: "群组", iconName: "person.2.circle.fill"),            POutlineModel(title: "加好友", iconName: "person.badge.plus")        ]),        POutlineModel(title: "文件夹二", iconName: "folder.fill", children: [            POutlineModel(title: "晴天", iconName: "sun.max.fill"),            POutlineModel(title: "夜间", iconName: "moon.fill"),            POutlineModel(title: "雨天", iconName: "cloud.rain.fill", children: [                POutlineModel(title: "雷加雨", iconName: "cloud.bolt.rain.fill"),                POutlineModel(title: "太阳雨", iconName: "cloud.sun.rain.fill")            ])        ]),        POutlineModel(title: "文件夹三", iconName: "folder.fill", children: [            POutlineModel(title: "电话", iconName: "phone"),            POutlineModel(title: "拍照", iconName: "camera.circle.fill"),            POutlineModel(title: "提醒", iconName: "bell")        ])    ]        var body: some View {        HStack {            // List 通过$语法可以将集合的元素转换成可绑定的值            List {                ForEach($l.ls) { $d in                    PRowView(s: d.s, i: d.i)                        .listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))                        .listRowBackground(Color.black.opacity(0.2))                }            }            .refreshable {                // 下拉刷新            }            .searchable(text: $s) // 搜索            .onChange(of: s) { newValue in                print("搜索关键字:\(s)")            }                        Divider()                        // 自定义 List            VStack {                PCustomListView($l.ls) { $d in                    PRowView(s: d.s, i: d.i)                }                // 添加数据                Button {                    l.ls.append(PLModel(s: "More", i: 0))                } label: {                    Text("添加")                }            }            .padding()                        Divider()                        // 使用大纲            List(outlineModel, children: \.children) { i in                Label(i.title, systemImage: i.iconName)            }                        Divider()                        // 自定义大纲视图            VStack {                Text("可点击标题展开")                    .font(.headline)                PCOutlineListView(d: outlineModel, c: \.children) { i in                    Label(i.title, systemImage: i.iconName)                }            }            .padding()                        Divider()                        // 使用 OutlineGroup 实现大纲视图            VStack {                Text("OutlineGroup 实现大纲")                                OutlineGroup(outlineModel, children: \.children) { i in                    Label(i.title, systemImage: i.iconName)                }                                // OutlineGroup 和 List 结合                Text("OutlineGroup 和 List 结合")                List {                    ForEach(outlineModel) { s in                        Section {                            OutlineGroup(s.children ?? [], children: \.children) { i in                                Label(i.title, systemImage: i.iconName)                            }                        } header: {                            Label(s.title, systemImage: s.iconName)                        }                    } // end ForEach                } // end List            } // end VStack        } // end HStack    } // end body}// MARK: - 自定义大纲视图struct PCOutlineListView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {    private let v: PCOutlineView<D, Content>        init(d: D, c: KeyPath<D.Element, D?>, content: @escaping (D.Element) -> Content) {        self.v = PCOutlineView(d: d, c: c, content: content)    }        var body: some View {        List {            v        }    }}struct PCOutlineView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {    let d: D    let c: KeyPath<D.Element, D?>    let content: (D.Element) -> Content    @State var isExpanded = true // 控制初始是否展开的状态        var body: some View {        ForEach(d) { i in            if let sub = i[keyPath: c] {                PCDisclosureGroup(content: PCOutlineView(d: sub, c: c, content: content), label: content(i))            } else {                content(i)            } // end if        } // end ForEach    } // end body}struct PCDisclosureGroup<C, L>: View where C: View, L: View {    @State var isExpanded = false    var content: C    var label: L    var body: some View {        DisclosureGroup(isExpanded: $isExpanded) {            content        } label: {            Button {                isExpanded.toggle()            } label: {                label            }            .buttonStyle(.plain)        }    }}// MARK: - 大纲模式数据模型struct POutlineModel: Hashable, Identifiable {    var id = UUID()    var title: String    var iconName: String    var children: [POutlineModel]?}// MARK: - List 的抽象,数据兼容任何集合类型struct PCustomListView<D: RandomAccessCollection & MutableCollection & RangeReplaceableCollection, Content: View>: View where D.Element: Identifiable {    @Binding var data: D    var content: (Binding<D.Element>) -> Content        init(_ data: Binding<D>, content: @escaping (Binding<D.Element>) -> Content) {        self._data = data        self.content = content    }        var body: some View {        List {            Section {                ForEach($data, content: content)                    .onMove { indexSet, offset in                        data.move(fromOffsets: indexSet, toOffset: offset)                    }                    .onDelete { indexSet in                        data.remove(atOffsets: indexSet) // macOS 暂不支持                    }            } header: {                Text("第一栏,共 \(data.count) 项")            } footer: {                Text("The End")            }        }        .listStyle(.plain) // 有.automatic、.inset、.plain、sidebar,macOS 暂不支持的有.grouped 和 .insetGrouped    }}// MARK: - Cell 视图struct PRowView: View {    var s: String    var i: Int    var body: some View {        HStack {            Text("\(i):")            Text(s)        }    }}// MARK: - 数据模型设计struct PLModel: Hashable, Identifiable {    let id = UUID()    var s: String    var i: Int}final class PLVM: ObservableObject {    @Published var ls: [PLModel]    init() {        ls = [PLModel]()        for i in 0...20 {            ls.append(PLModel(s: "\(i)", i: i))        }    }}

list 支持 Section footer。

list 分隔符可以自定义,使用 HorizontalEdge.leadingHorizontalEdge.trailing

list 不使用 UITableView 了。

今年 list 还新增了一个 EditOperation 可以自动生成移动和删除,新增了 edits 参数,传入 [.delete, .move] 数组即可。这也是一个演示如何更好扩展和配置功能的方式。

.searchable 支持 token 和 scope,示例如下:

struct PSearchTokensAndScopes: View {    enum AttendanceScope {        case inPerson, online    }    @State private var queryText: String    @State private var queryTokens: [InvitationToken]    @State private var scope: AttendanceScope        var body: some View {        invitationCountView()            .searchable(text: $queryText, tokens: $queryTokens, scope: $scope) { token in                Label(token.diplayName, systemImage: token.systemImage)            } scopes: {                Text("In Person").tag(AttendanceScope.inPerson)                Text("Online").tag(AttendanceScope.online)            }    }}

List-设置样式

内置样式

通过 .listStyle 修饰符可以用系统内置样式更改 List 外观。

List {   ...}.listStyle(.sidebar)

不同平台有不同的选项

ListStyle iOS macOS watchOS tvOS
plain iOS 13+ macOS 10.15+ watchOS 6+ tvOS 13+
sidebar iOS 14+ macOS 10.15+ - -
inset iOS 13+ macOS 11.15+ - -
grouped iOS 13+ - - tvOS 13+
insetGrouped iOS 14+ - - -
bordered - macOS 12+ - -
carousel - - watchOS 6+ -
elliptical - - watchOS 7+ -

行高

List {  ...}.environment(\.defaultMinListRowHeight, 100).environment(\.defaultMinListHeaderHeight, 50)

分隔符

listSectionSeparator 和 listRowSeparator 隐藏行和 Section 分隔符。

listRowSeparatorTint 和 listSectionSeparatorTint 更改分隔符颜色

例如:

.listRowSeparatorTint(.cyan, edges: .bottom)

背景

.alternatingRowBackgrounds() 可以让 List 的行底色有区分。

listRowBackground 调整行的背景颜色

更改背景颜色前需要隐藏内容背景

List {  ...}.scrollContentBackground(.hidden).background(Color.cyan)

这个方法同样可用于 ScrollView 和 TextEditor。

你可以使用 .listRowBackground() 修饰符来更改列表行的背景。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            ForEach(0..<5) { index in                Text("Row \(index)")                    .listRowBackground(index % 2 == 0 ? Color.blue : Color.green)            }        }    }}

在这个例子中,我们创建了一个包含五个元素的 List。我们使用 .listRowBackground() 修饰符来更改每个元素的背景颜色。如果元素的索引是偶数,我们将背景颜色设置为蓝色,否则我们将背景颜色设置为绿色。

Section

你可以使用 Section 视图的 headerfooter 参数来添加头部和尾部。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            Section {                ForEach(0..<5) { index in                    Text("Row \(index)")                }            } header: {                Text("Header").font(.title)            } footer: {                Text("Footer").font(.caption)            }        }    }}

headerProminence(.increase) 可以增加 Section Header 的大小。

safeAreaInset

你可以使用 .safeAreaInset() 修饰符来调整视图的安全区域插入。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            ForEach(0..<5) { index in                Text("Row \(index)")            }        }        .safeAreaInset(edge: .top, spacing: 20) {            Text("Header")                .frame(maxWidth: .infinity, alignment: .center)                .background(Color.blue)                .foregroundColor(.white)        }    }}

在这个例子中,我们创建了一个包含五个元素的 List。然后我们使用 .safeAreaInset() 修饰符来在 List 的顶部添加一个 Header。我们将 edge 参数设置为 .top,将 spacing 参数设置为 20,然后提供一个视图作为 Header。这个 Header 是一个文本视图,它的背景颜色是蓝色,前景颜色是白色,它被居中对齐,并且它的宽度和 List 的宽度相同。

List-移动元素

你可以使用 .onMove(perform:) 修饰符来允许用户移动 List 中的元素。以下是一个例子:

struct ContentView: View {    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        NavigationView {            List {                ForEach(items, id: \.self) { item in                    Text(item)                }                .onMove(perform: move)            }            .toolbar {                EditButton()            }        }    }    private func move(from source: IndexSet, to destination: Int) {        items.move(fromOffsets: source, toOffset: destination)    }}

在这个例子中,我们创建了一个包含五个元素的 List。我们使用 .onMove(perform:) 修饰符来允许用户移动这些元素,并提供了一个 move(from:to:) 方法来处理移动操作。我们还添加了一个 EditButton,用户可以点击它来进入编辑模式,然后就可以移动元素了。

List-搜索

搜索和搜索建议

你可以使用 .searchable() 修饰符的 suggestions 参数来提供搜索建议。以下是一个例子:

struct ContentView: View {    @State private var searchText = ""    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        NavigationView {            List {                ForEach(items.filter({ searchText.isEmpty ? true : $0.contains(searchText) }), id: \.self) { item in                    Text(item)                }            }            .searchable(text: $searchText, suggestions: {                 Button(action: {                    searchText = "Item 1"                }) {                    Text("Item 1")                }                Button(action: {                    searchText = "Item 2"                }) {                    Text("Item 2")                }            })            .navigationBarTitle("Items")        }    }}

在这个例子中,我们创建了一个包含五个元素的 List,并添加了一个搜索框。当用户在搜索框中输入文本时,List 会自动更新以显示匹配的元素。同时,我们提供了两个搜索建议 “Item 1” 和 “Item 2”,用户可以点击这些建议来快速填充搜索框。

在列表中显示搜索建议

struct ContentView: View {    @Environment(\.searchSuggestionsPlacement) var placement    @State private var searchText = ""    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]        var body: some View {        NavigationView {            List {                SearchSuggestionView()                ForEach(items.filter({ searchText.isEmpty ? true : $0.contains(searchText) }), id: \.self) { item in                    Text(item)                }            }            .searchable(text: $searchText, suggestions: {                VStack {                    Button(action: {                        searchText = "Item 1"                    }) {                        Text("Item 1")                    }                    Button(action: {                        searchText = "Item 2"                    }) {                        Text("Item 2")                    }                }                .searchSuggestions(.hidden, for: .content)            })            .navigationBarTitle("Items")        }    }        @ViewBuilder    func SearchSuggestionView() -> some View {        if placement == .content {            Button(action: {                searchText = "Item 1"            }) {                Text("Item 1")            }            Button(action: {                searchText = "Item 2"            }) {                Text("Item 2")            }        }    }}

搜索状态

搜索中

@Environment(\.isSearching) var isSearching

关闭搜索

@Environment(\.dismissSearch) var dismissSearch

提交搜索

List {    ...}.searchable(text: $vm.searchTerm).onSubmit(of: .search) {    //...}

搜索栏外观

占位文字说明

.searchable(text: $wwdcVM.searchText, prompt: "搜索 WWDC Session 内容")

一直显示搜索栏

.searchable(text: $wwdcVM.searchText,             placement: .navigationBarDrawer(displayMode:.always))

更改搜索栏的位置

.searchable(text: $wwdcVM.searchText, placement: .sidebar)

搜索去抖动

你可以使用 Combine 框架来实现搜索的去抖动功能。以下是一个例子:

import SwiftUIimport Combineclass SearchViewModel: ObservableObject {    @Published var searchText = ""    @Published var searchResults: [String] = []    private var cancellables = Set<AnyCancellable>()    init() {        $searchText            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)            .sink { [weak self] in self?.search($0) }            .store(in: &cancellables)    }    private func search(_ text: String) {        // 这里是你的搜索逻辑        // 例如,你可以从一个数组中过滤出匹配的元素        let items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]        searchResults = items.filter { $0.contains(text) }    }}struct ContentView: View {    @StateObject private var viewModel = SearchViewModel()    var body: some View {        VStack {            TextField("Search", text: $viewModel.searchText)                .padding()            List(viewModel.searchResults, id: \.self) { result in                Text(result)            }        }    }}

在这个例子中,我们创建了一个 SearchViewModel 类,它有一个 searchText 属性和一个 searchResults 属性。当 searchText 属性的值发生变化时,我们使用 Combine 的 debounce(for:scheduler:) 方法来延迟执行搜索操作,从而实现去抖动功能。然后我们在 ContentView 中使用这个 SearchViewModel 来显示搜索框和搜索结果。

List-下拉刷新

你可以使用 .refreshable() 修饰符来添加下拉刷新功能。以下是一个例子:

struct ContentView: View {    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        List {            ForEach(items, id: \.self) { item in                Text(item)            }        }        .refreshable {            await refresh()        }    }    func refresh() async {        // 这里是你的刷新逻辑        // 例如,你可以从网络获取新的数据,然后更新 items 数组        // 这里我们只是简单地将 items 数组反转        items.reverse()    }}

在这个例子中,我们创建了一个包含五个元素的 List,并添加了下拉刷新功能。当用户下拉 List 时,refresh() 方法会被调用,然后我们将 items 数组反转,从而模拟刷新操作。注意,refresh() 方法需要是一个异步方法,因为刷新操作通常需要一些时间来完成。

List-轻扫操作

你可以使用 .swipeActions() 修饰符来添加轻扫操作。以下是一个例子:

struct ContentView: View {    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]    var body: some View {        List {            ForEach(items, id: \.self) { item in                Text(item)                .swipeActions {                    Button(action: {                        // 这里是你的删除操作                        if let index = items.firstIndex(of: item) {                            items.remove(at: index)                        }                    }) {                        Label("Delete", systemImage: "trash")                    }                    .tint(.red)                }            }        }    }}

在这个例子中,我们创建了一个包含五个元素的 List,并为每个元素添加了一个滑动操作。当用户向左轻扫一个元素时,会显示一个 “Delete” 按钮,用户可以点击这个按钮来删除该元素。

List-大纲视图

List 树状结构

通过 children 参数指定子树路径。

List(outlineModel, children: \.children) { i in    Label(i.title, systemImage: i.iconName)}

DisclosureGroup 实现展开和折叠

DisclosureGroup 视图可以用来创建一个可以展开和折叠的内容区域。以下是一个例子:

struct ContentView: View {    @State private var isExpanded = false    var body: some View {        DisclosureGroup("Options", isExpanded: $isExpanded) {            Text("Option 1")            Text("Option 2")            Text("Option 3")        }    }}

在这个例子中,我们创建了一个 DisclosureGroup 视图,它的标题是 “Options”,并且它包含三个选项。我们使用一个 @State 属性 isExpanded 来控制 DisclosureGroup 视图是否展开。当用户点击标题时,DisclosureGroup 视图会自动展开或折叠,同时 isExpanded 属性的值也会相应地改变。

OutlineGroup 创建大纲视图

可以使用 OutlineGroup 视图来创建一个大纲视图。以下是一个例子:

struct ContentView: View {    var body: some View {        List {            OutlineGroup(sampleData, id: \.self) { item in                Text(item.name)            }        }    }}struct Item: Identifiable {    var id = UUID()    var name: String    var children: [Item]?}let sampleData: [Item] = [    Item(name: "Parent 1", children: [        Item(name: "Child 1"),        Item(name: "Child 2")    ]),    Item(name: "Parent 2", children: [        Item(name: "Child 3"),        Item(name: "Child 4")    ])]

在这个例子中,我们创建了一个 Item 结构体,它有一个 name 属性和一个 children 属性。然后我们创建了一个 sampleData 数组,它包含两个父项,每个父项都有两个子项。最后我们在 ContentView 中使用 OutlineGroup 视图来显示这个数组,每个父项和子项都显示为一个文本视图。

结合 OutlineGroup 和 DisclosureGroup 实现自定义可折叠大纲视图

代码如下:

struct SPOutlineListView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {    private let v: SPOutlineView<D, Content>        init(d: D, c: KeyPath<D.Element, D?>, content: @escaping (D.Element) -> Content) {        self.v = SPOutlineView(d: d, c: c, content: content)    }        var body: some View {        List {            v        }    }}struct SPOutlineView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {    let d: D    let c: KeyPath<D.Element, D?>    let content: (D.Element) -> Content    @State var isExpanded = true // 控制初始是否展开的状态        var body: some View {        ForEach(d) { i in            if let sub = i[keyPath: c] {                SPDisclosureGroup(content: SPOutlineView(d: sub, c: c, content: content), label: content(i))            } else {                content(i)            } // end if        } // end ForEach    } // end body}struct SPDisclosureGroup<C, L>: View where C: View, L: View {    @State var isExpanded = false    var content: C    var label: L    var body: some View {        DisclosureGroup(isExpanded: $isExpanded) {            content        } label: {            Button {                withAnimation {                    isExpanded.toggle()                }            } label: {                label            }            .buttonStyle(.plain)        }            }}

List-完全可点击的行

使用 .contentShape(Rectangle()) 可以使整个区域都可点击

struct ContentView: View {    var body: some View {        List {            ForEach(1..<50) { num in                HStack {                    Text("\(num)")                    Spacer()                }                .contentShape(Rectangle())                .onTapGesture {                    print("Clicked \(num)")                }            }        } // end list    }}

List-索引标题

这个代码是在创建一个带有索引标题的列表,用户可以通过拖动索引标题来快速滚动列表。

import SwiftUI...struct ContentView: View {  ...  var body: some View {    ScrollViewReader { proxy in      List {        ArticleListView      }      .listStyle(InsetGroupedListStyle())      .overlay(IndexView(proxy: proxy))    }  }  ...}struct IndexView: View {  let proxy: ScrollViewProxy  let titles: [String]  @GestureState private var dragLocation: CGPoint = .zero  var body: some View {    VStack {      ForEach(titles, id: \.self) { title in        TitleView()          .background(drag(title: title))      }    }    .gesture(      DragGesture(minimumDistance: 0, coordinateSpace: .global)        .updating($dragLocation) { value, state, _ in          state = value.location        }    )  }  func drag(title: String) -> some View {    GeometryReader { geometry in      drag(geometry: geometry, title: title)    }  }  func drag(geometry: GeometryProxy, title: String) -> some View {    if geometry.frame(in: .global).contains(dragLocation) {      DispatchQueue.main.async {        proxy.scrollTo(title, anchor: .center)      }    }    return Rectangle().fill(Color.clear)  }  ...}...

上面代码中 ContentView 是主视图,它包含一个 List 和一个 IndexViewList 中的内容由 ArticleListView 提供。IndexView 是一个自定义视图,它显示了所有的索引标题。

IndexView 接受一个 ScrollViewProxy 和一个标题数组。它使用 VStackForEach 来创建一个垂直的索引标题列表。每个标题都是一个 TitleView,并且它有一个背景,这个背景是通过 drag(title:) 方法创建的。

drag(title:) 方法接受一个标题,并返回一个视图。这个视图是一个 GeometryReader,它可以获取其包含的视图的几何信息。然后,这个 GeometryReader 使用 drag(geometry:title:) 方法来创建一个新的视图。

drag(geometry:title:) 方法接受一个 GeometryProxy 和一个标题,并返回一个视图。如果 GeometryProxy 的全局帧包含当前的拖动位置,那么这个方法将返回一个特定的视图。

IndexView 还有一个手势,这个手势是一个 DragGesture。当用户拖动索引标题时,这个手势会更新 dragLocation 属性的值,这个属性是一个 @GestureState 属性,它表示当前的拖动位置。

List-加载更多

你可以通过检测列表滚动到底部来实现加载更多的功能。以下是一个简单的例子:

struct ContentView: View {    @State private var items = Array(0..<20)    var body: some View {        List {            ForEach(items, id: \.self) { item in                Text("Item \(item)")                    .onAppear {                        if item == items.last {                            loadMore()                        }                    }            }        }        .onAppear(perform: loadMore)    }    func loadMore() {        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {            let newItems = Array(self.items.count..<self.items.count + 20)            self.items.append(contentsOf: newItems)        }    }}

在这个例子中,我们创建了一个包含多个元素的 List。当 List 出现最后一项时,我们调用 loadMore 方法来加载更多的元素。在 loadMore 方法中,模拟在一秒后添加新的元素到 items 数组中。

请注意,这只是一个基本的使用示例,实际的使用方式可能会根据你的需求而变化。例如,你可能需要从网络获取新的元素,而不是像这个例子中那样直接创建新的元素。

Lazy容器

LazyVStack和LazyHStack

LazyVStack 和 LazyHStack 里的视图只有在滚到时才会被创建。

struct PlayLazyVStackAndLazyHStackView: View {    var body: some View {        ScrollView {            LazyVStack {                ForEach(1...300, id: \.self) { i in                    PLHSRowView(i: i)                }            }        }    }}struct PLHSRowView: View {    let i: Int    var body: some View {        Text("第 \(i) 个")    }    init(i: Int) {        print("第 \(i) 个初始化了") // 用来查看什么时候创建的。        self.i = i    }}

LazyVGrid和LazyHGrid

列的设置有三种,这三种也可以组合用。

  • GridItem(.fixed(10)) 会固定设置有多少列。
  • GridItem(.flexible()) 会充满没有使用的空间。
  • GridItem(.adaptive(minimum: 10)) 表示会根据设置大小自动设置有多少列展示。

示例:

struct PlayLazyVGridAndLazyHGridView: View {    @State private var colors: [String:Color] = [        "red" : .red,        "orange" : .orange,        "yellow" : .yellow,        "green" : .green,        "mint" : .mint,        "teal" : .teal,        "cyan" : .cyan,        "blue" : .blue,        "indigo" : .indigo,        "purple" : .purple,        "pink" : .pink,        "brown" : .brown,        "gray" : .gray,        "black" : .black    ]        var body: some View {        ScrollView {            LazyVGrid(columns: [                GridItem(.adaptive(minimum: 50), spacing: 10)            ], pinnedViews: [.sectionHeaders]) {                Section(header:                            Text("🎨调色板")                            .font(.title)                            .frame(maxWidth: .infinity, maxHeight: .infinity)                                .background(RoundedRectangle(cornerRadius: 0)                                                .fill(.black.opacity(0.1)))                ) {                    ForEach(Array(colors.keys), id: \.self) { k in                        colors[k].frame(height:Double(Int.random(in: 50...150)))                            .overlay(                                Text(k)                            )                            .shadow(color: .black, radius: 2, x: 0, y: 2)                    }                }            }            .padding()                        LazyVGrid(columns: [                GridItem(.adaptive(minimum: 20), spacing: 10)            ]) {                Section(header: Text("图标集").font(.title)) {                    ForEach(1...30, id: \.self) { i in                        Image("p\(i)")                            .resizable()                            .aspectRatio(contentMode: .fit)                            .shadow(color: .black, radius: 2, x: 0, y: 2)                    }                }            }            .padding()        }    }}

Grid

Grid 会将最大的一个单元格大小应用于所有单元格

代码例子:

struct ContentView: View {    var body: some View {        Grid(alignment: .center,             horizontalSpacing: 30,             verticalSpacing: 8) {            GridRow {                Text("Tropical")                Text("Mango")                Text("Pineapple")                    .gridCellColumns(2)            }            GridRow(alignment: .bottom) {                Text("Leafy")                Text("Spinach")                Text("Kale")                Text("Lettuce")            }        }    }}

gridCellAnchor 可以让 GridRow 给自己设置对齐方式。

gridCellColumns() modifier 可以让一个单元格跨多列。

GridRow 的间距通过 Grid 的 horizontalSpacingverticalSpacing 参数来控制。

struct ContentView: View {    let numbers: [[Int]] = [        [1, 2, 3],        [4, 5, 6],        [7, 8, 9]    ]    var body: some View {        Grid(horizontalSpacing: 0, verticalSpacing: 0) {            ForEach(numbers.indices, id: \.self) { i in                GridRow {                    ForEach(numbers[i].indices, id: \.self) { j in                        Text("\(numbers[i][j])")                            .frame(maxWidth: .infinity, maxHeight: .infinity)                            .background(Color.gray.opacity(0.2))                            .border(Color.gray, width: 0.5)                    }                }            }        }    }}

按照以上代码这样写,每个数字 GridRow 之间的间隔就是0了。

空白的单元格可以这样写:

Color.clear    .gridCellUnsizedAxes([.horizontal, .vertical])

Table表格

Table

今年 iOS 和 iPadOS 也可以使用去年只能在 macOS 上使用的 Table了,据 digital lounges 里说,iOS table 的性能和 list 差不多,table 默认为 plian list。我想 iOS 上加上 table 只是为了兼容 macOS 代码吧。

table 使用示例如下:

struct ContentView: View {    var body: some View {        Table(Fruit.simpleData()) {            TableColumn("名字", value: \.name)            TableColumn("颜色", value: \.color)            TableColumn("颜色") {                Text("\($0.name)")                    .font(.footnote)                    .foregroundStyle(.cyan)            }        }        .contextMenu(forSelectionType: Fruit.ID.self) { selection in            if selection.isEmpty {                Button("添加") {                    // ...                }            } else if selection.count == 1 {                Button("收藏") {                    // ...                }            } else {                Button("收藏多个") {                    // ...                }            }        }    }        struct Fruit:Identifiable {        let id = UUID()        let name: String        let color: String                static func simpleData() -> [Fruit] {            var re = [Fruit]()            re.append(Fruit(name: "Apple", color: "Red"))            re.append(Fruit(name: "Banana", color: "Yellow"))            re.append(Fruit(name: "Cherry", color: "Red"))            re.append(Fruit(name: "Date", color: "Brown"))            re.append(Fruit(name: "Elderberry", color: "Purple"))            return re        }    }}

Table-样式

在 SwiftUI 中,Table 视图的 .tableStyle 修改器可以用来设置表格的样式。目前,SwiftUI 提供了以下几种表格样式:

  • inset:默认
  • inset(alternatesRowBackgrounds: Bool):是否开启行交错背景
  • bordered:加边框
  • bordered(alternatesRowBackgrounds: Bool): 是否开启行交错背景

你可以使用 .tableStyle 修改器来设置表格的样式,例如:

Table(data) {    // ...}.tableStyle(InsetGroupedListStyle())

这段代码会将表格的样式设置为 InsetGroupedListStyle

Table-行的选择

你可以使用 Table 视图的 selection 参数来实现单选和多选。selection 参数接受一个绑定到一个可选的 Set 的变量,这个 Set 包含了被选中的元素的标识。

以下是一个使用 Table 视图实现单选和多选的例子:

struct ContentView: View {    @State private var selectionOne: UUID? // 单选    @State private var selection: Set<UUID> = [] // 多选    let data = [        Fruit(name: "Apple", color: "Red"),        Fruit(name: "Banana", color: "Yellow"),        Fruit(name: "Cherry", color: "Red"),        Fruit(name: "Date", color: "Brown"),        Fruit(name: "Elderberry", color: "Purple")    ]    var body: some View {        Table(data, selection: $selectionOne) {            TableColumn("Fruit") { item in                Text(item.name)            }            TableColumn("Color") { item in                Text(item.color)            }        }    }}struct Fruit: Identifiable {    let id = UUID()    let name: String    let color: String}

在这个例子中,我们首先定义了一个 @State 变量 selection,它是一个 Set,包含了被选中的元素的标识。然后,我们将这个变量绑定到 Table 视图的 selection 参数。

现在,当用户选择或取消选择一个元素时,selection 变量就会被更新。你可以使用这个变量来判断哪些元素被选中,或者实现其他的交互功能。

Table-多属性排序

你可以使用 Table 视图的 sortOrder 参数来实现多属性排序。sortOrder 参数接受一个绑定到一个 SortDescriptor 数组的变量,这个数组定义了排序的顺序和方式。

以下是一个使用 Table 视图实现多属性排序的例子:

struct ContentView: View {    @State private var sortOrder: [KeyPathComparator<Fruit>] = [.init(\.name, order: .reverse)]    @State var data = [        Fruit(name: "Apple", color: "Red"),        Fruit(name: "Banana", color: "Yellow"),        Fruit(name: "Cherry", color: "Red"),        Fruit(name: "Date", color: "Brown"),        Fruit(name: "Elderberry", color: "Purple")    ]    var body: some View {        sortKeyPathView() // 排序状态        Table(data, sortOrder: $sortOrder) {            TableColumn("Fruit", value: \.name)            TableColumn("Color", value: \.color)            // 不含 value 参数的不支持排序            TableColumn("ColorNoOrder") {                Text("\($0.color)")                    .font(.footnote)                    .foregroundStyle(.mint)            }        }        .task {            data.sort(using: sortOrder)        }        .onChange(of: sortOrder) { oldValue, newValue in            data.sort(using: newValue)        }        .padding()    }        @ViewBuilder    func sortKeyPathView() -> some View {        HStack {            ForEach(sortOrder, id: \.self) { order in                Text(order.keyPath == \Fruit.name ? "名字" : "颜色")                Image(systemName: order.order == .reverse ? "chevron.down" : "chevron.up")            }        }        .padding(.top)    }}struct Fruit: Identifiable {    let id = UUID()    let name: String    let color: String}

在这个例子中,我们首先定义了一个 @State 变量 sortOrder,它是一个 SortDescriptor 数组,定义了排序的顺序和方式。然后,我们将这个变量绑定到 Table 视图的 sortOrder 参数。

现在,当用户点击表头来排序一个列时,sortOrder 变量就会被更新。你可以使用这个变量来实现多属性排序,或者实现其他的交互功能。

Table-contextMenu

struct ContentView: View {    @State private var selection: Set<UUID> = []    var body: some View {        Table(Fruit.simpleData(), selection: $selection) {            ...        }        .contextMenu(forSelectionType: Fruit.ID.self) { selection in            if selection.isEmpty {                Button("添加") {                    // ...                }            } else if selection.count == 1 {                Button("收藏") {                    // ...                }            } else {                Button("收藏多个") {                    // ...                }            }        } primaryAction: { items in            // 双击某一行时            debugPrint(items)        }    }    ...}

小册子之简说 Widget 小组件

作者 戴铭
2024年5月18日 08:07

以下内容已整理到小册子中,小册子代码在 Github 上,本文会随着系统更新和我更多的实践而新增和更新,你可以购买“戴铭的开发小册子”应用(98元),来跟踪查看本文内容新增和更新。

本文属于小册子系列中的一篇,已发布系列文章有:

Widge 允许开发者在用户的主屏幕或通知中心展示应用的信息。Widget 可以提供快速的信息预览,或者提供快速访问应用的方式。

开发 Widget 的基本步骤如下:

  1. 创建 Widget Extension:在 Xcode 中,你需要创建一个新的 Widget Extension。这将会生成一个新的 target,包含了创建 Widget 所需的基本代码。

  2. 定义 Timeline Entry:Timeline Entry 是 Widget 数据的模型。你需要创建一个遵循 TimelineEntry 协议的结构体,定义你的 Widget 所需的数据。

  3. 创建 Widget View:Widget View 是 Widget 的用户界面。你需要创建一个 View,展示你的 Widget 的内容。

  4. 实现 Timeline Provider:Timeline Provider 是 Widget 数据的提供者。你需要创建一个遵循 TimelineProvider 协议的结构体,提供 Widget 的数据。

  5. 配置 Widget:在 Widget 的主结构体中,你需要配置你的 Widget,包括它的类型(静态或者动态)、数据提供者、视图等。

  6. 测试 Widget:在模拟器或者真机上测试你的 Widget,确保它的数据和视图都按预期工作。

接下来,我们将详细介绍 Widget 的开发流程。

小组件-StaticConfiguration 静态配置

在 Xcode 中,File -> New -> Target,选择 Widget Extension。这将会生成一个新的 target,包含了创建 Widget 所需的基本代码。

以下是一个简单的小组件代码示例:

import WidgetKitimport SwiftUI// Timeline Entrystruct ArticleEntry: TimelineEntry {    let date: Date    let title: String}// Widget Viewstruct ArticleWidgetView : View {    let entry: ArticleEntry    var body: some View {        Text(entry.title)    }}// Timeline Providerstruct ArticleTimelineProvider: TimelineProvider {    typealias Entry = ArticleEntry        func placeholder(in context: Context) -> Entry {        // 占位大小,内容不会显示        return ArticleEntry(date: Date(), title: "Placeholder")    }    func getSnapshot(in context: Context, completion: @escaping (Entry) -> ()) {        let entry = ArticleEntry(date: Date(), title: "Snapshot")        completion(entry)    }    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {        let entry = ArticleEntry(date: Date(), title: "Timeline")        let timeline = Timeline(entries: [entry], policy: .never)        completion(timeline)    }}// Widget Configuration@mainstruct ArticleWidget: Widget {        var body: some WidgetConfiguration {        StaticConfiguration(            kind: "com.starming.articleWidget",            provider: ArticleTimelineProvider()        ) { entry in            ArticleWidgetView(entry: entry)        }        .configurationDisplayName("Article Widget")        .description("这是一个 Article Widget.")        .supportedFamilies([            .systemSmall,            .systemMedium,            .systemLarge,        ])    }}

在上面的代码中,我们定义了一个 ArticleWidget 小组件,它包含了一个 ArticleEntry 数据模型、一个 ArticleWidgetView 视图、一个 ArticleTimelineProvider 数据提供者和一个 ArticleWidget 配置。

小组件-AppIntentConfiguration

iOS 17 开始可以使用 AppIntentConfiguration 来配置小组件,这样可以让小组件和 AppIntent 交互。这样可以让小组件和 App 之间的进行交互。

下面是一个简单的小组件代码示例,展示了如何使用 AppIntentConfiguration 来配置小组件和 AppIntent 交互

import SwiftUIimport WidgetKitimport AppIntentsstruct ArticleWidget: Widget {    var body: some WidgetConfiguration {        AppIntentConfiguration(            kind: "com.starming.articleWidget",            intent: ArticleIntent.self,            provider: ArticleIntentProvider()        ) { entry in            ArticleWidgetView(entry: entry)        }        .configurationDisplayName("Article Widget")        .description("这是一个 Article Widget.")        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])    }}struct ArticleWidgetView: View {    var entry: IntentProvider.Entry    var body: some View {        Text(entry.author)    }}struct ArticleIntentProvider: AppIntentTimelineProvider {    func snapshot(for configuration: ArticleIntent, in context: Context) async -> ArticleEntry {        return .init(            date: Date(),            author: "snapshot"        )    }    func placeholder(in context: Context) -> ArticleEntry {        return .init(            date: Date(),            author: "某人"        )    }    func timeline(for configuration: ArticleIntent, in context: Context) async -> Timeline<ArticleEntry> {        return Timeline(            entries: [                .init(date: Date(),                      author: configuration.author,                      rate: await ArticleStore().rate())],            policy: .never)    }}struct ArticleEntry: TimelineEntry {    let date: Date    let author: String    var rate: Int = 0    //...}// 放在主应用中和小组件交互struct ArticleIntent: WidgetConfigurationIntent {        static var title: LocalizedStringResource  = "文章"    var author: String = "某某某"    func perform() async throws -> some IntentResult {        //...        return .result()    }}class ArticleStore {    //... SwiftData 相关配置    @MainActor    func rate() async -> Int {        //... 获取        return 5    }}

如上代码所示,我们定义了一个 ArticleWidget 小组件,它包含了一个 ArticleIntent 数据模型、一个 ArticleWidgetView 视图、一个 ArticleIntentProvider 数据提供者和一个 ArticleWidget 配置。

小组件-配置选项

显示区域

iOS 17 新增显示区域配置,有下面四种

  • homeScreen:主屏幕
  • lockScreen:锁屏
  • standBy:待机
  • iPhoneWidgetsOnMac:iPhone 上的 Mac 小组件

设置小组件不在哪个区域显示某尺寸。

struct SomeWidget: Widget {    ...    var body: some WidgetConfiguration {        AppIntentConfiguration(            ... { entry in            ...        }        // 在 StandBy 中取消显示 systemSmall 尺寸        .disfavoredLocations([.standBy], for: [.systemSmall])    }}

取消内容边距

使用 .contentMarginsDisabled() 取消内容边距。

struct SomeWidget: Widget {    ...    var body: some WidgetConfiguration {        AppIntentConfiguration(            ... { entry in            ...        }        // 使 Content margin 失效        .contentMarginsDisabled()    }}

每个平台内容边距大小不同,环境变量 \.widgetContentMargins 可以读取内容边距的大小。

取消背景删除

在 StandBy 和 LockScreen 的某些情况,小组件的背景是会被自动删除的。

使用 containerBackgroundRemovable() 修饰符可以取消背景删除。

struct SomeWidget: Widget {    ...    var body: some WidgetConfiguration {        AppIntentConfiguration(            ... { entry in            ...        }        // 取消背景删除        .containerBackgroundRemovable(false)        // 让自己的背景可以全覆盖        .contentMarginsDisabled()    }}

后台网络处理

.onBackgroundURLSessionEvents { (identifier, completion) in    //...}

AppIntentTimelineProvider

AppIntentConfiguration 需要 AppIntentTimelineProvider,AppIntentTimelineProvider 需要实现 snapshotplaceholdertimeline 三个方法来确定小组件在展示和实际运行时间线时的视图和数据。

struct ArticleIntentProvider: AppIntentTimelineProvider {    func snapshot(for configuration: ArticleIntent, in context: Context) async -> ArticleEntry {        return .init(            date: Date(),            author: "snapshot"        )    }    func placeholder(in context: Context) -> ArticleEntry {        return .init(            date: Date(),            author: "某人"        )    }    func timeline(for configuration: ArticleIntent, in context: Context) async -> Timeline<ArticleEntry> {        return Timeline(            entries: [                .init(date: Date(),                      author: configuration.author,                      rate: await ArticleStore().rate())],            policy: .never)    }}struct ArticleEntry: TimelineEntry {    let date: Date    let author: String    var rate: Int = 0    //...}````## Widget View### 不同的大小设置不同视图```swiftstruct ArticleWidgetView: View {  var entry: Provider.Entry  @Environment(\.widgetFamily) var family  @ViewBuilder  var body: some View {    switch family {    case .systemSmall:        SomeViewSmall()    default:      SomeViewDefault()    }  }}

锁屏小组件

让小组件支持锁屏

struct ArticleWidget: Widget {    var body: some WidgetConfiguration {        StaticConfiguration(            ...        ) { entry in            ...        }        ...        .supportedFamilies([            .systemSmall,            .systemMedium,            .systemLarge,            // 添加支持到 Lock Screen widgets            .accessoryCircular,            .accessoryRectangular,            .accessoryInline,        ])    }}

不同类型 widgetFamily 实现不同视图

struct ArticleWidgetView : View {       let entry: ViewSizeEntry    // 获取 widget family 值    @Environment(\.widgetFamily) var family    var body: some View {        switch family {        case .accessoryRectangular:            RectangularWidgetView()        case .accessoryCircular:            CircularWidgetView()        case .accessoryInline:            InlineWidgetView()        default:            ArticleWidgetView(entry: entry)        }    }}

不同渲染模式实现不同视图

小组件有三种不同的渲染模式:

  • Full-color:主屏用
  • Vibrant:用于待机模式和锁屏
  • The accented:用于手表
struct ArticleWidgetView: View {    let entry: Entry        @Environment(\.widgetRenderingMode) private var renderingMode        var body: some View {        switch renderingMode {        case .accented:            AccentedWidgetView(entry: entry)        case .fullColor:            FullColorWidgetView(entry: entry)        case .vibrant:            VibrantWidgetView(entry: entry)        default:            DefaultView()        }    }}

视图交互

使用 AppIntent

struct ArticleWidgetView : View {    var entry: IntentProvider.Entry    var body: some View {        VStack(spacing: 20) {            ...            Button(intent: RunIntent(rate: entry.rate), label: {                ...            })        }    }}

刷新小组件

通过 Text 视图更新

倒计时

let futureDate = Calendar.current.date(byAdding: components, to: Date())!// 日期会在 Text 视图中动态变化
struct CountdownWidgetView: View {        var body: some View {        Text(futureDate(), style: .timer)    }        private func futureDate() -> Date {        let components = DateComponents(second: 10)        let futureDate = Calendar.current.date(byAdding: components, to: Date())!        return futureDate    }}

Timeline Provider 更新

在 timeline 方法中实现,entries 包含了不同更新的数据。

func timeline(for configuration: ArticleIntent, in context: Context) async -> Timeline<ArticleEntry> {    return Timeline(        entries: [            .init(date: Date(),                  author: configuration.author,                  rate: await ArticleStore().rate())],        policy: .never)}

更新策略

3 种类型的刷新策略:

  • atEnd:上个刷新完成直接进入下个刷新,但是进入下一个刷新的时间由系统决定。
  • after(Date):指定进入下个刷新的时间,但是具体时间还是由系统说了算,因此可以理解为是指定的是最早进入下个刷新的时间。
  • never:不会进入下个刷新,除非显式调用 reloadTimelines(ofKind:)

举例,指定下个刷新周期至少是上个周期结束10秒后:

let lastUpdateDate = entries.last!.datelet nextUpdateDate = Calendar.current.date(byAdding: DateComponents(second: 10), to: lastUpdate)!let timeline = Timeline(entries: entries, policy: .after(nextUpdate))

Relevance 优先级

App 自定义刷新 Timeline 的优先级,使用 Relevance。先在 TimelineEntry 里定义:

struct ArticleEntry: TimelineEntry {    let date: Date    ...    let relevance: TimelineEntryRelevance?}

在 timeline 方法中根据必要刷新程序,定义不同 relevance 的值。

App 主动刷新

// 刷新单个小组件WidgetCenter.shared.reloadTimelines(ofKind: "CountryWidget")// 刷新所有小组件WidgetCenter.shared.reloadAllTimelines()

刷新小组件的最佳实践

调试时刷新率不会有限制,生产环境每天最多40到70次,相当于每15到60分钟刷新一次。

小组件动画

Text 视图动态时间

利用 Text 的动态时间能力

timeline 动画

timeline 是由一组时间和数据组成的,每次刷新时,小组件通过和上次数据不一致加入动画效果。

默认情况小组件使用的是弹簧动画。我们也可以添加转场(Transition)、动画(Animation)和内容过渡(Content Transition)动画效果。

文本内容过渡动画效果

.contentTransition(.numericText(value: rate))

从底部翻上来的专场

.transition(.push(from: .bottom))

小组件-远程定时获取数据

在 TimelineProvider 中的 timeline 方法中加入请求逻辑

func timeline(for configuration: RunIntent, in context: Context) -> Void) async -> Timeline<ArticleEntry> {    guard let article = try? await ArticleFetch.fetchNewestArticle() else {        return    }    let entry = ArticleEntry(date: Date(), article: article)        // 下次在 30 分钟后再请求    let afterDate = Calendar.current.date(byAdding: DateComponents(minute: 30), to: Date())!    return Timeline(entries: [entry], policy: .after(afterDate))}

以上代码中,我们在 timeline 方法中请求了最新的文章数据,并且设置了下次请求的时间是当前时间的 30 分钟后。

小组件-获取位置权限更新内容

小组件获取位置权限和主应用 target 里获取方式很类似,步骤:

  • 在 info 里添加 NSWidgetUseLocation = ture
  • 使用 CLLocationManager 来获取位置信息,设置较低的精度。
  • isAuthorizedForWidgetUpdates 请求位置权限。

支持多个小组件

widget bundle 可以支持多个小组件。

@mainstruct FirstWidgetBundle: WidgetBundle {        @WidgetBundleBuilder    var body: some Widget {        FirstWidget()        SecondWidget()        ...        SecondWidgetBundle().body    }}struct SecondWidgetBundle: WidgetBundle {    @WidgetBundleBuilder    var body: some Widget {        SomeWidgetOne()        SomeWidgetTwo()        ...    }}

获取小组件形状

不同设备小组件大小和形状都不同,比如要加个边框,就很困难。这就需要使用 ContainerRelativeShape 来获取 Shape 视图容器。

var body: some View {  ZStack {    ContainerRelativeShape()        .inset(by: 2)        .fill(.pink)    Text("Hello world")    ...  }}

小组件-Deep link

medium 和 large 的小组件可以使用 Link,small 小组件使用 .widgetURL 修饰符。

小组件访问SwiftData

Wdiget target 访问主应用 target 的 SwiftData 数据步骤如下:

  • 对主应用和 Widget 的 target 中的 Signing & Capabilities 都添加 App Groups,并创建一个新组,名字相同。
  • SwiftData 的模型同时在主应用和 Widget 的 target 中。
  • StaticConfiguration 或 AppIntentConfiguration 中添加 modelContainer() 修饰符,让 SwiftData 的容器可用。

小组件-参考资料

WWDC

23

22

21

20

小册子之如何使用 SwiftData 开发 SwiftUI 应用

作者 戴铭
2024年5月18日 00:17

以下内容已整理到小册子中,小册子代码在 Github 上,本文会随着系统更新和我更多的实践而新增和更新,你可以购买“戴铭的开发小册子”应用(98元),来跟踪查看本文内容新增和更新。

本文属于小册子系列中的一篇,已发布系列文章有:

小册子代码里有大量 SwiftData 实际使用实践的代码。

在 Swift 中,有许多库可以用于处理数据,包括但不限于 SwiftData、CoreData、Realm、SQLite.swift 等。这些库各有优势。

但,如果使用 SwiftData,你可以在 Swift 中更加方便地处理数据。SwiftData 是 Apple 在 WWDC23 上推出的一个新的数据持久化框架,它是 CoreData 的替代品,提供了更简单、更易用的 API。

创建@Model模型

先说说如何创建 SwiftData 模型。

创建

@Model 宏装饰类

@Modelfinal class Article {    let title: String    let author: String    let content: String    let publishedDate: Date        init(title: String, author: String, content: String, publishedDate: Date) {        self.title = title        self.author = author        self.content = content        self.publishedDate = publishedDate    }}

以上代码创建了一个 Article 模型,包含了标题、作者、内容和发布日期。

以下数据类型默认支持:

  • 基础类型:Int, Int8, Int16, Int32, Int64, UInt, UInt8, UInt16, UInt32, UInt64, Float, Double, Bool, String, Date, Data 等
  • 复杂的类型:Array, Dictionary, Set, Optional, Enum, Struct, Codable 等
  • 模型关系:一对一、一对多、多对多

默认数据库路径: Data/Library/Application Support/default.store

@Attribute

接下来说说如何使用 @Attribute 宏。

一些常用的:

  • spotlight:使其能出现在 Spotlight 搜索结果里
  • unique:值是唯一的
  • externalStorage:值存储为二进制数据
  • transient:值不存储
  • encrypt:加密存储

使用方法

@Attribute(.externalStorage) var imgData: Data? = nil

二进制会将其存储为单独的文件,然后在数据库中引用文件名。文件会存到 Data/Library/Application Support/.default_SUPPORT/_EXTERNAL_DATA 目录下。

@Transient 不存

如果有的属性不希望进行存储,可以使用 @Transient

@Modelfinal class Article {    let title: String    let author: String    @Transient var content: String    ...}

transformable

SwiftData 除了能够存储字符串和整数这样基本类型,还可以存储更复杂的自定义类型。要存储自定义类型,可用 transformable。

@Modelfinal class Article {    let title: String    let author: String    let content: String    let publishedDate: Date    @Attribute(.transformable(by: UIColorValueTransformer.self)) var bgColor: UIColor    ...}

UIColorValueTransformer 类的实现

class UIColorValueTransformer: ValueTransformer {        // return data    override func transformedValue(_ value: Any?) -> Any? {        guard let color = value as? UIColor else { return nil }        do {            let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: true)            return data        } catch {            return nil        }    }        // return UIColor    override func reverseTransformedValue(_ value: Any?) -> Any? {        guard let data = value as? Data else { return nil }                do {            let color = try NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: data)            return color        } catch {            return nil         }    }}

注册

struct SwiftPamphletAppApp: App {    init() {        ValueTransformer.setValueTransformer(UIColorValueTransformer(), forName: NSValueTransformerName("UIColorValueTransformer"))    }        var body: some Scene {        WindowGroup {            ContentView()                .modelContainer(for: [Article.self])        }    }}

SwiftData-模型关系

使用 ``@Relationship` 添加关系,但是不加这个宏也可以,SwiftData 会自动添加模型之间的关系。

@Modelfinal class Author {    var name: String    @Relationship(deleteRule: .cascade, inverse: \Brew.brewer)    var articles: [Article] = []}@Modelfinal class Article {    ...    var author: Author}

默认情况 deleteRule 是 .nullify,这个删除后只会删除引用关系。.cascade 会在删除用户后删除其所有文章。

SwiftData 可以添加一对一,一对多,多对多的关系。

限制关系表数量

@Relationship(maximumModelCount: 5)    var articles: [Article] = []

容器配置modelContainer

多模型

配置方法

@mainstruct SomeApp: App {    var body: some Scene {        WindowGroup {            ContentView()        }        .modelContainer(for: [Article.self, Author.self])    }}

有关系的两个模型,只需要加父模型,SwiftData 会推断出子模型。

数据存内存

let configuration = ModelConfiguration(inMemory: true)let container = try ModelContainer(for: schema, configurations: [configuration])

数据只读

let config = ModelConfiguration(allowsSave: false)

自定义存储文件和位置

如果要指定数据库存储的位置,可以按下面写法:

@mainstruct SomeApp: App {    var container: ModelContainer    init() {        do {            let storeURL = URL.documentsDirectory.appending(path: "database.sqlite")            let config = ModelConfiguration(url: storeURL)            container = try ModelContainer(for: Article.self, configurations: config)        } catch {            fatalError("Failed")        }    }    var body: some Scene {        WindowGroup {            ContentView()        }        .modelContainer(container)    }}

iCloud 支持

如果要添加 iCloud 支持,需要先确定模型满足以下条件:

  • 没有唯一约束
  • 关系是可选的
  • 有所值有默认值

iCloud 支持操作步骤:

  • 进入 Signing & Capabilities 中,在 Capability 里选择 iCloud
  • 选中 CloudKit 旁边的框
  • 设置 bundle identifier
  • 再按 Capability,选择 Background Modes
  • 选择 Remote Notifications

指定部分表同步到 iCloud

使用多个 ModelConfiguration 对象来配置,这样可以指定哪个配置成同步到 iCloud,哪些不同步。

添加多个配置

@mainstruct SomeApp: App {    var container: ModelContainer    init() {        do {            let c1 = ModelConfiguration(for: Article.self)            let c2 = ModelConfiguration(for: Author.self, isStoredInMemoryOnly: true)            container = try ModelContainer(for: Article.self, Author.self, configurations: c1, c2)        } catch {            fatalError("Failed")        }    }    var body: some Scene {        WindowGroup {            ContentView()        }        .modelContainer(container)    }}

撤销和重做

创建容器时进行指定

.modelContainer(for: Article.self, isUndoEnabled: true)

这样 modelContext 就可以调用撤销和重做函数。

struct SomeView: View {    @Environment(\.modelContext) private var context    var body: some View {        Button(action: {            context.undoManager?.undo()        }, label: {            Text("撤销")        })    }}

context

View 之外的地方,可以通过 ModelContainer 的 context 属性来获取 modelContext。

let context = container.mainContextlet context = ModelContext(container)

预先导入数据

方法如下:

.modelContainer(for: Article.self) { result in    do {        let container = try result.get()        // 先检查有没数据        let descriptor = FetchDescriptor<Article>()        let existingArticles = try container.mainContext.fetchCount(descriptor)        guard existingArticles == 0 else { return }        // 读取 bundle 里的文件        guard let url = Bundle.main.url(forResource: "articles", withExtension: "json") else {            fatalError("Failed")        }        let data = try Data(contentsOf: url)        let articles = try JSONDecoder().decode([Article].self, from: data)        for article in articles {            container.mainContext.insert(article)        }    } catch {        print("Failed")    }}

增删modelContext

添加保存数据

struct SomeView: View {   @Environment(\.modelContext) var context   ...   var body: some View {         ...         Button(action: {             self.add()         }, label: {             Text("添加")         })   }   func add() {      ...      context.insert(article)   }}

默认不用使用 context.save(),SwiftData 会自动进行保存,如果不想自动保存,可以在容器中设置

var body: some Scene {   WindowGroup {      ContentView()   }   .modelContainer(for: Article.self, isAutosaveEnabled: false)       }

编辑和删除数据

编辑数据使用 @Bindable

struct SomeView: View {    @Bindable var article: Article    @Environment(\.modelContext) private var modelContext    ...        var body: some View {        Form {            TextField("文章标题", text: $article.title)            ...        }        .toolbar {            ToolbarItem(placement: .destructiveAction) {                Button("删除") {                    modelContext.delete(article)                }            }        }        ...    }}

SwiftData-检索

@Query

使用 @Query 会从数据库中获取数据。

@Query private var articles: [Article]

@Query 还支持 filter、sort、order 和 animation 等参数。

@Query(sort: \Article.title, order: .forward) private var articles: [Article]

sort 可支持多个 SortDescriptor,SwiftData 会按顺序处理。

@Query(sort: [SortDescriptor(\Article.isArchived, order: .forward),SortDescriptor(\Article.updateDate, order: .reverse)]) var articles: [Article]

Predicate

filter 使用的是 #Predicate

static var now: Date { Date.now }@Query(filter: #Predicate<Article> { article in    article.releaseDate > now}) var draftArticles: [Article]

Predicate 支持的内置方法主要有 containsallSatisfyflatMapfiltersubscriptstartsminmaxlocalizedStandardContainslocalizedComparecaseInsensitiveCompare 等。

@Query(filter: #Predicate<Article> { article in    article.title.starts(with: "苹果发布会")}) var articles: [Article]

需要注意的是 .isEmpty 不能使用 article.title.isEmpty == false ,否则会崩溃。

FetchDescriptor

FetchDescriptor 可以在模型中查找数据,而不必在视图层做。

@Modelfinal class Article {    var title: String    ...    static var all: FetchDescriptor<Article> {        FetchDescriptor(sortBy: [SortDescriptor(\Article.updateDate, order: .reverse)])    }}struct SomeView: View {       @Query(Article.all) private var articles: [Article]    ...}

获取数量而不加载

使用 fetchCount() 方法,可完成整个计数,且很快,内存占用少。

let descriptor = FetchDescriptor<Article>(predicate: #Predicate { $0.words > 50 })let count = (try? modelContext.fetchCount(descriptor)) ?? 0

fetchLimit 限制获取数量

var descriptor = FetchDescriptor<Article>(  predicate: #Predicate { $0.read },  sortBy: [SortDescriptor(\Article.updateDate,           order: .reverse)])descriptor.fetchLimit = 30let articles = try context.fetch(descriptor)// 翻页let pSize = 30let pNumber = 1var fetchDescriptor = FetchDescriptor<Article>(sortBy: [SortDescriptor(\Article.updateDate, order: .reverse)])fetchDescriptor.fetchOffset = pNumber * pSizefetchDescriptor.fetchLimit = pSize

限制获取的属性

只请求要用的属性

var fetchDescriptor = FetchDescriptor<Article>(sortBy: [SortDescriptor(\.updateDate, order: .reverse)])fetchDescriptor.propertiesToFetch = [\.title, \.updateDate]

SwiftData-处理大量数据

SwiftData 模型上下文有个方法叫 enumerate(),可以高效遍历大量数据。

let descriptor = FetchDescriptor<Article>()...do {    try modelContext.enumerate(descriptor, batchSize: 1000) { article in        ...    }} catch {    print("Failed.")}

其中 batchSize 参数是调整批量处理的数量,也就是一次加载多少对象。因此可以通过这个值来权衡内存和IO数量。这个值默认是 5000。

SwiftData多线程

创建一个 Actor,然后 SwiftData 上下文在其中执行操作。

@ModelActoractor DataHandler {}extension DataHandler {    func addInfo() throws -> IOInfo {        let info = IOInfo()        modelContext.insert(info)        try modelContext.save()        return info    }    ...}

使用

Task.detached {    let handler = DataHandler()    let item = try await handler.addInfo()       ...}

SwiftData-版本迁移

以下的小改动 SwiftData 会自动执行轻量迁移:

  • 增加模型
  • 增加有默认值的新属性
  • 重命名属性
  • 删除属性
  • 增加或删除 .externalStorage.allowsCloudEncryption 属性。
  • 增加所有值都是唯一属性为 .unique
  • 调整关系的删除规则

其他情况需要用到版本迁移,版本迁移步骤如下:

  • 用 VersionedSchema 创建 SwiftData 模型的版本
  • 用 SchemaMigrationPlan 对创建的版本进行排序
  • 为每个迁移定义一个迁移阶段

设置版本

enum ArticleV1Schema: VersionedSchema {    static var versionIdentifier: String? = "v1"    static var models: [any PersistentModel.Type] { [Article.self] }    @Model    final class Article {        ...    }}

SchemaMigrationPlan 轻量迁移

enum ArticleMigrationPlan: SchemaMigrationPlan {    static var schemas: [any VersionedSchema.Type] {        [ArticleV1Schema.self, ArticleV2Schema.self]    }    static var stages: [MigrationStage] {        [migrateV1toV2]    }    static let migrateV1toV2 = MigrationStage.lightweight(        fromVersion: ArticleV1Schema.self,        toVersion: ArticleV2Schema.self    )}

自定义迁移

static let migrateV1toV2 = MigrationStage.custom(    fromVersion: ArticleV1Schema.self,    toVersion: ArticleV2Schema.self,    willMigrate: { context in        // 合并前的处理    },    didMigrate: { context in        // 合并后的处理    })

SwiftData-调试

CoreData 的调试方式依然适用于 SwiftData。

你可以设置启动参数来让 CoreData 打印出执行的 SQL 语句。在你的项目中,选择 “Product” -> “Scheme” -> “Edit Scheme”,然后在 “Arguments” 标签下的 “Arguments Passed On Launch” 中添加 -com.apple.CoreData.SQLDebug 1。这样,每当 CoreData 执行 SQL 语句时,都会在控制台中打印出来。

使用 -com.apple.CoreData.SQLDebug 3 获取后台更多信息。

SwiftData-资料

WWDC

23

戴铭的开发小册子6.0,使用SwiftUI、SwiftData、Observable、NavigationSplitView 进行了重构

作者 戴铭
2024年5月8日 21:05

目前戴铭的开发小册子已经上架 macOS 应用商店,点击安装,或在 macOS 应用商店“戴铭”关键字。

Available on the App Store

本版本解决了以前的几个问题。

第一个,存储的问题。以前使用的是三方数据库,写法比较繁琐且和 SwiftUI 结合的不好。现在用的是 SwiftData,写法简洁了很多,代码也好维护了。更多技术重构细节可以直接查看 App 代码

第二,手册内容和资料之间的关系。以前比较隔离,资料和手册没有联系。现在采用的是每个知识点都可以添加相关资料,这样更利于知识的积累。

第三,Github 库和开发者信息的管理问题。以前添加和删除都在代码层面,现在可以直接在 App 内进行。

这三个问题解决后,可以将更多精力花在内容的更新增加以及 App 使用体验上了。

内容主要包含

  • Apple 技术知识点以及示例
  • 历年 WWDC

功能主要包含

  • 手册书签收藏
  • 资料收集整理
  • 离线保存资料
  • 知识点和资料关联
  • 手册、WWDC和资料可搜索
  • Github 开发者和仓库信息添加管理

知识点目前主要有 Swift 基础语法,SwiftUI,SwiftData,小组件等知识内容。其他内容还在迭代新增和更新中。

给孩子小学的家长讲堂做了一个计算机科普分享

作者 戴铭
2023年5月31日 19:39

柠檬所在的学校举办了一个家长讲堂活动,家长们做了很多有意思的分享,柠檬也希望我能够去讲讲,因此我也专门准备了一些内容。下面是我在家长讲堂上所分享的内容。

分享的标题是《图灵对计算机的设想》,那么图灵是什么人?

阿兰·图灵,英国著名的数学家和计算机科学家,被誉为计算机科学之父、人工智能之父和密码学之父。

第二次世界大战中,阿兰·图灵是一位密码破译专家,协助英国政府破解了德国的密码,对盟军的胜利作出了贡献。

在1939年,英国参加了二战,他加入英国布莱切利园的一个密码破译组织,负责破解德军用的一种名为 Enigma 加密机的通信加密信息。

Enigma 看起来像一台打字机,有键盘、灯板、插线板和转子。键盘上按下一个字母键,灯板就会显示加密后的字母。

其中最重要的是转子,Enigma 的转子会轮换替代映射到密文。更改映射的能力很重要,因为一旦某人推导出一个字母替代规则,那么他将会知道密文中每个字母替换规则,因此需要将这些配对都改变,每次编码字母时都更改。

Enigma 实现方式是将所有布线嵌入到车轮/转子中。通过在保持字母静止的同时转动转子,字母之间的连接会发生变化。重复替换步骤,然后转动每个字母的转子。在转子中,每根导线的两端都有外部接触点。这允许这些转子中的多个并排放置,相邻触点接触。在内部,每个转子的接线方式不同,即每个转子都包含不同的密码。在一些Enigma机器中,有三个转子,最常用的是八个。每个转子还有一个附加的字母环,该字母环随转子转动并用于设置转子的初始位置。

每个转子都可以转动到任何位置。这意味着对于第一个转子,有26条可能的路径通过一个字母。但是一旦我们沿着导线穿过第一个转子,现在有26条可能的路径通过第二个转子。然后通过第三条路径还有26条可能的路径。因此,2条通过所有三个转子的路径总数为17576。如果是5个转子,我们可以从五个转子中选择用于左侧的转子,然后从剩余的四个转子中选择用于中间的转子,然后从三个转子中选择用于正确的转子。这提供了60种可能的方式来选择用于消息的三个转子。由于一个字母可以通过转子有17576条可能的路径,因此总共有1054560种可能性。

1930年,德国军队版本增加了一个插板,允许交换字母。由于有26个字母,最多可以进行13个掉期,但通常只有10个。计算连接插板的可能方法数量的数学有点复杂,但数字是150738274937250。乘以我们上面给出的其他可能的组合,我们得到一个字母可以采用的可能路径总数是158962555217826360000。

可能性超多的,在那个只能用真空管做布尔计算的时代,想要破译这些可能,是一件很难的事情。

那么当时盟军是怎么破译的呢?

早期替换加密规律很简单,比如凯撒加密把信件中的字母向前挪三个位置,还有玛丽女王密谋杀伊丽莎白女王的密文,通过统计字母出现频率之类的规则,当破解了一个字母替换方法就能找出通篇原文,没有计算机也能够手工破解出来,而 Enigma 每个字母的可能性都海量的,导致盟军在很长一段时间都没法破译 Enigma 加密的内容。

1932年波兰数学家马里安·雷耶夫斯基、杰尔兹·罗佐基和亨里克·佐加尔斯基按照法国情报人员秘密获取的 Enigma 的原理破解了 Enigma。由于波兰数学家们利用的漏洞不断被德军修复,算力无法及时算出结果,后来将破解方法告诉了英国。

图灵基于波兰破解方法,利用字母加密后一定会是一个和自己不同的字母这个缺陷,设计了一个叫 Bombe 的计算机,对加密消息尝试多种组合,如发现字母解密后和原先一样,这个组合就会被跳过,接着试另一组,因此 Bombe 大幅减少了搜索量,这样就能保证及时破解信息。

战争历史学家 Harry Hinsley 肯定了图灵和布莱切利园组织的工作,说由于他们的工作让战争缩短了两年多,挽救了1400万人的生命。

如今加密技术怎样了呢?进入民用了么?我们能够利用加密技术保护我们的数据安全吗?

加密技术从硬件转向了软件,早期加密算法是1977年的 DES。DES 是一种对称加密算法,它的原理是将明文分成64位的块,通过一系列的置换、替换和移位操作,使用一个56位的密钥对明文进行加密,得到64位的密文。意味着有2的56次方,或大约72千万亿个不同密钥。当时是没有计算能力可以暴力破解所有可能密钥的。

DES 加密算法的具体步骤如下:

  • 初始置换(IP):将明文按照一定的规则进行置换,得到一个新的64位明文。
  • 分组:将置换后的明文分成左右两个32位的块。
  • 轮函数:对右半部分进行一系列的置换、替换和移位操作,使用一个48位的子密钥对其进行加密。
  • 左右交换:将左半部分和右半部分进行交换。
  • 重复执行第3步和第4步,共进行16轮。
  • 合并:将左右两个32位的块合并成一个64位的块。
  • 末置换(FP):将合并后的块按照一定的规则进行置换,得到一个新的64位密文。

DES 解密算法的步骤与加密算法相反,主要是将加密算法中的子密钥按照相反的顺序使用,对密文进行解密。

DES 加密算法的安全性在当时是比较高的。

到了1999年,计算机芯片计算能力指数增加,一台计算机就能在几天内将 DES 的所有可能密钥都试一遍。因此,DES 已经不再被广泛使用,取而代之的是更加安全的加密算法,例如 AES。

2001年 AES 是一种对称加密算法,它的原理是将明文分成128位的块,通过一系列的置换、替换和移位操作,使用一个128位、192位或256位的密钥对明文进行加密,得到128位的密文。

AES 加密算法的具体步骤如下:

  • 密钥扩展:根据密钥长度,对密钥进行扩展,生成多个轮密钥。
  • 初始轮:将明文按照一定的规则进行置换,得到一个新的128位明文。
  • 轮函数:对明文进行一系列的置换、替换和移位操作,使用一个轮密钥对其进行加密。
  • 重复执行第3步,共进行多轮。
  • 末轮:对明文进行最后一轮的置换、替换和移位操作,使用最后一个轮密钥对其进行加密。
  • 得到密文。

AES 解密算法的步骤与加密算法相反,主要是将加密算法中的轮密钥按照相反的顺序使用,对密文进行解密。

AES 加密算法的安全性很高,主要基于其密钥长度和轮函数的复杂性。AES 支持三种密钥长度:128位、192位和256位,其中256位密钥的安全性最高。此外,AES 的轮函数使用了多种复杂的操作,例如有限域上的乘法和逆变换,使得密码破解变得更加困难。

AES 在性能和安全性间取得平衡。如今 AES 被广泛使用,比如 iPhone 上加密文件,访问 HTTPS 网站等。

进入互联网时代,以前加密技术中的密钥在网上传递过程中会被截获,截获到密钥就能够直接解密通信了。

那要怎么做才能够保证密钥不会被截获呢?

这就要用到密钥交换技术了。

密钥交换是一种不发送密钥,但依然让两台计算机在密钥上达成共识的算法。我们可以用单向函数来做。单向函数是一种数学操作,很容易算出结果,但想从结果逆向推算出输入非常困难。

密钥交换的原理是基于数学问题的难解性,例如离散对数问题。

其中,Diffie-Hellman 密钥交换协议是一种常见的密钥交换协议,在 Diffie-Hellman 里单向函数是模幂运算。意思是先做幂运算,拿一个数字当底数,拿一个数字当指数。其具体原理如下:

  • 选择两个大质数 p 和 g,其中 g 是 p 的原根。
  • 小明选择一个私钥 a,并计算 A=g^a(mod p),将 A 发送给小强。
  • 小强选择一个私钥 b,并计算 B=g^b(mod p),将 B 发送给小明。
  • 小明计算 s=B^a(mod p)
  • 小强计算 s=A^b(mod p)
  • 现在,小明和小强都拥有相同的密钥 s,可以在通信过程中使用它来加密和解密消息。

Diffie-Hellman 密钥交换协议的安全性基于离散对数问题的难解性,即使已知 p、g、A 和 B,也很难计算出 a 和 b。因此,Diffie-Hellman 密钥交换协议被广泛应用于安全通信和密钥交换等领域。

另外还可以用混色来比喻 Diffie-Hellman 密钥交换协议。

将颜色混合在一起很容易。但想知道混了什么颜色很难。要试很多种可能才知道,用这个比喻,那么我们的密钥是一种独特的颜色,首先,有一个公开的颜色 C,所有人都可以看到。然后小明和小强各自选一个秘密颜色 A 和颜色 C,只有自己知道,然后小明发给小强 A 和 C 的混色。小强也这样做,把他的秘密颜色 B 和公开颜色 C 混在一起,然后发给小明。小明收到小强的颜色后,把小明的秘密颜色 A 加进去,现在3种颜色混合在一起。小强也一样做。这样,小强和小明就有了一样的颜色。他们可以把这个颜色当密钥,尽管他们从来没有给对方发过这颜色。外部截获信息的人可以知道部分信息,但无法知道最终颜色。

Diffie-Hellman 密钥交换是建立共享密钥的一种方法。双方用一样的密钥加密和解密消息,这叫对称加密,因为密钥一样,凯撒加密,英格玛,AES 都是对称加密。

对称加密的内容两个人都能解密看到,如果加密的信息只想有一方可以解密查看就要用到非对称加密。非对称加密,有两个不同的密钥,一个是公开的,另一个是私有的,用公钥加密消息,只有有私钥的人能解密。

就好像把一个箱子和锁给你,你可以锁上箱子,但不能打开箱子,锁箱子就是公钥加密,能够打开箱子的是有钥匙的人,解锁就是私钥解密。

常见的非对称加密算法包括RSA、DSA和ECC等。目前最流行的非对称加密技术是 RSA。名字来自发明者:Rivest,Shamir,Adleman。

RSA 的原理是基于数学问题的难解性,例如大质数分解。RSA的具体原理如下:

  • 选择两个大质数 p 和 q,计算它们的乘积 n=p*q
  • 选择一个整数e,使得1<e<φ(n),且 e 与 φ(n) 互质,φ(n)=(p-1)*(q-1)
  • 计算 e 关于 φ(n) 的模反元素 d,即满足 e*d≡1(mod φ(n)) 的最小正整数 d。
  • 公钥为 (n,e),私钥为 (n,d)
  • 加密时,将明文 m 转换为整数 M,计算密文 C=M^e(mod n)
  • 解密时,将密文 C 计算出明文 m,即 M=C^d(mod n)

RSA 的安全性基于大质数分解的难度,即使已知公钥和密文,也很难计算出私钥。因此,RSA被广泛应用于数字签名、密钥交换和安全通信等领域。比如数字签名就是公钥来解密,大家都能公开看到签名内容,只有服务器端能够用私钥来加密,这样就能够证明签名是没有伪造的。

对称加密,密钥交换和公钥密码这些就是现代密码学。和图灵那个时代相比更加安全,加解密速度的提高让应用场景也更加地广泛了。

图灵除了密码破译外还做了一件对现代计算机影响深远的事情。

1935年,德国数学家大卫·希尔伯特提出的问题,就是“可判定性问题”,可判定性问题是指是否存在一种算法,输入逻辑语句,可以判断是和否。

图灵发明了一种叫做图灵机的东西,这个机器可以模拟任何其他的计算机,通过图灵机回答了可判定性问题,这个问题虽然看似简单,但是实际上却相当复杂,因为涉及了形式语言的理论、递归的原理等概念。

图灵机可以用于证明停机问题,即判断一个给定的程序是否会在有限时间内停止运行。停机问题是计算机科学中的一个经典问题,它在理论上是不可解的,即不存在一种通用的算法可以解决所有停机问题。这个图灵机可以接受一个程序集合作为输入,并输出一个程序,该程序与输入集合中的所有程序的行为都不同。通过对这个图灵机的构造和分析,图灵证明了停机问题的不可解性。

具体来说,当程序不递归自己,输出停机,测试程序就调用它,使其不停机;如果程序递归调用自己,输出不停机,测试程序不调用它,使其停机。那么问题是测试程序递归调用自己时。

另外还有个更形象的和停机问题一样的理发师悖论,具体说就是有个理发师他有个原则,有人不能刮胡子,他刮;有人刮胡子,他不能刮。无法回答的问题是,理发师会自己刮胡子么?因为他能自己刮,但根据他的原则他又不能刮,但他不能刮的话他又要刮。

图灵机是图灵对计算机的设想,他假设时间足够多,存储足够大,图灵机可以实现任何计算,另外通过停机问题也证明了并不是所有问题都能用计算来解决,也就是提前证明了计算机的极限。开启了可计算性理论,也就是丘奇-图灵论题。

图灵机工作过程和人处理问题的过程类似,获取外部信息,处理当前信息,将处理结果暂存,接下来再获取新的信息重复这个过程。为了完成这个过程,图灵设计的机器有用于输入信息的纸带,处理信息的状态规则,暂存结果的状态寄存器,以及用于获取信息和存储信息的读写器。图灵机的工作过程为:

  • 从纸带上读取信息
  • 通过状态规则查找状态并按规则执行
  • 状态寄存器存储结果
  • 进入新状态
  • 重复过程

现代计算机的设计和实现受到了图灵机的启发。计算机的核心部件包括中央处理器(CPU)、存储器、输入输出设备等,这些部件的设计和实现都是基于图灵机的模型。例如,CPU 可以看作是图灵机的控制器,存储器可以看作是图灵机的纸带,输入输出设备可以看作是图灵机的输入输出接口。

另外,现代计算机的编程语言和算法也受到了图灵机的影响。图灵机可以模拟任何可计算的问题,因此它可以用来证明某个问题是可计算的,也可以用来设计算法和编写程序。现代计算机的编程语言和算法都是基于图灵机的模型,它们可以用来描述和解决各种计算问题。

总的来说现代计算机实现了图灵对计算机的设想,也深入到了我们每个人的生活。一些本来机器解决不了而人类可以解决的问题,机器也可以通过大量数据学习人类来解决。

接下来,我先简单介绍下计算机最核心的计算处理控制器发展,是怎么从图灵时代的继电器发展到现代 CPU 的。

图灵所在二战时代最大的计算机叫哈佛一号,由哈佛大学和 IBM 公司合作研制,有76万5千个组件,300万个连接点和500英里长的导线。哈佛一号采用电子管和机械继电器作为计算元件,可以进行加、减、乘、除等基本运算,还可以进行对数、三角函数等高级运算。继电器是用电控制机械开关。可以把继电器控制线路想成水龙头,打开水龙头,水会流出来,关闭水龙头,水就没了。只不过继电器控制的是电子而不是水。机械开关速度有限,最好的继电器1秒翻转50次。

哈佛一号的体积庞大,重达 5 吨,占地面积达 51 平方米,需要 3 个人来操作。哈佛一号的设计和实现受到了图灵机的启发,它采用了分程序控制和存储程序的思想,可以根据不同的程序进行自动切换。哈佛一号的设计者之一霍华德·艾肯曾说过:“我们试图建造一台机器,它可以像人一样思考,但是我们失败了。相反,我们建造了一台机器,它可以像机器一样思考。”

哈佛一号的研制历时 11 年,耗资 500 万美元,是当时世界上最先进的计算机之一。

哈佛马克一号一秒3次加减,6秒乘法,15秒除法。更复杂操作比如三角函数需要1分钟以上。除了速度慢,齿轮也容易磨损,继电器数量多故障率也会增加,哈佛马克一号有3500个继电器。昆虫也会造成继电器故障,1947年9月操作员从故障继电器中拔出一只死虫,那时每当电脑出了问题,就说它出了 bug。这个就是术语 bug 的来源。

继电器的替代品是真空管。真空管是一种电子器件,它的工作原理基于热电子发射和电子在真空中的运动。真空管由阴极、阳极和控制网格等部件组成,其中阴极是一个加热的金属丝,当温度升高时,会发射出大量的自由电子。这些电子被加速器电场加速,穿过控制网格,最终撞击到阳极上,产生电流。真空管内通过电流控制开闭实现继电器功能,由于真空管内没有会动的组件,这样速度更快,磨损更少,每秒可以开闭数千次。

真空管的工作过程可以分为三个阶段:发射阶段、传输阶段和收集阶段。在发射阶段,阴极发射出大量的自由电子,这些电子被加速器电场加速,形成电子流。在传输阶段,电子流穿过控制网格,受到网格电场的控制,形成一个电子束。在收集阶段,电子束撞击到阳极上,产生电流。

真空管的工作原理与晶体管等现代电子器件不同,它需要加热阴极才能发射电子,因此功耗较大,体积较大,寿命较短。但是真空管具有高功率、高频率、高压等特点,在一些特殊的应用场合仍然得到广泛应用。

真空管很贵,收音机一般只用一个,但计算机可能要上百甚至上千个。一般只有政府才会使用真空管做计算机。第一个大规模用真空管做的计算机是巨人一号,由工程师 Tommy Flower 设计,1943年12月完成。巨人一号在英国的布莱切利园里,用来破解日本的通信。巨人一号是基于图灵机的原理设计的,它采用了存储程序的思想,可以自动执行多个程序。同在布莱切利园的图灵的 bombe 机器没有使用真空管,而是使用的机械装置。核心部件是旋转轮机,它通过模拟密码机的运行过程来破解密码。Bombe 机器的工作原理与真空管电子计算机不同,它不需要电子元件,而是通过机械装置来实现计算和控制。巨人一号和图灵的 bombe 机器在破解密码的方式上也存在一些区别。巨人一号主要使用了穷举法和字典攻击等方法,而图灵的 bombe 则主要使用了差分密码分析等方法。

计算机硬件技术真正实现突破沿用至今的时刻发生在1947年,当年为了降低计算机成本和大小,同时提高可靠性和速度,1947年贝尔实验室科学家 John Bardeen,Walter Brattain,William Shockley 发明了晶体管。晶体管由三个掺杂不同材料的半导体层构成,其中中间的层被称为基底,两侧的层被称为掺杂层。当掺杂层中注入电子或空穴时,它们会在基底中形成一个电子或空穴浓度较高的区域,这个区域被称为 PN 结。PN 结可以用来控制电流的流动,从而实现放大和开关电信号的功能。晶体管的发明是电子技术史上的重要里程碑,它的出现标志着电子器件从真空管时代进入了半导体时代。

晶体管的物理学相当复杂,牵扯到量子力学。晶体管有两个电极,电极之间有一种材料隔开他们,这种材料有时候导电,有时候不导电,叫半导体。半导体每秒可以开关10000次,与玻璃制作的真空管相比,晶体管是固态的,不容易坏,而且比真空管更小更便宜。

1957年 IBM 推出完全用晶体管的 IBM 608,由于便宜,消费者也可以买得到。它有3000个晶体管,每秒执行4500次加法,80次乘除法。IBM 将晶体管计算机带入千家万户。现在计算机里的晶体管小于50纳米,而一张纸的厚度大概是10万纳米。每秒可以切换上百万次,工作很多年。

晶体管和半导体的开发在圣克拉拉谷,半导体材料大部分是硅,硅很特别,它是半导体,它有时导电,有时不导电,我们可以控制导电时机,所以硅是做晶体管的绝佳材料。硅的蕴藏量丰富,占地壳四分之一,这个地方后来被称为硅谷。

1960年代,为了解决电子器件体积大、功耗高、可靠性差等问题。在德州仪器工作的 Jack Killby 把多个组件包在一起,变成一个新的独立组件,这个组件就是集成电路。Robert Noyce 的仙童半导体让集成电路变为现实。最开始一个 IC 只有几个晶体管,把简单电路,逻辑门封装成单独组件。

在集成电路中,数百万个晶体管、电容器、电阻器等元件被集成在一个芯片上,从而大大减小了电路的体积和功耗,提高了电路的可靠性和性能。

在集成电路出现之前,电子器件主要采用离散元件的方式进行组装。这种方式需要大量的电子元件,而且需要手工进行组装和连接,不仅体积大、功耗高,而且可靠性差。随着半导体技术的发展,人们开始尝试将多个晶体管、电容器、电阻器等元件集成在一个芯片上,从而形成了集成电路。

集成电路的出现极大地推动了电子技术的发展。它不仅使电子器件的体积和功耗大大减小,而且提高了电路的可靠性和性能。随着集成电路技术的不断发展,芯片上的晶体管数量不断增加,集成度不断提高。

为了创造更复杂的电路并能够大规模生产,出现了通过蚀刻金属线的方式,把零件连接到一起的印刷电路板技术,简称 PCB。是一种用于连接和支持电子元件的基板,它通过在表面覆盖一层导电材料(通常是铜)并在其上刻蚀出电路图案,从而实现电路的连接和布局。印刷电路板广泛应用于电子设备中,例如计算机、手机、电视等。

印刷电路板的制作过程通常包括以下几个步骤:

  • 设计电路图:首先需要根据电路的功能和布局设计电路图,通常使用电路设计软件进行设计。
  • 制作印刷电路板:将电路图转换为印刷电路板的图案,并使用光刻技术将图案转移到覆盖在基板上的光阻膜上。然后,使用化学蚀刻技术将未被光阻膜保护的铜层蚀刻掉,从而形成电路图案。
  • 镀金层:在印刷电路板表面镀上一层金属,通常是镀金,以提高电路板的导电性和耐腐蚀性。
  • 焊接元件:将电子元件焊接到印刷电路板上,通常使用表面贴装技术(Surface Mount Technology,SMT)或插件式技术(Through-Hole Technology,THT)。
  • 测试电路板:使用测试设备对印刷电路板进行测试,以确保电路板的功能和性能符合要求。

为了在相同体积下集成更多晶体管,全新的光刻工艺出现了,用光把复杂图案印到材料上,比如半导体。其基本原理是利用光敏材料对光的敏感性,通过光的照射和化学反应来形成所需的图案。光刻使用材料包括光掩膜,光刻胶,金属化,氧化层和晶圆。我们可以用晶圆做基础,把复杂金属电路放上面,集成所有东西,非常适合做集成电路。

光刻的基本步骤包括:

  • 涂覆光刻胶:将光刻胶涂覆在待加工的基板表面上,形成一层均匀的薄膜。
  • 曝光:将光刻胶暴露在紫外线下,通过掩膜将光刻胶暴露在特定的区域,形成所需的图案。
  • 显影:将光刻胶进行显影,将未暴露在紫外线下的光刻胶溶解掉,形成所需的图案。
  • 退光刻胶:使用退光刻胶剂将光刻胶进行退除,以便进行下一步的工艺步骤。

在曝光过程中,光刻胶中的光敏剂会吸收光子能量,从而发生化学反应,使得光刻胶在曝光区域发生物理或化学变化。在显影过程中,未曝光的光刻胶会被溶解掉,而曝光区域的光刻胶则会保留下来,形成所需的图案。在退光刻胶过程中,使用退光刻胶剂将光刻胶进行退除,以便进行下一步的工艺步骤。

用类似制作步骤,光刻可以制作其他电子元件,比如电阻和电容,都在一片硅上。而且互相连接的电路也做好了。现实中,光刻法一次会做上百万个细节。

有了光刻技术晶体管越来越小,密度也变得更高,戈登·摩尔发现了一个趋势,就是每两年相同空间所放晶体管数量会增加两倍,后来这个规律被称为摩尔定律。戈登·摩尔和罗伯特·诺伊斯联手成立了一家新公司,结合 Intergrated 和 Electronics 两个词,取名 Intel,是现在最大的芯片制造商。CPU 晶体管数量按摩尔定律一直在指数级地增长,1980年,一个芯片有3万晶体管。到1990年达到了100万,2010年一个芯片里已经可以放进10亿晶体管,现在苹果 M1 Ultra 的晶体管数量约为1140亿。英特尔说,到2030年,芯片将拥有约1万亿个晶体管。先进的芯片中晶体管的尺寸是以纳米为单位,小到2纳米,比血红细胞小2800倍。除了 CPU 还有内存,显卡,固态硬盘和摄像头感光元件等都得益于光刻带来的摩尔定律发展。现在的电路设计都是超大规模集成(VLSI)自动生成的设计。

目前由于光的波长精度已经接近极限,因此需要波长更短的光源来投射更小图案。另外晶体管小到一定程度电极之间可能只有原子长,会发生量子隧道贯穿,也就是电子会跳过间隙。不过相信只要有需求,这些技术问题终将被克服。

那么究竟都有什么样的需求一直推动着计算机技术爆发增长呢?

最早计算机的用途主要就是做数学计算,比如二战的炮手,需要根据射程和大气压力来计算近似多项式,多项式可以描述几个变量的关系,这些函数手算很麻烦耗时。Charles Babbage 提出一种新型机械装置叫差分机将欲求多项方程的前3个初始值输入到机器,推论出固定不变的差数,接下来每个值就可以将差数和前一个阶段的值相加得到。求多项方程的结果完全只需要用到加和减。

在19世纪末,美国人口10年一次普查,然而手工编制需要七年时间,编制完成已经过时了,1890年人口激增,手工编制普查数据需要13年之久。Herman Hollerith 发明了打孔卡片制表机,机器是电动机械的,用传统机械计数,用电动结构连接其他组件。用打孔来表示数据,每个孔代表一个二进制数码,机器会读取孔的位置将其转成数字,打孔卡片制表机的工作方式如下:

  • 使用打孔机将有关个人的数据记录在打孔卡片上。在卡片上打孔,代表一个人的姓名、年龄、职业等信息。
  • 打好的卡片被送入Hollerith的制表机。该机器有金属刷子,可以从卡片上的孔中穿过。
  • 当刷子经过一个开孔时,一个电路就会完成,一个计数器就会递增。计数器记录着有多少张牌具有某些特征。
  • 计数器还可以使用连接在机器上的打印机将结果打印在纸上。它将根据计数器的计数来打印数据的摘要。

与手工操作相比,Hollerith的系统加快了数据的统计过程,速度是手动的十倍。美国人口普查局在1890年采用了他的打孔卡系统,使他们能够在两年半内完成人口普查数据处理。

Herman Hollerith 后来成立了制表机器公司,服务于会计、保险评估和库存管理等数据密集行业。后来这家公司和其他公司合并后改名国际商业机器公司,简称 IBM。

二战时期及二战后冷战时期各国对计算机的需求达到了鼎盛,比如我前面提到图灵他们做的破译 Enigma 的机器。政府对计算机投入资源的时期是美国和苏联的冷战,这也得益于二战时计算机在曼哈顿计划和破解德军加密对自身价值的证明。其中阿波罗计划是投入经费最多的项目,雇了40多万人,还有2万多家大学和公司参与了其中。复杂轨道的计算需求是最大的,因此 NASA制造了阿波罗导航计算机,这台计算机首先使用了集成电路,当时首先使用了集成电路的价格是很贵的,一个芯片就需要五十多美元,而阿波罗导航计算机需要上千个这样的芯片。另外军事上,洲际导弹和核弹也促进了集成电路规模化生产。

随着冷战的结束,政府在计算机上的投入也逐渐减少,计算机迎来了家用消费级时代。

70年代初,计算机各个组件的成本都有大幅下降,可以做出低成本适用于个人使用的电脑,第一台取得商业成功的个人计算机是 Altair 8800,很多计算机爱好者都会购买,计算机的程序要用机器码编写,由于编写麻烦,比尔·盖茨编写了 BASIC 解释器,可以将 BASIC 代码转换成可执行机器码,这个解释器叫 Altair BASIC,也是微软的第一个产品。

24岁的 Steve Wozniak 受到 Altair 8800 的启发,做了一台自己的计算机,他的同学 Steve Jobs 看中了其中机会,1976年4月1日创立了苹果计算机公司,1976年7月开始将 Steve Wozniak 设计的计算机进行售卖,这也是苹果计算机公司的第一款产品。后来苹果的 Apple-II 卖了上百万套,苹果公司一战成名。

和苹果的封闭架构不同的是 IBM 发布的 IBM PC,IBM PC 采用的是开放式架构,这样每个公司都可以遵循这个标准做出自己的计算机,核心硬件和外设都可以有不同组合,这样的计算机也称为 IBM 兼容计算机。

开放的架构也繁荣了生态,更多公司比如康柏和戴尔加入了个人计算机领域。

让计算机进入更多普通人家庭的是交互上的革命。

1984年苹果发布了 Macintosh,使用图形界面取代了用命令行交互的终端。

更多用户对计算机的使用也带来视觉和听觉感官的诉求。那么图形和声音是怎么让计算机识别处理和保存的呢?

当一个图像以特定的格式保存时,构成图像的数字数据–像素和它们的颜色值–被编码并根据该格式的规范进行压缩。该文件还包含元数据,如图像大小、分辨率和色彩模式。

图像文件格式决定了数字数据的组织和压缩方式,图像文件格式的主要类型有:

  • JPEG:一种 “有损 “的压缩格式,通常用于照片。它压缩图像数据以减少文件大小,导致图像质量的一些损失。
  • PNG:一种 “无损 “的压缩格式,适用于带有文字、线条和图形的图像。用这种格式保存时,没有图像质量的损失。
  • GIF: 一种适用于颜色数量有限的图像的格式,通常用于网络上的简单图形和动画。
  • BMP: 一种未压缩的格式,存储图像的精确像素数据。BMP文件的尺寸往往很大。

数字音频文件是由代表音频波形的二进制数据组成。文件格式规定了这种二进制数据的结构和组织方式,以表示音频样本、比特深度、采样率、通道数量和其他元数据。像媒体播放器这样的计算机程序可以读取文件格式并解码二进制数据以播放音频。

常见的音频文件格式包括:

  • WAV:一种标准的未压缩的音频格式,由原始样本组成。WAV文件往往尺寸较大,但具有较高的音频质量。
  • MP3: 一种压缩的音频格式,使用有损压缩来减少文件大小。MP3文件较小,但与WAV相比,其音频质量略低。
  • AAC: 另一种压缩的音频格式,提供良好的压缩率,同时保持相对较高的音频质量。AAC文件通常用于iPod等设备。
  • FLAC: 一种无损压缩的音频格式,在保留所有原始音频信息和质量的同时压缩文件以减小尺寸。

如今,计算机已经可以大致模拟出我们所能感受到的东西,而图灵对计算机的构想也正随着硬件高速发展而逐步被实现,并走进每一个人的生活。关于图灵证明的计算机的极限,计算机已通过学习大量数据来模仿人类进行突破,学会根据情况忽略一些悖论来避免宕机。和计算机不同,我们的生命有限,记忆的容量有限,但也正因为如此,我们才能更好地享受和珍惜每一次对未知事物探索过程的回忆,而不是结果。

使用 SwiftUI 开发 RSS 阅读器

作者 戴铭
2023年4月24日 15:51

在 Apple 加速器活动和字节内分享了使用 SwiftUI 做 RSS 阅读器的一点心得。可能你还不知道什么是 RSS 阅读器,简单来说 RSS 是一些博客和新闻网站,甚至是播客和视频平台发布他们的内容更新的一种 XML 格式,阅读器就是通过请求这个 XML 以获取他们内容更新的客户端。

这就有了接下来几个问题:

目前已有 Reeder 和 NetNewsWire 等 RSS 阅读器,那么为什么还要再开发一个呢,早在14年我曾做过一个,陆续也更新过,后来还是以 Reeder 作为主力,feedly 作为服务,后来 feedly 有些不稳定,我又改成本地获取 feed 的方式,但是改成本地模式后设备同步又成了问题。正好最近几年苹果在界面、数据流和存储上都做了很大的功能加强。于是我打算将以前 objc、rac和 FMDB 替换成 SwiftUI 和 CoreData 技术,同时补上以前缺少的一些功能,比如添加管理feed,不同设备同步订阅 feed、文章已读状态和收藏信息等功能。

先说下怎么订阅 RSS。

如上图所示先通过链接获取待解析的数据,以及 mimeType,通过 mimeType 看里面是否包含如下描述:

application/atom+xmlapplication/rss+xmlapplication/jsonapplication/feed+jsontext/xmlapplication/xml

包含的话就可以判断是 RSS。

如果不是的话就需要手动从网页里获取 RSS 的链接,方法如下:

mime.contains("text/html")SwiftSoup.parse(homepageHTML)htmlDom.select("link[rel=alternate]")

其中 SwiftSoup 是一个专门用来将 HTML 解析成 DOM 对象的库。一般 RSS 的链接会在属性键值是 rel 和 alternate 的 Link 这个标签里。但是很多网站并没有遵循这个规范,那么就需要在链接后直接通过添加以下文件名来查找哪个是它的 RSS 链接:

["feed.xml","rss.xml","atom.xml","feed","feed.rss","rss","index.xml"]

找到了 RSS 的链接就可以获取到它的数据,接下来就是对数据的处理,根据 RSS 的规范,RSS 的数据主要是以下三种。

对应的结构体如下:

RSS 的图标的获取方式有两种

对处理好的数据需要进行本地的存储,目前不管是 Apple 还是三方库主要都是基于 SQLite 的封装。估计是因为 SQLite 开销小,支持大多数 SQL 92 标准语法,采用标准的 ANSI-C 代码,很容易在多个平台运行,同时 SQLite 还支持所有 SQL 用来保障数据安全和完整性的事务属性,比如原子性、一致性、隔离性和持久性。以下是 iOS 上一些主要基于 SQLite 封装库:

我选择的是 Core Data,首先是 Core Data 的 API 很强,将复杂数据建模和操作的 SQL 语句都做成了可视化和对象模式操作。多个数据对象之间的关联关系也做了很多自动处理。Core Data 还使用了惰性加载的方式,只有在需要时才从存储区域获取数据,以节省内存,提高执行效率。

Core Data 的使用需要对数据库进行设置。

在读取实体存储时可以设置 Core Spotlight 以及进行一些调试测试工作。

Core Data 对数据的增删改和检索操作都是在 NSManagerObjectContext 中完成的。

如果要支持 CloudKit,NSManagerObjectContext 初始化时需要在合并策略做一些设置。context 的数据操作都是基于对象操作的方式,比如增加一个 feed 就是在 context 中创建一个 feed 的对象,然后对其字段对应的属性进行设置即可。

删除就是用 context 的 delete 方法将对要删除数据对应的对象进行删除即可。

修改就是对读取的对象进行设置。

检索有两种方式,一种是创建一个 Controller,使用 lazy 来修饰检索检索结果,惰性加载以节省内存。数据变化会在 NSFetchedResultsController 代理里进行回调,在回调里可以更新 @Published 属性包装的属性以及时同步展示更新的数据。

另一种检索方式是使用 @FetchRequest 属性包装,写法更加简洁。

下面是 RSS 数据操作对应的代码。

添加 Feed 的代码

let newFeed = WebFeedMO(context: stack.context)newFeed.id = UUID()newFeed.createAt = Date.nownewFeed.homePageURL = inputURLstack.save()await handleAFeed(webFeed: newFeed) // 文章

删除 Feed

for a in webFeed.allElements {    stack.context.delete(a)}stack.deleteWebFeed(webFeed)

检索 Feed 列表

let fetch = WebFeedMO.fetchRequest()let sortDescriptorUnreadCount = NSSortDescriptor(key: "unreadCount", ascending: false)let sortDescriptorCreateAt = NSSortDescriptor(key: "createAt", ascending: false)fetch.sortDescriptors = [sortDescriptorUnreadCount, sortDescriptorCreateAt]let controller = NSFetchedResultsController(fetchRequest: fetch, managedObjectContext: stack.context, sectionNameKeyPath: nil, cacheName: "webFeeds")controller.delegate = selftry? controller.performFetch()

Feed 里文章的列表检索

let fetch = ArticleMO.fetchRequest()let sortDescriptor = NSSortDescriptor(key: "datePublished", ascending: false)fetch.sortDescriptors = [sortDescriptor]let controller = NSFetchedResultsController(fetchRequest: fetch, managedObjectContext: stack.context, sectionNameKeyPath: nil, cacheName: "newArticles")controller.delegate = selftry? controller.performFetch()

标记已读

@Published var selectedArticle: ArticleMO? {    willSet(newValue) {        newValue?.read = true        selectedWebFeed?.countUnreadArticles()    }}

全部标记已读

let countElement = selectedWebFeed?.allElements.count ?? 0var index = 0for a in selectedWebFeed?.allElements ?? [] {    index += 1    if a.read == false {        a.read = true    }    if countElement > 1000 && index > 1000 && a.favourite == false {        stack.context.delete(a)    }}// 最后重置未读总数selectedWebFeed?.countUnreadArticles()

收藏状态的切换直接对布尔属性 favourite 执行 toggle 方法。

selectedArticle?.favourite.toggle()selectedArticle?.dateModified = Date.now

工具栏中的分享功能可以直接使用 SwiftUI 内置的 ShareLink 视图。Item 的 placement 对于不同平台的位置会有不同。

.toolbar {    ToolbarItemGroup(placement: .primaryAction) {        Menu {            Button { ... } label: {                Label("拷贝链接", systemImage: "doc.on.doc")            }            Divider()            ShareLink("分享", item: link)        } label: {            Image(systemName: "square.and.arrow.up")        }    }    ToolbarItemGroup(placement: .automatic) {        Button { ... } label: {            Label("收藏", systemImage: "star")        }        Button { ... } label: {            Label("浏览器", systemImage: "safari")        }    }} // end toolbar

由于网站提供的 RSS 是静态的,因此每次获取数据时需要进行和本地存储的数据进行比对去重。

Core Data 提供了一种通过简单配置约束就可以去重的方法。具体方法如上图所示。

但是如果要支持 iCloud 就没法使用唯一约束这个功能。因此只能回到老办法,手动比对。

为了提升大量数据添加的效率,可以使用 NSBatchInsertRequest。正常情况下,在使用 Core Data 进行大量数据插入时,应用程序需要为每个插入操作都创建上下文和执行请求。这样会导致上下文过度膨胀和查询操作的重复,并且会对内存和 CPU 带来负担。而 NSBatchInsertRequest 则能够通过批量插入的方式一次性将多条数据插入到 Core Data 中,并且执行速度要比逐条插入要快得多。NSBatchInsertRequest 实际上是在底层利用 SQLite 数据库的 INSERT INTO 语法来执行批量插入操作。这种方式通过一次性将数据提交给 SQLite,可以减少插入操作所需的检查、协调和锁定操作,从而提高插入操作的效率和性能。当使用 NSBatchInsertRequest 执行批量插入时,Core Data 会首先创建一个临时表,然后将待插入的数据全部插入到该临时表中。接着,Core Data 会使用关联操作将临时表中的数据一次性插入到实际的数据库表中,从而进一步提高了数据插入的效率。NSBatchInsertRequest 还提供了一些可用的参数设置选项,开发者可以根据具体的需求进行灵活配置。例如,通过设置 batchSize 参数,可以控制批量插入时每个批次所包含的最大行数,以避免内存的过度消耗;通过设置 propertiesToUpdate 参数,可以在批量插入后更新指定的属性值,从而避免对整个对象进行额外的查询和更新操作。

Core Data 里的数据可以通过 iCloud 实现多设备的同步,比如我在 macOS 上订阅、阅读和收藏的信息能够无缝切换到手机和 iPad 上。未来支持 iCloud 可以进行如下的设置:

支持 iCloud 也会有一些限制,对于我目前来说最大限制就是不支持唯一约束,另外数据表结构更改后老版本的兼容也是需要注意的,这是由于 iCloud 是云端数据统一传输,并不会兼容多版本。

通过以下方法可以让兼容合并更安全。

应用支持 iCloud 后会有 cloudd 这个后台进程对 iCloud 服务的同步和管理,定期检查 iCloud 上数据是否需要同步到本地设备,或者本地数据是否需要传到 iCloud。 apsd 进程会将数据的更新以通知的方式推送到其他设备,dasd 进程会对 iCloud 的数据进行处理然后交给应用进程。对这个流程的调试就是基于上面提到的这四个进程进行日志记录。

另外 Core Data 还支持一些调试参数,除了 iCloud 还可以支持多线程、SQL、合并等信息的日志打印。

为了节省 iCloud 空间大小,对于文章内容这样数据量大的数据就不用支持 iCloud 了,方法是如下:

另外,Core Data 里的数据还能够很容易的支持 spotlight 索引,方便在应用外能够被检索。

界面使用的是 NavigationSplitView。代码如下:

struct HomeThreeColumnView: View {    @EnvironmentObject var webFeedController: WebFeedController    var body: some View {        NavigationSplitView {            SidebarView() // 左侧频道列表        } content: {            AWebFeedArticlesView() // 文章列表        } detail: {            ArticleWebView() // 文章内容        }    } // end body}

NavigationSplitView 可以同时显示主视图和辅助视图。实现了 iOS 系统中常见的 iPad 多窗口布局模式,允许用户同时操作两个视图,提高了应用程序的多任务处理能力和用户体验。NavigationSplitView 提供了一组简洁易用的 API,开发者可以通过少量的代码实现大部分常见的多窗口布局需求。例如,只需要设置主视图和辅助视图的内容即可快速创建一个 NavigationSplitView,而无需手动管理视图控制器的层次结构。NavigationSplitView 还支持自定义视图拆分行为、边缘滑动手势等功能。

数据处理,包括 Core Data 的初始化配置和增删改和检索等我都放在了 Controller 里,Controller 的关键代码如下:

final class WebFeedController: NSObject ,ObservableObject {    @Published var selectedWebFeed: WebFeedMO?    @Published var selectedArticle: ArticleMO?        @Published private(set) var webFeeds: [WebFeedMO] = []    @Published private(set) var newArticles: [ArticleMO] = [] // 最新文章    @Published private(set) var favoriteArticles: [ArticleMO] = [] // 收藏的文章    var stack: NRCDStack        init(stack: NRCDStack) {        ...        webFeeds = fetchedResults.fetchedObjects ?? []        newArticles = fetchedNewArticlesResults.fetchedObjects ?? []        favoriteArticles = fetchFavoriteArticlesResults.fetchedObjects ?? []    }        // 获取所有 feed 源    lazy var fetchedResults: NSFetchedResultsController<WebFeedMO> = { ... }()    // 获取最新 article    lazy var fetchedNewArticlesResults: NSFetchedResultsController<ArticleMO> = { ... }()    // 获取收集 article    lazy var fetchFavoriteArticlesResults: NSFetchedResultsController<ArticleMO> = { ... }()}// MARK: - NSFetchedResultsControllerDelegate// 跟踪变化,在回调中处理。extension WebFeedController: NSFetchedResultsControllerDelegate {    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {        webFeeds = fetchedResults.fetchedObjects ?? []        newArticles = fetchedNewArticlesResults.fetchedObjects ?? []        favoriteArticles = fetchFavoriteArticlesResults.fetchedObjects ?? []    }}// MARK: - 数据 CRUD 操作extension WebFeedController {    // 更新全部 Feed    func updateAllFeeds() async { ... }        // MARK: - Feed 的操作    // 添加 Feed    @discardableResult    func createFeed(inputURL: String, nameForDisplay: String = "") -> WebFeedMO { ... }    // 删除操作,删掉一个 Feed    func deleteWebFeed(_ webFeed: WebFeedMO) {        stack.deleteWebFeed(webFeed)    }        // 更新    func updateFeedByModel(for webFeed: WebFeedMO, model: FeedModel) { ... }        // MARK: - 文章的操作    // 收藏的文章    func fetchFavoriteArticles() {        favoriteArticles = fetchFavoriteArticlesResults.fetchedObjects ?? []    }        // 最新文章    func fetchNewArticles() {        newArticles = fetchedNewArticlesResults.fetchedObjects ?? []    }        // 收藏    func favoriteArticle() {        selectedArticle?.favourite.toggle()        selectedArticle?.dateModified = Date.now    }        // 清空所选 feed 下所有文章    func deleteAll() { ... }        // 标记全部已读    func markAllAsRead() { ... }        // 新增文章    func createArticleByModel(for webFeed: WebFeedMO, model: ArticleModel) async { ... } }

应用最终效果如下图:

参加了少数派和 SwiftGG 举办的《和我们一起畅想 Apple 的 AR 与 VR》直播活动

作者 戴铭
2022年10月24日 15:47

上周,我参加了少数派和 SwiftGG 举办的《和我们一起畅想 Apple 的 AR 与 VR》直播活动,这里是视频回放。其中聊了些参加 Ask Apple 活动的一些感受和收获,还聊了些 Apple XR 相关内容。

Ask Apple

这次 Apple 的 Ask Apple 活动有中文集锦版块,我感觉很贴心,先前 Apple 在 WWDC 期间的 Digital Lounge 中都是英文,阅读起来时间成本很高,需要过滤很多信息。我觉得参加活动的开发者分为两种,一种是在工作中有问题想要解决的,他们也会用各种渠道去找解法,比如请教身边有经验的人,问其他公司的朋友,或者直接在官方 feedback 里提。另一种开发者是当前没有疑难杂症,只是想关注下其他人关注的问题,还有看看有没有自己平时没太注意到的技术是否能够用到自己的工作中。

在 WeatherKit 主题环节中,有开发者问是否有有完整的天气相关的 Symbol 可以用,苹果工程师会列出自己整理的天气 Symbol 名称集,真是非常贴心了。

我还从天气主题的问答中,了解到天气预报还有分钟级的,不过目前可以用的国家只有美国、英国和爱尔兰。大部分国家会有恶劣天气事件可以用。天气主题中,大家很关注的是天气请求配额的问题,毕竟用多了是要自己掏腰包的嘛。了解到的情况是天气请求配额并不是简单的按请求次数来的,服务端会缓存的数据请求,相同的请求在缓存时间内请求多次也只算一次,数据更新了才会算新增加一次。

在 SF Symbols 环节有个有趣的情况,当时 Apple 工程师问大家平时自制 Symbol 一般用啥工具,举例是 Illustrator、Sketch、Affinity Designer,却没提最近最火的 Figma,Apple 提供的素材也没有 Figma 格式的,于是大家都回答 Figma,气氛略显尴尬,当然不排除砸场子的可能。我想大家只是希望 Apple 能够了解到 Figma 阵营已经很壮大了。

在 Widget 主题环节,开发者都提到了widget刷新频率问题,其实是有官方文档《Keeping a Widget Up To Date》专门阐述这个问题的, Apple 工程师也找出来发到了 Slack 中。

还有个是 SwiftUI 这次新推出的共享面板支持 ShareLink,我看还有开发者不知道,可能是他没来得及看 WWDC Session。其实这样没来得及看或者没时间看 Session 的人应该还有很多,正在解决手头问题或者开发新功能,碰到了具体问题就来问的。Ask Apple 这样的活动就可以解决他们的燃眉之急。

对于很多人关注用户体验的人,很关注后台任务怎样更新资源策略,Apple 工程师回答的很详细,还贴出了 一个官方 demo,我正好也全面了解了下。另外今年 Apple 推出的 Background Asset 可以让系统统筹进行资源更新,这个更新过程可以发生在 App 未启动状态。

fatbobman 整理了 Ask Apple 活动中关于 CoreData 的内容,。先前关注他博客里提到他开发的 App 用的是 Core Data 和 CloudKit,fatbobman 自己也在 Ask Apple 中问了相关问题,看得出他的收获很大。

XR

直播活动后半场环节是围绕 Apple 可能推出 XR 设备的讨论。

记得19年有篇报道称苹果内部有人透露出他们会在22年先推出结合AR和VR的头盔。23年再出轻便的 XR 眼镜,代号是 N421,官方回应的是硬件可能会不同,也可能不会推出。透露消息的人称自己是在 Apple 的千人大会上了解到的,参会人都是参与 XR 的项目组成员。

目前来看22年都快要结束了,还没有新 XR 消息。根据这几年苹果为XR做的技术铺垫,我觉得很有可能是一个轻便的很时尚的 AR 眼镜,不会像赛亚人那种很科幻的样子。

关于什么是 AR 什么是 VR,我觉得像环球影城里的哈利波特城堡还有迪斯尼加勒比海盗那种,在移动中结合了真实场景和虚幻场景的就是 AR。坐着不动体验比拟真实场景的虚幻场景,比如迪士尼的飞跃地平线就是VR。最终带来的愉悦感其实是差不多的。

眼镜最担心的是常带的电量问题。

我觉得 Apple 的这款常态是待机模式。待机模式会常显,类似 apple watch 和 iphone14 的那种常显,这样可以做到非常省电,内容类似 lock screen,可以自定义时间组件和下面三方 app 提供的 widget 和 live active。这样就在最省电的情况下依然可以看到自己关注的信息。

第二种是激活状态,这个状态下会开启摄像头,完成类似 iPhone 和 iPad 上的 AR 功能。AR Quick look、扫描物品、WorldMap、人物跟踪、4K、Location Anchor 这些 ARKit 的功能和应用都可以使用。

最后还可能会有投屏模式,可以将手机、电脑甚至是 Watch 上的内容投到眼镜上。使用通用控制功能,可以让 Apple Watch、iPhone、iPad 触摸屏、鼠标和键盘都可以进行眼镜的输入控制。

通过苹果申请的专利来看,他们使用的是无线充电模式,到时候可能会类似 airpod 那样的充电盒子的方式,比如可充电的眼镜盒。另外有个专利显示苹果设备是可以看穿到外部的,很有可能会是外带的轻便眼镜,而不会是头盔。因为 VR 头盔可以活动范围太小,不健康,不符合苹果提倡那种 Apple watch 运动模式的健康生活方式。

那什么 Apple 的技术会用到眼镜上呢?

ARKit 以及基于 ARKit 的 RoomPlan 和 Live Text 很明显就是给眼镜用的,RoomPlan 解决的是眼镜应用中虚拟物品摆放的问题,Live Text 可以解决现实世界中信息的识别问题。

ARKit 目前用的最多的应该就是 AR Quick Look 这个技术,它可以很方便的将虚拟物品摆到现实中。网页结合很简单,在 a 标签加上rel=ar 属性,然后href里添加模型USDZ文件连接就行了。

ARKit4 的地理位置跟踪,可以在坐标点上放置虚拟元素,当别人打开 AR 应用走到放置的区域时,就可以加载出预先放置的虚拟元素。

新增 Motion Capture 2D 和 3D 骨骼检测,还有耳骨检测,可能是想检测对方有没有在看你。

交互界面上,SwiftUI 和 Stage Manager 肯定会用于眼镜的 UI 和 UX 设计中。SwiftUI 描述界面和交互设计是再合适不过的了。Stage Manager 可以将空间里大量的复杂交互应用进行分组。

对于镜片显示的技术来说,苹果收购了多家研发 micro LED 的公司,micro LED 像素间距的上限更小,比 LCD 和 mini LED 都要小得多,上限高,就能有更大的分辨率的可能,更小的屏幕能够做到更高的分辨率。micro LED 使用的是非有机材料,相比较于 OLED 寿命要长,有着和 OLED 一样的高色彩对比度,还有更高的亮度尼特上限。micro LED 比过渡型的 mini LED 要省电。micro LED 的缺点是成本高,这个是目前在 micro LED 在攻克的难题。

Apple 的 Pro Display XDR 和最新的 iPad Pro MacBook Pro,用的是过渡到 micro LED 的 mini LED 屏幕,Pro Display XDR 亮度能够到1600尼特,并且使用寿命和节能都比 OLED 好,这点可以看出 Apple 未来的方向会体验更好的 micro LED。

从苹果收购 Beates 后,苹果加强了耳机硬件实力,空间音频可以提高虚拟世界元件的真实感,比如头顶有只虚拟的鸟,鸟叫声也是从上面传来,这样的感觉是不是很真实。

现在市场上已经推出了一些眼镜。微软的 VR + AR 的大头盔 Hololens 面对的不是普通用户而是专业人,比如建筑行业通过头盔可以看到现在建筑的情况,测量和检测,辅助工程,提高效率,保障安全。Hololens 售价是3500美金。Facebook 的 Oculus VR头显着重元宇宙,VR 主要是在室内场景使用,但室内里可以娱乐的东西太多了,竞争必然会很激烈。轻便的 AR 在户外的设备竞争会少些,目前用的最多的也就是听音频和看手表的设备,操作也比较麻烦,室外会有很多等待或者枯燥的时间待开发。

Snapchat 做的眼镜是为开发AR滤镜的人用的。Google 今年推出一个用于翻译垂直领域的眼镜,感觉应该是上次 Google Glass 没做成,吃一亏长一智,这次打算先精准定位下翻译这个市场。

开发者最关心的应该是如果 Apple 眼镜时代到来了,需要做哪些准备,储备什么技术。

我认为学好 ARKit 和 SwiftUI 是最有用的。还有这次 Ask Apple 活动里的那些技术主题,应该也是个信号,可以着重学习。

偏硬件的主题有增强现实RoomPlan、照片与相机、能耗与性能。偏界面交互的主题有设计、SwiftUIWeatherKitLive ActivitiesLive Text机器学习、隐私、Focus 等。

Apple 眼镜时代带来了更多机会。下面是我比较感兴趣的会带来的变化。

首先是数字收藏品,这对于数字艺术创作者来说有了更多机会。数字雕塑、模型、挂画和时钟等会更多的出现在房间、办公室甚至室外。

对艺术创作者体验来说,可能更接近以前雕塑家和壁画画家,只是减少了现场的清理工作,失误了也不用重头再来,简单的做个撤回就好了。因此创作者可以更好地专注在创作本身。

绘画素材的收集,以前可能更多的是速写或者拍照,现在用眼镜就能很方便的把整个环境都扫描下来,回到工作室可以慢慢的在各个角度来观察素材。

使用 LLVM 分享的幻灯片

作者 戴铭
2022年7月8日 13:01

我在快手中学出品,T沙龙主办的活动做了场直播分享。录播地址 T Chat | 戴铭:我在快手做移动端 Part 1:使用 LLVMT Chat | 戴铭:我在快手做移动端 Part 2:戴老师的斜杠人生

讲的内容我先前在清华大学和美团讲过。清华大学王继良教授给他的本科生开了门移动开发的课程,并邀请我给他们讲了一堂课。记得在18年 @Swift 大会我分享完了,现场有人开玩笑说要是以前大学老师能这么讲编译就好了,没想到我还实现了一次这个玩笑。

美团是他们 TC 委员会邀请的。当天小青还到了现场,完后他还给我推荐了南京大学《软件分析》课程,因为他觉得这门课是静态分析最好的入门课程,他说静态分析有个重要的点 sound/soundness,要保证分析的结果是可靠的/safe 的,我们说代码都不能删是无用结论,说都能删是错误结论。在这个过程寻找最合适的答案,不过考虑到运行的时候,那就是理论上 unsound 了(毕竟输入的参数不能证明是完整的)。小青说这个和我在分享开头说的差不多(那天时间比较充裕,我说的稍多了些)。小青最近也写了篇《超棒的课程推荐:南大《软件分析》(附我的完整学习路线)》,可以观摩学习学习。

这次 Apple 教育团队负责人还拉了高校同学来看,快手客户端校招简历投递信箱是 cuijia03@kuaishou.com 。下面是这次分享内容的幻灯片:

幻灯片原图我也上传了网盘,链接: https://pan.baidu.com/s/1Bip4mK5_B4cofnCAj7gpwg?pwd=4s7m 提取码: 4s7m

示例代码可参看文章《使用 LLVM》

昨天有个推荐资料的问题,我还有个要补充下,每年老司机技术周刊在小专栏出品的 WWDC 内参,尤其是今年《WWDC22 内参》 ,用浙剑跟我说的“今年我们做的很用心,每篇文章都保证了至少两个审核来保证质量。一个审核从专业性角度看内容是否正确,另外一位审核从读者视角看只是是否正确引导。目前发布的每一篇都质量非常高,大部分内容都已经超越视频原本的内容了。”

买买买吧。

❌
❌