cockscomblog?

cockscomb on hatena blog

SwiftUIのDynamicPropertyを試す

SwiftUIにはDynamicPropertyというprotocolがある。

これを使ってみようという趣旨の記事を見かけた。

ので、私も試してみました。

@Now

import Combine
import SwiftUI

class Clock: ObservableObject {
    @Published private(set) var date: Date = Date()

    init() {
        Timer.publish(every: 1, on: .main, in: .default)
            .autoconnect()
            .assign(to: &$date)
    }
}

@propertyWrapper
struct Now: DynamicProperty {
    @StateObject private var clock = Clock()

    var wrappedValue: Date {
        get {
            clock.date
        }
    }
}

こういうのを作っておいて

import SwiftUI

struct ContentView: View {
    static let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .none
        formatter.timeStyle = .long
        return formatter
    }()

    @Now private var date: Date

    var body: some View {
        Text(date, formatter: Self.dateFormatter)
    }
}

こう使う。

これでContentViewは毎秒更新され、現在時刻を表示し続ける。

DynamicPropertyって何

ドキュメントには、Viewが再計算される前にDynamicPropertyupdateメソッドを呼んでくれるくらいの情報しかないが、実際にはもうちょっといろいろあるようだ。

上記の例でもわかるように、@ObservedObjectの更新によってViewが再描画される。

ここで急に、SwiftUIのCore Dataサポートのように、@FetchRequest的な形で何かできるんじゃないか、と気づくと思う。

実際にRealmにはそういう機能があった。

現実のユースケース

DynamicPropertyでは、StateなりStateObject(またはObservedObject)なり、あるいはEnvironment(やEnvironmentObject)を、Viewのpropertyで使えるようだ。

これは少しReact Hooksに似ている。ReactのuseStateとSwiftUIの@Stateの対称性のように、ReactのCustom Hookとちょっと似ている。

とはいえ、@Nowの例は、単にObservableObjectをそのまま使えばいいわけで、独自のDynamicPropertyを作ることが正当化されるような場面は稀かも。

Relayに学ぶGraphQLのスキーマ設計

2018年の初めくらいから、仕事でGraphQL APIを何度も作っている。サーバーサイドもクライアントサイドも実装している。

最近クライアント側にRelayを使ってみている。

GraphQLのクライアントとしてはApolloを使う場合が多いと思うが、Facebook製のRelayもかなりよくできている。以前はTypeScriptに対応していなかったが、今はTypeScriptも使える。最近のバージョンではhooksのAPIがexperimentalではなくなり、ReactのSuspense API(Suspense for Data Fetchingは使わずに)と合わせて使える。

RelayはGraphQLのスキーマに制約を設けることで、クライアント側のAPIがデータの再取得やページネーションなどを抽象化している。換言すると、Relayからデータの再取得やページネーションに必要なスキーマ上の制約を学べる、ということだ。

ということで、スキーマの設計について抽象的な理解を得たので、それを記す。

RelayのGraphQL Server Specificationに倣う

GraphQL Clientとしてrelayを利用することもそうでないこともあるが、いずれにしても、これに倣っておくとうまくいくことが多い。

RelayのGraphQL Server Specificationは、再取得とページネーションのための仕様である。

Node

id という ID! 型のフィールドを持った Node インターフェースを定義する。

interface Node {
  id: ID!
}

ID はグローバルに(型に関わらず)ユニークな識別子。内部的には base64(type + ":" + internal_identifier) のような実装にすることが多い。クライアントサイドでは透過的な値として扱い、パースを試みるべきではない。

トップレベルのクエリに node(id: ID!): Node を持つ。Node インターフェースに準拠する型は、このトップレベルクエリから取得できるようにする。

type Query {
  node(id: ID!): Node
}

このようにしておくと、Node はクライアントから便利に扱える。キャッシュのノーマライズもできるし、再取得も簡単である。

Connection

Connectionはページングに関連している。要するに、ページネーションを行うフィールドで Connection というのを返したりすると、カーソルベースのページネーションができる。

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
type User implements Node {
  id: ID!
}
type UserEdge {
  node: User!
  cursor: String!
}
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}
type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}

組み合わせ

例えば Userfriends(first: Int!, after: String!): UserConnection! というページネーションできるフィールドがあるとき、2ページ目を取得するには User から指定しなければならない。

type User implements Node {
  id: ID!
  friends(first: Int!, after: String!): UserConnection!
}
query {
  node(id: "xxx") {
    ... on User {
      friends(first: 10, after: "yyy") {
        ...
      }
    }
  }
}

