背景介绍
在游戏开发中,拥有一个优雅的启动流程对于迭代开发和提升用户体验至关重要。对于使用 Cocos Creator 这一游戏引擎的开发者来说,设计和实现一个平滑、高效的启动流程需要关注几个关键步骤
启动优化常用方法
确保启动时加载的场景(通常为“加载场景”或“启动场景”)资源量尽可能小。仅包含必要的UI元素和最小的逻辑代码,确保它能快速加载和初始化。
使用简洁的背景图片和少量元素可使第一印象更加清晰。优化图片资源,使用压缩工具减小资源大小。
异步加载资源:利用 Cocos Creator的cc.resources.load或cc.assetManager.loadBundle进行资源的异步加载,可以在显示启动场景的同时加载后续场景的资源。
使用加载进度条或动画提升用户体验,让用户知道当前加载进度。
预加载关键资源:确定哪些资源是启动游戏后立即需要的,将它们作为预加载资源列表。
使用分批次加载的策略来减轻初始化时的性能压力。
使用加载动画和提示:在资源加载过程中,显示加载动画或者提示信息,既可以提升体验,也能减少用户等待时的焦虑。加载动画应设计得既简单又能吸引注意力,避免因复杂动画导致的额外性能开销。
后台预处理:对于一些耗时的初始化操作,如网络请求、数据库访问等,应尽可能地异步执行,避免阻塞主线程。 使用 Promise、async/await等语法进行异步流程控制,使代码更加清晰。
错误处理与重试机制:考虑网络不稳定或资源服务器不可达等异常情况,为资源加载和其他异步操作提供错误处理和重试机制。
性能监控: 使用Cocos Creator的性能监控工具监控启动过程的性能。 注意监控内存使用情况,避免在启动过程中出现内存峰值。
测试和优化:在不同的设备和网络环境下测试启动流程,确认加载时间和用户体验是否达到预期。 根据反馈和测试结果不断优化,可能需要调整资源分包、预加载策略等。
以上这些优化手段相信大家都了解了很多了,我之所以还要列出来,纯粹是凑字数的,今天要分享的不是优化方法,是如何方便合理的将这些优化手段组织起来,加入游戏启动流程中。
实际上,一个优雅的启动流程设计需要不断地迭代和优化。通过分析用户行为和收集性能数据,可以逐步调整和优化,以确保用户能够在最短的时间内进入游戏,并拥有愉快的用户体验。
开场小故事
某一天你决定组织一场聚会,而如何邀请你的朋友们将会决定这场聚会的成败。
你决定亲自去每个朋友家递邀请函。听起来亲切可人,但是当你有上百个朋友时,恭喜你,聚会开始时你可能已经累倒在某个朋友的门口了。所有人都得等着你一个个敲门,过程缓慢,效率低下,而且挺耗费你的精力。
于是你决定每邀请一个朋友,就让他去邀请另一个,然后那个朋友再去邀请另一个,以此类推。开始时听起来是个不错的传递信息的方式,但很快就变成了一个无尽的接力赛。最终,所有人都在迷茫地找寻下一个应该邀请的人,最终聚会变成了寻宝游戏。看似有效,实则让人迷失在无尽的嵌套和混乱中。
为了提高效率,你决定采用鸽子邮件。写好邀请函,绑在鸽子脚上,放飞它们。听起来高效,但你给每个邀请函都配了不止一只鸽子,以防万一。结果,你的房子周围现在全是鸽子,朋友们收到成堆的邀请函,反而感到困惑。本可以简化流程的操作,因为过度冗余和复杂化,效率却反降低了。
为了完成这场聚会,你决定使用高科技——搭建了一个精密的邀请系统。根据每个朋友可能的回应(接受、拒绝、或需更多信息),有不同的处理流程。太完美了,除了…你为这个系统花费了太多时间,聚会本可以简简单单,现在却变成了一个复杂的机械表演。朋友们对于如何才能正确回应你的邀请感到困惑。这让人忘记了最初的目的:简单快乐地聚会。
启动流程要解决哪些问题
在设计和实现启动流程时,开发者通常会面临一系列挑战,这些挑战可能会影响到应用的加载速度、性能以及用户体验。以下是一些启动流程中常见的难题:
-
加载时间过长
由于启动流程通常涉及加载大量资源(如图像、音频、视频、脚本等),如果不恰当地管理这些加载操作,可能会导致启动时间过长。长时间的等待会影响用户体验,用户可能因此而选择放弃使用应用。 -
资源管理和优化
正确地管理和优化应用资源是一个挑战,这包括但不限于资源的压缩、合理分配加载顺序、使用合适的文件格式等。资源的不当管理可能导致冗余加载、资源浪费或加载效率低下。 -
异步加载和依赖管理
在启动流程中,可能需要异步加载多个资源或模块,这些资源或模块间可能存在依赖关系。正确地处理这些异步操作,并管理它们之间的依赖,以确保正确的加载顺序和初始化,是一个技术挑战。 -
错误处理和重试机制
网络不稳定或服务器问题可能导致资源加载失败。如何恰当地处理这些错误,以及设计合理的重试机制,在不影响用户体验的前提下尽快恢复正常状态,是启动流程设计中的一个难题。 -
适应多种设备和环境
应用可能需要在不同的设备和操作系统上运行,每种环境的性能和资源限制都不相同。如何设计一个能够智能适应各种环境,同时在低端设备上也能保持良好性能的启动流程,是启动优化中的一个挑战。 -
用户体验和反馈
在启动流程中提供适当的用户反馈(例如加载进度指示)是很重要的。如何在不干扰加载过程的同时,保持用户的参与度和兴趣,需要仔细考虑UI/UX设计。 -
测试和优化
开发过程中,对启动流程的测试和优化是一个持续的过程。在不同的网络环境和设备上监测和优化启动性能,需要大量的测试和数据分析,这既是时间上的投入,也是技术上的挑战。
应对这些难题需要综合考虑技术选型、设计策略以及持续的性能监控与优化。通过迭代开发、用户反馈和技术创新,可以逐步解决这些问题,提升应用的启动性能和用户体验。
启动流程架构模式
上面的故事包含了4个模式,我把它们强行塞入了故事里,归纳为4种模式
- 一把梭模式(同步模式)
- Callback回调模式
- Promise模式
- 状态机模式
Cocos Creator 3.8.2实现
我们通过一个简单的例子来实现这4中模式
- 一把梭模式(同步模式)
启动->解析参数->获取设备信息->进入游戏
import { _decorator, Component, director, Node, Size, sys } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('Launcher1')
export class Launcher1 extends Component {
start() {
this.launchGame();
}
parseLaunchParams() {
console.log("解析启动参数");
// 在这部分解析你的启动参数,注意,这里的实现会根据平台的不同而有不同的处理方式
// 示例中,我们只是简单地输出信息
console.log("示例参数解析");
}
getDeviceInfo() {
console.log("获取设备信息");
// 获取并打印出屏幕大小
let screenSize: Size = sys.windowPixelResolution;
console.log(`屏幕大小:宽=${screenSize.width}, 高=${screenSize.height}`);
// 这里可以根据需要获取更多的设备信息,例如平台、操作系统等
}
enterGame() {
console.log("进入游戏");
// 示例:加载主场景
director.loadScene("main");
}
launchGame() {
console.log("启动游戏");
// 解析启动参数
this.parseLaunchParams();
// 获取设备信息
this.getDeviceInfo();
// 设备信息获取完毕,进入游戏
this.enterGame();
}
}
- Callback回调模式
启动->解析参数->获取设备信息->从网络获取配置信息->登陆->进入游戏
import { _decorator, Component, director, Node, Size, sys } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('Launcher2')
export class Launcher2 extends Component {
start() {
this.launchGame();
}
parseLaunchParams() {
console.log("解析启动参数");
// 在这部分解析你的启动参数,注意,这里的实现会根据平台的不同而有不同的处理方式
// 示例中,我们只是简单地输出信息
console.log("示例参数解析");
}
getDeviceInfo() {
console.log("获取设备信息");
// 获取并打印出屏幕大小
let screenSize: Size = sys.windowPixelResolution;
console.log(`屏幕大小:宽=${screenSize.width}, 高=${screenSize.height}`);
// 这里可以根据需要获取更多的设备信息,例如平台、操作系统等
}
fetchConfig(callback: Function) {
console.log("获取游戏配置");
// 示例:模拟异步获取配置操作
setTimeout(() => {
const config = {serverUrl: 'http://example.com', mode: 'production'};
console.log("配置获取完成");
callback(null, config); // 成功回调
}, 1000);
}
login(serverUrl: string, callback: Function) {
console.log(`登录游戏服务器: ${serverUrl}`);
// 示例:模拟异步登录操作
setTimeout(() => {
const loginResult = {userId: 12345, sessionId: 'abcd1234'};
console.log("登录成功");
callback(null, loginResult); // 成功回调
}, 1000);
}
enterGame() {
console.log("进入游戏");
// 示例:加载主场景
director.loadScene("main");
}
launchGame() {
console.log("启动游戏");
// 解析启动参数
this.parseLaunchParams();
// 获取设备信息
this.getDeviceInfo();
this.fetchConfig((err: Error | null, config?: any) => {
if (err) {
console.log("获取配置失败");
return;
}
this.login(config.serverUrl, (loginErr: Error | null, loginResult?: any) => {
if (loginErr) {
console.log("登录失败");
return;
}
// 设备信息获取完毕,进入游戏
this.enterGame();
});
});
}
}
- Promise模式
启动->解析参数->获取设备信息->(同时获取配置,获取游戏版本)->更新资源->登陆->拉取用户信息->进入游戏
import { _decorator, Component, director, Node, Size, sys } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('Launcher3')
export class Launcher3 extends Component {
start() {
this.launchGame();
}
parseLaunchParams() {
console.log("解析启动参数");
// 在这部分解析你的启动参数,注意,这里的实现会根据平台的不同而有不同的处理方式
// 示例中,我们只是简单地输出信息
console.log("示例参数解析");
}
getDeviceInfo() {
console.log("获取设备信息");
// 获取并打印出屏幕大小
let screenSize: Size = sys.windowPixelResolution;
console.log(`屏幕大小:宽=${screenSize.width}, 高=${screenSize.height}`);
// 这里可以根据需要获取更多的设备信息,例如平台、操作系统等
}
fetchConfig() {
return new Promise((resolve) => {
console.log("获取游戏配置");
setTimeout(() => resolve(true), 500); // 延迟模拟
});
}
fetchResVersion(){
return new Promise((resolve) => {
console.log("获取资源版本");
setTimeout(() => resolve(true), 500); // 延迟模拟
});
}
updateResources() {
return new Promise((resolve) => {
console.log("更新资源");
setTimeout(() => resolve(true), 500); // 延迟模拟
});
}
login() {
return new Promise((resolve) => {
console.log("登录游戏服务器");
setTimeout(() => resolve(true), 500); // 延迟模拟
});
}
fetchUserInfo() {
return new Promise((resolve) => {
console.log("拉取用户信息");
setTimeout(() => resolve(true), 500); // 延迟模拟
});
}
enterGame() {
console.log("进入游戏");
// 示例:加载主场景
director.loadScene("main");
}
async launchGame() {
console.log("启动游戏");
// 解析启动参数
this.parseLaunchParams();
// 获取设备信息
this.getDeviceInfo();
let res1 = await Promise.all([this.fetchConfig(),this.fetchResVersion()]);
console.log(res1);
if(!res1[0] || !res1[1]){
return;
}
// 更新资源
let res2 = await this.updateResources();
if(!res2){
return;
}
// 登陆
let res3 = await this.login();
if(!res3){
return;
}
// 拉取用户信息
let res4 = await this.fetchUserInfo();
if(!res4){
return;
}
// 获取完毕,进入游戏
this.enterGame();
}
}
- 状态机模式
启动->解析参数->获取设备信息->(同时获取配置,获取游戏版本)->更新资源->登陆->拉取用户信息->进入游戏
加个容错,获取配置或者游戏版本失败重试2次,更新资源失败重试3次,每次需要获取游戏版本信息,登陆失败重试3次,拉取用户失败重试2次就要退出游戏,并且每次都需要重新登陆
import { _decorator, Component, director, Node, Size, sys } from 'cc';
import { State, StateMachine } from './StateMachine';
const { ccclass, property } = _decorator;
// 随机生成一个指定范围内的整数
function randRange_Int (param1: number, param2: number): number {
const loc: number = Math.random() * (param2 - param1 + 1) + param1;
return Math.floor(loc);
}
const FAILED_FACTOR = 10; // 模拟失败的概率
enum eLauncherState {
INIT = 1, //初始化
FETCH_NATIVE, //获取启动信息
FETCH_SERVER, //获取服务器相关信息
UPDATE_RES, //更新资源
LOGIN, //登陆
GET_USER, //获取用户信息
GAMING, //游戏中
EXIT, //退出游戏
}
class InitState extends State {
public enter (preState: State = null): boolean {
console.log("初始化");
this._owner.changeState(eLauncherState.FETCH_NATIVE);
return true
}
public update(dt: number) {
console.log("初始化中...");
}
public exit() {
console.log("初始化完成");
}
}
class FetchNativeState extends State {
public parseLaunchParams() {
console.log("解析启动参数");
// 在这部分解析你的启动参数,注意,这里的实现会根据平台的不同而有不同的处理方式
// 示例中,我们只是简单地输出信息
console.log("示例参数解析");
}
public getDeviceInfo() {
console.log("获取设备信息");
// 获取并打印出屏幕大小
let screenSize: Size = sys.windowPixelResolution;
console.log(`屏幕大小:宽=${screenSize.width}, 高=${screenSize.height}`);
// 这里可以根据需要获取更多的设备信息,例如平台、操作系统等
}
public enter (preState: State = null): boolean {
console.log("获取启动信息");
this.parseLaunchParams();
this.getDeviceInfo();
this._owner.changeState(eLauncherState.FETCH_SERVER);
return true
}
public update(dt: number) {
console.log("获取启动信息中...");
}
public exit() {
console.log("获取启动信息完成");
}
}
class FetchServerState extends State {
public fetchConfig() {
return new Promise((resolve) => {
console.log("获取游戏配置");
setTimeout(() => resolve(randRange_Int(0,100)>FAILED_FACTOR), 500); // 延迟模拟
});
}
public fetchResVersion(){
return new Promise((resolve) => {
console.log("获取资源版本");
setTimeout(() => resolve(randRange_Int(0,100)>FAILED_FACTOR), 500); // 延迟模拟
});
}
public enter (preState: State = null): boolean {
console.log("获取服务器相关信息");
Promise.all([this.fetchConfig(),this.fetchResVersion()]).then(v=>{
if(v[0] && v[1]){
this._owner.setData("retryFetchServerCount",0);
this._owner.changeState(eLauncherState.UPDATE_RES);
}else{
const count = this._owner.getData("retryFetchServerCount") || 0;
if(count < 2){ //重试2次
console.log("获取服务器相关信息重试",count+1,"次");
this._owner.setData("retryFetchServerCount",count+1);
this._owner.changeState(eLauncherState.FETCH_SERVER)
}else{
console.log("获取服务器相关信息失败");
this._owner.setData('exit',eLauncherState.FETCH_SERVER)
this._owner.changeState(eLauncherState.EXIT);
}
}
})
return true
}
public update(dt: number) {
console.log("获取服务器相关信息中...");
}
public exit() {
console.log("获取服务器相关信息完成");
}
}
class UpdateResState extends State {
public updateResources() {
return new Promise((resolve) => {
console.log("更新资源");
setTimeout(() => resolve(randRange_Int(0,100)>FAILED_FACTOR), 500); // 延迟模拟
});
}
public enter (preState: State = null): boolean {
console.log("更新资源");
this.updateResources().then((res)=>{
if(res){
this._owner.setData("retryUpdateResCount",0);
this._owner.changeState(eLauncherState.LOGIN);
}else{
const count = this._owner.getData("retryUpdateResCount") || 0;
if(count < 3){ //重试3次,退回去获取信息
console.log("更新资源重试",count+1,"次");
this._owner.setData("retryUpdateResCount",count+1);
this._owner.changeState(eLauncherState.FETCH_SERVER)
}else{
console.log("更新资源失败");
this._owner.setData('exit',eLauncherState.UPDATE_RES)
this._owner.changeState(eLauncherState.EXIT);
}
}
});
return true
}
public update(dt: number) {
console.log("更新资源中...");
}
public exit() {
console.log("更新资源完成");
}
}
class LoginState extends State {
public login() {
return new Promise((resolve) => {
console.log("登录游戏服务器");
setTimeout(() => resolve(randRange_Int(0,100)>FAILED_FACTOR), 500); // 延迟模拟
});
}
public enter (preState: State = null): boolean {
console.log("登陆");
this.login().then((res)=>{
if(res){
this._owner.setData("retryLoginCount",0);
this._owner.changeState(eLauncherState.GET_USER);
}else{
const count = this._owner.getData("retryLoginCount") || 0;
if(count < 3){ //重试3次
console.log("登陆失败重试",count+1,"次");
this._owner.setData("retryLoginCount",count+1);
this._owner.changeState(eLauncherState.LOGIN)
}else{
console.log("登陆失败");
this._owner.setData('exit',eLauncherState.LOGIN)
this._owner.changeState(eLauncherState.EXIT);
}
}
});
return true
}
public update(dt: number) {
console.log("登陆中...");
}
public exit() {
console.log("登陆完成");
}
}
class GetUserState extends State {
public fetchUserInfo() {
return new Promise((resolve) => {
console.log("拉取用户信息");
setTimeout(() => resolve(randRange_Int(0,100)>FAILED_FACTOR), 500); // 延迟模拟
});
}
public enter (preState: State = null): boolean {
console.log("获取用户信息");
this.fetchUserInfo().then((res)=>{
if(res){
this._owner.setData("retryGetUserCount",0);
this._owner.changeState(eLauncherState.GAMING);
}else{
const count = this._owner.getData("retryGetUserCount") || 0;
if(count < 2){ //重试2次,退回登陆
console.log("获取用户失败重试",count+1,"次");
this._owner.setData("retryGetUserCount",count+1);
this._owner.changeState(eLauncherState.LOGIN)
}else{
console.log("获取用户失败");
this._owner.setData('exit',eLauncherState.LOGIN)
this._owner.changeState(eLauncherState.EXIT);
}
}
});
return true
}
public update(dt: number) {
console.log("获取用户信息中...");
}
public exit() {
console.log("获取用户信息完成");
}
}
class GamingState extends State {
public enterGame() {
console.log("进入游戏");
// 示例:加载主场景
director.loadScene("main");
}
public enter (preState: State = null): boolean {
console.log("进入游戏");
this.enterGame();
return true
}
public update(dt: number) {
console.log("游戏中...");
}
public exit() {
console.log("退出游戏");
}
}
class ExitState extends State {
public enter (preState: State = null): boolean {
console.log("弹框或者退出游戏");
return true
}
public update(dt: number) {
console.log("退出游戏中...");
}
public exit() {
console.log("退出游戏完成");
}
}
@ccclass('Launcher4')
export class Launcher4 extends Component {
private _lanuncherSM = new StateMachine();
start() {
this.launchGame();
}
launchGame() {
console.log("启动游戏");
this._lanuncherSM.registerState(eLauncherState.INIT, new InitState());
this._lanuncherSM.registerState(eLauncherState.FETCH_NATIVE, new FetchNativeState());
this._lanuncherSM.registerState(eLauncherState.FETCH_SERVER, new FetchServerState());
this._lanuncherSM.registerState(eLauncherState.UPDATE_RES, new UpdateResState());
this._lanuncherSM.registerState(eLauncherState.LOGIN, new LoginState());
this._lanuncherSM.registerState(eLauncherState.GET_USER, new GetUserState());
this._lanuncherSM.registerState(eLauncherState.GAMING, new GamingState());
this._lanuncherSM.registerState(eLauncherState.EXIT, new ExitState());
this._lanuncherSM.changeState(eLauncherState.INIT);
}
protected update(dt: number): void {
this._lanuncherSM?.tick(dt);
}
}
选型参考
启动流程是用户与游戏互动的第一步。不同的启动流程设计模式适合不同的场景,它们各有优缺点。下面分别介绍这几种启动流程并探讨它们的优缺点。
一把梭模式(同步模式)
- 优点:
简单直接: 进行同步操作,代码易于编写和理解。
执行顺序清晰: 按照代码编写顺序依次执行,没有异步操作带来的并发问题。 - 缺点:
阻塞用户界面: 引起界面卡顿,影响用户体验。在加载大量数据或进行复杂计算时,应用可能会暂时无响应。
不利于复杂应用: 对于需要加载大量资源或执行长时间操作的复杂应用或游戏,不适用。 - 适用场景:
小型应用或游戏,加载资源较少。
需要保证加载顺序且不担心阻塞主线程的场景。 - 选择理由:
如果你正在开发的是一个比较简单的应用或游戏,而且启动时需要加载的资源不多,启动流程不复杂,可以选择同步加载的方式。这样可以避免复杂的异步编码,使得代码更容易编写和理解。
Callback回调
- 优点:
简单易懂:对于JavaScript开发者来说,回调模式非常熟悉,容易理解和实现。
广泛应用:在老版本的库和框架中仍然广泛使用,兼容性好。 - 缺点:
回调地狱:多层嵌套的回调函数不仅代码难以维护,而且也很难读懂和调试。
容易出错:错误处理困难,代码质量不易保证。 - 适用场景:
对兼容性要求高的场景,尤其是需要支持旧版JavaScript环境的项目。
简单的异步操作,不涉及复杂的嵌套调用。 - 选择理由:
虽然回调模式可能会导致回调地狱,但在一些简单场景或是保持代码最大兼容性时(尤其是在一些老的JavaScript环境中),使用回调函数仍是一种可行的方案。它是异步编程的基础,许多现代JavaScript特性(如 Promises 和 async/await)底层仍然依赖于回调函数。
Promise
- 优点:
异步操作管理: 提供了一个更加优雅的方式来进行异步操作的管理。
链式调用: 可以通过.then()和.catch()方法进行链式调用,代码结构清晰,易于捕获错误和处理。
避免回调地狱: 相比于传统的回调函数,使用Promise可以避免层层嵌套的回调函数(回调地狱)。 - 缺点:
不支持取消:一旦新建,就无法取消。
错误需要通过回调捕获:如果不设置回调函数,Promise内部抛出的错误不会反应到外部。 - 适用场景:
异步操作较多,需要避免回调地狱。
需要链式调用处理结果的场景。 - 选择理由:
当应用或游戏的启动流程中涉及到多个异步操作,比如网络请求、大量资源加载等,采用Promise可以极大地简化异步操作的管理。Promise提供了优雅的链式调用,以及错误处理机制,可以让你的代码更加清晰和健壮。
状态机
- 优点:
状态管理清晰:状态机模型能够清晰地定义不同状态间的转移,适合于复杂逻辑的状态管理,尤其是在复杂的异步流程控制中。
可维护性和可扩展性强:因为状态转换和执行逻辑的分离,使得新增状态或修改逻辑变得容易,提高了代码的可维护性和可扩展性。
易于理解和调试:状态机的可视化表达通常比较直观,有利于理解程序运行逻辑和调试。 - 缺点:
实现复杂:对于简单的启动流程来说,使用状态机可能会过于复杂,增加了实现的难度。
性能考量:状态机的实现可能会引入一定的性能开销,尤其是在状态非常多或者状态转换非常频繁的场景下。
不同的启动流程设计模式适用于不同的场景。选择合适的模式应基于应用的复杂性、资源加载需求以及开发团队的熟悉程度等因素。理想的启动流程应该能够平衡开发的便利性、用户体验以及应用性能。 - 适用场景:
启动流程或者初始化流程包含多个状态和状态之间的复杂转换关系。
需要清晰地管理和追踪应用在加载或启动过程中的不同状态。 - 选择理由:
如果你的应用或游戏启动流程特别复杂,涉及多个加载阶段和状态,且这些状态之间有复杂的转换逻辑,使用状态机可以为这种复杂性提供清晰的结构和管理。它可以帮助你在不同的加载状态间精准控制流程和行为,提升代码的可维护性和可扩展性。
5.其他更复杂如行为树
跑路吧大兄弟
综上所述,选择正确的架构模式需要根据项目的具体需求和团队的技术栈偏好来决定。通常,对于现代Web应用和游戏而言,推荐使用更为现代且能够提供更好异步流程管理的Promise或状态机模式。针对简单场景或高兼容性需求,则可能需要使用同步加载或回调函数。无论选择哪种方式,最重要的是保证启动流程的整体性能和用户体验。
ps
如果你的老板或者产品对交互体验细节要求比较高(龟毛),那就一开始就用状态机,缺点就是你可能这坨代码甩不出去了,只能自己维护了。
附带工程测试代码
logindemo.zip (2.4 MB)