2015/08/05

Android Studio のカスタマイズ

ADT 終了のお知らせが Google から出たので仕方なく Android Studio を使ってみている。
いろいろ操作に戸惑うのでカスタマイズをメモ。

お勧めのエラー対処方の表示

Eclipseではマウスオーバーだが Studio では ALT+Enter。
マウスオーバーはキーバインドできないので中ボタンに割り当てる。

  1. Settings の Keymap へ移動
  2. 「Show Intention Actions」を検索
  3. 鉛筆のメニューから「Add Mouse shortcut」を選択して設定ダイアログ
  4. マウスアイコンの上で中ボタンをクリック

エディタ設定

行番号と空白の表示

デフォルトで行番号も空白も表示されないのは Eclipse と同じ。
改行の表示方法が不明。

  1. Settings の Editor > General > Apprearaaance へ移動
  2. 「Show line numers」と「Show white space」をチェック

フォントの変更

スキーマを作らないと変更できない?
Windows10 だと Source code pro が選択できたので設定しておいた。

  1. Settings の Editor > Colors&Fonts > Font へ移動
  2. 「Save As...」ボタンで新しいスキーマを作る。
  3. 「Show only monospaced fonts」をチェックしてフォントを選ぶ。
  4. 「Line Spaceng」はフォントにより 0.8~0.9 くらいが良い

コンテキストメニュー

エディタの右クリックメニューによく使う機能だけを割り当てた。
メニューの編集機能が恐ろしく使いずらい。

  1. Settings の Appearance & Behavior > Menus and Toolbars へ移動
  2. 「Editor Popup Menu」を開いて内容を右側のボタンで編集

作ったメニュー

Declaration
Call hierarchy
Super Method
Implements
---
Generate
Toggle Case
Rename
---
Cut
Copy
Peast

削除した項目の「Column Selection mode」は Toolar の Paste の後ろに追加する。

参考、機能一覧:http://gihyo.jp/dev/serial/01/android_studio/0022

ツールバーのアイコン

ツールバーをカスタマイズすると 16x16 のアイコンが必要になる。 指定しないと意味不明なデフォルトアイコンが設定されてしまう。

おすすめのフリーのアイコンセット。16x16 で500種類以上ある。

フラットではないので若干違和感あるけど編集アプリ向けのアイコンが充実している。。

コード・フォーマッタ

Eclipse Code Formatter プラグインを入れて Eclipse の設定を流用する。
移行プロジェクトだと差分出ちゃうから必須。

  1. settings の Plugins へ移動。
  2. 「Browse repositories」ボタンをクリック
  3. 「Eclipse Code Formatter」を検索してインストール
  4. settings の Other Settings > Eclipse Code Formatter へ移動
  5. 「Use the Eclipse code fomtter」をチェック
  6. 「Eclipse Java formatter config file」にエクスポートした定義ファイルを設定する。

GitHub

  1. git.exe をインストールしてパスを設定する。
  2. gitbub のユーザとパスワードを設定する。
  3. メニューから「Create git repository」「Share project on github」の順に実行する。

その他

使ってると色々出てくるから都度追加。

雑感

途中で気が付いたのでが Studio って Android 以外のプロジェクトって作れないのね。
GAE のサポートはあるみたいだけど Tomcat は結局 Eclipse が必要。
IntelliJ も無料版はサーバ開発できないみたいだし。

Eclipse+Gradle ではダメだったんだろうか?


2015/05/31

Androidの動画ストリーミング配信

思う所有ってAndroidで動画のストリーミング配信を受ける方法を調べで見た。

ストリーミング配信のプロトコル

ストリーミング配信で使われるプロトコルとAndroidの対応状況は以下となる。

プロトコルAndroidサポート
RTSP
RTMPライブラリ(Vitamio)
HLSAndroid/3.0以降

Vitamio は個人利用はフリーで法人は有料。 完全にフリーなライブラリもあるかもしれないが調べていない。

サーバ

フリーのサーバは2つ見つけた。

  • Darwin Streaming Server
    • Apple の QuickTime 用のサーバが OSS になったものらしい。
  • Red5
    • Javaで記述されている。
    • ニコ動でも採用されているらしい。

最初、Darwin を試したのだがなんと Yosemite では動かないらしい。 Ubuntu でビルドしてみたが途中で失敗する。

諦めて Red5 を試してみる。 こちらはあっさり動作した。 但し、サイト引越し中なのか情報源のリンク切れが多くちょっと困った。今回はこちらを採用。

Red5 のインストール

以下から最新のバイナリをDLする。

ZIP を展開してできたフォルダの red5.bat を実行する。
これでサーバは起動する。
バッチでエラーが出る場合は、red5.bat に以下の修正を入れてみる。

"%JAVA_HOME%\bin\java" %JAVA_OPTS% -cp "%RED5_CLASSPATH%" %RED5_MAINCLASS% %RED5_OPTS%
     ↓
%JAVA_HOME%\bin\java %JAVA_OPTS% -cp "%RED5_CLASSPATH%" %RED5_MAINCLASS% %RED5_OPTS%

ファイヤーウォールのポート使用確認ダイアログが出たら許可する。

Red5デモアプリの準備

Red5 起動後、ブラウザで http://localhost:5080/ を開くと以下の画面が表示されるので下の方に有る

  • Install a ready-made application

をクリックする。

アプリケーションの一覧から OFLA Demo を選択し「Install」をクリックする。

デモアプリインストール後にブラウザで http://localhost:5080/oflaDemo/ を開くとデモの動画が再生される。

これでサーバ側の準備は完了。

動画を追加する場合には webapps/アプリ/streams の配下に置くだけで良い。

Androidクライアントアプリ

Red5 はプラグインを入れないと RTMP しかサポートしないので Vitamio ライブラリを使用する事にした。

