技术相对论之软件架构

文 | 欧阳锋

有同学问我,你是怎样学习编程的呢?为了回答你的这个问题,今天,我们一起来做一件非常有意思的事情。我们以MVC架构为基,从服务端编程开始,依次类推iOS、Android,并最终完成登录、注册功能。

What is MVC ?

正文开始之前,我们先来简单了解一下MVC架构。

MVC全称是Model-View-Controller,是上个世纪80年底Xerox PARC为其编程语言SmallTalk发明的一直软件设计模式。我们可以用一张图来表示MVC架构模型:

MVC的核心思想是希望通过控制层管理视图呈现,从将逻辑层和视图层进行分离。

服务端编程其实就是MVC的最佳实践,理解了MVC架构之后,我们马上开始服务端编程。

服务端编程

服务端编程也叫后端编程,主要用于为前端提供数据源以及完成必要的业务逻辑处理。

这个部分我们使用Java语言开发,MVC框架使用最常用的 Spring MVC,完整配置请参考下方表格:

IDE 编程语言 框架 数据库 服务器
IntelliJ IDEA Java 1.8 Spring MVC MySQL Tomcat 7.0.57

为了简化数据库的访问,我们再增加一个轻量级的数据库访问框架 MyBatis

这里假设你已经正确安装了MySQL数据库和Tomcat服务器,如果你对具体的安装步骤有疑问,请在文章下方评论告诉我。

在开始编程之前,我们需要完成以下准备工作:

第一步:创建数据库d_user以及用户表t_user用于保存用户数据

