カテゴリー 開発Tips

ソフトウェア開発における豆知識をご紹介!

WEBアプリで保存忘れを防止するチェック用スクリプト

よくWEBアプリケーションを利用していると思わず感心してしまうUIに出くわすことがある。例えばある大手SNSのプロフィール設定画面などがそれだ。プロフィールをいろいろいじっていてやっぱり止めたということはあるだろう。でも逆にうっかり保存しないでリンクをクリックしてしまい、せっかく書いた内容がパーになってしまうというケースもある。

ところが先ほどのSNSでは、保存前の状態で内容に変更があると、別の画面を開こうとした時に確認ダイアログが表示されるのだ。初めて見たときはなるほどと思った。これなら確かにうっかり保存し忘れるということも防げる。優れたUIだと思う。

ではこれを自分で実装してみたいと思った時、どうするか。まず最初に思いつくのはフォーム内のすべての項目の、変更前の値を保持しておき、ボタンが押されたりリンクがクリックされた時に全ての項目について現在の値と変更前の値とを比較するという方法である。愚直だが確実だ。しかし、項目が10個程度の編集画面であれば良いが、100ぐらいを超えるとそのやり方では正直厳しい。

そこで一つの例として、jQueryの serialize() というメソッドを使ったやり方を紹介したい。このメソッドはフォームの内容をいわゆる Query String に変換してくれるものだ。データの型は文字列なので保持も比較も簡単だ。

例えば次の例はよくある予定管理のWEBアプリだと思っていただきたい。
フォームの内容を適当に編集してから画面上のどこでも良いのでリンクをクリックしてみて欲しい。
もし、何も変更点がなければそのままリンク先のページを開くだろう。
だが、何かしら変更されている場合は確認ダイアログが表示され、OKを押せばリンク先を開き、キャンセルを押せば元の画面のままのはずだ。

日時
2012年10月23日(火)
時
分 ~
時
分
予定
場所
詳細
公開設定


これは具体的には次のようなスクリプトを書いている。
(1) 最初に表示したタイミングでフォームの内容を文字列に変換してbeforeという変数に保持しておく。
(2) リンク(ボタンにすることも可能)がクリックされたタイミングで、もう一度フォームの内容を文字列に変換してafterという変数に保持しておく。
(3) before と after を比較し、異なっていれば警告を出す。
ちなみに文字列化したフォームの内容は次のようなものである。フォームの内容を変更すると文字列も変化する様子が確認できるだろう。

var before = "";
$(document).ready(function() {
    before = $("#frm_my_form").serialize(); // (1)
    $("a").click(function() {
        var after = $("#frm_my_form").serialize(); // (2)
        if (before != after) {
            return confirm("保存されてませんがよろしいですか?"); // (3)
        }
        return true;
    });
});

 

絵文字を含むNSStringの正確な文字数をカウントする(2)

ちょっと前に絵文字の混在した文字列のカウント方法について書いたら、ちゃんとカウントできない文字があると指摘された。
なるほど、確かにカウントできない文字がある。いったいどんな規則性があるのかと思っていたら、絵文字の文字コードをまとめていたサイトを教えてもらった。
それがこれ↓
iOS Emoji

う~ん、まるで規則性が見当たらないと思っていたらピンときた。前は UTF8String とかやってたけど、内部の文字コードはもしかして UTF-16 ではなかろうか?と思って調べたらやっぱり UTF-16 だった。それならわざわざ UTF-8 に変換しないで UTF-16 のまま処理した方が良い。