Vitamio は初期化以外は標準の VideoView/MediaPlayer と互換 API なので非常に扱いやすい。
ライブラリ・プロジェクトは以下から取得できる。

Android studio 用のプロジェクトなので Eclipse の場合は自力で変換する必要がある。
簡単に方法を書いておくと

  • 上記リポジトリをZIPでDLしてくる。
  • Android ライブラリプロジェクトを新規作成。
  • ZIPから src,res,libs,AndroidManifest.xml,proguard-project.txt,project.properties をコピー
  • 新規作成の時に自動生成された不要ファイルを削除。

で行けるはず。

実装コードは以下のようになる。

MainActivity.java:
package org.kotemaru.android.rtmpclient;

import io.vov.vitamio.LibsChecker;
import io.vov.vitamio.MediaPlayer;
import io.vov.vitamio.MediaPlayer.OnBufferingUpdateListener;
import io.vov.vitamio.MediaPlayer.OnInfoListener;
import io.vov.vitamio.widget.MediaController;
import io.vov.vitamio.widget.VideoView;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ProgressBar;

public class MainActivity extends Activity {
    private final static String KEY_POSITION = "KEY_POSITION";

    private VideoView mVideoView;
    private PlayerListener mPlayerListener;
    private ProgressBar mProgressBar;
    private long mPosition = 0; // ms

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (!LibsChecker.checkVitamioLibs(this)) return; // ※Vitamio で必須

        setContentView(R.layout.activity_main);

        mVideoView = (VideoView) findViewById(R.id.videoView);
        mProgressBar = (ProgressBar) findViewById(R.id.progressBar);
        mPlayerListener = new PlayerListener();

        mVideoView.setOnInfoListener(mPlayerListener);
        mVideoView.setOnBufferingUpdateListener(mPlayerListener);
        mVideoView.setMediaController(new MediaController(this));

        if (savedInstanceState != null) {
            mPosition = savedInstanceState.getLong(KEY_POSITION);
        }
    }

    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        setIntent(intent);
    }

    @Override
    protected void onResume() {
        super.onResume();
        Intent intent = getIntent();
        String path = intent.getDataString();
        Log.d("DEBUG", "path=" + path);
        if (path == null) return;
        mVideoView.setVideoPath(path);
        mVideoView.requestFocus();
        mVideoView.seekTo(mPosition);
        mVideoView.start();
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putLong(KEY_POSITION, mVideoView.getCurrentPosition());
    }

    private class PlayerListener implements OnInfoListener, OnBufferingUpdateListener {
        @Override
        public boolean onInfo(MediaPlayer mp, int what, int extra) {
            switch (what) {
            case MediaPlayer.MEDIA_INFO_BUFFERING_START:
                if (mVideoView.isPlaying()) {
                    mVideoView.pause();
                    mProgressBar.setVisibility(View.VISIBLE);
                }
                break;
            case MediaPlayer.MEDIA_INFO_BUFFERING_END:
                mVideoView.start();
                mProgressBar.setVisibility(View.INVISIBLE);
                break;
            case MediaPlayer.MEDIA_INFO_DOWNLOAD_RATE_CHANGED:
                break;
            }
            return true;
        }
        @Override
        public void onBufferingUpdate(MediaPlayer mp, int percent) {
            Log.d("DEBUG", "buff=" + percent);
        }
    }
}
AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="org.kotemaru.android.rtmpclient" android:versionCode="1" android:versionName="1.0">

    <uses-sdk android:minSdkVersion="14" android:targetSdkVersion="15" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <application android:allowBackup="true" android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" android:theme="@style/AppTheme">
        <activity android:name=".MainActivity" android:label="@string/app_name" android:launchMode="singleTop">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="rtmp" />
            </intent-filter>
        </activity>
        <!-- ※Vitamio で必須 -->
        <activity android:name="io.vov.vitamio.activity.InitActivity"
            android:configChanges="orientation|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
            android:launchMode="singleTop" android:theme="@android:style/Theme.NoTitleBar"
            android:windowSoftInputMode="stateAlwaysHidden" />
    </application>
</manifest>

特殊なのは LibsChecker.checkVitamioLibs() の呼び出しと io.vov.vitamio.activity.InitActivity の宣言が必要になることだけ。

実行

アプリ起動用のHTMLファイルを用意する。

red5-server-1.0.5-RELEASE/webapps/oflaDemo/test.html:
<ul>
<li><a href="rtmp://192.168.0.9/oflaDemo/Avengers2.mp4">Avengers2</a>
<li><a href="rtmp://192.168.0.9/oflaDemo/test.mp4">test</a>
<li><a href="rtmp://192.168.0.9/oflaDemo/test2.mp4">test2</a>
</ul>
  • 192.168.0.9 は Red5 のIPアドレス。

実行結果

Androidのブラウザから http://192.168.0.9:5080/oflaDemo/test.html を開く。

「Avengers2」をタップすると再生アプリが起動する。

…>

ちょっと待たされて再生成功。

所感

まずサーバの準備が思いの外大変。
Darwin が動かなくて困ったが結果的には Red5 の方が自分で手を入れられて使い勝手良さそう。

それと、プロトコルが色々有ると思っていなかった。
RTSP は時代遅れっぽいが HLS はスマホの主流と目されているようなので次は HLS を試してみたいなーと思っている。


2015/05/23

AndroidのCamera2 APIのサンプル

カメラの実装をしようとしたら android.hardware.Camera が Deprecated だって怒られた。
android.hardware.camera2 と言うのが API Level 21 から出来ていてこちらを使えとのことらしい。
でも Level 21 って、Android/5.0 以上だよね。
いきなり 4.x を Deprecated にするとか Google 強気すぎるだろw

5.0が普及するまで使われる事は無いと思うけど使い方だけも確認しておこうと思い調べてみた。

