从简单的 HTML 网站创建移动应用程序:第 4 部分

如何完善你的应用程序并准备上市

在本系列教程的前几部分(第 1 部分第 2 部分,以及 第 3 部分)中,我们创建了一个从服务器加载多个学校计划的应用程序。

目前我们所拥有的应用程序功能齐全,但仍然存在一些问题,其中两个问题尤为重要:没有离线模式和硬编码的配置。在本文的最后,我们将解决所有这些问题。

如果你还没有构建之前各部分的示例,请使用 stage7 应用程序stage7 服务器 作为起点。要开始,请按照 本教程中的任何阶段的加载说明 进行操作。

重构

首先,让我们进行一些重构。我们将添加一个 User 对象类型,以便更容易选择要显示哪个用户的计划。这将允许我们从应用程序的主要逻辑流程中删除更多代码,使代码更简洁、更模块化。此对象将从服务器加载数据以创建计划 UI。目前,渲染是在 app.onDeviceReadyapp.renderData 方法中完成的。我们新的 onDeviceReady 方法创建 app.user 对象并运行获取和渲染计划所需的方法。将现有的 onDeviceReady 方法替换为以下方法

onDeviceReady: function() {
    app.user = new User('johndoe');
    app.user.getPlans();

    app.activateFingerSwipe();
},

接下来,完全删除 app.renderData 方法。我们将把此功能存储在我们新的 User 对象中,如下所示。

接下来,在 index.js 文件的开头(在 Plan 的定义之前),添加以下代码以定义 User 对象

function User(userid) {
    this.id = userid;
    this.plans = [];
}

User.prototype.getPlans = function() {
    var self = this;
    var request = new XMLHttpRequest({
        mozAnon: true,
        mozSystem: true});
    request.onload = function() {
        var plans = JSON.parse(this.responseText);
        for (var i = 0; i < plans.length; i++) {
            self.plans.push(new Plan(plans[i]));
        }
        self.displayPlans();
    };
    request.open("get", app.getPlansURL + this.id, true);
    request.send();
};

User.prototype.displayPlans = function() {
    var self = this;
    navigator.globalization.getDateNames(function(dayOfWeek){
        var deck = document.getElementById('plan-group');
        var tabbar = document.getElementById('plan-group-menu');
        for (var i = 0; i < self.plans.length; i++) {
            self.plans[i].createUI(deck, tabbar, dayOfWeek);
        }
    }, function() {}, {type: 'narrow', item: 'days'});
};

这包含了之前在 renderDataonDeviceReady 中找到的功能。在从服务器加载计划的 JSON 后,我们将遍历列表并在 user.plans 中创建一系列 Plan 对象。然后,我们调用 user.displayPlans,该方法创建所需的 DOM 环境并为 user.plans 的每个元素调用 plan.createUI

你可能会注意到,上面的代码使用 app.getPlansURL 来允许 XHR 调用找到 JSON。以前,URL 是硬编码到 request.open("get", "http://127.0.0.1:8080/plans/johndoe", true); 中的。由于最好将设置保留在一个地方,因此我们将将其添加为 app 对象的参数。

将以下内容添加到你的代码中

var app = {
    <strong>getPlansURL: "http://127.0.0.1:8080/plans/",</strong>
    // ....
}

现在,尝试使用 cordova prepare 终端命令重新准备你的应用程序,启动你的服务器,并在 WebIDE 中重新加载应用程序(就像我们在之前的文章中所做的那样)。应用程序应该像以前一样工作。

离线模式

让我们将注意力转向使应用程序在离线状态下工作。

有几种技术可用于在设备上存储数据。我决定使用 localStorage API,因为我们需要存储的数据基本上只是一些简单的字符串,不需要任何复杂的结构。由于这是一个键值存储,因此对象需要被字符串化(表示为字符串)。我将只使用两个函数 - setItem()getItem()。例如,存储用户 ID 需要你调用 localStorage.setItem('user_id', user.id);

