Pythonista3で英語学習アプリ風に作ってみた
皆さんはPythonista3をご存じでしょうか。iOSデバイス上でPythonによるプログラミングが出来る、iPhone/iPadユーザーにはとても便利なアプリです。
今回はPythonista3を使って、TOEIC Part5を意識した空欄選択問題の練習をするアプリ風なプログラムを作ってみました。
荒削りですが、試行錯誤した点を含めて備忘録的に投稿させていただきます。
1. プログラムを作った背景
会社からTOEICを受講するよう言われたため勉強を始めたのですが、Part5の正答率を上げるためには苦手な問題を繰り返し学習して潰し込むのが良きと考え、App Storeを探索していました。
が、下記の要件を満たす、なるべく安いアプリが見つからず、であれば作ってしまおうとなりました。
[要件]
- CSVファイルから独自の問題をインポート出来る
- 品詞問題に対応するよう用意したフレーズを使用できる(≠他問題の選択肢ランダム)
- 解答後には解説が確認できる
- 苦手問題をマーキングしたり、学習結果を記録として出力できる
- 繰り返し学習に対応するため出題順をシャッフル出来る
- 正解の英文を音声で読み上げてくれる
2. Pythonista3の紹介
Pythonista3は有償App(2024-04-29執筆時点;¥1,500)となりますが、主に下記のような特徴を有しています。
ただし、App Storeで配布するアプリは作れません。代わりに、ホーム画面に設置するショートカットからプログラムを実行することでアプリと同じような扱いができるためアプリ風と表現しています。
- iOSデバイスでPythonのプログラムが組めて実行できる
- 統合開発環境(IDE)により、iOSデバイスだけで開発出来る
- 専用のUIデザイナーがあり、Tkinterに代わって簡単にUIが作れる
- 主要ライブラリの多くがそのまま使える
- 画面タッチは勿論、傾きセンサやGPS情報などのiOSデバイスの入力手段が使える
- リマインダー、通知センター、位置情報、クリップボード、日時・写真ピッカーなどが使用可能
- ショートカットでホーム画面に登録することでアプリ風に使うことができる
- アクションゲーム向けのスプライトやアニメーション機能に、既成のグラフィック、サウンドも標準で搭載
他にも沢山特徴があるのですが、関心のある方は公式サイトや専門書籍をご覧願います。
なお、先人の皆さんの記事群がとても秀逸なので、色んな使い方を探索されたい方は必見です。
[参考にしたソース]
3. 利用環境とアプリ風の概要
[スクリーンショット]
英語学習 |
||
---|---|---|
ホーム画面 アイコン |
正誤判定の画面 | 取組状況の画面 |
[利用環境]
- iOS 17.4.1
- Pythonista3 ver 3.4
[アプリ風の概要]
- アイコンはiOSのショートカットで適当に作成
- CSVファイルから問題、選択肢、正解、解説を読み込み、iOS上のGUIで出題する
- 選択肢をCSVファイルから読み込むので品詞問題にも対応できる
- 空欄を含む英文に対して、A〜Dの選択肢のフレーズが表示され、タッチで選択し回答
- 解答すると正誤判定して、その問題の解説を表示
- 回答時間は20秒としてスライダーがカウントダウン(ゼロになっても回答は可能)
- 間違えた問題は自動でincorrectフラグが立つ
- 苦手だと思う問題は自分でrewiewフラグを設定できる
- 全問一巡すると、結果を画面&CSVファイルで出力する
- その後、出題順をシャッフルして、再び最初に戻って問題を繰り返せる
- 解説が表示されている時は、iOSのテキストtoスピーチ機能で音読も聴ける
- ステータスは自動保存しない設計なので、キリの良いところで保存ボタンを押す
4. アプリ風の設計と実装
思いのほかデータと処理が膨らんでしまったためclass化してみました。
pyuiファイル
全部で27個のパーツを配置。
- ボタン=10個(選択肢4つ、<、>、保存、音読、情報、シャッフル)
- ラベル=9個(選択肢4つ、スイッチ2つ、回答、正解、大きな○×マーク)
- テキストフィールド=3個(問題番号、回答、正解)
- テキストビュー=2個(出題欄、解説欄)
- スイッチ=2個(review, incorrect)
- スライダー=1個
pyファイル
長くてすみません。
ui.ViewのMyViewクラスを作り、そこにデータとメソッドをまとめています。
ただ、UIのActionとの紐づけなど不慣れな点もあり、大変見苦しいコードとなってしまいました。ご容赦ください。
[モジュール]
- ui...PythonistaのUIを使用するため
- sound...正解/不正解のサウンドを鳴らすため
- datetime...全問一巡した時の結果出力のタイムスタンプに使用
- threading...○×アニメーション、カウントダウンタイマーの動作に使用
- pandas...CSVファイルの読み込み、データの前処理に使用
- numpy...NaNの取り扱いのため
- re...音読する正解文を作るのに使用
- speech...正解文を音読するため
- console...alertダイアローグを表示するため
[クラス変数]
- df...問題を取り込むデータフレーム
- df_ws...ワークシート(取り組み状況)を取り込むデータフレーム
- IsDestroy...終了時にThreadを止めるためのフラグ
- play_mode...音読中であることを示すフラグ
- mode...プログラム全体でのステートを表す(answering, checking, pause)
- time_limit...タイマーの時限を0.1秒単位で表す
- pos_idx...ワークシート上での現在の出題の行位置を表す
- cur_no...現在の出題の問題番号を表す
- no_idx...問題のデータフレームでの問題番号の行位置を表す
[メソッド]
各メソッドのコード中に注釈文を付しているので割愛します。
import ui
import sound
import datetime
import threading
import pandas as pd
import numpy as np
import re
import speech
import console
class MyView(ui.View):
def __init__(self):
#スーパークラスから継承
super().__init__()
#クラス変数/ファイル指定
mondai_csv = './csv/mondai.csv'
main_pyui = './eibunpo.pyui'
self.worksheet_csv = './csv/worksheet.csv'
#メイン画面の初期設定
self.main_view = ui.load_view(main_pyui)
self.name = '文法問題'
self.add_subview(self.main_view)
self.main_view['q_no'].editable = False
self.main_view['forward_button'].action = self.forward
self.main_view['backward_button'].action = self.backward
self.main_view['Button_A'].action = self.answering
self.main_view['Button_B'].action = self.answering
self.main_view['Button_C'].action = self.answering
self.main_view['Button_D'].action = self.answering
self.main_view['review_sw'].action = self.review_sw
self.main_view['incorrect_sw'].action = self.incorrect_sw
self.main_view['save_button'].action = self.save_tapped
self.main_view['play_button'].action = self.play_tapped
self.main_view['info_button'].action = self.info_tapped
self.main_view['shuffle_button'].action = self.list_shuffle
#ファイルの読み込み
self.df = pd.read_csv(mondai_csv)
self.df_ws = pd.read_csv(self.worksheet_csv)
#クラス変数の初期設定
self.IsDestroy = False
self.play_mode = False
self.mode = 'answering' # checking pause
self.prev_mode = 'answering'
self.time_limit = 200 #0.1s単位
self.pos_idx = self.df_ws[self.df_ws['pos'] == True].index.tolist()[0]
self.cur_no = self.df_ws['No'][self.pos_idx]
self.no_idx = self.df.loc[self.df['No'] == self.cur_no].index.tolist()[0]
def start(self):
# カウントダウンタイマーのthreadを始動させる
self.update_countdown()
# 最初の設問へ
self.forward(None)
def will_close(self):
# フラグを立ててthreadを停止する
self.IsDestroy = True
@ui.in_background
def forward(self, sender):
# ▶️が押された時の処理
if sender != None:
self.pos_idx += 1
# 最終問題かどうか判定する
if self.pos_idx < len(self.df_ws):
# 最終問題でない場合は次の問題へ
self.df_ws['pos'] = np.NaN
self.df_ws.at[self.pos_idx,'pos'] = True
self.cur_no = self.df_ws['No'][self.pos_idx]
self.no_idx = self.df.loc[self.df['No'] == self.cur_no].index.tolist()[0]
# 画面表示メソッドを呼び出す
self.display(self.df.iloc[self.no_idx], self.df_ws.iloc[self.pos_idx])
else: # 最後の問題に辿り着いた時の処理
left = self.df_ws['incorrect'].isnull().sum()
if left > 0: # 残問数がまだある場合
self.prev_mode = self.mode
self.mode = 'pause'
console.hud_alert('1番最後の問題です。\nまだ残り{}問あります。'.format(left), 'error')
self.pos_idx -= 1
self.mode = self.prev_mode
else: # 残問数=0の場合は結果出力
dt_now = datetime.datetime.now()
fname = '{:%Y%m%d%H%M%S}'.format(dt_now)
self.df_ws.to_csv(f'./csv/result_{fname}.csv', index=False)
right = (self.df_ws['incorrect'] == False).sum()
wrong = (self.df_ws['incorrect'] == True).sum()
done = right + wrong
# 完了数により正答率のエラー補正
if done == 0:
accuracy = 0
else:
accuracy = right / done * 100
# ワークアウト結果と次の操作を表示する
self.pos_idx -= 1
self.prev_mode = self.mode
self.mode = 'pause'
# alertを表示する
console.alert('【WORKOUT】', '取り組み結果です。\n正答={}\n誤答={}\n正答率={:.1f}%\n\n右上⚙️で問題リストを再作成して下さい。'.format(right, wrong, accuracy))
self.mode = self.prev_mode
@ui.in_background
def backward(self, sender):
# ◀️が押された時の処理
self.pos_idx -= 1
# 最初の問題かどうか判定する
if self.pos_idx < 0:
self.prev_mode = self.mode
self.mode = 'pause'
console.hud_alert('1番最初の問題です。', 'error')
self.pos_idx += 1
self.mode = self.prev_mode
else: # 最初の問題でない場合は前の問題へ
self.df_ws['pos'] = np.NaN
self.df_ws.at[self.pos_idx,'pos'] = True
self.cur_no = self.df_ws['No'][self.pos_idx]
self.no_idx = self.df.loc[self.df['No'] == self.cur_no].index.tolist()[0]
# 画面表示メソッドを呼び出す
self.display(self.df.iloc[self.no_idx], self.df_ws.iloc[self.pos_idx])
def display(self, df_i, df_s):
# 画面表示をする
# 各データを取り込み表示する
self.mode = 'answering'
self.main_view['q_no'].text = str(int(df_i['No']))
self.main_view['question'].text = df_i['Question'].strip('”')
self.main_view['Button_A'].title=df_i['A']
self.main_view['Button_B'].title=df_i['B']
self.main_view['Button_C'].title=df_i['C']
self.main_view['Button_D'].title=df_i['D']
self.main_view['answer'].text=''
self.main_view['correct'].text=''
self.main_view['comment'].text=''
self.main_view['play_button'].alpha=0.3
# 各フラグをスイッチに取り込む
if df_s['review'] == True:
self.main_view['review_sw'].value = True
else:
self.main_view['review_sw'].value = False
if df_s['incorrect'] == True:
self.main_view['incorrect_sw'].value = True
else:
self.main_view['incorrect_sw'].value = False
# カウントダウンタイマーをリセット
self.time_limit = 200
# 音読ボタンを無効化
self.play_mode = False
self.main_view['play_button'].tint_color = '#909090'
self.main_view['play_button'].alpha = 0.3
def answering(self, sender):
# 押されたボタンに応じた回答で判定メソッドへ
if self.mode == 'answering':
self.mode = 'checking'
your_ans = sender.name.replace('Button_', '')
self.checking_answer(your_ans)
def checking_answer(self, your_ans):
# your_ansを判定する
if self.df.iloc[self.no_idx]['Answer'] == your_ans:
incorrect_value = False
check_result = '○'
sound.play_effect('voice:female_correct')
else:
incorrect_value = True
check_result = '×'
sound.play_effect('voice:female_wrong')
# 判定結果の表示
self.main_view['incorrect_sw'].value = incorrect_value
self.df_ws.at[self.pos_idx,'incorrect'] = incorrect_value
self.main_view['answer'].text = your_ans
self.main_view['correct'].text = self.df.iloc[self.no_idx]['Answer']
self.main_view['comment'].text = self.df.iloc[self.no_idx]['Explain']
# ○×マークをアニメ表示する
marking = self.main_view['mark']
marking.text = check_result
marking.text_color = 'blue'
marking.alpha = 1
threading.Timer(0.5, lambda: self.set_alpha(marking, 0)).start()
# 音読ボタンを有効化
self.play_mode = True
self.main_view['play_button'].tint_color = '#4169e1'
self.main_view['play_button'].alpha = 1.0
def set_alpha(self, obj, alpha):
# ○×マークを0.5秒たったら消す
ui.animate(lambda: setattr(obj, 'alpha', alpha), duration=0.5)
def review_sw(self, sender):
# 画面のフラグスイッチの操作を反映する
if self.main_view['review_sw'].value == True:
self.df_ws.at[self.pos_idx,'review'] = True
else:
self.df_ws.at[self.pos_idx,'review'] = np.NaN
def incorrect_sw(self, sender):
# 画面のフラグスイッチの操作を反映する
if self.main_view['incorrect_sw'].value == True:
self.df_ws.at[self.pos_idx,'review'] = True
else:
self.df_ws.at[self.pos_idx,'review'] = np.NaN
def update_countdown(self):
# スライダーによるカウントダウンタイマーの処理
if self.IsDestroy: # UI閉じた時は終了する
return
slider = self.main_view['countdown']
# modeがansweringの時だけカウントダウンする
if self.mode == 'answering':
self.time_limit -= 1
slider.value = self.time_limit / 200.0
# threadの再帰動作
threading.Timer(0.1, self.update_countdown).start()
@ui.in_background
def save_tapped(self, sender):
# 状態をセーブする
self.prev_mode = self.mode
self.mode = 'pause'
# データフレームをCSV出力する
self.df_ws.to_csv(self.worksheet_csv, index=False)
# メッセージを表示する
console.hud_alert('履歴をセーブしました。')
self.mode = self.prev_mode
def play_tapped(self, sender):
# スクリプトを音読する
if not(self.play_mode):
return
narration = self.main_view['question'].text
# 正解ワードを取得する
if self.main_view['correct'].text == 'A':
correct_word = self.main_view['Button_A'].title
elif self.main_view['correct'].text == 'B':
correct_word = self.main_view['Button_B'].title
elif self.main_view['correct'].text == 'C':
correct_word = self.main_view['Button_C'].title
else:
correct_word = self.main_view['Button_D'].title
# ナレーションに正解ワードを入れる
pattern = r'_{3,}'
narration = re.sub(pattern, correct_word, narration)
# 音読中でなければ音読する
if not(speech.is_speaking()):
speech.say(narration, 'en-US', 0.5)
@ui.in_background
def info_tapped(self, sender):
# 履歴を表示する
# modeの値を一時退避
self.prev_mode = self.mode
self.mode = 'pause'
# 履歴を算出する
right = (self.df_ws['incorrect'] == False).sum()
wrong = (self.df_ws['incorrect'] == True).sum()
done = right + wrong
left = self.df_ws['incorrect'].isnull().sum()
# 完了数により正答率のエラー補正
if done == 0:
accuracy = 0
else:
accuracy = right / done * 100
# ワークアウトを表示する
console.alert('【WORKOUT】', 'これまでの取り組み状況です。\n正答={}\n誤答={}\n正答率={:.1f}%\n残問数={}'.format(right, wrong, accuracy, left), 'OK', hide_cancel_button=True)
self.mode = self.prev_mode
@ui.in_background
def list_shuffle(self, sender):
# 出題リストを再作成する
# modeの値を一時退避
self.prev_mode = self.mode
self.mode = 'pause'
# alertを表示させる
reply = console.alert('【shuffle】', '問題リストをシャッフルします。\n回答履歴(incorrect)はリセットされますが、苦手チェック(review)はそのまま残ります。', 'OK', 'Cancel', hide_cancel_button=True)
# OK-Cancelの処理分岐
if reply == 1: # shuffleを実行する
# posを全クリアする
self.df_ws['pos'] = np.NaN
# すべての問題をシャッフル
self.df_ws = self.df_ws.sample(frac=1).reset_index(drop=True)
# incorrectを全クリアする
self.df_ws['incorrect'] = np.NaN
# 最初の行にposを指定しCSVファイルを出力する
self.pos_idx = 0
self.df_ws.at[self.pos_idx,'pos'] = True
self.df_ws.to_csv(self.worksheet_csv,index=False)
# 完了メッセージを表示する
console.alert('【shuffle】', 'shuffleが完了しました。\n作成した問題リストを開始します。', 'OK', hide_cancel_button=True)
# フラグを初期化する
self.play_mode = False
self.mode = 'answering'
self.prev_mode = 'answering'
# 最初の設問へ(再スタート)
self.forward(None)
else:
self.mode = self.prev_mode
def main():
# インスタンスを作成する
mv = MyView()
# user interfaceを表示しstartメソッドを実行する
mv.present('sheet')
mv.start()
if __name__ == '__main__':
main()
※適用ライセンス
上記プログラムを使いたい人はいないと思いますが、MITライセンスを適用としておきます。
CSVファイル
問題記載CSVに学習状況を入れるとシャッフルした際にごちゃごちゃしてしまったためCSVを分離し、問題記載CSVは参照のみ、学習状況は全て別のCSVで管理する形をとりました。
各CSVファイルのメタ情報は下記の通りです。
[問題データ]
- No...問題ごとにユニークな問題番号
- Question...問題文(空欄箇所=______)
- A...選択肢Aのフレーズ
- B...選択肢Bのフレーズ
- C...選択肢Cのフレーズ
- D...選択肢Dのフレーズ
- Answer...正解の記号(A, B, C, D)
- Explain...解説の文章
[出題回答シート]
- No...上記のNoと同一のユニークな問題番号(ただしシャッフルされている)
- Review...見直したい問題のフラグ(ブール型;Trueまたはnull)
- incorrect...不正解だった問題のフラグ(ブール型;Trueまたはnull)
- pos...現在の出題箇所(ブール型; Trueまたはnull)
5. アプリの特徴と工夫した点
問題を解くペース配分を意識したスライダー式のタイマー
油断するとペースオーバーになってしまうため、本番を意識した20秒のカウントダウンタイマーをスライダーで実装。動作にはThreadingを0.1秒間隔で適用しました。
なお、mode="answering"以外ではストップし、新しい問題に移ると再び20秒がセットされます。
def update_countdown(self):
# スライダーによるカウントダウンタイマーの処理
if self.IsDestroy: # UI閉じた時は終了する
return
slider = self.main_view['countdown']
# modeがansweringの時だけカウントダウンする
if self.mode == 'answering':
self.time_limit -= 1
slider.value = self.time_limit / 200.0
# threadの再帰動作
threading.Timer(0.1, self.update_countdown).start()
正誤判定の効果音&アニメーション+英文読み上げ
問題のマルバツをアニメーションで表示させて、組み込みサウンド(人の声によるcorrect/wrong)も適用。
ボタンを押せば、正解となる英文を読み上げる機能まで実装した。
マルバツのアニメーションは、他にスマートな手法が色々ありそうでしたが、手っ取り早くUIのラベルをフォントサイズ=100で使用。透明度を制御して判定時に0.5秒だけ表示させている。
※正解となる英文すは、正規表現を用いて、問題文の空所を正解となるフレーズに置き換えて生成。
def checking_answer(self, your_ans):
# your_ansを判定する
if self.df.iloc[self.no_idx]['Answer'] == your_ans:
incorrect_value = False
check_result = '○'
sound.play_effect('voice:female_correct')
else:
incorrect_value = True
check_result = '×'
sound.play_effect('voice:female_wrong')
# 判定結果の表示
self.main_view['incorrect_sw'].value = incorrect_value
self.df_ws.at[self.pos_idx,'incorrect'] = incorrect_value
self.main_view['answer'].text = your_ans
self.main_view['correct'].text = self.df.iloc[self.no_idx]['Answer']
self.main_view['comment'].text = self.df.iloc[self.no_idx]['Explain']
# ○×マークをアニメ表示する
marking = self.main_view['mark']
marking.text = check_result
marking.text_color = 'blue'
marking.alpha = 1
threading.Timer(0.5, lambda: self.set_alpha(marking, 0)).start()
# 音読ボタンを有効化
self.play_mode = True
self.main_view['play_button'].tint_color = '#4169e1'
self.main_view['play_button'].alpha = 1.0
def set_alpha(self, obj, alpha):
# ○×マークを0.5秒たったら消す
ui.animate(lambda: setattr(obj, 'alpha', alpha), duration=0.5)
def play_tapped(self, sender):
# スクリプトを音読する
if not(self.play_mode):
return
narration = self.main_view['question'].text
# 正解ワードを取得する
if self.main_view['correct'].text == 'A':
correct_word = self.main_view['Button_A'].title
elif self.main_view['correct'].text == 'B':
correct_word = self.main_view['Button_B'].title
elif self.main_view['correct'].text == 'C':
correct_word = self.main_view['Button_C'].title
else:
correct_word = self.main_view['Button_D'].title
# ナレーションに正解ワードを入れる
pattern = r'_{3,}'
narration = re.sub(pattern, correct_word, narration)
# 音読中でなければ音読する
if not(speech.is_speaking()):
speech.say(narration, 'en-US', 0.5)
class使用時のuiボタンとメソッドの紐付け
UIエディタでActionを指定しても起動せず。
下記の記事を参考にして、classのコンストラクタでまとめて指定すると動作できた。
def __init__(self):
self.main_view['forward_button'].action = self.forward
self.main_view['backward_button'].action = self.backward
self.main_view['Button_A'].action = self.answering
self.main_view['Button_B'].action = self.answering
self.main_view['Button_C'].action = self.answering
self.main_view['Button_D'].action = self.answering
self.main_view['review_sw'].action = self.review_sw
self.main_view['incorrect_sw'].action = self.incorrect_sw
self.main_view['save_button'].action = self.save_tapped
self.main_view['play_button'].action = self.play_tapped
self.main_view['info_button'].action = self.info_tapped
self.main_view['shuffle_button'].action = self.list_shuffle
(参考記事)
ダイアローグメッセージがフリーズ
ダイアローグを表示させようとdialogやconsole(alert)を使用するとフリーズ。
色々試行錯誤した結果、コミュニティの書き込みで対象の関数/メソッドに「デコレータ=@ui.in_background」を指定すると、メインの処理と別にバックグラウンド処理に切り替わるのでフリーズを避けられるとあり適用。
よく調べてみると公式Docsにも説明があり納得。
話が脱線しますが、dialogで写真ピッカーやinputから入力を得ようとして、フリーズしたり、戻り値がNoneであったりしたのが、このデコレータで改善させることができました。
@ui.in_background
def save_tapped(self, sender):
# 状態をセーブする
self.prev_mode = self.mode
self.mode = 'pause'
# データフレームをCSV出力する
self.df_ws.to_csv(self.worksheet_csv, index=False)
# メッセージを表示する
console.hud_alert('履歴をセーブしました。')
self.mode = self.prev_mode
(参考Docs)
「@ui.in_background」で検索するとすぐ見つかります。
Threadが止まらない
カウントダウンタイマーをThreadingで動作させているが、UIをバツボタンで閉じても、止まらず、再度プログラムを実行すると、カウントダウンが倍速になったりする不具合に遭遇。
Threadをdaemon化しても改善できず、結局、下記の記事を参考にしながら、ui.viewクラスのwill_closeメソッドを使ってフラグを立ててThreadingを終了させる形とした。
def will_close(self):
# フラグを立ててthreadを停止する
self.IsDestroy = True
(参考記事)
6. まとめ
以上、「Pythonista3で英語学習アプリ風をつくってみた」でした。
Pythonista3のことが初見だった方、聞いてはいたがよく知らなかったという方、いかがでしたでしょうか?
もし何かの一助にでもなったら幸いです。
今回のようなiPhoneのUIを使ったプログラム以外にも、グラフ機能や通信機能をつかったものなど、IoT的なものにも適用できるようで、次回は、Pythonista3でBluetooth通信(BLE)を扱ってみたいと思っています。