背景

OpenCV 官方提供 Framework 下载:https://opencv.org/releases/
但是官方给的这个 Framework 只能在真机运行,如果想在模拟器上运行,就得 Github 上查找,或者手动编译。但是 Github 上面找的很多不一定符合我们的需求,也不好维护,如果作者不及时更新,我们就很难升级上去。所以,还是觉得自己动手。

编译

  1. 首先从 Github 下载源码:https://github.com/opencv/opencv

  2. 系统及环境要求

    • MacOS 10.15 or later
    • Python 3.6 or later
    • CMake 3.18.5/3.19.0 or later (make sure the cmake command is available on your PATH)
    • Xcode 12.2 or later (and its command line tools)
  3. 执行脚本

源码中已经提供了脚本,我们只需要对应执行即可,脚本在opencv/platforms/apple/build_xcframework.py路径下。

直接按照官方提供的脚本执行示例(官方文档),大概率是不行的,反正我在M2上一直没有编成功。

经过一番折腾,修改脚本执行时的相关参数,最终正常编译出 Xcframework。如下:

1
python3 opencv/platforms/apple/build_xcframework.py --out ./build_xcframework --contrib opencv_contrib --framework_name=opencv2 --build_only_specified_archs --iphoneos_archs "arm64" --iphonesimulator_archs "arm64,x86_64" --iphoneos_deployment_target "12.0" --macos_archs "arm64,x86_64" --catalyst_archs "arm64,x86_64" --without objc

注意

1)上述执行完,会导出苹果各个平台的 opencv 库,然后合并成一个 Xcframework,如果你只需要iOS平台的,可以调整下相关的参数,比如去掉 --macos_archs "arm64,x86_64" --catalyst_archs "arm64,x86_64"
2)另外还有一点非常重要的,也是我多次失败后发现的,就是如果你的网络访问 Github 存在问题的话,你最好科学上网,否则一些资源拉不下在也会造成编译失败。

在给老项目添加 .gitignore 文件时,如果你已经有文件被跟踪并提交到了仓库,那么这些文件将不会被 .gitignore 文件自动忽略。为了从 Git 中移除这些文件并使 .gitignore 文件生效,需要运行以下命令:

1
2
3
git rm -r --cached .
git add .
git commit -m "Remove tracked files"

已经有几年没有熬夜看 WWDC 了,这次为了见证苹果 XR 产品的发布,熬夜看完了整场发布会。发布会上的东西不少,几款硬件产品发布、几个系统更新等等。但是印象深刻的还是 Vision Pro。苹果终于发布了这个传闻已久的产品,也是我非常期待的一款产品。后面也打算深入学习下相关的 Session,为后续的开发做准备。

相信大家目前都有在开发或者维护的 iPhoneiPad 产品。那么针对这些已经存在产品如何能够很好的支持 visionOS 呢?看了官方的相关资料,然后整理如下:

一、学习 visionOS

首先当然是学习 visionOS 相关的技能了。WWDC23 准备了46个 session 用于介绍 visionOS 一定会帮助你很好的学习相关内容。利用苹果提供的开发工具进行很好的开发工作,包括 XcodeReality Composer Pro 以及其他工具。

二、最佳实践

虽然,UIKitSwiftUI 都可以支持 visionOS 开发,但是官方还是更推荐使用 SwiftUI 进行开发,并且 SwiftUIStack 可以支持深度的设置,这样在 visonOS 中可以更好的呈现效果。

(1)使用 Scenes

支持 Scenes ,这个是 iOS 13 推出的。要适配 visonOS 的话,就必须使用 Scenes,那还是说什么呢,赶快适配起来。

(2)使用 flexible 布局

使用 flexible 布局,而非绝对布局。这样会使你的应用能够更快的适应不同的操作系统。在发布应用前,应当充分测试各种平台和场景,以保证显示的准确性。并且确保布局变化时的动效高效和准确。

(3)采用标准的手势和输入

采用标准的手势和输入,这样系统能够帮我们适配好 visionOS。尽量减少自定义输入和手势识别器的使用,这可能需要你针对 visionOS 做相应的适配工作。

(4)支持多种与您的内容交互的方式

添加命令菜单支持,以便人们可以使用连接的键盘执行任务。为您的应用程序添加辅助功能支持,以便使用专门设备的人可以与您的内容交互。这些替代交互方式使人们即使在没有触摸或鼠标事件的情况下也能使用您的应用程序。

(5)在使用前检查硬件和功能的可用性

许多框架提供检查功能,以确定当前设备是否支持特定功能。例如,Core Location 可以让您确定设备上可用的位置服务。为了避免在将您的应用程序带到 visionOS 时出现错误,请始终确保当前设备上的功能可用后再使用它们。

(6)使您的应用程序具有包容性