有两个值我们绝对需要存储在手机内存中 - 当前用户和计划的 ID。为了使应用程序完全开放并允许用户使用应用程序创建者未提供的服务器,我们还需要存储服务器地址。我们将从存储计划开始。用户 ID 和服务器将仍然是硬编码的。让我们添加一个新方法 - User.loadPlans - 并从 app.onDeviceReady 中调用它,而不是同时调用 user.getPlans()app.user.displayPlans。我们将通过从 User.loadPlans 中调用 User.displayPlans() 来决定是否需要从服务器加载计划,然后显示计划。

首先再次更新 onDeviceReady,使其变为

onDeviceReady: function() {
    app.user = new User('johndoe');
    app.user.loadPlans();
    app.activateFingerSwipe();
},

现在添加以下 loadPlans 方法的定义,放置在你其他 User 对象定义代码附近

User.prototype.loadPlans = function() {
    var plans = localStorage.getItem('plans');
    if (plans === null) {
        return this.getPlans();
    }
    console.log('DEBUG: plans loaded from device');
    this.initiatePlans(plans);
    this.displayPlans();
};

上面的方法调用了另一个新方法 - initiatePlans。此方法解析计划的 JSON 表示形式,并在 User.plans 数组中启动 Plan 对象。将来,我们希望能够从服务器重新加载计划,但现有代码总是添加新计划,而不是替换它们。

将以下定义添加到你添加 loadPlans 代码的位置下方

User.prototype.initiatePlans = function(plansString) {
    var plans = JSON.parse(plansString);
    for (var i = 0; i < plans.length; i++) {
        this.plans.push(new Plan(plans[i]));
    }
}

我们还需要在客户端存储计划。最佳时机是在从服务器加载完计划之后立即进行。我们将在 getPlans 的成功响应函数中调用 localStorage.setItem('plans', this.responseText)。我们还必须记住调用 initiatePlans 方法。

将你的 getPlans 定义更新为以下内容

User.prototype.getPlans = function() {
    var self = this;
    var request = new XMLHttpRequest({
        mozAnon: true,
        mozSystem: true});
    request.onload = function() {
        console.log('DEBUG: plans loaded from server');
        self.initiatePlans(this.responseText);
        localStorage.setItem('plans', this.responseText);
        self.displayPlans();
    };
    request.onerror = function(error) {
        console.log('DEBUG: Failed to get plans from ``' + app.getPlansURL + '``', error);
    };
    request.open("get", app.getPlansURL + this.id, true);
    request.send();
};

目前,用户计划的成功加载只发生一次。由于学校计划偶尔会更改(通常每年两次),因此你应该能够随时从服务器重新加载计划。我们可以使用 User.getPlans 来返回更新的计划,但首先我们需要从 UI 中删除现有的计划,否则将显示多个副本。

让我们创建 User.reloadPlans - 在你现有的 User 对象定义代码下方添加以下代码

User.prototype.reloadPlans = function() {
    // remove UI from plans
    for (var i = 0; i < this.plans.length; i++) {
        this.plans[i].removeUI();
    }
    // clean this.plans
    this.plans = [];
    // clean device storage
    localStorage.setItem('plans', '[]');
    // load plans from server
    this.getPlans();
};

Plan 对象中还需要添加一个新方法 - Plan.removeUI - 该方法只是对计划的卡片和标签调用 Element.remove()。将此方法添加到你之前的 Plan 对象定义代码下方

Plan.prototype.removeUI = function() {
    this.card.remove();
    this.tab.remove();
};

现在是时候测试代码是否真的重新加载了。再次在你的代码上运行 cordova prepare,并在 WebIDE 中重新加载它。

我们在 loadPlansgetPlans 中有两个 console.log 语句,以便让我们知道计划是从设备的存储还是从服务器加载的。要启动重新加载,请在控制台中运行以下代码

app.user.reloadPlans()