参考にしたのはGoogle製の以下のサンプル。

毎度の事ながらGoogleのサンプルはスパゲティで訳がわかりません。
サンプルを自分で書き起こす事にしました。

旧Camera API との違い

全部違いますw
とりあえず要点は以下

  • プレビューは SafaceView でなく TextureView を使う。
  • 撮影した画像は android.media.ImageReader 経由で取得する。
  • オートフォーカスや露出はアプリ側がプレビューをハンドリングしながら行う。

旧APIは最初にパラメータ設定して後はお任せな感じでしたが Camera2 API はアプリ側のロジックでカメラを制御していく感じです。 細かな制御ができるようになった分、アプリが複雑になった印象です。

尚、シャッタースピードとか感度設定の新機能は今回試して無いのでよくわかりません。

Camera2 API はコールバック地獄

Camera2 API ではカメラの初期化に数ステップ、 さらに撮影で数ステップのコールバックによる非同期処理が必要になります。

整理すると以下のようになります。

  • 初期化
    • TextureView の準備待
    • カメラデバイス準備待
    • キャプチャセッション接続待
  • 撮影
    • オートフォーカス待
    • オート露出待
    • 画像取得待

そのまま実装するといわいるコールバック地獄になります。
Googleのサンプルコードからこれを読み取るのに1日かかりました。

Camera2 API のオリジナルのサンプル

Googleのサンプルを元に最小構成のサンプルを独自に作ってみます。
簡略化のため以下の機能を削ります。

  • バックグラウンドスレッドと排他制御(全部UIスレッド)
  • 画面回転対応(縦置固定)

状態遷移マシン

コールバック地獄回避のため状態遷移を導入します。
Googleのサンプルから読み取った各状態を整理した物が以下になります。

状態遷移図:

状態説明:
状態名説明
InitSafaceTextureViewの初期化。
- Safaceとして使えるようになるのを待ます。
- 初期化済の場合はスルー。
OpenCameraカメラデバイスの開始。
- カメラの情報から画像サイズ、プレビューサイズを決定し ImageReaderやTexcureViewの設定を行います。
CreateSessionキャプチャセッションの接続。(補足後述)
Previewプレビュー表示中。ここが安定状態となります。
AutoFocusオートフォーカス(AF)中。
- AF要求を発行して焦点が合うまでコールバックを受け続けます。
AutoExposure自動露出(AE)調整中。
- AE要求を発行して露出が合うまでコールバックを受け続けます。
TakePicture画像取得中。
- 撮影要求を発行して完了を待ちます。
- 結果画像は ImageReader のコールバックに返されます。
Abortカメラの終了。全てのリソースを開放します。

キャプチャセッションとは Camera2 API の新しい仕組みでカメラから画像を受け取る為のAPIです。 これに各種要求を発行して結果をコールバックで貰うのが基本パターンになります。
詳細は CameraCaptureSessionクラス のドキュメントを参照してください。

イベント説明:
イベント名説明
open()カメラの利用開始。通常 onResume() から呼ばれる。
close()カメラの利用終了。通常 onParse() から呼ばれる。
takePicture()撮影開始。通常シャッターの onClick() から呼ばれる。

サンプルの実装

完全なソースは GitHub にあります。

Camera2StateMachine.java:

上記、状態遷移図をそのまま実装しただけですが 300行になりました。
これでもカメラとして機能するための必要最小限です。

// Copyright 2015 kotemaru.org. (http://www.apache.org/licenses/LICENSE-2.0)
package org.kotemaru.android.camera2sample;

import java.util.Arrays;
import java.util.List;

import android.app.Activity;
import android.content.Context;
import android.graphics.ImageFormat;
import android.graphics.SurfaceTexture;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCaptureSession;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraDevice;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.CameraMetadata;
import android.hardware.camera2.CaptureRequest;
import android.hardware.camera2.CaptureResult;
import android.hardware.camera2.TotalCaptureResult;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.ImageReader;
import android.os.Handler;
import android.util.Log;
import android.util.Size;
import android.view.Surface;
import android.view.TextureView;

public class Camera2StateMachine {
    private static final String TAG = Camera2StateMachine.class.getSimpleName();
    private CameraManager mCameraManager;

    private CameraDevice mCameraDevice;
    private CameraCaptureSession mCaptureSession;
    private ImageReader mImageReader;
    private CaptureRequest.Builder mPreviewRequestBuilder;

    private AutoFitTextureView mTextureView;
    private Handler mHandler = null; // default current thread.
    private State mState = null;
    private ImageReader.OnImageAvailableListener mTakePictureListener;