UTF-16 といえば主要なトピックはサロゲートペアだ。 0xD800-0xDBFF が上位サロゲート、 0xDC00-0xDFFF が下位サロゲート。(Wikipedia#Unicode 参考)なので上位サロゲートを検出したら1文字スキップする。(1)

さらに、国旗の絵文字には Regional Indicator という専用のシンボルが2つの組み合わせで使われている。( Unicode.orgのこのページ の最下段に記載されている。)なので Regional Indicator を検出して次のサロゲートペアも Regional Indicator だったら3文字スキップする。(2)

あと、ガラケーではお馴染みの四角で囲まれた数字には通常の数字の後に COMBINING ENCLOSING KEYCAP という専用の文字が配置されている。だがこれは単純に無視すればいいだろう。(3)

そうすると、こんな感じで書けばいいかな?ちゃんと実装するには文字のインデックスが out of bounds にならんようにした方がいいかも。

- (NSUInteger) actualNSStringSize:(NSString *) txt {
    int len = [txt length];
    NSUInteger count = 0;
    for (NSUInteger i = 0; i < len; i++) {
        unichar c = [txt characterAtIndex:i];
        
        if (0xD83C == c) {
            unichar c1 = [txt characterAtIndex:i+1];
            if ((0xDDE6 <= c1) && (c1 <= 0xDDFF)) {
                unichar c2 = [txt characterAtIndex:i+2];
                if (0xD83C == c2) {
                    unichar c3 = [txt characterAtIndex:i+3];
                    if ((0xDDE6 <= c3) && (c3 <= 0xDDFF)) {
                        // 国旗なのでスキップ・・・(2)
                        i += 3;
                        ++count;
                        continue;
                    }
                }
            }
            i++;
            ++count;
        }
        else if (0xD800 <= c && c <= 0xDBFF) {
            // 上位サロゲート・・・(1)
            i++;
            ++count;
        }
        else if (0xDC00 <= c && c <= 0xDFFF) {
            // 下位サロゲートなので念のため無視
        }
 		else if (0x20E3 == c) {
            // 囲みなので無視・・・(3)
        }
        else {
            ++count;
        }
    }
    return count;
}

ちなみに、これらの絵文字は iOS 独自の実装かと思っていたらそうではなく、 Unicode6.0 というれっきとした国際規格なのだそうだ。ガラケーに端を発した日本発の絵文字が知らない間に国際規格に…っていうか Unicode のバージョンって知らない間にこんなに上がっていたんだ。

絵文字を含むNSStringの正確な文字数をカウントする

iPhone/iPad のキーボード設定で「絵文字」というキーボードを追加すると、ガラケーよろしく絵文字を入力できるようになる。なので、何も考えないと UITextField などに絵文字を入力されてしまう可能性がある。ここでアプリの仕様上、絵文字を許容するかしないかという議論は当然起こるが、許容したくないというケースはひとまず置いといて、許容する場合のトピックを取り上げる。

絵文字も他の文字と同様UTF-8であるようだが、絵文字の中には4バイトで表現されるものもあるようで、その4バイトの絵文字を含んだ文字列は [str length] で得られる文字数が狂ってしまうのだ。そうなるとバリデーションとして文字数制限を設ける場合にうまくない。

この現象はどうやらNSStringクラスのUTF-8の取り扱いに原因があるようだが、不具合だと認識されていれば将来的にSDKの方で対応される可能性がある。それまでは次のような関数で凌ぎたいと思う。

- (NSUInteger) actualUtf8StringSize:NSString str {
     const char* cstr = [str UTF8String];
     int len = strlen(cstr);
     NSUInteger count = 0;
     unsigned char c;
     for (int i = 0; i < len; i++) {
          c = (unsigned char)cstr[i];
          if (0x00 <= c && c <= 0x7F) {
               ;
          }
          else if (0xC2 <= c && c <= 0xDF) {
               i += 1;
          }
          else if (0xE0 <= c && c <= 0xEF) {
               i += 2;
          }
          else if (0xF0 <= c && c <= 0xF7) {
               i += 3;
          }
          else {
               continue;
          }
          ++count;
     }
     return count;
}

これはUTF-8の性質を利用して文字数をカウントしている。UTF-8は文字の1バイト目でその文字が何バイトかを示している。なので、バイト列を先頭から調べて行き、文字のバイト数分スキップしながらカウンタを増加している。ひとつ留意したいのは、バイト列から1バイト取得する時に char 型ではなく unsigned char 型の変数に代入することだ。C言語では常識の範疇だが、慣れていないと見落としやすい。

Log4jで同一内容を複数の出力先に出力させる

システムを運用しているとログが重要な意味を持ってくる。

カットオーバーしたばかりの頃はシステムも安定しおらず、初期不良とでも言うべき障害に見舞われることもある。そこで、安定するまではデバッグログやトレースログを出力しておき、障害の数が落ち着いてきたらそれらの出力を止めるというケースが考えられる。ただ、デバッグログやトレースログを出力している場合、ログファイルが膨らんでしまい、監視ツールなどにログファイルを食わせている場合には負荷の面で不安が出てくる。

そういう時、FATALレベルやERRORレベルのログだけを別ファイルにミラーリング出来たら便利だ。そのエラーログはサイズも小さく、監視ツールに食わせても安心だ。そしてエラーを検出したらデバッグログやトレースログを調査するという運用が可能になる。

では、そのようなログを出力するにはどうすればよいだろうか。

エラーログ専用のロガーを定義して、プログラムからそれぞれログ記録メソッドを呼び出すという方法も無くはないが、コードとしては冗長で無意味である。そうではなく、以下の例のように1つのロガーに対して複数のアペンダを設定すればよい。ERRORレベル以上のログがエラーログにも出力されるようになる。

# RootLogger
log4j.rootLogger=TRACE,A,B

# Logger
log4j.logger.myLogger=TRACE,A,B

# Appender A
log4j.appender.A=org.apache.log4j.FileAppender
log4j.appender.A.Append=true
log4j.appender.A.threshold=DEBUG
log4j.appender.A.File=C:/temp/normal.log
log4j.appender.A.layout=org.apache.log4j.PatternLayout
log4j.appender.A.layout.ConversionPattern=%d{yyyy/MM/dd HH:mm:ss} %m%n

# Appender B
log4j.appender.B=org.apache.log4j.FileAppender
log4j.appender.B.Append=true
log4j.appender.B.threshold=ERROR
log4j.appender.B.File=C:/temp/error.log
log4j.appender.B.layout=org.apache.log4j.PatternLayout
log4j.appender.B.layout.ConversionPattern=%d{yyyy/MM/dd HH:mm:ss} %m%n

尚、複数指定するアペンダは、同じアペンダクラスでなくても、例えばファイルとコンソールに出力するといった指定も可能である。

複数行のUILabelを内容に合わせて自動縮小

UILabelは文字列を表示する部品だが、固定した内容を表示する時はもちろん、状況に応じて内容を変更することもできる。この部品にはInterface
Builderで見るとFont指定欄下に Autoshrink という属性(プロパティとしては adjustsFontSizeToFitWidth )があって、ここにチェックを入れていると文字列が長くてはみ出てしまうときは、自動でフォントサイズを小さくして全体が収まるようにしてくれる。(際限なく小さくすると読めなくなってしまうため、 MinimumSize という属性で最小サイズを指定できる。)もちろんチェックを外せば、はみ出る場合に例えば末尾が「…」という風に省略される。

ところが、公式リファレンスにも記載されている通り、この
Autoshrink という属性は Lines 属性が 1 の時しか有効にならないという制限がある。そうは言っても長い文字列の時は折り返して見やすく表示したいし、行数も分からないけど表示枠だけは決まっているというケースもあるはずだ。そんな時、自動でフォントサイズを小さくして決められた枠内に収まるような機能があれば嬉しい。

例えば、Interface Builder上で UILabel 部品を貼り付けて、 label という名前で Outlet を作成したとする。まずは。 Size Inspector を開いてサイズを決める。次に Attributes Inspector を開き、フォントとフォントサイズを決めてしまおう。そして今回は Autoshrink のチェックを外しておく。そして行数は動的に変わって欲しいので
Lines = 0
としておく。(こうすると自動で行数を増減してくれる。)そして次のようなコードを書いて、本来のサイズに収まるまでフォントサイズを小さくしていけば良い。

float orgHeight = self.label.frame.size.height;    // ラベルの高さの初期値を保持しておく
self.label.text = @"...";                          // 充分に長い文字列
[self.label sizeToFit];                            // 内容に合わせてラベルの大きさ(高さ)を変更
// 本来の高さになるまでフォントを小さくしていく
while (orgHeight < self.label.frame.size.height)
{
    self.label.font = [UIFont fontWithName:self.label.font.fontName size:self.label.font.pointSize - 0.1f];
    [self.label sizeToFit];
}

ログローテートせずに日付別のログ出力

Javaでログ出力ライブラリというと標準の java.util.logging よりは Apache log4j を利用する機会が多い。

さてLog4jでファイルにログを出力し続けると、ファイルのサイズが大きくなってしまうので日付ごとにログローテーションさせたいということがある。そこで
org.apache.log4j.DailyRollingFileAppender
を採用したところ、ローテート(ロールオーバ)されるタイミングが日付が変わった時点ではなく、日付が変わってから初めてログが出力される瞬間であることが判明した。これは通常は問題ないのだが、人によっては気になることだし顧客から指摘を受けることもあるだろう。実際ネット上では困っている人がそれなりにいるようだ。

これに対処するには日付が変わったタイミングで強引にログを出力させるやり方や、バッチ処理でログファイル名を変更するといったこともできないではないが、個人的にはあまり好きではない。

PHPでシステムを構築する際には個人的によく採用するパターンが、予めファイル名に日付を含めておくというものだ。例えば今日の日付が2012年4月15日だとしたら、
error_20120415.log
といった具合にだ。実装としてはログ出力の際にシステム日付からファイル名を生成する。そうしておけば日付が変われば自動的に翌日の日付の入ったログファイルが生成される。

これをLog4jでやろうとしたがそういった機能は標準では備わっていないようだ。そこでAppenderを自作することにした。クラス名は仮に DailyFileAppender とでもしておこう。このクラスは
org.apache.log4j.FileAppender
のサブクラスとして作るのが良い。まずはこんな設定ができればよいだろう。

log4j.appender.A=DailyFileAppender
log4j.appender.A.Append=true
log4j.appender.A.FilePattern='C:/temp/error_'yyyyMMdd'.log'
log4j.appender.A.layout=org.apache.log4j.PatternLayout
log4j.appender.A.layout.ConversionPattern=%d{yyyy/MM/dd HH:mm:ss} %m%n

Dailyといってもファイル名の構成に java.text.SimpleDateFormat を利用するだけなので、月単位でも週単位でも、逆に時単位でも分単位でも自在に設定可能だ。日頃 FileAppender を使っている人は、 File というプロパティが FilePattern に換わったものと捉えればよいだろう。そこに SimpleDateFormat に渡すパターンを定義する。当然 SimpleDateFormat が解釈できる書式でなければならない。

以下、DailyFileAppender の実装例を示す。コンストラクタやエラー処理は適当に書いたので、必要に応じて修正されたい。

public class DailyFileAppender extends FileAppender {
    
    // コンストラクタ
    public DailyFileAppender() {}
    
    public DailyFileAppender(Layout layout, String pattern) throws IOException {
        this(layout, pattern, true);
    }
    
    public DailyFileAppender(Layout layout, String pattern, boolean append) throws IOException {
        this.layout = layout;
        this.filePattern = pattern;
        String filename = this.generateFileName(pattern);
        this.setFile(filename, append, false, bufferSize);
    }
    
    public DailyFileAppender(Layout layout, String pattern, boolean append, boolean bufferedIO, int bufferSize) throws IOException {
        this.layout = layout;
        this.filePattern = pattern;
        String filename = this.generateFileName(pattern);
        this.setFile(filename, append, bufferedIO, bufferSize);
    }
    
    // ファイル名のパターン
    protected String		filePattern	= null;
    private SimpleDateFormat	sdf;
    
    // プロパティのgetter/setter
    public String getFilePattern() {
        return filePattern;
    }
    public void setFilePattern(String filePattern) {
        this.filePattern = filePattern.trim();
    }
    
    // システム日付からファイル名を生成
    private String generateFileName(String pattern) {
        String fileName;
        sdf = new SimpleDateFormat(pattern);
        fileName = sdf.format(new Date());
        return fileName;
    }
    
    // プロパティのオプションを反映させる
    @Override
    public void activateOptions() {
        if (this.filePattern != null) {
            try {
                this.fileName = this.generateFileName(this.filePattern);
                setFile(this.fileName, this.fileAppend, this.bufferedIO,
                    this.bufferSize);
            }
            catch (java.io.IOException e) {
                errorHandler.error("setFile(" + fileName + "," + fileAppend
                        + ") call failed.", e, ErrorCode.FILE_OPEN_FAILURE);
            }
        }
        else {
            LogLog.error("File option not set for appender [" + name + "].");
        }
    }
    
    // 実際にログを出力する
    @Override
    protected void subAppend(LoggingEvent event) {
        String fileName = this.generateFileName(this.filePattern);
        if (!fileName.equals(this.fileName)) {
            try {
                this.setFile(fileName, this.getAppend(), this.bufferedIO,
                    bufferSize);
            }
            catch (IOException ioe) {
                LogLog.error("Failed open the file [" + fileName + "].", ioe);
            }
        }
        super.subAppend(event);
    }
}

UIWebViewをスクロールさせない

UIWebViewクラスはUIScrollViewの派生クラスなので基本的にはWEBブラウザと同様に内容がはみ出る場合には自動でスクロールする。
ところが、場合によってはUIWebViewを使いたいがスクロールはさせたくないということもある。
そういう場合は userInteractionEnabled プロパティをNOにしてしまえばよい。これでタップ等のイベントを拾わなくなるのでスクロールすることもない。
(ちなみに、この方法だとハイパーリンクもタップ出来なくなるので注意!)

尚、Interface Builderを使用する場合は、Attribute Inspectorを開き、
View > Interaction > User Interaction Enabled
のチェックを外せば良い。