hishidaの開発blog

EBシリーズ(EBPocket,EBWin,EBMac,EBStudio),KWIC Finder,xdoc2txt,読書尚友の開発者ブログ

読書尚友のPDFサポート 他

PDF対応について

「読書尚友のPDF対応を開発中」という記事を2015年8月に載せてから、1年半も経ってしまった。
2015-08-26 - hishidaのblog
おさらいすると、Android用のPDF用ライブラリはmuPDFをはじめGPLのものが多く、ソースの公開義務が生じるので、なかなか使用しづらい。このため、Android 用の書籍ビューアの中には、PDFビューアの部分だけプラグインにしているものもある(MHE Novel Viewer、Perfect Viewer等)。
その後、Android 5.0 Lollipop からPdfRendererという機能が標準で備わったが、これはページ毎にImageViewにビットマップで表示するもので、拡大縮小したときにスケールに合わせてレンダリングしなおさないと文字がギザギザになる等、あまり使いやすくない。またLollipop以上でないと使用できないので、kitkat以下を切り捨てることになり、制限がきつい。PdfRendererを使用した読書尚友のPDF対応は2015年時点でできていたのだが、上記の理由で公開していなかった。
ところが最近、Apache2.0ライセンスのPDFライブラリで使用できそうなものを見つけた。
com.github.barteksc.pdfviewer.PDFView
GitHub - barteksc/AndroidPdfViewer: Android view for displaying PDFs rendered with PdfiumAndroid
スワイプによる拡大縮小、ダブルタップによる拡大もできるし、アンチエイリアスも効くので、PDFの表示 だけで編集をしないのなら、十分に実用になる。何より、Android3.0以降で使用できるので、大半のユーザが恩恵を得られる。
というわけで、読書尚友1.54でPDFビューアを実装してみた。apkのサイズが10kb→28kbと大きくなってしまったが、それほど問題にならないと思う。今の所、有料版だけの機能だが、free版にも入れるかどうかは今後検討したい。
この後の作業としては、PDF関連の機能の追加 (目次とメタ情報の表示機能)を予定している。

書籍一覧のカード型UIへの変更とブクログサポート

他に最近の改良としては、書籍一覧のUIをCardViewを使用してカード型にした(Androidのアプリの流行としては数年遅れだが)。RecyclerViewの導入も試してみたが、ViewHolderモデルのListViewと速度的な差はないので、結局ListViewのままとした。RecyclerViewでないとできないようなUI(スワイプによる削除や項目の移動など)が必要なければ、無理にRecyclerViewに変える必要はないと思う。

また、メニューボタンにブクログへのリンクを追加してみた。独自の感想投稿機能を作るよりも、青空文庫で正式に使用されている感想投稿サイトをサポートしたほうがいいと判断した。

OPDSの検討開始

次の段階として、OPDSをサポートしてみようかと思っている。
JEPA|日本電子出版協会 OPDSとは?
OPDSを実装しておけば、Project Gutenbergなども接続できるようになる。そうなると英文のePubの表示をきちんとしないといけないが(せめて英文の行端揃えは必須)、時間をかければできていくのではないかと思う。
また、有志による青空文庫のOPDSも稼働しているようだ。
20 | 2月 | 2011 | 潮流工房
継続してサポートされる保証はないが、とても有意義な試みだ。
OPDSクライアントとして単独のアプリにするか、読書尚友に組み込むかはこれから検討する。

読書尚友とEBPocket for Androidをsplit-screenに対応させた

Android7.0 Nougatからsplit-screenの機能が加わっているが、読書尚友とEBPocket for Androidをsplit-screenに対応させてみた。
といっても日常的に使用しているZenfone 3 laserにはまだAndroid7.0アップデートが来ないので、エミュレータでの動作確認になる。
(あまりアップデートが遅れるようだと、初めからAndroid7.0が搭載された格安SIMフリーのnova liteあたりに買い換えたほうがいいかもしれない)
実はAndroid 7.0 に対応していないアプリでもsplit-screenは使用できるが、"app may not work with split-screen."というメッセージが表示されてしまう。

Android 7.0 Split-screen対応

マルチ ウィンドウのサポート  |  Android Developers
Split-screenにする要件は、

  • 画面のサイズが動的に変更されても画面のパーツが正常に表示されること。読書尚友もEBPocketも画面の回転に対応しているので、これはクリアしている。
  • targetSdkVersionを24(Android7.0)以上にする
  • manifestsでapplicationかactivityに、android:resizeableActivity="true"を記述する。