    public void open(Activity activity, AutoFitTextureView textureView) {
        if (mState != null) throw new IllegalStateException("Alrady started state=" + mState);
        mTextureView = textureView;
        mCameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE);
        nextState(mInitSurfaceState);
    }
    public boolean takePicture(ImageReader.OnImageAvailableListener listener) {
        if (mState != mPreviewState) return false;
        mTakePictureListener = listener;
        nextState(mAutoFocusState);
        return true;
    }
    public void close() {
        nextState(mAbortState);
    }

    // ----------------------------------------------------------------------------------------
    // The following private
    private void shutdown() {
        if (null != mCaptureSession) {
            mCaptureSession.close();
            mCaptureSession = null;
        }
        if (null != mCameraDevice) {
            mCameraDevice.close();
            mCameraDevice = null;
        }
        if (null != mImageReader) {
            mImageReader.close();
            mImageReader = null;
        }
    }

    private void nextState(State nextState) {
        Log.d(TAG, "state: " + mState + "->" + nextState);
        try {
            if (mState != null) mState.finish();
            mState = nextState;
            if (mState != null) mState.enter();
        } catch (CameraAccessException e) {
            Log.e(TAG, "next(" + nextState + ")", e);
            shutdown();
        }
    }

    private abstract class State {
        private String mName;

        public State(String name) {
            mName = name;
        }
        //@formatter:off
        public String toString() {return mName;}
        public void enter() throws CameraAccessException {}
        public void onSurfaceTextureAvailable(int width, int height){}
        public void onCameraOpened(CameraDevice cameraDevice){}
        public void onSessionConfigured(CameraCaptureSession cameraCaptureSession) {}
        public void onCaptureResult(CaptureResult result, boolean isCompleted) throws CameraAccessException {}
        public void finish() throws CameraAccessException {}
        //@formatter:on
    }

    // ===================================================================================
    // State Definition
    private final State mInitSurfaceState = new State("InitSurface") {
        public void enter() throws CameraAccessException {
            if (mTextureView.isAvailable()) {
                nextState(mOpenCameraState);
            } else {
                mTextureView.setSurfaceTextureListener(mSurfaceTextureListener);
            }
        }
        public void onSurfaceTextureAvailable(int width, int height) {
            nextState(mOpenCameraState);
        }

        private final TextureView.SurfaceTextureListener mSurfaceTextureListener = new TextureView.SurfaceTextureListener() {
            @Override
            public void onSurfaceTextureAvailable(SurfaceTexture texture, int width, int height) {
                if (mState != null) mState.onSurfaceTextureAvailable(width, height);
            }
            @Override
            public void onSurfaceTextureSizeChanged(SurfaceTexture texture, int width, int height) {
                // TODO: ratation changed.
            }
            @Override
            public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) {
                return true;
            }
            @Override
            public void onSurfaceTextureUpdated(SurfaceTexture texture) {
            }
        };
    };
    // -----------------------------------------------------------------------------------
    private final State mOpenCameraState = new State("OpenCamera") {
        public void enter() throws CameraAccessException {
            String cameraId = Camera2Util.getCameraId(mCameraManager, CameraCharacteristics.LENS_FACING_BACK);
            CameraCharacteristics characteristics = mCameraManager.getCameraCharacteristics(cameraId);
            StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);

            mImageReader = Camera2Util.getMaxSizeImageReader(map, ImageFormat.JPEG);
            Size previewSize = Camera2Util.getBestPreviewSize(map, mImageReader);
            mTextureView.setPreviewSize(previewSize.getHeight(), previewSize.getWidth());

            mCameraManager.openCamera(cameraId, mStateCallback, mHandler);
            Log.d(TAG, "openCamera:" + cameraId);
        }
        public void onCameraOpened(CameraDevice cameraDevice) {
            mCameraDevice = cameraDevice;
            nextState(mCreateSessionState);
        }

        private final CameraDevice.StateCallback mStateCallback = new CameraDevice.StateCallback() {
            @Override
            public void onOpened(CameraDevice cameraDevice) {
                if (mState != null) mState.onCameraOpened(cameraDevice);
            }
            @Override
            public void onDisconnected(CameraDevice cameraDevice) {
                nextState(mAbortState);
            }
            @Override
            public void onError(CameraDevice cameraDevice, int error) {
                Log.e(TAG, "CameraDevice:onError:" + error);
                nextState(mAbortState);
            }
        };
    };
    // -----------------------------------------------------------------------------------
    private final State mCreateSessionState = new State("CreateSession") {
        public void enter() throws CameraAccessException {
            mPreviewRequestBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
            SurfaceTexture texture = mTextureView.getSurfaceTexture();
            texture.setDefaultBufferSize(mTextureView.getPreviewWidth(), mTextureView.getPreviewHeight());
            Surface surface = new Surface(texture);
            mPreviewRequestBuilder.addTarget(surface);
            List<Surface> outputs = Arrays.asList(surface, mImageReader.getSurface());
            mCameraDevice.createCaptureSession(outputs, mSessionCallback, mHandler);
        }
        public void onSessionConfigured(CameraCaptureSession cameraCaptureSession) {
            mCaptureSession = cameraCaptureSession;
            nextState(mPreviewState);
        }

        private final CameraCaptureSession.StateCallback mSessionCallback = new CameraCaptureSession.StateCallback() {
            @Override
            public void onConfigured(CameraCaptureSession cameraCaptureSession) {
                if (mState != null) mState.onSessionConfigured(cameraCaptureSession);
            }
            @Override
            public void onConfigureFailed(CameraCaptureSession cameraCaptureSession) {
                nextState(mAbortState);
            }
        };
    };
    // -----------------------------------------------------------------------------------
    private final State mPreviewState = new State("Preview") {
        public void enter() throws CameraAccessException {
            mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
            mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH);
            mCaptureSession.setRepeatingRequest(mPreviewRequestBuilder.build(), mCaptureCallback, mHandler);
        }
    };
    private final CameraCaptureSession.CaptureCallback mCaptureCallback = new CameraCaptureSession.CaptureCallback() {
        @Override
        public void onCaptureProgressed(CameraCaptureSession session, CaptureRequest request, CaptureResult partialResult) {
            onCaptureResult(partialResult, false);
        }
        @Override
        public void onCaptureCompleted(CameraCaptureSession session, CaptureRequest request, TotalCaptureResult result) {
            onCaptureResult(result, true);
        }
        private void onCaptureResult(CaptureResult result, boolean isCompleted) {
            try {
                if (mState != null) mState.onCaptureResult(result, isCompleted);
            } catch (CameraAccessException e) {
                Log.e(TAG, "handle():", e);
                nextState(mAbortState);
            }
        }
    };
    // -----------------------------------------------------------------------------------
    private final State mAutoFocusState = new State("AutoFocus") {
        public void enter() throws CameraAccessException {
            mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_START);
            mCaptureSession.setRepeatingRequest(mPreviewRequestBuilder.build(), mCaptureCallback, mHandler);
        }
        public void onCaptureResult(CaptureResult result, boolean isCompleted) throws CameraAccessException {
            Integer afState = result.get(CaptureResult.CONTROL_AF_STATE);
            boolean isAfReady = afState == null
                    || afState == CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED
                    || afState == CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED;
            if (isAfReady) {
                nextState(mAutoExposureState);
            }
        }
    };
    // -----------------------------------------------------------------------------------
    private final State mAutoExposureState = new State("AutoExposure") {
        public void enter() throws CameraAccessException {
            mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
                    CameraMetadata.CONTROL_AE_PRECAPTURE_TRIGGER_START);
            mCaptureSession.setRepeatingRequest(mPreviewRequestBuilder.build(), mCaptureCallback, mHandler);
        }
        public void onCaptureResult(CaptureResult result, boolean isCompleted) throws CameraAccessException {
            Integer aeState = result.get(CaptureResult.CONTROL_AE_STATE);
            boolean isAeReady = aeState == null
                    || aeState == CaptureResult.CONTROL_AE_STATE_CONVERGED
                    || aeState == CaptureRequest.CONTROL_AE_STATE_FLASH_REQUIRED;
            if (isAeReady) {
                nextState(mTakePictureState);
            }
        }
    };
    // -----------------------------------------------------------------------------------
    private final State mTakePictureState = new State("TakePicture") {
        public void enter() throws CameraAccessException {
            final CaptureRequest.Builder captureBuilder =
                    mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE);
            captureBuilder.addTarget(mImageReader.getSurface());
            captureBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
            captureBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH);
            captureBuilder.set(CaptureRequest.JPEG_ORIENTATION, 90); // portraito
            mImageReader.setOnImageAvailableListener(mTakePictureListener, mHandler);

            mCaptureSession.stopRepeating();
            mCaptureSession.capture(captureBuilder.build(), mCaptureCallback, mHandler);
        }
        public void onCaptureResult(CaptureResult result, boolean isCompleted) throws CameraAccessException {
            if (isCompleted) {
                nextState(mPreviewState);
            }
        }
        public void finish() throws CameraAccessException {
            mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL);
            mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH);
            mCaptureSession.capture(mPreviewRequestBuilder.build(), mCaptureCallback, mHandler);
            mTakePictureListener = null;
        }
    };
    // -----------------------------------------------------------------------------------
    private final State mAbortState = new State("Abort") {
        public void enter() throws CameraAccessException {
            shutdown();
            nextState(null);
        }
    };
}
Camera2Util.java:

