Android Gesture 手勢研究

怎么理解一個手勢,就是在屏幕上,手畫一個符號就是一個手勢,它代表了用戶的一個意圖,也就是用戶希望程序做點什么,一般程序大多數是通過按鈕,按鈕上有對應的文字,這樣進行人機交互,而手勢也是很多地方會使用到,而常用的手勢好像下拉刷新,用戶希望列表內容下拉一下就有新的信息,雙指縮放等等,一般這些手勢都是跟對應的view綁定起來,而今天介紹的都是方法是可以不綁定view,直接在界面上畫一個手勢就可以人機交互.實現的代碼可以在github上的Demo源碼了解.

這篇手勢研究會大概分三部分

  1. 手勢Gesture使用方式
  2. 展示手勢開發的步驟及代碼實現
  3. 分析Gesture的源碼及原理

使用的方式

首先我們需要把用戶需要使用到的手勢提前記錄下來,準備一些手勢的樣本,在app安裝時隨著資源文件或者下載等方式存儲到用戶的手機里,當用戶在app畫一個手勢時,就去匹配手勢樣本,當時樣本最吻合時,就知道用戶的意圖,采取執行對應的功能,這樣就是個很好的人機交互的方式.

從上文使用方式,我們大概猜想到,我們需要一個東西,用來管理和讀取我們已經存儲的手勢樣本,我們還要需要這個東西可以設別用戶的手勢跟我們已經存儲的手勢進行匹配.還有,我們需一個東西在app的界面上記錄用戶的手勢,沒錯,兩個東西都存在,就是GestureLibrary和GestureOverlayView,這兩個類就是手勢開發里使用的主要兩個類,通過這兩個類,我們就可以實現手勢開發的所有功能,是不是很簡單.

總結一下:

  1. 提前準備好手勢樣本,在安裝時加入到資源文件或者安裝后網絡下載.
    
  2. 需要使用手勢的界面里使用GestureOverlayView記錄用戶的手勢,
    
  3. 使用GestureLibrary對象對用戶的手勢進行監聽和匹配,找到用戶手勢的意圖,執行對應的功能
    

步驟及代碼實現

  1. 手勢庫的初始化
    GestureLibrary gLib=GestureLibraries.fromFile(手勢庫文件); gLib.load();
    這個過程是讀取已經存儲手勢樣本文件,構造出GestureLibrary實例的過程,需要第一步實現.

  2. 對用戶手勢的監聽
    GestureOverlayView.addOnGesturePerformedListener()

  3. 使用GestureLibrary對用戶的手勢進行匹配
    recognize(Gesture gesture)

  4. 循環遍歷返回的ArrayList<Prediction>對象,使用Prediction的score來匹配手勢的相似度,
    score越高代表越匹配.
    Prediction.score()

這里就是手勢開發的實現的全部內容,但是作為一個程序猿,需要知其然知其所以然,就要對源碼進行解剖.


原理

手勢的結構

手勢是用戶在屏幕上畫的符號,那么手勢可以簡單的一筆筆畫,例如一個方向的箭頭(>),也可以多筆劃,很復雜,例如一個文字.這些都手勢,所以我們就知道

手勢是由一個或者多個筆畫組成

學過數學的我們都到線是由點組成的,所以

一個手勢筆畫是由多個時間連續的點組成

一個點意味著什么呢,它會固定在屏幕的某個地方,還需要時間連續不斷,所以

手勢中的點包含坐標X軸和Y軸,還有時間戳

所以我們就很容易了解手勢對應的文件了

GesturePoint : 是手勢筆劃中的一個點,包含X軸,Y軸的坐標,還有時間戳.
GestureStroke : 手勢筆劃,可以理解為線,由多個點組成的.
Gesture : 手勢,代表用戶的一個手勢,可以由一個或者多個手勢筆劃組成.
GestureStore 手勢倉庫,里面存儲了多個手勢樣本


手勢的使用

使用手勢的過程都是先從GestureLibrary開始,那么看看GestureLibrary的關系圖.

14937957345949.jpg

從圖中看,GestureLibrary的實現有兩種,一個File的實現,另外一個是由資源Resource實現,說明我們的手勢庫可有兩個方向可以構造.

然后看回GestureLibrary的源碼

public abstract class GestureLibrary {
     protected final GestureStore mStore;
     ...
}

里面只有一個對象,而所有的方法都是由這個對象實現,也就是GestureLibrary其實是GestureStore的代理類,而真正的功能其實是在GestureStore里.

GestureStore的內容很多,首先看到的是頂部注釋里有手勢文件的結構內容