注意:Brick 在此时报告错误,因为每个标签和卡片之间的链接在内部系统中已丢失。请忽略此错误。同样,我们的应用程序仍然可以正常工作。

reloadPlans from Console

让我们通过添加一个 UI 元素来重新加载计划,使重新加载功能正常工作。在 index.html 中,将 div#topbar 包裹在 brick-tabbar 周围。首先,添加一个重新加载按钮

<div id="reload-button"></div>
<div id="topbar">
    <brick-tabbar id="plan-group-menu">
    </brick-tabbar>
</div>

现在,我们将使按钮浮动到左侧,并使用 calc 来设置 div#topbar 的大小。这使得 Brick 的标签栏在布局更改(纵向或横向)时计算大小。在 css/index.css 中,在文件末尾添加以下内容

#topbar {
	width: calc(100% - 45px);
}

#reload-button {
	float: left;
	/* height of the tabbar */
	width: 45px;
	height: 45px;
	background-image: url('../img/reload.svg');
	background-size: 25px 25px;
	background-repeat: no-repeat;
	background-position: center center;
}

我们将下载 一些 Gaia 图标 供应用程序使用。在此阶段,你应该将 reload.svg 文件保存到你的 img 文件夹中。

最后一个细节:监听 #reload-button 上的 touchstart 事件,并调用 app.user.reloadPlans。让我们添加一个 app.assignButtons 方法

assignButtons: function(){
    var reloadButton = document.getElementById('reload-button');
    reloadButton.addEventListener('touchstart', function() {
        app.user.reloadPlans();
    }, false);
},

我们需要在 app.deviceReady 中调用它,在 app.activateFingerSwipe() 之前或之后。

onDeviceReady: function() {
    // ...
    app.activateFingerSwipe();
    app.assignButtons();
},

此时,保存你的代码,准备你的 cordova 应用程序,并在 WebIDE 中重新加载它。你应该看到类似以下内容

reload button

你可以在此处找到 应用程序服务器 的当前代码。

设置页面

目前,所有身份信息(服务器地址和用户 ID)都在应用程序中硬编码。如果该应用程序要与其他人一起使用,我们将提供功能,允许用户设置此数据而不必编辑代码。我们将实现一个设置页面,使用 Brick 的 flipbox 组件在内容和设置之间切换。

首先,我们必须告诉应用程序加载该组件。让我们更改 index.html,通过在 HTML <head> 部分添加以下行来加载 flipbox 小部件

<link rel="import" href="app/bower_components/brick-flipbox/dist/brick-flipbox.html">

HTML <body> 中也必须进行一些更改。brick-topbarbrick-deck 需要放置在同一个 flipbox 的可切换部分内,重新加载按钮也移动到设置内。将你目前在 <body> 中的所有内容替换为以下内容(但你需要将 <script> 元素保留在底部)

<brick-flipbox>
    <section id="plans">
        <div id="settings-button"></div>
        <div id="topbar">
            <brick-tabbar id="plan-group-menu">
            </brick-tabbar>
        </div>
        <brick-deck id="plan-group">
        </brick-deck>
    </section>
    <section id="settings">
        <div id="settings-off-button"></div>
        <h2>Settings</h2>
    </section>
</brick-flipbox>

这样做的目的是在按下 settings-button 时切换到“设置”视图,然后使用 settings-off-button 切换回“计划”视图。

以下是连接此新功能的方法。首先,在你的 app 定义代码中,在 assignButtons() 的定义之前添加一个 toggleSettings() 函数

toggleSettings: function() {
    app.flipbox.toggle();
},

assignButtons() 本身更新为以下内容(我们将保留 reloadButton 代码,因为我们很快就会再次用到它)

assignButtons: function() {
    app.flipbox = document.querySelector('brick-flipbox');
    var settingsButton = document.getElementById('settings-button');
    var settingsOffButton = document.getElementById('settings-off-button');
    settingsButton.addEventListener('click', app.toggleSettings);
    settingsOffButton.addEventListener('click', app.toggleSettings);
        
    var reloadButton = document.getElementById('reload-button');
    reloadButton.addEventListener('touchstart', function() {
        app.user.reloadPlans();
    }, false);
},