エミュレータでの実行結果は次の通り。


縦横でEBPokcetのレイアウトが変わっていることがわかる。読書尚友で単語を選択してEBPocketでクリップボード検索で辞書を引くこともできる。これはEBシリーズ全体でやりたかったことのゴールに近い。

さてここで一つ問題があり、targetSdkVersionをAPI24(Android7.0)以上にするということは、API23(Android 6.0)で導入された新しいパーミッションの考え方に対応しないといけないということ。
どちらかというと、こちらの作業のほうが大変だった。

Android 6.0 パーミッション対応

実行時のパーミッション リクエスト  |  Android Developers
Android 5.x以前のパーミッションの考えかたは、アプリのインストール時に一括で許可を与えるものだったが、Android6.0からは、パーミッションを使用するときに個別に許可・不許可できるようになった。
例えば、「カメラは許可するが位置情報の使用は許可しない」とかを選択できるようになった。
読書尚友、EBPocketの場合は、WRITE_EXTERNAL_STORAGEのパーミッションが必要になる。

パーミッションがあるかどうかを確認し、ない場合は要求するコードは次の通り。

//	権限があるかどうか確認
int permissionCheck = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE);

if (permissionCheck != PackageManager.PERMISSION_GRANTED) {
	// Should we show an explanation?
	if (ActivityCompat.shouldShowRequestPermissionRationale(this,
			Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
		// 説明が必要な場合。EBPocketの場合はパーミッションが必須なので要求
		ActivityCompat.requestPermissions(this, new String{
				Manifest.permission.WRITE_EXTERNAL_STORAGE
		}, REQCODE_PERMISSION);
	} else {
		// 説明が不要な場合。パーミッションを要求する
		ActivityCompat.requestPermissions(this, new String{
				Manifest.permission.WRITE_EXTERNAL_STORAGE
		}, REQCODE_PERMISSION);
	}
	return;
}

パーミッションの要求の結果はコールバックされる。

/**
 *
 * @param requestCode
 * @param permissions
 * @param grantResults
 */
@Override
public void onRequestPermissionsResult(int requestCode, String permissions, int grantResults) {
	switch (requestCode) {
		case REQCODE_PERMISSION: {
			if (grantResults.length > 0
					&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
				// パーミッションの取得に成功した。
				// パーミッションが必要な処理をここに書く

			} else {
				// パーミッションの取得に失敗した

			}
		}
	}
}

C配列にObjective-Cのオブジェクトを保存するのは危険?

やっと安定したと思っていたEBPocket for iOSだが、複合検索で外字を選択すると異常終了するという報告をいただいた。
http://ebstudio.info/wforum_ebppc/hatenacamera.cgi?mode=allread&no=2686&page=0
エミュレータで調べたところ、32bit OSでは問題が起きず、64bit OSだけ異常終了するらしい。NSStringの文字列がいつの間にかautoreleaseされて不正参照になっていることまでわかったが、原因がわかるまでしばらく時間がかかった。
結論的には、C配列にNSString*のオブジェクト参照を入れていたためだった。

問題の個所はこんな感じで、NSString*の配列m_keywordをクラス変数として静的に確保していた。

#define	MAX_CPLX_GRP	10
@interface ComplexSearchViewController : UIViewController 
{
	//	省略

	NSString*m_keyword[MAX_CPLX_GRP];
}

クラスオブジェクトの生存中は、クラス変数のm_keywordに代入した文字列も生存するものだとなんとなく思いこんでいた。
(Objective-Cのメモリ管理の基本であるalloc/init/retain/releaseなどについては、一応理解しています)
だがよく考えてみれば、m_keyword
は単なるC配列なので、代入してもNSStringの参照カウントは増えない。これではどこかのタイミング(関数の出口など)でautoreleaseされるのは当然だ。
そこで次のようにC配列をやめてNSMutableArrayにしてみた。NSMutableArrayなら代入すれば参照カウントがインクリメントされてautoreleaseされなくなるのではないか。
NSMutableArrayでは C配列と同様に 変数名[添字] の形で代入や参照が書けるので、ソースの修正は最小限にできる(本当はreplaceObjectAtIndex:withObject:みたいな長ったらしい名前のメソッドがある。[]はいわゆるシンタックスシュガー)。

@interface ComplexSearchViewController : UIViewController 
{
	//	省略

	NSMutableArray*m_keyword;
}

- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {
    if (self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]) {
        // Custom initialization
        m_keyword = [[NSMutableArray alloc]initWithCapacity:MAX_CPLX_GRP];
    }
    return self;
}