ツール化できそうな部分を分離しました。

package org.kotemaru.android.camera2sample;

import android.graphics.SurfaceTexture;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.ImageReader;
import android.util.Size;

public class Camera2Util {
    public static String getCameraId(CameraManager cameraManager, int facing) throws CameraAccessException {
        for (String cameraId : cameraManager.getCameraIdList()) {
            CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId);
            if (characteristics.get(CameraCharacteristics.LENS_FACING) == facing) {
                return cameraId;
            }
        }
        return null;
    }

    public static ImageReader getMaxSizeImageReader(StreamConfigurationMap map, int imageFormat) throws CameraAccessException {
        Size[] sizes = map.getOutputSizes(imageFormat);
        Size maxSize = sizes[0];
        for (Size size:sizes) {
            if (size.getWidth() > maxSize.getWidth()) {
                maxSize = size;
            }
        }
        ImageReader imageReader = ImageReader.newInstance(
                //maxSize.getWidth(), maxSize.getHeight(), // for landscape.
                maxSize.getHeight(), maxSize.getWidth(), // for portrait.
                imageFormat, /*maxImages*/1);
        return imageReader;
    }

    public static Size getBestPreviewSize(StreamConfigurationMap map, ImageReader imageSize) throws CameraAccessException {
        //float imageAspect = (float) imageSize.getWidth() / imageSize.getHeight(); // for landscape.
        float imageAspect = (float) imageSize.getHeight() / imageSize.getWidth(); // for portrait
        float minDiff = 1000000000000F;
        Size[] previewSizes = map.getOutputSizes(SurfaceTexture.class);
        Size previewSize = previewSizes[0];
        for (Size size : previewSizes) {
            float previewAspect = (float) size.getWidth() / size.getHeight();
            float diff = Math.abs(imageAspect - previewAspect);
            if (diff < minDiff) {
                previewSize = size;
                minDiff = diff;
            }
            if (diff == 0.0F) break;
        }
        return previewSize;
    }
}
MainActivity.java:

状態遷移マシンを使用したカメラActivityです。
撮影した画像を ImageView で表示します。

package org.kotemaru.android.camera2sample;

import java.nio.ByteBuffer;

import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.Image;
import android.media.ImageReader;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.View;
import android.widget.ImageView;

public class MainActivity extends Activity {
    private AutoFitTextureView mTextureView;
    private ImageView mImageView;
    private Camera2StateMachine mCamera2;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mTextureView = (AutoFitTextureView) findViewById(R.id.TextureView);
        mImageView = (ImageView) findViewById(R.id.ImageView);
        mCamera2 = new Camera2StateMachine();
    }

    @Override
    protected void onResume() {
        super.onResume();
        mCamera2.open(this, mTextureView);
    }
    @Override
    protected void onPause() {
        mCamera2.close();
        super.onPause();
    }
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_BACK && mImageView.getVisibility() == View.VISIBLE) {
            mTextureView.setVisibility(View.VISIBLE);
            mImageView.setVisibility(View.INVISIBLE);
            return false;
        }
        return super.onKeyDown(keyCode, event);
    }
    public void onClickShutter(View view) {
        mCamera2.takePicture(new ImageReader.OnImageAvailableListener() {
            @Override
            public void onImageAvailable(ImageReader reader) {
                // 撮れた画像をImageViewに貼り付けて表示。
                final Image image = reader.acquireLatestImage();
                ByteBuffer buffer = image.getPlanes()[0].getBuffer();
                byte[] bytes = new byte[buffer.remaining()];
                buffer.get(bytes);
                Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
                image.close();

                mImageView.setImageBitmap(bitmap);
                mImageView.setVisibility(View.VISIBLE);
                mTextureView.setVisibility(View.INVISIBLE);
            }
        });
    }
}

