OpenGL Android課程一:入門
翻譯文
原文標題:OpenGL Android Lesson One: Getting Started
原文連結: www.learnopengles.com/android-les…
這是在Android中使用OpenGL ES2的第一個教程。這一課中,我們將一步一步跟隨程式碼,學習如何建立一個OpenGL ES 2並繪製到螢幕上。我們還將瞭解什麼是著色器,它們如何工作,以及怎樣使用矩陣將場景轉換為您在螢幕上看到的影象。最後,您需要在清單檔案中新增您正在使用OpenGL ES 2的說明,以告知Android應用市場支援的裝置可見。
入門
我們將過一道下面所有的程式碼並且解釋每一部分的作用。您可以跟著拷貝每一處的程式碼片段來建立您自己的專案,您也可以在文章末尾下載這個已完成的專案。在開發工具(如:Android Studio)中建立您的Android專案,名字不重要,這裡由於這個課程我將 MainActivity
更名為 LessonOneActivity
。
我們來看這段程式碼:
/** 保留對GLSurfaceView的引用*/ private GLSurfaceView mGLSurfaceView; 複製程式碼
這個GLSurfaceView是一個特別的View,它為我們管理OpenGL介面並且將它繪製在Android View系統。它還添加了許多功能,使其更易於使用OpenGL,包括下面等等:
- 它為OpenGL提供一個專用的著色執行緒,因此主執行緒不會停懈
- 它支援連續或按需渲染
- 它使用EGL (OpenGL和底層系統視窗之間的介面)來處理螢幕設定
GLSurfaceView
使得在Android中設定和使用OpenGL相對輕鬆
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mGLSurfaceView = new GLSurfaceView(this); //檢測系統是否支援OpenGL ES 2.0 final ActivityManager activityManager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); final ConfigurationInfo configurationInfo = activityManager.getDeviceConfigurationInfo(); final boolean supportsEs2 = configurationInfo.reqGlEsVersion >= 0x20000; if (supportsEs2) { // 請求一個OpenGL ES 2.0相容的上下文 mGLSurfaceView.setEGLContextClientVersion(2); // 設定我們的Demo渲染器,定義在後面講 mGLSurfaceView.setRenderer(new LessonOneRenderer()); } else { // 如果您想同時支援ES 1.0和2.0的話,這裡您可以建立相容OpenGL ES 1.0的渲染器 return; } setContentView(mGLSurfaceView); } 複製程式碼
在 onCreate()
方法中是我們建立OpenGL上下文以及一切開始發生的重要部分。
在我們的 onCreate()
方法中,在呼叫 super.onCreate()
後我們首先建立了 GLSurfaceView
例項。然後我們需要弄清楚系統是否支援OpenGL ES 2.為此,我們獲得一個 ActivityManager
例項,它允許我們與全域性系統狀態進行互動。然後我們使用它獲取裝置配置資訊,它將告訴我們裝置是否支援OpenGL ES 2。我們也可以通過傳入不同的渲染器來支援OpenGL ES 1.x,儘管因為API不同,我們需要編寫不同的程式碼。對於本課我們僅僅關注支援OpenGL ES 2。
一旦我們知道裝置是否支援OpenGL ES 2,我們告訴 GLSurfaceView
相容OpenGL ES 2,然後傳入我們的自定義渲染器。無論何時調整介面或繪製新幀,系統都會呼叫此渲染器。
最後,我們呼叫 setContentView()
設定GLSurfaceView為顯示內容,它告訴Android這個活動內容因該被我們的OpenGL介面填充。要入門OpenGL,就是這麼簡單。
@Override protected void onResume() { super.onResume(); //Activity 必須在onResume中呼叫GLSurfaceView的onResume方法 mGLSurfaceView.onResume(); } @Override protected void onPause() { super.onPause(); //Activity 必須在onPause中呼叫GLSurfaceView的onPause方法 mGLSurfaceView.onPause(); } 複製程式碼
GLSurfaceView
要求我們在Activity onResume()
和 onPause()
的父方法被呼叫後分別調用它的 onResume()
和 onPause()
方法。我們在此新增呼叫以完善我們的Activity。
視覺化3D世界
在這部分,我們來看怎樣讓OpenGL ES 2工作,以及我們如何在螢幕上繪製東西。在Activity中我們傳入自定義的 GLSurfaceView.Renderer 到 GLSurfaceView
,它將在這裡定義。這個渲染器有三個重要的方法,每當系統事件發生時,它們將會自動被呼叫:
public void onSurfaceCreated(GL10 gl, EGLConfig config)
當介面第一次被建立時呼叫,如果我們失去介面上下文並且之後由系統重建,也會被呼叫。
public void onSurfaceChanged(GL10 gl, int width, int height)
每當介面改變時被呼叫;例如,從縱屏切換到橫屏,在建立介面後也會被呼叫。
public void onDrawFrame(GL10 gl)
每當繪製新幀時被呼叫。
您可能注意到 GL10
的例項被傳入名字是 gl
。當使用OpengGL ES 2繪製時,我們不能使用它;我們使用 GLES20
類的靜態方法來代替。這個 GL10
引數僅僅是在這裡,因為相同的介面被使用在OpenGL ES 1.x。
在我們的渲染器可以顯示任何內容之前,我們需要有些東西去顯示。在OpenGL ES 2,我們通過制定數字陣列傳遞內容。這些數字可以表示位置、顏色或任何我們需要的。在這個Demo中,我們將顯示三個三角形。
// 新類成員 private final FloatBuffer mTriangle1Verticels; private final FloatBuffer mTriangle2Verticels; private final FloatBuffer mTriangle3Verticels; /** 每個Float多少位元組*/ private final int mBytePerFloat = 4; /** * 初始Model資料 */ public LessonOneRenderer() { // 這個三角形是紅色,藍色和綠色組成 final float[] triangle1VerticesData = { // X, Y, Z, // R, G, B, A -0.5F, -0.25F, 0.0F, 1.0F, 0.0F, 0.0F, 1.0F, 0.5F, -0.25F, 0.0F, 0.0F, 0.0F, 1.0F, 1.0F, 0.0F, 0.559016994F, 0.0F, 0.0F, 1.0F, 0.0F, 1.0F }; ... // 初始化緩衝區 mTriangle1Verticels = ByteBuffer.allocateDirect(triangle1VerticesData.length * mBytePerFloat) .order(ByteOrder.nativeOrder()).asFloatBuffer(); ... mTriangle1Verticels.put(triangle1VerticesData).position(0); ... } 複製程式碼
那麼,這些是什麼意思?如果您曾經使用過OpenGL 1, 您可能會習慣這樣做:
glBegin(GL_TRIANGLES); glVertex3f(-0.5f, -0.25f, 0.0f); glColor3f(1.0f, 0.0f, 0.0f); ... glEnd(); 複製程式碼
這種方法在OpenGL ES 2中不起作用。我們不是通過一堆方法呼叫來定義點,而是定義一個數組。讓我們再來看看我們這個陣列:
final float[] triangle1VerticesData = { // X, Y, Z, // R, G, B, A -0.5f, -0.25f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, ... 複製程式碼
上面展示的代表三角形的一個點。我們已設定好前三個數字代表位置(X,Y,Z),隨後的四個數字代表顏色(紅,綠,藍,透明度)。您不必太擔心如何定義這個陣列;只要記住當我們想繪製東西在OpenGL ES 2時,我們需要以塊的形式傳遞資料,而不是一次傳遞一個。
瞭解緩衝區
// 初始化緩衝區 mTriangle1Verticels = ByteBuffer.allocateDirect(triangle1VerticesData.length * mBytePerFloat) .order(ByteOrder.nativeOrder()).asFloatBuffer(); ... 複製程式碼
我們在Android上使用Java進行編碼,但OpengGL ES 2底層實現其實使用C語言編寫的。
在我們將資料傳遞給OpenGL之前,我們需要將其轉換成它能理解的形式。 Java和native系統可能不會以相同的順序儲存它們的位元組,因此我們使用一個特殊的緩衝類並建立一個足夠大的 ByteBuffer
來儲存我們的資料,並告訴它使用native位元組順序儲存資料。
然後我們將它轉換成 FloatBuffer
,以便我們可以使用它來儲存浮點資料。
最後,我們將陣列複製到緩衝區。
這個緩衝區的東西看起來可能很混亂,單請記住,在將資料傳遞給OpenGL之前,我們需要做一個額外的步驟。我們現在的緩衝區已準備好可以用於將資料傳入OpenGL。
另外, float緩衝區在Froyo上很慢 ,在Gingerbread上緩慢,因此您可能不希望經常更換它們。
理解矩陣
// new class 定義 /** * 儲存view矩陣。可以認為這是一個相機,我們通過相機將世界空間轉換為眼睛空間 * 它定位相對於我們眼睛的東西 */ private float[] mViewMatrix = new float[16]; @Override public void onSurfaceCreated(GL10 gl, EGLConfig config) { // 設定背景清理顏色為灰色 GLES20.glClearColor(0.5F, 0.5F, 0.5F, 0.5F); // 將眼睛放到原點之後 final float eyeX = 0.0F; final float eyeY = 0.0F; final float eyeZ = 1.5F; // 我們的眼睛望向哪 final float lookX = 0.0F; final float lookY = 0.0F; final float lookZ = -5.0F; // 設定我們的向量,這是我們拿著相機時頭指向的方向 final float upX = 0.0F; final float upY = 1.0F; final float upZ = 0.0F; // 可以這樣想象:我們在橋上拿著相機90°彎腰拍攝水平面下5米處的美人魚 // 設定view矩陣,可以說這個矩陣代表相機的位置 // 注意:在OpenGL 1中使用ModelView matrix,這是一個model和view矩陣的組合。 //在OpenGL2中,我們選擇分別跟蹤這些矩陣 Matrix.setLookAtM(mViewMatrix, 0, eyeX, eyeY, eyeZ, lookX, lookY, lookZ, upX, upY, upZ); ... } 複製程式碼
另一個有趣的話題是矩陣!無論您何時進行3D程式設計,這些都將成為您最好的朋友。因此,您需要很好的瞭解他們。
當我們的介面被建立,我們第一件事情是設定清理顏色為灰色。alpha部分也設定為灰色,但在我們本課程中沒有進行alpha混合,因此該值未使用。我們只需要設定一次清理顏色,之後我們不會更改它。
我們第二件事情是設定view矩陣。我們使用了幾個不同種類的矩陣,它們都做了些重要的事情:
- model(模型)矩陣,該矩陣用於在“世界”中的某處放置模型。例如,您有一個模型車,你想將它放置在東邊一千米處,您將使用矩陣模型來做這件事。
- view (檢視)矩陣,該矩陣代表相機。如果我們想檢視位於東邊一千米處的車,我們也需要向東移動一千米(另一種思考方式是我們保持靜止,世界向西移動一千米)。我們使用檢視矩陣來做到這點。
- projection(投影)矩陣。由於我們的螢幕是平面的,我們需要進行最後的轉換,將我們的檢視“投影”到我們的螢幕上並獲得漂亮的3D視角。這就是投影矩陣的用途
可以在SongHo的OpenGL教程中找到很好的解釋。我建議您閱讀幾次直到您把握好這個想法為止;別擔心,我也閱讀了它好幾次!
在OpenGL 1中,模型和檢視矩陣被組合並且假設了攝像機處於(0,0,0)座標並面向Z軸方向。
我們不需要手動構建這些矩陣,Android有一個Matrix幫助類,它能為我們做繁重的工作。這裡,我為攝像機建立了一個檢視矩陣,它位於原點後,朝向遠處。
定義vertex(頂點)和fragment(片段)著色器
final String vertexShader = "uniform mat4 u_MVPMatrix;\n" + // 一個表示組合model、view、projection矩陣的常量 "attribute vec4 a_Position;\n" + // 我們將要傳入的每個頂點的位置資訊 "attribute vec4 a_Color;\n" + // 我們將要傳入的每個頂點的顏色資訊 "varying vec4 v_Color;\n" + // 他將被傳入片段著色器 "void main()\n" + // 頂點著色器入口 "{\n" + "v_Color = a_Color;\n" + // 將顏色傳遞給片段著色器 // 它將在三角形內插值 "gl_Position = u_MVPMatrix \n" + // gl_Position是一個特殊的變數用來儲存最終的位置 "* a_Position\n" + // 將頂點乘以矩陣得到標準化螢幕座標的最終點 "}\n"; 複製程式碼
在OpenGL ES 2中任何我們想展示在螢幕中的東西都必須先經過頂點和片段著色器,還好這些著色器並不像他們看起來的那麼複雜。頂點著色器在每個頂點執行操作,並把這些操作的結果使用在片段著色器做額外的每畫素計算。
每個著色器基本由輸入(input)、輸出(output)和一個程式(program)組成。
首先我們定義一個統一(uniform),它是一個包含所有變換的組合矩陣。它是所有頂點的常量,用於將它們投影到螢幕上。
然後我們定義了位置和顏色屬性(attribute),這些屬性將從我們之前定義的快取區中讀入,並指定每個頂點的位置和顏色。
接著我們定義了一個變數(varying),它負責在三角形中插值並傳遞到片段著色器。當它執行到片段著色器,它將為每個畫素持有一個插值。
假設我們定義了一個三角形每個點都是紅色、綠色和藍色,我們調整它的大小讓它佔用10畫素螢幕。當片段著色器執行時,它將為每畫素包含一個不同的變數(varying)顏色。在某一點上,變數(varying)將是紅色,但是在紅色和藍色之間它可能是更紫的顏色。
除了設定顏色,我們還告訴OpenGL頂點在螢幕上的最終位置。然後我們定義片段著色器:
final String fragmentShader = "precision mediump float;\n" + // 我們將預設精度設定為中等,我們不需要片段著色器中的高精度 "varying vec4 v_Color;\n" + // 這是從三角形每個片段內插的頂點著色器的顏色 "void main()\n" + // 片段著色器入口 "{\n" + "gl_FragColor = v_Color;\n" + // 直接將顏色傳遞 "}\n"; 複製程式碼
這是個片段著色器,它會將東西放到螢幕上。在這個著色器中,我們得到的變數(varying)顏色來自頂點著色器,然後將它直接傳遞給OpenGL。該點已按畫素插值,因為片段著色器將針對每個將要繪製的畫素點執行。
更多資訊: OpenGL ES 2 API快速參考卡
將著色器載入到OpenGL
// 載入頂點著色器 int vertexShaderHandle = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER); if (vertexShaderHandle != 0) { // 傳入頂點著色器原始碼 GLES20.glShaderSource(vertexShaderHandle, vertexShader); // 編譯頂點著色器 GLES20.glCompileShader(vertexShaderHandle); // 獲取編譯狀態 final int[] compileStatus = new int[1]; GLES20.glGetShaderiv(vertexShaderHandle, GLES20.GL_COMPILE_STATUS, compileStatus, 0); // 如果編譯失敗則刪除著色器 if (compileStatus[0] == 0) { GLES20.glDeleteShader(vertexShaderHandle); vertexShaderHandle = 0; } } if (vertexShaderHandle == 0) { throw new RuntimeException("Error creating vertex shader."); } 複製程式碼
首先,我們建立一個著色器物件。如果成功,我們將得到這個物件的引用。
然後,我們使用這個引用傳入著色器原始碼然後編譯它。
我們可以從OpenGL獲取編譯是否成功的狀態,如果失敗我們可以使用 GLES20.glGetShaderInfoLog(shader)
找到原因。我們按照相同的步驟載入片段著色器。
將頂點和片段著色器連結到一個程式中
// 建立一個程式物件並將引用放進去 int programHandle = GLES20.glCreateProgram(); if (programHandle != 0) { // 繫結頂點著色器到程式物件中 GLES20.glAttachShader(programHandle, vertexShaderHandle); // 繫結片段著色器到程式物件中 GLES20.glAttachShader(programHandle, fragmentShaderHandle); // 繫結屬性 GLES20.glBindAttribLocation(programHandle, 0, "a_Position"); GLES20.glBindAttribLocation(programHandle, 1, "a_Color"); // 將兩個著色器連線到程式 GLES20.glLinkProgram(programHandle); // 獲取連線狀態 final int[] linkStatus = new int[1]; GLES20.glGetProgramiv(programHandle, GLES20.GL_LINK_STATUS, linkStatus, 0); // 如果連線失敗,刪除這程式 if (linkStatus[0] == 0) { GLES20.glDeleteProgram(programHandle); programHandle = 0; } } if (programHandle == 0) { throw new RuntimeException("Error creating program."); } 複製程式碼
在我們使用頂點和片段著色器之前,我們需要將它們繫結到一個程式中,它連線了頂點著色器的輸出和片段著色器的輸入。這也是讓我們從程式傳遞輸入並使用著色器繪製形狀的原因。
我們建立一個程式物件,如果成功繫結著色器。我們想要將位置和顏色作為屬性傳遞進去,因此我們需要繫結這些屬性。然後我們將著色器連線到一起。
// 新類成員 /** 這將用於傳遞變換矩陣*/ private int mMVPMatrixHandle; /** 用於傳遞model位置資訊*/ private int mPositionHandle; /** 用於傳遞模型顏色資訊*/ private int mColorHandle; @Override public void onSurfaceCreated(GL10 gl, EGLConfig config) { ... // 設定程式引用,這將在之後傳遞值到程式時使用 mMVPMatrixHandle = GLES20.glGetUniformLocation(programHandle, "u_MVPMatrix"); mPositionHandle = GLES20.glGetAttribLocation(programHandle, "a_Position"); mColorHandle = GLES20.glGetAttribLocation(programHandle, "a_Color"); // 告訴OpenGL渲染的時候使用這個程式 GLES20.glUseProgram(programHandle); } 複製程式碼
在我們成功連線程式後,我們還要完成幾個任務,以便我們能實際使用它。
第一個任務是獲取引用,因為我們要傳遞資料到程式中。
然後我們要告訴OpenGL在繪製時使用我們這個程式。
由於本課我們僅使用了一個程式,我們可以將它放到 onSurfaceCreated()
方法中而不是 onDrawFrame()
設定透視投影
// 新類成員 // 存放投影矩陣,用於將場景投影到2D視角 private float[] mProjectionMatrix = new float[16]; @Override public void onSurfaceChanged(GL10 gl, int width, int height) { // 設定OpenGL介面和當前檢視相同的尺寸 GLES20.glViewport(0, 0, width, height); // 建立一個新的透視投影矩陣,高度保持不變,而高度根據縱橫比而變換 final float ratio = (float) width / height; final float left = -ratio; final float right = ratio; final float bottom = -1.0F; final float top = 1.0F; final float near = 1.0F; final float far = 10.0F; Matrix.frustumM(mProjectionMatrix, 0, left, right, bottom, top, near, far); } 複製程式碼
onSurfaceChanged()
方法至少被呼叫一次,每當介面改變也會被呼叫。因為我們需要每當介面改變的時候重置投影矩陣,那麼 onSurfaceChanged()
方法中是個理想的地方。
繪製東西到螢幕上!
// 新類成員 // 存放模型矩陣,該矩陣用於將模型從物件空間(可以認為每個模型開始都位於宇宙的中心)移動到世界空間 private float[] mModelMatrix = new float[16]; @Override public void onDrawFrame(GL10 gl) { GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT); // 每10s完成一次旋轉 long time = SystemClock.uptimeMillis() % 10000L; float angleDegrees = (360.0F / 10000.0F) * ((int)time); // 畫三角形 Matrix.setIdentityM(mModelMatrix, 0); Matrix.rotateM(mModelMatrix, 0, angleDegrees, 0.0F, 0.0F, 1.0F); drawTriangle(mTriangle1Verticels); ... } 複製程式碼
這是實際顯示在螢幕上的內容。我們清理螢幕,因此不會得到任何奇怪的映象效應影響,我們希望我們的三角形在螢幕上能有平滑的動畫,通常使用時間而不是幀率更好。
實際繪製在 drawTriangle()
方法中完成
// 新的類成員 /** 為最終的組合矩陣分配儲存空間,這將用來傳入著色器程式*/ private float[] mMVPMatrix = new float[16]; /** 每個頂點有多少位元組組成,每次需要邁過這麼一大步(每個頂點有7個元素,3個表示位置,4個表示顏色,7 * 4 = 28個位元組)*/ private final int mStrideBytes = 7 * mBytePerFloat; /** 位置資料偏移量*/ private final int mPositionOffset = 0; /** 一個元素的位置資料大小*/ private final int mPositionDataSize = 3; /** 顏色資料偏移量*/ private final int mColorOffset = 3; /** 一個元素的顏色資料大小*/ private final int mColorDataSize = 4; /** * 從給定的頂點資料中繪製一個三角形 * @param aTriangleBuffer 包含頂點資料的緩衝區 */ private void drawTriangle(FloatBuffer aTriangleBuffer) { aTriangleBuffer.position(mPositionOffset); GLES20.glVertexAttribPointer(mPositionHandle, mPositionDataSize, GLES20.GL_FLOAT, false, mStrideBytes, aTriangleBuffer); GLES20.glEnableVertexAttribArray(mPositionHandle); // 傳入顏色資訊 aTriangleBuffer.position(mColorOffset); GLES20.glVertexAttribPointer(mColorHandle, mColorDataSize, GLES20.GL_FLOAT, false, mStrideBytes, aTriangleBuffer); GLES20.glEnableVertexAttribArray(mColorHandle); // 將檢視矩陣乘以模型矩陣,並將結果存放到MVP Matrix(model * view) Matrix.multiplyMM(mMVPMatrix, 0, mViewMatrix, 0, mModelMatrix, 0); // 將上面計算好的檢視模型矩陣乘以投影矩陣,並將結果存放到MVP Matrix(model * view * projection) Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mMVPMatrix, 0); GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mMVPMatrix, 0); GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3); } 複製程式碼
您還記得我們最初建立渲染器時定義的那些緩衝區嗎?我們終於可以使用它們了。
我們需要使用 GLES20.glVertexAttribPointer()
來告訴OpenGL怎樣使用這些資料。
我們來看第一個使用
aTriangleBuffer.position(mPositionOffset); GLES20.glVertexAttribPointer(mPositionHandle, mPositionDataSize, GLES20.GL_FLOAT, false, mStrideBytes, aTriangleBuffer); GLES20.glEnableVertexAttribArray(mPositionHandle); 複製程式碼
我們設定緩衝區的位置偏移,它位於緩衝區的開頭。然後我們告訴OpenGL使用這些資料並將其提供給頂點著色器並將其應用到位置屬性(a_Position)。我們也需要告訴OpenGL每個頂點或邁幅之間有多少個元素。
注意:邁幅(Stride)需要定義為位元組(byte),儘管每個頂點之間我們有7個元素(3個是位置,4個是顏色),但我們事實上有28個位元組,因為每個浮點數(float)就是4個位元組(byte)。忘記此步驟您可能沒有任何錯誤,但是你會想知道為什麼您的螢幕上看不到任何內容。
最終,我們使用了頂點屬性,往下我們使用了下一個屬性。再往後點我們構建一個組合矩陣,將點投影到螢幕上。我們也可以在頂點著色器中執行此操作,但是由於它只需要執行一次我們也可以只快取結果。
我們使用 GLES20.glUniformMatrix4fv()
方法將最終的矩陣傳入頂點著色器。
GLES20.glDrawArrays()
將我們的點轉換為三角形並將其繪製在螢幕上。
總結
呼呼!這是重要的一課,如果您完成了本課,感謝您!
我們學習了怎樣建立OpenGL上下文,傳入形狀資料,載入頂點和片段著色器,設定我們的轉換矩陣,最終放在一起。
如果一切順利,您因該看到了類似下面的截圖。

這一課有很多需要消化的內容,您可能需要多次閱讀這些步驟才能理解它。
OpenGL ES 2需要更多的設定才能開始,但是一旦您完成了這個過程幾次,您就會記住這個流程。
在Android市場上釋出
當開發的應用我們不想在無法執行這些應用程式的人在市場上看到它們,否則當應用程式在其裝置上崩潰時,我們可能會收到大量糟糕的評論和評分。
要防止OpenGL ES 2 應用程式出現在不支援它的裝置上,你可以在清單檔案中新增:
<uses-feature android:glEsVersion="0x00020000" android:required="true" /> 複製程式碼
這告訴市場您的app需要有OpenGL ES 2支援,不支援的裝置將會隱藏您的app。