このようにネストしたフィールドでのページネーションでは、目的のフィールドまでのパスが一意に定まる必要がある。

Viewerパターン

サービスにもよるが、GraphQL APIを認証情報付きで呼び出している利用者を、type Viewer として表し、トップレベルから viewer フィールドで取得できるようにする。

type Viewer {
  name: String!
}

type Query {
  viewer: Viewer
}

このようなフィールドがあると、例えばログインしている利用者の名前を表示する際に便利なショートカットになる。従って利用者自身に紐づくリソースは Viewer に持たせるとよい。

Viewer 以外にも Visitor のような語彙でもよさそうではあるが、統一されている方が便利なので、特別な理由がなければ合わせるとよい。また、Relayではこのようなフィールドを Viewer に決め打ちして特別扱いしている。

取得可能性

type Query のフィールド、Node、そしてViewerパターンでは、いずれも目的のオブジェクトを直接取得可能である。使い分けは、グローバルにユニークなオブジェクトなら type Query のフィールド、そうでなければNode、そして現在の利用者を指すショートカットとしてViewerパターン、となるだろう。

Relayではもうひとつ、@fetchable ディレクティブと fetch__Xxx というトップレベルのクエリを組み合わせることができる。

directive @fetchable(field_name: String!) on OBJECT

type User @fetchable(field_name: "id") {
  id: ID
}

type Query {
  fetch__User(id: ID!): User
}

見ての通り、ほとんどNodeと同じである。

合わせて4つが、直接取得可能なフィールドということになる。Relay Compiler(Rustで書かれている)でも、これらが特別扱いされている。

GraphQLで扱うオブジェクトは、なるべく直接取得ができるように設計されていると、取り回しがよい。例えばパーマリンクとして扱うには直接取得が可能でなければならないだろう。

Mutationの戻り値

Mutationでオブジェクトを作成、更新、もしくは削除したときの戻り値をどうするか。

更新

GraphQLクライアントは普通、内部にノーマライズされたキャッシュを保持している。

例えばTwitterを作るとして、ツイート一覧画面について考える。一覧でツイートを選択して、個別のツイートの画面で「いいね」する。その後ツイート一覧に戻ってきたとき、一覧の中でも該当のツイートが「いいね」状態になっていてほしい。

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Tweet implements Node {
  id: ID!
  text: String!
  likeCount: Int!
  viewerLiked: Boolean
}

type TweetEdge {
  node: Tweet!
  cursor: String!
}
type TweetConnection {
  edges: [TweetEdge!]!
  pageInfo: PageInfo!
}

type Query {
  node(id: ID!): Node
  timeline(first: Int!, after: String): TweetConnection!
}

このとき、TweetNode なので、GraphQLクライアントは id の値をキーとしてキャッシュをノーマライズする。Mutationとして like(tweetID: ID!): Tweet! が用意されていれば、これの戻り値を使ってキャッシュを更新できるため、「いいね」状態の一貫性が維持される。

要するに、特に Node の更新については、戻り値は Node そのものであればよい。

作成

作成されたオブジェクトが一覧にも表示される場合、取得されたConnectionのキャッシュにも要素を付け加える必要がある。

大まかには、戻り値が Node であればよい。RelayのConnectionを利用している場合、戻り値をEdgeにしてもよい。戻り値を使ってConnectionのキャッシュを書き換える必要がある。

Relayの場合、@appendEdge / @prependEdge もしくは @appendNode / @prependNode といったディレクティブが用意されており、Connectionの前後に要素を付け加えるのが少し簡単になっている。

削除

オブジェクトを削除する場合も、やはり一覧での表示から取り除く必要がある。

Connectionから取り除くには、Nodeid さえわかっていればいい。従って戻り値は ID! で十分である。

Relayでは @deleteEdge ディレクティブが用意されている。

まとめると

GraphQLのスキーマを設計するとき、とりあえず従っておくべき指針をRelayから得た。

まずはRelayのGraphQL Server Specificationに従っておく。NodeとConnectionを実装する。

そして直接的に取得可能な、type Query のフィールドやNode、Viewerパターンを意識する。

Mutationの戻り値は、作成や更新ならNode、削除はIDが基本になる。

例外はいくらでもあると思うが、最初はこれくらいから考えると概ね妥当と思う。

Work From Living Roomで使うマイク

プロローグ

西暦2021年、COVID-19のパンデミックによって人類が活動の抑制を余儀なくされてからおよそ1年が経っていた。一部のデスクワーカーは在宅勤務、「Work From Home(WFH)」にシフトすることで、他者との物理的な接触を避けながら職務を継続した。ビデオ会議が対面でのミーティングにとって代わり、人々はこぞってカメラやマイク、照明を買い漁った。