実行結果

Nexus5 では問題なく実行できました。
分かりづらいですが左がプレビュー中で右が撮影した画像をImageViewで表示しているところです。

プレビュー中 撮影結果表示中

所感

最初は戸惑いましたが構造が分かってくるとこちらの方が扱い易い気がしてきました。
アプリは複雑になりますがカメラ・デバイスを完全に制御できる感じです。

機種依存問題が解決されるのか否かは 5.0 対応機がもう少し増えないと判断できませんが期待したいところです。


2015/05/18

iOSとAndroidのライフサイクル比較

iOSとAndroidの差異で一番気になっていたライフサイクルの違いを調べてみました。

とりあえず意味的に近そうな主要メソッドの対応表です。

漠然と AppDelegate=Application、ViewController=Activity と考えていたのですが 整理して見ると役割がだいぶ違うようです。

iOS では素直に AppDelegate=アプリ、ViewController=画面制御 と考えて良さそうですが、 Android では Application の役割が希薄で Activity=サブアプリ+画面制御 になっています。 ViewController は Fragment として考えたほうが良いのかもしれません。

モジュールの対比はこんな感じでしょうか?

iOS→Android の移植の場合には AppDelegate=Activity、ViewController=Fragment とするとシンプルに行きそうです。

Android→iOS の移植は難しそうです。複数の Activity を持つアプリの場合、 単純にマッピングできないのでケースバイケースで対応を考えるしかなさそうです。

Android は iOS をもっとパクっていると思っていたのですが基本的な考え方から異なるようですね。 アプリの根幹の部分の差異なので共通アプリを作る場合には設計時点で考慮しておく必要がありそうです。

所感

こんな情報はネットにいくらでも転がってると思っていたんだけど以外に見つからない。 iOSとAndroidの相互移植は頻繁に発生してると思うんだが...

思う所有って iOS の勉強を始めたのだが iOS の開発ってハードル高いよね。
まず Mac 買わなきゃいけない。 勉強目的なので Mac book は予算オーバーで mini を購入。

しかも、ユーザにはメモリ拡張すらさせないボッタクリ。 ますますアップルが嫌いになりますw


2015/04/10

Android-x86-4.4r2 の設定メモ

Android-x86とVirtualBoxを使った開発用の環境の設定方法メモです。
以前 4.0 で作ったのですが 4.4 になったらだいぶ変わっていたので備忘録として残します。

画面のバランスこんな感じです。 (モニタは 1920x1200/22inch、端末は6inchくらいに見えます。)

標準のエミュレータと同じように使えますがこちらは実機並にサクサク動きます。

VirtualBox

ダウンロード

インストール

  • 普通に .exe 叩くだけ。
  • Extension Pack は VirtualBox にリモートデスクトップ接続するときに必要。
    • 「ファイル」->「環境設定」->「拡張機能」 からインストールする。

Android-x86

ダウンロード

インストール

  • VirtualBox で仮想マシンを作る。
    • OSは Other Linux(32bit)
    • HDDは最低2G
    • メモリは512M~1G
    • ネットワークはブリッジ
    • リモートディスプレイを有効
  • android-x86-4.4-r2.iso をCDにマウントして起動。
    • インストーラでインストール。

ネットワーク設定

固定IPを Android-x86 に設定する。

  • Android-x86 をデバックモードで起動
  • /system/etc/init.sh の最後に以下を追加。
    • 最後といっても return 0 の行の前。
ifconfig eth0 {ip-addr} netmask {netmask}
route add default gw {gateway-ip-addr} dev eth0
setprop net.eth0.dns1 {dns-ip-addr}
  • プロキシが必要な場合の設定
sqlite3 /data/data/com.android.providers.settings/databases/settings.db
INSERT INTO system VALUES(99, 'http_proxy', ':');

縦画面モード設定

開発時に適当なサイズとなる 270x480 ピクセルを設定。

  • VirtualBox に画面モード追加
C:> VBoxManage.exe setextradata {VM name} CustomVideoMode1 270x480x16
  • Android をデバックモードで起動
    • /mnt/grub/menu.lst を編集して kernel のオプションに以下を追加
video=-16 UVESA_MODE=240x480
  • DPIを120に設定
C:> adb connect {ip-addr}
C:> adb shell wm density 120

Eclipse との接続

  • VM 起動後に以下のコマンドを実行
C:> adb disconnect {ip-addr}
C:> adb connect {ip-addr}
  • DDMS が自動的に認識してくれる。

その他

  • Android-x86 は VirtualBox のマウス統合が使えないので結構不便。
  • リモートデスクトップ接続すると解消される。
    • 但し、描画は遅くなる。

2015/04/09

Android技術社認定試験

「Androidアプリケーション技術者認定試験ベーシック」と言うのを受けてきました。
仕事先の壁にポスターが張ってあって存在を知りました。
プロメトリックの試験は何回かやっているので大体分かっていましたが案の定、暗記オンリーのクソテストです。
しかも、実際の開発ではほぼ役に立たない API やDOSコマンドの丸暗記でまったく楽しくない苦痛な作業でした。

購入した本は以下2点

これ丸暗記すれば誰でも受かります。
Android開発やったことの無い人でもw


2015/03/08

ループする HorizontalScrollView

