肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

iOS每次升级后,苹果表表盘通知额度归零问题的处理

watchOS

我是苹果表的老用户了。有多老?初代苹果表可以预购的第一天,我就预定了一只42mm的不锈钢苹果表。除了2018年苹果表电池把屏幕拱开,维修返厂的那几天,我天天都戴着它。

我写的应用,有一个叫Stand Minus的,提交到了苹果商店后,被审核的工作人员认为功能太少给拒绝上架了。我觉得它现有功能“增一分则肥,减一分则瘦”,拒绝修改。所以这个应用我就自己这一个用户。今天我要说的就是和这个Stand Minus相关的内容。

感谢git的存在,我们可以知道,Stand Minus是我在2017年1月31日最初创建的。

stand_minus_initiation

Stand Minus有两种工作方式,根据当前表盘是否有Stand Minus的小部件来判断。

发现问题

根据苹果的文档,当iOSwatchOS发送消息的时候,可以使用WCSession,其中最好用的方法是transferCurrentComplicationUserInfo(_:)。这个方法,同时还能唤醒手表端扩展的后台。因为能唤醒后台,会导致额外的耗电,所以苹果规定每天唤醒次数最多只能有50次,超过就自动变成不能唤醒后台的transferCurrentComplicationUserInfo(_:)调用。

一天有24小时,50次限制约等于每小时2次。我们可以使用remainingComplicationUserInfoTransfers属性来获得剩余的次数。

Stand Minus设计就是每小时发送两次,一天50次肯定是用不光的。但在实际使用中,有时候表盘小部件并没能及时更新。通过手表端查看我发现,提示剩余次数为0。

zero_count_remain

这个问题不常见,但是有时也会出。经过多次观察,我最终发现,这个问题和iOS升级有关,每次iOS升级完成之后,剩余次数都会归零。解决的办法很简单,额外再重启一次手机,然后就好了。

尝试解决问题

时光荏苒,到了2019年,因为升级到iOS 13 beta之后,我一直使用的推送OneSignal失效了。不得不重新折腾起一直用得好好的Stand Minus。同时,我尝试测试究竟是什么原因导致了次数归零。

我将代码

remainCountsLabel.text = String(session.remainingComplicationUserInfoTransfers)

改成了

remainCountsLabel.text = {
    guard session.activationState == .activated else {
        return "Session状态不是.activated。"
    }
    
    guard session.isWatchAppInstalled else {
        return String("对应手表应用正在安装中……")
    }
    
    guard session.isComplicationEnabled else {
        return "错误:手表当前表盘未安装Stand-的小部件。"
    }
    
    return String(session.remainingComplicationUserInfoTransfers)
}()

然后等待iOS的更新再测试。我最终发现了,导致错误的原因是,虽然手表的表盘上有Stand Minus的小部件,但是session.isComplicationEnabled返回的是否。而对于当前表盘没有对应小部件的情况下,session.remainingComplicationUserInfoTransfers返回0。

no_complication_erro

找到了更确切的原因,就可以解决问题了。我找了不用重启iPhone的办法。先切换手表的表盘到另外的表盘,然后再切回来,然后再点击Stand Minus的表盘小部件,打开手表应用。

经过上面的步骤,再重新打开手机上的应用查看,就会显示正确的剩余次数了。

最终解决方案

每次iOS升级之后,都做一遍切表盘,切表盘,开应用的操作。不算麻烦,但是也挺讨厌的。有没有有什么更好的办法呢?

我用的苹果表初代,在2018年的watchOS 5就不能更新了,我曾经猜测苹果应该早就修复了这个问题。我今年卖了苹果表5,手表到手之后,我发现苹果表5还是有这个问题。苹果怎么回事?这都watchOS 6了啊。

我尝试绕过这个问题。观察代码,手机向手表发送消息的核心代码如下:

if session.activationState == .activated && session.isPaired && session.isComplicationEnabled {
    session.transferCurrentComplicationUserInfo(userInfo)
}