接下来:我们需要为应用程序检索更多图标。将 settings.svgback.svg 文件保存到你的 img 文件夹中。

让我们也更新一下我们的 css/index.css 文件,在其中添加额外的图标并为我们的新功能设置样式。在该文件的末尾添加以下内容

#form-settings {
    font-size: 1.4rem;
    padding: 1rem;
}
#settings-button, #settings-off-button {
    float: left;
    width: 45px;
    height: 45px;
    background-size: 25px 25px;
    background-repeat: no-repeat;
    background-position: center center;
}
#settings-button {
    background-image: url('../img/settings.svg');
}
#settings-off-button {
    background-image: url('../img/back.svg');
    border-right: 1px solid #ccc;
    margin-right: 15px;
}
brick-flipbox {
    width: 100%;
    height: 100%;
}

如果你现在再次使用 cordova prepare 准备你的 cordova 应用程序,并在 WebIDE 中重新加载应用程序,你应该看到类似以下内容

flipbox working

添加设置表单

要添加表单和一些数据,请将你当前的 <section id="settings"></section> 元素替换为以下内容

<section id="settings">
    <div id="settings-off-button"></div>
    <h2>Settings</h2>
    <form id="form-settings">
        <p><label for="input-server">Server</label></p>
        <p><input type="text" id="input-server" placeholder="http://example.com/"/></p>
        <p><label for="input-user" id="label-user">User ID</label></p>
        <p><input type="text" id="input-user" placeholder="johndoe"/></p>
        <p><button id="reload-button">RELOAD ALL</button></p>
    </form>
</section>

让它看起来更美观很容易!我们将使用 Firefox OS 构建模块 中提供的一些现成的 CSS。下载 input_areas.cssbuttons.css,并将它们保存到你的 www/css 目录中。

然后在你的 HTML <head> 中添加以下内容,以将新的 CSS 应用于你的标记。

<link rel="stylesheet" type="text/css" href="css/input_areas.css">
<link rel="stylesheet" type="text/css" href="css/buttons.css">

进入 css/index.css 文件,删除 #reload-button { ... } 规则,以使“重新加载所有”按钮正确显示。

再次运行 cordova prepare 并在 WebIDE 中重新加载应用程序,你应该看到类似以下内容

settings

我们已经有了视图,现在要支持它。当应用程序第一次安装时,将没有设置,因此应用程序将不知道如何加载数据。我们不会显示空的计划,而是立即切换视图到设置。如所示更新您的`onDeviceReady`函数

onDeviceReady: function() {
    app.plansServer = localStorage.getItem('plansServer');
    app.userID = localStorage.getItem('userID');
    app.activateFingerSwipe();
    app.assignButtons();

    if (app.plansServer && app.userID) {
        app.user = new User(app.userID);
        app.user.loadPlans();
    } else {
        app.toggleSettings();
    }
},

应用程序需要从我们的表单输入中读取数据,将其保存到手机的存储中,并在更改后重新加载。让我们在`app.assignButtons`函数的末尾添加此功能。首先,我们将阻止表单提交(否则我们的应用程序只会重新加载`index.html`,而不是重新绘制内容)。在`assignButtons`函数的末尾添加以下代码,就在结束的`},`之前

document.getElementById('form-settings').addEventListener('submit', function(e) {
    e.preventDefault();
}, false)

我们将读取并设置服务器的输入值。我决定在`blur`事件上读取输入值。当用户触摸页面上的任何其他 DOM 元素时,它就会被派发。发生这种情况时,服务器值将存储在设备的存储中。如果在应用程序启动时`app.plansServer`存在,我们将设置默认输入值。对 userID 也必须这样做,但有一个特殊的步骤。我们要么更改 app.user,要么在不存在的情况下创建一个新的。将以下内容添加到您添加的先前代码块的正上方