- (void)dealloc {
	//	省略
    [m_keyword release];
	
    [super dealloc];
}

案の定、これで落ちなくなった。万歳。
Objective-Cはメモリ管理に気を使う。
今はiOS向けの新規案件は、より進化した言語であるSwiftの利用が増えているのではないだろうか。私もこれから何かiOS向けに書くとすれば、Swiftを選ぶ。読書尚友のiOS版をSwiftで作ってみてもいいかもしれない。

読書尚友に底本出版社別リストを追加

青空文庫のHPでは、1万冊を超える作品を探すためのインデックスとして、作家別(作家名の五十音順)、作品別(作品名の五十音順)の総合インデックスと、分野別リスト(日本十進分類)が提供されている。
大体の青空文庫ビューアアプリでは、作家別・作品別のインデックスはあるが、分野別のインデックスを実装しているものは少ない。読書尚友を開発するとき、分野別リストを実現することは目標の一つだった。
読書尚友では、これに加えて、公開年別リストも提供している。青空文庫では毎年1月1日に、その年著作権が切れる著者の作品を一斉に公開する慣例があり、毎年の年初の公開作品を見たい場合があるので、年月日別のインデックスがあれば便利ではないかと思ったためだ。また、青空文庫に最初に登録された作品は何だったかなど、青空文庫の歴史を調べたいときにも役立つ。
今回さらに、新たな試みとして、底本出版社別リストを追加した。たとえば、底本出版社別リスト→「作品社」(底本出版社)→「日本の名随筆10 山」(底本)→「槍ヶ岳第三回登山」のような読み方ができる。




実は「えあ草紙」というビューアに、特別企画ページとして「日本の名随筆、花の名随筆」や「推理作家一覧」などがあるのがヒントになった。面白いと思ったが、テーマ別だとメンテナンス作業がネックになる。底本出版社別ならメンテナンス不要で、青空文庫の収録冊数が増えるほど有益性が増すと思う。
一つの問題は、作品や作家のようにID化されていないので、同じ底本でも表記が異なるとインデックスが分かれてしまうこと(例:「山と渓谷社」と「山と溪谷社」、「学芸書林」と「學藝書林」、半角空白と全角空白の混在等)。全角空白を半角化するなど正規化を試みているが、完全にはいかない。
まだ発展途上だが、新しい青空文庫の読み方を一つ提案できたと思う。

EBWin4,EBMacに「全ての項目の表示」を実装

EBWin4、EBMacに、「検索に一致した全ての項目の表示」機能を追加した。(EBPocket for iOS/Androidも順次対応予定)
これまではEBシリーズの本文の表示モードには、連続表示と項目毎表示しかなかった。
連続表示とは、見出し語の本文を表示する場合に、後続の本文のテキストを続けて表示するモードである。
通常の連続表示の例:検索語 detectiveの後続のテキストを続けて表示

EPWINGはもともと「電子書籍」を目指しており、一冊の本、または巻物のように、最初から最後まで通読できるようになっている。このため、EPWINGの公式ビューアであるCDView(富士通)、Viewing(イースト)、こととい(岩波書店)では、基本的に連続表示を行うようになっていた。(今ではWindows10にインストールできるかどうかも定かでない)
項目毎表示は、検索に一致した見出し語の本文だけを表示するモードである。ただしEPWINGには項目の区切りという概念がないので、ソフトウェアで何の識別子を項目の区切りにするかを決定しないといけない。これはたぶんUnix上のEPWING検索システムや、DDWinのようなサードパーティ製のEPWINGビューアで出てきた概念だと思う。

今回追加した「全ての項目の表示」とは、検索に一致した見出し語の本文だけを、すべて一覧で表示するものであり、項目毎表示の結果を連結したものと考えるといいと思う。

全ての項目の表示:検索に一致した語の本文を連結表示

実はDDWinにはこの機能が昔からあり(項目表示→全て表示)、今でもこの機能のためにDDWinを利用しているというユーザもいらっしゃるらしい。
DDWinには一つ制約があり、串刺し検索で「全て表示」を行なった場合に、先頭の辞書しか外字が表示できなかった。EBWin4/EBMacでは全ての辞書の外字を表示するようになっている。これについては、EBWin3.xまではWin32 APIで描画していたため拡張が難しかったが、EBWin4では本文表示をWebブラウザコントロールで行っているため、実現がしやすくなった。