即当一切正常,且当前表盘存在对应小部件的时候,发送能够唤醒后台的消息。已知出错的是session.isComplicationEnabled,那么可否移除它,直接发送唤醒后台的消息呢?

阅读文档,苹果是这么说的:

Call this method when you have new data to send to your complication. Your WatchKit extension can use the data to replace or extend its current timeline entries.

This method can only be called while the session is active—that is, the activationState property is set to WCSessionActivationState.activated. Calling this method for an inactive or deactivated session is a programmer error.

原来,调用transferCurrentComplicationUserInfo(_:)方法,只要求WCSession为活跃就可以,其余的都是可以忽略的。

于是我删掉了session.isComplicationEnabled的检测。这相当于主动忽略了系统出错的部分。

no_complication_but_still_send

不过,因为不再检测当前表盘是否存在小部件,相对于Stand Minus的原本架构,在不使用表盘小部件的情况下,手表的额外耗电理论上会多一些。

其它

我这边能做的就是这些。API的错误,最终还是需要苹果来解决。

参考资料

脱离CocoaPods 1.8.0的trunk

今早升级pods的时候,看到1.8.0的CocoaPods正式版出了。于是升级了。之后pod install的时候,被强制安装了新的源trunk.

使用时,我发现,pod repo update的界面变了,不仅没有了之前的进度和速度,还变慢了。我搜索了一下trunk,都是说和个人建立自己的源有关,于是我迅速提交了一个故障报告。CocoaPods 1.8.0 added 'trunk' to my repo though I don't use 'trunk' #9190

很快收到了回复,说这个是1.8.0的新功能,采用cdn替换了原本的master。通俗说,就是用分布各地的服务器来替换了原本的GitHub的源。理论上讲,使用了cdn之后,如果你的附近有源服务器,就会加速你更新pods的速度。

但实际上我这里反而重回龟速了。

CocoaPods 1.8希望你这么做

删除掉原本的master

千万别这么做!

pod repo remove master

只要你这么做了,以后的操作就都是基于trunk的了。

如何脱离trunk

如果你已经有了trunk

pod repo remove trunk

如果你已经删掉了master,采用此命令恢复

git clone https://github.com/CocoaPods/Specs.git ~/.cocoapods/repos/master

最重要的一步

打开你每个项目的Podfile,在最顶部添加

source 'https://github.com/CocoaPods/Specs.git'

trunk的方式已经成为了新的默认。如果还想使用旧的方式,就必须每个Podfile顶部都指定源。

两个脚本

为已有的Podfile添加源

#!/bin/bash
echo -e "source 'https://github.com/CocoaPods/Specs.git'\n" | cat - Podfile > temp && mv temp Podfile

新建Podfile

#!/bin/bash
pod init
echo -e "source 'https://github.com/CocoaPods/Specs.git'\n" | cat - Podfile > temp && mv temp Podfile

此外

如果你对于自己的网络有信心,可以测试一下使用cdn的网速。满意的话,也可以按照官方的指导做,删掉master。这样的好处就是不用每个Podfile都需要指定源了。

在终端使用代理

如果你的代理包含socks5,可以在~/.bashrc文件中添加两行。

alias proxy='export all_proxy=socks5://127.0.0.1:1086'
alias unproxy='unset all_proxy'

这样就可以在终端中使用proxy开启代理,可以极大地加速终端下访问GitHub的速度。

参考资料

Ubuntu服务器Livepatch安装及问题解决

Ubuntu

Ubuntu在14.04以后,支持更新内核之后动态更新的技术。但是需要激活。步骤如下:

  1. https://auth.livepatch.canonical.com申请一个token。
  2. 然后安装命令操作。

问题

执行第二行命令的时候,遇到了错误。

2019/08/30 01:46:55 error executing enable: cannot enable machine: this machine ID is already enabled with a different key or is non-unique. Either "sudo canonical-livepatch disable" on the other machine, or regenerate a unique /etc/machine-id on this machine with "sudo rm /etc/machine-id /var/lib/dbus/machine-id && sudo systemd-machine-id-setup": {"error": "Conflicting machine-id"}