Nb.bytes Java type Description
Header
2 bytes short File format version
4 bytes int number Number of entries
Entry
X bytes UTF String Entry name
4 bytes int Number of gestures
Gesture
8 bytes long Gesture ID
4 bytes int Number of strokes
Stroke
4 bytes int Number of points
Point
4 bytes float X coordinate of the point
4 bytes float Ycoordinate of the point
8 bytes long Time stamp

從源碼可以知道,GestureStore的文件格式主要組成部分,也就是GestureLibrary讀取文件的格式內容,也可以考慮根據這樣的格式來進行加密,假如用手勢來做成一個手寫輸入法的軟件,那么手勢庫一定是龐大的內容庫,而且根據所有人不同的手寫方式,這樣的手勢庫一定很有價值,至于怎樣加密來保護這些價值,就可以考慮每個手勢的內容進行拆分來分別存儲和采取不同的加密方式加密.

然后我們再看Store對手勢的讀取保存

讀取和保存

讀取第一步GestureLibraries中讀取手勢文件

public boolean load() {
    ...
    mStore.load(new FileInputStream(file), true);
    ...
}

第二步store獲取文件流

public void load(InputStream stream, boolean closeStream) throws IOException {
        DataInputStream in = null;
        try {
            in = new DataInputStream((stream instanceof BufferedInputStream) ? stream :
                    new BufferedInputStream(stream, GestureConstants.IO_BUFFER_SIZE));
            ...

            // Read file format version number
            final short versionNumber = in.readShort();
            switch (versionNumber) {
                case 1:
                    readFormatV1(in);
                    break;
            }
           ...
    }

第三步從文件流里讀取文件名和手勢對象(Gestire),然后存進HashMap里

    /**
     * 讀取文件數據
     *
     * @param in
     * @throws IOException
     */
    private void readFormatV1(DataInputStream in) throws IOException {
        ...
        for (int i = 0; i < entriesCount; i++) {
            // Entry name
            final String name = in.readUTF();
            // Number of gestures
            final int gestureCount = in.readInt();

            final ArrayList<Gesture> gestures = new ArrayList<Gesture>(gestureCount);
            for (int j = 0; j < gestureCount; j++) {
                final Gesture gesture = Gesture.deserialize(in);
                gestures.add(gesture);
                classifier.addInstance(Instance.createInstance(mSequenceType, mOrientationStyle, gesture, name));
            }

            namedGestures.put(name, gestures);
        }
}


讀取的方式是從文件流里獲取到手勢數據,從Gesture的deserialize方法可以知道,每一步的解析都是按照文件存儲格式一步步獲取數據,當然,存儲也是反向一步步保存成文件流格式存儲的.


手勢的匹配

這里我們再好好探求手勢的設別匹配,也是我認為手勢源碼之中最有研究價值的一塊.當把代碼解析一下就會發現其實很多功能的本質就是數學問題,而這里的手勢匹配的本質就是數學的線性代數.

首先從匹配的方法入手,GestureStore.recognize()方法開始看

public ArrayList<Prediction> recognize(Gesture gesture) {

        //實例
        Instance instance = Instance.createInstance(mSequenceType, mOrientationStyle, gesture, null);

        //歸類
        return mClassifier.classify(mSequenceType, mOrientationStyle, instance.vector);
    }

recognize()方法里有兩個核心,一個是根據手勢對象(Gesture)來構造一個實例,二是通過mClassifier對象的classify()方法來返回一個Prediction數組.

首先從Instance來研究.

static Instance createInstance(int sequenceType, int orientationType, Gesture gesture, String label) {
        float[] pts;
        Instance instance;
        if (sequenceType == GestureStore.SEQUENCE_SENSITIVE) {//單筆手勢
            //得到一個連續點的數組
            pts = temporalSampler(orientationType, gesture);
            instance = new Instance(gesture.getID(), pts, label);
            instance.normalize();
        } else {
            pts = spatialSampler(gesture);
            instance = new Instance(gesture.getID(), pts, label);
        }
        return instance;
}

從Instance的構造方法看是需要三個參數,id,連續點數組,和標簽label.所以temporalSampler()和spatialSampler()都是把手勢gesture轉換為一個數組.

但是為什么需要把一個手勢轉換為一個數組呢,我們都知道一條線是由無數個點,假如點太多就帶來很大量的計算工作,所以我們采用生物學的抽樣法.每隔固定的間隔就取一個樣本,這樣就減少計算量,但是太少的話就會樣本集合與真實的差別就很大,所以我們去了一個適合的量作為樣本數量.

private static final int SEQUENCE_SAMPLE_SIZE = 16;

我們取了樣本數量為16,把任何一個手勢筆劃轉換為均勻分割的16個點來代替.

轉換的方法就是GestureUtils.temporalSampling()

    /**
     * Samples a stroke temporally into a given number of evenly-distributed
     * points.
     * 代表均勻分布的點的一系列數字作為時間取樣的筆劃例子
     * 把一個手勢的筆劃(連續點的線)轉化為離散的點
     *
     * @param stroke    the gesture stroke to be sampled
     * @param numPoints the number of points 取樣點的數量(越多越精確,越多消耗性能越大)
     * @return the sampled points in the form of [x1, y1, x2, y2, ..., xn, yn]
     */
    public static float[] temporalSampling(GestureStroke stroke, int numPoints) {
        //遞增量,手勢筆畫的長度除以需要切開的段數(離散點數 - 1)
        final float increment = stroke.length / (numPoints - 1);
        //向量長度
        int vectorLength = numPoints * 2;
        //向量
        float[] vector = new float[vectorLength];//因為向量就是取樣點的內容,包含x,y坐標,所以是取樣點的兩倍
        float distanceSoFar = 0;
        float[] pts = stroke.points;
        //上次最新的坐標
        float lstPointX = pts[0];
        float lstPointY = pts[1];
        int index = 0;
        //當前坐標
        float currentPointX = Float.MIN_VALUE;
        float currentPointY = Float.MIN_VALUE;
        vector[index] = lstPointX;
        index++;
        vector[index] = lstPointY;
        index++;
        int i = 0;
        int count = pts.length / 2;
        while (i < count) {
            //默認值,也是第一個運行時執行的
            if (currentPointX == Float.MIN_VALUE) {
                i++;
                if (i >= count) {
                    break;
                }
                currentPointX = pts[i * 2];
                currentPointY = pts[i * 2 + 1];
            }
            //坐標偏移量
            float deltaX = currentPointX - lstPointX;//兩個坐標點的X軸差值
            float deltaY = currentPointY - lstPointY;//兩個坐標點的Y軸差值
            //deltaX 和 deltaY的平方和的平方根(根據三角函數,)也就是兩個點的直線距離
            float distance = (float) Math.hypot(deltaX, deltaY);//根據三角函數定理,X2 + Y2 = Z2

            if (distanceSoFar + distance >= increment) {//當兩個點(疊加上次循環的距離)的距離大于遞增量(根據numPoints來確定的離散點的間隔距離)時執行
                //比例
                float ratio = (increment - distanceSoFar) / distance;
                float nx = lstPointX + ratio * deltaX;
                float ny = lstPointY + ratio * deltaY;
                vector[index] = nx;
                index++;
                vector[index] = ny;
                index++;
                lstPointX = nx;
                lstPointY = ny;
                distanceSoFar = 0;
            } else {//當兩個點的距離少于間隔距離
                //緩存當前的點
                lstPointX = currentPointX;
                lstPointY = currentPointY;
                //當前點默認最小值
                currentPointX = Float.MIN_VALUE;
                currentPointY = Float.MIN_VALUE;
                //疊加記錄兩點距離
                distanceSoFar += distance;
            }
        }

        //添加剩下最后一個點的坐標
        for (i = index; i < vectorLength; i += 2) {
            vector[i] = lstPointX;
            vector[i + 1] = lstPointY;
        }
        return vector;
    }

其中就使用到數學的三角函數公式,通過兩個點的坐標(x,y)來計算兩點距離.

回到Instance的類

    //時間取樣
    private static float[] temporalSampler(int orientationType, Gesture gesture) {
        //離散點
        float[] pts = GestureUtils.temporalSampling(gesture.getStrokes().get(0), SEQUENCE_SAMPLE_SIZE);
        //重心點
        float[] center = GestureUtils.computeCentroid(pts);
        //計算弧度值(計算第一個點與重心點形成的角度的弧度值)
        float orientation = (float) Math.atan2(pts[1] - center[1], pts[0] - center[0]);

        //???
        float adjustment = -orientation;
        if (orientationType != GestureStore.ORIENTATION_INVARIANT) {
            int count = ORIENTATIONS.length;
            for (int i = 0; i < count; i++) {
                float delta = ORIENTATIONS[i] - orientation;
                if (Math.abs(delta) < Math.abs(adjustment)) {
                    adjustment = delta;
                }
            }
        }

        //根據中心點平移,平移到中心點在原點上
        GestureUtils.translate(pts, -center[0], -center[1]);
        //根據調整出來的adjustment旋轉數據
        GestureUtils.rotate(pts, adjustment);

        return pts;
    }

除計算adjustment的方法還沒理解透,歡迎讀者可以繼續跟我交流

這個方法主要計算出手勢的間隔點數組,然后平移到坐標原點上和調整角度,輸出調整后的數組.就大概完成這個功能內容.接著我們繼續看下個功能點classify.

mClassifier這個對象的類似Learner,就是用于實現匹配功能的類,而classify的實現類在InstanceLearner這個類里,那么到底一個這么重要的方法classify到底做了什么呢?

    /**
     * 歸類
     *
     * @param sequenceType
     * @param orientationType
     * @param vector
     * @return
     */
    @Override
    ArrayList<Prediction> classify(int sequenceType, int orientationType, float[] vector) {

        //預測對象數組
        ArrayList<Prediction> predictions = new ArrayList<Prediction>();
        //實例數組
        ArrayList<Instance> instances = getInstances();

        int count = instances.size();

        //便簽找到得分值的map
        TreeMap<String, Double> label2score = new TreeMap<String, Double>();

        for (int i = 0; i < count; i++) {
            Instance sample = instances.get(i);

            //保證數據長度一致
            if (sample.vector.length != vector.length) {
                continue;
            }

            //距離(與手勢的差距)
            double distance;
            if (sequenceType == GestureStore.SEQUENCE_SENSITIVE) {
                distance = GestureUtils.minimumCosineDistance(sample.vector, vector, orientationType);
            } else {
                distance = GestureUtils.squaredEuclideanDistance(sample.vector, vector);
            }

            //權重(權重越大,代表越匹配)
            double weight;
            if (distance == 0) {
                //代表完全吻合
                weight = Double.MAX_VALUE;
            } else {
                //取distance的倒數
                weight = 1 / distance;
            }
            Double score = label2score.get(sample.label);
            if (score == null || weight > score) {
                label2score.put(sample.label, weight);
            }
        }

        for (String name : label2score.keySet()) {
            double score = label2score.get(name);
            predictions.add(new Prediction(name, score));
        }

        //排序
        Collections.sort(predictions, sComparator);

        return predictions;
    }

這個類主要做的事情就是對用戶的手勢和所有的已存的手勢進行匹配,計算出相識度的權重,然后我們就可以根據這個權重來知道用戶的手勢大概是什么意思.所以這個方法最重要的內容是計算權重的方法,GestureUtils的minimumCosineDistance()和squaredEuclideanDistance()

   /**
     * Calculates the "minimum" cosine distance between two instances.
     * <p>
     * 最小的余弦距離
     *
     * @param vector1
     * @param vector2
     * @param numOrientations the maximum number of orientation allowed
     * @return the distance between the two instances (between 0 and Math.PI)
     */
    static float minimumCosineDistance(float[] vector1, float[] vector2, int numOrientations) {
        final int len = vector1.length;
        //???
        float a = 0;
        float b = 0;
        for (int i = 0; i < len; i += 2) {
            a += vector1[i] * vector2[i] + vector1[i + 1] * vector2[i + 1];//(x1 * x2 + y1 * y2)疊加所有坐標
            b += vector1[i] * vector2[i + 1] - vector1[i + 1] * vector2[i];//(x1 * y2 + y1 * x2)疊加所有坐標
        }
        if (a != 0) {
            final float tan = b / a;
            //角度
            final double angle = Math.atan(tan);
            if (numOrientations > 2 && Math.abs(angle) >= Math.PI / numOrientations) {
                return (float) Math.acos(a);
            } else {
                final double cosine = Math.cos(angle);
                final double sine = cosine * tan;
                return (float) Math.acos(a * cosine + b * sine);
            }
        } else {
            return (float) Math.PI / 2;
        }
    }

minimumCosineDistance()方法從注釋來說就是實現最小的余弦距離,把用戶手勢點和一個樣本的手勢點進行疊加計算,

 /**
     * Calculates the squared Euclidean distance between two vectors.
     *
     * @param vector1
     * @param vector2
     * @return the distance
     */
    static float squaredEuclideanDistance(float[] vector1, float[] vector2) {
        float squaredDistance = 0;
        int size = vector1.length;
        for (int i = 0; i < size; i++) {
            //坐標點的x軸或y軸差距
            float difference = vector1[i] - vector2[i];
            squaredDistance += difference * difference;
        }
        return squaredDistance / size;
    }

squaredEuclideanDistance 的方法就是計算兩點差距,然后平方和再除以數量.

minimumCosineDistance()和squaredEuclideanDistance()的實現是知道,但是為什么要這樣計算,和使用哪些數學原理還需繼續深究,歡迎讀者跟我進行探究.

到這里Gesture的初步研究就差不多了,假如讀者需要安卓源碼的部分翻譯,可以點擊這里獲取.
假如讀者需要閱讀GestureDemo可以點擊這里

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
禁止轉載,如需轉載請通過簡信或評論聯系作者。

推薦閱讀更多精彩內容