iOS17正式対応の舞台裏

Yu Ogasawara
スタディスト Tech Blog
12 min readDec 18, 2023

--

スタディスト Tech Blog Advent Calendar 2023の18日目の記事です。iOSアプリエンジニアの小笠原がお送りします。

Teachme Biz iOSアプリでは、2023年にiOS17への正式対応とiOS14のサポート終了を行いました。

この記事では、iOS17対応・Xcode15移行の作業中に発生した事件とその対処を振り返っていきます。

UIButtonが荒ぶる

Xcode15でビルドした結果、UIButtonの表示が崩れる現象が発生しました。

ボタンのアイコンが表示されず、意図しない改行が追加されてしまっている

この現象を解決するため、

  • ボタンのレイアウトのためにこれまで使っていたAuto layoutの見直し
  • UIButton.Configurationの導入

を行いました。

Auto layoutの見直し

3つのボタンに関しては、StackViewの内部に、3つのUIViewを水平方向に並べ、それぞれのUIViewの内部にUIButtonを入れる構造になっています。

iOS16までは、UIViewと内側のUIButtonのleading・trailing edgeでのConstraintsに不等式を使っていました。この設定によって引き起こされる曖昧さが、Xcode15でビルドした時に表示崩れを引き起こしていました。そこで、Constraintsに不等式ではなく等式を設定するようにし、曖昧さを消し去りました。

曖昧さよ、さらば

UIButton.Configurationの導入

UIButtonの内部のアイコンとタイトルの表示設定は、従来以下のような実装でした:

class FooButton: UIButton {
override var intrinsicContentSize: CGSize {
let imageWidth = imageView?.intrinsicContentSize.width ?? .zero

guard let titleLabel = titleLabel else { return .zero }
let labelSize = titleLabel.sizeThatFits(
CGSize(
width: frame.width - (imageWidth + titleEdgeInsets.left + titleEdgeInsets.right),
height: .greatestFiniteMagnitude
)
)

let desiredButtonSize = CGSize(
width: imageWidth + labelSize.width + contentEdgeInsets.left + contentEdgeInsets.right,
height: labelSize.height + contentEdgeInsets.top + contentEdgeInsets.bottom
)

return desiredButtonSize
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)

let imageTitlePadding: CGFloat = 4
let vPadding: CGFloat = 12
let hPadding: CGFloat = 2

let contentPadding = UIEdgeInsets(
top: vPadding, left: hPadding, bottom: vPadding, right: hPadding
)

self.contentEdgeInsets = UIEdgeInsets(
top: contentPadding.top,
left: contentPadding.left,
bottom: contentPadding.bottom,
right: contentPadding.right + imageTitlePadding
)

self.titleEdgeInsets = UIEdgeInsets(
top: 0,
left: imageTitlePadding,
bottom: 0,
right: -imageTitlePadding
)
}
}

特に、アイコンとタイトルの間隔の設定は、contentEdgeInsetsとtitleEdgeInsetsの両方を組み合わせて実現しており、なかなか一読で理解しづらいところがあります。

参考:https://noahgilmore.com/blog/uibutton-padding/

今回、サポートOSの下限をiOS15に変更したため、UIButton.Configurationを使った記述に置き換えることができました。以下のようになります:

class FooButton: UIButton {
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)

let imageTitlePadding: CGFloat = 4
let vPadding: CGFloat = 12
let hPadding: CGFloat = 2

var configuration = UIButton.Configuration.plain()
configuration.contentInsets = NSDirectionalEdgeInsets(
top: vPadding, leading: hPadding, bottom: vPadding, trailing: hPadding
)
configuration.image = self.imageView?.image
configuration.imagePadding = imageTitlePadding

self.configuration = configuration
}
}

intrinsicContentSizeの設定は不要になっています。さらに、タイトルとアイコンの間隔は、 configuration.imagePadding = imageTitlePaddingだけで設定できるようになりました。すっきり!

これで、iOS15でdeprecatedになったcontentEdgeInsets・titleEdgeInsetsへの依存を減らすことができました。

改修後のボタンたち。アイコンが復活し、謎の改行も解消された

super init無しではいられない

Xcode15でビルドしたアプリをiOS17の端末で検証すると、UIViewControllerのサブクラスを初期化する際にクラッシュしていました。

クラッシュ時のログ:

Thread 1: "UIViewController is missing its initial trait collection populated during initialization. This is a serious bug, likely caused by accessing properties or methods on the view controller before calling a UIViewController initializer. View controller: <ASDTextEditView: 0x106953020>"

類似の事例が

に紹介されています。

よくよく見てみると、UIViewControllerのサブクラスの1つで初期化処理の変遷の過程でsuper.initが呼ばれなくなっていたようです。

該当クラスの初期化時にsuper.initが実行されるように変更した結果、クラッシュが解消しました。

size = (0,0)が許容されなくなった

QA検証中に、画像にモザイクを追加する箇所でクラッシュが発生しました。

クラッシュ検出時のWrikeチケット

エラーメッセージ

UIGraphicsBeginImageContext() failed to allocate CGBitampContext: size={0, 0}, scale=1.000000, bitmapInfo=0x2002. Use UIGraphicsImageRenderer to avoid this assert.

原因の考察

UIGraphicsBeginImageContextUIGraphicsEndImageContextはiOS17から挙動が変化したようです(ちなみにどちらもiOS17からdeprecatedになっています)。

具体的には、引数のsizeが(0,0)であってもiOS16までは許容されていたのが、iOS17では例外が発生するようになっています。

対処法

UIGraphicsBeginImageContext, UIGraphicsEndImageContextを利用していた部分を、UIGraphicsImageRendererを使った実装に置き換えました。

変更前の実装

// imageはモザイクをかける対象のUIImage
UIImageView* imageview = [[UIImageView alloc] initWithImage:image];
UIGraphicsBeginImageContext(imageview.frame.size);
[imageview.layer renderInContext:UIGraphicsGetCurrentContext()];
NSData* pngData = UIImagePNGRepresentation(UIGraphicsGetImageFromCurrentImageContext());
UIImage* imageFormed = [UIImage imageWithData:pngData];
UIGraphicsEndImageContext();

変更後の実装イメージ

UIImageView* imageview = [[UIImageView alloc] initWithImage:image];

UIGraphicsImageRendererFormat *format = [[UIGraphicsImageRendererFormat alloc] init];

// opaque = NO, scale = 1.0 は、UIGraphicsBeginImageContextをオプション指定なしで呼ぶときと同等の指定
format.opaque = NO;
format.scale = 1.0;

// 画像のカラープロファイルによって色が変わってしまうケースがあるので指定する
format.preferredRange = UIGraphicsImageRendererFormatRangeStandard;

UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:imageview.frame.size format:format];
NSData* pngData = [renderer PNGDataWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) {
[imageview.layer renderInContext:rendererContext.CGContext];
}];
UIImage* imageFormed = [UIImage imageWithData:pngData];

総じて、緩い実装で許されていたものがXcode15でビルドすると許容されなくなっています。リリース前にこれらのクラッシュを検出できたのはQAチームのおかげです。

ここまで読んでいただき、ありがとうございました。iOS新バージョンが出る際には、Teachme Bizに限らず各アプリでiOSの新バージョンでの動作検証や実装の修正が入っていることが伝われば幸いです。

We’re hiring!

スタディストでは、“伝えることを、もっと簡単に” するために、一緒に働く仲間を探しています。

開発組織に興味を持たれた方はお気軽にご連絡ください!

https://studist-engineering.gitbook.io/entrance-book

--

--