用QML開發Android APP(一)

1、需求/目標

用QML已經有一段時間了,我想經過博客記錄本身是如何使用QML的,算是寫點本身的經驗吧,但願對未接觸過QML但對它有興趣的人提供點中文資料,僅此而已。
爲了寫起來有思路,咱們來實現一個能在Android手機上運行的APP,暫且叫這個APP爲「135Todo」吧,它是一個待辦事項類的軟件,相似的現成的軟件不少,這類軟件最基本的功能應該包含新建事項、標識事項是否完成、刪除事項、對事項設置處理時間和優先級。我用過目前很流行的一些APP,但感受都不是很合我的心意,我很贊同的一點是:「時間管理,不是真的去管理時間,更準確的說應該是效率管理,經過管理來提高作事效率。」軟件或手機,它只不過是一個工具,咱們用它來計劃待辦事項,目的是想提升辦事效率,因此咱們應該把注意力放在事情上面,而不是軟件上,因此過於依賴軟件功能、花哨界面的話,就顯得有點本末倒置了。沒有最好的,只有適合本身的,我最近在嘗試一種時間管理方法,叫:「1-3-5 Rule」(1-3-5法則,這也是咱們要作的APP的名字來源),關於它的詳細狀況有興趣請自行google一下,我只簡單的介紹,也當是APP的需求點吧:
一、一天中最多處理9件事情,1件最重要的,雷打不動,最多不超過3件次重要的,還有最多不超過5件的瑣碎的事。
二、第1點是原則性的但不是強制性的,若是瑣碎的事情太多,超過5件也是能夠的。若是以爲一天有多件很重要的事情,那麼能夠指定1件最重要的,其它暫定爲次重要的,待最重要的事情完成後,能夠把次重要的事情提到最重要的位置。簡言之,這樣的待辦事項列表是很靈活的,當一些事項處理完了而且時間時間容許,本身能夠隨時增長新的事項進來,而一天也只是一個象徵時間,能夠是一週或一個月。
三、除了如下描述的3種優先級,還有臨時想起的事情、忽然冒出的想法、心血來潮的計劃,也是常有之事,因此我我的補充了一點,就是能夠隨時增長這類事項,它的狀態是未計劃的、未分解的、或有待提上日程的。
經過以上需求,咱們的APP看起來可能像這樣:
請輸入圖片描述javascript

新建事項的狀態:
請輸入圖片描述java

長按一條事項,事項會變成菜單,能夠修改優先級等(模仿了Pocket):
請輸入圖片描述linux

2、代碼組織

新建工程

一、啓動QtCreator,新建項目,選擇「Qt Quick Application」,這樣代碼能夠混合C++和QML,基礎功能用C++實現,界面和操做在QML完成。
二、工程的構建套件要選擇Android for armxxx類型的,請事先在選項->Android下配置好JDK、Android SDK、Android NDK的路徑。
注:本文用的QtSDK版本是:qt-opensource-linux-x64-android-5.3.1,5.3.1版本默認生成的代碼模板相對之前的版本變得簡潔,有些不同。android

混合使用C++和QML

咱們使用C++完成數據的讀取和保存,有必要的話,還能夠作一些與Java層或Android Java SDK交互的事情,這個之後咱們再研究。而後,QML專門負責界面佈局、用戶交互和響應。segmentfault

C++與QML的交互

  • C++能夠調用QML中定義的function,但原則上,我不會這麼作,由於不少時候,由QML調用C++接口來完成事情就能夠了。二者隨意的相互調用會致使軟件流程混亂,不易維護。
  • QML能夠經過C++對象,獲取它的屬性和調用它的接口,同時也能夠接收它的信號,下面咱們也是經過這些方法來完成軟件功能的。

首先,咱們定義一個C++的類叫TodoCpp,要繼承QObject,爲QML層提供基礎的功能接口,代碼:app

#ifndef TODOCPP_H
#define TODOCPP_H

#include <QObject>
#include <QSettings> 
class TodoCpp : public QObject
{
    Q_OBJECT
public:
    explicit TodoCpp(QObject *parent = 0);

signals:

public slots:
    //從文件中讀出事項列表,並以List方式返回
    QVariantList getItems();

    //把事項列表保存到文件
    void saveItems(const QVariantList& list);

private:
    //使用ini文件來保存待辦事項列表
    QSettings* settings;
};

