技术相对论之软件架构

文 | 欧阳锋

有同学问我,你是怎样学习编程的呢?为了回答你的这个问题,今天,我们一起来做一件非常有意思的事情。我们以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用于保存用户数据

1
2
3
4
5
6
7
8
9
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依赖

1
2
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文件中添加如下配置信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?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配置文件中添加如下信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?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自动解析

1
2
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 库。

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

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

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

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

b)指定数据源

b1) 添加Spring JDBC与MySQL Connector依赖

1
2
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源

1
2
3
4
repositories {
mavenCentral()
jcenter()
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<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数据库信息

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

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

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

1
2
3
4
5
6
7
8
9
10
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配置文件中添加如下配置:

1
2
3
4
<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部分我们直接使用一个类来实现了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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)控制层编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@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。没错,这就是传说中的控制器。

1
2
3
4
5
6
#import <UIKit/UIKit.h>

@interface ViewController : UIViewController


@end

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
@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文件,并添加如下内容:

1
2
3
4
5
6
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'

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

b)安装依赖

1
pod install

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
//
// 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

1
2
pod 'JSONModel'
pod 'SVProgressHUD'

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 登录部分逻辑
- (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的控制器。

1
2
3
4
5
6
7
8
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 依赖:

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

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

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

1
2
3
4
5
6
7
8
9
10
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:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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方法中加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
}
});

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

1
2
3
4
5
6
7
8
9
10
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文件中添加如下配置:

1
<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端四大组件必须在清单文件中注册。具体是什么原因,请自行思考,这不是本文研究的重点。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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"/>

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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

Android两行代码实现仿微信滑动返回效果

iPhone滑动关闭页面是一个非常讨喜的设计。滑动关闭可以让你聚焦屏幕内容,而不需要因为返回突然切换思维到屏幕下方寻找返回按钮。事实上,在使用Android手机的时候,我经常这样做。原因是,Android不同机型的返回按钮位置不一样。以至于在更换机型后我常常找不到返回按钮,需要一段时间的适应期。而滑动关闭就可以有效地避免这个问题,目前已经有很多类型的Android应用开始支持滑动关闭,比如你熟悉的微信、快手等都已经支持了滑动返回效果。使用 Snake 框架你只需要两行代码就可以搞定滑动关闭集成…

如果你还不知道Snake是什么,请关注简书下面的文章:

Snake

初体验

如果你需要在Activity中实现滑动关闭效果,使用如下两个步骤即可:

  • 在你的Application中对Snake进行初始化:Snake.init(this)
  • 在你的Activity类的onCreate方法中对其进行托管:Snake.host(this)

以上两个方法已经完成了Activity滑动关闭集成,为了开启滑动关闭功能,你还需要在Activity类顶部添加@EnableDragToClose注解

Snake设计思路

为了保证Snake框架尽可能灵活,我使用了注解实现单页固定滑动参数配置。而全局配置则使用单独的snake.xml文件进行配置。同时,为了支持动态关闭和开启,在Snake类中提供了相关API用于动态控制滑动关闭和开启。

设计目标

看过Snake官方文档的同学会发现,Snake并不提供左滑关闭或者其它方向关闭页面的设置,Snake也没有提供不同的关闭效果设置。没有这样设计的原因很简单,因为这种关闭效果并不常见,这样的设计不过是哗众取宠,浪费时间,且增加使用难度。

我的目标是:尽可能简化Snake设计,仅提供必要API,且专注于滑动关闭效果实现。

新版本来了

这是本篇文章的重点,昨天,Snake 0.3.0 版本已经发布了。

0.3.0版本主要针对Fragment提供了继承方式集成:

使用方法

按照下面的对应关系,改变你的Fragment父类就可以完成滑动关闭集成:

  • android.app.Fragment => com.youngfeng.snake.app.Fragment
  • android.support.v4.app.Fragment => com.youngfeng.snake.support.v4.app.Fragment

注意:使用继承方式集成的情况下,原来的API完全可以通用。你可以选择使用Snake的API进行滑动控制,也可以使用父类中的方法进行滑动控制,这取决于你自己。甚至实例创建你依然可以交给newProxy/newProxySupport接口。

详细信息,请查看官方文档:https://github.com/yuanhoujun/Android_Slide_To_Close

交流群

QQ群:288177681
如果你在使用Snake的过程中,遇到任何问题,请使用QQ群联系我。


我是 欧阳锋,开源的道路上,我与你同行。

是你自己选择了安逸,别怪路途太遥远

“老同学,我的工作好无聊,每天有大把的时间却不知道如何打发。我也不知道这个工作的意义在哪里?” 这是两年前我的一个高中同学给我发来的微信消息。一年后,我再次收到他发来的微信消息 “嘿,哥们!我到清远工作了”,“做运维了吗?”,“不是,还是公务员”,“…..”

他是我的铁哥们,叫赵亦然。他在大学的主修专业是计算机,莫名其妙地做了公务员。现在在利用业余时间学习系统运维。他说,他想去考红帽子认证工程师。希望可以换个工作,并详细询问了我关于深圳系统运维工程师的一些事情。可是,正如你所见,一年后,一切照旧。工作依旧,抱怨依然。

曾几何时,公务员已经成为了年轻人选择工作的第一标准,也成为了父母口中的金饭碗。而对于年轻人,选择公务员这个职业,大多都是出于无奈。当然,也不排除部分同学的确对从政有很大的兴趣。

严格来讲,这个结论也许有失偏颇。但从我周围的情况来看,结果确实是这样。不信,问问你的公务员朋友,也许你也会得到下面这样一段对话。

A:为什么你选择做公务员?
B:大学的时候,没有目标,不知道自己喜欢什么,就想着考着试试看。没想到,真考上了,就一直做下来了…

然而,很多同学在真正从事这个职业的时候就开始后悔,公务员机械式的工作方式让他们痛苦不堪。但一想到放弃,想到要放弃这安逸的工作,想到对未知的恐惧,马上就打消了这个念头。

公务员职业如此,软件行业也是如此。我是一名软件工程师,在工作中也遇到了很多类似情况的小伙伴。他们并不喜欢编程,却因为IT行业高额薪资而逼迫自己从事这个行业。

同样地,他们也是痛苦的,如果你正好处在这样一个阶段,你应该可以感同身受。而如果你恰好做了自己喜欢的工作,你不妨试想一下,让你从事一份你并不喜欢的工作是什么滋味。

拿身边的一些朋友举例,因为并不热爱编程,以至于在对话中很难听到他跟你讨论编程。平时也很少看到他在代码层面的产出,大多是朋友圈晒晒图,或者玩玩游戏,以至于编程技术始终在原地踏步。而一旦离开原有单位跳槽的时候,问题就来了。这类同学往往面临找工作困难的问题,部分同学甚至容易出现因为找工作而情绪低迷的问题。每次听到这类抱怨,我都不知道如何安慰他们。因为,这其实就是一个因果关系而已。你自己不努力,谁能帮你?

有时候,我也劝他们,换一份工作吧,去做你真正喜欢的事业。结果你应该可以想见,耳朵是听进去了,行动却没有跟上。

有人可能觉得,IT行业应该不好混,因为是技术活。这样的同学可以过得轻松自在吗?其实不然,在IT公司总有一部分同学可以独挑大梁,以至于复杂的工作他都可以帮你搞定。这部分同学就做一些“扫扫地、擦擦桌子”的工作就行了。日子不仅过得逍遥快活,还烙下个优秀员工的称号。

不仅身边的同学和朋友是这样,我的妹妹也是如此。

毕业后,她选择了在一所市里的普通小学教书。为了获得教师许可证,她几经磨难。如此艰难的求职过程,我以为她应该喜欢这份工作。可事实,却并非如此。

一天我突然收到她发来的消息 “哥,学生们太不听话了,领导们还屡次刁难我。我决定,换个工作,我想去长沙,做点其他的事情,我不太适合当老师”。为此,我给她推荐了文员或者前台或者培训机构指导老师一类的工作。我以为,一年后我应该能看到她有所举动。可事实上,当我再次见到她的时候。很明显,她已经取消了这个计划。

每年都有无数的人挣扎在痛苦工作的边缘,想要放弃吧,又害怕没有一技之长找不到工作。坚持吧,又如坐针毡、心如刀绞。看似面露微笑,其实心里在骂娘。早上起来先骂一遍自己傻逼,然后扭头坐上上班的地铁。

我非常喜欢一句话:如果你觉得很难,那是因为你在走上坡路。人对于未知的事物都会充满恐惧,以至于迈出第一步往往是最艰难的。

试想一下,如果让你每天早上8点起来慢跑一个小时,坚持1年,你能做到吗?毫无疑问,能够做到的人寥寥无几。事实上,这事并没有想象的那么困难。你不敢去做,只是因为你给自己预设了太多门槛,你害怕面对未知的事情。

挖掘本性往往是一个痛苦的过程,可顺遂本性的安逸,永远不过是原地踏步、无所作为。

有人说,有一种鸟生来就没有脚,以至于它必须不停地飞,才不至于摔死。其实,人生就是这样,停在树上休息,不如奋力往前飞,看到更美的风景。有人问我,你为什么可以坚持一直学习,你有什么动机。其实,我的动机很简单,我不想在我80岁的时候,当我的孙子问起我来的时候,我无话可说。人生多一点挫折,多一点磨难,没什么不好。

其实,你仔细算一下,人生不过3万天而已。与其一直做着自己不喜欢的工作混混度日,还不如跳出来,勇敢去做自己擅长的事情。用1万天去摸索,1万天去热爱,最后的1万天用来安享晚年。

余生可贵,请别再抱怨!是你自己选择了安逸,何必怪路途太遥远!

备注:文章并没有歧视任何职业的意思,请勿产生误解。


我是欧阳锋,你中枪了吗?那还不点个赞 ~

10分钟看懂动态代理设计模式

动态代理是Java语言中非常经典的一种设计模式,也是所有设计模式中最难理解的一种。本文将通过一个简单的例子模拟JDK动态代理实现,让你彻底明白动态代理设计模式的本质,文章中可能会涉及到一些你没有学习过的知识点或概念。如果恰好遇到了这些知识盲点,请先去学习这部分知识,再来阅读这篇文章。

什么是代理

从字面意思来看,代理比较好理解,无非就是代为处理的意思。举个例子,你在上大学的时候,总是喜欢逃课。因此,你拜托你的同学帮你答到,而自己却窝在宿舍玩游戏… 你的这个同学恰好就充当了代理的作用,代替你去上课。

是的,你没有看错,代理就是这么简单!

理解了代理的意思,你脑海中恐怕还有两个巨大的疑问:

  • 怎么实现代理模式
  • 代理模式有什么实际用途

要理解这两个问题,看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface Flyable {
void fly();
}

