🔬

LocalizationValue / LocalizedStringResource / LocalizedStringKey 調査隊

2024/04/03に公開

環境

macOS Ventura 13.6.6
Xcode 15.2

String(localized: )

String(localized: “abc”)

というのがある。iOS15から使えるものである。従来の
NSLocalizedString("abc", comment: "") と同じ役割である。

ややこしいのだが、

String(localized: の後ろに来るものはiOS16の時点で

  • String.LocalizationValue
  • LocalizedStringResource

の2つとなっている。

String.LocalizationValue による String(localized: 定義は

//String
init(
    localized keyAndValue: String.LocalizationValue,
    table: String? = nil,
    bundle: Bundle? = nil,
    locale: Locale = .current,
    comment: StaticString? = nil
)

一方 LocalizedStringResource による String(localized: 定義は

//String
init(localized resource: LocalizedStringResource)

ここから String.LocalizationValueLocalizedStringResource についてまとめる。

注意

細かいところに入っていくと
Localize : それぞれの地域/言語にあわせる
Interpolation : \() を扱う
が混乱しがちなので気を付ける

String.LocalizationValue

String(localized: に与える、String.LocalizationValue について調べる。

これはローカライズするときのネタを扱う。イメージでいうと各言語ファイル(Localizable.stringsなど)に投げるネタ。

  • 文字
  • 変数

という2つの性質のものを持つことができる。

定義

public struct LocalizationValue :
    Equatable, ExpressibleByStringInterpolation {

    public init(_ value: String)
    public init(stringLiteral value: String)
    public init(stringInterpolation:
        String.LocalizationValue.StringInterpolation)

    public struct StringInterpolation : StringInterpolationProtocol {
        //展開の定義
    }
}

これもまた ExpressibleByStringInterpolation が付いていて、StringInterpolation の自家製定義を持ち、\() 展開の定義がされている。

定義されている init などを使って初期化すると

let s: String = "abc"

let ls1 = String.LocalizationValue(s) //OK
let ls2 = String.LocalizationValue("abc") //OK
let ls3 = String.LocalizationValue("abc\(s)") //OK
let ls4: String.LocalizationValue = s //NG ❌
let ls5: String.LocalizationValue = "abc" //OK
let ls6: String.LocalizationValue = "abc\(s)" //OK

となる。

"abc"などのストリングリテラルの他に、実行時に決定できるように、\() 方式にも対応している。上の例でいうと、ls3ls6 にあたる。

ls6 は先ほどの「ある型が想定されるところにストリングリテラルや string interpolation が投げられて、その型が対応している場合は対応するイニシャライザにつながる」と考えるとスッキリする。

String.LocalizationValue.StringInterpolation

いままで StringInterpolation は展開するとさんざん言ってきたが、String.LocalizationValue.StringInterpolation を見ると、

public struct StringInterpolation : StringInterpolationProtocol {

    //略

    @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
    public mutating func appendInterpolation(placeholder: String.LocalizationValue.Placeholder)
    @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
    public mutating func appendInterpolation(placeholder: String.LocalizationValue.Placeholder, specifier: String)

    //略
}

となっていて、プレースホルダを扱うものがある(引数の名前がplaceholder)。これに関する説明が 2024年春の時点で見当たらないが、調査で次のコードを走らせる。

let s: String = "abc"
let n: Int = 1
let ph = String.LocalizationValue("abc\(s)\(n)")
print(ph)

すると出力が

LocalizationValue(
arguments: [
(extension in Foundation):
  Swift.String.LocalizationValue.FormatArgument(
    storage: (extension in Foundation):
      Swift.String.LocalizationValue.FormatArgument.Storage.value("abc")),
(extension in Foundation):
  Swift.String.LocalizationValue.FormatArgument(
    storage: (extension in Foundation):
      Swift.String.LocalizationValue.FormatArgument.Storage.value(1))],
key: "abc%@%lld")

となって "abc%@%lld" というキーになるらしい。
資料が見当たらないので推測になるが、String.LocalizationValue\() 方式を投げても具体的な値に展開しないで、キーとそれぞれの値をバラバラに保持する模様。

string interpolation 自体は展開処理をしない

let a = 5
let b = "a is \(a)"

と書くと b は、a is 5 になる。これは "a is \(a)" 自体に \(a) の展開機能があるように見える(展開してから b に代入されるように見える)。
しかし

let a = 5
let b = String.LocalizationValue("a is \(a)")

と書くと b は、"a is " と \(a) を分解して保持する。

つまり string interpolation の処理方法は string interpolation を受け取った側で決定する

let b = "a is \(a)""a is \(a)" がStringに投げられて、String内の処理機能によって展開される、と考えるとスッキリする。

再び String(localized:)

String(localized:)String.LocalizationValue を投げられたらどうするの?というのが次にやることである。

これに関しては推測であるが以前別の記事で書いた。

https://zenn.dev/samekard_dev/articles/e0149efe77e403#考察

挙動を観測すると上のような解釈で自然になる。

String.LocalizationValue まとめ

初期化パターン

let s: String = "abc"

let ls1 = String.LocalizationValue(s) //OK
let ls2 = String.LocalizationValue("abc") //OK
let ls3 = String.LocalizationValue("abc\(s)") //OK
let ls4: String.LocalizationValue = s //NG ❌
let ls5: String.LocalizationValue = "abc" //OK
let ls6: String.LocalizationValue = "abc\(s)" //OK

また、string interpolationを渡したときは

  1. キー(string interpolationの部分は暗号風)でローカライズ
  2. string interpolationの展開

の順番で行われる。

LocalizedStringResource

String(localized: に与えるもう一つの方、LocalizedStringResource について調べる。

iOS16から使える

https://developer.apple.com/documentation/foundation/localizedstringresource/

この型が扱う情報は

  • localization key
  • table
  • bundle
  • locale

である。
localeを含むので任意のlocaleに設定できる。後からlocaleを書き換えることが出来る。

定義は

public struct LocalizedStringResource :
    Equatable, Codable,
    CustomLocalizedStringResourceConvertible,
    ExpressibleByStringInterpolation {

    public let key: String
    public let defaultValue: String.LocalizationValue
    public let table: String?
    public var locale: Locale
    public var bundle: LocalizedStringResource.BundleDescription { get }
    public enum BundleDescription : Sendable {
        case main
        case forClass(AnyClass)
        case atURL(URL)
    }

    public init(
        _ keyAndValue: String.LocalizationValue,
        table: String? = nil,
        locale: Locale = .current,
        bundle: LocalizedStringResource.BundleDescription = .main,
        comment: StaticString? = nil)

    public init(
        _ key: StaticString,
        defaultValue: String.LocalizationValue,
        table: String? = nil,
        locale: Locale = .current,
        bundle: LocalizedStringResource.BundleDescription = .main,
        comment: StaticString? = nil)

    public init(
        stringLiteral value: String)

    public init(
        stringInterpolation: String.LocalizationValue.StringInterpolation)

がある。
ExpressibleByStringInterpolation であるので初期化は

let s = "abc"
var lr1 = LocalizedStringResource(s) //NG
var lr2 = LocalizedStringResource("abc") //OK
var lr3 = LocalizedStringResource("abc\(s)") //OK
var lr4: LocalizedStringResource = s //NG ❌
var lr5: LocalizedStringResource = "abc" //OK
var lr6: LocalizedStringResource = "abc\(s)" //OK

となる。lr5lr6 は何度も出てきた「ある型が想定されるところにストリングリテラルや string interpolation が投げられて、その型が対応している場合は対応するイニシャライザにつながる」と考えると辻褄があう。

LocalizedStringResource の使い方は

var lr1 = LocalizedStringResource("Capital")
var lr2 = LocalizedStringResource("Capital")
lr1.locale = Locale(identifier: "ja-JP")
lr2.locale = Locale(identifier: "en-US")
let lrs1 = String(localized: lr1)
let lrs2 = String(localized: lr2)
print(lrs1)
print(lrs2)

Localizable.stringsには首都が設定してある。

結果

Tokyo
Washington DC

という風に、

  • 初期化
  • ローカライズ情報設定

が別のタイミングで行える。
バンドルやテーブルの情報も含むことが出来るので、そういう使い方が必要な場合には有効だと思われる。

String(localized: ) どっち問題

String(localized: ) には少し疑問があって、単に
String(localized: "abc") としたときに何が呼ばれるのか、つまり
public init(localized keyAndValue: String.LocalizationValue, /* 略 */)

public init(localized resource: LocalizedStringResource)
か、どっちが呼ばれるのかここまでに出てきた知識では説明することができない。
これは単に私が何かを知らないだけかもしれない。

String.LocalizationValue でハマりがち

英語圏では、lang -> ABC
日本語圏では、lang -> あいう
の変換をしたいとする。ただしキーの値(つまりlang)は実行時に決まるとする。
これは

let key: String = "lang"
let s = String(localized: String.LocalizationValue(key))

と書ける。
これを短く

let key: String = "lang"
let s = String(localized: "\(key)")

とすると、意図するようにはならない。
public init(localized keyAndValue: String.LocalizationValue, /* 略 */) が走るとすると、String.LocalizationValue が想定されるところに "(key)" が投げられたので関連するイニシャライズにつながり、キーが %@String.LocalizationValue が作られる。これが String(localized:) に送られて キーが %@ のローカライズデータを探し、多分何もヒットしないか %@ -> %@ の変換が行われ、その後 %@ が展開されて "lang" になる。

String ローカライズしたい/したくない問題

String、Stringリテラル、String Interpolationのそれぞれをローカライズしたい、ローカライズしたくないで分けて、その実装例を次の記事にまとめた

https://zenn.dev/samekard_dev/articles/9f47ba85d5ebf5

SwiftUI の Text

SwiftUIのTextを調べる。イニシャライザを見ていく。

@inlinable public init(verbatim content: String)

ローカライズさせないで文字をそのまま出す時に使うのが verbatim:

Text(verbatim: "Direct")

のように使う。しかし 「Stringが想定されるところに "\()" を流すと解釈するイニシャライズつながる」の法則があるので

let a = "文字a"
Text(verbatim: "\(a)")

なんてのも可能。Textビューは「文字a」と表示する

引数なしで StringProtocol を受け付けるものもある。

public init<S>(_ content: S) where S : StringProtocol
let a = "abc"

Text(a)

これは ローカライズしない
ただし、Textのイニシャライズに渡す前にローカライズすることは当然できる

Text(String(localized: "abc")) //ローカライズされたStringがTextに渡される

先ほど見た LocalizedStringResource を使った

public init(_ resource: LocalizedStringResource)

もある。これはローカライズが可能。

次が

Text("abc")

とリテラルで書いたときのやつ。これは後ろで

//Text
public init(_ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, comment: StaticString? = nil)

が走っているそうである。Textの別のイニシャライザのコメントにそう書いてある。

コメント

SwiftUI doesn't call the init(_:) method when you initialize a text view with a string literal as the input. Instead, a string literal triggers the Text/init(_:tableName:bundle:comment:) method — which treats the input as a LocalizedStringKey instance — and attempts to perform localization.

この初めの引数が LocalizedStringKey ですな。ではこれをやります。

LocalizedStringKey

import SwiftUI が必要

キャッチコピー(説明の一文目)は

The key used to look up an entry in a strings file or strings dictionary file.

ローカライズファイルで使うためのキー

上でやった LocalizationValue と似ている。キーとその他の関連する情報を持つ。

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct LocalizedStringKey : Equatable, ExpressibleByStringInterpolation {

    public init(_ value: String)
    public init(stringLiteral value: String)
    public init(stringInterpolation:
        LocalizedStringKey.StringInterpolation)

    public struct StringInterpolation : StringInterpolationProtocol {
        //日付やらImageやらを展開する機能が書いてある。一部はextensionで追加。
    }
}

とあるので、init ではおなじみの

  • Stringインスタンス
  • Stringリテラル
  • string interpolation

の3点セットに対応している。

let s = "abc"
let n = 1
let lk1 = LocalizedStringKey(s)
let lk2 = LocalizedStringKey("abc")
let lk3 = LocalizedStringKey("string \(s)")
let lk4 = LocalizedStringKey("number \(n)")
print(lk1)
print(lk2)
print(lk3)    
print(lk4) 

結果は

LocalizedStringKey(
  key: "abc",
  hasFormatting: false,
  arguments: [])
LocalizedStringKey(
  key: "abc",
  hasFormatting: false,
  arguments: [])
LocalizedStringKey(
  key: "string %@",
  hasFormatting: true,
  arguments: [SwiftUI.LocalizedStringKey.FormatArgument(storage: SwiftUI.LocalizedStringKey.FormatArgument.Storage.value("abc", nil))])
LocalizedStringKey(
  key: "number %lld",
  hasFormatting: true,
  arguments: [SwiftUI.LocalizedStringKey.FormatArgument(storage: SwiftUI.LocalizedStringKey.FormatArgument.Storage.value(1, nil))])

要は、いろいろな情報をバラバラに持っている。()方式で渡したときはキーが暗号風( number %lld )になる。

LocalizedStringKey.StringInterpolation

LocalizedStringKey の中に StringInterpolation が新規定義されている(新規で定義ないときはDefaultStringInterpolationが使われる)。この新規定義を見ると、

public struct StringInterpolation : StringInterpolationProtocol {
  public mutating func appendLiteral(_ literal: String)
  public mutating func appendInterpolation(_ string: String)

  public mutating func appendInterpolation(_ resource: LocalizedStringResource)

  //略

という感じになっている。
説明によると

The interpolated types can include numeric values, Foundation types, and SwiftUI Text and Image instances.

となっていて、ローカライズ以外の仕組みも大量にある。
例えば

public mutating func appendInterpolation(_ date: Date, style: Text.DateStyle)

というものがあって、引数を2つ持つ \() 表記という、言葉で説明すると何が何やらわからないが

let key = LocalizedStringKey("Date is \(company.foundedDate, style: .offset)")
let text = Text(key) // Text contains "Date is +45 years"

というように、\() 表記の中に引数が2つある書き方が出来る。

Text("") でハマりがち

さて、よくあるシチュエーションで
"money" -> "円"
"money" -> "Dollar"
という変換をしたいが、キーは実行時に決まる場合

let s = "money"
Text("\(s)")

と書いてしまうが、これはローカライズされない。

Text ローカライズしたい/したくない問題

Textを作る時に、素材となるString、Stringリテラル、String Interpolationのそれぞれをローカライズしたい、ローカライズしたくないで分けて、その実装例を次の記事にまとめた

https://zenn.dev/samekard_dev/articles/9f47ba85d5ebf5

LocalizedStringKey のまとめ

SwiftUIで定義されている LocalizedStringKeyLocalizationValue と似たようなものだが、SwiftUI用などの機能がかなり豊富にある。

感想

As a general rule, use a string literal argument when you want localization, and a string variable argument when you don't.

一般的なルールとして、ローカライズしたいならStringリテラルで書き、ローカライズしたくないならString変数を使う。

と言っているが、普通に、
何も指定しないならそのまま、ローカライズするときはその旨を(引数などで)書く
というルールの方がシンプルに感じる。

さいごに

この記事は内容自体は検索したら出てきたものを並べただけというレベルである。
何か指摘がある場合はコメントでお願いしたい。

Discussion