STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

レシートプリンターの印刷が途中で停止する不具合を解消した話

こんにちは! STORES レジ の開発をしている iOS / Android エンジニアの @satoryo056 です。
今回は STORES レジ のレシート印刷で起きた不具合と解消方法についてご紹介します。

そして今回対応した内容について、先日行われた iOSDC Japan 2025 で発表してきました。
ブログの最後に発表した感想や登壇資料を掲載しましたので、そちらも合わせてご覧ください。

背景

STORES レジ について

STORES レジ (以下、レジアプリ)は iOS と iPadOS 向けに提供しているPOSレジアプリで、店舗のオーナーさんやスタッフさんが実店舗(オフライン)でのお会計に利用しています。
多くのオーナーさんやスタッフさんがレジアプリに求めるものとしてレシートや領収書の印刷機能があります。
iPadOS版レジアプリではこの機能をサポートしており、各メーカーのレシートプリンターと接続してレシート印刷をすることができます。

レシートプリンターについて

レシートプリンターはその名の通り、レシートや領収書を印刷するための専用のプリンターです。
レジアプリでは Bluetooth や USBケーブル、有線LAN などを使用しレシートプリンターと接続することでレシート印刷することができます。

レジアプリで印刷できるレシートの例

印刷が停止する不具合に遭遇

先日レジアプリの Swift6 対応でレシート印刷処理の改修を行ったのですが、動作確認としてQAを実施してくれているチームから以下のような報告が届きました。

レシート印刷が途中で停止してしまう

報告を確認してみると、本来であれば左側の画像のようにQRコードまで印刷されているはずのレシートが、右側の画像のように途中で印刷が停止してしまっていることが分かりました。
これは正常な動作ではないため調査することにしました。

レシート印刷の正常時・異常時の比較

不具合の原因調査

調査の観点と結果

まず原因特定のため3つの観点で調査しました。

  • 複数のレシートプリンターで再現するか
  • レシート印刷時にエラーログが出力されているか
  • 印刷処理のどこで停止しているか

調査したところ以下のことが分かりました。

  • メーカーは関係なく複数のレシートプリンターで同様の不具合が再現できた
  • レジアプリからレシートプリンターへ、レシートデータを送信する部分で印刷処理が停止していた

これらを踏まえてソースコードのどの部分で不具合が発生しているか確認していきます。

レジアプリのレシート印刷処理について

レジアプリでは Foundation フレームワークの Stream クラスを利用してレシート印刷処理を自前で実装しています。
OutputStreamInputStream と聞くと知っている方は多いのではないでしょうか。

developer.apple.com

レシートプリンターには各メーカーや有志が開発・提供している SDK がありアプリに SDK を導入することで印刷処理を実装することが可能なのですが、レジアプリでは以下の理由から Stream クラスを使用した自前実装を行っています。

  • SDK ごとに API の仕様が異なるため共通のインターフェース化の難易度が高い
  • そもそも SDK がないプリンターが存在する

もちろん SDK を利用することにより「独自実装よりも少ないコードでレシート印刷処理が実装できる」などのメリットがあるため、SDK を採用するか自前で実装するかは開発者の好みに左右されます。

不具合の原因をソースコードで確認

以下がレシート印刷処理の実装例です。
なおソースコードはブログ掲載用として書き直したため、実際のレジアプリのソースコードとは異なります。

// レシート印刷コマンドの例(いずれもData型を返す)
// テキスト印刷コマンド
public func textCommand(_ text: String) -> Data {
    text.data(using: .utf8) ?? Data()
}

// テキストを太文字にするコマンド
public func textCommand(_ enabled: Bool) -> Data {
    let value: UInt8 = enabled ? 0x01 : 0x00
    return Data([0x1B, 0x45, value])
}

// レシート印刷に使用するコマンド(Data型)をバイト配列に変換する
var bytes: [UInt8] = receiptData.toByte()

// EASession でアプリと接続したレシートプリンターとのセッションを開始しておく
// EASession の OutputStream にバイト配列を書き込みレシートプリンターに印刷コマンドを送信する
func write() {
    let session: EASession
    let failedLimit: UInt = 10
    let failedCount: UInt = 0

    // bytes の書き込みが完了するか、10回書き込みに失敗するまでループ処理
    while let outputStream = session.outputStream, failedCount < failedLimit {
        // OutputStream に印刷コマンドを書き込みレシート印刷を実行
        let writtenBytes = outputStream.write(bytes, maxLength: bytes.count)

        if writtenBytes > 0 {
            // 書き込みが行われたらバイト配列から書き込み分を取り除く
            let toRange = min(bytes.count, Int(writtenBytes))
            bytes.removeSubrange(0 ..< toRange)
        } else {
            // 書き込みに失敗したら失敗カウントを1つ増加
            failedCount += 1
        }
    }
}