var serverInput = document.getElementById('input-server');
serverInput.addEventListener('blur', function() {
    app.plansServer = serverInput.value || null;
    localStorage.setItem('plansServer', app.plansServer);
});
if (app.plansServer) {
    serverInput.value = app.plansServer;    
}

var userInput = document.getElementById('input-user');
userInput.addEventListener('blur', function() {
    app.userID = userInput.value || null;
    if (app.userID) {
        if (app.user) {
            app.user.id = app.userID;
        } else {
            app.user = new User('app.userID');
        }
        localStorage.setItem('userID', app.userID);
    }
});
if (app.userID) {
    userInput.value = app.userID;    
}

在准备应用程序并在 WebIDE 中重新加载它之后,您现在应该看到设置按预期工作。如果在本地存储中找不到计划,则设置屏幕会自动出现,允许您输入服务器和 userID 详细信息。

使用以下设置进行测试

  • 服务器:`http://127.0.0.1:8080`
  • 用户 ID:`johndoe`

唯一的不便之处是,您需要在加载设置后手动切换回计划视图。此外,如果您没有从两个表单字段中模糊出来,您可能会收到错误。

settings are working

改进设置 UX

实际上,在计划成功加载后,应该自动切换回计划视图。我们可以通过向`User.getPlans`函数添加回调,并将其包含在从`User.reloadPlans`进行的调用中来实现这一点

首先,将您的`getPlans()`函数定义更新为以下内容

User.prototype.getPlans = function(callback) {
    var self = this;
    var url = app.plansServer + '/plans/' + this.id;
    var request = new XMLHttpRequest({
        mozAnon: true,
        mozSystem: true});
    request.onload = function() {
        console.log('DEBUG: plans loaded from server');
        self.initiatePlans(this.responseText);
        localStorage.setItem('plans', this.responseText);
        self.displayPlans();
        if (callback) {
            callback();
        }
    };
    request.open("get", url, true);
    request.send();
};

接下来,在`User.reloadPlans`中,将回调函数作为`this.getPlans`调用的参数添加

this.getPlans(app.toggleSettings);

再次尝试保存、准备和重新加载新代码。您现在应该看到,当输入并提供正确的服务器和用户信息时,应用程序会自动显示计划视图。(如果`self.initiatePlans(this.responseText)`没有错误,它只会切换到设置视图)。

final

我们接下来该怎么做

恭喜!您已经完成了这个 4 部分教程,现在您应该拥有了自己的有趣的学校计划应用程序的工作原型。

还有很多工作要做来改进应用程序。这里有一些值得探索的想法

  • 编写 JSON 文件并不是一个舒适的任务。最好添加新的功能,让您可以在服务器上编辑计划。在服务器上将它们分配给用户会打开新的可能性,这只需要一个在服务器和客户端同步用户帐户的操作。
  • 显示小时数也很不错。
  • 为一些学校活动实现提醒警报会很酷。

尝试自己实现这些功能,或为自己的学校时间安排应用程序构建其他改进。告诉我们结果如何。

关于 Piotr Zalewa

Piotr Zalewa 是 Mozilla 的 Dev Ecosystem 团队的高级 Web 开发人员。从事 Web 应用程序开发。他是 JSFiddle 的创建者。

更多 Piotr Zalewa 的文章…

关于 Chris Mills

Chris Mills 是 Mozilla 的高级技术作家,他撰写关于开放 Web 应用程序、HTML/CSS/JavaScript、A11y、WebAssembly 等的文档和演示。他喜欢用 Web 技术捣鼓,并在会议和大学偶尔进行技术演讲。他曾在 Opera 和 W3C 工作,喜欢演奏重金属鼓和喝好啤酒。他和他的妻子和三个美丽的孩子住在英国曼彻斯特附近。

更多 Chris Mills 的文章…