#endif // TODOCPP_H

爲了讓QML層可使用C++對象,咱們需求在main函數中增長几行代碼:框架

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include "todocpp.h"

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);

    QQmlApplicationEngine engine;

    //建立對象並暴露給QML,QML可使用名字todocpp來使用對象
    TodoCpp cpp;
    engine.rootContext()->setContextProperty("todocpp", &cpp); 

    engine.load(QUrl(QStringLiteral("qrc:///main.qml")));

    return app.exec();
}

C++提供基礎服務

  • 數據保存路徑:通常狀況,咱們想保存在存儲器上如sdcard,系統不必定有/mnt/sdcard這個目錄,因此咱們須要使用可移植性更好的方法,我發現最新的Qt版本包含了QStandardPaths這個類,提供了獲取系統上各類路徑的接口,咱們要的就是一個能夠保存數據的地方,當APP卸載時,數據不會被刪除,由於重裝APP後,數據還在,因此我使用了以下的方法來獲取這樣的路徑:
    QString path = QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation).first();ide

  • 數據持久化:一般有兩種方式:DB和文件,考慮到軟件的簡單性,直接用ini文件存儲待辦事項列表(包含每一個事項的屬性),QSettings這個強大的類,提供了對ini文件的讀寫操做,構造QSettings的代碼以下:
    settings = new QSettings(path.append("/todo1-2-3.ini"), QSettings::IniFormat);
    QSettings還提供了讀寫一個列表到ini文件的接口,如:
    beginReadArray、setArrayIndex、endArray、beginWriteArray,具體用法見下代碼。函數

  • C++和QML之間的數據傳遞格式: 咱們但願把事項列表存放在一個QList中,而且做爲getItems()的返回值,若是QML調用getItems()後,可以直接使用這個list,那是很美好的事情,要知道,QML那裏用的是javascript,慶幸的是,真的能夠辦到,就是使用QVariantMap或QVariantList。因此便有了以下的代碼,咱們把待辦事項一個個轉換成QVariantMap,而後全部待辦事項再放入QVariantList就能夠了,同時咱們也約定,從QML傳下來的參數,也是一樣的格式,代碼以下:工具

    QVariantList TodoCpp::getItems()
    {
        QVariantList list;
    
        int size = settings->beginReadArray("items");
        for (int i = 0; i < size; ++i) {
            settings->setArrayIndex(i);
            QVariantMap m;
            m.insert("text", settings->value("text", "").toString());
            m.insert("pri", settings->value("pri", 99).toInt());
            m.insert("done", settings->value("done", false).toBool());
    
            if(!m.value("text").toString().isEmpty())
                list.push_back(m);
        }
        settings->endArray();
    
        return list;
    }
    
    void TodoCpp::saveItems(const QVariantList &list)
    {
        settings->beginWriteArray("items");
        for (int i = 0; i < list.size(); ++i) {
            settings->setArrayIndex(i);
    
            if(!list.at(i).toMap().value("text").toString().isEmpty()){
                settings->setValue("text", list.at(i).toMap().value("text").toString());
                settings->setValue("pri", list.at(i).toMap().value("pri", 99).toInt());
                settings->setValue("done", list.at(i).toMap().value("done", false).toBool());
            }
        }
        settings->endArray();
        settings->sync();
    }

    如今,在main.qml中,添加代碼,獲取列表並解析:

    Component.onCompleted: {
            var l  = todocpp.getItems();
            console.debug(JSON.stringify(l));
            for(var i=0; i<l.length; ++i){
                //這裏直接使用l[i].text, l[i].pri拿屬性
            }
        }

    在qml中要保存一個新的事項列表,能夠這樣:

    function saveItems(){
    var l = [];
    for(var i=0; i<listmodel.count; ++i){
        l.push({'pri': listmodel.get(i).iPri,
                   'text': listmodel.get(i).iText,
                   'done': listmodel.get(i).iDone });
    }
    
    todocpp.saveItems(l);
    }

3、調試運行