……というプロローグでやっているわけだが、人によって住環境も様々で、Work From HomeというかWork From BedroomとかWork From Living roomのような状況の人も多いと思う。かくいう私も部屋数の少ない賃貸住宅で暮らしており、まさにWFLの様相を呈している。

リビングの端で窓を背に陣取っていて、それなりに快適にやっているのだけど、ビデオ会議のときに生活音が入ってしまうのが気になっていた。特に子供がうるさくする音は、会議に同席する同僚たちは寛容にも気にしないでいてくれるのだけど、それでも居心地はよくない。そのうち引っ越したら仕事部屋を用意しよう、くらいにのんびり構えていたが、最近になって急に、指向性のあるマイクが気になり始めた。

ということでマイクについて調べてみました。

指向性と構造

指向性については、audio-technicaマイクロホンの指向特性のページがわかりやすい。なるほど、シンプルだけど存外に物理的な仕組みである。

ついでにaudio-technicaマイクロホンの内部構造のページを見ると、「ダイナミック型」や「コンデンサ型」のような駆動方式があり、空気の振動をどうやって電気信号に変換するかというものだが、方式によって感度などに差が出るということだった。

これまで、なんとなくコンデンサ型の方が音質がいいような印象を持っていたが、実際には用途に合わせるとよいようである。生活音を入れたくないならダイナミック型の方が合っているのかも。

何を買ったらいいのか

なんとなく理屈はわかってきたが、パソコンに容易に接続可能なダイナミック型のマイクはあまり多くない。Blue Yetiのような製品はコンデンサ型だった。オーディオインターフェースから揃えるとなると面倒。

というところでいろいろ教えてもらって、ゲーミング用のヘッドセットもいいと聞いたので心が揺れた。しかし昨年末に AirPods Maxを買ってしまっていて、ついでにPlayStation 5用のPULSE 3D ワイヤレスヘッドセットも持っているので、頭に着けるものがこれ以上増えることについて、妻がいい顔をしなかった。

結局どうしたかというとShure MV7を買いました。これは2020年11月に発売されたもので、なんとなくよさそうな雰囲気があるが、あまりわかっていない。とにかくUSB接続できて、ダイナミック型で、カーディオイド型の単一指向性がある。あと見た目がいい。

試しに買ってみるにはちょっとpriceyではあるが、しかしマイクの良し悪しについて、まして生活音をどれくらい拾わないかなんて、事前にわかるはずもない。だから決め手は id:nagayama さんが使っているという事実である。nagayamaさんが使っているなら間違いなくないですか。

ということでnagayamaさんが使っていることをもって妻も納得し、購入に至った。口から近い位置に設置できた方がいいので、適当な安いアームも買った。ディスプレイのUSBポートに接続したので、Macをディスプレイに繋げばマイクも使える状態になった。

2週間ほど使ってみたところ、同僚からの評判はいい。いい声になったとさえ言われる。録音して試してみた感じでは、同じ部屋で子供が騒がしくしていてもかなり小さな音量になっている。Discordのようにノイズキャンセリング処理のしっかりしたツールならほとんど伝わることがない。これが他の製品よりよい結果なのかどうかはわからない、かなり満足した。

ディスプレイを介して接続されたShure MV7


エピローグ

パンデミックを機に、新しい暮らし方、働き方を模索する動きも多い。私の勤務先でも「フレキシブルワークスタイル制度」というのが導入されている*1。マイクを買ったことで、ビデオ会議への心理的な負担が小さくなったのはもちろん、いいマイクを持っているという不思議な充足感が得られた。

気をよくして、3歳の息子とPodcastごっこをしてみたが、ハマっているゲームについて話すばかり。10年後くらいに聞き返したい。

ちょっとスクリプトを書くくらいの気持ちで作るSwiftUIアプリ

12年前くらいからiOS向けのアプリを作ってきた。最初は学生の個人開発、途中から仕事、そして最近は(仕事ではあまりやらなくなったので)趣味的にやっている。UIKitで、はじめの頃はUITableViewが難関だった。毎年のアップデートでUIKitはどんどん拡充されて、Objective-CはMRCからARCへ、そしてSwiftも出た。

毎年の変化を差分で学んできて、振り返ってみると、当初のそれからは大きく変わっていて、便利なんだけど、とにかく膨大だ。

SwiftUIの登場