create database d_server;
use d_server;
CREATE TABLE `t_user` (
  `id` int(10) NOT NULL AUTO_INCREMENT,
  `username` varchar(20) NOT NULL,
  `pwd` varchar(32) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`)
)

第二步:使用IntelliJ IDE创建一个Gradle依赖工程

最后一个步骤选择工作目录确定即可。

第三步:在build.gradle脚本文件中添加Spring MVC以及MyBatis依赖

compile group: 'org.springframework', name: 'spring-webmvc', version: '5.0.4.RELEASE'
compile group: 'org.mybatis', name: 'mybatis', version: '3.4.6'

第四步:关联本地Tomcat服务器

a)编辑运行设置,选择本地Tomcat服务器


b)选择以war包的方式部署到Tomcat


c)在浏览器中输入http://localhost:8080测试工作是否正常

如果看到下面这个界面,证明一切工作正常


第五步:配置Spring MVC

备注:参考官方文档 Web on Servlet Stack

a)在webapp目录下面生成WEB-INF/web.xml配置文件
选择菜单File->Project Structure进入如下界面:



在弹出的界面中设置路径为.../webapp/WEB-INF即可。

b)在web.xml文件中添加如下配置信息

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">

    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/app-context.xml</param-value>
    </context-param>

    <servlet>
        <servlet-name>/</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value></param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>/</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>

</web-app>

上面这部分配置主要是使用Spring MVC的DispatcherServlet完成请求的拦截分发。配置文件中引用了另外一个配置文件app-context.xml,这个配置文件主要是完成Spring的依赖注入。

c)在app-context.xml配置文件中添加如下信息

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:p="http://www.springframework.org/schema/p"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd
        http://www.springframework.org/schema/mvc
        http://www.springframework.org/schema/mvc/spring-mvc.xsd">
  
   <!-- 添加扫描注解的包 -->
    <context:component-scan base-package="com.youngfeng.server"/>
    
   <!-- 使用注解完成依赖注入 -->
    <mvc:annotation-driven />

</beans>

d)添加jackson依赖用于Spring实现Json自动解析

compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.9.4'
compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: '2.2.3'

PS:不得不承认,Java后端开发的xml文件配置实在是一件繁琐至极的事情,尽管我们只需要配置一次。为了简化配置,Spring官方推出了一个重磅产品 Spring Boot。不过,这不是本文讨论的重点,感兴趣的同学请自行了解。

虽然我们已经完成了Spring的配置,但MyBatis的配置工作才刚刚开始。

配置MyBatis

为了简化Spring中MyBatis的配置,我们再增加一个MyBatis官方的提供的 mybatis-spring 库。

compile group: 'org.mybatis', name: 'mybatis-spring', version: '1.3.2'

备注:参考官方文档 mybatis-spring

a)在spring配置文件app-context.xml配置文件中添加如下bean配置:

<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
  <property name="dataSource" ref="dataSource" />
</bean>

b)指定数据源

b1) 添加Spring JDBC与MySQL Connector依赖

compile group: 'org.springframework', name: 'spring-jdbc', version: '5.0.4.RELEASE'
compile group: 'mysql', name: 'mysql-connector-java', version: '6.0.6'

注意:因为部分依赖包只存在于JCenter,需要在build.gradle脚本中添加jcenter maven源

repositories {
    mavenCentral()
    jcenter()
}

b2)在app-context.xml文件中添加如下配置:

    <context:property-placeholder location="classpath:db.properties"/>
    
    <bean id="dataSource"
          class="org.springframework.jdbc.datasource.DriverManagerDataSource">
        <property name="driverClassName">
            <value>${jdbc.driverClassName}</value>
        </property>
        <property name="url">
            <value>${jdbc.url}</value>
        </property>
        <property name="username">
            <value>${jdbc.username}</value>
        </property>
        <property name="password">
            <value>${jdbc.password}</value>
        </property>
    </bean>

b3)在类路径目录下创建db.properties文件指定MySQL数据库信息

jdbc.driverClassName = com.mysql.jdbc.Driver
jdbc.url = jdbc:mysql://localhost:3306/d_server
jdbc.username = root
jdbc.password = root

至此,所有的配置工作终于完成了,接下来进入最重要的编码阶段。

由于控制层需要依赖模型层的代码,因此,我们按照从下往上的原则进行编码。
a)先完成数据库的访问部分(DAO)

public interface UserDAO {
    @Select("select * from t_user where username = #{username}")
    User findByUsername(@Param("username") String username);

    @Select("select * from t_user where username = #{username} and pwd = #{pwd}")
    User findUser(@Param("username") String username, @Param("pwd") String pwd);

    @Insert("insert into t_user(username, pwd) values(#{username}, #{pwd})")
    void insert(@Param("username") String username, @Param("pwd") String pwd);
}

结合MyBatis,这个部分的工作很简单,甚至DAO的实现都不需要手动编码。

为了实现DAO的依赖注入,我们在app-context.xml配置文件中添加如下配置:

<bean id="userDAO" class="org.mybatis.spring.mapper.MapperFactoryBean">
     <property name="mapperInterface" value="com.youngfeng.server.dao.UserDAO"/>
     <property name="sqlSessionFactory" ref="sqlSessionFactory"/>
</bean>

b)Service层编码(也叫Domain层)
Service部分是控制层直接调用的接口,从抽象思维来说,也应该使用面向接口的方式编码。这里为了简化,Service部分我们直接使用一个类来实现了。

@Component("userService")
public class UserService {
    @Autowired
    UserDAO userDAO;

    public boolean isExist(String username) {
        return null != userDAO.findByUsername(username);
    }

    public boolean isExist(String username, String pwd) {
        return null != userDAO.findUser(username, pwd);
    }

    public void saveUser(String username, String pwd) {
        this.userDAO.insert(username, pwd);
    }
}

c)控制层编码

@Controller
@RequestMapping("/user")
public class UserController {
    @Autowired
    UserService userService;

    @ResponseBody
    @GetMapping("/login")
    public Response login(@RequestParam("username") String username, @RequestParam("pwd") String pwd) {
        Response response = new Response();
  
        // 先判断用户名是否存在,给定不同Code用于区分不同错误
        boolean isExist = userService.isExist(username);
        if(!isExist) {
            response.setCode(Response.CODE_USER_NOT_EXIST);
            response.setMsg("用户不存在或密码错误");
        }
      
        // 判断用户名和密码是否匹配
        isExist = userService.isExist(username, pwd);

        if(!isExist) {
            response.setCode(Response.CODE_USER_PWD_ERR);
            response.setMsg("用户不存在或密码错误");
        }

        return response;
    }

    @ResponseBody
    @GetMapping("/register")
    public Response register(@RequestParam("username") String username, @RequestParam("pwd") String pwd) {
        Response response = new Response();
       
        // 注册之前,判断用户名是否已存在
        boolean isExist = userService.isExist(username);
        if(isExist) {
           response.setCode(Response.CODE_USER_HAS_EXIST);
           response.setMsg("用户名已存在");
        } else {
            userService.saveUser(username, pwd);
        }

        return response;
    }

}

想必大家应该已经注意到了,控制层部分请求类型我使用了GET,这是为了方便在浏览器上面测试。测试通过后,要修改为POST请求类型。

以上代码,我已经在浏览器上测试通过。接下来,我们马上进入iOS客户端编程。

iOS客户端编程

iOS部分开发工具我们使用Xcode 9.2,其实你也可以使用AppCode,这是基于IntelliJ IDE开发的一款IDE,使用习惯完全接近IntelliJ IDE。

为了防止部分同学对Swift语言不熟悉,我们使用最常见的编程语言OC。

完整配置请参考如下表格:

IDE 编程语言 网络框架
Xcode 9.2 Objective C AFNetworking

打开Xcode,依次选择Create new Xcode Project->Single View App

下一步填入如下信息,语言选择OC


第一步:完成UI部分

这一部分参考苹果官方文档,按照苹果官方推荐,我们使用Storyboard进行布局。由于我们只是完成一个简单的Demo,所有的页面将在同一个Storyboard中完成。实际开发过程中,要根据功能划分Storyboard,方便进行小组开发。


使用约束布局我们很快完成了UI的构建,接下来进入最重要的编码阶段。约束布局的意思就是为一个控件添加N个约束,使其固定在某个位置。这个部分只要稍加尝试,就能掌握。具体的使用方法,请参考官方文档。

第二步:创建控制器,并关联UI

从服务器编程类推,iOS编程模型中应该也有一个叫Controller的东西。果不其然,在iOS新创建的工程中就有一个叫做ViewController的类,其父类是UIViewController。没错,这就是传说中的控制器。

#import <UIKit/UIKit.h>

@interface ViewController : UIViewController


@end

完成登录、注册功能,我们至少需要三个控制器:LoginViewController、RegisterViewController、MainViewController,分别代表登录、注册、首页三个页面。

接下来,将控制器与UI进行关联。

UI关联控制器部分,如果你不知道,请先参考苹果官方文档。

事实上,Xcode的Interface Builder非常好用。按照下图操作即可:


最后,关联按钮点击事件以及输入框。

选中控件并按住鼠标右键拖拽到控制器源码中,松开,并选择相应类型即可:


以登录控制器为例,拖拽完成后的源码如下:

@interface LoginViewController ()
@property (weak, nonatomic) IBOutlet UITextField *mUsernameTextField;
@property (weak, nonatomic) IBOutlet UITextField *mPwdTextField;

@end

@implementation LoginViewController

- (IBAction)login:(id)sender {
}

- (IBAction)goToRegister:(id)sender {
}

接下来进入网络部分编程。

为了简化网络部分编程,我们引入AFNetworking框架。还记得服务端编程是怎么引入依赖的吗?没错,是Gradle。iOS端也有类似的依赖管理工具Cocoapods,这个部分如果不会依然请你参考官方文档。

使用如下步骤安装依赖(这里假设你已经正确安装了Cocoapod):
a)在根目录下面创建Podfile文件,并添加如下内容:

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'

target 'IOSClient' do
pod 'AFNetworking', '~> 3.0'
end

b)安装依赖

pod install

PS:可能有人会问,为什么服务端编程没有安装依赖的步骤。其实,很简单,intelliJ IDE非常智能,它自动检测了build.gradle文件的修改。一旦发现修改,自动安装依赖。因此,看起来就像没有依赖安装这个步骤一样。事实上,Cocoapod并非苹果官方的产品,如果产品来自苹果官方,恐怕Xcode也会支持自动安装依赖。

依赖安装完成后,为了更好地服务我们的业务。我们对网络请求做一点简单封装,增加HttpClient类,仅提供一个POST请求接口即可。

//
//  HttpClient.m
//  IOSClient
//
//  Created by 欧阳锋 on 17/03/2018.
//  Copyright © 2018 xbdx. All rights reserved.
//

#import "HttpClient.h"
#import <AFNetworking.h>
#import "Response.h"

@implementation HttpClient

static const NSString *BASE_URL = @"http://192.168.31.146:8080";

- (instancetype)init {
    self = [super init];
    if (self) {
        self.baseUrl = BASE_URL;
    }
    return self;
}

+ (HttpClient *)initWithBaseUrl:(NSString *)baseUrl {
    HttpClient *client = [[HttpClient alloc] init];
    client.baseUrl = baseUrl;
    
    return client;
}

+ (HttpClient *)sharedInstance {
    static HttpClient *client = nil;
    static dispatch_once_t once;
    dispatch_once(&once, ^{
        client = [[self alloc] init];
    });
    return client;
}

- (void)POST:(NSString *)url params:(NSDictionary *)params success:(void (^)(NSString *, id))success error:(void (^)(NSString *, NSInteger, NSInteger, NSString *))error {
    AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
    manager.responseSerializer = [AFJSONResponseSerializer serializer];
    [[AFHTTPSessionManager manager] POST: [_baseUrl stringByAppendingString:url]
                              parameters: params
                                progress: nil
                                 success: ^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
                                     if(nil != success) {
                                         if(nil != responseObject) {
                                             if([responseObject isKindOfClass: [NSDictionary class]]) {
                                                 NSInteger code = ((NSDictionary *)responseObject)[@"code"];
                                                 
                                                 if(SUCCESS == code) {
                                                     success(url, responseObject);
                                                 } else {
                                                     if(nil != error) {
                                                         NSString *msg = ((NSDictionary *)responseObject)[@"msg"];
                                                         error(url, SC_OK, code, msg);
                                                     }
                                                 }
                                             }
                                         }
                                     }
                                 }
                                 failure: ^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull nsError) {
                                     if(nil != nsError) {
                                         error(url, nsError.code, nil, nsError.description);
                                     }
                                 }];
    
}

@end

为了简化JSON解析,我们增加一个最常见的Json解析库 jsonmodel 库。等待对话框也使用最常见的第三方库 SVProgressHUD

pod 'JSONModel'
pod 'SVProgressHUD'

安装依赖使用同样的命令pod install即可。

接下来,我们添加登录注册逻辑,完成最后部分编码:

// 登录部分逻辑
- (IBAction)login:(id)sender {
    [SVProgressHUD show];
    HttpClient *client = [HttpClient sharedInstance];
    [client POST: @"/user/login"
          params: @{@"username" : _mUsernameTextField.text, @"pwd" : _mPwdTextField.text}
         success:^(NSString *url, id data) {
             [SVProgressHUD dismiss];
             
             if([data isKindOfClass: [NSDictionary class]]) {
                 // 例子代码,这里不做严格判断了
                 User *user = [[User alloc] initWithDictionary: data[@"data"] error: nil];
                 [self pushToMainViewController: user];
             }
         } error:^(NSString *url, NSInteger httpCode, NSInteger bizCode, NSString *error) {
             [SVProgressHUD dismiss];
             
             [self promptError: error];
         }];
}

- (void)pushToMainViewController: (User *) user {
    UIStoryboard *storyboard = [UIStoryboard storyboardWithName: @"Main" bundle: [NSBundle mainBundle]];
    MainViewController *mainViewController = [storyboard instantiateViewControllerWithIdentifier: @"mainViewController"];
    mainViewController.user = user;
    [self.navigationController presentViewController: mainViewController animated: YES completion: nil];
}

// 注册部分逻辑
- (IBAction)register:(id)sender {
    NSString *username = _mUsernameTextField.text;
    NSString *pwd = _mPwdTextField.text;
    NSString *confrimPwd = _mConfirmTextField.text;
    
    if([StringUtil isBlankString: username]) {
        [self promptError: @"请输入用户名"];
        return;
    }
    
    if([StringUtil isBlankString: pwd]) {
        [self promptError: @"请输入用户密码"];
        return;
    }
    
    if([StringUtil isBlankString: confrimPwd]) {
        [self promptError: @"请输入确认密码"];
        return;
    }
    
    if(![pwd isEqualToString: confrimPwd]) {
        [self promptError: @"两次密码输入不一致,请重新输入"];
        return;
    }
    
    HttpClient *client = [HttpClient sharedInstance];
    [client POST: @"/user/register" params: @{@"username" : username, @"pwd" : pwd} success:^(NSString *url, id data) {
        [self promptError: @"注册成功" handler:^(UIAlertAction *action) {
            [self.navigationController popViewControllerAnimated: YES];
        }];
    } error:^(NSString *url, NSInteger httpCode, NSInteger bizCode, NSString *error) {
        [self promptError: error];
    }];
}

通过上面的步骤,我们已经完成了iOS客户端的开发。苹果官方默认支持的就是经典的MVC模式。因此,我们完全参考服务端开发模式完成了iOS客户端的开发。你唯一需要克服的是对新语言的恐惧,以及适应UI开发的节奏。事实上,大部分服务端程序员都害怕UI编程。

最后,我们进入Android客户端编程。

Android客户端编程

Android部分开发工具,我们使用Android Studio,网络框架使用Retrofit,完整配置参考下方表格:

IDE 编程语言 网络框架
Android Studio Java 1.8 Retrofit

打开Android Studio,选择Start a new Android Studio Project,在打开的页面中填入以下信息:



剩下步骤全部选择默认。

按照iOS编码部分类推,Android端应该也有一个类似UIViewController的控制器。果不其然,在模板工程中就有一个MainActivity,其父类是AppCompatActivity,这就是Android的控制器。

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

PS:事实上Android早期版本的控制器就叫Activity,由于系统设计不断变更,最终诞生了兼容性子类AppCompatActivity。这都是早期设计不够严谨,导致的问题。相对而言,iOS端的设计就靠谱了许多。

同样地,在开始编码之前,我们加入所需的第三方依赖。那么,问题来了。Android端如何添加依赖呢?

碰巧,Android端主要的开发语言就是Java。因此,我们依然可以使用Gradle进行依赖管理。碰巧,Android Studio默认支持的就是使用Gradle进行依赖管理。

首先,在app模块目录的build.gradle添加 Retrofit 依赖:

implementation 'com.squareup.retrofit2:retrofit:2.4.0'

添加完成后,点击文件右上方Sync now下载依赖:


相对于AFNetworking,Retrofit设计的更加精妙。参考Retrofit官方文档,我们开始加入登录注册逻辑:

public interface UserService {
    
    @FormUrlEncoded
    @POST("user/login")
    Call<User> login(@Field("username") String username, @Field("pwd") String pwd);

    @FormUrlEncoded
    @POST("user/register")
    Call<User> register(@Field("username") String username, @Field("pwd") String pwd);
}

Retrofit设计的其中一个巧妙之处在于:你只需要定义好接口,具体的实现交给Retrofit。你可以看到,上面的代码中我们仅仅定义了请求的类型,以及请求所需要的参数就已经完成了网络部分的所有工作。

不过,操作这个接口实现,需要使用Retrofit实例。接下来,我们参考官方文档生成一个我们需要的Retrofit实例。

在生成Retrofit实例之前,还需要注意一个事情。还记得iOS端我们是怎么完成JSON解析的吗?是的,我们使用了第三方库jsonmodel。

在Json解析的设计上,Retrofit也相当巧妙。Retrofit提供了一个转换适配器用于实现Json数据的自动转换。使用它,你可以自定义自己的Json转换适配器;也可以使用官方已经实现好的适配器。一旦添加了这个适配器,所有的Json解析工作Retrofit就会自动帮忙完成。不再需要像AFNetworking一样在回调里面反复进行Json解析操作。

因此,我们增加一个官方版本的Json转换适配器依赖 converter-json:

implementation 'com.squareup.retrofit2:converter-gson:2.4.0'

加入Json适配器之后,我们使用一个新的Retrofit管理类RetrofitManager用于生成项目所需要的Retrofit实例。完整代码如下:

public class RetrofitManager {
    private static final String BASE_URL = "http://192.168.31.146:8080";

    public static Retrofit create(String baseUrl) {
        return new Retrofit.Builder()
                          .baseUrl(baseUrl)
                          .addConverterFactory(GsonConverterFactory.create())
                          .build();
    }
    
    public static Retrofit createDefault() {
        return create(BASE_URL);
    }
}

接下来,我们尝试在MainActivity中测试登录接口,确定是否编写正确。我们在MainActivity的onCreate方法中加入如下代码:

Retrofit retrofit = RetrofitManager.createDefault();

UserService userService = retrofit.create(UserService.class);
Call < User > call = userService.login("1", "1");
call.enqueue(new Callback < User > () {
  
    @Override 
    public void onResponse(Call < User > call, Response < User > response) {
        Log.e("MainActivity", call + "" + response);
    }

    @Override 
    public void onFailure(Call < User > call, Throwable t) {
        Log.e("MainActivity", call + "" + t);
    }
});

打开模拟器,运行,你将看到以下错误:

03-18 04:03:24.546 7277-7277/com.youngfeng.androidclient D/NetworkSecurityConfig: No Network Security Config specified, using platform default
03-18 04:03:24.574 7277-7277/com.youngfeng.androidclient W/System.err: java.net.SocketException: Permission denied
03-18 04:03:24.578 7277-7277/com.youngfeng.androidclient W/System.err:     at java.net.Socket.createImpl(Socket.java:454)
03-18 04:03:24.578 7277-7277/com.youngfeng.androidclient W/System.err:     at java.net.Socket.getImpl(Socket.java:517)
03-18 04:03:24.578 7277-7277/com.youngfeng.androidclient W/System.err:     at java.net.Socket.setSoTimeout(Socket.java:1108)
03-18 04:03:24.578 7277-7277/com.youngfeng.androidclient W/System.err:     at okhttp3.internal.connection.RealConnection.connectSocket(RealConnection.java:238)
03-18 04:03:24.578 7277-7277/com.youngfeng.androidclient W/System.err:     at okhttp3.internal.connection.RealConnection.connect(RealConnection.java:160)
03-18 04:03:24.578 7277-7277/com.youngfeng.androidclient W/System.err:     at okhttp3.internal.connection.StreamAllocation.findConnection(StreamAllocation.java:257)
03-18 04:03:24.578 7277-7277/com.youngfeng.androidclient W/System.err:     at okhttp3.internal.connection.StreamAllocation.findHealthyConnection(StreamAllocation.java:135)
03-18 04:03:24.578 7277-7277/com.youngfeng.androidclient W/System.err:     at okhttp3.internal.connection.StreamAllocation.newStream(StreamAllocation.java:114)

提示我们权限被拒绝,这是和iOS平台不一样的地方。如果你的应用需要使用网络,你需要在清单文件中手动指定使用网络权限。为此,我们在AndroidManifest.xml文件中添加如下配置:

<uses-permission android:name="android.permission.INTERNET" />

再次运行,一切正常。

注意:这里的service部分和服务端的service不一样,它只是Retrofit用于将网络接口分模块处理的一种手段,不要混淆。

上面说到,Android里面的AppCompatActivity就是MVC中的控制器,接下来我们就完成最重要的控制器以及UI部分编码。

a)创建LoginActivity以及布局文件activity_login.xml,在其onCreate方法中使用setContentView接口进行关联。

b)UI编程
你相信吗?一旦你学会了一门新的技术,你的技能就会Double。

iOS UI部分我们使用了约束布局的方式完成了整体布局,Android是否也可以使用约束布局呢?答案是:当然可以。

事实上,Android官方也推荐使用这种布局方式进行页面布局。

切换到可视化布局模式,我们依然使用拖拽UI的方式完成整个布局,完整代码请参考文章最后的附录部分:


PS:目前,Android端的约束布局相对iOS逊色不少,希望后面官方能够提供更多功能支持。

按照同样的方式完成注册页面和首页布局,UI部分开发完成后,尝试跳转到指定控制器。你会发现,出错了。这也是和iOS不一样的地方,Android端四大组件必须在清单文件中注册。具体是什么原因,请自行思考,这不是本文研究的重点。

因此,我们首先在清单文件中对所有控制器进行注册:

    <activity android:name=".login.LoginActivity"
            android:screenOrientation="portrait">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity android:name=".MainActivity"
            android:screenOrientation="portrait"/>

        <activity android:name=".register.RegisterActivity"
            android:screenOrientation="portrait"/>

然后,以登录为例,我们在控制器中完善登录逻辑:

public class LoginActivity extends BaseActivity {
    private EditText mUsernameEdit;
    private EditText mPwdEdit;
    private Button mLoginBtn;
    private Button mRegisterBtn;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        mUsernameEdit = findViewById(R.id.edit_username);
        mPwdEdit = findViewById(R.id.edit_pwd);
        mLoginBtn = findViewById(R.id.btn_login);
        mRegisterBtn = findViewById(R.id.btn_register);

        mLoginBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                login(mUsernameEdit.getText().toString(), mPwdEdit.getText().toString());
            }
        });

        mRegisterBtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent(LoginActivity.this, RegisterActivity.class);
                startActivity(intent);
            }
        });
    }

    private void login(String username, String pwd) {
        Retrofit retrofit = RetrofitManager.createDefault();
        UserService userService = retrofit.create(UserService.class);
        Call<HttpResponse<User>> call = userService.login(username, pwd);

        showLoading(true);
        call.enqueue(new Callback<HttpResponse<User>>() {
            @Override
            public void onResponse(Call<HttpResponse<User>> call, Response<HttpResponse<User>> response) {
                showLoading(false);

                // 例子代码,暂时忽略空值判断
                if(HttpResponse.CODE_SUCCESS != response.body().getCode()) {
                    promptError(response.body().getMsg() + "");
                } else {
                    Intent intent = new Intent(LoginActivity.this, MainActivity.class);
                    intent.putExtra(MainActivity.KEY_USER, response.body().getData());
                    startActivity(intent);
                    finish();
                }
            }

            @Override
            public void onFailure(Call<HttpResponse<User>> call, Throwable t) {
                showLoading(false);

                promptError(t.getMessage() + "");
            }
        });
    }
}

至此,按照iOS的开发模式,我们完成了Android客户端的开发。与iOS不同的地方是,Android端控制器必须在清单文件中注册。程序员不能主动创建Activity,只能间接使用intent进行通信。而对于布局,两者都可以使用约束管理的方式完成。从这个角度来说,Android端和iOS端开发切换的难度还是比较低的。

距离全栈还差最后一步

至此,我们已经完成了文章开头定下的目标。以MVC架构为基础,完成了服务端、iOS客户端、Android客户端编码。

然而,很多同学希望成为一个全栈工程师。按照现在的主流开发分支来说,成为一个全栈工程师,你还需要掌握Web前端开发。那么,问题来了,Web前端开发是否也是使用MVC架构呢?

事实上,如果你使用 Angular,你应该早就习惯了MVC。而如果你偏爱React,你恐怕会搭配Redux,使用这种响应式的数据流框架编码。如果你使用Vue,你恐怕也会选择MVC或者MVVM架构。

如果你选择使用MVC,你依然可以按照类推的方式来学习。由于文章篇幅的原因,这部分就不予展示了。

编后说

这篇文章我们以MVC为架构,从服务端编程开始,使用类推的方式依次完成了iOS客户端、Android客户端的开发。

有人可能会说,文章中的例子太简单,没有实际意义。事实上,在学习一门新技术的时候,就要从最基础的部分出发,建立对这门技术的最初印象。很多同学容易一开始就陷入细节当中无法自拔,产生的最直观的结果就是对新技术产生恐惧。因此,你常常可以看到一个程序员面对新东西骂娘,无怪乎。

其实,如果你慢慢进入到细节编程中,你会发现技术之间越来越多的相似性。这个时候你的积极性就会越来越高,编码也会更加得心应手。

我在学习一门新技术的时候,都是先从相似性开始。然后,再去攻克不同的部分。从不同的部分中去提炼相同的思想,这样在面对不同问题的时候,我始终可以使用同样的思想去解决。

当然,我想,你应该会说。虽然克服了框架问题,可是不同的编程语言千差万别。我们无法从一门语言快速过渡到另外一门语言,这在学习新技术的时候才是最大的拦路虎。

你说的很对,这恰好是下一个我想和你分享的问题。关注我的简书,下一篇我们一起探讨《技术相对论之编程语言》

附录

本篇例子完整代码:https://github.com/yuanhoujun/it-theory-of-relativity
IntelliJ IDEA下载地址:https://www.jetbrains.com/idea/
Tomcat下载地址:http://tomcat.apache.org/
iOS开发者官网:https://developer.apple.com/
Android开发者官网:https://developer.android.com/index.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,825评论 4 377
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,887评论 2 308
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,425评论 0 255
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,801评论 0 224
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,252评论 3 299
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,089评论 1 226
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,216评论 2 322
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 31,005评论 0 215
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,747评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,883评论 2 255
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,354评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,694评论 3 265
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,406评论 3 246
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,222评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,996评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,242评论 2 287
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 36,017评论 2 281

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,598评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,112评论 18 139
  • 2016年9月14日 阴 跟我说话的人们 睡去吧 看我恢复清醒 好好听一席谈话 日子是沙 略过了第一层选拔 被放进...
    鲜栗子阅读 198评论 1 0
  • Edit 我的知识管理 【法律}【知识管理】 一、首先不能把目光放得过于分散,而要尽量集中到一个适合的域。 一个合...
    唐山律师老王阅读 157评论 0 0
  • 文/南木婉清 夜泊秦淮宿水乡 桂堂西畔听雨眠 青瓦乌蓬江如黛 长亭萍州入梦来
    南木婉清阅读 521评论 8 7