打開項目屬性->運行,點開Deploy configurations詳情,選擇Deploy local Qt libraries to temporary directory,這樣當咱們第一次高度時,會把Qt依賴庫push到Android系統的一個臨時目錄下,之後調試時能夠大大提高速度。
啓動調試,按QtCreator左側欄的運行按鈕,或按Ctrl+R,彈出設備選擇窗口,若是咱們的目標設備出現一串問號,那是由於adb server沒有足夠的權限,Ubuntu Linux下以超級用戶權限從新啓動adb服務:

sudo adb kill-server
sudo adb devices

4、UI

一、準備素材。能夠到http://www.iconpng.com/找些合適的按鈕,放在項目/assets目錄,而後新建個Qt資源文件叫assets.qrc,把準備好的圖片加入到資源文件中。
二、主界面結構規劃。從截圖中能夠把UI規劃成3部分,從上往下分別是:頂端的標題欄(Titlebar,含左側的返回按鈕和右側的新建按鈕)、增長事項、事項列表,因此mail.qml的框架看起來差很少這樣:

import QtQuick 2.2
import QtQuick.Window 2.1

Window {
    id: window
    visible: true

    //這裏的width和height設置,不影響APP的顯示,由於在QQmlApplicationEngine
    //默認會讓Window最大化顯示。
    //這裏設置的值的仍是有意義的,好比一般我會在開發初期,編寫UI時,
    //會用Desktop的構建套件,直接在開發環境的PC上啓動來看UI的效果,
    //這樣比用設備調試快多了,這種方法還有另外一個好處,就是在編寫
    //自動適應屏幕大小的UI時,我能夠直接拖動窗口大小來看效果。
    //因此這裏的width和height值設置爲目標設備的通用分辨率。
    width: 480
    height: 1024

    //背景顏色
    Rectangle {
        id: backgroundColor
        anchors.fill: parent
        color: "#D9D2D2"
    }

    Column{
        anchors.fill: parent
        //標題欄
        Titlebar{
            id: titlebar
        }
        //增長事項
        AddView {
            id: addview
            width: parent.width
        }
        //已添加的事項列表
        TodoListView {
            id: list
            width: parent.width
            height: window.height - titlebar.height - addview.height
        }
    }
    //UI構建完成後,讀取待辦事項列表,並顯示出來
    Component.onCompleted: {
        var l  = todocpp.getItems();
        console.debug(JSON.stringify(l));
        for(var i=0; i<l.length; ++i){
            list.insertItem(l[i]);
        }
    }
    //這裏能夠捕捉Android系統的返回按鍵事件,若是須要按兩次返回就退出軟件的話,能夠在這裏作
    //    Keys.onReleased: {
    //        if (event.key == Qt.Key_Back) {
    //             event.accepted = true;
    //        }
    //    }
}

三、標題欄(Titlebar)元素對應Titlebar.qml,我一般會把界面分解成容易理解和維護的控件或子界面,這些控件和子界面,以及一些能夠重用的東西(如按鈕),都以獨立的qml文件存在,若是軟件較複雜的話,應該創建qml文件夾和子文件夾分別存放這些qml,提升可維護性。因爲程序簡單,我只是把qml文件都放在工程根目錄下。Titlebar.qml代碼以下:

import QtQuick 2.0

Rectangle {
    id: titlebar
    width: parent.width
    height: 100
    color: "#f2f2ee"

    property int pageIndex: 0

    state: "default"

    Text {
        anchors.centerIn: parent
        text: qsTr("135待辦")
        font.pointSize: 20
        color: "#929292"
    }

    ActionButton {
        anchors {
            left: parent.left; leftMargin: 20
            verticalCenter: parent.verticalCenter
        }

        visible: titlebar.state == "adding"
        icon: "assets/reverse_arrow.png"
        onClicked: titlebar.state = "default"
    }

    ActionButton {
        anchors {
            right: parent.right; rightMargin: 20
            verticalCenter: parent.verticalCenter
        }

        visible: titlebar.state == "default"
        icon: "assets/new.png"
        onClicked: titlebar.state = "adding"
    }

    Line {
        anchors {
            left: parent.left
            right: parent.right
            bottom: parent.bottom
        }
    }
}

