トップ地図アプリGIS > タイル地図を表示する

タイル地図を表示する

はじめに

地図アプリ起動時はストレージに保存されたタイル画像ファイルを読み込んで表示するが、 それはタイルキャッシュに格納され、スクロールではこのタイルキャッシュからビットマップ画像を取り出して 表示する。

ファイル読み込み、ダウンロード、レンダリングには時間がかかるため、状態管理が多少複雑になっていた。 タイルキャッシュにない場合、ファイル読み込み/ダウンロード/レンダリングのリクエストが発生するが、 スクロールした場合、同じタイルに対するリクエストが重ならないようにする必要がある。

キューでリクエストを管理し、HashSetでリクエストの重なりを回避できるが、これまでは独自の方法で、 キューを管理し、リクエストの重なりを回避してきた。

タイル画像のキャッシュは必要であるが、管理方法を一から見直し、より分かりやすいものにしたい。

最初のプログラム[gis00]

最初はファイル読み込みだけで、ダウンロード/レンダリングは考えない。

タイルキャッシュは必須である。自前とはせず、LinkedHashMapを使用する。

表示の核となるプログラムを下に示す。

    static long key(Tile.Source src, int zoom, int x, int y) {
        return (((long)src.ordinal())<<56) + (((long)zoom)<<48) + (((long)x)<<24) + y;
    }

    void drawBitmap(Canvas canvas, Bitmap bmp, int xoff, int yoff) {
        if (src == Tile.Source.gsi || src == Tile.Source.ort) {
            rSrc.set(xoff < 0 ? (int) (-xoff / Scale) : 0, yoff < 0 ? (int) (-yoff / Scale) : 0,
                    xoff / Scale + 256 < W / Scale ? 256 : (int) ((W - xoff) / Scale),
                    yoff / Scale + 256 < H / Scale ? 256 : (int) ((H - yoff) / Scale));
        } else {
            rSrc.set(xoff < 0 ? -xoff : 0, yoff < 0 ? -yoff : 0,
                    xoff + PX < W ? PX : (int) ((W - xoff)),
                    yoff + PX < H ? PX : (int) ((H - yoff)));
        }
        rDst.set(Math.max(xoff, 0), Math.max(yoff, 0),
                Math.min(xoff + PX, W), Math.min(yoff + PX, H));
        canvas.drawBitmap(bmp, rSrc, rDst, paintCanvas);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        W = getWidth();
        H = getHeight();
        int offX = (CX - W/2) % PX;
        int offY = (CY - H/2) % PX;
        double margin = 0;
        int bx = (int) ((CX - W/2.0 - margin)/PX);          // 画面左端タイルX座標
        int ex = (int) ((CX + W/2.0 + PX - 1 + margin)/PX); // 画面右端タイルX座標
        int by = (int) ((CY - H/2.0 - margin)/PX);
        int ey = (int) ((CY + H/2.0 + PX - 1 + margin)/PX);

        int MAX = 1 << zoom;
        for (int xx = bx; xx < ex; xx++) {
            int x = (xx + MAX) % MAX;
            for (int y = by; y < ey; y++) {
                final Tile tile = Tile.get(src, zoom, x, y);
                if (tile.bmp == null) {
                    Executors.newCachedThreadPool().execute(() -> {
                        tile.bmp = loadBitmap(tile.name + ".webp");
                        if (tile.bmp != null) {
                            tile.status = Tile.Status.ready;
                            // Mainスレッドで実行する
                            HandlerCompat.createAsync(getMainLooper()).post(this::invalidate);
                        }
                    });
                    if (tile.bmp == null) {
                        System.out.println("no file: " + tile.name);
                        // TODO: download or rendering
                    }
                } else if (tile.status == Tile.Status.ready) {
                    int tx = (tile.x + MAX) % MAX;
                    drawBitmap(canvas, tile.bmp, PX*(tx-bx)-offX, PX*(tile.y-by)-offY);
                }
            }
        }

    }

class Tile

public class Tile {

    static class TileCache<K,V> extends LinkedHashMap<K,V> {
        protected int limit;

        public TileCache(int size) {
            super(size, 0.75F, true);    // true: LRU, false: FIFO
            this.limit = size;
        }

        @Override
        protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
            return size() > limit;
        }
    }

    enum Status { free, waiting, busy, ready }
    enum Source { lands, japan, kanto, kansai, hot, gsi, ort, osm }
    enum Range  { norm, high, mid, low, split }

    final static String GSI = "https://cyberjapandata.gsi.go.jp/xyz/std/";
    final static String ORT = "https://cyberjapandata.gsi.go.jp/xyz/ort/";
    final static TileCache<Long,Tile> cache = new TileCache<>(50);

    Bitmap bmp;             // GSI: 256x256画素、OSM: PX x PX画素
    Canvas canvas;
    Status status;
    Source src;             // 地図のソース
    int zoom, x, y;         // タイルアドレス
    String url, dir, name;
    long key;

    public Tile(Source src, int zoom, int x, int y, long key) {
        this.src = src;
        this.zoom = zoom;
        this.x = x;
        this.y = y;
        this.key = key;     // ユニーク
        this.dir = Map.DIR + "GSI/" + src.name() + "/" + zoom + "/" + x;
        this.name = this.dir + "/" + y;      // ファイル名
        if (src==Source.gsi || src==Source.ort || src==Source.osm) {
            String zxy = zoom + "/" + x + "/" + y;
            this.url = src == Source.gsi ? GSI + zxy + ".png" :
                       src == Source.ort ? ORT + zxy + ".jpg" : null;
        } else {
            this.url = null;
        }
    }

    static Tile get(Source src, int zoom, int x, int y) {
        long key = Map.key(src, zoom, x, y);
        synchronized(cache) {
            Tile tile = cache.get(key);
            if (tile == null) {
                tile = new Tile(src, zoom, x, y, key);
                cache.put(key, tile);
            }
            return tile;
        }
    }

}