前提として、事前に External Accessory フレームワークを利用してアプリとレシートプリンターを Bluetooth で接続し、EASession を利用してレシートプリンターとのセッションを開始する必要があります。レシートプリンターに印刷を実行してもらうために EASessionOutputStream にバイト配列の書き込みを行っているのが write() になります。

write() では while によるループ処理で何度か書き込みを行いますが failedCount が10回に達してしまい、ループが終了し書き込みが途中で終わっていたことが分かりました。
まだQA中だったためこの処理がそのままリリースされることはありませんでしたが、エラーハンドリングやリトライ処理が不十分な状態でした。
ただ、ソースコードから書き込みに失敗し続けたことが分かりましたが、そもそもなぜ書き込みに失敗するのでしょうか。

書き込みに失敗する原因

以下の表は Epson 製のレシートプリンターで印刷を行い、印刷のループ処理が書き込み失敗により終了した際のレシートデータ量をログ出力しまとめたものです。

レシート印刷処理での書き込み量

表から1回目と4回目に書き込みが行われ10回目の書き込みが行われた後に 35,425 Byte が OutputSteam に書き込まれず残っていることが分かります。
これは1回目の書き込みで EASessionOutputStream のバッファサイズ制限に到達してしまい、レシートプリンターの印刷コマンド処理が間に合わずループ処理で書き込みに失敗(0 Byte)したことが分かりました。

ちなみに4回目で書き込みができた理由は、このタイミングでたまたまレシートプリンターが印刷コマンド処理が間に合ったからです。ただし、write() のループ処理では EASession の OutputStream の状態をリアルタイムで監視することができない ため、アプリとレシートプリンター間の同期を取ることができません。

そのため、 OutputStream の状態変化を監視する仕組みが必要 です。

不具合の解消方法

「レジアプリのレシート印刷処理について」 の項目で述べましたが、レジアプリでは Foundation フレームワークの Stream クラスを利用しています。
不具合を解消するには StreamDelegate を利用し Stream.Event を監視する必要があります。

developer.apple.com

developer.apple.com

// StreamDelegate を利用し Stream の状態 (Event) を監視できる
final class PrinterStream: StreamDelegate {
    func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
        switch aStream {
        // OutputStream
        case _ as OutputStream:
            switch eventCode {
            // OutputStream が再度書き込みが可能になった際に呼ばれる
            case .hasSpaceAvailable:
                // 書き込みを再開
                write()
            }
        ....
        }
    }
}

上記のように StreamDelegate を利用したクラスで OutputStream の監視を行います。eventCode にはいくつか種類がありますが、OutputStream が書き込み可能になった Stream.EventhasSpaceAvailable が呼ばれた際に再び write() を実行することでレシートコマンドの書き込み処理を再開することができます。

これで「レシート印刷が途中で停止してしまう不具合」が解消しました!

iOSDC Japan 2025 に登壇しました

今回の内容を元に、先日行われた iOSDC Japan 2025 に登壇しました。 初めての登壇で約40分、レシート印刷と今回ご紹介した不具合について話させていただきました。

登壇の様子

40分という長い時間で見に来てくれた方を飽きさせず重要な部分を覚えてもらいながら話を進める必要があるため、話の構成に非常に苦労しましたが、登壇後は社員や友人たちに「良かった」と声をかけてもらえて一安心でした。
初めての社外のカンファレンスでの登壇でしたが良い緊張感で挑むことができて、楽しかったです。また登壇したいです。
iOSDC Japan の雰囲気は大好きで、今年も思いっきり楽しむことができました!

以下のリンクから登壇資料やプロポーザルが閲覧できますので、もしよろしければ合わせてご覧ください。 fortee.jp

また 10/17(金) には STORES でアフターイベントも開催します!ぜひご参加ください。

hey.connpass.com

おわりに

今回はレシートプリンターを使用した印刷処理の実装例と不具合の解消方法について紹介しました。
また、 STORES のモバイルアプリは他にもあり、各チームさまざまな取り組みをしています。

もし少しでも興味をもっていただけましたら、ぜひ採用サイトをご覧ください。

最後までご覧いただきありがとうございました。

jobs.st.inc