具有可访问性和包容性的应用程序可以改变人们的生活,而 visionOS 提供了支持,以帮助您实现这一目标。国际化和本地化您的应用程序,以支持不同国家、地区和语言的更多人。审查您的应用程序的辅助功能支持,使所有能力的人更容易导航和访问信息。选择包容性的语言和术语,让每个人都感到受欢迎。

(7)避免特定于设备的代码和决策

如果您基于当前设备类型做出决策,您的应用程序可能无法在 visionOS 中正确运行。而不是基于设备形态做出决策,应该根据情境需要做出选择,并提供合理的默认行为。例如,使用系统提供的大小来布置视图,而不是预期的设备大小。

(8)更新调用已弃用API的代码

苹果定期弃用框架和 API,为现代替代品腾出空间。尽快转向这些现代替代品,以确保您的代码在 visionOS 中平稳运行。

三、探索RealityKit和3D内容

visionOS 构建的应用程序可以利用深度和无限画布来创建强大的沉浸式体验。发现您可以如何使用 RealityKit 和其他框架来准备在 visionOS 中构建应用程序。

(1)考虑何时向您的界面添加3D元素

您可以使用 3D 形状和网格增强 visionOS 中的窗口,或在人物周围单独显示这些项目。购物应用程序可以在其主窗口中的产品描述旁显示一个物品的 3D 模型。然后,它可以让人们将该物品从窗口中拖出,以查看它在客厅中的外观。考虑如何将深度融入您的应用程序窗口,并在何时添加体积或沉浸式场景。

(2)探索RealityKit

RealityKit 是苹果的 3D 渲染引擎,用于 visionOS,现在已与 SwiftUI 深度集成,帮助您构建锐利、响应和立体的界面。3D 内容可以绑定到 SwiftUI 视图和状态上,无缝地将 2D3D 视觉元素相结合。如果您的项目已经有3D内容,请考虑使用 RealityKit 来渲染该内容。

(3)了解更多关于MaterialX着色器的信息

MaterialX 是由领先的电影、视觉效果、娱乐和游戏公司支持的开放标准。了解如何使用 Reality Composer Pro 创建 MaterialX 中的表面和几何着色器。

(4)了解更多关于USDZ的信息

开始探索创建 USDZ 资产的工具和技术,以适用于您的 3D 内容。许多 3D 设计程序现在提供 USDZ 导入和导出,从本月晚些时候开始,您可以使用 Reality Composer Pro 为您的项目构建 USDZ 资产。

四、准备下一级别的音频和视频

开始考虑您需要对应用程序的音频和视频内容进行的更改,以支持 visionOS

(1)准备您的应用程序的音轨

即使在您当前的应用程序中,空间音频也为您的内容增加了另一个维度。该音频音轨在 visionOS 中的效果更佳,因为它采用空间音频作为默认播放选项。利用 PHASEAVFAudio 框架构建您的音频内容,并将其定位于您的场景中。

(2)开始为视频资产添加3D支持

通过以 3D 方式捕捉视频并准备资产以进行播放,为 visionOS 中的立体声观看做好准备。QuickTime 文件格式扩展允许您包括多个视频轨道以进行立体声播放,但在需要时可以回退到单眼播放。使用 AVFoundation 播放这些资产,以使播放自动适应当前设备。

(3)考虑替代触觉反馈的方案

如果您的当前应用程序使用触觉反馈提供反馈,请准备在 visionOS 中用声音替换这些触觉反馈。开始构建音频替代品库,或考虑替代类型的反馈。

五、扩展SharePlay体验

共享和协作是 visionOS 的核心部分。就像在 macOS 中一样,人们可以与 FaceTime 通话中的其他人共享任何应用程序窗口。而通过 SharePlay,下一代共享体验成为可能。
通过识别现在人们可以共享的活动,并使用 Group Activities 框架来实现它们,让您的应用程序为 visionOSSharePlay 做好准备。考虑您可能需要在设备之间传递哪些附加信息,以确保内容保持视觉同步。例如,您可能想要共享窗口的滚动位置以及窗口的内容。头脑风暴一下人们如何在面对面的环境中享受您的应用程序活动,并观看有关设计和构建空间 SharePlay 体验的 WWDC 会议。

六、优先考虑用户隐私

visionOS 非常重视隐私,并帮助人们保护个人信息。在一些必须访问敏感信息的情况下,应用程序必须得到设备佩戴者的授权。
无论您的应用程序是否涉及识别信息、财务数据、位置数据、联系人、内容、购买历史记录、搜索历史记录、诊断或其他信息,请确保遵循隐私的基本原则:

(1)为您收集的任何数据提供实际的好处

当有人同意向您提供个人信息时,请确保您的应用程序能够提供真正的价值作为回报。不要仅仅为了收集个人数据而收集它。将数据用于应用程序本身,并不要用于广告。

