Edited at

iPhoneを持ってない僕が無理やりiOSの4択クイズアプリを作ってみるとこうなる


なんなのこれ?

iOS開発初心者の最初の一歩として4択クイズアプリがよく選ばれているらしい。

僕もiOS開発したことないのでiOS開発初心者には違いないので、それを僕が作ったらどうなるのかをただ試してみたかっただけです。

4択クイズアプリを作りながら書いてます。


仕様

初心者なのでまずは仕様を定めます。

開発初心者のための4択クイズアプリなのでごくごく簡単に。


  1. なんかボタンを押すとクイズが始まる

  2. 問題と選択肢4つが出てくる。たぶん、問題はUILabelで選択肢はUIButtonだと思う。

  3. 選択肢を1つ選ぶと正解か不正解かが示される。

  4. 正解なら次のクイズが出てくる。不正解なら始めのなんかボタンがでてくる。

  5. 10問ぐらいでいいのでは?

  6. 全部正解するとなんかでる。

こんな感じ? iOSのアプリが分からん。全然固まってない。


モデル

初心者なのでモデルをある程度固めます。

何はともあれ問題モデル。

struct Question {

struct Answer: Equatable {

let text: String

let correct: Bool
}

let text: String

let answers: [Answer]
}

シンプル。

選択肢(String)の配列と、正解のindexを持つとかするかもだけど、はっきり言ってそれは面倒くさい。

選択肢自体が正解か不正解かを知っていればそれでいい。

ただQuestionさんには4択クイズの問題としての矜持がありますのでこれだとまずいです。

ちょっと細工します。

enum QuestionError: Error {

case answersCountNotFour

case correcetCountNotOne
}

struct Question {

struct Answer: Equatable {

let text: String

let correct: Bool
}

let text: String

let answers: [Answer]

init(text: String, answers: [Answer]) throws {

guard answers.count == 4 else {

throw QuestionError.answersCountNotFour
}

guard answers.filter({ $0.correct }).count == 1 else {

throw QuestionError.correcetCountNotOne
}

self.text = text
self.answers = answers.shuffled()
}
}

Questionさんは4択クイズの問題なので選択肢が4つ以外なら超怒ります。

正解が1つ以外でも超怒ります。

あと、毎回選択肢が同じ並びなのはちょっとどうかと思うのでshuffled()します。

選択肢自体が正解かどうかを知ってるので単にかき混ぜるだけでOK。


読み手

クイズなので問題を読み上げる人も欲しいですね。

class GameMaster {

private let questionStack = QuestionStack()

func next() -> Question {

return questionStack.pop()
}
}

あれ?問題の山が...


問題の山

見たことのないQuestionStackなるクラスが出てきてしまったので、これを実装。

class QuestionStack {

private var questions = Queue<Question>()

init() {

QuestionLoader().load { question in

self.questions.push(question)
}
}

func pop() -> Question {

defer {

QuestionLoader().load { question in

self.questions.push(question)
}
}

return questions.pop()
}
}

...多分この人は、Queueに問題をため込んでおいて、pop()でそれを渡すのでしょう。

で、Queueが空にならないようにQuestionLoaderから問題を貰う、のだと思う。


キュー

とりあえず、Queueを実装。

struct Queue<Value> {

private var values: [Value] = []

private let semaphore = DispatchSemaphore(value: 0)

mutating func push(_ new: Value) {

values.insert(new, at: 0)

semaphore.signal()
}

mutating func pop() -> Value {

semaphore.wait()

return values.popLast()!
}
}

スレッドをブロックするタイプのキューです。

念のため書いておきますが、今まさにこの順番で実装してます。

こっそりとユニットテストを行ってますが。


JSONを使いたい

先に進むに当たってJSONからQuestionを作れるといいような気がしたので。


/// 問題集をJSONから取り出すためのstruct
/// 1つ目の答えが正解でなければならない
struct CodableQuestion: Codable {

let text: String

let answerTexts: [String]

enum CodingKeys: String, CodingKey {

case text

case answerTexts = "answers"
}
}

こんな感じだ。

あとは、

extension CodableQuestion {

func convert() throws -> Question {

let answers = zip(answerTexts, 0...)
.map { ($0.0, $0.1 == 0) }
.map(Question.Answer.init)

return try Question(text: text, answers: answers)
}
}

Questionに変換できないと意味がない。


問題を取り寄せる人