Android の widget に有りそうで無いのが左右が繋がって循環ループできる View。
横スクロールする HorizontalScrollView は有るけど端っこで止まってしまう。

ググっても質問ばかりでまともな回答が無いようなので汎用的な widget を作ってみた。

ソース

200 行程のクラス1つに収まった。

LoopHScrollView.java:

// Copyright (c) 2015 kotemaru.org  /  License is APL-2.0
package org.kotemaru.android.fw.widget;

import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Canvas;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.HorizontalScrollView;
import android.widget.LinearLayout;

public class LoopHScrollView extends HorizontalScrollView {
    private InnerLayout mInnerLayout;
    private AnimeManager mAnimeManager;
    private GestureDetector mGestureDetector;
    private CsutomOnGestureListener mOnGestureListener = new CsutomOnGestureListener();

    public LoopHScrollView(Context context) {
        this(context, null);
    }
    public LoopHScrollView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public LoopHScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mAnimeManager = new AnimeManager();
        mGestureDetector = new GestureDetector(context, mOnGestureListener);

        mInnerLayout = new InnerLayout(context);
        mInnerLayout.setOrientation(LinearLayout.HORIZONTAL);
        FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
                LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
        mInnerLayout.setLayoutParams(params);
        this.addView(mInnerLayout);
    }

    public void setChildViewGroup(ViewGroup child) {
        CloneView clone1 = new CloneView(getContext(), child);
        CloneView clone2 = new CloneView(getContext(), child);

        LinearLayout.LayoutParams childParams = new LinearLayout.LayoutParams(
                LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
        mInnerLayout.removeAllViews();
        mInnerLayout.addView(clone1, childParams);
        mInnerLayout.addView(child, childParams);
        mInnerLayout.addView(clone2, childParams);
    }

    public class InnerLayout extends LinearLayout {
        public InnerLayout(Context context) {
            super(context);
        }
        @Override
        public void onWindowFocusChanged(boolean hasFocus) {
            super.onWindowFocusChanged(hasFocus);
            resizing();
        }
        @Override
        public void onSizeChanged(int w, int h, int oldw, int oldh) {
            super.onSizeChanged(w, h, oldw, oldh);
            resizing();
        }
        private void resizing() {
            if (getChildCount() == 3) {
                View clone1 = this.getChildAt(0);
                View origin = this.getChildAt(1);
                View clone2 = this.getChildAt(2);
                int width = origin.getMeasuredWidth();
                if (width < LoopHScrollView.this.getMeasuredWidth()) {
                    // TODO: Can not scroll small child view.
                    mInnerLayout.removeView(clone1);
                    mInnerLayout.removeView(clone2);
                } else {
                    clone1.setMinimumWidth(width);
                    clone2.setMinimumWidth(width);
                }
            }
        }
    }

    public static class CloneView extends ViewGroup {
        ViewGroup mOrigin;

        public CloneView(Context context, ViewGroup origin) {
            super(context);
            mOrigin = origin;
        }
        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
        }
        @Override
        protected void dispatchDraw(Canvas canvas) {
            mOrigin.draw(canvas);
        }
        @Override
        public boolean dispatchKeyEvent(KeyEvent event) {
            return mOrigin.dispatchKeyEvent(event);
        }
        @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            boolean result = mOrigin.dispatchTouchEvent(ev);
            this.invalidate();
            return result;
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) mOnGestureListener.onDown(ev);
        return super.onInterceptTouchEvent(ev);
    }
    @SuppressLint("ClickableViewAccessibility")
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        return mGestureDetector.onTouchEvent(ev);
    }

    private class CsutomOnGestureListener extends GestureDetector.SimpleOnGestureListener {
        boolean mIsFirstScroll = true; // Note: 子要素のACTION_DOWNが届かず誤動作するので初回を無視する。

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (!mIsFirstScroll) mAnimeManager.startScroll(velocityX);
            mIsFirstScroll = false;
            return true;
        }
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            mAnimeManager.startFling(-velocityX / 50);
            return true;
        }
        @Override
        public boolean onDown(MotionEvent ev) {
            mAnimeManager.stopFling();
            mIsFirstScroll = true;
            return true;
        }
    };

    private class AnimeManager {
        private static final int INTERVAL = 50; // ms
        private static final float ATTENUAION_RATE = 0.90F;
        private float mDelta = 0;
        private final Runnable mUpdateRunner = new Runnable() {
            @Override
            public void run() {
                onUpdate();
            }
        };
        public void update() {
            Handler handler = getHandler();
            if (handler == null) return;
            handler.postDelayed(mUpdateRunner, INTERVAL);
        }

        public void startScroll(float delta) {
            stopFling();
            if (!loopScrollPosition()) {
                scrollBy((int) delta, 0);
            }
        }
        public void startFling(float delta) {
            mDelta = delta;
            update();
        }
        public void stopFling() {
            mDelta = 0.0F;
        }
        private void onUpdate() {
            if (Math.abs(mDelta) > 1.0F) {
                scrollBy((int) mDelta, 0);
                mDelta = mDelta * ATTENUAION_RATE;
                loopScrollPosition();
                update();
            } else {
                stopFling();
            }
        }
        private boolean loopScrollPosition() {
            int curX = computeHorizontalScrollOffset();
            int unitWidth = computeHorizontalScrollRange() / 3;
            if (curX > unitWidth * 1.8F) {
                scrollTo(curX - unitWidth, 0);
                return true;
            } else if (curX < unitWidth * 0.2F) {
                scrollTo(curX + unitWidth, 0);
                return true;
            }
            return false;
        }
    }
}
  • やっていることは同じ 子View を3つ横に並べてスクロールさせているだけ。
    • 端に来ると真ん中にジャンプする。
  • 肝は指定された子Viewの表示をコピーする CloneView 内部クラス。
    • dispatch{Draw|KeyEvent|MotionEvent}() メソッドをオリジナルに転送するだけだがほぼ同じ動きをさせる事ができている。