解决

rm -f /etc/machine-id
rm /var/lib/dbus/machine-id
dbus-uuidgen --ensure=/etc/machine-id
dbus-uuidgen --ensure

> 注意:上面的操作有风险,需要先备份你的系统。

然后再执行第二行的命令。

参考资料

开发者备份和恢复Realm数据库

RealmSwift

最近在研究带参数的捷径。因为是SiriKit相关,需要把Realm放在应用组里。这样就不能通过Xcode下载容器了。几天时间里,已经有了两次不小心删除掉数据库,而不得不通过备份恢复手机的经历。我开始研究一下如何导出和恢复数据。

思路

因为目的只是为了方便开发,而不是交给用户操作,过程简便,代码容易是重点。

首先想到的是通过长按某个按钮实现导出,然后出了问题就将导出的数据库放在应用里,应用启动时检测是否有数据,如果有,就替换,然后再正常启动。

实现

具体实现的时候,遇到了一个问题,分享出来的URL,并没有写成文件,而是写成了纯文本。

@objc private func exportRealm(_ sender:Any) {
    let sourceURL = Realm.Configuration.defaultConfiguration.fileURL!
    
    // copy realm to cache and rename it to current date and time
    let fm = FileManager.default
    let filename = { () -> String in
        let df = DateFormatter()
        df.dateFormat = "yyyy.MM.dd_HH.mm.ss"
        return "any_counter_2.backup.at.\(df.string(from: Date())).realm"
    }()
    let cacheFolderURL = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
    let destinationURL = URL(fileURLWithPath: filename, isDirectory: false, relativeTo: cacheFolderURL)
    
    do {
        try fm.copyItem(at: sourceURL, to: destinationURL)
    } catch {
        return
    }

    // export
    let activityViewController = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil)
    activityViewController.completionWithItemsHandler = { (_, isCompleted, _, activityError) -> Void in
        guard activityError == nil || (activityError! as NSError).code == 3072 else { // Error Domain=NSCocoaErrorDomain Code=3072 \"操作已被取消。\"
            fatalError("\(activityError!)")
        }

        // clear cache after success
        try? fm.removeItem(at: destinationURL)
    }
    present(activityViewController, animated: true, completion: nil)
}

这个问题花费了我不少时间去寻找解决方案。最后我突发奇想解决了。实际上,就是和URL有关。将12行

let destinationURL = URL(fileURLWithPath: filename, isDirectory: false, relativeTo: cacheFolderURL)

替换为

let destinationURL = URL(fileURLWithPath: cacheFolderURL.path + "/" + filename, isDirectory: false)

,问题解决。

类似的事情很早就遇到过,比如

import Foundation

let folder = "/foo"
let file = "bar"

let folderURL = URL(fileURLWithPath: folder, isDirectory: true)
let u1 = URL(fileURLWithPath: file, isDirectory: false, relativeTo: folderURL)
print(u1.path) // /foo/bar

let u2 = URL(fileURLWithPath: folder + "/" + file, isDirectory: false)
print(u2.path) // /foo/bar

print(u1 == u2) // false
print(u1.path == u2.path) // true

即两个path相同的URL,仅仅是因为构造方法的不同,它们就不相等。这其实是很不直观的,人们在没遇到之前,可能就会认为path相等的两个URL就是相等的,但是实际上系统认为不是。

这里也是这样,通过relativeTo构造的URL,iOS系统仅能将它存成一个纯文本。但是直接构造的URL就没有这个问题。

我虽然不认同,但是只能记住。

Realm的坑(四)

RealmSwift

之前的三个坑都是很久远的事情了。今天要填一个新坑。

我们知道,Realm.objects(_)返回的值是Results<T>,由于Realm自身的特性,Results<T>是很有用的,它是lazy动态的。因此,在使用Realm的时候,我们更喜欢使用Results<T>而不是Array