主要想說明一下的是按鈕的狀態變化處理。打開軟件,進入默認狀態:標題欄右邊有個「新建」按鈕,點擊它,進入新建事項狀態。新建狀態下:「新建」按鈕應該被隱藏,標題欄的左邊要出現「返回」按鈕,點「返回」按鈕將取消新建狀態,回到默認狀態。其實,應該說這是狀態間的切換,而每種狀態,都會有不一樣的按鈕或者是界面的變化,而對於外部(標題欄其它界面部分:AddView等)它們也只要關注Titlebar的狀態變化,而後對不一樣的狀態作反應便可,如mail.qml中,咱們添加代碼以下:

Titlebar{
            id: titlebar
            onStateChanged: {
                if(state == "default")
                    addview.hide();
                else if(state == "adding")
                    addview.show();
            }
        }

addview就是新建面板,咱們經過Titlebar的狀態,來控制新建面板的打開和隱藏。
四、咱們作一個可重用的按鈕,它能夠是一個圖標按鈕,也能夠是文字按鈕,也能夠是二者的疊加:)並且文字的大小將根據按鈕的大小自動縮放,ActionButton.qml代碼:

import QtQuick 2.0

Rectangle {
    id: root
    width: 100
    height: 100

    property alias text: txt.text
    property alias icon: img.source
    signal clicked();

    color: mouse.pressed? "#8FE2D2" : "transparent"


    Image {
        id: img
        anchors.centerIn: parent
        fillMode: Image.PreserveAspectFit
    }

    Text {
        id: txt
        anchors.fill: parent
        anchors.margins: 8
        color: "#929292"
        font.pointSize: 50
        fontSizeMode: Text.Fit
    }

    MouseArea {
        id: mouse
        anchors.fill: parent
        onClicked: root.clicked()
    }
}

對於Line.qml(線)控件,QML沒有現成的類型,我是這樣來實現的:)

Rectangle {
    id: line
    height: 1
    color: "#CCCCCC"
}

五、TodoListView,這是界面的主體,顯示待辦事項列表,它的實現還有幾點值得說一說的:
(1)根根據優先級從上往下排序,並分組顯示,每一個事項前邊用一個顏色條表示優先級。
(2)長近某條事項,將進入編輯狀態,編輯狀態下,將切換出動做按鈕,包括從新設置優先級,完成,重作,刪除。若是改成了優先級,須要把事項條目移動到相應的位置(聽從排序原則)。完成狀態下,條目將出現一條劃線。
(3)當一個條目處於編輯狀態,當用戶再長按其它條目或者翻滾列表,本條目要回復爲非編輯狀態。
(4)當用戶按下某個條目的時候,應該要有按下的提示(條目顏色變爲高亮),可是,用戶翻滾列表的動做也會讓手指所在的條目產生按下事件,因此咱們要作點工做區別這二者:使用Timer,計算用戶按下的時間,若是是100ms以內的,就忽略,不然才認爲是按下了。
(5)當列表有變化,好比增長、移動、刪除、狀態切換,若是加之一些動畫過渡效果,會讓用戶體驗頓時不同。爲一個ListView增長動畫效果是很容易的事情,完整的代碼以下:

import QtQuick 2.0