(2)清楚地解释您如何使用所收集的信息

透明地使用人们的数据。在请求隐私敏感技术的授权时,提供清晰的使用说明字符串,帮助人们理解为什么您需要这些数据。在应用商店上的应用程序页面上解释数据。

(3)保护您收集的数据

通过加密数据来保护它,以防止在您的应用程序不使用时被访问。如果必须本地存储数据,请在将其写入磁盘之前对其进行加密。

(4)遵循应用商店审核指南和所有适用的法律

有关更多信息,请查看应用商店上的应用程序隐私详情App privacy details on the App Store

有关其他隐私指南,请参阅访问私人数据Accessing private data

七、调整您的应用程序性能。

使性能成为您开发过程的重要部分。现在优化您的应用程序将使它更容易达到您在将其引入 visionOS 时的性能目标。定期收集性能指标,并使用这些指标来识别回归,以防止它们成为更大的问题。

(1)注意您的应用程序执行的工作量

确保您的应用程序执行的工作提供实际的好处。优化算法以最小化您的应用程序对 CPUGPU 资源的消耗。

(2)识别要测试的代码流和场景

构建 XCTest 案例以测量您的应用程序最重要的功能的性能。在不同的系统负载下运行这些测试,以确定您的应用程序的行为。

(3)为您当前的应用程序设置性能和电源目标

为您的应用程序设置激进的性能目标,并使用 Instruments 监控这些目标。确保您的应用程序不会在执行不必要或重复的任务时浪费电力。

(4)采用持续集成(CI)工作流程

设置您的构建在 Xcode Cloud 中运行。通过确保每个提交都维护您的代码库的质量和稳定性来采用CI思维方式。将与性能相关的测试作为测试套件的一部分运行。

八、运行您的iPad和iPhone应用程序

iPadOSiOS 中提供的相同框架也包含在 visionOS 中。这意味着您的 iPadiPhone 应用程序将在 Apple Vision Pro 上运行,每个应用程序都作为一个可扩展的 2D 窗口,具有其原始的外观和感觉。当应用商店可用时,兼容的应用程序将自动可用,无需采取任何措施,为人们在 visionOS 中提供更多选项。本月晚些时候,您可以使用模拟器和 TestFlightApple Vision Pro 上测试您的应用程序。如果您的应用程序不适合 Apple Vision Pro,您将能够在 App Store Connect 中编辑其可用性。

准备工作

  • 首先,你需要注册Firebase账号。
  • 然后,创建Firebase项目。
  • 生成app-ads.txt文件。

安装 Firebase CLI

1.安装

1
curl -sL https://firebase.tools | bash

2.登录并测试 Firebase CLI

1
firebase login

注意:firebase login 命令会打开与机器上的 localhost 关联的网页。如果您使用的是远程机器且您无权访问 localhost,请运行带有 –no-localhost 标志的命令。

初始化 Firebase 项目

切换到项目目录,然后执行以下命令:

1
firebase init

注意:firebase init 命令不会创建新目录。如果您要设置一个新应用,必须先创建一个目录,然后在该目录下运行 firebase init。

根据提示,选择:

1
2
Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub 
Action deploys

然后,可以选择创建已有的项目,或者新建。前面准备工作中我们已经创建项目,所以选择已有项目即可。

配置app-ads.txt

app-ads.txt文件放置到public目录下面即可。

部署

1
firebase deploy --only hosting

验证

在浏览器输入一下链接并打开。

1
https://PROJECT_ID.web.app/app-ads.txt

其中,PROJECT_ID是你的项目名

如果能够正确访问app-ads.txt中的内容,说明配置成功。

One More Thing

如果你有自己的网站,可以将主页重定向到你的网站,在firebase.json 文件中添加redirects 键值。

1
2
3
4
5
6
7
8
9
10
"hosting": {
...
"redirects": [
{
"source": "/",
"destination": "https://www.example.com",
"type": 301
}
]
}

如果没有的话,也可以自己写一个静态网页替换public目录下的index.html文件。

然后,执行部署命令完成部署即可。

Swift高阶函数

Swift高阶函数是指可以接受函数作为参数或返回函数的函数。高阶函数强调了函数的参数和返回值都可以是函数类型,这使得函数能够被视为可传递和可组合的值和运算。

Swift中有多种高阶函数,包括mapfilterreducesortflatMapcompactMap等。

1. Map

map是高阶函数中最常用的函数之一。它接受一个函数和一个数组,并返回一个新数组,其中每个元素都是原始数组中对应元素通过输入的函数转换后得到的结果。

以下是一个简单示例,将一个数组中的每个元素加倍:

1
2
3
4
let numbers = [1, 2, 3, 4, 5]
let doubled = numbers.map { $0 * 2 }
print(doubled)
// prints [2, 4, 6, 8, 10]