使い方

使い方は layout.xml に LoopHScrollView を指定して Activity.onCreate() からループする内容となる子ViewGroupを設定するだけ。
ループする内容は実装上の都合により別の layout に定義する必要がある。

Activity の main_activity.xml の抜粋

    <org.kotemaru.android.fw.widget.LoopHScrollView
        android:id="@+id/loopHScrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
    </org.kotemaru.android.fw.widget.LoopHScrollView>
  • LoopHScrollView を好きな所へ埋め込む。

ループする内容 の loop_scroll_item.xml の例

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content" android:layout_height="match_parent"
    android:orientation="vertical" >
    <LinearLayout  android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:orientation="horizontal" >
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#ff8888" android:text="Red" android:textSize="24dp" />
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#ffff88" android:text="Yellow" android:textSize="20dp" />
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#88ff88" android:text="Green" android:textSize="24dp" />
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#88ffff" android:text="Cyan" android:textSize="24dp" />
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#8888ff" android:text="Blue" android:textSize="24dp" />
    </LinearLayout>
</LinearLayout>
  • 横幅は LoopHScrollView より大きい幅が必要。
    • 小さいとスクロールできない。
  • トップ要素は ViewGroup が必須。

onCreate() の実装

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main_activity);

    LoopHScrollView scrollView = (LoopHScrollView) findViewById(R.id.loopHScrollView);
    LayoutInflater inflater = LayoutInflater.from(this);
    ViewGroup child = (ViewGroup) inflater.inflate(R.layout.loop_scroll_item, null, false);
    scrollView.setChildViewGroup(child);
}
  • 子要素を生成して設定しているだけ。
    • 子要素は ViewGroup なら何でも良い。
  • イベントは KeyKevent と MotionEvent は拾う。
    • 但し、ドラッグ系の操作はできない。(HorizontalScrollViewと同じ)

実行結果

ちゃんとループ出来てます。
セレクタ系 UI では使えそうな気がする。

追記(2016/6/4)

フラグメントから使うと上手くいかないとのコメントが有ったので修正方法。

    public class InnerLayout extends LinearLayout {
        public InnerLayout(Context context) {
            super(context);
        }

        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            if (getChildCount() == 3) {
                View clone1 = this.getChildAt(0);
                View origin = this.getChildAt(1);
                View clone2 = this.getChildAt(2);
                int width = origin.getMeasuredWidth();
                if (width < LoopHScrollView.this.getMeasuredWidth()) {
                    // TODO: Can not scroll small child view.
                    mInnerLayout.removeView(clone1);
                    mInnerLayout.removeView(clone2);
                } else {
                    clone1.setMinimumWidth(width);
                    clone2.setMinimumWidth(width);
                }
                super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            }
        }
    }
  • サイズ補正のタイミングを onMeasure() に移動して解決。

2015/03/05

縦横比固定FrameLayout

Android の画面設計をやっていると縦横比(アスペクト比)を固定したまま 端末の画面サイズ一杯にレイアウトしたい事がよく有るのだけど標準の機能に無い。

ググってみたところ自力で計算して設定するしか無いらしい。

このサイトを参考に汎用的に使える FrameLayout を作ってみた。

FixedAspectFrameLayout.java:

// Copyright (c) 2015 kotemaru.org  / License is APL-2.0
package org.kotemaru.android.fw.widget;

import org.kotemaru.android.fw.R;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.FrameLayout;

/**
 * 縦横比固定FrameLayout。
 * - 上位のLayoutによって動的に決定された幅(or高さ)に応じて縦横比が一定になるように高さ(or幅)を決定する。
 * - 縦横比はカスタム属性 aspectRate に float (幅÷高さ) で与る。
 * - 参考:http://stackoverflow.com/a/13846628/804479
 * 
 * @author kotemaru.org
 */
public class FixedAspectFrameLayout extends FrameLayout {
    private float mAspectRate;

    public FixedAspectFrameLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.fw_FixedAspectFrameLayout);
        this.mAspectRate = a.getFloat(R.styleable.fw_FixedAspectFrameLayout_aspectRate, 1.0F);
        a.recycle();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        if (widthMode == MeasureSpec.EXACTLY && heightMode != MeasureSpec.EXACTLY) {
            int h = (int) (MeasureSpec.getSize(widthMeasureSpec) / mAspectRate);
            super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY));
        } else if (widthMode != MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
            int w = (int) (MeasureSpec.getSize(heightMeasureSpec) * mAspectRate);
            super.onMeasure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), heightMeasureSpec);
        } else {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }
}

attr.xml

<resources>
    <declare-styleable name="fw_FixedAspectFrameLayout">
        <attr name="aspectRate" format="float" />
    </declare-styleable>
</resources>

 

使い方

使い方は layout.xml に FixedAspectFrameLayout を指定してカスタム属性の aspectRate を指定するだけ。

layout.xml

    <org.kotemaru.android.fw.widget.FixedAspectFrameLayout
        xmlns:custom="http://schemas.android.com/apk/res/org.kotemaru.android.fw"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#00ff00"
        custom:aspectRate="2.35" >
    </org.kotemaru.android.fw.widget.FixedAspectFrameLayout>
  • この例ではシネスコのアスペクト比の FrameLayout になるので内側に適当な View を match_parent で入れれば良い。

実行結果

 

  • 端末も縦横も関係無く縦横比を維持したしたまま画面一杯にレイアウトできている。

プロフィール
20年勤めた会社がリーマンショックで消滅、紆余曲折を経て現在はフリーランスのSE。 失業をきっかけにこのブログを始める。

サイト内検索

登録
RSS/2.0

カテゴリ

最近の投稿【android】

リンク

アーカイブ