Item {
    id: root

    function insertItem(item){
        for(var i=0; i<listmodel.count; ++i){
            if(listmodel.get(i).iPri > item.pri){
                listmodel.insert(i, {'iText': item.text,
                                     'iPri': item.pri,
                                     'iDone': item.done,
                                     'iColor': addview.getColor(item.pri) });
                return;
            }
        }

        //not found
        listmodel.append({'iText': item.text,
                             'iPri': item.pri,
                             'iDone': item.done,
                             'iColor': addview.getColor(item.pri) });
    }

    function saveItems(){
        var l = [];
        for(var i=0; i<listmodel.count; ++i){
            l.push({'pri': listmodel.get(i).iPri,
                       'text': listmodel.get(i).iText,
                       'done': listmodel.get(i).iDone });
        }

        todocpp.saveItems(l);
    }

    function changePri(index, newPri){
        list.currentIndex = -1;
        listmodel.setProperty(index, 'iDone', false);
        listmodel.setProperty(index, 'iPri', newPri);
        listmodel.setProperty(index, 'iColor', addview.priColorMap[newPri]);

        var moved = false;
        for(var i=0; i < listmodel.count; ++i){
            if(i != index &&
                    listmodel.get(i).iPri > newPri){
                if(index > i)
                    listmodel.move(index, i, 1);
                else
                    listmodel.move(index, i - 1, 1);
                moved = true;
                break;
            }
        }

        if(!moved)
            listmodel.move(index, listmodel.count - 1, 1);

        root.saveItems();
    }

    clip: true

    ListView {
        id: list
        anchors.fill: parent


        clip: true
        model: ListModel {
            id: listmodel
        }

        delegate: Component {
            Item {
                id: wrapper
                width: list.width
                height: 120

                Row {
                    id: actionBar
                    anchors.centerIn: parent

                    spacing: (parent.width - 100 * 6) / 7

                    ActionButton {
                        text: "1"
                        onClicked: {
                            root.changePri(index, 1);
                        }
                    }
                    ActionButton {
                        text: "3"
                        onClicked: {
                            root.changePri(index, 3);
                        }
                    }
                    ActionButton {
                        text: "5"
                        onClicked: {
                            root.changePri(index, 5);
                        }
                    }
                    ActionButton {
                        icon: "assets/timer.png"
                        onClicked: {
                            root.changePri(index, 99);
                        }
                    }
                    ActionButton {
                        visible: iDone
                        icon: "assets/reset.png"
                        onClicked: {
                            list.currentIndex = -1;
                            listmodel.setProperty(index, "iDone", false);
                            root.saveItems();
                        }
                    }
                    ActionButton {
                        visible: !iDone
                        icon: "assets/flag.png"
                        onClicked: {
                            list.currentIndex = -1;
                            listmodel.setProperty(index, "iDone", true);
                            root.saveItems();
                        }
                    }
                    ActionButton {
                        icon: "assets/trash.png"
                        onClicked: {
                            list.currentIndex = -1;
                            listmodel.remove(index);
                            root.saveItems();
                        }
                    }
                }

                Item {
                    id: contentRow
                    x: 0
                    width: parent.width
                    height: parent.height
                    Row {
                        anchors.fill: parent
                        Rectangle {
                            id: colorRect
                            width: 15
                            height: parent.height
                            color: iColor
                        }

                        Rectangle {
                            width: parent.width - colorRect.width
                            height: parent.height
                            color:  contentMouse.realPressed? "#8FE2D2": "#ECF0F1"
                            Text {
                                id: txt
                                anchors.verticalCenter: parent.verticalCenter
                                width: parent.width
                                height: parent.height - 60
                                text: iText
                                fontSizeMode: Text.Fit
                                font.pointSize: 50
                                color: iPri == 99 || iDone? "grey" : "#4E6061"
                                wrapMode: Text.WrapAtWordBoundaryOrAnywhere
                                font.strikeout: iDone
                            }
                        }
                    }


                    MouseArea {
                        id: contentMouse
                        anchors.fill: parent
                        property bool realPressed: false
                        onPressAndHold: {
                            realPressed = false;
                            list.currentIndex = index;
                        }

                        onPressed: {
                            pressedTimer.restart();
                        }
                        onReleased: {
                            pressedTimer.stop();
                            realPressed = false;
                        }

                        onCanceled: {
                            pressedTimer.stop();
                            realPressed = false;
                        }

                        Timer {
                            id: pressedTimer
                            repeat: false
                            interval: 100
                            onTriggered: contentMouse.realPressed = true
                        }
                    }
                }

                Line {
                    anchors {
                        left: parent.left; leftMargin: 15
                        right: parent.right
                        bottom: parent.bottom
                    }
                }


                state: ""
                states: [
                    State {
                        name: "showAction"
                        when: list.currentIndex == index
                        PropertyChanges {
                            target: contentRow
                            x: contentRow.width
                        }
                    }
                ]

                transitions: [
                    Transition {
                        from: "*"
                        to: "*"

                        ParallelAnimation {
                            id: actionShowAnim
                            NumberAnimation {
                                target: contentRow
                                property: "x"
                                duration: 200
                            }
                        }
                    }
                ]

            }

        }

        move: Transition {
            NumberAnimation {
                property: "y"
                duration: 500
            }
        }

        remove: Transition {
            ParallelAnimation {
                NumberAnimation { property: "opacity"; to: 0; duration: 500 }
                NumberAnimation { property: "x"; from: 0; to: root.width; duration: 500 }
            }
        }

        add: Transition {
            NumberAnimation {
                property: "y"
                from: 0
                duration: 500
            }
        }

        displaced: Transition {
            NumberAnimation {
                property: "y"
                duration: 500
            }
        }

        currentIndex: -1
        onDragStarted: {
            currentIndex = -1;
        }
    }
}