どこかからか知らないけど、問題を取り寄せる人です。

class QuestionLoader {

func load(_ completion: (Question) -> Void) {

let json = """
{
"
text": "もんだい",
"
answers": [
"
こたえ1",
"
こたえ2",
"
こたえ3",
"
こたえ4"
]
}
"""

do {
let data = json.data(using: .utf8)
let q = try JSONDecoder().decode(CodableQuestion.self, from: data!)

let qq = try q.convert()

completion(qq)

}
catch {

fatalError("Can not load question.")
}
}
}

仮だ! どう考えても仮置きだ!

依存性の注入? なにそれ、おいしいの?


UI

やっとUI作れるところまできたが、大問題としてiPhoneをほぼ触ったことがないので標準的UIすらわからない。というか、デザインセンスのかけらもない。困った。

出題画面はテーブルビューのほうがいい気がするんだけど、まずはmacOSならこうするだろうというUILabelとUIButtonを使うことにする。


AnswerButton

いきなりUIButtonをサブクラス化する。


class AnswerButton: UIButton {

var answer: Question.Answer? {

didSet {

setTitle(answer?.text, for: .normal)
}
}
}

Answerを投げつけておけばいいようにしてくれるボタン。


出題画面

ttttttってキャプションのボタンはすべてAnswerButtonです。

うーん。 絶対変だろ。


QuestionViewContrtoller

この人は出題することしか考えてない人。ユーザが正解するかしないかには何の興味もない。自分の消滅にも興味がない。

import UIKit

class QuestionViewController: UIViewController {

@IBOutlet private weak var questionLabel: UILabel?

@IBOutlet private weak var button01: AnswerButton?
@IBOutlet private weak var button02: AnswerButton?
@IBOutlet private weak var button03: AnswerButton?
@IBOutlet private weak var button04: AnswerButton?

var question: Question? {

didSet { validate() }
}

var completor: ((Question, Bool) -> Void)?

private func validate() {

questionLabel?.text = question?.text

button01?.answer = question?.answers[0]
button02?.answer = question?.answers[1]
button03?.answer = question?.answers[2]
button04?.answer = question?.answers[3]
}

override func viewDidLoad() {

super.viewDidLoad()

validate()
}

@IBAction private func answer(_ button: AnswerButton) {

guard let q = question, let c = button.answer?.correct else {

fatalError("Missing Question.")
}

completor?(q, c)
}
}

めちゃくちゃシンプル。

何も言うことはない。


GameViewController

QuestionViewContrtollerの画面を表示する人がいなかった。

テンプレートのViewControllerをちょろっと改変。

import UIKit

class GameViewController: UIViewController {

private let gameMaster = GameMaster()

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

guard let vc = segue.destination as? QuestionViewController else {

return
}

vc.question = gameMaster.next()

vc.completor = { q, c in

vc.dismiss(animated: true, completion: {})
}
}
}

ちょろっと改変ではなかった。全然違うものになった。


画面遷移

たぶんこうするんだと思う。


First Test

初めてUIを試してみるぞ!

まあ、UnitTestはしてるんですけど。

わーい。うごいたー。


依存性の注入

初めからやっておけよ案件。


QuestionStack.swift

protocol QuestionStack {

func pop() -> Question
}



GameMaster.swift

class GameMaster {

private let questionStack: QuestionStack

init(_ questionStack: QuestionStack) {

self.questionStack = questionStack
}

func next() -> Question {

return questionStack.pop()
}
}



SingleQuestionStack.swift

class SingleQuestionStack: QuestionStack {

private var questions = Queue<Question>()

private let loader: QuestionLoader

init(_ loader: QuestionLoader) {

self.loader = loader

loader.load { question in

self.questions.push(question)
}
}

func pop() -> Question {

defer {

loader.load { question in

self.questions.push(question)
}
}

return questions.pop()
}
}


class MockQuestionLoader: QuestionLoader {

func load(_ completion: (Question) -> Void) {

let json = """
{
"
text": "もんだい",
"
answers": [
"
こたえ1",
"
こたえ2",
"
こたえ3",
"
こたえ4"
]
}
"""

do {
let data = json.data(using: .utf8)
let q = try JSONDecoder().decode(CodableQuestion.self, from: data!)

let qq = try q.convert()

completion(qq)

}
catch {

fatalError("Can not load question.")
}
}
}


GameViewController.swift