2. Filter

filter函数接受一个函数和一个数组,并返回一个新数组,其中只包含原始数组中符合条件的元素。这个函数可以用于过滤数组中不需要的元素。

以下是一个简单示例,从一个字符串数组中过滤出包含大写字母的字符串:

1
2
3
4
5
6
7
8
let words = ["apple", "banana", "CAT", "dog", "EGG"]
let capitalized = words.filter { word in
let pattern = ".*[A-Z]+.*"
let range = word.range(of: pattern, options: .regularExpression)
return range != nil
}
print(capitalized)
// prints ["CAT", "EGG"]

3. Reduce

reduce函数接受一个函数和一个数组,并返回一个单一的值,这个值是通过使用指定的函数对数组中的所有元素进行聚合得到的。这个函数可以用于计算数组中所有元素的总和或平均值等。

以下是一个简单示例,计算一个数组中所有元素的总和:

1
2
3
4
let numbers = [1, 2, 3, 4, 5]
let sum = numbers.reduce(0, { $0 + $1 })
print(sum)
// prints 15

上面的例子在reduce函数的初始化值参数中指定了一个初始值0,reduce函数对该数组中的所有元素进行了求和操作。reduce函数中的第二个参数是一个闭包,用于对数组中的每个元素进行操作。

4. Sorted

sorted函数接受一个数组,并返回一个新数组,其中所有元素都按照指定的顺序排列。这个函数可以用于对数组中的元素进行排序操作。

以下是一个简单示例,对一个包含人员信息的数组按照年龄从小到大排序:

1
2
3
4
5
6
let people = [("Alice", 20), ("Bob", 18), ("Charlie", 25), ("David", 22)]
let sortedPeople = people.sorted { (person1, person2) in
return person1.1 < person2.1
}
print(sortedPeople)
// prints [("Bob", 18), ("Alice", 20), ("David", 22), ("Charlie", 25)]

在这个例子中,我们传递了一个闭包作为sorted函数的参数,这个闭包用于比较元组中的第二个元素(年龄),成升序排列。

5. FlatMap

flatMap接受一个数组,然后将每个元素映射为新的数组,并将所有结果组合成一个单一的数组。与map不同,在使用flatMap时,元素可以映射到一个可选类型的数组,从而更加灵活。

以下是一个简单示例,将一个含有字符串的数组转换成一个包含所有单词的数组:

1
2
3
4
let phrases = ["hello world", "goodbye cruel world"]
let words = phrases.flatMap { $0.split(separator: " ") }
print(words)
// prints ["hello", "world", "goodbye", "cruel", "world"]

在这个例子中,首先使用split函数将字符串分割成单词数组,然后使用flatMap将多个数组合并为一个数组。

6. CompactMap

compactMap通过一个闭包参数来转化数组中的每个元素,并返回一个新的数组,其中nil值会被过滤掉。这个闭包必须返回一个可选类型值,当返回值为nil时,元素将被过滤掉。

例如:

1
2
3
4
5
6
7
8
9
let items = [1, 2, 3, 4, 5]

let mappedItems = items.map { $0 * 2 }
print(mappedItems)
// prints [2, 4, 6, 8, 10]

let compactMappedItems = items.compactMap { $0 % 2 == 0 ? $0 : nil }
print(compactMappedItems)
// prints [2, 4]

在上面的例子中,我们将数组中的元素乘以 2 进行了映射,产生了一个新的数组。然后我们使用compactMap过滤掉了奇数元素,只剩下了偶数元素 2 和 4。

One more Thing

Swift高阶函数的另一个强大之处在于它们可以链接在一起形成一个管道,这被称为函数链或函数式编程范式。使用函数链,我们可以通过将多个函数应用于同一个数组来生成一个复杂的、自定义的操作序列。

以下是一个简单示例,将一个字符串数组中的大写字母转换为小写字母,并按字母顺序排序:

1
2
3
4
5
6
7
let words = ["AbC", "deF", "ghi", "JKL"]
let result = words
.map { $0.lowercased() }
.filter { $0.count <= 3 }
.sorted()
print(result)
// prints ["abc", "def", "ghi"]

在这个例子中,我们调用了串联式的mapfiltersorted函数,它们在一起形成了一个管道,每个函数都返回一个新的数组,以形成复合操作。

通过函数链,我们可以创建与原始数据完全不同的结果,并且所需的代码量非常少。使用高阶函数以及函数链编程方式,可以使代码更加可读性强,更加易于维护和测试。

总之,高阶函数是Swift语言中非常强大和灵活的工具,可以大大简化代码,并提高代码的可读性和可维护性。掌握这些高阶函数,可以让我们更快地构建高效、简洁的代码。

