肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

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了。剩下的你只需要根据自己的需求,继续填充就可以了。

Realm模型升级(二)

RealmSwift

Realm模型升级(一)中讲了对象添加了新属性之后要如何升级。最近我又遇到了新的问题,要将对象改名。

新问题

因为业务的需要,必须将代码

class Item:Object {
    static let calendar = Calendar(identifier: .gregorian)
    
    @objc dynamic var title = ""
    @objc dynamic var addedDate:Date = Date()
    @objc dynamic var relatedItem:Item? = nil
    let records = List<Record>()
    
    override class func primaryKey() -> String? {
        return "title"
    }
}

class Record:Object {
    static let dateFormatter:DateFormatter = {
        let df = DateFormatter()
        df.locale = Locale(identifier: "zh")
        df.dateStyle = .medium
        df.timeStyle = .none
        
        return df
    }()
    
    @objc dynamic var id:Int = 1
    @objc dynamic var addedDate:Date = Date()
}

更改为

class ACItem:Object {
    static let calendar = Calendar(identifier: .gregorian)
    
    @objc dynamic var title = ""
    @objc dynamic var addedDate:Date = Date()
    @objc dynamic var relatedItem:ACItem? = nil
    let records = List<ACRecord>()
    
    override class func primaryKey() -> String? {
        return "title"
    }
}

class ACRecord:Object {
    static let dateFormatter:DateFormatter = {
        let df = DateFormatter()
        df.locale = Locale(identifier: "zh")
        df.dateStyle = .medium
        df.timeStyle = .none
        
        return df
    }()
    
    @objc dynamic var id:Int = 1
    @objc dynamic var addedDate:Date = Date()
}

即原来的类的名称之前,需要添加AC字样。更改了代码之后运行,发现数据不见了。

分析

打开数据库文件查看(如图)。原来,虽然代码中的类变了,但是数据库中的类还是原来的名字,需要手动迁移。

realm database

解决方案

23-39行,利用之前的Item对象,生成ACItem对象;利用Item对象中的records属性,生成ACRecord对象。需要注意的事,因为代码中此时已经没有了Item类和Record类,我们没法使用Item.className(),而只能直接使用"Item"

41-43行,删除掉旧数据。

let config:Realm.Configuration = {
    // update realm
    let config = Realm.Configuration(
        fileURL: destinationURL,
        
        // Set the new schema version. This must be greater than the previously used
        // version (if you've never set a schema version before, the version is 0).
        schemaVersion: 3,
        
        // Set the block which will be called automatically when opening a Realm with
        // a schema version lower than the one set above
        migrationBlock: { migration, oldSchemaVersion in
            // We haven’t migrated anything yet, so oldSchemaVersion == 0
            if (oldSchemaVersion < 1) {
                // Nothing to do!
            }
            
            if (oldSchemaVersion < 2) {
                // Nothing to do!
            }
            
            if (oldSchemaVersion < 3) {
                migration.enumerateObjects(ofType: "Item") { (oldObject, _) in
                    let acItem = migration.create(ACItem.className())
                    acItem["title"] = oldObject!["title"]
                    acItem["addedDate"] = oldObject!["addedDate"]
                    
                    guard let list = acItem["records"] as? List<MigrationObject>,
                        let oldList = oldObject!["records"] as? List<MigrationObject> else {
                        fatalError()
                    }
                    
                    oldList.forEach { o in
                        let acRecord = migration.create(ACRecord.className())
                        acRecord["id"] = o["id"]
                        acRecord["addedDate"] = o["addedDate"]
                        list.append(acRecord)
                    }
                }
                
                // delete
                migration.deleteData(forType: "Item")
                migration.deleteData(forType: "Record")
            }
    })
    
    return config
}()

总结

  1. 代码中对象的改名,意味着数据库中数据的迁移。
  2. MigrationObjectEnumerateBlock中的oldObject,实际上一个词典,我们通过键来进行数据的查找。

咕唧在iPad浮屏时,有时会意外地重新加载账户头像问题的处理

iOS

咕唧在iPad浮屏时,既屏幕右侧向里滑动弹出咕唧,有时会出现全部头像闪动一下的问题。这个问题没什么规律,如果立即你把它划回去,再重新滑出来,它就又没有这个问题了。另外,我在iOS 12的时候也没有遇到过这个问题。

我认为这可能是iOS 13 beta独有的一个问题。

我尝试解决这个问题。

方案一

因为视图是UITableView,我尝试减少reloadData()的次数。仔细阅读代码,修改了几处限定条件,减少了reloadData()。并且出于调试目的,我在预期不应该发生的位置,插入fatalError()强制结束应用。

测试结果

方案一减少了重新加载表格视图的次数,但是对于解决该问题无效。测试中发现,偶尔还是会出现重新加载头像的问题,并且,应用没有崩溃,也就是说,我预期不会执行的部分,也的确没有执行。

方案二

因为问题是和头像有关,于是着重查看头像部分的代码。

cell.imageView?.image = profileImage
            