public class Bird implements Flyable {

@Override
public void fly() {
System.out.println("Bird is flying...");
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

很简单的一个例子,用一个随机睡眠时间模拟小鸟在空中的飞行时间。接下来问题来了,如果我要知道小鸟在天空中飞行了多久,怎么办?

有人说,很简单,在Bird->fly()方法的开头记录起始时间,在方法结束记录完成时间,两个时间相减就得到了飞行时间。

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public void fly() {
long start = System.currentTimeMillis();
System.out.println("Bird is flying...");
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}

的确,这个方法没有任何问题,接下来加大问题的难度。如果Bird这个类来自于某个SDK(或者说Jar包)提供,你无法改动源码,怎么办?

一定会有人说,我可以在调用的地方这样写:

1
2
3
4
5
6
7
public static void main(String[] args) {
Bird bird = new Bird();
long start = System.currentTimeMillis();
bird.fly();
long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}

这个方案看起来似乎没有问题,但其实你忽略了准备这些方法所需要的时间,执行一个方法,需要开辟栈内存、压栈、出栈等操作,这部分时间也是不可以忽略的。因此,这个解决方案不可行。那么,还有什么方法可以做到呢?

a)使用继承

继承是最直观的解决方案,相信你已经想到了,至少我最开始想到的解决方案就是继承。
为此,我们重新创建一个类Bird2,在Bird2中我们只做一件事情,就是调用父类的fly方法,在前后记录时间,并打印时间差:

1
2
3
4
5
6
7
8
9
10
11
12
public class Bird2 extends Bird {

@Override
public void fly() {
long start = System.currentTimeMillis();

super.fly();

long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
}

这是一种解决方案,还有一种解决方案叫做:聚合,其实也是比较容易想到的。
我们再次创建新类Bird3,在Bird3的构造方法中传入Bird实例。同时,让Bird3也实现Flyable接口,并在fly方法中调用传入的Bird实例的fly方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Bird3 implements Flyable {
private Bird bird;

public Bird3(Bird bird) {
this.bird = bird;
}

@Override
public void fly() {
long start = System.currentTimeMillis();

bird.fly();

long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
}

为了记录Bird->fly()方法的执行时间,我们在前后添加了记录时间的代码。同样地,通过这种方法我们也可以获得小鸟的飞行时间。那么,这两种方法孰优孰劣呢?咋一看,不好评判!

继续深入思考,用问题推导来解答这个问题:

问题一:如果我还需要在fly方法前后打印日志,记录飞行开始和飞行结束,怎么办?
有人说,很简单!继承Bird2并在在前后添加打印语句即可。那么,问题来了,请看问题二。

问题二:如果我需要调换执行顺序,先打印日志,再获取飞行时间,怎么办?
有人说,再新建一个类Bird4继承Bird,打印日志。再新建一个类Bird5继承Bird4,获取方法执行时间。

问题显而易见:使用继承将导致类无限制扩展,同时灵活性也无法获得保障。那么,使用 聚合 是否可以避免这个问题呢?
答案是:可以!但我们的类需要稍微改造一下。修改Bird3类,将聚合对象Bird类型修改为Flyable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Bird3 implements Flyable {
private Flyable flyable;

public Bird3(Flyable flyable) {
this.flyable = flyable;
}

@Override
public void fly() {
long start = System.currentTimeMillis();

flyable.fly();

long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
}

为了让你看的更清楚,我将Bird3更名为BirdTimeProxy,即用于获取方法执行时间的代理的意思。同时我们新建BirdLogProxy代理类用于打印日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class BirdLogProxy implements Flyable {
private Flyable flyable;

public BirdLogProxy(Flyable flyable) {
this.flyable = flyable;
}

@Override
public void fly() {
System.out.println("Bird fly start...");

flyable.fly();

System.out.println("Bird fly end...");
}
}

接下来神奇的事情发生了,如果我们需要先记录日志,再获取飞行时间,可以在调用的地方这么做:

1
2
3
4
5
6
7
public static void main(String[] args) {
Bird bird = new Bird();
BirdLogProxy p1 = new BirdLogProxy(bird);
BirdTimeProxy p2 = new BirdTimeProxy(p1);

p2.fly();
}

反过来,可以这么做:

1
2
3
4
5
6
7
public static void main(String[] args) {
Bird bird = new Bird();
BirdTimeProxy p2 = new BirdTimeProxy(bird);
BirdLogProxy p1 = new BirdLogProxy(p2);

p1.fly();
}

看到这里,有同学可能会有疑问了。虽然现象看起来,聚合可以灵活调换执行顺序。可是,为什么 聚合 可以做到,而继承不行呢。我们用一张图来解释一下:

静态代理

接下来,观察上面的类BirdTimeProxy,在它的fly方法中我们直接调用了flyable->fly()方法。换而言之,BirdTimeProxy其实代理了传入的Flyable对象,这就是典型的静态代理实现。

从表面上看,静态代理已经完美解决了我们的问题。可是,试想一下,如果我们需要计算SDK中100个方法的运行时间,同样的代码至少需要重复100次,并且创建至少100个代理类。往小了说,如果Bird类有多个方法,我们需要知道其他方法的运行时间,同样的代码也至少需要重复多次。因此,静态代理至少有以下两个局限性问题:

  • 如果同时代理多个类,依然会导致类无限制扩展
  • 如果类中有多个方法,同样的逻辑需要反复实现

那么,我们是否可以使用同一个代理类来代理任意对象呢?我们以获取方法运行时间为例,是否可以使用同一个类(例如:TimeProxy)来计算任意对象的任一方法的执行时间呢?甚至再大胆一点,代理的逻辑也可以自己指定。比如,获取方法的执行时间,打印日志,这类逻辑都可以自己指定。这就是本文重点探讨的问题,也是最难理解的部分:动态代理

动态代理

继续回到上面这个问题:是否可以使用同一个类(例如:TimeProxy)来计算任意对象的任一方法的执行时间呢。

这个部分需要一定的抽象思维,我想,你脑海中的第一个解决方案应该是使用反射。反射是用于获取已创建实例的方法或者属性,并对其进行调用或者赋值。很明显,在这里,反射解决不了问题。但是,再大胆一点,如果我们可以动态生成TimeProxy这个类,并且动态编译。然后,再通过反射创建对象并加载到内存中,不就实现了对任意对象进行代理了吗?为了防止你依然一头雾水,我们用一张图来描述接下来要做什么:

动态生成Java源文件并且排版是一个非常繁琐的工作,为了简化操作,我们使用 JavaPoet 这个第三方库帮我们生成TimeProxy的源码。希望 JavaPoet 不要成为你的负担,不理解 JavaPoet 没有关系,你只要把它当成一个Java源码生成工具使用即可。

PS:你记住,任何工具库的使用都不会太难,它是为了简化某些操作而出现的,目标是简化而不是繁琐。因此,只要你适应它的规则就轻车熟路了。

第一步:生成TimeProxy源码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Proxy {

public static Object newProxyInstance() throws IOException {
TypeSpec.Builder typeSpecBuilder = TypeSpec.classBuilder("TimeProxy")
.addSuperinterface(Flyable.class);

FieldSpec fieldSpec = FieldSpec.builder(Flyable.class, "flyable", Modifier.PRIVATE).build();
typeSpecBuilder.addField(fieldSpec);

MethodSpec constructorMethodSpec = MethodSpec.constructorBuilder()
.addModifiers(Modifier.PUBLIC)
.addParameter(Flyable.class, "flyable")
.addStatement("this.flyable = flyable")
.build();
typeSpecBuilder.addMethod(constructorMethodSpec);

Method[] methods = Flyable.class.getDeclaredMethods();
for (Method method : methods) {
MethodSpec methodSpec = MethodSpec.methodBuilder(method.getName())
.addModifiers(Modifier.PUBLIC)
.addAnnotation(Override.class)
.returns(method.getReturnType())
.addStatement("long start = $T.currentTimeMillis()", System.class)
.addCode("\n")
.addStatement("this.flyable." + method.getName() + "()")
.addCode("\n")
.addStatement("long end = $T.currentTimeMillis()", System.class)
.addStatement("$T.out.println(\"Fly Time =\" + (end - start))", System.class)
.build();
typeSpecBuilder.addMethod(methodSpec);
}

JavaFile javaFile = JavaFile.builder("com.youngfeng.proxy", typeSpecBuilder.build()).build();
// 为了看的更清楚,我将源码文件生成到桌面
javaFile.writeTo(new File("/Users/ouyangfeng/Desktop/"));

return null;
}

}

在main方法中调用Proxy.newProxyInstance(),你将看到桌面已经生成了TimeProxy.java文件,生成的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.youngfeng.proxy;

import java.lang.Override;
import java.lang.System;

class TimeProxy implements Flyable {
private Flyable flyable;

public TimeProxy(Flyable flyable) {
this.flyable = flyable;
}

@Override
public void fly() {
long start = System.currentTimeMillis();

this.flyable.fly();

long end = System.currentTimeMillis();
System.out.println("Fly Time =" + (end - start));
}
}
第二步:编译TimeProxy源码

编译TimeProxy源码我们直接使用JDK提供的编译工具即可,为了使你看起来更清晰,我使用一个新的辅助类来完成编译操作:

1
2
3
4
5
6
7
8
9
10
11
public class JavaCompiler {

public static void compile(File javaFile) throws IOException {
javax.tools.JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager = javaCompiler.getStandardFileManager(null, null, null);
Iterable iterable = fileManager.getJavaFileObjects(javaFile);
javax.tools.JavaCompiler.CompilationTask task = javaCompiler.getTask(null, fileManager, null, null, null, iterable);
task.call();
fileManager.close();
}
}

在Proxy->newProxyInstance()方法中调用该方法,编译顺利完成:

1
2
3
4
5
6
// 为了看的更清楚,我将源码文件生成到桌面
String sourcePath = "/Users/ouyangfeng/Desktop/";
javaFile.writeTo(new File(sourcePath));

// 编译
JavaCompiler.compile(new File(sourcePath + "/com/youngfeng/proxy/TimeProxy.java"));

第三步:加载到内存中并创建对象
1
2
3
4
5
6
URL[] urls = new URL[] {new URL("file:/" + sourcePath)};
URLClassLoader classLoader = new URLClassLoader(urls);
Class clazz = classLoader.loadClass("com.youngfeng.proxy.TimeProxy");
Constructor constructor = clazz.getConstructor(Flyable.class);
Flyable flyable = (Flyable) constructor.newInstance(new Bird());
flyable.fly();

通过以上三个步骤,我们至少解决了下面两个问题:

  • 不再需要手动创建TimeProxy
  • 可以代理任意实现了Flyable接口的类对象,并获取接口方法的执行时间

可是,说好的任意对象呢?

第四步:增加InvocationHandler接口

查看Proxy->newProxyInstance()的源码,代理类继承的接口我们是写死的,为了增加灵活性,我们将接口类型作为参数传入:

接口的灵活性问题解决了,TimeProxy的局限性依然存在,它只能用于获取方法的执行时间,而如果要在方法执行前后打印日志则需要重新创建一个代理类,显然这是不妥的!

为了增加控制的灵活性,我们考虑针将代理的处理逻辑也抽离出来(这里的处理就是打印方法的执行时间)。新增InvocationHandler接口,用于处理自定义逻辑:

1
2
3
public interface InvocationHandler {
void invoke(Object proxy, Method method, Object[] args);
}

想象一下,如果客户程序员需要对代理类进行自定义的处理,只要实现该接口,并在invoke方法中进行相应的处理即可。这里我们在接口中设置了三个参数(其实也是为了和JDK源码保持一致):

  • proxy => 这个参数指定动态生成的代理类,这里是TimeProxy
  • method => 这个参数表示传入接口中的所有Method对象
  • args => 这个参数对应当前method方法中的参数

引入了InvocationHandler接口之后,我们的调用顺序应该变成了这样:

1
2
3
4
5
MyInvocationHandler handler = new MyInvocationHandler();
Flyable proxy = Proxy.newProxyInstance(Flyable.class, handler);
proxy.fly();

方法执行流:proxy.fly() => handler.invoke()

为此,我们需要在Proxy.newProxyInstance()方法中做如下改动:

  • 在newProxyInstance方法中传入InvocationHandler
  • 在生成的代理类中增加成员变量handler
  • 在生成的代理类方法中,调用invoke方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
 public static Object newProxyInstance(Class inf, InvocationHandler handler) throws Exception {
TypeSpec.Builder typeSpecBuilder = TypeSpec.classBuilder("TimeProxy")
.addModifiers(Modifier.PUBLIC)
.addSuperinterface(inf);

FieldSpec fieldSpec = FieldSpec.builder(InvocationHandler.class, "handler", Modifier.PRIVATE).build();
typeSpecBuilder.addField(fieldSpec);

MethodSpec constructorMethodSpec = MethodSpec.constructorBuilder()
.addModifiers(Modifier.PUBLIC)
.addParameter(InvocationHandler.class, "handler")
.addStatement("this.handler = handler")
.build();

typeSpecBuilder.addMethod(constructorMethodSpec);

Method[] methods = inf.getDeclaredMethods();
for (Method method : methods) {
MethodSpec methodSpec = MethodSpec.methodBuilder(method.getName())
.addModifiers(Modifier.PUBLIC)
.addAnnotation(Override.class)
.returns(method.getReturnType())
.addCode("try {\n")
.addStatement("\t$T method = " + inf.getName() + ".class.getMethod(\"" + method.getName() + "\")", Method.class)
// 为了简单起见,这里参数直接写死为空
.addStatement("\tthis.handler.invoke(this, method, null)")
.addCode("} catch(Exception e) {\n")
.addCode("\te.printStackTrace();\n")
.addCode("}\n")
.build();
typeSpecBuilder.addMethod(methodSpec);
}

JavaFile javaFile = JavaFile.builder("com.youngfeng.proxy", typeSpecBuilder.build()).build();
// 为了看的更清楚,我将源码文件生成到桌面
String sourcePath = "/Users/ouyangfeng/Desktop/";
javaFile.writeTo(new File(sourcePath));

// 编译
JavaCompiler.compile(new File(sourcePath + "/com/youngfeng/proxy/TimeProxy.java"));

// 使用反射load到内存
URL[] urls = new URL[] {new URL("file:" + sourcePath)};
URLClassLoader classLoader = new URLClassLoader(urls);
Class clazz = classLoader.loadClass("com.youngfeng.proxy.TimeProxy");
Constructor constructor = clazz.getConstructor(InvocationHandler.class);
Object obj = constructor.newInstance(handler);

return obj;
}

上面的代码你可能看起来比较吃力,我们直接调用该方法,查看最后生成的源码。在main方法中测试newProxyInstance查看生成的TimeProxy源码:

测试代码

1
Proxy.newProxyInstance(Flyable.class, new MyInvocationHandler(new Bird()));

生成的TimeProxy.java源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.youngfeng.proxy;

import java.lang.Override;
import java.lang.reflect.Method;

public class TimeProxy implements Flyable {
private InvocationHandler handler;

public TimeProxy(InvocationHandler handler) {
this.handler = handler;
}

@Override
public void fly() {
try {
Method method = com.youngfeng.proxy.Flyable.class.getMethod("fly");
this.handler.invoke(this, method, null);
} catch(Exception e) {
e.printStackTrace();
}
}
}

MyInvocationHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MyInvocationHandler implements InvocationHandler {
private Bird bird;

public MyInvocationHandler(Bird bird) {
this.bird = bird;
}

@Override
public void invoke(Object proxy, Method method, Object[] args) {
long start = System.currentTimeMillis();

try {
method.invoke(bird, new Object[] {});
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}

long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
}

至此,整个方法栈的调用栈变成了这样:

看到这里,估计很多同学已经晕了,在静态代理部分,我们在代理类中传入了被代理对象。可是,使用newProxyInstance生成动态代理对象的时候,我们居然不再需要传入被代理对象了。我们传入了的实际对象是InvocationHandler实现类的实例,这看起来有点像生成了InvocationHandler的代理对象,在动态生成的代理类的任意方法中都会间接调用InvocationHandler->invoke(proxy, method, args)方法。

其实的确是这样。TimeProxy真正代理的对象就是InvocationHandler,不过这里设计的巧妙之处在于,InvocationHandler是一个接口,真正的实现由用户指定。另外,在每一个方法执行的时候,invoke方法都会被调用 ,这个时候如果你需要对某个方法进行自定义逻辑处理,可以根据method的特征信息进行判断分别处理。

如何使用

上面这段解释是告诉你在执行Proxy->newProxyInstance方法的时候真正发生的事情,而在实际使用过程中你完全可以忘掉上面的解释。按照设计者的初衷,我们做如下简单归纳:

  • Proxy->newProxyInstance(infs, handler) 用于生成代理对象
  • InvocationHandler:这个接口主要用于自定义代理逻辑处理
  • 为了完成对被代理对象的方法拦截,我们需要在InvocationHandler对象中传入被代理对象实例。

查看上面的代码,你可以看到我将Bird实例已经传入到了MyInvocationHandler中,原因就是第三点。

这样设计有什么好处呢?有人说,我们大费周章,饶了一大圈,最终变成了这个样子,到底图什么呢?

想象一下,到此为止,如果我们还需要对其它任意对象进行代理,是否还需要改动newProxyInstance方法的源码,答案是:完全不需要!

只要你在newProxyInstance方法中指定代理需要实现的接口,指定用于自定义处理的InvocationHandler对象,整个代理的逻辑处理都在你自定义的InvocationHandler实现类中进行处理。至此,而我们终于可以从不断地写代理类用于实现自定义逻辑的重复工作中解放出来了,从此需要做什么,交给InvocationHandler。

事实上,我们之前给自己定下的目标“使用同一个类来计算任意对象的任一方法的执行时间”已经实现了。严格来说,是我们超额完成了任务,TimeProxy不仅可以计算方法执行的时间,也可以打印方法执行日志,这完全取决于你的InvocationHandler接口实现。因此,这里取名为TimeProxy其实已经不合适了。我们可以修改为和JDK命名一致,即$Proxy0,感兴趣的同学请自行实践,本篇文章的代码将放到我的Github仓库,文章结尾会给出代码地址。

JDK实现揭秘

通过上面的这些步骤,我们完成了一个简易的仿JDK实现的动态代理逻辑。接下来,我们一起来看一看JDK实现的动态代理和我们到底有什么不同。

Proxy.java

InvocationHandler

可以看到,官方版本Proxy类提供的方法多一些,而我们主要使用的接口newProxyInstance参数也和我们设计的不太一样。这里给大家简单解释一下,每个参数的意义:

  • Classloader:类加载器,你可以使用自定义的类加载器,我们的实现版本为了简化,直接在代码中写死了Classloader。
  • Class<?>[]:第二个参数也和我们的实现版本不一致,这个其实很容易理解,我们应该允许我们自己实现的代理类同时实现多个接口。前面设计只传入一个接口,只是为了简化实现,让你专注核心逻辑实现而已。

最后一个参数就不用说了,和我们实现的版本完全是一样的。

仔细观察官方版本的InvocationHandler,它和我们自己的实现的版本也有一个细微的差别:官方版本invoke方法有返回值,而我们的版本中是没有返回值的。那么,返回值到底有什么作用呢?直接来看官方文档:

核心思想:这里的返回值类型必须和传入接口的返回值类型一致,或者与其封装对象的类型一致。

遗憾的是,这里并没有说明返回值的用途,其实这里稍微发挥一下想象力就知道了。在我们的版本实现中,Flyable接口的所有方法都是没有返回值的,问题是,如果有返回值呢?是的,你没有猜错,这里的invoke方法对应的就是传入接口中方法的返回值。

答疑解惑

invoke方法的第一个参数proxy到底有什么作用?

这个问题其实也好理解,如果你的接口中有方法需要返回自身,如果在invoke中没有传入这个参数,将导致实例无法正常返回。在这种场景中,proxy的用途就表现出来了。简单来说,这其实就是最近非常火的链式编程的一种应用实现。

动态代理到底有什么用?

学习任何一门技术,一定要问一问自己,这到底有什么用。其实,在这篇文章的讲解过程中,我们已经说出了它的主要用途。你发现没,使用动态代理我们居然可以在不改变源码的情况下,直接在方法中插入自定义逻辑。这有点不太符合我们的一条线走到底的编程逻辑,这种编程模型有一个专业名称叫 AOP。所谓的AOP,就像刀一样,抓住时机,趁机插入。

基于这样一种动态特性,我们可以用它做很多事情,例如:

  • 事务提交或回退(Web开发中很常见)
  • 权限管理
  • 自定义缓存逻辑处理
  • SDK Bug修复

如果你阅读过 Android_Slide_To_Close 的源码会发现,它也在某个地方使用了动态代理设计模式。

总结

到此为止,关于动态代理的所有讲解已经结束了,原谅我使用了一个诱导性的标题“骗”你进来阅读这篇文章。如果你不是一个久经沙场的“老司机”,10分钟完全看懂动态代理设计模式还是有一定难度的。但即使没有看懂也没关系,如果你在第一次阅读完这篇文章后依然一头雾水,就不妨再仔细阅读一次。在阅读的过程中,一定要跟着文章思路去敲代码。反反复复,一定会看懂的。我在刚刚学习动态代理设计模式的时候就反复看了不下5遍,并且亲自敲代码实践了多次。

为了让你少走弯路,我认为看懂这篇文章,你至少需要学习以下知识点:

  • 至少已经理解了面向对象语言的多态特性
  • 了解简单的反射用法
  • 会简单使用 JavaPoet 生成Java源码

如果你在阅读文章的过程中,有任何不理解的问题或者建议,欢迎在文章下方留言告诉我!

本篇文章例子代码:https://github.com/yuanhoujun/java-dynamic-proxy


我是欧阳锋,设计模式是一种非常好的编程指导模型,它在所有编程语言中是通用的,并且是亘古不变的。我建议你在这个方面多下苦功,不要纠结在一些重复的劳动中,活用设计模式会让你的代码更显灵动。想要了解我吗?看这里:欧阳锋档案馆

欧阳锋档案馆

你好,欢迎来到欧阳锋档案馆!你可能已经看过了我的一些文章,但对应欧阳锋工作室可能依然比较陌生。这篇文章主要记录当前欧阳锋工作室的主要关注渠道,以及最新的一些进展。

文 | 欧阳锋

关注渠道

官方网站:http://www.youngfeng.com

微信公众号:欧阳锋工作室(微信号:OuyangfengOffice)

新浪微博:欧阳锋工作室

GitHub:https://github.com/yuanhoujun

简书:https://www.jianshu.com/u/db019edd34b4

交流群

iOS交流群:468167089
Kotlin语言交流群:329673958


我是欧阳锋,我期待着与你的邂逅 <<

2017年终总结

Yes, I'm ready

2017年对于我来说,是收获的一年。这一年,我放弃了一份稳定的工作,去尝试了一份生死未卜的事业。我以为我会后悔,相反,我很庆幸这个决定。虽然,失去了稳定,却在跌宕起伏中收获了不一样的精彩…

这一年,我在公司主要负责移动部门技术管理工作。移动部门技术同学水平参差不齐、管理涣散是前期遇到的最大的一个问题。为此,我制定了一个简单的计划:

  • 定期工作汇报
  • 技术培训
  • 代码Review
  • 定期会议

定期工作汇报

定期工作汇报的效果是显著的,在汇报工作的过程中,通过互相对比,彼此会看到自己的不足,加以改进。在工作汇报的设计上,我没有采用日报的形式,我认为日报有点太过机械化了,甚至有可能导致一定的负面情绪。因此,我把工作汇报集中在周二和周四,仅仅两天的时间既不会太紧凑,也不会太松散。虽然工作汇报在团队中形成了一个良好的氛围,但因为技术实力薄弱带来的问题依然暴露了很多。以至于在团队中出现了一些负面情绪,前端同学在抱怨后端开发,后端开发也在推责前端,气氛一度紧张。在这样一种情形下,我认为技术培训工作已经迫在眉睫了。

技术培训

技术培训工作是这一年最用心的工作之一,培训的核心主要集中在Kotlin语言。团队中几乎所有的同学对Kotlin语言都是陌生的,仅仅停留在最基础的用法上面,这显然不行!针对这个问题,我将培训内容集中在Kotlin重难点攻克上面。

回顾过去一年,我大约进行了十多次技术培训,内容包括:

  • Kotlin基础知识
  • Kotlin难点攻克
  • Git基础用法(针对公司全员)
  • Android常见问题解决方案
  • 从Swift看Kotlin
  • 基础网络知识
  • 基础调试技巧培训(adb,gradle,Linux)

其中一部分内容是作为技术分享课程进行的,准备这些课程其实并不容易。但依然有极少数同学对于培训课程不屑一顾,即使在课堂上再三强调过的问题,在实际开发中依然屡屡再犯。为此,在技术层面上,我想我还需要做更多的工作。

Code Review

与往常不一样,这一次我决定使用Pull Request的方式合并代码。这样,我可以对每一次提交进行Code Review。每一次的Pull Request我都会逐行检查,并且会对每一次的打回整理一个完整的文档描述问题出在哪里。

不得不说,Code Review的效果是显著的,团队中一些代码风格很差的同学。经过几次代码被打回的洗礼之后,代码风格有了很大的改善。然而,遗憾的是,后期由于工作过于繁忙,这个部分的工作也慢慢取消了。

整体而言,Code Review的确带动了团队整体编码水平的提升。编码能力提高之后,问题减少了许多。不过,由于前期遗留的一些老问题,导致部分低级错误依然在犯,真是让人心力交瘁。与此同时,团队中另外一个问题也慢慢暴露了出来。你可以很明显地感觉到,团队整体的凝聚力、核心价值观是缺失的,以至于针对同一个问题每个人的聚焦点完全不一样。部分同学采取“当一天和尚撞一天钟”的做法。因此,我认为核心价值观的建设必须尽快搞起来。

定期会议

为了建立团队核心价值观,我决定定期开展一些会议。然而,由于时间的关系,仅组织了几场会议。但在每次例会结束的时候,我都会做一些核心价值观引导。我一直认为,一个没有核心价值观的公司是一个不完整的公司。这有点类似于“精神出轨”和“肉体出轨”,两者都非常可怕,在IT公司,“精神出轨”的最终症状往往就是“肉体出轨”。但核心价值观的建设是一个漫长的过程,每次会议开完,总感觉收效甚微。内心就像嚼一块过期的臭豆腐,五味杂陈。为此,我改变了策略,决定先带动部分同学,再通过部分同学带动其他同学。有点像旧社会的“让一部分人先富起来”的感觉。这一部分的工作真是举步维艰,不过,好在最后的结果还不错。最终,大部分同学都形成了统一的价值观。

以上,是过去一年我在工作上所做的一些尝试,其实都是一些老生常谈的套路,但实践下来的效果其实还不错。

回首2017年,的确是完全献给工作的一年。这一年,我常常奋战到深夜,即使在周末,也是如此。这一年,我几乎没有出去旅游,甚至于几公里外的徒步都没有。相对往年,时间显得更加稀缺,但与此同时带来的回报也是双倍的。在团队管理上,我有了新的见解;在技术上,我也有了新的突破;在人际关系处理上,我也有了新的进步。

伴随收获随之而来的就是遗憾,这一年主要有两个遗憾。第一个遗憾,对于团队,始终感觉有些亏欠。这一年,我给团队的关键词是“技术”。因此,在团队感情集结、团队建设方面做的工作太少。以至于直到年尾,大家对彼此依然有些陌生,这不得不说是一个很大的遗憾。第二个遗憾,是对自己,17年我给自己定的关键词是“运动”。然而,由于工作的繁忙,锻炼并没有按计划进行,以至于体质一再下降,这不得不说又是一个遗憾,这一部分希望2018年能够及时弥补上来。

明天就是农历2018年了,突然意识到,我已经三十岁了,虽然我很不愿意承认。三十而立,我希望自己可以有一些新的突破。因此,我给自己的2018预设了几个目标,希望在2019年之前可以顺利完成。

最后,感谢所有陪伴我度过2017年的小伙伴们,2018年我会更加努力,让你看到一个更优秀的欧阳锋。这一年我给自己定的年度关键字是“稳”,无论是在事业上,还是在身体上,亦或者是其它方面,都希望可以稳扎稳打、稳步向前。

我是欧阳锋,即使而立之年,我依然相信,只要坚持做一件事情,就一定会成功。我始终偏爱那些无畏困难、不问前程、坚持不懈的人们。我始终怀揣着梦想,2018年我会从“新”出发。2018,祝福正在看文章的你,也能高挂云帆,傲视沧海。

遇见欧阳锋,希望你没有后悔,2018年再见。

Kotlin 操作符重载及中缀调用

操作符重载其实很有意思!但这个概念却很少有人知道,使用操作符重载在某种程度上会给代码的阅读带来一定的麻烦。因此,慎用操作符被认为是一个好习惯。的确,操作符重载是一把双刃剑,既能削铁如泥,也能“引火烧身”,这篇文章将从实用的角度来讲解操作符重载的基本用法。

支持重载的操作符类型

Kotlin语言支持重载的操作符类型比较多。以最新版本1.2.21为准,目前支持重载的操作符可以归纳为以下几类:

一元操作符

一元前缀操作符

操作符 对应方法
+a a.unaryPlus()
-a a.unaryMinus()
!a a.not()

以上三个操作符在日常使用中频率很高,第一个操作符在基本运算中很少使用,第二个操作符就是常见的取反操作,第三个操作符是逻辑取反操作。接下来,我们使用扩展的方式重载这三个操作符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 一元操作符
*
* @author Scott Smith 2018-02-03 14:11
*/
data class Number(var value: Int)

/**
* 重载一元操作符+,使其对Number中实际数据取绝对值
*/
operator fun Number.unaryPlus(): Number {
this.value = Math.abs(value)
return this
}

/**
* 重载一元操作符-,使其对Number中实际数据取反
*/
operator fun Number.unaryMinus(): Number {
this.value = -value
return this
}

/**
* 这个操作符通常是用于逻辑取反,这里用一个没有意义的操作,来模拟重载这个操作符
* 结果:始终返回Number中实际数据的负值
*/
operator fun Number.not(): Number {
this.value = -Math.abs(value)
return this
}

fun main(args: Array<String>) {
val number = Number(-3)
println("Number value = ${number.value}")
println("After unaryPlus: Number value = ${(+number).value}")
println("After unaryMinus: Number value = ${(-number).value}")

number.value = Math.abs(number.value)
println("After unaryNot: Number value = ${(!number).value}")
}

运行上述代码,将得到如下结果:

1
2
3
4
Number value = -3
After unaryPlus: Number value = 3
After unaryMinus: Number value = -3
After unaryNot: Number value = -3

自增和自减操作符

操作符 对应方法
a++/++a a.inc()
a–/–a a.dec()

重载这个操作符相对比较难理解,官方文档有一段简短的文字解释,翻译成代码可以这样表示:

1
2
3
4
5
6
7
8
9
10
11
12
// a++
fun increment(a: Int): Int {
val a0 = a
a = a + 1
return a0
}

// ++a
fun increment(a: Int): Int {
a = a + 1
return a
}

看懂上面的代码后,我们换成需要重载的Number类,Kotlin最终会这样处理:

1
2
3
4
5
6
7
8
9
10
11
// Number++
fun increment(number: Number): Number {
val temp = number
val result = number.inc()
return result
}

// Number++
fun increment(number: Number): Number {
return number.inc()
}

因此,重载Number类自加操作符,我们可以这样做:

1
2
3
operator fun Number.inc(): Number {
return Number(this.value + 1)
}

重载自减操作符同理,完整代码请参考我的Git版本库:kotlin-samples

二元操作符

算术运算符

操作符 对应方法
a + b a.plus(b)
a - b a.minus(b)
a * b a.times(b)
a / b a.div(b)
a % b a.rem(b)
a..b a.rangeTo(b)

前5个操作符相对比较好理解,我们以a + b为例,举个一个简单的例子:

1
2
3
4
5
6
7
8
9
10
// 重载Number类的加法运算符
operator fun Number.plus(value: Int): Number {
return Number(this.value + value)
}

fun main(args: Array<String>) {
println((Number(1) + 2))
}
// 输出结果:
Number value = 3

相对比较难理解的是第六个范围运算符,这个操作符主要用于生成一段数据范围。我们认为Number本身就代表一个整型数字,因此,重载Number是一件有意义的事情。直接看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
operator fun Number.rangeTo(to: Number): IntRange {
return this.value..to.value
}

fun main(args: Array<String>) {
val startNumber = Number(3)
val endNumber = Number(9)

(startNumber..endNumber).forEach {
println("value = $it")
}
}

// 运行结果:
value = 3
value = 4
value = 5
value = 6
value = 7
value = 8
value = 9

“In”运算符

操作符 对应方法
a in b b.contains(a)
a !in b !b.contains(a)

这个操作符相对比较好理解,重载这个操作符可以用于判断某个数据是否在另外一个对象中。我们用一个非常简单的自定义类来模拟集合操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class IntCollection { 
val intList = ArrayList<Int>()
}

// 重载"in"操作符
operator fun IntCollection.contains(value: Int): Boolean {
return this.intList.contains(value)
}

fun main(args: Array<String>) {
val intCollection = IntCollection()
intCollection.add(1, 2, 3)
println(3 in intCollection)
}

// 输出结果:
true

索引访问运算符

操作符 对应方法
a[i] a.get(i)
a[i, j] a.get(i, j)
a[i_1, …, i_n] a.get(i_1, …, i_n)
a[i] = b a.set(i, b)
a[i, j] = b a.set(i, j, b)
a[i_1, …, i_n] = b a.set(i_1, …, i_n, b)

这个操作符很有意思,例如,如果你要访问Map中某个数据,通常是这样的map.get("key"),使用索引运算符你还可以这样操作:

1
val value = map["key"]

我们继续以IntCollection类为例,尝试重写a[i]a[i] = b两个运算符,其它运算符同理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 重载a[i]操作符
operator fun IntCollection.get(index: Int): Int {
return intList[index]
}

// 重载a[i] = b操作符
operator fun IntCollection.set(index: Int, value: Int) {
intList[index] = value
}

fun main(args: Array<String>) {
val intCollection = IntCollection()
intCollection.add(1, 2, 3)
println(intCollection[0])

intCollection[2] = 4
print(intCollection[2])
}

接下来,我们用索引运算符来做一点更有意思的事情!新建一个普通的KotlinUser

1
2
3
4
class User(var name: String,
var age: Int) {

}

使用下面的方式重载索引运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
operator fun User.get(key: String): Any? {
when(key) {
"name" -> {
return this.name
}
"age" -> {
return this.age
}
}

return null
}

operator fun User.set(key: String, value:Any?) {
when(key) {
"name" -> {
name = value as? String
}
"age" -> {
age = value as? Int
}
}
}

接下来,你会神奇地发现,一个普通的Kotlin类居然也可以使用索引运算符对成员变量进行操作了,是不是很神奇?

1
2
3
4
5
6
fun main(args: Array<String>) {
val user = User("Scott Smith", 18)
println(user["name"])
user["age"] = 22
println(user["age"])
}

因此,索引运算符不仅仅可以对集合类数据进行操作,对一个普通的Kotlin类也可以发挥同样的作用。如果你脑洞足够大,你还可以发现更多更神奇的玩法。

调用操作符

操作符 对应方法
a() a.invoke()
a(i) a.invoke(i)
a(i, j) a.invoke(i, j)
a(i_1, ……, i_n) a.invoke(i_1, ……, i_n)

重载这个操作符并不难,理解它的应用场景却有一定的难度。为了理解它的应用场景,我们来举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class JsonParser {

}

operator fun JsonParser.invoke(json: String): Map<String, Any> {
val map = Json.parse(json)
...
return map
}

// 可以这样调用
val parser = JsonParser()
val map = parser("{name: \"Scott Smith\"}")

这里的调用有点像省略了一个解析Json数据的方法,难道它仅仅就是这个作用吗?是的,调用操作符其实就这一个作用。如果一个Kotlin类仅仅只有一个方法,直接使用括号调用的确是一个不错的主意。不过,在使用的时候还是要稍微注意一下,避免出现歧义。

广义赋值操作符

操作符 对应方法
a += b a.plusAssign(b)
a -= b a.minusAssign(b)
a *= b a.timesAssign(b)
a /= b a.divAssign(b)
a %= b a.remAssign(b)

这个操作符相对比较好理解,我们以Number类为例,举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 广义赋值运算符
operator fun Number.plusAssign(value: Int) {
this.value += value
}

fun main(args: Array<String>) {
val number = Number(1)
number += 2
println(number)
}

// 输出结果:
Number value = 3

相等与不等操作符

操作符 对应方法
a == b a?.equals(b) ?: (b === null)
a != b !(a?.equals(b) ?: (b === null))

重载这个操作符与Java重写equals方法是一样的。不过,这里要注意与Java的区别,在Java端==用于判断两个对象是否是同一对象(指针级别)。而在Kotlin语言中,如果我们不做任何处理,==等同于使用Java对象的equals方法判断两个对象是否相等。

另外,这里还有一种特殊情况,如果左值等于null,这个时候a?.equals(b)将返回null值。因此,这里还增加了?:运算符用于进一步判断,在这个情况下,当且仅当b === null的时候,a、b才有可能相等。因此,才有了上面的对应关系,这里以User类为例举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
class User(var name: String?,
var age: Int?) {

operator override fun equals(other: Any?): Boolean {
if(other is User) {
return (this.name == other.name) && (this.age == other.age)
}
return false
}
}

注意:这里有一个特殊的地方,与其它操作符不一样的地方是,如果使用扩展的方式尝试重载该操作符,将会报错。因此,如果要重载该操作符,一定要在类中进行重写。

比较操作符

操作符 对应方法
a > b a.compareTo(b) > 0
a < b a.compareTo(b) < 0
a >= b a.compareTo(b) >= 0
a <= b a.compareTo(b) <= 0

比较操作符是一个在日常使用中频率非常高的操作符,重载这个操作符只需要掌握以上表格中几个规则即可。我们以Number类为例举一个简单的例子:

1
2
3
operator fun Number.compareTo(number: Number): Int {
return this.value - number.value
}

属性委托操作符

属性委托操作符是一种非常特殊的操作符,其主要用在代理属性中。关于Kotlin代理的知识,如果你还不了解的话,请参考这篇文章
Delegation。这篇文章介绍的相对简略,后面会出一篇更详细的文章介绍代理相关的知识。

中缀调用

看到这里,可能有一些追求更高级玩法的同学会问:Kotlin支持自定义操作符吗?

答案当然是:不能!不过,别失望,infix也许适合你,它其实可以看做一种自定义操作符的实现。这里我们对集合List新增一个扩展方法intersection用于获取两个集合的交集:

1
2
3
4
5
6
7
8
9
10
11
// 获取两个集合的交集
fun <E> List<E>.interSection(other: List<E>): List<E> {
val result = ArrayList<E>()
forEach {
if(other.contains(it)) {
result.add(it)
}
}

return result
}

接下来,我们就可以在List及其子类中使用点语法调用了。但,它看起来仍然不像一个操作符。为了让它更像一个操作符,我们继续做点事情:

  • 添加infix关键词
  • 将函数名修改为∩(这是数学上获取交集的标记符号)
    然而,万万没想到,修改完成后居然报错了。Kotlin并不允许直接使用特殊符号作为函数名开头。因此,我们取形近的字母n用于表示函数名:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 获取两个集合的交集
    infix fun <E> List<E>.n(other: List<E>): List<E> {
    val result = ArrayList<E>()
    forEach {
    if(other.contains(it)) {
    result.add(it)
    }
    }

    return result
    }

接下来,我们就可以这样调用了val interSection = list1 n list2,怎么样?是不是很像自定义了一个获取交集的操作符n?如果你希望自定义操作符,可以尝试这么做。

其实infix的应用场景还不止这些,接下来,我们再用它完成一件更有意思的事情。

在实际项目开发中,数据库数据到对象的处理是一件繁琐的过程,最麻烦的地方莫过于思维的转换。那我们是否可以在代码中直接使用SQL语句查询对象数据呢?例如这样:

1
val users = Select * from User where age > 18

纸上学来终觉浅,觉知此事需躬行。有了这个idea,接下来,我们就朝着这个目标努力。
一、先声明一个Sql类,准备如下方法:

1
2
3
4
5
6
7
infix fun select(columnBuilder: ColumnBuilder): Sql {

infix fun from(entityClass: Class<*>): Sql

infix fun where(condition: String): Sql

fun <T> query(): T

二、我们的目的是:最终转换到SQL语句形式。因此,增加如下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class ColumnBuilder(var columns: Array<out String>) {

}

class Sql private constructor() {
var columns = emptyList<String>()
var entityClass: Class<*>? = null
var condition: String? = null

companion object {
fun get(): Sql {
return Sql()
}
}

infix fun select(columnBuilder: ColumnBuilder): Sql {
this.columns = columnBuilder.columns.asList()
return this
}

infix fun from(entityClass: Class<*>): Sql {
this.entityClass = entityClass
return this
}

infix fun where(condition: String): Sql {
this.condition = condition
return this
}

fun <T> query(): T {
// 此处省略所有条件判断
val sqlBuilder = StringBuilder("select ")

val columnBuilder = StringBuilder("")
if(columns.size == 1 && columns[0] == "*") {
columnBuilder.append("*")
} else {
columns.forEach {
columnBuilder.append(it).append(",")
}
columnBuilder.delete(columns.size - 1, columns.size)
}

val sql = sqlBuilder.append(columnBuilder.toString())
.append(" from ${entityClass?.simpleName} where ")
.append(condition)
.toString()
println("执行SQL查询:$sql")

return execute(sql)
}

private fun <T> execute(sql: String): T {
// 仅仅用于测试
return Any() as T
}
}

三、为了看起来更形似,再增加如下两个方法:

1
2
3
4
5
6
7
8
9
// 使其看起来像在数据库作用域中执行
fun database(init: Sql.()->Unit) {
init.invoke(Sql.get())
}

// 因为infix限制,参数不能直接使用可变参数。因此,我们增加这个方法使参数组装看起来更自然
fun columns(vararg columns: String): ColumnBuilder {
return ColumnBuilder(columns)
}

接下来,就是见证奇迹的时刻!

1
2
3
4
5
6
7
8
fun main(args: Array<String>) {
database {
(select (columns("*")) from User::class.java where "age > 18").query()
}
}

// 输出结果:
执行SQL查询:select * from User where age > 18

为了方便大家查看,我们提取完整执行代码段与SQL语句对比:

1
2
select          *       from User             where  age > 18
select (columns("*")) from User::class.java where "age > 18"

神奇吗?
至此,我们就可以直接在代码中愉快地使用类似SQL语句的方式进行方法调用了。

总结

本篇文章从操作符重载实用的角度讲解了操作符重载的所有相关知识。如文章开头所说,操作符重载是一把双刃剑。用得好事半功倍,用不好事倍功半。因此,我给大家的建议是:使用的时候一定要保证能够自圆其说,简单来说,就是自然。我认为相对于古老的语言C++来说,Kotlin语言操作符重载的设计是非常棒的。如果你知道自己在做什么,我非常推荐你在生产环境中使用操作符重载来简化操作。

本篇文章例子代码点这里:kotlin-samples


我是欧阳锋,一个热爱Kotlin语言编程的学生。如果你喜欢我的文章,请在文章下方留下你爱的印记。如果你不喜欢我的文章,请先喜欢上我的文章。然后再留下爱的印记!

下次文章再见,拜拜!


关于Git,你真的学会了吗?

“锋哥,Git有什么可说的,不就是git add添加,git commit提交嘛” 听说我要写一篇Git教程,小明不屑一顾地说。
“…”。

小明是我的一个学生。目前,是一名Android开发工程师。

过了几天,我又再次见到了小明。

“锋哥,今天,我在Github新建了一个版本库,本地提交后推送远程的时候,却被拒绝了,是怎么回事?”

以下是小明的操作记录:

1
2
3
4
5
git init
git add .
git commit -m "Init commit"
git remote add origin git@github.com:xiaoming/xxx.git
git pull origin master

以上操作触发了下面的错误:

1
2
3
4
From git@github.com:xiaoming/xxx.git
* branch master -> FETCH_HEAD
* [new branch] master -> origin/master
fatal: refusing to merge unrelated histories

“小明,注意看最后一句提示。翻译成中文的意思是 ‘拒绝合并不相关的历史’,这个问题有两个方案可以处理。”

  • git pull命令其实是触发了拉取git fetch和合并git merge两个操作。而本地的版本库和远程版本库在第一次拉取或推送完成之前是毫不相关的,Git为了避免不必要的合并,默认不允许进行这样的操作。但你可以手动添加--allow-unrelated-histories强制进行合并,这是方案一。

    1
    git pull origin master --allow-unrelated-histories
  • 再来看方案二,从你上面的操作来看,你只是在本地初始化了一个版本库,并完成了基础的提交。接下来,你希望和远程版本库建立关联,将提交推送到远程。这种情况下,其实你可能并不需要远程的默认数据(通常是一个空的README文件)。所以,你可以添加-f参数,将提交强制提交并覆盖远程版本库。

    1
    git push -f origin master

小明若有所思地点点头,这是小明第一次遇到Git问题。我想,接下来他应该会比较顺利了。

没想到,过了几天,我又收到了小明的消息。这一次,他发来的是对Git的抱怨。

“锋哥,Git好讨厌,提交日志出现了错误,也不能修改。你知道搜狗输入法有时候不够智能,输入太快不小心就输错了…😓”

“🙂,你这孩子,别轻易下结论哈。其实,Git是允许修改提交记录的。使用Git最舒服的一点就是:Git永远都会给你反悔的机会。这一点,其它的版本控制工具是做不到的!”

“哦,原来是这样啊!那快说说看,要怎么做?” 小明已经一副迫不及待的表情了。

git commit命令中有一个参数叫--amend就是为解决这个问题而生的。因此,如果是最近的提交,你只需要按照下面的命令操作即可。”

1
git commit --amend -m "这是新的提交日志"

看完我的消息,小明给我发来一个微笑的表情。小明的抱怨让我想起一句好气又好笑的农村俗语 “屙屎不出怪茅坑”,哈哈。

本以为一切可以风平浪静了。没想到,过了一个月左右,突然接到了小明的紧急电话。电话那头,小明似乎心情很急躁。

“锋哥,我不小心进行了还原操作,我写的代码全丢了。几千行的代码啊,明天晚上就要发版本了,有办法找回来吗?”

听到这个消息,我心里盘算,大约有50%的概率应该是找不回来了。这孩子比较粗心,可能根本就没提交到版本库。但如果他正好提交到了版本库,兴许还有救。因此,我安慰他说 “小明,别急!你打开TeamViewer,我远程帮你看看”

连上机器后,我使用history命令看到小明在提交之后使用了git reset --hard xxx命令进行重置。--hardgit reset命令中唯一一个不安全的操作,它会真正地销毁数据,以至于你在git log中完全看不到操作日志。可是,Git真的很聪明,它还保存了另外一份日志叫reflog,这个日志记录了你每次修改HEAD的操作。因此,你可以通过下面的命令对数据进行还原:

1
2
3
4
5
6
7
8
git reflog

// 使用这个命令,你看到的日志大概是这样
c8278f9 (HEAD -> master) HEAD@{0}: reset: moving to c8278f9914a91e3aca6ab0993b48073ba1e41b2b
3e59423 HEAD@{1}: commit: a
c8278f9 (HEAD -> master) HEAD@{2}: commit (amend): v2 update
2dc167b HEAD@{3}: commit: v2
2e342e9 HEAD@{4}: commit (initial): Init commit

可以看到,我们在版本3e59423进行了git reset操作,最新版本是3e59423。因此,我们可以再次通过git reset命令回到这个版本:

1
git reset --hard 3e59423

以上操作完成后,你会惊喜地发现,丢失的数据居然神奇般地回来了。

“🌺 🌺 🌺”

“下次别这样操作了哈。另外,你怎么一次性丢失这么多代码。一定要记得勤提交。” 小明出现这样的问题,与平时的不规范操作也是分不开的。因此,最后我还不忘嘱咐了他一句。

“好的,我知道了。对了,我一个还有比较疑惑的问题。git checkoutgit reset到底有啥区别?我以前用SVN的时候git checkout是用来检出代码的,在Git中可以用它切换分支或者指定版本,但git reset同样可以做到。难道两者是完全一样的吗?” 小明在QQ中给我发来了回复消息。

“这是一个比较有深度的问题,解释这个问题需要一点时间。接下来,你仔细听”

理解Git工作空间

理解这个问题之前,先来简单学习一些Git基础知识。Git有三种状态:

  • 已提交(commited):数据已完全保存到本地数据库中
  • 已修改(modified):修改了文件,但还没有保存到数据库中
  • 已暂存(staged):对一个已修改的文件做了标记,将包含在下一次提交的版本快照中

这三种状态对应Git三个工作区域:Git版本库、暂存区和工作区

Git版本库是Git用来保存项目的元数据和对象数据库的地方,使用git clone命令时拷贝的就是这里的数据。

工作目录是对某个版本独立检出的内容,这些数据可以供你使用和修改。

暂存区在Git内部对应一个名为index的文件,它保存了下次将要提交的文件列表信息。因此,暂存区有时候也被叫作 “索引”。

一个基础的Git工作流程如下:
1)在工作区修改文件
2)使用git add将文件添加到暂存区,也就是记录到index文件中
3)使用git commit将暂存区中记录的文件列表,使用快照永久地保存到Git版本库中

理解HEAD

解释这个问题,你还需要简单理解HEAD是什么。简单来说,HEAD是当前分支引用的指针,它永远指向该分支上最后一次提交。为了让你更容易理解HEAD,你可以将HEAD看作上一次提交数据的快照。

如果你感兴趣,你可以使用一个底层命令来查看当前HEAD的快照信息:

1
2
3
4
5
6
7
git ls-tree -r HEAD

100644 blob aca4b576b7d4534266cb818ab1191d91887508b9 demo/src/main/java/com/youngfeng/snake/demo/Constant.java
100644 blob b8691ec87867b180e6ffc8dd5a7e85747698630d demo/src/main/java/com/youngfeng/snake/demo/SnakeApplication.java
100644 blob 9a70557b761171ca196196a7c94a26ebbec89bb1 demo/src/main/java/com/youngfeng/snake/demo/activities/FirstActivity.java
100644 blob fab8d2f5cb65129df09185c5bd210d20484154ce demo/src/main/java/com/youngfeng/snake/demo/activities/SecondActivity.java
100644 blob a7509233ecd8fe6c646f8585f756c74842ef0216 demo/src/main/java/com/youngfeng/snake/demo/activities/SplashActivity.java

这里简单解释一下每个字段的意思:100644表示文件模式,其对应一个普通文件。blob表示Git内部存储对象数据类型,另外还有一种数据类型tree,对应一个树对象,中间较长的字符串对应当前文件的SHA-1值,这部分不需要记住,简单了解即可。

所以,简单来说,HEAD对应一个树形结构,存储了当前分支所有的Git对象快照:

我们用一个表格简单来总结一下以上知识点:

HEAD Index(暂存区) 工作区
上一次提交的快照,下一次提交的父节点 预期的下一次提交快照 当前正在操作的沙盒目录

理解git resetgit checkout区别主要是理解Git内部是怎么操作以上三棵树的。

接下来,我们用一个简单的例子来看一下使用git reset到底发生了什么。先创建一个Git版本库并触发三次提交:

1
2
3
4
5
6
7
8
9
10
11
12
git init repo
touch file.txt
git add file.txt
git commit -m "v1"

echo v2 > file.txt
git add file.txt
git commit -m "v2"

echo v3 > file.txt
git add file.txt
git commit -m "v3"

以上操作完成后,版本库现在看起来是这样的:

接下来执行命令git reset 14ad152看看会发生什么。以下是命令执行完成后看到的结果:

1
2
3
4
5
6
7
8
9
10
11
12
git log --abbrev-commit --pretty=oneline
### This is output ###
14ad152 (HEAD -> master) v2
bcc49f4 v1

git status -s
### This is output ###
M file.txt

cat file.txt
### This is output ###
v3

可以看到版本库中文件版本回退到了V2,工作区文件内容同之前的版本V3一致;为了确认暂存区发生了什么变化,我们再使用一个底层命令对比一下暂存区数据和版本库数据是否一致:

1
2
3
4
5
6
7
8
9
# 查看暂存区信息
git ls-files -s
### This is output ###
100644 8c1384d825dbbe41309b7dc18ee7991a9085c46e 0 file.txt

# 查看版本库快照信息
git ls-tree -r HEAD
### This is output ###
100644 blob 8c1384d825dbbe41309b7dc18ee7991a9085c46e file.txt

可以看到当前版本库和暂存区信息是完全一致的,HEAD指向了v2提交,用一个图形来表示整个过程,应该是这样:

看一眼上图,理解一下刚刚发生的事情:首先,HEAD指针发生了移动,指向了V2,并撤销了上一次提交。目前,版本库和暂存区都保存的是第二次提交的记录,工作区却保存了最近一次修改。稍微联想一下,你就会发现,这次的git reset命令恰好是最近一次提交的逆向操作。让数据完全回到了上一次提交前的状态。所以,如果你想撤销最近一次提交,可以这么做。

增加–soft参数测试

以上是我们对git reset命令的第一次尝试,在下一轮尝试前,先执行git help reset看看reset命令的用法:

1
2
3
git reset [-q] [<tree-ish>] [--] <paths>...
git reset (--patch | -p) [<tree-ish>] [--] [<paths>...]
git reset [--soft | --mixed [-N] | --hard | --merge | --keep] [-q] [<commit>]

看最后一句发现,reset命令后面还可以接5个不同的参数: --soft--mixed--hard--merge--keep。这里我们主要关注前面三个,其中--mixed其实刚刚已经尝试过,它和不带参数的git reset命令是同样的效果。换而言之,--mixedgit reset命令的默认行为。接下来执行git reset --soft 14ad152看看会发生什么。命令执行完成后,按照惯例,我们同样使用基础命令看看发生了什么变化:

1
2
3
4
5
6
7
8
9
10
11
12
git log --abbrev-commit --pretty=oneline
### This is output ###
14ad152 (HEAD -> master) v2
bcc49f4 v1

git status -s
### This is output ###
M file.txt

cat file.txt
### This is output ###
v3

奇怪了?为什么会和上次不带任何参数的执行结果完全一致?难道Git出现了设计错误。相信你看到结果一定会有这样的疑问,其实不然!因为,这里我用文本粘贴了输出结果,忽略了命令的字体颜色,其实这里第二条命令输出结果中的M颜色与上一次执行结果是不一样的。为了让你看到不同,看下面的截图:

这个颜色表示:file.txt文件已经被添加到了暂存区,使用git commit命令就可以完成提交。为了严谨,我们依然使用上面的底层命令看看版本库和暂存区信息是否一致。注意:这里的结果应该是不一致才对,因为版本库记录的文件版本是v2,而暂存区记录的文件版本其实是v3。

1
2
3
4
5
6
7
git ls-tree -r HEAD
### This is output ###
100644 blob 8c1384d825dbbe41309b7dc18ee7991a9085c46e file.txt

git ls-files -s
### This is output ###
100644 29ef827e8a45b1039d908884aae4490157bcb2b4 0 file.txt

可以看到,两个命令执行输出的SHA-1并不一致,验证了我们的猜想。

这里我们可以得出一个结论:--soft和默认行为(--mixed)不一样的地方是:--soft会将工作区的最新文件版本再做一步操作,添加到暂存区。使用这个命令可以用来合并提交。即:如果你在某一次提交中有未完成的工作,而你反悔了,你可以使用这个命令撤销提交,等工作做完后继续一次性完成提交。

增加–hard参数测试

接下来我们对最后一个参数进行测试,这也是小明在使用过程出现问题的一个参数。执行命令git reset --hard 14ad152,看看发生了什么:

1
2
3
4
5
6
7
8
9
10
11
git log --abbrev-commit --pretty=oneline
### This is output ###
14ad152 (HEAD -> master) v2
bcc49f4 v1

git status -s
### This is output ###
>>> No output <<<

cat file.txt
v2

注意看,这次使用git status -s完全看不到输出,这就证明:当前工作区,暂存区,版本库数据是完全一致的。查看文件内容,发现文件回到了v2版本。通常情况下,如果你看到这种情况,一定会吓一跳,你最近一次提交的数据居然完全丢失了。的确,这是Git命令中少有的几个真正销毁数据的命令之一。除非你非常清楚地知道自己在做什么,否则,请尽量不要使用这个命令!

我们依然用一张图,完整地描述这个命令到底发什么了什么:

可以看到,相对于默认行为,--hard将工作区的数据也还原到了V2版本,以至于V3版本的提交已经完全丢失。

git checkout

接下来看git checkout, 按照惯例,先执行git checkout 14ad152看看会发生什么:

1
2
3
4
5
6
7
8
9
10
11
git log --abbrev-commit --pretty=oneline
### This is output ###
14ad152 (HEAD -> master) v2
bcc49f4 v1

git status -s
### This is output ###
>>> No output <<<

cat file.txt
v2

可以看到,又出现了神奇的一幕,这一次git checkout命令的执行结果的确和git reset --hard完全一致。这是否意味着两者就没有任何区别了呢?当然也不是。严格来说,两者有两个“本质”的区别:

  • 相对而言,git checkout对工作目录是安全的,它不会将工作区已经修改的文件还原,git reset则不管三七二十一一股脑全部还原。
  • 另外一个比较重要的区别是,git checkout并不移动HEAD分支的指向,它是通过直接修改HEAD引用来完成指针的指向。

第二个不同点相对比较难理解,我们用一张图来更直观地展示二者的区别:

简单来说,git reset会通过移动指针来完成HEAD的指向,而git checkout则通过直接修改HEAD本身来完成指向的移动。

命令作用于部分文件

git resetgit checkout还可以作用于一个文件,或者部分文件,即带文件路径执行。这种情况下,两个命令的表现不太一样。我们来试试看,先执行git reset 14ad15 -- file.txt命令尝试将文件恢复到V2版本。命令执行完成,按照惯例用一些基础命令来看看发生了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
git log --abbrev-commit --pretty=oneline
### This is output ###
4521405 (HEAD -> master) v3
14ad152 v2
bcc49f4 v1

git status -v
### This is output ###
diff --git a/file.txt b/file.txt
index 29ef827..8c1384d 100644
--- a/file.txt
+++ b/file.txt
@@ -1 +1 @@
-v3
+v2

cat file.txt
v3

可以看到,版本库和工作区的数据都没有发生变化。唯一发生变化的是暂存区,暂存区记录下一次提交的改动将导致数据从V3恢复到V2版本!

这里我们可以这样理解:执行这条命令后,Git先将暂存区和工作区的文件版本恢复到V2,再将工作区的文件版本恢复到V3。与--hard不一样的地方是:这个命令并不会覆盖工作区已经修改的文件,是安全操作。

执行带路径的git checkout命令和git reset命令有一些细微的差别,相对于git resetgit checkout带路径执行会覆盖工作区已经修改的内容,导致数据丢失,是一个非安全操作。

针对上面的所有实验,我们用一个简单的表格来总结他们的区别,以及操作是否安全:

不带路径执行

命令行 HEAD 暂存区 工作区 目录安全
git reset [–mixed] YES YES NO YES
git reset –soft YES YES NO YES
git reset –hard YES YES YES NO
git checkout Modify YES YES YES

带路径执行

命令行 HEAD 暂存区 工作区 目录安全
git reset – NO YES NO YES
git checkout NO YES YES NO

注意:执行非目录安全的命令操作的时候,一定要慎重,除非你非常清楚自己在做什么!

“小明,你明白了吗?” 消息发送过去之后,等了很久却一直没有响应。
“哎,这孩子!估计听睡着了… 😆”

自从这次问到Git的问题后,已经两年过去了,小明再没有问到关于Git的问题。而就在昨天,突然又收到了小明的消息。

也许你应该试试Git Flow

“锋哥,我现在已经是Android Leader了。现在安卓团队一共6个人,我们现在在做一个社交类应用,在Git管理方面我还是发现了一些问题。其中一个问题就是,现在版本库有好多分支,其中开发主要在develop分支。主干分支是master主要用于版本发布。可还有一些分支却显得非常混乱,有什么办法改善这种情况吗?”

“关于Git的分支设计,目前有一个公认比较好的设计叫 Git Flow模型。关于Git Flow模型,你可以查看这篇文章 http://nvie.com/posts/a-successful-git-branching-model/ 了解一下”

一个idea,一次提交

“好的!还有一个困扰了我很久的问题是,大家的提交日志写的比较笼统。在查找问题的时候非常不便,而且大部分同学一次性提交好多文件,导致解决问题的时候不能准确定位到具体是哪一次提交导致的。我告诉大家,一次提交改动要尽可能小。但当别人问到具体的提交规则的时候我又不知道从何说起…”

“这是一个很好的问题 。中国程序员普遍存在的一个问题是,恨不得把这辈子能提交的代码一次性搞定。甚至有人用多次提交太麻烦的借口来搪塞问责人。简单来说,可以用一句话概括提交原则:一个idea,一次提交。另外,你说的没错,提交必须尽可能小,注释必须尽可能表述准确!”

给小明讲了这么多Git,我忍不住半开玩笑地问他,“小明,你现在还觉得Git简单吗?”

小明发了一个无奈的表情!说道,“以前是我才疏学浅,略知皮毛,不知道Git原来还有这么多玩法,忍不住为Git的发明者点赞了。对了,锋哥,Git到底是谁开发的?”

Git的最大功臣,其实不是Linus

”关于Git的故事,互联网上其实已经烂大街了。我简单给你介绍一下吧!Git的诞生其实是一个偶然,其初始使命是为Linux内核代码管理服务的。早年的时候Linux内核源码是用Bitkeeper版本控制工具管理的。可是,后来因为某些利益关系,Bitkeeper要求Linux社区付费使用。这一举动激怒了Linus,也就是Linux的创始人,他决定自己开发一个分布式版本控制系统。几周时间下来,Git的雏形就诞生了,并且开始在Linux社区中应用开来。虽然Linus是Git的创始人,可是背后的最大功臣却是一个日本人 Junio C Hamano。Linus在Git开源版本库的提交只有258次,而Junio C Hamano却提交了4000多次。也就是说,在Linus开发后不久项目的管理权就交给了这个日本人。关于 Junio C Hamano,你感兴趣的话可以Google了解一下。他现在在Google工作,如同Linus一样非常低调。“

“这个故事也告诉我:不要用技术去挑战一个程序员 @_@ ”

这个故事讲完,小明与Git的故事就已经告一段落了。其实,还有一些比较常见的问题,小明并没有问到过。这里,我为你准备了一个附录,给你介绍一些常用的小命令帮你解决日常小问题。它很有用,一定要拿笔记下来,或者收藏这篇文章备用。

常见问题

问题一:公司的Git服务器是搭建在一个内网服务器上面的,我想把代码同时提交到OsChina上面,以便在家拉取代码,远程办公,怎么办?
Git本身是一个分布式的版本管理系统,实现这个需求非常简单,使用git remote add命令添加多个远程版本库关联即可。

1
2
git remote add company git@xxx
git remote add home git@xxx

问题二:在拉取远程代码的时候,如果本地有代码还没有提交,Git就会提示先提交代码到版本库。可暂时我又不想提交,怎么办?
针对这个问题,Git提供了一个临时区域用于保存不想提交的记录,对应的命令是git stash。通常情况下,你可以这样操作:

1
2
3
4
5
6
7
8
9
10
11
12
# 将暂时还不想提交的数据保存到临时区域,保存成功后,工作区将和版本库完全一致
git stash
# 还原stash数据到工作区
git stash apply
# 以上操作完成后,stash数据依然保存在临时区域中,为了删除这部分数据,使用如下命令即可。
git stash drop
# 如果你想在还原数据的同时从临时区域删除数据,可以这样操作:
git statsh pop
# 以上两个命令如果不接任何参数将删除掉所有的临时区域数据,如果你只想删除其中一条记录,指定对应索引数据即可。
git stash pop/drop stash@{index}
# 查看临时区域所有数据,使用如下命令:
git stash list

问题三:作为项目负责人,我希望迅速找出问题代码的“元凶”,有什么办法吗?
针对这个问题,最好的答案是git blame,使用这个命令并指定具体文件它将显示文件每一行代码的最近修改记录,你可以清晰地看到最近代码的修改人。

问题四:部分Team Leader会要求使用git rebase合并代码,这有什么好处吗?
我们用一个简单的思维来理解这个问题,最常见的合并操作是使用git merge,而这样操作会在合并分支生成一次新的提交,并且会严格记录分支提交日志,在长期开发过程中,日志就会呈现多条线路展示,给阅读带来一定的障碍。而使用git rebase会使整体代码提交记录始终像在单一分支开发一样,仅使用一条线路展示。但使用git rebase是有一定陷阱的,这个问题需要一定的时间才能说清楚,如果需要了解两个命令的详细区别,我推荐你阅读这篇文章 Rebase 代替合并

总结

Git是一个非常优秀的版本控制系统,我极力推荐你在日常开发中使用。这篇文章从小明的角度解释了几个常见问题的解决方案,毫无悬念地,你可能还会遇到其它的一些问题。遇到问题,你可以尝试使用Google搜索解决方案;也可以在文章下方给我留言,我非常乐意为你解答Git问题。


我是欧阳锋,版本控制,我使用Git。了解欧阳锋,从这里开始:欧阳锋档案馆

也许你应该试试用Kotlin写Gradle脚本

文 | 欧阳锋

Android应用开发中,离不开Gradle脚本的构建。大部分Android开发同学忽视了脚本的力量,甚至有很大一部分同学不知道Gradle脚本是什么,用什么语言编写的;当然,也有相当一部分同学知道Gradle脚本是使用Groovy语言编写的,但对于Groovy语言却一窍不通,只是勉强可以看懂Gradle脚本。正所谓,知其然,但并不知其所以然…

换个角度看问题,熟练掌握Gradle脚本还需要精通Groovy语言,这对Android开发同学来说的确是一个不小的挑战。这种Java + Groovy的开发套餐对于普通的Android开发者来说的确存在一定的知识断层,显而易见的是,部分同学写的Gradle脚本简直“不堪入目”。时间回到去年5月份,Google IO大会上宣布了一个重磅消息,Android官方开始支持使用Kotlin语言进行应用开发。其实,在这个时间节点上,我已经在生产环境使用Kotlin开发Android将近一年。对于我来说,这无疑是一个让人欣喜若狂的消息。但,惊喜还远远不止这些,过了一段时间,我又看到了这篇文章 Kotlin Meets Gradle。很有诗意的标题:当Gradle邂逅Kotlin,文章的核心意思是:Gradle团队正在尝试使用Kotlin语言作为Gradle脚本的官方开发语言。

我想,也许,Android开发者的春天就要到了!

Why use Kotlin ?

在写Gradle脚本的时候,最痛苦的莫过于没有任何提示,唯一的调试手段就是使用print方法打印调试日志。正如 Kotlin Meets Gradle 文中所说,当你使用Kotlin语言编写Gradle脚本的时候,你会发现一切都变得有趣起来。突然:

  • 脚本代码可以自动补全了
  • 源码之间可以互相跳转了
  • 插件源码更容易看懂了
  • 重构(Refactoring)也可以支持了

当然,惊喜还不止这些,当你开始决定使用Kotlin语言的时候,仿佛一切都变得美好了起来!

Let’s start

好了,废话不多说,接下来我们开始尝试用Kotlin语言编写Gradle脚本。由于当前 kotlin-dsl 正处于预发布状态(kotlin-dsl的最新版本是0.14.2,对应Gradle插件版本4.5),IDE的支持也不完善,为了更好的体验该功能,推荐大家使用如下配置:

实验室配置

操作系统:macOS 10.13.2
Android Studio: 3.1 Canary 9
Gradle Wrapper: 4.5
Gradle Plugin: 3.1.0-alpha9
Kotlin:1.2.21

操作步骤

首先,按照以往步骤创建一个Android工程:

接下来,改造开始,Gradle Script Kotlin脚本以.gradle.kts后缀结尾。因此,我们先将工程根目录settings.gradle更名为settings.gradle.kts

这个地方的错误有两个原因:

  • Kotlin语言中,单引号只能包裹字符,不能包裹字符串
  • Kotlin语言中,方法调用使用括号。仅在使用infix修饰的方法中可以省略括号。这里显然是一个正常调用方法。因此,我们修改为:
    1
    include("app")

接下来,修改根目录的build.gradle脚本,用同样的方式修改后缀,方法修改为括号调用,修改后的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
buildscript {
repositories {
google()
jcenter()
}
dependencies {
// 这里修改为括号调用即可
classpath("com.android.tools.build:gradle:3.0.1")
}
}

allprojects {
repositories {
google()
jcenter()
}
}

注意:在修改后缀名称的时候IDE会出现警告提示,这里可以忽略,选择continue即可。

由于我们手动修改了build.gradle脚本,为了保证工程可以使用这个脚本,需要在settings.gradle.kts中添加一行代码,让Gradle知道使用build.gradle.kts脚本构建。因此,最后的settings.gradle.kts代码如下:

1
2
include("app")
rootProject.buildFileName = "build.gradle.kts"

最后一步,修改app模块build.gradle文件,这也是最复杂的一步,修改完后缀名后,你会看到整个脚本全部被红色标识错误:

别慌!还是一样的方式,这里我们先将这里的所有代码注释掉。在最上方逐一对应修改,apply plugin部分修改为:

1
2
3
plugins {
id("com.android.application")
}

接下来,修改android {}闭包部分。这里有两个小技巧,由于目前IDE的支持不是很完善,在输入的时候稍微等待一段时间,IDE会给出相应的提示。另外,如果没有提示,例如android {}闭包就没有任何提示,输入完成后展开右侧gradle面板,选择gradle/buid setup/init,双击执行:

在底部面板可以看到任务执行是否成功。注意,即使任务执行成功,脚本依然可能被红色标识,这是IDE支持不完善导致的,可以忽略。

修改完成后的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
android {
compileSdkVersion(27)
buildToolsVersion("27.0.2")

defaultConfig {
applicationId = "com.youngfeng.kotlindsl"
minSdkVersion(15)
targetSdkVersion(27)
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner"
}

buildTypes {
getByName("release") {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
}
}
}

注意:你在使用的过程中,依然可能会遇到无论如何都不生效的问题。这个时候别着急,使用./gradlew assembleDebug命令调试,查看终端找到错误原因。Windows用户去掉./执行即可。

最后的依赖部分,同样地,全部修改为括号调用即可。这里就不赘述了,文章的最后部分会提供操作视频,在使用过程中有任何问题可以打开操作视频参考,如果依然不能解决,可以在文章下方给我留言,我会在第一时间给你答复。修改后的内容如下:

1
2
3
4
5
6
7
8
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation("com.android.support:appcompat-v7:26.1.0")
implementation("com.android.support.constraint:constraint-layout:1.0.2")
testImplementation("junit:junit:4.12")
androidTestImplementation("com.android.support.test:runner:1.0.1")
androidTestImplementation("com.android.support.test.espresso:espresso-core:3.0.1")
}

通过上面的步骤,从Groovy转换到Kotlin的步骤已经全部完成,你可以在终端输入./gradlew assembleDebug测试是否可以正常构建了:

统一依赖管理

上面的步骤虽然完成了脚本的转换,但依赖的管理依然是混乱的,为了实现类似
Snake 工程的统一依赖管理,我们还需要做一些工作。

Gradle官方提供了使用 buildSrc 目录实现自定义任务和插件逻辑,这里我们可以使用它完成依赖的统一处理,一个完整的buildSrc目录结构如下:

Deps类中,可以这样定义依赖结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
object deps {
object plugin {
val gradle = "com.android.tools.build:gradle:3.1.0-alpha09"
val kotlin = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.2.21"
}

object kotlin {
val stdlibJre7 = "org.jetbrains.kotlin:kotlin-stdlib-jre7:1.2.21"
}

object android {
object support {
val compat = "com.android.support:appcompat-v7:27.0.2"
val constraintLayout = "com.android.support.constraint:constraint-layout:1.0.2"
}

object test {
val junit = "junit:junit:4.12"
val runner = "com.android.support.test:runner:1.0.1"
val espressoCore = "com.android.support.test.espresso:espresso-core:3.0.1"
}
}
}

定义之后,我们就可以在脚本中直接引用了:

1
2
3
4
5
6
7
8
9
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation(deps.kotlin.stdlibJre7)
implementation(deps.android.support.compat)
implementation(deps.android.support.constraintLayout)
testImplementation(deps.android.test.junit)
androidTestImplementation(deps.android.test.runner)
androidTestImplementation(deps.android.test.espressoCore)
}

是不是漂亮了许多?

至此,整个转换过程就顺利完成了,为了保证转换的成功率,我推荐使用文章开头的实验室配置。如果版本过低,不保证可以转换成功。最新版本的kotlin-dsl会跟随最新版本的Gradle插件发布,因此一定要使用最新版本。另外,目前IDE对kts的支持依然不完善,即使正确的写法也会报错,这个一定要注意,不要被IDE欺骗了。

更详细的操作,请看视频教程

腾讯视频:用Kotlin写Android Gradle脚本

一些建议

虽然使用Kotlin语言写脚本是一件非常美妙的事情,但目前依然存在一些问题:

  • IDE支持不完善
  • kotlin-dsl 正在快速开发中,语法变动较大
  • 缺少官方文档
  • 互联网上缺少相关资料,遇到问题很难追踪

因此,目前我并不推荐你在生产环境中使用,但可以作为日常学习练手之用。预计1.0版本的发布在今年6月份左右,正式版本发布后,我推荐你立即将Gradle脚本转换到Kotlin语言。

遇到问题,看这里 ==>

在使用的过程中,按照文章同样的步骤,你依然可能会遇到很多问题。因此,我为你整理了目前互联网上可以参考的资料,你可以收藏这篇文章。遇到问题别慌,来这里查找答案。

关于kotlin-dsl的开发路线图,请看这篇文章:https://blog.gradle.org/kotlin-scripting-update

如果你在使用过程中,遇到了任何问题,并且确定是 kotlin-dsl 的bug,请点这里:https://github.com/gradle/kotlin-dsl 并推送 issue

如果你遇到了知识盲点,并且在Google找不到答案。可以来 Slack#gradle频道反馈,我在 Slack 的昵称是Scott Smith,也欢迎你给我发送私信消息。

本篇文章例子完整代码,请点击这里:https://github.com/yuanhoujun/gradle-script-kotlin-example

kts文档正在编写当中,具体进度,请点这里:https://github.com/gradle/kotlin-dsl-docs

欢迎加入Kotlin交流群

如果你也喜欢Kotlin语言,欢迎加入我的Kotlin交流群: 329673958 ,一起来参与Kotlin语言的推广工作。

记一次印象深刻的Bug追踪过程

问题现象:使用安卓手机以小程序的形式分享产品到微信,使用微信打开,产品详情数据无法显示。而使用iPhone分享到微信,却始终可以正常打开,这个时候所有的矛头都指向了安卓同学。

小程序中打开,显示空白

逻辑设计说明:这里的分享数据来自H5接口,通过addJavascriptInterface自定义接口完成H5和Java端的数据传递,产品ID来自后台接口获取。

这个时候,安卓同学首先做出了响应,通过调试拿到了JS端的数据,以下是这位小陈同学的截图消息:

Android调试结果

小陈同学这个时候把问题抛给了Web前端同学小徐,以为小徐传递了科学计数法的ID字符串。

大家看小陈同学的截图,图中的ID是使用字符串接收的,这个时候我已经完全排除问题出现在安卓端的可能性了。于是,我问小徐,H5有对参数进行处理吗?得到的答案如下:

大家看到图中,我已经给出了确定的答案,认为问题来自于后台。因为,后台同学之前的确出现过对ID进行toInt处理最终转换为负数的情况。现在在传递时出现这种低级错误的概率应该也挺高的。这段话抛出去之后,团队炸开了锅,有同学认为大家在互相推诿…

其实,还有很长的截图,这里没有展示出来。群里提到最多的一句话就是:iOS没问题啊。就连我们的运维同学以及UI设计同学都加入了“讨伐”队伍,种种迹象似乎都指向了安卓同学。这个时候,我们的安卓同学真是“哑巴吃黄连,有苦说不出”,心里的潜台词肯定是:我TM的就用string接收了一下,我招谁惹谁了我!

但其实出现这种不知所踪的情况,完全可以理解,大家大都集中在单一平台开发,对于其它环节的理解难免有偏差。其实,用常识来理解这个问题的话,的确后台的概率比较大,前端同学对ID进行运算处理的概率几乎为0,这一点即使是刚刚入行的新手也不太可能。而我一直苦等的后台同学却迟迟没有响应,我目前始终无法确定问题到底来自于后台还是Web前端。直到我终于看到了下面的截图。

这个时候,我终于有九成的把握确定问题来自于Web前端了。可是,我知道我不能明说。前端同学已经在聊天记录中给出了证据,在Chrome的控制台打印出了正常的id值,到了安卓端却出现了异常。前端同学这个时候心里也有了一个定性结论,问题来自安卓端。这个时候,我只能亲自上场,而恰好我在外面,正在办理深圳户口,比较不便。于是,我微信给小陈发消息,嘱咐它把详情页的源码“爬”下来,我回来看看源码。

回到家的时候,我问小陈html源码是否已经“爬”了下来,他给我发来截图,我意识到前端使用了https协议,没法获取html源码。于是,我想了一个办法,在源码中嵌入一段代码,通过代码的形式获取WebView产品详情页的数据。这个方法果然奏效,不一会儿,小陈就发来了页面的html源码。

哎哟,我的天哪!混淆后的代码简直不堪入目,不过还好,我可以搜索方法关键字showShareView。可是,很遗憾没有搜索到,事件的绑定被放到了JS代码中。在这段源码中,我注意到一个文件名已经被混淆的JS文件,我猜想代码应该就在这里。可是,怎样抓到具体的方法呢?

灵机一动!我之前在代码中让小陈把Debug权限开发给了H5,这次正好可以派上用场。可是,对于混淆后的代码,我心里依然有点打退堂鼓。

连上手机,在Chrome浏览器中输入chrome://inpsect,点击相应链接,非常顺利地进入了调试界面:

在控制台的Source中,我通过关键词搜索找到了混淆后的JS代码片段,在方法名前面增加了一个断点,等调试到底方法位置的时候。这个时候已经获取到了JS的上下文,直接通过this.gid打印出了当前产品ID信息,居然是一个非常正常的整型数字。大家注意,这已经是一个在安卓端出问题的产品了,在JS端居然显示是正常的。这个时候,我的大脑非常转动,我的第一感觉应该是webkit内核看到接收的字符串全是数字做了”自以为是“的转换。于是,我给出了团队如下的答案:

为了进一步确定我的猜想,我让小陈写了一个简单的Demo,通过JS接口传递一个非常大的数字字符串给Java端,看接收是否异常。不一会儿,我就得到了答案:

至此,我终于基本确定问题的原因了!
猜测:JS在传递数据给安卓端的时候,应该是使用了基本数据类型。而webkit内核在处理的时候可能是以JS端数据类型为准,在传递到Java端时候做了转换。

为了验证这个猜想,我使用typeof打印id的数据类型,得到了如下结果:

于是,我告诉小徐,问题来自于你没有传递正确的数据类型给安卓端。其实这是比较危险的,不同CPU可以容纳的最大整型值是不一样的。如果iOS端和安卓处理一致,也是以JS端数据类型为准,只不过iOS的CPU字节宽度较大,恰好在iPhone高端机型上面没有出现而低端机型出现的话。其实问题依然存在,而如果iOS的确是以Native端数据类型为准。这就根本不是一个问题。但答案虽然给了团队,可是小徐仍然一脸狐疑,没有经验的CTO也是跟着一脸狐疑,加上解决问题的时间较长。小徐在发布更新的时候也遇到了问题,导致更新失败,问题持续,整个问题一直在持续。

这个时候,我告诉小徐,你发布更新后先别着急,确定更新成功后再告诉团队小伙伴。

一直到确定更新成功,我们再次尝试分享,问题终于引刃而解!

问题虽然解决了,可是,安卓系统为什么要这样处理呢?为什么不能以Native端数据类型为准呢?带着这个疑问,我开始查看安卓源码。

阅读安卓源码是一个痛苦的过程,随着系统版本的升级,安卓系统的兼容性代码越来越多,这给阅读带来了极大的困难。加上安卓系统本身源码量巨大,阅读源码就像在一个巨大的森林中寻找宝藏一样。这个时候,其实你非常容易迷路,而我知道,只要我坚信我想要什么,就一定可以找到。

这里我们以addJavascriptInterface这个方法作为突破口,进入源码:

1
2
3
4
public void addJavascriptInterface(Object object, String name) {
checkThread();
mProvider.addJavascriptInterface(object, name);
}

额,mProvider是什么鬼?难道WebView只是一个傀儡,真正处理业务的其实是mProvider?是的,没错!WebView只不过是一个壳而已!可是,mProvider的实现到底是什么呢?带着这个疑问,我们看到了如下mProvider实例创建的方法:

1
2
3
4
5
6
7
8
9
10
11
12
private void ensureProviderCreated() {
checkThread();
if (mProvider == null) {
// As this can get called during the base class constructor chain, pass the minimum
// number of dependencies here; the rest are deferred to init().
mProvider = getFactory().createWebView(this, new PrivateAccess());
}
}

private static WebViewFactoryProvider getFactory() {
return WebViewFactory.getProvider();
}

又出现了一个工厂方法,别怕,继续往下追踪:
getProvider方法较长,我们截取部分,看下面源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static WebViewFactoryProvider getProvider() {
synchronized (sProviderLock) {
// For now the main purpose of this function (and the factory abstraction) is to keep
// us honest and minimize usage of WebView internals when binding the proxy.
if (sProviderInstance != null) return sProviderInstance;

final int uid = android.os.Process.myUid();
if (uid == android.os.Process.ROOT_UID || uid == android.os.Process.SYSTEM_UID
|| uid == android.os.Process.PHONE_UID || uid == android.os.Process.NFC_UID
|| uid == android.os.Process.BLUETOOTH_UID) {
throw new UnsupportedOperationException(
"For security reasons, WebView is not allowed in privileged processes");
}

StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
Trace.traceBegin(Trace.TRACE_TAG_WEBVIEW, "WebViewFactory.getProvider()");
try {
Class<WebViewFactoryProvider> providerClass = getProviderClass();
Method staticFactory = null;
try {
staticFactory = providerClass.getMethod(
CHROMIUM_WEBVIEW_FACTORY_METHOD, WebViewDelegate.class);
} catch (Exception e) {
if (DEBUG) {
Log.w(LOGTAG, "error instantiating provider with static factory method", e);
}
}

这里的单用户检测,安全调用之类的代码就先忽略了。集中注意力看Provider实例创建的代码,大家可以看到,这里的创建其实通过反射调用创建的。这里有一个关键的方法getProviderClass(),这个方法可能获取到真正的Provider类对象,跟踪这个方法调用,我们看到了如下的调用过程:
getProviderClass() -> getWebViewProviderClass

1
2
3
4
5
public static Class<WebViewFactoryProvider> getWebViewProviderClass(ClassLoader clazzLoader)
throws ClassNotFoundException {
return (Class<WebViewFactoryProvider>) Class.forName(CHROMIUM_WEBVIEW_FACTORY,
true, clazzLoader);
}

看到了吗?CHROMIUM_WEBVIEW_FACTORY 这才是真正的WebViewFactoryProvider类声明,跟进这个常量:

1
private static final String CHROMIUM_WEBVIEW_FACTORY = "com.android.webview.chromium.WebViewChromiumFactoryProviderForO";

从命名ForO来看,这个类恰好是用于最新版本Android系统Oreo的。没错,这里我们就从最新版本的源码入手,找到真正的问题”元凶“。

可是,这个代码在哪里呢?你搜索安卓源码,根本搜索不到该类,这是为什么呢?也许你已经猜到了,其实这段代码就来自于Chrome核心工程 chromium。这段代码,大家通过谷歌搜索找找看,这里我们以官方版本的代码为准:
WebViewChromiumFactoryProviderForO

具体代码很简单,如下:

1
2
3
4
5
6
7
8
9
package com.android.webview.chromium;
class WebViewChromiumFactoryProviderForO extends WebViewChromiumFactoryProvider {
public static WebViewChromiumFactoryProvider create(android.webkit.WebViewDelegate delegate) {
return new WebViewChromiumFactoryProviderForO(delegate);
}
protected WebViewChromiumFactoryProviderForO(android.webkit.WebViewDelegate delegate) {
super(delegate);
}
}

LOL,可是,你以为真的很简单吗?其实不然,实现在父类,跟进父类。这个时候千万保持清醒,别跟丢了哦。我们想要的是Provider的创建过程,这个是Provider工厂类的真正类型,由它完成WebViewProvider的创建。

如果你已经忘了,我们再来回顾一下刚刚创建WebViewProvider的代码,别走神,看这里:

1
2
3
4
5
6
7
8
private void ensureProviderCreated() {
checkThread();
if (mProvider == null) {
// As this can get called during the base class constructor chain, pass the minimum
// number of dependencies here; the rest are deferred to init().
mProvider = getFactory().createWebView(this, new PrivateAccess());
}
}

看到了吗?这里拿到工厂类之后,调用了createWebView方法创建了Provider对象。那好办了,我们在WebViewChromiumFactoryProviderForO的父类WebViewChromiumFactoryProvider直接搜索createWebView方法即可。

1
2
3
4
@Override
public WebViewProvider createWebView(WebView webView, WebView.PrivateAccess privateAccess) {
return new WebViewChromium(this, webView, privateAccess, mShouldDisableThreadChecking);
}

怎么样,这段代码熟悉吗?这里直接返回了一个WebViewChromium对象,也就是说,WebView的所有操作,都由WebViewChromium帮忙完成。好吧,我们继续跟进这个类。可是跟进这个类做什么呢?哈哈,忘了吧,我们的目的是寻找addJavascriptInterface实现。稍等,容我先擦一把汗。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void addJavascriptInterface(final Object obj, final String interfaceName) {
if (checkNeedsPost()) {
mFactory.addTask(new Runnable() {
@Override
public void run() {
addJavascriptInterface(obj, interfaceName);
}
});
return;
}
mAwContents.addJavascriptInterface(obj, interfaceName);
}

稍微瞅一眼这个方法checkNeedsPost

1
2
3
4
5
6
7
protected boolean checkNeedsPost() {
boolean needsPost = !mFactory.hasStarted() || !ThreadUtils.runningOnUiThread();
if (!needsPost && mAwContents == null) {
throw new IllegalStateException("AwContents must be created if we are not posting!");
}
return needsPost;
}

简单理解一下,如果已经启动或者调用该方法的线程不在UI线程,则需要post到UI线程中去,这里很明显,我们的调用是在UI线程中。因此,我们之间走下面的分支: mAwContents.addJavascriptInterface(obj, interfaceName);。那么,问题来了,AwContent又是什么鬼?在哪里创建的呢?

仔细查找这个类,我们发现AwContent是在initForReal方法中被创建的。而initForReal调用来自init方法。可是,init方法是在哪里调用的呢?答案是:WebView。看下面的截图:

OK,继续往下,看AwContent是怎么创建的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void initForReal() {
AwContentsStatics.setRecordFullDocument(sRecordWholeDocumentEnabledByApi
|| mAppTargetSdkVersion < Build.VERSION_CODES.LOLLIPOP);
mAwContents = new AwContents(mFactory.getBrowserContextOnUiThread(), mWebView, mContext,
new InternalAccessAdapter(), new WebViewNativeDrawGLFunctorFactory(),
mContentsClientAdapter, mWebSettings.getAwSettings(),
new AwContents.DependencyFactory() {
@Override
public AutofillProvider createAutofillProvider(
Context context, ViewGroup containerView) {
return mFactory.createAutofillProvider(context, mWebView);
}
});
if (mAppTargetSdkVersion >= Build.VERSION_CODES.KITKAT) {
// On KK and above, favicons are automatically downloaded as the method
// old apps use to enable that behavior is deprecated.
AwContents.setShouldDownloadFavicons();
}
if (mAppTargetSdkVersion < Build.VERSION_CODES.LOLLIPOP) {
// Prior to Lollipop, JavaScript objects injected via addJavascriptInterface
// were not inspectable.
mAwContents.disableJavascriptInterfacesInspection();
}
// TODO: This assumes AwContents ignores second Paint param.
mAwContents.setLayerType(mWebView.getLayerType(), null);
}

下面是一些版本兼容判断,与本文探讨主题无关,先忽略。好了,看到这里,大家是不是感觉被安卓源码忽悠的团团转,最开始我们天真地以为真正的调用来自WebView,安卓系统告诉我们来自WebViewProvider,我们以为这应该就是头了。可是现在又出现了一个AwContent。那么,它是不是真正的最终调用者呢?继续往下看:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @see ContentViewCore#addPossiblyUnsafeJavascriptInterface(Object, String, Class)
*/
@SuppressLint("NewApi") // JavascriptInterface requires API level 17.
public void addJavascriptInterface(Object object, String name) {
if (TRACE) Log.i(TAG, "%s addJavascriptInterface=%s", this, name);
if (isDestroyedOrNoOperation(WARN)) return;
Class<? extends Annotation> requiredAnnotation = null;
if (mAppTargetSdkVersion >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
requiredAnnotation = JavascriptInterface.class;
}
mContentViewCore.addPossiblyUnsafeJavascriptInterface(object, name, requiredAnnotation);
}

我擦,又来了一个调用对象mContentViewCore。Relax,继续往下看,看它的实现:

1
2
3
4
5
6
7
8
public void addPossiblyUnsafeJavascriptInterface(Object object, String name,
Class<? extends Annotation> requiredAnnotation) {
if (mNativeContentViewCore != 0 && object != null) {
mJavaScriptInterfaces.put(name, object);
nativeAddJavascriptInterface(mNativeContentViewCore, object, name, requiredAnnotation,
mRetainedJavaScriptObjects);
}
}

看方法名,nativeAddJavascriptInterface看起来最终调用来自于Native,继续往下看:

1
2
private native void nativeAddJavascriptInterface(int nativeContentViewCoreImpl, Object object,
String name, Class requiredAnnotation, HashSet<Object> retainedObjectSet);

接下来看C++代码,这里的中间调用过程没有深究,但最终应该是来到了这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
static void AddJavascriptInterface(JNIEnv *env, jobject obj, jint nativeFramePointer,
jobject javascriptObj, jstring interfaceName)
{
#ifdef ANDROID_INSTRUMENT
TimeCounterAuto counter(TimeCounter::NativeCallbackTimeCounter);
#endif
WebCore::Frame* pFrame = 0;
if (nativeFramePointer == 0)
pFrame = GET_NATIVE_FRAME(env, obj);
else
pFrame = (WebCore::Frame*)nativeFramePointer;
LOG_ASSERT(pFrame, "nativeAddJavascriptInterface must take a valid frame pointer!");
JavaVM* vm;
env->GetJavaVM(&vm);
LOGV("::WebCore:: addJSInterface: %p", pFrame);
#if USE(JSC)
// Copied from qwebframe.cpp
JSC::JSLock lock(false);
WebCore::JSDOMWindow *window = WebCore::toJSDOMWindow(pFrame);
if (window) {
JSC::Bindings::RootObject *root = pFrame->script()->bindingRootObject();
JSC::Bindings::setJavaVM(vm);
// Add the binding to JS environment
JSC::ExecState* exec = window->globalExec();
JSC::JSObject *addedObject = WeakJavaInstance::create(javascriptObj,
root)->createRuntimeObject(exec);
const jchar* s = env->GetStringChars(interfaceName, NULL);
if (s) {
// Add the binding name to the window's table of child objects.
JSC::PutPropertySlot slot;
window->put(exec, JSC::Identifier(exec, (const UChar *)s,
env->GetStringLength(interfaceName)), addedObject, slot);
env->ReleaseStringChars(interfaceName, s);
checkException(env);
}
}
#endif // USE(JSC)
#if USE(V8)
if (pFrame) {
const char* name = JSC::Bindings::getCharactersFromJStringInEnv(env, interfaceName);
NPObject* obj = JSC::Bindings::JavaInstanceToNPObject(new JSC::Bindings::JavaInstance(javascriptObj));
pFrame->script()->bindToWindowObject(pFrame, name, obj);
// JavaInstanceToNPObject calls NPN_RetainObject on the
// returned one (see CreateV8ObjectForNPObject in V8NPObject.cpp).
// BindToWindowObject also increases obj's ref count and decrease
// the ref count when the object is not reachable from JavaScript
// side. Code here must release the reference count increased by
// JavaInstanceToNPObject.
_NPN_ReleaseObject(obj);
JSC::Bindings::releaseCharactersForJString(interfaceName, name);
}
#endif
}

这里的代码量较大,我们主要关注下面这一行代码:

1
2
window->put(exec, JSC::Identifier(exec, (const UChar *)s, 
env->GetStringLength(interfaceName)), addedObject, slot);

最终数据的处理原来来自于C++端的window对象,这又是什么呢?继续看:

1
WebCore::JSDOMWindow *window = WebCore::toJSDOMWindow(pFrame);

这是在WebCore命名空间下面的JSDOMWindow对象,看到这里,其实大多数同学应该已经都没有兴趣看下去了。这实在是一个冗长的调用过程,而且在阅读源码过程中,我们还忽略多进程调用,忽略各种细节。对此,关于这段源码的阅读,我们暂且告一段落,等时间充裕,我再来补充。

总结

这次的问题牵扯了移动端、Web前端和后台,这种跨平台的问题解决起来的确存在很大的困难。其实,我已经很长时间没有写JS了,仅仅在几个月前使用RN的时候有了解一些ES6的语法。凭借刚刚工作时仅有的2个月JS经验,加上在多方面知识的累积,总算顺利解决了问题。其实,根据我的经验来看,越是看起来无头绪的问题,往往越是一个极其简单的问题。为了避免出现这种问题,在编码过程中,必须小心翼翼。尽量多检查几次,避免出现类似这样的错误。另外,要尝试接受不一样的观点,如果你一开始就接受了其他人的观点,在解决问题上就会有很强的目的性,解决问题的速度也就更快。

最后,新的一年里,祝大家万事如意,阖家欢乐,工作顺顺利利,身体健健康康。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×