本文主要针对之前写的文章《iOS模块化中的通用图片访问器》的方案改进。

痛点

项目组件化,壳工程后,我们对于资源的使用也做了规范。每个模块的图片资源使用xcassets进行管理。正常访问不同Bundle中的资源需要使用以下方法:

1
2
3
4
open class UIImage : NSObject, NSSecureCoding {
@available(iOS 8.0, *)
public init?(named name: String, in bundle: Bundle?, compatibleWith traitCollection: UITraitCollection?)
}

从其他Bundle访问图片资源,至少要传两个参数,一个name,一个bundle

在之前的文章中介绍的使用方式如下(详细介绍请查看原文《iOS模块化中的通用图片访问器》):

1
2
3
4
5
// 从 ModuleName.bundle 中获取图片
MyImage[ModuleName.self, "image name"]

// 或者,从 main bundle 中获取图片
MyImage["image name"]

这种方式,通过传入一个模块命名空间,来获取对应的Bundle。这种使用方式并不优雅。

寻找解题方法

有了上面这个问题,于是我开始查找可以自动获取模块名的方法。功夫不负有心人,最终找到一个解决方案。

就是使用#fileID这个表达式,代替我们手动传递模块名。

literal Type Value
#fileID String The name of the file and module in which it appears.

通过#fileID可以得到类似下面这串字符串:

1
MyModule/some/disambiguation/MyFile.swift.

将字符串通过/分割后,第一个元素就是模块名。

解决痛点

结合下标语法和#fileID表达式,可以对代码做如下改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
public extension UIImage {

static subscript (named: String, fileID: String = #fileID) -> UIImage? {
guard let moduleName = fileID.components(separatedBy: "/").first,
moduleName.isNonEmpty,
named.isNonEmpty
else {
return nil
}
// 加载图片资源具体实现
...
}
}

这样就可以在不同的模块中这样访问图片资源了:

1
UIImage["name"]

仅仅只需要填写一个图片名即可,使用起来非常方便。

总结

这个问题其实并不复杂,可以使用不同的方式去解决。很多开源代码里面,都有关于Bundle中资源的使用,也有一些封装实现。在我遇到这个问题时,不断尝试最优解,好在最后找到了一种比较满意的方式。当然,我了解的也很有限,如果大家有更好的实现方式,欢迎留言交流。


原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

1.安装 Rosetta 2 环境

1
sudo softwareupdate --install-rosetta --agree-to-license

2.下载Flutter SDK

flutter_macos_arm64_3.7.7-stable.zip

当然你也可以在这个文档中找到最新的安装包SDK,下载arm64 版本即可。

当然,你也可以使用克隆的方式下载SDK。具体,根据个人喜好。

3.添加环境变量

1
vim ~/.zshrc

~/.zshrc中添加以下内容:

1
2
3
PATH=[flutter 安装目录]/bin:$PATH
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

保存并退出编辑,然后刷新一下,让我们的配置立即生效:

1
source ~/.zshrc

4.Run flutter doctor

进入我们之前下载并解压好的flutter目录,然后执行:

1
flutter doctor

根据对应的提示操作即可。

按照上面这些步骤基本就能完成环境的搭建,其他一些操作,可以查看官方文档

从iOS 14和macOS 11开始,Vision增加了识别人体姿势的强大功能。他可以识别人体的19个关键点。如图所示:
人体关键点.png

实现

1.发起一个请求

使用Vision框架,通过VNDetectHumanBodyPoseRequest提供身体姿势检测功能。 下面代码演示了,如何从CGImage中检测身体关键点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Get the CGImage on which to perform requests.
guard let cgImage = UIImage(named: "bodypose")?.cgImage else { return }

// Create a new image-request handler.
let requestHandler = VNImageRequestHandler(cgImage: cgImage)

// Create a new request to recognize a human body pose.
let request = VNDetectHumanBodyPoseRequest(completionHandler: bodyPoseHandler)

do {
// Perform the body pose-detection request.
try requestHandler.perform([request])
} catch {
print("Unable to perform the request: \(error).")
}

2.处理结果

请求处理完之后,会调用完成的闭包,通过闭包,可以获取到检测结果和错误信息。 如果正常检测到人体关键点,将以VNHumanBodyPoseObservation数组的形式返回。VNHumanBodyPoseObservation中包含识别到的关键点和一个置信度分数,置信度越大,说明识别的精度越高。

1
2
3
4
5
6
7
8
9
func bodyPoseHandler(request: VNRequest, error: Error?) {
guard let observations =
request.results as? [VNHumanBodyPoseObservation] else {
return
}

// Process each observation to find the recognized body pose points.
observations.forEach { processObservation($0) }
}

3.获取关键点