既知の問題点、および制約についても書いておくと、

  1. 「全ての項目の表示」では縦書き設定は解除される
  2. 「全ての項目の表示」を行なった後で、検索一致リストから項目を選択した時の挙動は、通常の連続表示/項目毎表示に戻る
  3. 串刺し検索の場合、リンクが働くのは先頭の辞書のみ (その後、全辞書のリンクが動作するように修正した)

複合検索のモードレス化

もう一つ、EBWin4で改良を行なったのは、複合検索のダイアログがこれまでモーダルポップアップだったのを、モードレスポップアップに変更した。複合検索ダイアログを表示したままで、次々と絞り込み検索をするという使い方が可能になった。
モードレスにしようとすると、ダイアログ側から、メインスレッドの表示処理をコールバックする必要がある。C++だと関数ポインタを引数で渡すような実装方法になるが、C#にはdelegateという機能があり、もう少し美しく実装できる。

モーダルダイアログの場合。OKボタンを押すまで制御が帰ってこない。OKボタンを押すとダイアログが消え、メインスレッドで検索結果を表示する。

	ComplexSearchDialog cplx_dlg = new ComplexSearchDialog();
	cplx_dlg.Owner = this;
	// モーダルダイアログとして表示
	if (dlg.ShowDialog() == DialogResult.OK)
	{
		// 検索結果を表示
	}

モードレスダイアログの場合。ダイアログを表示したまま、検索結果をメインスレッドで表示する。

	private ComplexSearchDialog cplx_dlg = null

	if ( cplx_dlg == null || cplx_dlg.IsDisposed)   // 二重起動を防ぐ
	{
		cplx_dlg = new ComplexSearchDialog();
		cplx_dlg.RefreshEvent += delegate(object sender, EventArgs e)
		{
			// 検索結果を表示
		};
		cplx_dlg.Owner = this;

		// モードレスダイアログを表示する
		cplx_dlg.Show();
	}					

ダイアログ側

public partial class ComplexSearchDialog : Form
{

	public delegate void RefreshEventHandler(object sender, EventArgs e);
	public event RefreshEventHandler RefreshEvent;