但是Results<T>有一个问题,就是它的排序功能十分有限。它最基本的排序只有一个函数,即sorted(byKeyPath:ascending:)。也就是说,Results<T>只能按照Realm对象的动态属性排序,而不能使用我们经常用到的closure排序。

分析

这就使得我们在有些时候,为了排序的方便,要么增加磁盘占用,添加额外的动态属性;要么,就只能使用Array类型。

值得一提的是,有时,如果是涉及到当前时间这一类的排序变量,即便我们使用额外的动态属性,也是不能排序的。因为这个值一直在变。因此就只能使用Array类型。

didSet不执行的问题

特别的,我们需要注意,在Realm的动态属性中,didSet是不会执行的,这应该是和Objective-C的运行时相关。因此,如果需要使用didSet,就需要改成其它的方式。

参考资料

相关

CGImageSource对照片自动旋转问题的解决

Image I/O

前两天用咕唧分享照片的时候,我发现分享出去的照片,与我查看时的角度不一致,被转了90°。这是一个需要解决的问题。

分析问题1

一开始,我以为是咕唧的隐私保护功能导致的。为了保护隐私,咕唧默认会移除照片中的GPS信息和Exif信息。此外,用户可以在设置中,选择分享这些信息。

我们知道,平常拍照片,你的手机可能是横着拍,也可能是竖着拍,偶尔可能倒过来拍。但是不管你拍照时,手机处于何种角度,系统在显示该照片的时候,都会正确的显示该照片为当时你在屏幕上见到的样子。因为系统会读取照片属性中的方向信息,从而知道你拍照时手机的状态,这样就能正确的显示。

我首先就是认为Exif信息被咕唧删除了,系统没有了参照,只能按照默认的方式处理,因而与实际的方式造成了偏差。

解决问题1

我创建了一个测试项目,然后将那张出问题的照片作为资源,做同样的缩小操作,之后查看照片属性。结果我发现其实方向Orientation属性并没有保存在Exif信息,而是在图片的属性中。也就是说,事实上,咕唧缩小的图片是包含这个信息的。之前的分析是错误的。

那么是什么原因导致了包含了正确方向信息的照片,实际显示的反而是错误的呢?

分析问题2

排除了一切不可能,剩下的就是唯一的可能。

我们知道,系统在读取照片的时候,会根据方向Orientation做正确的处理。也就是说,CGImageSourceCreateImageAtIndex(_:_:_:)获得的照片本身就是正确方向的。那么这个方向正确的照片。在通过CGImageDestination重新保存时,就应该改变Orientation值为默认值,而不是继续保存原始值。

解决问题2

如果上面的分析正确,思路就很简单了。保存之前,检查方向Orientation是否为默认值,如果不是,则修改为默认值。

注意:是只要CGImageSource加载了图片,就会造成这个问题。因此,不仅缩小照片有这个问题,仅仅是去除Exif或者GPS信息,都会存在这个问题,同样需要修改方向Orientation为默认值。

思考

我在想要不要把这个问题,作为bug报告给苹果。思考的结果是不要。因为如果苹果修复了这个问题,将新照片变成与原始照片的方向一致。那么之前有做过特殊处理的应用,照片的方向就又都会从正确变成错误。这相当于是API不延续了。

为了保证之前的兼容性,好多时候,只能遗留不正确的代码。这个就是API设计出问题的代价。

参考资料

UISplitViewController的几种模式

iOS

模型

  • UISplitViewController拥有两个视图控制器,在故事版中分别对应主视图控制器和细节视图控制器,在编码中对应为viewControllers属性,主视图控制器在前,细节视图控制器在后。
  • 开发者通过preferredDisplayMode推荐想要的模式,通过displayMode获得当前的模式。
  • 在iPhone中,横屏、和竖屏都是allVisible,此时可以通过UISplitViewControllerDelegatesplitViewController(_:collapseSecondary:onto:)方法来告诉系统默认是显示哪个视图控制器。
  • 在iPad中,横屏是allVisible,竖屏是primaryHidden。此时如果想要默认显示主视图控制器,需要在UISplitViewControllerDelegatesplitViewController(_:willChangeTo:)方法,设定preferredDisplayModeprimaryOverlay