class GameViewController: UIViewController {

private let gameMaster = GameMaster(SingleQuestionStack(MockQuestionLoader()))

...



クイズのネタ探し

ここに至ってクイズのネタ自体が存在しないことが発覚!!

...

...

...

しばし検索したが、そんな都合のいいものは存在しなかった。


LocalQuestionLoader

いきなり最初の実装は捨てられました。

バンドル内にある大人の事情によりお見せできないJSON化された4択クイズ問題集を読み込むローダーです。

問題数が少ないのでループでごまかします。

import Foundation

class LocalQuestionLoader: QuestionLoader {

private var questions: [Question]
private var cursor = 0
private let questionCount: Int

init() {

questions = LocalQuestionLoader.load()
questionCount = questions.count
}

private static func load() -> [Question] {

guard let jsonPath = Bundle.main.url(forResource: "Questions", withExtension: "json"),
let jsonData = try? Data(contentsOf: jsonPath),
let qs = try? JSONDecoder().decode([CodableQuestion].self, from: jsonData),
let qs2 = try? qs.map({ try $0.convert() }) else {

fatalError("Can not load questions.json")
}

return qs2
}

func load(_ completion: (Question) -> Void) {

if questionCount <= cursor {

cursor = 0
}

completion(questions[cursor])

cursor += 1
}
}


正解不正解の表示と次への移行

GameViewControllerの画面を再利用して正解不正解を表示します。


GameViewController.swift

class GameViewController: UIViewController {

private var gameMaster = GameMaster(SingleQuestionStack(LocalQuestionLoader()))

@IBOutlet private weak var button: UIButton?

@IBOutlet private weak var label: UILabel?

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

guard let vc = segue.destination as? QuestionViewController else {

return
}

vc.question = gameMaster.next()

vc.completor = { q, isCorrect in

defer {

vc.dismiss(animated: true, completion: {})
}

guard isCorrect else {

self.gameMaster = GameMaster(SingleQuestionStack(LocalQuestionLoader()))

let correctAnswer = q.answers.first { $0.correct }

self.button?.setTitle("最初から", for: .normal)
self.label?.text = "不正解\n正解は「\(correctAnswer!.text)」です。"

return
}

self.button?.setTitle("次のクイズ", for: .normal)
self.label?.text = "正解です。"
}
}
}



一応完成

少なくとも4択クイズアプリとしては機能するようになりました。

お疲れ様です。


いやまて

入れるか入れるまいか一瞬だけ悩んで結局入れなかったものに、「過不足なく4つの要素を持たなければインスタンス化できない入れ物」があります。

これ作っちゃいましょう。

struct AnswerBox {

private let answers: [Question.Answer]

init(_ answeres: [Question.Answer]) throws {

guard answers.count == 4 else {

throw QuestionError.answersCountNotFour
}

guard answers.filter({ $0.correct }).count == 1 else {

throw QuestionError.correcetCountNotOne
}

self.answeres = answeres
}

subscript(_ index: Int) throws -> Question.Answer {

switch index {

case 0..<4: throw QuestionError.outOfBounds

default: return answers[index]
}
}

う~ん。いらないか。


山積する問題たち

QuestionStackじゃなくてIssueのほうだ!

回答後のdissmisのアニメーションを逆方向にして、違う画面に遷移しているように見せかけるとか思ってたんだけど、そもそもちゃんとアニメーションしないじゃん!なんで??

あと、途中でアプリを終了されたりしたときの処理が分かんないし、なんもわからん。


改良点

問題はどこか遠いところから取り寄せるものと思いながら作ったので、当然そのような変更は簡単にできるようにはしてある。

QuestionStack を調整することで、先読みする問題の数も変更可能なはず。

Queueは、失敗だな。使う方法を変えるとかしないと。 これたぶんメインスレッドをブロックしてるよね?

問題の難易度やジャンルの追加はちょっと面倒くさい。モデルから作り変えないとダメかも。

UIは壊滅してるので、もはや改良とかいう話ではない。


結論


どうにもならない

やっぱりiPhoneユーザじゃないとダメでしょ、基本的なUIがわからん。

というか、俺のセンスがなさ過ぎた。

裏側的には普通のiOS開発初心者よりはましになってるはずだ!

なってないと困る。


追記

記事を書いたあとで最初に書いた仕様を満たしていないことに気付きました。

手元では修正しました。

プロジェクト全体をGitHubにあげています。