というところで、2019年にSwiftUIが出た。SwiftUIを使うと、宣言的にユーザーインターフェースを構築できる。UIKitでできること全てをSwiftUIで実現できるわけではないが、それでも2020年のアップデートでかなりカバー範囲が拡がった。

それで、SwiftUIでちょっと何か作ったりしている。例えばメニューバーに目玉を表示するやつなんかもそうだ。これはとても楽しい。

何かをリスト表示したいとき、SwiftUIならListを使って数行で書ける。UITableViewDataSourceを使っていたのと較べれば雲泥の差だ。とはいえ最近ではUIKitも進歩していて、そもそもリスト表示でもUICollectionViewを使うのが推奨されているし、iOS 13やiOS 14で追加された機能を使うと、以前とは別次元の使いやすさになる。

とにかくそういうわけで、SwiftUIでアプリを作るのは楽しい。Xcodeを開いて、適当なテンプレートを選んで、小一時間プログラミングすると、ちょっとくらいのものができる。SwiftUIなら、ちょっとスクリプトを書くくらいの気持ちGUIアプリを作れる。子供にアプリを作るのも、そういう感覚があったからできたことだ。

SwiftUIのこういう気安さは、極めて本質的なことだと思う。プロトタイプを開発するのにもいいが、自分だけにしか需要がないアプリを作るのにもいい。堅苦しく考えれば、ソフトウェア開発の民主化である。多くの人が、自分のほしいソフトウェアを自分で作れるようになったら、こんなにすごいことはない。自分というドメインのエキスパートは、自分自身に他ならない。

SwiftUIを学ぼう

f:id:cockscomb:20210214163704p:plain

ここまで書けば、SwiftUIについて学びたくなった人がほとんどだと思う。そういう人はまずSwiftUI Tutorialsをやってみるのもいいだろう。あとはとにかくドキュメントを読み込んだり、WWDCのビデオを全部みれば、だいたいのことはわかります。

ほかに信頼できる最新のリソースはないかな〜、というところで、来週(2021年2月22日)発売のWEB+DB PRESS Vol. 121で、同僚のid:yutailang0119id:kouki_danらとともに「iOS 14最前線」という特集を執筆しました。この特集では、SwiftUIの基本的な要素がなめるように紹介しているほか、使いやすくなっているUICollectionViewの最新情報や、iPadOSに最適化したりウィジェットを開発したりするための知識を詰め込んでいます。これはお得!どうぞお買い求めください。

WEB+DB PRESS Vol.121

WEB+DB PRESS Vol.121

  • 発売日: 2021/02/22
  • メディア: Kindle

メニューバーに目玉を表示するやつ

なんでかわからないけど、昔はデスクトップに目玉を表示する風習があったと思う。正月に突然そのことを思い出して、「macOS Eyball」とかでググったりした。最近は流行ってないらしい。

マウスカーソルを追いかける目玉を作るには、目玉の座標とマウスカーソルの座標を使って何か計算してやればいい。なつかしの三角関数っていうやつだ。どれどれと思ってちょっとコードを書いてみる。

元日にこんなことしているのもおかしいけど、子供の世話をしつつ数時間やったら、それなりに形になった。近頃はSwiftUIっていうやつがあるから、こういうのを作るのも気が楽。

ノスタルジックな気持ちになりたい人もいるかと思って、Mac App Storeで販売することにしました。みなさまの暖かいご支援に感謝いたします。もし利益が出たら次回作への意欲に変換します。

Eyeballs at Menu Bar

Eyeballs at Menu Bar

  • 尋樹 加藤
  • ユーティリティ
  • ¥120

子供にアプリを作る

3歳の息子にiPad Proを与えている。2018年の11インチのiPad Proで、僕のお下がりだ。Apple Pencilも与えてあるが、こちらは1歳になったばかりの娘が狙っているため、恐れた息子はApple Pencilをなるべく片付けておく。

自由に使っていい

iPad Proを与えたからといって、3歳の息子がそれで何か大層なことをするということはない。退屈なときにYouTube Kidsで何かを見ていることが多い。ときどきGarageBandiRig Keys 2で音を出して遊んだり、ProcreateApple Pencilで謎の絵を描いたりしている。こういうのは大人も一緒にやってあげると喜ぶ。Smart Keyboardをくっつけて「ブログを書いてる」と宣ったりもする。あとは週末に祖父母とFaceTimeをする。

とにかくiPadを自由に使わせている。自由に使えない道具に関心を持ち続けるのは難しかろうから、最低限ペアレンタルコントロールを設定して、あとはなるべく好きにさせる。

悪い大人