可以通过VNHumanBodyPoseObservation.JointName来获取对应的关键点的坐标。注意,recognizedPoints(_:) 方法返回的点取值范围[0, 1],原点位于左下角,实际使用中需要进行转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func processObservation(_ observation: VNHumanBodyPoseObservation) {

// Retrieve all torso points.
guard let recognizedPoints =
try? observation.recognizedPoints(.torso) else { return }

// Torso joint names in a clockwise ordering.
let torsoJointNames: [VNHumanBodyPoseObservation.JointName] = [
.neck,
.rightShoulder,
.rightHip,
.root,
.leftHip,
.leftShoulder
]

// Retrieve the CGPoints containing the normalized X and Y coordinates.
let imagePoints: [CGPoint] = torsoJointNames.compactMap {
guard let point = recognizedPoints[$0], point.confidence > 0 else { return nil }

// Translate the point from normalized-coordinates to image coordinates.
return VNImagePointForNormalizedPoint(point.location,
Int(imageSize.width),
Int(imageSize.height))
}

// Draw the points onscreen.
draw(points: imagePoints)
}

拓展

除了使用VisionVNDetectHumanBodyPoseRequest来做人体姿势识别之外,还可以使用CoreML来实现。官方示例:Detecting Human Body Poses in an Image ,此示例可以运行在iOS 13及以上版本。

背景

历史原因加上功能的不断迭代,造成直播间代码越来越难以维护,前期架构也没有很好设计,造成各种耦合,不管是业务上的耦合,还是UI上的耦合。加上遗留了很多适配上的问题,以及交互问题,这块的代码成了一块比较难啃的硬骨头,重构这块代码已刻不容缓。

重构思路

  1. 引入多代理实现(使用过XMPP的同学应该会熟悉这个概率,笔者工作早期有使用过XMPP),用于统一事件的分发,各个业务都可以注册成为代理,用于将数据转发给各个关心的业务模块,做到各业务模块的解耦。
  2. 将页面划分成不同的Container,使用UIStatickView来做流式布局。通过协议的方式,添加不同业务的视图,业务层自己维护视图的显示尺寸,以及是否显示,不需要管理布局间的相对关系。这样可以很好的将UI进行解耦,达到插拔式使用效果。
  3. UI联动解耦,比如键盘升起某些视图需要位移,某些需要隐藏,原来的处理方式是在一个地方注册监听,然后处理所有的视图逻辑。重构思路是,下放权利,各个业务模块自己注册通知,然后自己处理这块的逻辑。这个主要也是充分发挥了UIStatickView的特性,不然做起来会比较麻烦。
  4. 横竖屏切换,布局相应调整。主要也是利用UIStatickView 流式布局的线性方向,结合SizeClass,可以达到切换横屏后,布局方向的灵活调整。
  5. 业务层采用MVVM结构实现。

UI结构

以下只是部分结构
直播间UI架构.png

部分效果

由于涉及到一些隐私数据问题,这里只放很小的一部分效果。

横竖屏切换自适应:

点击图片查看动图

键盘联动效果,以及横竖屏切换:

点击图片查看动图

体验优化

本次重构,还做了很多交互体验上的优化。

1.添加动画

交互没有动画,就像一个人没有灵魂一样。但是,动效也不是越多越好,越复杂越好。常规动效即可,耐看。也可以理解为,在该有的地方有该有的动效,才是好的动效。
比如底部弹起的弹框,那么他就该从底部慢慢升起,而不是直接就盖在了页面上。这是用户已经见惯不怪的效果了,没有这个效果反而觉得很low。
再比如键盘升起的时候,页面内容需要跟着键盘的动效一起慢慢升起或者降落。
当然,还有很多弹框的动效,由于时间和资源有限,加上直播间的弹框较多,需要重新架构一下。所以,本次只做了部分修改。

2.直播间刷新优化

老版本在进入直播间,加载数据的时候加了很多loading,这个其实很影响视觉。所以,我在不影响功能的情况下,直接干掉了霸屏loading。

再一个,老版本从直播间切换到其他页面后,会再次刷新直播间数据,并且重新拉流。这样就会看到聊天列表闪屏、直播拉流断掉并重新启动等。这样一顿操作下来,体验真的有点难以忍受。这个细节问题,其实很好解决,从新回到直播间的时候,已经在拉流了就不需要再次启动拉流了,这样画面就不会断掉(视网络情况而定)。

聊天列表闪屏问题。为什么会闪屏?仔细看不难发现,造成闪屏的原因是:列表数据从有到无,再到有。找出这个问题就好解决了,解决方式就是在有数据的时候去刷新列表,不做清空列表的动作。事实上,聊天列表数据也不会有清空的情况。真的有这种情况,那就是数据库数据被清了。

3.样式升级