	// OKボタンが押された場合
        private void okButton_Click(object sender, EventArgs e)
        {
		//検索処理をする
		(略)
		//  検索結果をメインスレッドで表示する
		this.RefreshEvent(this, new EventArgs());
 }

EBPocket for iOS サスペンドからの復帰で異常終了する件が解決か

「EBPocket for iOS が、サスペンドからの復帰時に異常終了する」という報告が以前から上がっており、なかなか原因が分からなくて頭を悩ませていたが、どうやら解決できたと思う。

iOSアプリのプロセスのライフサイクルについて

まず話の前提として、iOSのアプリには「終了させる」という概念がない。(実際、アプリに終了ボタンをつけると審査でリジェクトされる)
アプリを切り替えて(1)アクティブから(2)バックグラウンドになると、しばらくして(3)サスペンドに移行し、メモリが少なくなるとiOSから自動的に終了させられ、(4)停止状態になる。
iOSアプリの状態遷移とライフサイクル
ホームボタンを押してアプリを選択した場合、(3)サスペンドから(1)アクティブに復帰する場合と、(4)停止状態から起動されて(1)アクティブになる場合がある。このうち、後者の(4)から(1)のときに異常終了していたらしい。
これを再現しようと思うと、他にメモリを占有するアプリを多数立ち上げて、メモリ不足の状況をつくらないといけない。
私は普段はAndroidを使っていてiOSは実機デバッグでしか使用しないので、なかなか気付かなかった。
ところが最近、あるきっかけでiOSを日常的に使うようになった。

キャンペーンでキクタンアプリを買う

昨年末頃にAppStoreのセールで、次のキクタンアプリが通常480円のところ全品120円になっており、紙の書籍より大幅に安いので、まとめ買いした。

iPhoneアプリ「キクタン」で、効率的に英単語学習!:アルク
それでiPod touchを毎日使うようになり、キクタンを同時に立ち上げてからEBPocketに戻ると、ユーザからの報告通りに異常終了することがわかった。

Xcodeでのクラッシュログの取得方法

再現さえすれば対処が可能になる。Xcodeデバッグモードで実行し、異常終了させてから、次の手順でクラッシュログが取得できる。
Menu→Windows→Devices
左側ペインの[DEVICES]から実機を選択し、[View Device Logs]を押すとクラッシュログが表示される。デバッグモジュールなので、異常終了したソースの箇所がわかる。

その結果、NSString::drawAtPoint:forWidth:withFont:lineBreakMode:で落ちていることがわかった。このメソッドはiOS7からdeprecated(非推奨)になっているもので、
これをiOS7以後の推奨メソッドの NSString::drawAtPoint:withAttributes: に変えたら落ちなくなった。

iOSは毎年メジャーバージョンが上がり、その度に使用できていたAPIが使用できなくなったりするので、メンテナンスを続けていかないとiOSのバージョンアップで使用できなくなることがある。
もしかすると二年ぐらい前から異常終了するようになっていた可能性がある。今回の修正で、失った信頼が回復できるといいのだが。

EBPocket FreeをEBPocket Basicに改称した経緯

今回提出にあたって一つトラブルがあった。Pro版はすぐに審査が通って公開されたが、EBPocet Freeの方が、アプリ名に"free"が入っていることが原因で、metadata rejectを食らった。

2. 3 PERFORMANCE: ACCURATE METADATA
Performance - 2.3.7
Your app's name to be displayed on the App Store includes references to your app’s price, which is not considered part of an app name.

Next Steps

Please remove any references to your app’s price from your app’s name, including any references to your app being free or discounted. If you would like to advertise changes to your app’s price, it would be appropriate to include this information in the app description. Changes to your app’s price can be made in the Pricing and Availability section of iTunes Connect.

アプリ名に価格を含んでいるといけないらしい。だがAppStore には free という名称を含むアプリがごまんとある。理不尽だが、Appleと戦っても勝てないので、あきらめてアプリ名を EBPocket Basic に変えて再提出したら、あっさり審査に通って公開された。
EBPocket FreeをアップデートしたらEBPocket Basicに変わってしまって驚かれるかもしれないが、これはAppleの審査のためで、内容は同じなのでご理解いただきたい。

P.S.
前述のキクタンアプリはとてもよく出来ていて、音声も収録されているので、紙の書籍よりアプリの方がいいと思う。残念ながらiOSのみで、Android版は提供されていない。おかげで通勤時にキクタンを聞く習慣ができた。

読書尚友のSDカード対応他

以前、Nexus5が故障してASUS Zenfone 3 Laser を購入した顛末を書いた。
Nexus 5 恐怖の無限ループからの脱出 - hishidaのblog
性能的には3年前のNexus 5 と際立った違いはない気もするが、Zenfone 3 Laserに変えて良かったことの一つは、外部Micro SDカードが使えるようになったことである。Nexus5 では外部SDカードによる拡張ができなかったので、拙作の読書尚友でも外部SDに青空文庫データを置くことができなかった(ということに作者が気づかなかった)。予定外の出費だったが、外部SDカードに対応できるようになったという意味では怪我の功名だったと思う。
Androidでの外部ストレージの扱いはAPIバージョンごとに変遷がある。古き良き時代はWRITE_EXTERNAL_STORAGEのパーミッションがあれば外部ストレージのどこにでも読み書きができた。ところが、kitkat Android4.4以降、外部SDカードへの書き込みが制限されるようになり、Storage Access Frameworkを使わないとSDカードにアクセスできなくなった。これは通常のファイルアクセスのAPIと異なるのが問題で、内部ストレージと同様に透過的にアクセスできないと、アプリが複雑になってしまう。
実は簡単な解決策があった。外部ストレージのパスの取得にgetExternalFilesDirを使うと、<外部ストレージのルート>/Android/data/<パッケージ名>/filesというアプリ専用のフォルダが作成される。(ここで「外部ストレージ」というのは実は外部SDカードのことではなく、内蔵メモリなのでややこしい。)
ここで外部SDカードをマウントしていた場合、外部SDカードのルート下にも同時に、/Android/data/<パッケージ名>/filesが自動的に作られる。この領域は外部SDカードであっても、アプリから読み書きが許されている。
簡単なことだが、Nexus5ではSDカードを拡張できないので、この挙動がわからなかった。
今回、読書尚友のデータ保存先を、外部SDカードのAndroid/data/<パッケージ名>/filesに変更できるようにした。これで、メモリの少ない端末の場合、青空文庫の全データをSDカードに逃がせるようになった。