DispatchQueue.main.async {
    ProfileImageHelper.decorate(cell.imageView!, for: accountCore.type)
}

这段代码使用了异步代码,在UI队列里执行了对于UIImageView的装饰。这里必须使用的原因是cell.imageView是只读属性,不能直接替代。并且,当cell.imageView?.image = profileImage之后,如果立即装饰图片视图,图片视图的尺寸还是0,必须在下一个事件循环才能获得实际尺寸。

imageView_decoration

上图是咕唧账户头像的部分截图。照片原本是正方形,在实际显示时,被处理为圆形。并且,根据账户类型的不同,加上了颜色不同的圆圈。

我们知道,异步的代码,与同步不同,是分成两次执行的。因此,存在一种可能,既第一部分的代码执行了之后,很久,第二部分才执行。这在显示上,就会出现先显示方形的头像,然后才变成圆形和圆圈,就像看幻灯片那样。

须要将此部分的代码由异步改为同步。

此前采用异步的原因是无法立即知道图片视图的大小,改成同步,就必须修改代码,手动指定视图的大小。我们知道,这在平时其实是一种不好的行为。代码写死了,就失去了灵活性。但是这里,我们不得不把它写死。

通过调试,我们知道这里目前的大小是48.0点。(点,point,是苹果使用的相对大小的尺寸单位)。

cell.imageView?.image = profileImage
ProfileImageHelper.decorate(cell.imageView!, for: accountCore.type)

测试结果

这次的测试很成功,之后没有发现再次出现这个问题。

方案三

虽然问题解决了,但是写死代码还是不够灵活。思考了一下,虽然图片视图的尺寸不能立即知道,但是图片的尺寸是可以知道的。因此,将写死的代码,改为使用图片的尺寸。这样问题就彻底解决了。

思考

虽然这个问题很有可能是iOS 13 beta引入的,但是解决掉这个问题,还是有收获的。我们在实际编程中,应该尽量减少异步代码的使用。因为,有时,异步代码会带来意想不到的问题。

Swift 5 String补遗

Foundation

Swift 5中的String采用了UTF-8编码。而NSString是UTF-16编码的。NSStringString的转换是lazy的,这句话充满了刀光剑影。

所谓lazySwift中最常见的用法,简单的描述就是,当在需要复制的时候,不进行复制,而仅标记,然后如果后面的操作是读操作,就一直读,直到出现了写操作,才会真正将内容分离写入。这么做的好处,是性能比较好,如果有幸最终也没有写入操作,那么就完全省去了写入操作和额外的内存占用。

不过由于StringNSString的编码不同,这种lazy导致了一个严重的问题。就是如果你从某个框架获得了一个String,你其实是不知道它是原生的String,还是过来的NSString。比如你读取了一个String.Index,等你要用的时候,它可能已经失效了。

举一个简单的例子:

import Foundation

let ns:NSString = "ab两只老虎,两只老虎,跑得快,跑得快。"
var s = ns as String

let aIndex = s.firstIndex(of: "只")!
print(s[aIndex]) // 只
s += ""
print(s[aIndex]) // \270

为了解决上面的问题,Swift有两项硬性规定。

  1. 对于String.Index,索引只对于它自身的String。使用非自身字符串的索引,可能导致未知的问题。
  2. String只要有任何改变,String.Index都应该重新获取。

解决办法

由于String.Index非常容易失效,且不能直接使用。因此,在一个字符串使用另一个字符串的索引是需要转换才能使用。但是,这种转换,Swift本身是没有直接提供的。需要自己算一下。

import Foundation

extension String {
    func sameIndex(_ index:String.Index, of str:String) -> String.Index? {
        let offSet = self.distance(from: self.startIndex, to: index)
        return str.index(str.startIndex, offsetBy: offSet, limitedBy: str.endIndex)
    }
}

let ns:NSString = "ab两只老虎,两只老虎,跑得快,跑得快。"
var s = ns as String

let aIndex = s.firstIndex(of: "只")!
print(s[aIndex]) // 只
let s1 = s + ""
let i1 = s.sameIndex(aIndex, of: s1)!
print(s1[i1]) // 只

于此类似,Range<String.Index>也有同样的问题。更扩大一步说,只要是支持Collection类型的,都有这个问题。

CGImageDestination写入属性时的架构

Image I/O

func CGImageDestinationAddImage(CGImageDestination, CGImage, CFDictionary?)func CGImageDestinationSetProperties(CGImageDestination, CFDictionary?)都可以设定一个字典参数做为写入到图片的属性,但是这两个是存在区别的。

前者是写入属性到图片,后者是写入属性到图片的容器。举例来说,如果你保存一个JPEG的图片,那么写入Exif信息,就是写入到图片。而如果你是写入一个动态Gif图片,那么动图里面的每一帧,都是单独一张图片,每个图片有自己的属性。此外,动图本身是一个容器,有自己的属性,比如一共包含了多少张图片,播放时,间隔多长时间放一张,播放结束之后是否循环播放等。

如果本该写入到图片的属性,写入到了容器,就会出现属性丢失的现象。

切记切记。

相关问题

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