当你在一家公司接触了一波又一波UI设计师后,你会发现每个人的设计都有自己的风格。我觉得一个好的App,需要有一个整体的风格,不管是图标、圆角、配色、字体字号、插图、弹框、交互等等,都应该在一个规范里面去执行,需要一个宏观的把控。巧了,我在公司就主导推动过这件事,并且最终出了一套规范。在这次重构中发现的一些配色和字体问题,做了修改。比如邀请下单按钮背景色、价格颜色和字体、七天无理由颜色等等。

4.异形屏适配

屏幕适配,其实,也是UI重构的一部分。放在体验优化里面讲,是因为很多人不觉得这是一个 bug,仅仅是丑了点。它确实不是 bug,但真的看不过去呀。

关于如何准确适配异形屏,可以参考我之写的一篇文章 《iOS 如何更好的适配异形屏(刘海屏)》,掌握了这个技巧,可以很轻松的就解决这个问题。

5.聊天列表顶部渐隐效果

这个细节问题,也是不痛不痒。但是,别人家孩子有的东西,咱也不能落下。

6.统一转屏效果

很多地方都是直接在转屏的同时去切换下一个页面,这样不仅看起来怪怪的,同时也会产生布局的问题,特别的有两个操作都需要转屏时,出现同时调两次转屏造成一些意想不到的问题。
本次重构统,重构了转屏的方法,提供了转屏结束的回调。将需要转屏的操作,统一放在完成转屏的回调中去处理。这样既保持了统一性,又能避免出现布局错误。

7.页面联动效果

这个包括发布评论时键盘的升起和收起时,页面的联动效果。以及出价时,出价器显示,包括优惠券展示的联动效果等。

重构收益

经过重构,主要有以下收益:

  • 直播交互更加的丝滑,体验提升几个档次
    • 页面切换,拉流不再卡顿
    • 键盘交互、横竖屏更加丝滑
    • 交互动效更加合理
  • 结构更加清晰,扩展性强,解耦,易维护
    • 利于新业务的扩展,不再需要梳理大量的业务逻辑
    • 老业务的废除,都能够做到”干净”移除
  • 期间解决了很多细节问题和历史遗留bug,总数在100+
  • 上线后,运行稳定,几乎没产生因重构造成的bug
  • 最后,也是非常重要的一个收益,就是对于开发来说,这块硬骨头终于被拿下,后期维护更加友好,间接提升效率

总结和感悟

本次重构任务量很大,重构中也发现了很多历史遗留问题和细节问题。细节问题处理和自测占了大部分时间。很多个夜晚笔者都是自觉加班加点的自测和打磨细节。一方面是希望把这个事情做好,当然,最主要还是害怕出线上bug😂。

在开启这项工作的前,也有小伙伴劝我不要做这件事。一方面是比较麻烦,另一方面是几乎没有业务价值,再者就是如果出了严重的线上事故,你所做的这一切都将毁于一旦。但是,作为一个有追求的技术来说,在遇到这样的问题时,是没有退路的。而且,我也很享受问题被解决后的成就感,我相信很多人都会有这样的感受。

通过这次重构,不仅仅是从技术上去解决问题,更多的时候,我会从产品的角度去思考一些功能和交互的合理性,然后去改进它。


原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

前言

在我们平时日常开发中,经常会遇到各种样式的弹框。你是否也经常遇到呢?你是如何实现的?
本文介绍使用UIPresentationController,结合自定义转场动效,实现一个高度自定义的弹框,这也是苹果比较推荐的一种实现方式。

预备知识

开始之前,我们要了解下几个知识点:

  • UIPresentationController
  • UIViewControllerTransitioningDelegate
  • UIViewControllerAnimatedTransitioning

1、UIPresentationController是什么?官方文档中介绍如下:

An object that manages the transition animations and the presentation of view controllers onscreen.

简单来说,它可以管理转场动画和模态出来的窗口控制器。详细信息可以参考:UIPresentationController文档

2、UIViewControllerTransitioningDelegate定义了转场代理方法,可以指定PresentedDismissed动画,以及UIPresentationController

3、UIViewControllerAnimatedTransitioning就是转场动画协议,我们可以遵守该协议,实现转场动画。

实现

1、自定义UIPresentationController ,并实现相应方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
struct ZCXPopoup {}

extension ZCXPopoup {

class PresentationController: UIPresentationController {

override func presentationTransitionWillBegin() {
guard let containerView else { return }
dimmingView.frame = containerView.bounds
dimmingView.alpha = 0.0
containerView.insertSubview(dimmingView, at: 0)

// 背景蒙层淡入动画
presentedViewController.transitionCoordinator?.animate { _ in
self.dimmingView.alpha = 1.0
}
}

override func dismissalTransitionWillBegin() {
// 背景蒙层淡出动画,以及移除操作
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0.0
}, completion: { _ in
self.dimmingView.removeFromSuperview()
})
}