代碼中有一個我本身不太滿意的地方,就是changePri這個函數,在移動列表條目的時候,須要作一些額外的判斷,由於move函數的行爲看起來不是我所指望的那樣,不知道有沒更好的優化方法。
六、新建事項面板:AddView.qml,代碼:

import QtQuick 2.0

Item {
    id: root

    signal added(var intent);

    readonly property var priColorMap: {
        1: "#F37570",
                3: '#F6BB6E',
                5: '#2175D5',
                99: '#DEDEDE'
    }

    function getColor(pri){
        switch(pri){
        case 1:
        case 3:
        case 5:
        case 99:
            return root.priColorMap[pri];
        default:
            return root.priColorMap[99];
        }
    }

    function show(){
        state = "show";
    }

    function hide(){
        state = "";
    }

    state: ""
    height: 0
    clip: true

    states: [
        State {
            name: "show"
            PropertyChanges {
                target: root
                height: 250
                focus: true
            }
            PropertyChanges {
                target: input
                focus: true
            }
        }
    ]

    transitions: [
        Transition {
            from: ""
            to: "show"
            PropertyAnimation { target: root; property:"height"; duration: 200 }
        },
        Transition {
            from: "show"
            to: ""
            SequentialAnimation {
                ScriptAction {
                    script: {
                        Qt.inputMethod.hide();
                    }
                }
                PropertyAnimation { target: root; property:"height"; duration: 200 }
            }
        }
    ]


    Column {
        anchors {
            left: parent.left; right: parent.right
        }

        Item {
            width: parent.width
            height: root.height - btnRow.height
            TextInput {
                id: input
                anchors { fill: parent; margins: 10 }
                color: "#4E6061"
                font.pointSize: 24
                wrapMode: TextInput.WrapAtWordBoundaryOrAnywhere
                focus: false
            }
        }

        Row {
            id: btnRow
            anchors {
                horizontalCenter: parent.horizontalCenter
            }

            spacing: (parent.width - 100 * 4) / 5
            ActionButton {
                text: "1"
                onClicked: {
                    root.added({'text': input.text, 'pri': 1});
                    input.text = "";
                }
            }
            ActionButton {
                text: "3"
                onClicked: {
                    root.added({'text': input.text, 'pri': 3});
                    input.text = "";
                }
            }
            ActionButton {
                text: "5"
                onClicked: {
                    root.added({'text': input.text, 'pri': 5});
                    input.text = "";
                }
            }
            ActionButton {
                icon: "assets/timer.png"
                onClicked: {
                    root.added({'text': input.text, 'pri': 99});
                    input.text = "";
                }
            }
        }
    }
}

咱們一般增長一個信號added來告訴使用者,有一個新的事項添加了,事項的數據經過intent參數傳出,因此在main.qml中,咱們會監聽這個信號,並把新建的事項插入列表:

AddView {
            id: addview
            width: parent.width
            onAdded: {
                titlebar.state = "default"; //恢復標題欄的狀態
                if(intent.text !== ""){
                    intent.done = false;
                    list.insertItem(intent);
                    list.saveItems();
                }
            }
        }

七、關於QML佈局的補充說明:
咱們從對齊佈局中看到兩種方式,一種是使用屬性綁定如width: parent.width,另外一種是使用anchors { left:parent.left; right:parent.right },根據文檔說明,儘可能使用後者,由於效率可能會比前者高點。對於父元素爲Row或Column的話,只能使用前者來對齊。
本程序的界面結構比較簡單,沒有涉及子界面,全部控件和元素也是靜態建立的,對於一個界面複雜的軟件,子界面或界面的變化是須要動態建立的,這個之後咱們經過複雜點的例子來探討。

5、部署

一、以Release模式編譯代碼。 二、打開項目屬性->運行,點開Deploy configurations詳情,選擇Bundle Qt libraries in APK,(也能夠進行APK簽名),完成後點菜單的構建->部署項目xxx,將在構建目錄/android-build/bin/目錄下生成release的APK,APK能夠直接提供給用戶下載或安裝。

相關文章
相關標籤/搜索