ダウンロード[gis01]

ストレージにタイル画像ファイルが保存されていなかった場合には、 直後でダウンを行えばよい。

        int MAX = 1 << zoom;
        for (int xx = bx; xx < ex; xx++) {
            int x = (xx + MAX) % MAX;
            for (int y = by; y < ey; y++) {
                final Tile tile = Tile.get(src, zoom, x, y);
                if (tile.bmp == null) {
                    Executors.newCachedThreadPool().execute(() -> {
                        tile.bmp = loadBitmap(tile.name + ".webp");
                        if (tile.bmp == null) {
                            download(tile);
                        }
                        if (tile.bmp != null) {
                            tile.status = Tile.Status.ready;
                            // Mainスレッドで実行する
                            HandlerCompat.createAsync(getMainLooper()).post(this::invalidate);
                        }
                    });
                } else if (tile.status == Tile.Status.ready) {
                    int tx = (tile.x + MAX) % MAX;
                    drawBitmap(canvas, tile.bmp, PX*(tx-bx)-offX, PX*(tile.y-by)-offY);
                }
            }
        }

並列処理

gis00、gis01とも概ね狙い通りの動作をすることを確認した。 メインスレッド(UIスレッド)自体はシングルスレッドであるが、 loadBitmap()/download() は同時に複数のスレッドで実行が行われる。少し、スクロールした場合、 ほぼ同じ複数のタイルに描画処理が行われる。

上のプログラムでは、最初の loadBitmap/download が終わらない内に、同じタイルに対して、 次の loadBitmap/download スレッドがスタートする。

このような無駄を省くために、ステータスを free -> busy -> ready のように遷移させる。

これで無駄がなくなり、タイル表示がスムースになった。

        int MAX = 1 << zoom;
        for (int xx = bx; xx < ex; xx++) {
            int x = (xx + MAX) % MAX;
            for (int y = by; y < ey; y++) {
                final Tile tile = Tile.get(src, zoom, x, y);
                if (tile.bmp == null && tile.status != Tile.Status.busy) {
                    tile.status = Tile.Status.busy;
                    Executors.newCachedThreadPool().execute(() -> {
                        tile.bmp = loadBitmap(tile.name + ".webp");
                        if (tile.bmp == null) {
                            download(tile);
                        }
                        tile.status = tile.bmp != null ? Tile.Status.ready : Tile.Status.free;
                        // Mainスレッドで実行する
                        HandlerCompat.createAsync(getMainLooper()).post(this::invalidate);
                    });
                } else if (tile.status == Tile.Status.ready) {
                    int tx = (tile.x + MAX) % MAX;
                    drawBitmap(canvas, tile.bmp, PX*(tx-bx)-offX, PX*(tile.y-by)-offY);
                }
            }
        }

レンダリング

レンダリングの場合には download() を render() に変えればよい。ダウンロードの場合、並列実行するスレッド数が大きく なっても問題はないが、レンダリングの場合、メモリの使用量が大きくなることとパフォーマンス上、 スレッド数は4か5に抑えた方がよい。

次のようにすれば、スレッド数は最大5に制限される[2]。 しかし、これで、最大スレッド数が5に抑えられるか未確認である。


    static ExecutorService newCachedThreadPool(int num_thread) {
        return new ThreadPoolExecutor(0, num_thread, 60L, TimeUnit.SECONDS,
                new SynchronousQueue());
    }

                    newCachedThreadPool(5).execute(() -> {
                        tile.bmp = loadBitmap(tile.name + ".webp");
                        if (tile.bmp == null) {
                            download(tile);
                        }
                        tile.status = tile.bmp != null ? Tile.Status.ready : Tile.Status.free;
                        // Mainスレッドで実行する
                        HandlerCompat.createAsync(getMainLooper()).post(this::invalidate);
                    });

おわりに

今回のマルチスレッドはこれまでも部分的には使ってきたが、ダウンロードおよびレンダリングでは、 古典的な並列処理方法を採用してきた。

この新世代のマルチスレッド方式の方がプログラムが簡単で分かりやすい。

レンダリングではメモリ使用量が大きいため、メモリの再利用を図りたい。 また、バッググラウンドでの更新には古典的な方式の方がやりやすい。

ファイル読み込みまでは、新方式がいいが、レンダリングは旧方式を使う。ダウンロードも旧方式に戻すかもしれない。

リファレンス

[1] LinkedHashMap
[2] newCachedThreadPool メソッド