override var frameOfPresentedViewInContainerView: CGRect { UIScreen.main.bounds }

override func containerViewWillLayoutSubviews() {

guard let containerView else { return }
dimmingView.frame = containerView.bounds

guard let presentedView else { return }
presentedView.frame = frameOfPresentedViewInContainerView
}

// MARK: -

/// 背景蒙层
private lazy var dimmingView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
return view
}()
}
}

代码比较简单,主要的工作就是添加了一个背景蒙层,以及蒙层的动画交互处理,加上子视图尺寸的控制。

注:上面的ZCXPopoup结构体没有实际作用,仅仅是为了区分命名空间。

2、UIViewControllerAnimatedTransitioning实现类实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
extension ZCXPopoup {

class TransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {

private var isOpen: Bool = false

convenience init(isOpen: Bool = false) {
self.init()
self.isOpen = isOpen
}

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
transitionContext?.isAnimated == true ? 0.5 : 0
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

guard let fromView = transitionContext.viewController(forKey: .from)?.view else { return }
guard let toView = transitionContext.viewController(forKey: .to)?.view else { return }

if isOpen {
transitionContext.containerView.addSubview(toView)
toView.transform = .init(scaleX: 0.7, y: 0.7)
toView.alpha = 0
}

UIView.animate(
withDuration: transitionDuration(using: transitionContext),
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: 0.7,
options: []) {
if self.isOpen {
toView.transform = .identity
toView.alpha = 1
} else {
fromView.transform = .init(scaleX: 0.7, y: 0.7)
fromView.alpha = 0
}
} completion: { _ in
let wasCancelled = transitionContext.transitionWasCancelled
transitionContext.completeTransition(!wasCancelled)
}
}
}
}

这个实现类的内容也较简单,主要是设置转场动画时长,以及实现转场动画,转场动画分为进场(present)和出场(dismiss)动画。

3、UIViewControllerTransitioningDelegate实现类实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
extension ZCXPopoup {

class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {

func presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController
) -> UIPresentationController? {
PresentationController(presentedViewController: presented, presenting: presenting)
}

func animationController(
forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
TransitionAnimator(isOpen: true)
}

func animationController(
forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
TransitionAnimator(isOpen: false)
}
}
}

在该实现类中,实现代理方法,分别返回自定义的PresentationController TransitionAnimator 即可。

4、为控制器增加一个扩展,方便使用弹框交互

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
extension UIViewController {

/// 转场类型,方便后续扩展
@objc public enum TransitioningType: Int {
case none = 0
case popup = 1
}

/// 设置转场类型
@objc public var transitioningType: TransitioningType {
get { getAssociatedObject() as? TransitioningType ?? .none }
set {
if newValue == .popup {
transitioningDelegate = self.popupTransitioningDelegate
modalPresentationStyle = .custom
}
setAssociatedObject(newValue)
}
}

/// transitioningDelegate 实现类,需要被持有
private var popupTransitioningDelegate: ZCXPopoup.TransitioningDelegate {
lazyVarAssociatedObject { ZCXPopoup.TransitioningDelegate() }
}
}

到这里,一个轻量级的弹窗管理就封装好了。我们就可以给任意一个控制器加上这个交互。

自定义弹框

上面只是封装了弹框的交互,那么我们要怎么实现一个弹框呢?
很简单,具体来说就是,创建一个控制器,将其view设置成透明,然后在其中间加上弹框内容视图contentView。然后,设置控制器的transitioningType = .popup,使用present方式打开即可。

这里大家可能会问,为什么不直接修改控制器的preferredContentSize,而是弄了一个背景透明的全屏控制器。这个问题非常好,欢迎留言讨论。

设置转场类型和打开弹框:

1
2
3
4
5
6
@IBAction func showPopoup(_ sender: Any) {
let sb = UIStoryboard(name: "DemoViewController", bundle: nil)
guard let controller = sb.instantiateInitialViewController() else { return }
controller.transitioningType = .popup
present(controller, animated: true)
}

关闭弹框:

1
2
3
4
5
class DemoViewController: UIViewController {
@IBAction func dismiss(_ sender: Any) {
dismiss(animated: true)
}
}

Popup.gif

总结

上述方法,可以将弹框的交互独立封装出来,具体的业务弹框只需要实现好UI和交互事件,以及相应功能即可,弹框的打开和关闭,使用presentdismiss即可。
可以看到,弹框交互和业务可以完全解耦,这也是能做到弹框的高度可定制的核心。我们可以将这个交互沉淀到基础库,用来规范项目中弹框的统一交互。

源码

ZCXPopoup

参考

UIPresentationController
UIViewControllerAnimatedTransitioning
UIViewControllerTransitioningDelegate


原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

0%