大人というのは悪いから、ひらがなのキーボードを見せて、これで自分の名前を入力してみなさい、と勧めたりもする。もちろんそこには、文字を覚えさせようという魂胆がある。息子も素直なもので、喜んで従う。

あるとき息子が自分の名前を入力したというので見せてもらうと、そこには逆さまに並んだ名前があった。ふつうに入力するより難しいだろうに、子供というのは不思議だ。

その晩、妻が一計を案じて、入力した文字が読み上げられたらいいのではないか、と言う。しからばと思い、僕は自慢のMacBook Pro (13-inch, M1, 2020)を取り出した。

アプリを作った

f:id:cockscomb:20201215194655j:plain
写真を撮ろうとしたら娘がいたずらしにきた

AppleプラットフォームではAVSpeechSynthesizerというのを使うと、テキストを読み上げさせられる*1。近頃はSwiftUIがあるので、UIを作るのも簡単。

アプリのコードはこういう感じ。コピペしたら動くと思う。

import SwiftUI
import AVFoundation

@main
struct SpeechApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

struct ContentView: View {
  @StateObject var speaker = Speaker()

  var body: some View {
    VStack {
      TextField("Text", text: $speaker.text)
        .font(Font.system(size: 36))
        .textFieldStyle(RoundedBorderTextFieldStyle())
        .padding()

      Button(action: speaker.speech) {
        Label("Play", systemImage: "play.fill")
          .font(Font.system(size: 36))
      }
      .padding()
      .disabled(speaker.isSpeaking)

      GroupBox(label: Label("Speed",
                            systemImage: "speedometer")) {
        Slider(
          value: $speaker.rate,
          in: Speaker.minimumRate...Speaker.maximumRate,
          minimumValueLabel: Image(systemName: "tortoise"),
          maximumValueLabel: Image(systemName: "hare")) {
        }
        .disabled(speaker.isSpeaking)
      }
      .padding()
    }
  }
}

class Speaker: NSObject, ObservableObject {
  static let defaultRate = AVSpeechUtteranceDefaultSpeechRate
  static let minimumRate = AVSpeechUtteranceMinimumSpeechRate
  static let maximumRate = AVSpeechUtteranceMaximumSpeechRate

  private let synth = AVSpeechSynthesizer()

  @Published var text: String = ""
  @Published var rate: Float = defaultRate
  @Published private(set) var isSpeaking: Bool = false

  override init() {
    super.init()
    synth.delegate = self
  }

  func speech() {
    let utterance = AVSpeechUtterance(string: text)
    utterance.voice = AVSpeechSynthesisVoice(language: nil)
    utterance.rate = rate
    synth.speak(utterance)
  }
}

extension Speaker: AVSpeechSynthesizerDelegate {
  func speechSynthesizer(
    _ synthesizer: AVSpeechSynthesizer,
    didStart utterance: AVSpeechUtterance
  ) {
    isSpeaking = synthesizer.isSpeaking
  }
  func speechSynthesizer(
    _ synthesizer: AVSpeechSynthesizer,
    didFinish utterance: AVSpeechUtterance
  ) {
    isSpeaking = synthesizer.isSpeaking
  }
}

10分くらいでプロトタイプができて、見かけをちょっとマシにしたり、読み上げ速度を変えられるようにしたり、実機にインストールしたりで、トータルで2時間くらいかかっている。

子供にアプリを与える

翌朝、息子にアプリの入ったiPadを見せた。尊敬されたいから、お父ちゃんが作ったアプリだよ、とアピールした。息子もなんとなく嬉しそうに遊んでくれる。いい子だ。

最初は自分の名前を読み上げさせたりしていたが、すぐに滅茶苦茶な文字列を読み上げさせる方が楽しいことに気づいた。子供は自由。

ということで、必ずしも思い通りにいくわけではないが、悪くはない。子供にアプリを作るのは新しい感じがある。最近はApple Developer Programに加入していなくても、とりあえず実機にインストールするくらいはできるようになっている。加入すればAd hocに配信できるし、なんならApp Storeでリリースもできる。僕は加入している。

ということで、子供にiPadを与えたり、SwiftUIで気楽にアプリを作るのは楽しい。

最新 Apple iPad Air (10.9インチ, Wi-Fi, 64GB) - シルバー (第4世代)

最新 Apple iPad Air (10.9インチ, Wi-Fi, 64GB) - シルバー (第4世代)

  • 発売日: 2020/10/23
  • メディア: Personal Computers

*1:Webの技術で作りたければWeb Speech APIという選択肢もあると思う。