UISplitViewController

  • displayMode
    • 这是一个只读属性。
    • 它代表的是UISplitViewController当前的具体模式。
  • preferredDisplayMode
    • 开发者只能通过displayMode了解到UISplitViewController当前的模式。如果开发者不满意这个模式,想要手动调节,就可以通过修改preferredDisplayMode来实现。
    • 值得注意的是,这个是开发者的推荐,并不是完全的设定,系统仅会在条件允许的情况下,优先使用这个推荐。如果系统认为可视面积过小,就会忽略这个推荐,而使用系统认为最适合的模式。

UISplitViewController.DisplayMode(按照rawValue的数值排列)

  • automatic
    • 自动模式是默认的模式,完全由系统进行判断,开发者没有任何推荐。
    • 注意:这个自动模式,只在preferredDisplayMode中有效,displayMode实际显示的,只会是下面三个模式。
  • primaryHidden
    • UISplitViewController包含两个视图控制器,主视图控制器和细节视图控制器。primaryHidden表示优先显示细节视图控制器,而将主视图控制器隐藏起来。用户需要使用手势或者是后退按钮显示主视图控制器。
    • iPad在竖屏时默认会是这个模式。
  • allVisible
    • 同时显示主视图控制器和细节视图控制器。
    • 需要注意:这个模式虽然叫allVisible,却不是一定都能同时显示。如果不能,就会需要告诉系统,优先显示哪个视图控制器。
    • iPad在横屏时默认会是这个模式。此时会全部显示。
    • iPhone 6s Plus在竖屏和横屏时都是这个模式。竖屏时,因为实际上不能全部显示,就还需要考虑UISplitViewControllerDelegatesplitViewController(_:collapseSecondary:onto:)方法。
  • primaryOverlay
    • primaryHidden类似。但是会优先显示主视图控制器,叠加在细节视图控制器之上。
    • 如果我们要特别提示用户主视图控制器的存在,就可以使用这个选项。

UISplitViewControllerDelegate

splitViewController(_:collapseSecondary:onto:)方法

UISplitViewController.DisplayModeallVisible,且可显示的面积不能同时容纳显示主视图控制器和细节视图控制器时,系统会调用UISplitViewControllerDelegatesplitViewController(_:collapseSecondary:onto:)方法。

这个方法返回true代表显示主视图控制器,返回false代表显示细节视图控制器。

splitViewController(_:willChangeTo:)方法

当你需要在系统选择了primaryHidden时,就更改为primaryOverlay。使用此方法。

iOS自定义AlertController

iOS

最初的代码在这里。作者自己的说明

最初的作品已经很好用了。只是细节需要改动一下。

更改

  1. 原代码低于Swift 4.0,Xcode 11 beta 5无法编译。此次,先将Swift改成4.0。发现一切正常,没有需要改动的地方。
  2. 原代码不支持暗模式,可以在故事版中将颜色重新选择为支持暗模式的。特别的,在资源中新建颜色资源AlertBackgroundColor,获得模拟器中UIAlertController.view在亮/暗模式下的背景颜色。并指定为Alert View的背景色。
  3. 原代码ViewController.swift第55行使用了.overCurrentContext,这个在master/detail模式的布局中,如果detail中弹出自定义的警告窗口,之后旋转屏幕或者更改显示模式(比如由亮改暗),警告窗口就会出现问题。此处需要改成.overFullScreen
  4. 原代码CustomAlertView.swift第23行alertViewGrayColor使用了自定义颜色,注释掉掉。在第35行插入let alertViewGrayColor = UIColor.systemGray2
  5. CustomAlertView.swift第36行插入let lineThickness:CGFloat = 0.5,并且将第37-39行的width: 1.0改成width: lineThickness

小结

经过以上的改动,就可以获得一个支持iOS 13 beta的自定义AlertController了。剩下的你只需要根据自己的需求,继续填充就可以了。