CallteFoot's blog

Victory belongs to the most persevering


  • 首页

  • 归档

  • 分类

  • 标签

  • 关于

Android Support库:AsynListUtil

发表于 2018-06-14 | 分类于 Android , Support

    在介绍完 Android Support 库发展历程(http://blog.chengyunfeng.com/?p=1047)后, 再分别介绍下 Android Support 库中有用但是被忽略的一些功能。了解这些功能,在需要的时候可以避免在引入其他类似的第三方库或者避免自己重复制造轮子,提高开发效率。

    这是一系列的介绍文章,每次介绍 Support 库中的一个功能,篇幅尽可能的短,方便大家利用碎片时间阅读。

    这次介绍来自 recyclerview-v7 中的 AsyncListUtil 类。

RecyclerView

    RecyclerView 发布后大家应该都放弃使用 ListView 了。使用 RecyclerView 需要对数据控制的更精细,比如如果删除了某个位置的数据,则需要使用 RecyclerView.Adapter 的 notifyItemRemoved(int position) 函数来通知 RecyclerView 被删除的数据,这样 RecyclerView 就可以实现一个删除该条数据的动画,而 ListView 只要数据变化了,都只需要调用 notifyDataSetChanged() 即可。

    而在使用 RecyclerView 过程中数据来源是各种各样的,数据可以来自于网络、也可以是数据库、还可以是读取文件,不同的数据来源对 Adapter 的处理也是有差别的。本次介绍的 AsyncListUtil 适用于数据来源数据库或者本地磁盘的情况。

AsyncListUtil

    AsyncListUtil 是一个用来异步加载数据的类。例如,如果你的数据是来至于数据库的,则可以使用 AsyncListUtil 在后台批量加载 Cursor 中的数据,当数据加载完成后在通知 UI 线程使用,并且 AsyncListUtil 支持数据缓存,这样可以实现非常流畅的滚动性能。

    AsyncListUtil 在后台线程加载数据,每次加载一批数据,数据的个数是可以配置的,并且在内存中缓存了一些数据来提高效率。

    AsyncListUtil 使用一个后台线程来加载数据,所以 AsyncListUtil 适合用来从磁盘加载数据的情况(比如,读取数据库、读取文件等),而不适合从网络获取数据的情况。

    AsyncListUtil 有如下四个函数可以使用:

  • T getItem (int position): 返回 position 位置的数据,如果数据尚未加载完成,则返回 null;
  • int getItemCount () 返回数据集的个数,该数值为 DataCallback.refreshData() 函数返回的值
  • void onRangeChanged () 更新当前数据可见范围,当 RecyclerView 滚动的时候,需要回调这个函数来告诉 AsyncListUtil 可见数据范围发生变化,需要根据当前新的可见数据范围来加载所需数据,在 RecyclerView 的 onScrolled(RecyclerView, int, int)) 中调用该函数即可。
  • void refresh () : 如果数据发生了变化, 调用该函数来强制重新加载数据,丢弃之前缓存的数据。
    AsyncListUtil 有两个回调函数分别处理数据加载和 UI 通知事件:AsyncListUtil.DataCallback 和 AsyncListUtil.ViewCallback。

AsyncListUtil.DataCallback

    DataCallback 类提供了在后台加载数据的功能,里面定义了如下四个函数,这些函数都是在后台线程中执行的:

  • int refreshData () : 刷新数据,返回新的数据个数,如果你所加载的数据来自于 Cursor,则当数据发生变化的时候,在这里从新生成一个 Cursor 并返回 Cursor 的数目。
  • void fillData (T[] data, int startPosition, int itemCount) :加载数据,其中 data 数组是加载后的数据,而 startPosition 是数据开始的位置,而 itemCount 是加载数据的 个数。 data 数组中的对象是可以重复利用的,如果 data[i] 的数据不为 null,则可以重复利用而无需重新创建数据对象。
  • int getMaxCachedTiles () : 返回缓存数量的限制,一般不用实现这个函数,默认实现即可。
  • void recycleData (T[] data, int itemCount) : 回收 fillData 中所创建的数据,如果你的数据包含需要回收的资源,则可以在这里处理。

    一般只需要实现 refreshData 和 fillData 函数即可。

AsyncListUtil.ViewCallback

    ViewCallback 类提供了和 RecyclerView 关联的接口,里面的函数都是在 UI 线程中调用的,该类同样定义了四个函数:

  • void onItemLoaded (int position) : 当 position 位置的数据加载完后会调用该函数;
  • void onDataRefresh () : 当数据集发生变化的时候调用;
  • void getItemRangeInto (int[] outRange) : 返回当前可见的数据范围,参数 outRange 数组长度为2, outRange[0] 为第一个可见数据的位置,outRange[1] 为 最后一个可见数据的位置;
  • void extendRangeInto (int[] range, int[] outRange, int scrollHint) 用来计算平滑滚动所需要加载的额外数据,一般不用实现这个函数,默认实现即可;

参考:
[1]. 示例代码 https://github.com/CattleFoot/RecyclerViewUtils
[2]. 官网文档 https://developer.android.google.cn/reference/android/support/v7/util/AsyncListUtil
[3]. 译文 http://blog.chengyunfeng.com/?p=1057
[4]. 分页加载ORM数据 https://blog.csdn.net/zhangphil/article/details/78661838

java单例模式

发表于 2018-06-13 | 分类于 Java

简介

  1. 什么是Singleton类?你之前用过Singleton吗?
    Singleton是一个类,在整个应用程序中只有一个实例,并提供一个getInstance()方法来访问单例实例。在JDK中有许多类是使用Singleton模式实现的,如java.lang.Runtime,它提供了getRuntime()方法来访问它并用于获得Java中的可用内存和总内存。

  2. 单例模式优缺点:

    • 优点:
      • 提供了对唯一实例的受控访问;
      • 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象单例模式无疑可以提高系统的性能;
      • 允许可变数目的实例;
    • 缺点:
      • 由于单利模式中没有抽象层,因此单例类的扩展有很大的困难;
      • 单例类的职责过重,在一定程度上违背了“单一职责原则”;
      • 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失。

单例模式的五种实现

  1. 懒汉

    • 1.1 懒汉,线程不安全
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      public class Singleton {  
      private static Singleton instance;
      private Singleton (){}

      public static Singleton getInstance() {
      if (instance == null) {
      instance = new Singleton();
      }
      return instance;
      }
      }

    这种写法lazy loading很明显,但是致命的是在多线程不能正常工作。

    • 1.2 懒汉,线程安全
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public class Singleton {  
      private static Singleton instance;
      private Singleton (){}
      public static synchronized Singleton getInstance() {
      if (instance == null) {
      instance = new Singleton();
      }
      return instance;
      }
      }

    这种写法能够在多线程中很好的工作,而且看起来它也具备很好的lazy loading,但是,遗憾的是,效率很低,99%情况下不需要同步。

  2. 饿汉

    • 2.1 饿汉,基本型
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      public class Singleton {  
      private static Singleton instance = new Singleton();
      private Singleton (){}
      public static Singleton getInstance() {
      return instance;
      }
      }
      ```
      这种方式基于classloder机制避免了多线程的同步问题,不过,instance在类装载时就实例化,虽然导致类装载的原因有很多种,在单例模式中大多数都是调用getInstance方法, 但是也不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化instance显然没有达到lazy loading的效果。

      - 2.2 饿汉,变种
      ```Java
      public class Singleton {
      private Singleton instance = null;
      static {
      instance = new Singleton();
      }
      private Singleton (){}
      public static Singleton getInstance() {
      return this.instance;
      }
      }

    表面上看起来差别挺大,其实跟2.1方式差不多,都是在类初始化即实例化instance。

  3. 静态内部类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class Singleton {  
    private static class SingletonHolder {
    private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton (){}
    public static final Singleton getInstance() {
    return SingletonHolder.INSTANCE;
    }
    }

    这种方式同样利用了classloder的机制来保证初始化instance时只有一个线程,它跟2.1和2.2方式不同的是(很细微的差别):2.1和2.2方式是只要Singleton类被装载了,那么instance就会被实例化(没有达到lazy loading效果),而这种方式是Singleton类被装载了,instance不一定被初始化。因为SingletonHolder类没有被主动使用,只有显示通过调用getInstance方法时,才会显示装载SingletonHolder类,从而实例化instance。想象一下,如果实例化instance很消耗资源,我想让他延迟加载,另外一方面,我不希望在Singleton类加载时就实例化,因为我不能确保Singleton类还可能在其他的地方被主动使用从而被加载,那么这个时候实例化instance显然是不合适的。这个时候,这种方式相比2.1和2.2种方式就显得很合理。

  4. 双重校验锁
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class Singleton {  
    private volatile static Singleton singleton;
    private Singleton (){}
    public static Singleton getSingleton() {
    if (singleton == null) {
    synchronized (Singleton.class) {
    if (singleton == null) {
    singleton = new Singleton();
    }
    }
    }
    return singleton;
    }
    }

这个是1.2方式的升级版,俗称双重检查锁定,详细介绍请查看:http://www.ibm.com/developerworks/cn/java/j-dcl.html
在JDK1.5之后,双重检查锁定才能够正常达到单例效果。

  1. 枚举
    1
    2
    3
    4
    5
    public enum Singleton {  
    INSTANCE;
    public void whateverMethod() {
    }
    }

这种方式是Effective Java作者Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还能防止反序列化和通过反射重新创建新的对象,可谓是很坚强的壁垒啊,这种方式在1.5中才加入的enum特性。

单例模式的安全性问题

懒汉式、饿汉式、内部类、双重锁、枚举这5种单例模式中,枚举最为特殊,由于是官方提供的一种模式,所以不能被破解,是十分安全的。其他四种在一定程度上会有一定安全问题。

关于懒汉式的单利破解:

  1. 用反射破解单例模式:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import java.lang.reflect.Constructor;  
    public class Client {
    public static void main(String[] args) throws Exception {
    Singleton s011 = Singleton.getInstance();
    Singleton s012 = Singleton.getInstance();

    //利用反射创建新对象
    Class<Singleton> h1 = (Class<Singleton>) Class.forName("Singleton");//反射获取H1对象
    Constructor c = h1.getDeclaredConstructor(null);//获取无参构造函数
    c.setAccessible(true);//更改无参构造函数权限为公开
    Singleton s013 = (Singleton) c.newInstance(null);//创建新对象

    System.out.println(s011);
    System.out.println(s012);
    System.out.println(s013);
    System.out.println(s011 == s012);
    System.out.println(s011 == s013);
    }
    }

输出的结果:

1
2
3
4
5
Singleton@1bc4459  
Singleton@1bc4459
Singleton@12b6651
true
false

  1. 用反序列化(需要实现Serializable)单例模式:
    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
    import java.io.FileInputStream;  
    import java.io.FileOutputStream;
    import java.io.ObjectInputStream;
    import java.io.ObjectOutputStream;
    public class Client {
    public static void main(String[] args) throws Exception {
    Singleton s011 = Singleton.getInstance();
    Singleton s012 = Singleton.getInstance();

    //利用反序列化创建新对象
    FileOutputStream fos = new FileOutputStream("d:/a.txt");
    ObjectOutputStream oos = new ObjectOutputStream(fos);
    oos.writeObject(s011);
    fos.close();
    oos.close();

    FileInputStream fis = new FileInputStream("d:/a.txt");
    ObjectInputStream ois = new ObjectInputStream(fis);
    Singleton s013 = (Singleton) ois.readObject();
    fis.close();
    ois.close();

    System.out.println(s011);
    System.out.println(s012);
    System.out.println(s013);
    System.out.println(s011 == s012);
    System.out.println(s011 == s013);
    }
    }

输出的结果是:

1
2
3
4
5
Singleton@13bad12  
Singleton@13bad12
Singleton@1a626f
true
false

以上知道了如果破解,那么如果避免,代码应该这样写:

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
import java.io.ObjectStreamException;  
import java.io.Serializable;

/**
* 懒汉式模式:在类加载时不初始化,当需要时再反馈(延迟加载)
*/
public class Singleton implements Serializable{
private static Singleton instance = null;
//私有化构造函数
private Singleton(){
if(instance != null){//防止反射破解
throw new RuntimeException();
}
}


public static synchronized Singleton getInstance(){
//添加synchronized防止多线程插入问题
if(instance == null){
instance = new Singleton();
}
return instance;
}

// 防止反序列化破解
private Object readResolve() throws ObjectStreamException {
return wife;
}
}

总结

有两个问题需要注意:

  1. 如果单例由不同的类装载器装入,那便有可能存在多个单例类的实例。假定不是远端存取,例如一些servlet容器对每 个servlet使用完全不同的类装载器,这样的话如果有两个servlet访问一个单例类,它们就都会有各自的实例。
    该问题修复的办法是:

    1
    2
    3
    4
    5
    6
    7
    private static Class getClass(String classname) throws ClassNotFoundException {     
    ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
    if(classLoader == null)
    classLoader = Singleton.class.getClassLoader();
    return (classLoader.loadClass(classname));
    }
    }
  2. 如果Singleton实现了java.io.Serializable接口,那么这个类的实例就可能被序列化和复原。不管怎样,如果你序列化一个单例类的对象,接下来复原多个那个对象,那你就会有多个单例类的实例。
    该问题修复的办法是:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class Singleton implements java.io.Serializable {     
    public static Singleton INSTANCE = new Singleton();
    protected Singleton() {
    }
    private Object readResolve() {
    //主要在这里!!!
    return INSTANCE;
    }
    }

参考:
[1]. 单例模式的实现 https://blog.csdn.net/liangxw1/article/details/51353654
[2]. 单例模式面试问题 https://blog.csdn.net/u012613251/article/details/79477525
[3]. 单例模式的破解 https://blog.csdn.net/a445849497/article/details/57531266
[4]. 单例模式的优缺点 https://blog.csdn.net/tayanxunhua/article/details/8250329
[5]. 枚举实现单例模式更好 https://blog.csdn.net/normallife/article/details/51152246

DDComponentForAndroid原理剖析

发表于 2018-06-06 | 分类于 Gradle , Android

原理

DDComponentForAndroid组件化实现原理主要基于两点:

  1. 静态注解:作用是实现路由的扫描、路由注册接口实现、路由表生成、路由参数的传递;
  2. gladle plugin:作用是动态调整编译脚本、资源管理和类修改;

实现

注解知识点和自定义注解请参考:
      DDComponentForAndroid中定义了两个注解:RouteNode(路由节点)和Autowired(参数装配),这个两个注解分别用于标记那些页面(DD中指的时activity)可以作为一个路由节点和这个页面需要那些参数。再看看DDComponentForAndroid中怎么根据这个两个注解去实现路由的扫描、路由注册接口的实现、路由表的生成、路由参数注入。

  1. RouteNode注解的注解处理器为RouterProcessor,该注解处理器主要做以下几件事情:
    • 解析注解:查找所有activity上标注了RouteNode注解元素,并记录路由节点同时扫描Autowired注解同样记录注解标记的参数和参数类型;
    • 路由注册接口的实现,根据gradle脚本下host属性并结合RouteNode中的path值生成一个java类该类通过继承BaseCompRouter然后实现getHost和initMap分别返回host标志和向父类登记路由(path);
    • 生成路由表:依照解析注解时记录的路由节点生成一个txt文件,文件写明了host名称,path路径和每个节点需要的参数;
  2. Autowired注解的注解处理器为AutowiredProcessor,该注解处理器主要做以下几件事情:
    • 解析注解:查找所有activity中属性标注了Autowired注解元素,并根据activity名称进行分类记录下标记的属性;
    • 生成参数注入的辅助类:依照解析注解记录的参数分类记录为每个activity生成一个注入辅助类(类名后缀为:$$Router$$Autowired,这个可以根据自己喜好来的),这个辅助类是类ISyringe接口的实现;
      sign
            以上静态注解处理器生成的java类都在[module名称]/build/generated/source/apt下的debug或release下。而路由表的生成路径就看源码里面设置的在哪里,主要还是注解处理器生成的java类。

通过以上分析,静态注解部分工作已经完成,也可以看到所有的结果还是在具体module下,各个module还是对非自身内的路由不可见的,结下就是让各个module的路由对其他module可见,这个呢则是通过gladle plugin来达到这个目的。接下来分析gladle plugin如何去实现各个module的路由可见。

gladle plugin主要工作是动态调整编译脚本、资源管理和类修改,进一步看下其是如何实现的;

  1. 动态调整编译脚本:

    • 记录当前assemble的module的名称,也就是点击AS的右三角运行的module;
    • 查找module下gradle配置文件中的isRunAlone属性,同时查找根项目下的gradle配置文件中的mainModuleName属性;
    • 根据上面两步的结果,判定当前module是主项目还是依赖的项目,如果是主项目则动态设置编译脚本应用的插件为application,
      1
      project.apply plugin: 'com.android.application'

    如果是依赖的项目则动态设置编译脚本应用的插件为library,

    1
    project.apply plugin: 'com.android.library'

    通过动态调整编译脚本,现在已经实现了各个module的class文件对其他module是可见的了,但是目前还是不能实现路由间的跳转,继续分析。

  2. 资源文件管理:
    • 在第1步中前两步知道当前那些module是依赖的项目,注意!!android中application和library的AndroidManifest,因此对于依赖的项目中的AndroidManifest文件进行管理或者修改,DDComponentForAndroid通过指定另外的路径来加载library的AndroidManifest文件。
  3. 类修改:

    • 扫描所有项目的directory文件,扫描出application文件并记录;
    • 扫描所有项目的directory文件,扫描出实现了接口IApplicationLike的文件并记录,应为在IApplicationLike实现类中会去注册路由实现类;

    通过类修改,这个样就达到了路由间的跳转。自此组件化就达到了,而且各个组件间可以独立调试无需手动再次设置。

Android自定义注解处理器

发表于 2018-05-29 | 分类于 Android , Annotation

理论

&npsb;&npsb;&npsb;&npsb;在开发中经常使用到注解,最近在研究DDComponent插件化时又碰到了。因此,也再次深入了解了注解,记录下在学习过程中学到的一些知识。目前比较主流的框架也多数用到了注解技术,
如:ButterKnife、Dagger2、Retrofit、Glide等。
&npsb;&npsb;&npsb;&npsb;注解一般分为两种:运行时注解;编译时注解。
运行时注解:一般配合反射机制使用,相对编译时注解性能比较低,但灵活性好。例如:Retrofit用的就是运行时注解。
编译时注解:编译时注解能够自动处理java源文件,并可以根据需要生成新的文件。

基础知识点-元注解

&npsb;&npsb;&npsb;&npsb;元注解的作用是负责注解其他注解,Java5.0定义了 4个标准的meta-annotation类型,它们被
用来提供对其它 annotation类型作说明。Java5.0定义的元注解:@Target、@Retention、@Documented、@Inherited。

  1. @Target:说明了Annotation所修饰的对象范围,其取值为枚举类java.lang.annotation.ElementType。
    取值类型有:
    • TYPE:Class, interface (including annotation type), or enum declaration(用于描述类、接口(包括注解类型) 或enum声明);
    • FIELD:Field declaration (includes enum constants)(用于描述域)
    • METHOD:Method declaration(用于描述方法);
    • PARAMETER:Formal parameter declaration(用于描述参数);
    • CONSTRUCTOR:Constructor declaration(用于描述构造器);
    • LOCAL_VARIABLE:Local variable declaration(用于描述局部变量);
    • ANNOTATION_TYPE:Annotation type declaration(用于注解类型声明);
    • PACKAGE:Package declaration(用于描述包);
    • TYPE_PARAMETER:Type parameter declaration(用于类型定义),since 1.8;
    • TYPE_USE:Use of a type(?????),since 1.8;
  2. @Retention:定义了Annotation被保留的时间长短,其取值为枚举类java.lang.annotation.RetentionPolicy。
    取值类型有:
    • SOURCE:Annotations are to be discarded by the compiler(注解仅存在于源码中,在class字节码文件中不包含);
    • CLASS:默认的保留策略,注解会在class字节码文件中存在,但运行时无法获得;
    • RUNTIME:注解会在class字节码文件中存在,在运行时可以通过反射获取到;
  3. @Documented:用于描述其它类型的annotation应该被作为被标注的程序成员的公共API,因此可以被例如javadoc此类
    的工具文档化。Documented是一个标记注解,没有成员。
  4. @Inherited:阐述了某个被标注的类型是被继承的,同样是一个标记注解,没有成员。

自定义注解

&npsb;&npsb;&npsb;&npsb;使用@interface自定义注解时,自动继承了java.lang.annotation.Annotation接口,由编译程序自动完成其他细节。在定义注解时,不能继承其他的注解或接口。@interface用来声明一个注解,其中的每一个方法实际上是声明了一个配置参数。方法的名称就是参数的名称,返回值类型就是参数的类型(返回值类型只能是基本类型、Class、String、enum)。可以通过default来声明参数的默认值。

定义注解格式:

  public @interface 注解名 {定义体}
  注解参数的可支持数据类型:
    1. 所有基本数据类型(int,float,boolean,byte,double,char,long,short)
    2. String类型
    3. Class类型
    4. enum类型
    5. Annotation类型
    6. 以上所有类型的数组
  Annotation类型里面的参数该怎么设定:
第一,只能用public或默认(default)这两个访问权修饰.例如,String value();这里把方法设为defaul默认类型;
第二,参数成员只能用基本类型byte,short,char,int,long,float,double,boolean八种基本数据类型和 String,Enum,Class,annotations等数据类型,以及这一些类型的数组.例如,String value();这里的参数成员就为String;
第三,如果只有一个参数成员,最好把参数名称设为”value”,后加小括号。
一个简单的自定义注解:

1
2
3
4
5
6
7
8
9
10
11
12
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FruitName {
String value() default "";
}

注解处理器

&npsb;&npsb;&npsb;&npsb;如果没有用来读取注解的方法和工作,那么注解也就不会比注释更有用处了。使用注解的过程中,很重要的一部分就是创建于使用注解处理器。Java SE5扩展了反射机制的API,以帮助程序员快速的构造自定义注解处理器。

注解处理器类库(java.lang.reflect.AnnotatedElement

&npsb;&npsb;&npsb;&npsb;Java使用Annotation接口来代表程序元素前面的注解,该接口是所有Annotation类型的父接口。除此之外,Java在java.lang.reflect 包下新增了AnnotatedElement接口,该接口代表程序中可以接受注解的程序元素,该接口主要有如下几个实现类:
  - Class:类定义
  - Constructor:构造器定义
  - Field:累的成员变量定义
  - Method:类的方法定义
  - Package:类的包定义

&npsb;&npsb;&npsb;&npsb;java.lang.reflect 包下主要包含一些实现反射功能的工具类,实际上,java.lang.reflect 包所有提供的反射API扩充了读取运行时Annotation信息的能力。当一个Annotation类型被定义为运行时的Annotation后,该注解才能是运行时可见,当class文件被装载时被保存在class文件中的Annotation才会被虚拟机读取。
&npsb;&npsb;&npsb;&npsb;AnnotatedElement接口是所有程序元素(Class、Method和Constructor)的父接口,所以程序通过反射获取了某个类的AnnotatedElement对象之后,程序就可以调用该对象的如下四个个方法来访问Annotation信息:

  1. T getAnnotation(Class annotationClass): 返回该程序元素上存在的、指定类型的注解,如果该类型注解不存在,则返回null。
  2. Annotation[] getAnnotations():返回该程序元素上存在的所有注解。
  3. boolean is AnnotationPresent(Class<?extends Annotation> annotationClass):判断该程序元素上是否包含指定类型的注解,存在则返回true,否则返回false。
  4. Annotation[] getDeclaredAnnotations():返回直接存在于此元素上的所有注释。与此接口中的其他方法不同,该方法将忽略继承的注释。(如果没有注释直接存在于此元素上,则返回长度为零的一个数组。)该方法的调用者可以随意修改返回的数组;这不会对其他调用者返回的数组产生任何影响。

    示例程序:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public static void getFruitInfo(Class<?> clazz){

    String strFruitName=" 水果名称:";

    Field[] fields = clazz.getDeclaredFields();

    for(Field field :fields){
    if(field.isAnnotationPresent(FruitName.class)){
    FruitName fruitName = (FruitName) field.getAnnotation(FruitName.class);
    strFruitName=strFruitName+fruitName.value();
    System.out.println(strFruitName);
    }
    }
    }

    &npsb;&npsb;&npsb;&npsb;以上代码是理论部分提到的运行时注解,在运行时配合反射机制使用。早期的butterknife确实是使用的动态注解的方式。可是后来,静态注解出现了,如燎原之火般席卷而来。接下来是静态注解。

Android自定义注解处理器

&npsb;&npsb;&npsb;&npsb;在Android中自定义注解处理器一般通过继承AbstractProcessor类来实现,通过process方法进行处理。需要注意的是,注解处理器只能生成新的文件,不能修改已存在的源文件。AbstractProcessor类是接口Processor类,其在Java1.7加入,用于处理编译时的注解。
自定义注解处理其主要分为6个步骤:

第一步 创建Java Library项目

      首先我们新建一个Java Library项目,来作为注解处理器模块。注意是Java Library,不是Android Library。因为我们要用到的是javax包中的类,而Android Library中的JDK不包含这些类。

第二步 新建自定义注解

1
2
3
4
5
6
7
8
@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.CLASS)
public @interface RouteNode {
String path();

String desc() default "";
}

第三步 新建自定义注解处理器

1
2
3
4
5
6
public class RouteNodeProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
return false;
}
}

      通过新建的AutowiredProcessor,知道要实现抽象类
AbstractProcessor的process方法,在这个方法中处理注解与生成新文件的。另外需要注意的是:
1.需要配置注解处理器支持处理的注解;2.需要指定支持java的源码版本,这两点我们可以通过Override
抽象类AbstractProcessor的getSupportedAnnotationTypes()和getSupportedSourceVersion()方法类配置,或者通过注解的形式来配置,这个稍后再升级下。

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

public class RouteNodeProcessor extends AbstractProcessor {
/**
* 该处理器支持的所有注解类集合,在这里可以添加自定义注解
*/
@Override
public Set<String> getSupportedAnnotationTypes() {
Set<String> set = new HashSet<>();
// 添加自定义注解
set.add(RouteNode.class.getCanonicalName());
return set;
}

/**
* 该处理器支持的JDK版本,例如:SourceVersion.RELEASE_7
* 一般返回SourceVersion.latestSupported()
*/
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}

@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
return false;
}
}

第四步 处理process()方法,并生成文件

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
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
StringBuilder sb = new StringBuilder("package com.xuhj.java.processor;\n")
.append("public class GeneratedTemplate{\n")
.append("\tpublic String getMessage(){\n")
.append("\t\treturn \"");
for (Element element : roundEnvironment.getElementsAnnotatedWith(Template.class)) {
String objectType = element.getSimpleName().toString();
sb.append(objectType).append(" say hello!\\n");
}
sb.append("\";\n")
.append("\t}\n")
.append("}\n");
try {
JavaFileObject source = processingEnv.getFiler()
.createSourceFile("com.xuhj.java.processor.generated.GeneratedTemplate");
Writer writer = source.openWriter();
writer.write(sb.toString());
writer.flush();
writer.close();
} catch (Exception e) {

}
return true;
}

      通过以上我们自定义注解处理器已经写好了,下一步就是如何集成到具体项目中。在这里我们通过StringBuilder的方式一点一点来拼写Java代码,较为繁琐还容易写错,目前有第三方的开源库来处理java代码的生成,如:javapoet。

第五步 声明自定义注解处理器

      在使用前,还需要声明注解处理器,也即广而告之当前项目下有那些注解处理器。
其声明主要在 main目录 下,新建 resource/META-INF/services目录,并在目录下新建一个 javax.annotation.processing.Processor 文件,这个也可以通过注解的形式来配置,这个稍后再升级下。
在文件中使用文本声明已经编写好的注解处理器,每个注解处理器各占一行。
最后执行 Make Project,就可以在build目录下看到生成好的jar包。可以直接拿jar包集成到项目中或者依赖该项目亦可。

第六步 集成到项目中

     通过将生成的jar包放到项目的libs中或者使项目依赖注解处理器的项目,就可以使用注解处理器了,执行Make Project,就会在项目的build/generated/source/apt/debug下看到通过注解处理器生成的java文件。
     如果没有生成java文件,请检查注解器的声明是否写错。

总结
     通过上面自定义注解处理器已经可以使用了,其主要是通过继承抽象AbstractProcessor类并实现process方法,同时配置注解器支持的java版本和注解,最后对外声明注解器本身在哪里即可。用注解处理器的好处是可以自动生成一些重复大量的代码,并且能让类变得干净、逻辑清晰。

升级

     在第三步和第五步讲到可以通过注解的形式来配置注解器支持的java版本、注解和声明,为何要这样做呢,主要是在声明的时候容易人为的输入容易出错而且麻烦。使用注解配置前,需要引入注解所在的jar
包(com.google.auto.service:auto-service)。

1
2
3
dependencies {
implementation ‘com.google.auto.service:auto-service:1.0-rc2’
}

注意auto-service的不同版本(存在兼容性),然后通过注解来配置和声明:

1
2
3
4
5
6
7
8
9
//声明当前类为是注解处理器,Processor.class为静态注解入口也是一个接口
@AutoService(Processor.class)
//支持的注解
@SupportedAnnotationTypes("com.mugwort.annotation.RouteNode")
//支持的java版本
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class RouteNodeProcessor extends AbstractProcessor {
......
}

注意 这里有一点不好就是注解的支持只能手写。

调试注解器

编写代码不可避免会出现错误的,如何定位错误则需要调试的,而注解器如何调试呢?调试有两种方式:日志定位;断点调试;

  1. 日志定位,繁琐但方便快捷:

    1
    2
    3
    4
    // 1. sout
    System.out.println(xxx)
    // 2. messager
    processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "only support field");
  2. 断点调试:
    注解器的断点调试比较麻烦点,不像java那样,其主要分为三个步骤。

    1. 配置debug后台服务,在在gradle.properties文件中加入下面两句话,然后sync一下项目(或者在控制台执行./gradlew –daemon),会开启一个远程debug_server。
    1
    2
    org.gradle.daemon=true
    org.gradle.jvmargs=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
    1. 配置Remote Debugger,在AS中创建一个Remote Debugger。

    2. 执行编译过程,在需要的位置打开断点,在控制台输入

      1
      ./gradlew assembleDebug

或者,待清除功能的编译

1
./gradlew clean assembleDebug

配置没问题的话就会走到打开的断点,然后就可以调试了。

mvc_mvp和mvvm理解

发表于 2018-04-15 | 分类于 模式

前言

    MVC、MVP和MVVM是Android耳熟能详的三个模式,且应用广泛。对于三者的内容、区别、使用场景有时很能道出个123,而且这些框架思想在各个平台都有,但对于各个平台具体的实现有一定差异和限制。本文主要从Android方面来介绍它们。主要有:

  • 了解并区分MVC、MVP和MVVM;
  • 它们在Android中如何使用;
  • 走出data binding的误区;
  • 理解MVP+data binding开发模式;

MVC、MVP和MVVM基础介绍

    水之积也不厚,则其负大舟也无力:正如庄子逍遥游所说,水不深则没有能够担负大船的力量。在涉及代码前,我们需要对MVC、MVP和MVVM有足够的了解。

MVC

    MVC:(Model View Controller)是软件架构中常见的一种模式,其通过controller层的控制去操作model层的数据,并且返回给view层展示,具体如下:
MVC执行图
    其工作原理为:当用户触发事件的时候,view层会发送指令到controller层,接着controller去通知model层更新数据,model层更新晚数据以后直接显示在view层。

    那具体到android上又是如何?,我们知道android工程有java的class文件、有res文件夹、asset、还有manifest文案等等,对于原生的android项目来说,layout.xml里面的xml
文件就对应于MVC的view层,而各种java bean、还有类是repository类就是对应于model层,至于controller层,则是各种activity。当我们在页面触发一个按钮去加载网络资源时,加载网络资源xiangg代码和结果类,则是model层,view和model的关联则是通过按钮的点击事件,这个在activity中,也就是contrller层。android中完整的MVC流程就是这样。
    Android这样处理有何问题?显然有的。问题在于xml作为view层,其控制
内里太弱,当要改变一个页面的背景或者动态控制按钮的隐藏或显示,这些都只能在activity中控制,这样
activity的cotroller职责就多了。不符合六大原子的 单一原则。
     通过以上我们知道MVC重要的缺陷是view层和model层是相互可知,意味着两层之间存在耦合。这是因为这个缺陷,才演化出MVP和MVVM两种框架。

MVP

    MVP(View、Presenter、model)作为MVC的演化,解决了MVC不少缺点。对于Android来说,MVP的model层相对于MVC是一样的,而activity和fragment不在是controller层,而是纯粹的view层,所有相关用户的事件的转发全部交由presenter处理,其原理图如下:
MVP原理图
    由图我们可知,最明显的差别就是view层和model层不在相互告知,完全解耦,取而代之的是有presenter层充当桥梁作用,用于操作view层发出的事件传递到presenter层中,presenter层去操作model层,,并将数据返回给view层,整个过程中view层和model层完全没有关系。也许有人会问,虽然view层和model层解耦了,但是view层和presenter层不是耦合在一起吗?其实不是的,对于view层和presenter的通行是可以通过接口实现的,也就是说activity,fragment可以去实现定义好的接口,而在对应的presenter中通过接口调用方法。同时,我们还可以编写测试用的view,模拟用户操作,从而实现对presenter的测试,解决了MVC模式中测试,维护难问题。
当然,其实最好的方式是使用fragment作为view层,而activity则是用于创建view层(fragment)和presenter层(presenter)的一个控制器。

MVVM

    MVVM(View、ViewModel、Model)最早有微软提供的,其原理图如下:
MVVM原理图
    由图可知,其和MVP的区别不到,只不过是presenter层换成viewmodel层,还有就是view层和viewmodel层是相互绑定的关系,意味着当更新viewmodel层的数据的时候,view层会相应的变动ui。
     很难说MVP和MVVM这两个MVC的变种孰优孰劣,需要具体分析。

纸上得来终觉浅,绝知此事要躬行

路漫漫其修远兮,吾将上下而求索

参考地址:
[1].MVC、MVP、MVVM详解 https://blog.csdn.net/jdsjlzx/article/details/51174396
[2].MVC在Android中的使用 https://blog.csdn.net/feiduclear_up/article/details/46363207
[3].Android中常见的MVC模式 https://blog.csdn.net/sylcc_/article/details/7346149
[4].MVC,MVP,MVVM与架构经验谈 https://www.cnblogs.com/wytiger/p/5305087.html
[5].平台间的MVC、MVP、MVVM http://www.cnblogs.com/indream/p/3602348.html
[6].前端MVC、MVP、MVVM http://www.ruanyifeng.com/blog/2015/02/mvcmvp_mvvm.html

插件化种种和思考

发表于 2017-06-28 | 分类于 Android

个人态度

  学习这项技术是关心技术后的本质,在项目中能不用就不用,因为本身这种做法是 Google 不推荐的,RN才是今后的发展方向。但是RN性能方面的优化也很重要,这方面没深入了解。

在项目中引入新技术

  在项目中使用新技术,哪怕是引入一个第三方库也要小心谨慎,确定是否确实需要。但是还是要对不了解的新的东西要积极学习、积极研究,这样在引入实际生产环境后也能快速踩坑出坑。
个人认为开发不能脱离业务,不能脱离实际场景为了技术而技术,也不能害怕引入新技术,风险和效率等优点是并存的,但是要合理的控制风险。

介绍名词

插件化 – apk 分为宿主和插件部分,插件在需要的时候才加载进来
热修复 – 更新的类或者插件粒度较小的时候,我们会称之为热修复,一般用于修复bug
热更新 – 2016 Google 的 Android Studio 推出了Instant Run 功能 同时提出了3个名词
“热部署” – 方法内的简单修改,无需重启app和Activity。 “暖部署” – app无需重启,但是activity需要重启,比如资源的修改。 “冷部署” – app需要重启,比如继承关系的改变或方法的签名变化等。

  所以站在app开发者角度的“热”是指在不发版的情况来实现更新,而Google提出的“热”是指值是否需要重新启动。 同时在开发插件化的时候也有两种情景,一种是插件与宿主apk没有交互,只是在用户使用到的时候进行一次吊起,还有一种是与宿主有很多的交互。

从MulitiDex开始

  当 Android 系统安装一个应用的时候,有一步是对 Dex 进行优化,这个过程有一个专门的工具来处理,叫 DexOpt 。DexOpt 的执行过程是在第一次加载Dex文件的时候执行的。这个过程会生成一个 ODEX 文件,即 Optimised Dex。执行 ODex 的效率会比直接执行 Dex 文件的效率要高很多。

  但是在早期的 Android 系统中,DexOpt 有一个问题,DexOpt 会把每一个类的方法 id 检索起来,存在一个链表结 构里面。但是这个链表的长度是用一个 short 类型来保存的,导致了方法 id 的数目不能够超过65536个。当一个项目足够大的时候,显然这个方法数的上限是不够的。尽管在新版本的 Android 系统中,DexOpt 修复了这个问题,但是我们仍然需要对低版本的 Android 系统做兼容。

  为了解决方法数超限的问题,需要将该dex文件拆成两个或多个,为此谷歌官方推出了 multidex 兼容包,配合 AndroidStudio 实现了一个 APK 包含多个 dex 的功能。

MulitDex 引起的问题有:

  • 在应用安装到手机上的时候dex文件的安装是复杂的(complex)有可能会因为第二个dex文件太大导致ANR。
  • 使用了mulitDex的App有可能在4.0(api level 14)以前的机器上无法启动,因为Dalvik linearAlloc bug(Issue 22586) 。
  • 使用了mulitDex的App在runtime期间有可能因为Dalvik linearAlloc limit (Issue 78035) Crash。该内存分配限制在 4.0版本被增大,但是5.0以下的机器上的Apps依然会存在这个限制。
  • 主dex被dalvik虚拟机执行时候,哪些类必须在主dex文件里面这个问题比较复杂。build tools 可以搞定这个问题。但是如果你代码存在反射和native的调用也不保证100%正确。

对于 davilk 和 art 虚拟机 Mulitdex 的不同:

  1. ART模式相比原来的Dalvik,会在安装APK的时候,使用Android系统自带的dex2oat工具把APK里面的.dex文件转化成OAT文件。
    这里说一下罗迪的快速加载 Mulitdex 方案:art虚拟机对dex优化需要很长时间,加载插件dex跳过优化实现加速。跳过会影响类加载的性能。

插件化实现方案分析对比

下面对实现插件化需要关注的一些点,和主流插件化框架进行了一些总结分析。
实现插件化需要解决的技术点:

  • 资源如何加载(资源冲突问题如何解决)?
  • 代码如何加载访问访问?
  • 插件的管理后台包括的内容?
  • 插件的增量更新问题(非必须) ?
  • 加载插件中的so库 (非必须)?

针对如上问题的解决方案:

  • 针对问题1
      原理: > 资源id是在编译时生成的,其生成的规则是0xPPTTNNNN,PP段,是用来标记apk的,默认情况下系统资源PP是01,应用程序的PP是07。TT段,是用来标记资源类型的,比如图标、布局等,相同的类型TT值相同,但是同一个TT值不代表同一种资源,例如这次编译的时候可能使用03作为layout的TT,那下次编译的时候可能会使用06作为TT的值,具体使用那个值,实际上和当前APP使用的资源类型的个数是相关联的。NNNN则是某种资源类型的资源id,默认从1开始,依次累加。
      那么我们要解决资源id问题,就可从TT的值开始入手,只要将每次编译时的TT值固定,即可是资源id达到分组的效果,从而避免重复。例如将宿主程序的layout资源的TT固定为33,将插件程序资源的layout的TT值固定为03(也可不对插件程序的资源id做任何处理,使其使用编译出来的原生的值), 即可解决资源id重复的问题了。
    固定资源id的TT值的办法也非常简单,提供一份public.xml,在public.xml中指定什么资源类型以什么TT值开头即可
      还有一个方法是通过定制过的aapt在编译时指定插件的PP段的值来实现分组,重写过的aapt指定PP段来实现id分组。
    方案一: 将插件apk资源解压,通过操作文件的方式来使用,这个只是理论上可行,实际使用的时候还是有很多的问题。(主要是混淆后就懵逼了)
    方案二: 重写 Context 的getResource() getAsset() 之类的方法。资源冲突需要扩展 aapt 实现。
    方案三: 打包后执行一个脚本修改资源ID。

  • 针对问题2
      原理说明:主要就是 classloader 加载dex,代理模式就是本身宿主中有Activity,通过欺骗系统来创建Activity,欺骗系统的部分hook的有深有浅(对比DroidPlugin和Small),让这个Activity有生命周期,而动态加载模式就是运行时动态创建并编译一个Activity类,需要使用动态创建类的工具实现动态字节码操作。
    方案一: 简单加载模式。
    方案二: Activity代理模式。
    方案三: 动态加载模式。

  • 针对问题3:

  1. 提供插件信息查询和下载,包括回滚到某一版本。
  2. 管理使用插件的apk,可以向不同版本apk 提供不同插件。
  3. MD5校检插件的合法性。

插件化现有框架分析对比:

框架名称 出现时间 作者 实现简介 已知存在问题
AndroidDynamicLoader 2012年7月 mmin18 不使用Activity采用Fragment实现
DynamicAPK 携程 扩展aapt解决资源问题
android-pluginmg houkx 动态创建Activity来实现插件化
DynamicLoadApk 2014年底 百度工程师-任玉刚 通过代理Activity来实现插件化
DroidPlugin 2015年8月 奇虎360 深度hook实现 不支持通知栏定制
Small 2015年底 林光亮 比较DroidPlugin轻量一点,脚本来解决资源问题 不支持Service插件化
ACCD 2015年4月 bunnyblue OpenAtlas 之后改为ACCD 携程开源框架参考了这个
nuwa 2015年9月 贾吉新 通过前置相同Dex来实现热修复
AndFix 2015年11月 阿里 使用JNI实现的热修复,针对Davilk和Art调用的方法不同
Dexposed 阿里 不支持Art虚拟机

来源:
http://www.ntkexun.net/inc/exchanges/android_news.asp?id=2377

nestedscollin原理

发表于 2017-06-28 | 分类于 Android

Android在发布 5.0(Lollipop)版本之后,Google为我们提供了嵌套滑动(NestedScrolling) 的特性,接下来主要介绍嵌套滑动机制是怎样的原理?

首先,NestedScrolling特性实现时使用到的4个类

  • NestedScrollingChild
  • NestedScrollingChildHelper
  • NestedScrollingParent
  • NestedScrollingParentHelper

  接下来从这四个类分析看看具体做什么的:

  1. NestedScrollingChild
    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
    74
    75
    76
    77
    public interface NestedScrollingChild {  
    /**
    * 设置嵌套滑动是否能用
    *
    * @param enabled true to enable nested scrolling, false to disable
    */
    public void setNestedScrollingEnabled(boolean enabled);

    /**
    * 判断嵌套滑动是否可用
    *
    * @return true if nested scrolling is enabled
    */
    public boolean isNestedScrollingEnabled();

    /**
    * 开始嵌套滑动
    *
    * @param axes 表示方向轴,有横向和竖向
    */
    public boolean startNestedScroll(int axes);

    /**
    * 停止嵌套滑动
    */
    public void stopNestedScroll();

    /**
    * 判断是否有父View 支持嵌套滑动
    * @return whether this view has a nested scrolling parent
    */
    public boolean hasNestedScrollingParent();

    /**
    * 在子View的onInterceptTouchEvent或者onTouch中,调用该方法通知父View滑动的距离
    *
    * @param dx x轴上滑动的距离
    * @param dy y轴上滑动的距离
    * @param consumed 父view消费掉的scroll长度
    * @param offsetInWindow 子View的窗体偏移量
    * @return 支持的嵌套的父View 是否处理了 滑动事件
    */
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);

    /**
    * 子view处理scroll后调用
    *
    * @param dxConsumed x轴上被消费的距离(横向)
    * @param dyConsumed y轴上被消费的距离(竖向)
    * @param dxUnconsumed x轴上未被消费的距离
    * @param dyUnconsumed y轴上未被消费的距离
    * @param offsetInWindow 子View的窗体偏移量
    * @return true if the event was dispatched, false if it could not be dispatched.
    */
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
    int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);



    /**
    * 滑行时调用
    *
    * @param velocityX x 轴上的滑动速率
    * @param velocityY y 轴上的滑动速率
    * @param consumed 是否被消费
    * @return true if the nested scrolling parent consumed or otherwise reacted to the fling
    */
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    /**
    * 进行滑行前调用
    *
    * @param velocityX x 轴上的滑动速率
    * @param velocityY y 轴上的滑动速率
    * @return true if a nested scrolling parent consumed the fling
    */
    public boolean dispatchNestedPreFling(float velocityX, float velocityY);

  类的方法虽然不多,但主要还是他的作用?我们想想一种场景:CoordinatorLayout里嵌套着RecyclerView和Toolbar,我们上下滑动RecyclerView的时候,Toolbar会随之显现隐藏,这是典型的嵌套滑动机制情景。这里,RecyclerView作为嵌套的子View,我们猜测,它一定实现了NestedScrollingChild 接口,上代码:

1
2
3
public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild {
..................................................................................
}

可以看出RecyclerView 实现了NestedScrollingChild 接口里的方法,我们在跟进去看看各个方法是怎么实现的?

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
@Override
public void setNestedScrollingEnabled(boolean enabled) {
getScrollingChildHelper().setNestedScrollingEnabled(enabled);
}

@Override
public boolean isNestedScrollingEnabled() {
return getScrollingChildHelper().isNestedScrollingEnabled();
}

@Override
public boolean startNestedScroll(int axes) {
return getScrollingChildHelper().startNestedScroll(axes);
}

@Override
public void stopNestedScroll() {
getScrollingChildHelper().stopNestedScroll();
}

@Override
public boolean hasNestedScrollingParent() {
return getScrollingChildHelper().hasNestedScrollingParent();
}

@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
int dyUnconsumed, int[] offsetInWindow) {
return getScrollingChildHelper().dispatchNestedScroll(dxConsumed, dyConsumed,
dxUnconsumed, dyUnconsumed, offsetInWindow);
}

@Override
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
return getScrollingChildHelper().dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
}

@Override
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
return getScrollingChildHelper().dispatchNestedFling(velocityX, velocityY, consumed);
}

@Override
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
return getScrollingChildHelper().dispatchNestedPreFling(velocityX, velocityY);
}

  通过代码我们知道,全部都是由getScrollingChildHelper()这个方法的返回对象处理了,看看这个方法是怎么实现的。

1
2
3
4
5
6
private NestedScrollingChildHelper getScrollingChildHelper() {
if (mScrollingChildHelper == null) {
mScrollingChildHelper = new NestedScrollingChildHelper(this);
}
return mScrollingChildHelper;
}

  NestedScrollingChild 接口的方法都交给NestedScrollingChildHelper这个代理对象处理了。现在我们继续深入,分析下NestedScrollingChildHelper中开始嵌套滑动startNestedScroll(int axes)方法是怎么实现的。
NestedScrollingChildHelper#startNestedScroll

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean startNestedScroll(int axes) {
if (hasNestedScrollingParent()) {
return true;
}
if (isNestedScrollingEnabled()) {//判断是否可以滑动
ViewParent p = mView.getParent();
View child = mView;
while (p != null) {
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {//回调了父View的onStartNestedScroll方法
mNestedScrollingParent = p;
ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
return true;
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}

方法主要做了:

  1. 判断是否有嵌套滑动的父View,返回值 true 表示找到了嵌套滑动的父View和同意一起处理 Scroll 事件。
  2. 用While的方式寻找最近嵌套滑动的父View ,如果找到调用父view的onNestedScrollAccepted.

从这里至少可以得出 子view在调用某个方法都会回调嵌套父view相应的方法,比如子view开始了startNestedScroll,如果嵌套父view存在,就会回调父view的onStartNestedScroll、onNestedScrollAccepted方法。

接下来,在来看看嵌套滑动父view NestedScrollingParent,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface NestedScrollingParent {

public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);

public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);

public void onStopNestedScroll(View target);

public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed);

public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);

public boolean onNestedFling(View target, float velocityX,
float velocityY,boolean consumed);

public boolean onNestedPreFling(View target, float velocityX, float velocityY);

public int getNestedScrollAxes();
}

仔细对比一下会发现,其实和子view差不多的方法,大致一一对应关系,而且它的具体实现也交给了NestedScrollingParentHelper这个代理类。

大致流程

  1. 当 NestedScrollingChild(下文用Child代替) 要开始滑动的时候会调用 onStartNestedScroll ,然后交给代理类NestedScrollingChildHelper(下文ChildHelper代替)的onStartNestedScroll请求给最近的NestedScrollingParent(下文Parent代替).
  2. 当ChildHelper的onStartNestedScroll方法 返回 true 表示同意一起处理 Scroll 事件的时候时候,ChildHelper会通知Parent回调onNestedScrollAccepted 做一些准备动作
  3. 当Child 要开始滑动的时候,会先发送onNestedPreScroll,交给ChildHelper的onNestedPreScroll 请求给Parent ,告诉它我现在要滑动多少距离,你觉得行不行,这时候Parent 根据实际情况告诉Child 现在只允许你滑动多少距离.然后 ChildHelper根据 onNestedPreScroll 中回调回来的信息对滑动距离做相对应的调整.
  4. 在滑动的过程中 Child 会发送onNestedScroll通知ChildeHelpaer的onNestedScroll告知Parent 当前 Child 的滑动情况.
  5. 当要进行滑行的时候,会先发送onNestedFling 请求给Parent,告诉它 我现在要滑行了,你说行不行, 这时候Parent会根据情况告诉 Child 你是否可以滑行.
  6. Child 通过onNestedFling 返回的 Boolean 值来觉得是否进行滑行.如果要滑行的话,会在滑行的时候发送onNestedFling 通知告知 Parent 滑行情况.
  7. 当滑动事件结束就会child发送onStopNestedScroll通知 Parent 去做相关操作.
    nestedScrolling流程图

小结
  通过上面可以知道,要实现NestedScrolling特性,我们需要使用到开始提到四个的类,并在需要一个父view与子view(父view与子view也可能为同一个view)配合来实现。

实例演示

  5.0之前的ListView是没有实现NestedScrollingChild这个接口的,如果要实现CoordinatorLayout里嵌套着ListView和Toolbar,在上下滑动ListView的时候,Toolbar会随之显现隐藏,就必须重写ListView。show code

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
package com.cjj.nestedlistview;

import android.content.Context;
import android.support.v4.view.MotionEventCompat;
import android.support.v4.view.NestedScrollingChild;
import android.support.v4.view.NestedScrollingChildHelper;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.ListView;

public class NestedListView extends ListView implements NestedScrollingChild {

private NestedScrollingChildHelper mChildHelper;
private int mLastY;
private final int[] mScrollOffset = new int[2];
private final int[] mScrollConsumed = new int[2];
private int mNestedOffsetY;

public NestedListView(Context context) {
super(context);
init();
}

public NestedListView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

public NestedListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

private void init() {
mChildHelper = new NestedScrollingChildHelper(this);
setNestedScrollingEnabled(true);
}


@Override
public void setNestedScrollingEnabled(boolean enabled) {
mChildHelper.setNestedScrollingEnabled(enabled);
}

@Override
public boolean startNestedScroll(int axes) {
return mChildHelper.startNestedScroll(axes);
}

@Override
public void stopNestedScroll() {
mChildHelper.stopNestedScroll();
}

@Override
public boolean hasNestedScrollingParent() {
return mChildHelper.hasNestedScrollingParent();
}

@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {
return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow);
}

@Override
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
}

@Override
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
}

@Override
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
}


@Override
public boolean onTouchEvent(MotionEvent event) {
final int action = MotionEventCompat.getActionMasked(event);

int y = (int) event.getY();
event.offsetLocation(0, mNestedOffsetY);
switch (action) {
case MotionEvent.ACTION_DOWN:
mLastY = y;
mNestedOffsetY = 0;
break;
case MotionEvent.ACTION_MOVE:

int dy = mLastY - y;
int oldY = getScrollY();

startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
if (dispatchNestedPreScroll(0, dy, mScrollConsumed, mScrollOffset)) {
dy -= mScrollConsumed[1];
event.offsetLocation(0, -mScrollOffset[1]);
mNestedOffsetY += mScrollOffset[1];
}
mLastY = y - mScrollOffset[1];
if (dy < 0) {
int newScrollY = Math.max(0, oldY + dy);
dy -= newScrollY - oldY;
if (dispatchNestedScroll(0, newScrollY - dy, 0, dy, mScrollOffset)) {
event.offsetLocation(0, mScrollOffset[1]);
mNestedOffsetY += mScrollOffset[1];
mLastY -= mScrollOffset[1];
}
}
stopNestedScroll();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:

stopNestedScroll();

break;
}
return super.onTouchEvent(event);
}
}

效果:
嵌套滚动视图效果

来源:

  • http://www.jianshu.com/p/6547ec3202bd
  • https://race604.com/android-nested-scrolling/
  • http://blog.csdn.net/lmj623565791/article/details/52204039
  • http://blog.csdn.net/zf6688/article/details/56677350 ??
  • http://blog.csdn.net/happy_horse/article/details/54631695

downloadManagerSupport7.0

发表于 2017-06-24 | 分类于 Android

DownloadManager

  1. downloadmanager概述
    DownloadManager是Android SDK中封装的下载文件类,可以很方便开发者使用下载文件。其具体看官方APIhttps://developer.android.com/reference/android/app/DownloadManager.html

  2. 使用DownloadManager下载

    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
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    public class DownloadUtils {

    private DownloadManager mDownloadManager;
    private Context mContext;
    private long downloadId;
    private String apkName;

    public DownloadUtils(Context context) {
    mContext = context;
    }

    public void download(String url, String name) {
    final String packageName = "com.android.providers.downloads";
    int state = mContext.getPackageManager().getApplicationEnabledSetting(packageName);
    //检测下载管理器是否被禁用
    if (state == PackageManager.COMPONENT_ENABLED_STATE_DISABLED
    || state == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER
    || state == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED) {
    AlertDialog.Builder builder = new AlertDialog.Builder(mContext).setTitle("温馨提示").setMessage
    ("系统下载管理器被禁止,需手动打开").setPositiveButton("确定", new DialogInterface.OnClickListener() {
    @Override
    public void onClick(DialogInterface dialog, int which) {
    dialog.dismiss();
    try {
    Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
    intent.setData(Uri.parse("package:" + packageName));
    mContext.startActivity(intent);
    } catch (ActivityNotFoundException e) {
    Intent intent = new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS);
    mContext.startActivity(intent);
    }
    }
    }).setNegativeButton("取消", new DialogInterface.OnClickListener() {
    @Override
    public void onClick(DialogInterface dialog, int which) {
    dialog.dismiss();
    }
    });
    builder.create().show();
    } else {
    //正常下载流程
    apkName = name;
    DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
    request.setAllowedOverRoaming(false);
    //通知栏显示 request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
    request.setTitle(AppUtils.getAppName(mContext));
    request.setDescription("正在下载中...");
    request.setVisibleInDownloadsUi(true);
    //设置下载存放的文件夹和文件名字 request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, apkName);

    //获取DownloadManager
    mDownloadManager = (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE);
    downloadId = mDownloadManager.enqueue(request);
    mContext.registerReceiver(mReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
    }
    }

    private BroadcastReceiver mReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
    checkStatus();
    }
    };

    /**
    * 检查下载状态
    */
    private void checkStatus() {
    DownloadManager.Query query = new DownloadManager.Query();
    query.setFilterById(downloadId);
    Cursor cursor = mDownloadManager.query(query);
    if (cursor.moveToFirst()) {
    int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
    switch (status) {
    //下载暂停
    case DownloadManager.STATUS_PAUSED:
    break;
    //下载延迟
    case DownloadManager.STATUS_PENDING:
    break;
    //正在下载
    case DownloadManager.STATUS_RUNNING:
    break;
    //下载完成
    case DownloadManager.STATUS_SUCCESSFUL:
    installAPK();
    break;
    //下载失败
    case DownloadManager.STATUS_FAILED:
    Toast.makeText(mContext, "下载失败", Toast.LENGTH_SHORT).show();
    break;
    }
    }
    cursor.close();
    }

    /**
    * 7.0兼容
    */
    private void installAPK() {
    File apkFile =
    new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), apkName);
    Intent intent = new Intent(Intent.ACTION_VIEW);
    // 由于没有在Activity环境下启动Activity,设置下面的标签
    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    Uri apkUri = FileProvider.getUriForFile(mContext, mContext.getPackageName() + ".provider", apkFile);
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
    intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
    } else {
    intent.setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive");
    }
    mContext.startActivity(intent);
    }
    }

android 7.0 DownloadManager不在按照文件名分享私人存储的文件

  1. 按照开发者官网上的描述,DownloadManager不在支持使用COLUMN_LOCAL_FILENAME访问路径,其会触发SecurityException。官方建议有DownloadManager公开的文件,首选
    的访问方式是使用ContentResolver.openFileDescriptor()。

  2. 如果使用downloadManager做下载功能的且没有做7.0适配的话,在7.0读取数据库内容时会遇到如下错误:

    1
    java.lang.SecurityException: COLUMN_LOCAL_FILENAME is deprecated; use ContentResolver.openFileDescriptor() instead

在Android 7.0中通过DownloadManager根据downId获取的uri安装apk是会得到如下错误:

1
2
Caused by: android.os.FileUriExposedException:
file:///storage/emulated/0/Download/myApp.apk exposed beyond app through Intent.getData()

同时在7.0上通过DownloadManager根据downId获取的uri变为:

1
content://downloads/all_downloads/430

这些都是由于Android7.0执行了“StrictMode API 政策禁”的原因,可以用FileProvider来解决这一问题。

  1. 原来的代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    DownloadManager manager = (DownloadManager) mContext
    .getSystemService(Context.DOWNLOAD_SERVICE);
    Cursor downloadCursor = dm.query(myDownloadQuery);
    if (myDownload != null && downloadCursor.moveToFirst()) {
    int fileNameIdx = downloadCursor
    .getColumnIndex(DownloadManager.COLUMN_LOCAL_FILENAME);
    String filePath = downloadCursor.getString(fileNameIdx);
    }

7.0获取文件路径

  1. DownloadManager.COLUMN_LOCAL_URI:DownloadManager的数据库表中应该是存放了对应的URI,目测应该是file: 协议开头。那看来可以通过查询这个URI,随后将URI转换成文件path路径。
  2. 代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    DownloadManager manager = (DownloadManager) mContext
    .getSystemService(Context.DOWNLOAD_SERVICE);
    Cursor downloadCursor= dm.query(myDownloadQuery);
    if (downloadCursor!= null && downloadCursor.moveToFirst()) {
    String filePath = null;
    String downloadFileLocalUri = downloadCursor
    .getString(downloadCursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI));
    if (downloadFileLocalUri != null) {
    filePath = new File(Uri.parse(downloadFileLocalUri).getPath())
    .getAbsolutePath();
    }
    }

FileProvider

Why FileProvider?

  1. 老生常谈的内容,为什么Android推荐使用FileProvider呢?其实FileProvider也不是一个新概念了,Google很早就提出这个概念了。这是因为Android上的权限管理过于松散,但是随着系统版本的不断提升,权限的不断收紧,对于进程之间的数据共享就产生了问题。于是FileProvider就出现了。
  2. 它通过在AndroidManifest中定义Provider,为指定的文件提供一个ContentURI,通过这个URI临时赋予第三方应用授权处理指定的某些文件。简而言之,也可以说成是使用ContentURI代替了FileURI。

fileprovider 使用场景

  1. 正如上面我们说到的DownloadManager的变更中,我们通过DownloadManager下载了一个APK。此时我们需要发送一个Intent,带着我们下载下来的APK的URI,通知系统的APK安装管理服务,安装这个应用。
  2. 此时,这个Intent就要带着这个URI离开我们的App,去到另一个应用。此时,我们就需要对这个URI进行处理。如果发送的Intent带的是一个FileURI,就会导致:
    FileUriExposedException。

使用方法:

<1> AndroidManifest.xml:在manifest中加入如下代码:

1
2
3
4
5
6
7
8
9
10
<provider
android:exported="false"
android:grantUriPermissions="true"
android:authorities="com.×××.fileprovider"
android:name="android.support.v4.content.FileProvider">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"
/>
</provider>

  • exported要设置为false,否则会报安全异常。
  • grantUriPermissions设置为true,表示的是设置URI临时访问权限。
  • authorities可以随便写,不过一般最好是包名.fileprovider。当然也可以是其他。

<2> file_paths.xml:

  • 在manifest中有下面这一句:

    1
    android:resource="@xml/file_paths"
  • 我们需要在res/xml目录下新建一个file_paths.xml的文件,内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    <?xml version="1.0" encoding="utf-8"?>
    <resources>
    <paths>
    <root-path
    name="/"
    path="/" />
    </paths>
    </resources>
  • 作用是指定临时授权的路径,其选项有:

选项 含义
root-paty 根目录(/)
external-path Context.getExternalStorageDirectory()
file-path Context.getFilesDir()
cache-path Context.getCacheDir()

其中root-path指的是Android系统根目录,众所周知Android是基于Linux内核,所以Android的根目录就是“/”

<3> 使用FileProvider:

  • 当我们拿到一个Apk文件时,需要启动安装界面进行安装。则可以使用以下方式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    if (Build.VERSION.SDK_INT >= 24) {
    File apkFile=new File(apkFile Path);
    Intent intent = new Intent(Intent.ACTION_VIEW);
    Uri contentUri = FileProvider.getUriForFile(mContext,
    "com.×××.fileprovider", apkFile);
    intent.setDataAndType(contentUri,
    "application/vnd.android.package-archive");
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    mContext.startActivity(intent);
    }
  • 使用FileProvider.getUriForFile()将一个FileURI转换成ContentURI

  • 并且不要忘记添加Intent.FLAG_GRANT_READ_URI_PERMISSION临时的读或者写权限。这样就完成一个FileProvider共享的使用了。

后记

  1. 已兼容7.0私有文件权限问题
  2. 对于部分机型默认或者一些原因,下载管理器是被禁用掉的,必须手动开启或者写代码去跳转到设置界面开启,代码中已兼容。

来源:
http://www.ryanhuen.tech/2017/02/28/Android-N-Diff/
http://www.jianshu.com/p/3eb4106133f4
http://blog.csdn.net/yulianlin/article/details/52775160

git命令和使用

发表于 2017-06-24 | 分类于 GitHub

git命令详解

git命令形式:
usage: git [–version] [–help] [-C < path >] [ -c name=value ]
[–exec-path[ = < path > ] ] [–html-path] [–man-path] [–info-path]
[-p | –paginate | –no-pager] [–no-replace-objects] [–bare]
[–git-dir=< path >] [–work-tree=< path >] [–namespace=< name >]
< command > [< args >]

These are common Git commands used in various situations:

  1. start a working area (see also: git help tutorial)

    • clone : Clone a repository into a new directory
    • init : Create an empty Git repository or reinitialize an existing one
  2. work on the current change (see also: git help everyday)

    • add : Add file contents to the index
    • mv : Move or rename a file, a directory, or a symlink
    • reset : Reset current HEAD to the specified state
    • rm : Remove files from the working tree and from the index
  3. examine the history and state (see also: git help revisions)

    • bisect : Use binary search to find the commit that introduced a bug
    • grep : Print lines matching a pattern
    • log : Show commit logs
    • show : Show various types of objects
    • status : Show the working tree status
  4. grow, mark and tweak your common history

    • branch : List, create, or delete branches
    • checkout: Switch branches or restore working tree files
    • commit : Record changes to the repository
    • diff : Show changes between commits, commit and working tree, etc
    • merge : Join two or more development histories together
    • rebase : Reapply commits on top of another base tip
    • tag :Create, list, delete or verify a tag object signed with GPG
  5. collaborate (see also: git help workflows)

    • fetch : Download objects and refs from another repository
    • pull : Fetch from and integrate with another repository or a local branch
    • push : Update remote refs along with associated objects

‘git help -a’ and ‘git help -g’ list available subcommands and some
concept guides. See ‘git help < command >’ or ‘git help < concept >’
to read about a specific subcommand or concept.

git使用

git诞生

很多人都知道,Linus在1991年创建了开源的Linux,从此,Linux系统不断发展,已经成为最大的服务器系统软件了。

Linus虽然创建了Linux,但Linux的壮大是靠全世界热心的志愿者参与的,这么多人在世界各地为Linux编写代码,那Linux的代码是如何管理的呢?

事实是,在2002年以前,世界各地的志愿者把源代码文件通过diff的方式发给Linus,然后由Linus本人通过手工方式合并代码!

你也许会想,为什么Linus不把Linux代码放到版本控制系统里呢?不是有CVS、SVN这些免费的版本控制系统吗?因为Linus坚定地反对CVS和SVN,这些集中式的版本控制系统不但速度慢,而且必须联网才能使用。有一些商用的版本控制系统,虽然比CVS、SVN好用,但那是付费的,和Linux的开源精神不符。

不过,到了2002年,Linux系统已经发展了十年了,代码库之大让Linus很难继续通过手工方式管理了,社区的弟兄们也对这种方式表达了强烈不满,于是Linus选择了一个商业的版本控制系统BitKeeper,BitKeeper的东家BitMover公司出于人道主义精神,授权Linux社区免费使用这个版本控制系统。

安定团结的大好局面在2005年就被打破了,原因是Linux社区牛人聚集,不免沾染了一些梁山好汉的江湖习气。开发Samba的Andrew试图破解BitKeeper的协议(这么干的其实也不只他一个),被BitMover公司发现了(监控工作做得不错!),于是BitMover公司怒了,要收回Linux社区的免费使用权。

Linus可以向BitMover公司道个歉,保证以后严格管教弟兄们,嗯,这是不可能的。实际情况是这样的:

Linus花了两周时间自己用C写了一个分布式版本控制系统,这就是Git!一个月之内,Linux系统的源码已经由Git管理了!牛是怎么定义的呢?大家可以体会一下。

Git迅速成为最流行的分布式版本控制系统,尤其是2008年,GitHub网站上线了,它为开源项目免费提供Git存储,无数开源项目开始迁移至GitHub,包括jQuery,PHP,Ruby等等。

历史就是这么偶然,如果不是当年BitMover公司威胁Linux社区,可能现在我们就没有免费而超级好用的Git了。

集中式vs分布式

Linus一直痛恨的CVS及SVN都是集中式的版本控制系统,而Git是分布式版本控制系统,集中式和分布式版本控制系统有什么区别呢?

先说集中式版本控制系统,版本库是集中存放在中央服务器的,而干活的时候,用的都是自己的电脑,所以要先从中央服务器取得最新的版本,然后开始干活,干完活了,再把自己的活推送给中央服务器。中央服务器就好比是一个图书馆,你要改一本书,必须先从图书馆借出来,然后回到家自己改,改完了,再放回图书馆。

集中式管理

集中式版本控制系统最大的毛病就是必须联网才能工作,如果在局域网内还好,带宽够大,速度够快,可如果在互联网上,遇到网速慢的话,可能提交一个10M的文件就需要5分钟,这还不得把人给憋死啊。

那分布式版本控制系统与集中式版本控制系统有何不同呢?首先,分布式版本控制系统根本没有“中央服务器”,每个人的电脑上都是一个完整的版本库,这样,你工作的时候,就不需要联网了,因为版本库就在你自己的电脑上。既然每个人电脑上都有一个完整的版本库,那多个人如何协作呢?比方说你在自己电脑上改了文件A,你的同事也在他的电脑上改了文件A,这时,你们俩之间只需把各自的修改推送给对方,就可以互相看到对方的修改了。

和集中式版本控制系统相比,分布式版本控制系统的安全性要高很多,因为每个人电脑里都有完整的版本库,某一个人的电脑坏掉了不要紧,随便从其他人那里复制一个就可以了。而集中式版本控制系统的中央服务器要是出了问题,所有人都没法干活了。

在实际使用分布式版本控制系统的时候,其实很少在两人之间的电脑上推送版本库的修改,因为可能你们俩不在一个局域网内,两台电脑互相访问不了,也可能今天你的同事病了,他的电脑压根没有开机。因此,分布式版本控制系统通常也有一台充当“中央服务器”的电脑,但这个服务器的作用仅仅是用来方便“交换”大家的修改,没有它大家也一样干活,只是交换修改不方便而已。
分布式管理

当然,Git的优势不单是不必联网这么简单,后面我们还会看到Git极其强大的分支管理,把SVN等远远抛在了后面。

CVS作为最早的开源而且免费的集中式版本控制系统,直到现在还有不少人在用。由于CVS自身设计的问题,会造成提交文件不完整,版本库莫名其妙损坏的情况。同样是开源而且免费的SVN修正了CVS的一些稳定性问题,是目前用得最多的集中式版本库控制系统。

除了免费的外,还有收费的集中式版本控制系统,比如IBM的ClearCase(以前是Rational公司的,被IBM收购了),特点是安装比Windows还大,运行比蜗牛还慢,能用ClearCase的一般是世界500强,他们有个共同的特点是财大气粗,或者人傻钱多。

微软自己也有一个集中式版本控制系统叫VSS,集成在Visual Studio中。由于其反人类的设计,连微软自己都不好意思用了。

分布式版本控制系统除了Git以及促使Git诞生的BitKeeper外,还有类似Git的Mercurial和Bazaar等。这些分布式版本控制系统各有特点,但最快、最简单也最流行的依然是Git!

》》》》读者解读
Git与SVN的主要差别:这两个工具主要的区别在于历史版本维护的位置。
Git本地仓库包含代码库还有历史库,在本地的环境开发就可以记录历史;
而SVN的历史库存在于中央仓库,每次对比与提交代码都必须连接到中央仓库才能进行;
这样的好处在于:
1、自己可以在脱机环境查看开发的版本历史
2、多人开发时如果充当中央仓库的Git仓库挂了,任何一个开发者的仓库都可以作为中央仓库进行服务
》》》

安装Git

最早Git是在Linux上开发的,很长一段时间内,Git也只能在Linux和Unix系统上跑。不过,慢慢地有人把它移植到了Windows上。现在,Git可以在Linux、Unix、Mac和Windows这几大平台上正常运行了。

要使用Git,第一步当然是安装Git了。根据你当前使用的平台来阅读下面的文字:

  1. 在linux上安装Git
    首先,你可以试着输入git,看看系统有没有安装Git:
    1
    2
    3
    $ git
    The program 'git' is currently not installed. You can install it by typing:
    sudo apt-get install git

安装Git

阅读: 1563566
最早Git是在Linux上开发的,很长一段时间内,Git也只能在Linux和Unix系统上跑。不过,慢慢地有人把它移植到了Windows上。现在,Git可以在Linux、Unix、Mac和Windows这几大平台上正常运行了。

要使用Git,第一步当然是安装Git了。根据你当前使用的平台来阅读下面的文字:

在Linux上安装Git

首先,你可以试着输入git,看看系统有没有安装Git:

$ git
The program ‘git’ is currently not installed. You can install it by typing:
sudo apt-get install git
像上面的命令,有很多Linux会友好地告诉你Git没有安装,还会告诉你如何安装Git。

如果你碰巧用Debian或Ubuntu Linux,通过一条sudo apt-get install git就可以直接完成Git的安装,非常简单。

老一点的Debian或Ubuntu Linux,要把命令改为sudo apt-get install git-core,因为以前有个软件也叫GIT(GNU Interactive Tools),结果Git就只能叫git-core了。由于Git名气实在太大,后来就把GNU Interactive Tools改成gnuit,git-core正式改为git。

如果是其他Linux版本,可以直接通过源码安装。先从Git官网下载源码,然后解压,依次输入:./config,make,sudo make install这几个命令安装就好了。

  1. 在Mac OS X上安装Git
    如果你正在使用Mac做开发,有两种安装Git的方法。

一是安装homebrew,然后通过homebrew安装Git,具体方法请参考homebrew的文档:http://brew.sh/。

第二种方法更简单,也是推荐的方法,就是直接从AppStore安装Xcode,Xcode集成了Git,不过默认没有安装,你需要运行Xcode,选择菜单“Xcode”->“Preferences”,在弹出窗口中找到“Downloads”,选择“Command Line Tools”,点“Install”就可以完成安装了。
Mac下安装Git

  1. 在Windows上安装Git

Windows下要使用很多Linux/Unix的工具时,需要Cygwin这样的模拟环境,Git也一样。Cygwin的安装和配置都比较复杂,就不建议你折腾了。不过,有高人已经把模拟环境和Git都打包好了,名叫msysgit,只需要下载一个单独的exe安装程序,其他什么也不用装,绝对好用。

msysgit是Windows版的Git,从https://git-for-windows.github.io下载(网速慢的同学请移步国内镜像),然后按默认选项安装即可。

安装完成后,在开始菜单里找到“Git”->“Git Bash”,蹦出一个类似命令行窗口的东西,就说明Git安装成功!
windows下安装Git

安装好最后

安装完成后,还需要最后一步设置,在命令行输入:

1
2
$ git config --global user.name "Your Name"
$ git config --global user.email "email@example.com"

因为Git是分布式版本控制系统,所以,每个机器都必须自报家门:你的名字和Email地址。你也许会担心,如果有人故意冒充别人怎么办?这个不必担心,首先我们相信大家都是善良无知的群众,其次,真的有冒充的也是有办法可查的。

注意git config命令的–global参数,用了这个参数,表示你这台机器上所有的Git仓库都会使用这个配置,当然也可以对某个仓库指定不同的用户名和Email地址。

创建版本库

什么是版本库呢?版本库又名仓库,英文名repository,你可以简单理解成一个目录,这个目录里面的所有文件都可以被Git管理起来,每个文件的修改、删除,Git都能跟踪,以便任何时刻都可以追踪历史,或者在将来某个时刻可以“还原”。

所以,创建一个版本库非常简单,首先,选择一个合适的地方,创建一个空目录:

1
2
3
4
$ mkdir learngit
$ cd learngit
$ pwd
/Users/michael/learngit

pwd命令用于显示当前目录。在我的Mac上,这个仓库位于/Users/michael/learngit。

如果你使用Windows系统,为了避免遇到各种莫名其妙的问题,请确保目录名(包括父目录)不包含中文。

第二步,通过git init命令把这个目录变成Git可以管理的仓库:

1
2
$ git init
Initialized empty Git repository in /Users/michael/learngit/.git/

瞬间Git就把仓库建好了,而且告诉你是一个空的仓库(empty Git repository),细心的读者可以发现当前目录下多了一个.git的目录,这个目录是Git来跟踪管理版本库的,没事千万不要手动修改这个目录里面的文件,不然改乱了,就把Git仓库给破坏了。

如果你没有看到.git目录,那是因为这个目录默认是隐藏的,用ls -ah命令就可以看见。

也不一定必须在空目录下创建Git仓库,选择一个已经有东西的目录也是可以的。不过,不建议你使用自己正在开发的公司项目来学习Git,否则造成的一切后果概不负责。

把文件添加到版本库

首先这里再明确一下,所有的版本控制系统,其实只能跟踪文本文件的改动,比如TXT文件,网页,所有的程序代码等等,Git也不例外。版本控制系统可以告诉你每次的改动,比如在第5行加了一个单词“Linux”,在第8行删了一个单词“Windows”。而图片、视频这些二进制文件,虽然也能由版本控制系统管理,但没法跟踪文件的变化,只能把二进制文件每次改动串起来,也就是只知道图片从100KB改成了120KB,但到底改了啥,版本控制系统不知道,也没法知道。

不幸的是,Microsoft的Word格式是二进制格式,因此,版本控制系统是没法跟踪Word文件的改动的,前面我们举的例子只是为了演示,如果要真正使用版本控制系统,就要以纯文本方式编写文件。

因为文本是有编码的,比如中文有常用的GBK编码,日文有Shift_JIS编码,如果没有历史遗留问题,强烈建议使用标准的UTF-8编码,所有语言使用同一种编码,既没有冲突,又被所有平台所支持。

使用Windows的童鞋要特别注意:

千万不要使用Windows自带的记事本编辑任何文本文件。原因是Microsoft开发的记事本会在每个文件开头添加了0xefbbbf(十六进制)的字符,你会遇到很多不可思议的问题,比如,网页第一行可能会显示一个“?”,明明正确的程序一编译就报语法错误,等等。建议你下载Notepad++代替记事本,不但功能强大,而且免费!记得把Notepad++的默认编码设置为UTF-8 without BOM即可:
notepad++设置
言归正传,现在我们编写一个readme.txt文件,内容如下:

1
2
Git is a version control system.
Git is free software.

一定要放到learngit目录下(子目录也行),因为这是一个Git仓库,放到其他地方Git再厉害也找不到这个文件。

和把大象放到冰箱需要3步相比,把一个文件放到Git仓库只需要两步。

第一步,用命令git add告诉Git,把文件添加到仓库:

1
$ git add readme.txt

执行上面的命令,没有任何显示,这就对了,Unix的哲学是“没有消息就是好消息”,说明添加成功。

第二步,用命令git commit告诉Git,把文件提交到仓库:

1
2
3
4
$ git commit -m "wrote a readme file"
[master (root-commit) cb926e7] wrote a readme file
1 file changed, 2 insertions(+)
create mode 100644 readme.txt

简单解释一下git commit命令,-m后面输入的是本次提交的说明,可以输入任意内容,当然最好是有意义的,这样你就能从历史记录里方便地找到改动记录。

嫌麻烦不想输入-m “xxx”行不行?确实有办法可以这么干,但是强烈不建议你这么干,因为输入说明对自己对别人阅读都很重要。实在不想输入说明的童鞋请自行Google,我不告诉你这个参数。

git commit命令执行成功后会告诉你,1个文件被改动(我们新添加的readme.txt文件),插入了两行内容(readme.txt有两行内容)。

为什么Git添加文件需要add,commit一共两步呢?因为commit可以一次提交很多文件,所以你可以多次add不同的文件,比如:

1
2
3
$ git add file1.txt
$ git add file2.txt file3.txt
$ git commit -m "add 3 files."

小结
初始化一个Git仓库,使用git init命令。添加文件到Git仓库,分两步:

第一步,使用命令git add < file >,注意,可反复多次使用,添加多个文件;

第二步,使用命令git commit,完成。

时光机穿梭

我们已经成功地添加并提交了一个readme.txt文件,现在,是时候继续工作了,于是,我们继续修改readme.txt文件,改成如下内容:

1
2
Git is a distributed version control system.
Git is free software.

现在,运行git status命令看看结果:

1
2
3
4
5
6
7
8
9
$ git status
# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: readme.txt
#
no changes added to commit (use "git add" and/or "git commit -a")

git status命令可以让我们时刻掌握仓库当前的状态,上面的命令告诉我们,readme.txt被修改过了,但还没有准备提交的修改。

虽然Git告诉我们readme.txt被修改了,但如果能看看具体修改了什么内容,自然是很好的。比如你休假两周从国外回来,第一天上班时,已经记不清上次怎么修改的readme.txt,所以,需要用git diff这个命令看看:

1
2
3
4
5
6
7
8
9
$ git diff readme.txt
diff --git a/readme.txt b/readme.txt
index 46d49bf..9247db6 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,2 +1,2 @@
-Git is a version control system.
+Git is a distributed version control system.
Git is free software.

git diff顾名思义就是查看difference,显示的格式正是Unix通用的diff格式,可以从上面的命令输出看到,我们在第一行添加了一个“distributed”单词。

知道了对readme.txt作了什么修改后,再把它提交到仓库就放心多了,提交修改和提交新文件是一样的两步,第一步是git add:

1
$ git add readme.txt

同样没有任何输出。在执行第二步git commit之前,我们再运行git status看看当前仓库的状态:

1
2
3
4
5
6
7
$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# modified: readme.txt
#

git status告诉我们,将要被提交的修改包括readme.txt,下一步,就可以放心地提交了:

1
2
3
$ git commit -m "add distributed"
[master ea34578] add distributed
1 file changed, 1 insertion(+), 1 deletion(-)

提交后,我们再用git status命令看看仓库的当前状态:

1
2
3
$ git status
# On branch master
nothing to commit (working directory clean)

Git告诉我们当前没有需要提交的修改,而且,工作目录是干净(working directory clean)的。
小结

  • 要随时掌握工作区的状态,使用git status命令。
  • 如果git status告诉你有文件被修改过,用git diff可以查看修改内容

》》》读者解读

  1. git add 的各种区别:

    1
    2
    3
    4
    git add -A   // 添加所有改动
    git add * // 添加新建文件和修改,但是不包括删除
    git add . // 添加新建文件和修改,但是不包括删除
    git add -u // 添加修改和删除,但是不包括新建文件
  2. 在 commit 前撤销 add:

    1
    2
    git reset <file> // 撤销提交单独文件
    git reset // unstage all due changes
  3. add/commit 前撤销对文件的修改:

    1
    git checkout -- README.md  // 注意, add添加后(同commit提交后)就无法通过这种方式撤销修改

》》》

版本回退

现在,你已经学会了修改文件,然后把修改提交到Git版本库,现在,再练习一次,修改readme.txt文件如下:

1
2
Git is a distributed version control system.
Git is free software distributed under the GPL.

然后尝试提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "append GPL"
[master 3628164] append GPL
1 file changed, 1 insertion(+), 1 deletion(-)

像这样,你不断对文件进行修改,然后不断提交修改到版本库里,就好比玩RPG游戏时,每通过一关就会自动把游戏状态存盘,如果某一关没过去,你还可以选择读取前一关的状态。有些时候,在打Boss之前,你会手动存盘,以便万一打Boss失败了,可以从最近的地方重新开始。Git也是一样,每当你觉得文件修改到一定程度的时候,就可以“保存一个快照”,这个快照在Git中被称为commit。一旦你把文件改乱了,或者误删了文件,还可以从最近的一个commit恢复,然后继续工作,而不是把几个月的工作成果全部丢失。

现在,我们回顾一下readme.txt文件一共有几个版本被提交到Git仓库里了:

版本1:wrote a readme file

1
2
Git is a version control system.
Git is free software.

版本2:add distributed

1
2
Git is a distributed version control system.
Git is free software.

版本3:append GPL

1
2
Git is a distributed version control system.
Git is free software distributed under the GPL.

当然了,在实际工作中,我们脑子里怎么可能记得一个几千行的文件每次都改了什么内容,不然要版本控制系统干什么。版本控制系统肯定有某个命令可以告诉我们历史记录,在Git中,我们用git log命令查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git log
commit 3628164fb26d48395383f8f31179f24e0882e1e0
Author: Michael Liao <askxuefeng@gmail.com>
Date: Tue Aug 20 15:11:49 2013 +0800

append GPL

commit ea34578d5496d7dd233c827ed32a8cd576c5ee85
Author: Michael Liao <askxuefeng@gmail.com>
Date: Tue Aug 20 14:53:12 2013 +0800

add distributed

commit cb926e7ea50ad11b8f9e909c05226233bf755030
Author: Michael Liao <askxuefeng@gmail.com>
Date: Mon Aug 19 17:51:55 2013 +0800

wrote a readme file

git log命令显示从最近到最远的提交日志,我们可以看到3次提交,最近的一次是append GPL,上一次是add distributed,最早的一次是wrote a readme file。
如果嫌输出信息太多,看得眼花缭乱的,可以试试加上–pretty=oneline参数:

1
2
3
4
$ git log --pretty=oneline
3628164fb26d48395383f8f31179f24e0882e1e0 append GPL
ea34578d5496d7dd233c827ed32a8cd576c5ee85 add distributed
cb926e7ea50ad11b8f9e909c05226233bf755030 wrote a readme file

需要友情提示的是,你看到的一大串类似3628164…882e1e0的是commit id(版本号),和SVN不一样,Git的commit id不是1,2,3……递增的数字,而是一个SHA1计算出来的一个非常大的数字,用十六进制表示,而且你看到的commit id和我的肯定不一样,以你自己的为准。为什么commit id需要用这么一大串数字表示呢?因为Git是分布式的版本控制系统,后面我们还要研究多人在同一个版本库里工作,如果大家都用1,2,3……作为版本号,那肯定就冲突了。

每提交一个新版本,实际上Git就会把它们自动串成一条时间线。如果使用可视化工具查看Git历史,就可以更清楚地看到提交历史的时间线:
时间线
好了,现在我们启动时光穿梭机,准备把readme.txt回退到上一个版本,也就是“add distributed”的那个版本,怎么做呢?

首先,Git必须知道当前版本是哪个版本,在Git中,用HEAD表示当前版本,也就是最新的提交3628164…882e1e0(注意我的提交ID和你的肯定不一样),上一个版本就是HEAD^,上上一个版本就是HEAD^^,当然往上100个版本写100个^比较容易数不过来,所以写成HEAD~100。

现在,我们要把当前版本“append GPL”回退到上一个版本“add distributed”,就可以使用git reset命令:

1
2
$ git reset --hard HEAD^
HEAD is now at ea34578 add distributed

–hard参数有啥意义?这个后面再讲,现在你先放心使用。

看看readme.txt的内容是不是版本add distributed:

1
2
3
$ cat readme.txt
Git is a distributed version control system.
Git is free software.

还可以继续回退到上一个版本wrote a readme file,不过且慢,然我们用git log再看看现在版本库的状态:

1
2
3
4
5
6
7
8
9
10
11
12
$ git log
commit ea34578d5496d7dd233c827ed32a8cd576c5ee85
Author: Michael Liao <askxuefeng@gmail.com>
Date: Tue Aug 20 14:53:12 2013 +0800

add distributed

commit cb926e7ea50ad11b8f9e909c05226233bf755030
Author: Michael Liao <askxuefeng@gmail.com>
Date: Mon Aug 19 17:51:55 2013 +0800

wrote a readme file

最新的那个版本append GPL已经看不到了!好比你从21世纪坐时光穿梭机来到了19世纪,想再回去已经回不去了,肿么办?

办法其实还是有的,只要上面的命令行窗口还没有被关掉,你就可以顺着往上找啊找啊,找到那个append GPL的commit id是3628164…,于是就可以指定回到未来的某个版本:

1
2
$ git reset --hard 3628164
HEAD is now at 3628164 append GPL

版本号没必要写全,前几位就可以了,Git会自动去找。当然也不能只写前一两位,因为Git可能会找到多个版本号,就无法确定是哪一个了。

再小心翼翼地看看readme.txt的内容:

1
2
3
$ cat readme.txt
Git is a distributed version control system.
Git is free software distributed under the GPL.

果然,我胡汉三又回来了。

Git的版本回退速度非常快,因为Git在内部有个指向当前版本的HEAD指针,当你回退版本的时候,Git仅仅是把HEAD从指向append GPL:
head指向
改为指向add distributed:

修改head指向
然后顺便把工作区的文件更新了。所以你让HEAD指向哪个版本号,你就把当前版本定位在哪。

现在,你回退到了某个版本,关掉了电脑,第二天早上就后悔了,想恢复到新版本怎么办?找不到新版本的commit id怎么办?

在Git中,总是有后悔药可以吃的。当你用$ git reset –hard HEAD^回退到add distributed版本时,再想恢复到append GPL,就必须找到append GPL的commit id。Git提供了一个命令git reflog用来记录你的每一次命令:

1
2
3
4
5
$ git reflog
ea34578 HEAD@{0}: reset: moving to HEAD^
3628164 HEAD@{1}: commit: append GPL
ea34578 HEAD@{2}: commit: add distributed
cb926e7 HEAD@{3}: commit (initial): wrote a readme file

终于舒了口气,第二行显示append GPL的commit id是3628164,现在,你又可以乘坐时光机回到未来了。
小结

  • HEAD指向的版本就是当前版本,因此,Git允许我们在版本的历史之间穿梭,使用命令git reset –hard commit_id。
  • 穿梭前,用git log可以查看提交历史,以便确定要回退到哪个版本。
  • 要重返未来,用git reflog查看命令历史,以便确定要回到未来的哪个版本。
    》》》读者解读
    git reflog 与 git log区别:
  • git log :是当你回退到某个版本,此版本之后的记录,就自动抹去了
  • git reflog:是你提交的完整的命令历史,回退到某个版本,仍然可以查到位于此版本之后的版本号。
    》》》

工作区和暂存区

Git和其他版本控制系统如SVN的一个不同之处就是有暂存区的概念。
工作区(Working Directory)
就是你在电脑里能看到的目录,比如我的learngit文件夹就是一个工作区:
工作区

版本库(Repository)

工作区有一个隐藏目录.git,这个不算工作区,而是Git的版本库。

Git的版本库里存了很多东西,其中最重要的就是称为stage(或者叫index)的暂存区,还有Git为我们自动创建的第一个分支master,以及指向master的一个指针叫HEAD。
版本库

分支和HEAD的概念我们以后再讲。

前面讲了我们把文件往Git版本库里添加的时候,是分两步执行的:

第一步是用git add把文件添加进去,实际上就是把文件修改添加到暂存区;

第二步是用git commit提交更改,实际上就是把暂存区的所有内容提交到当前分支。

因为我们创建Git版本库时,Git自动为我们创建了唯一一个master分支,所以,现在,git commit就是往master分支上提交更改。

你可以简单理解为,需要提交的文件修改通通放到暂存区,然后,一次性提交暂存区的所有修改。

俗话说,实践出真知。现在,我们再练习一遍,先对readme.txt做个修改,比如加上一行内容:

1
2
3
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.

然后,在工作区新增一个LICENSE文本文件(内容随便写)。

先用git status查看一下状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git status
# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: readme.txt
#
# Untracked files:
# (use "git add <file>..." to include in what will be committed)
#
# LICENSE
no changes added to commit (use "git add" and/or "git commit -a")

Git非常清楚地告诉我们,readme.txt被修改了,而LICENSE还从来没有被添加过,所以它的状态是Untracked。

现在,使用两次命令git add,把readme.txt和LICENSE都添加后,用git status再查看一下:

1
2
3
4
5
6
7
8
$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# new file: LICENSE
# modified: readme.txt
#

现在,暂存区的状态就变成这样了:

存放暂存区
所以,git add命令实际上就是把要提交的所有修改放到暂存区(Stage),然后,执行git commit就可以一次性把暂存区的所有修改提交到分支。

1
2
3
4
$ git commit -m "understand how stage works"
[master 27c9860] understand how stage works
2 files changed, 675 insertions(+)
create mode 100644 LICENSE

一旦提交后,如果你又没有对工作区做任何修改,那么工作区就是“干净”的:

1
2
3
4

$ git status
# On branch master
nothing to commit (working directory clean)

现在版本库变成了这样,暂存区就没有任何内容了:
提交到本地仓库
》》》读者解读

  • git diff #是工作区(work dict)和暂存区(stage)的比较
  • git diff –cached #是暂存区(stage)和分支(master)的比较
    》》》

管理修改

现在,假定你已经完全掌握了暂存区的概念。下面,我们要讨论的就是,为什么Git比其他版本控制系统设计得优秀,因为Git跟踪并管理的是修改,而非文件。

你会问,什么是修改?比如你新增了一行,这就是一个修改,删除了一行,也是一个修改,更改了某些字符,也是一个修改,删了一些又加了一些,也是一个修改,甚至创建一个新文件,也算一个修改。

为什么说Git管理的是修改,而不是文件呢?我们还是做实验。第一步,对readme.txt做一个修改,比如加一行内容:

1
2
3
4
5
$ cat readme.txt
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes.

然后,添加:

1
2
3
4
5
6
7
8
$ git add readme.txt
$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# modified: readme.txt
#

然后,再修改readme.txt:

1
2
3
4
5
$ cat readme.txt
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes of files.

提交:

1
2
3
$ git commit -m "git tracks changes"
[master d4f25b6] git tracks changes
1 file changed, 1 insertion(+)

提交后,再看看状态:

1
2
3
4
5
6
7
8
9
$ git status
# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: readme.txt
#
no changes added to commit (use "git add" and/or "git commit -a")

咦,怎么第二次的修改没有被提交?

别激动,我们回顾一下操作过程:

第一次修改 -> git add -> 第二次修改 -> git commit

你看,我们前面讲了,Git管理的是修改,当你用git add命令后,在工作区的第一次修改被放入暂存区,准备提交,但是,在工作区的第二次修改并没有放入暂存区,所以,git commit只负责把暂存区的修改提交了,也就是第一次的修改被提交了,第二次的修改不会被提交。

提交后,用git diff HEAD – readme.txt命令可以查看工作区和版本库里面最新版本的区别:

1
2
3
4
5
6
7
8
9
10
11
$ git diff HEAD -- readme.txt
diff --git a/readme.txt b/readme.txt
index 76d770f..a9c5755 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,4 +1,4 @@
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
-Git tracks changes.
+Git tracks changes of files.

可见,第二次修改确实没有被提交。那怎么提交第二次修改呢?你可以继续git add再git commit,也可以别着急提交第一次修改,先git add第二次修改,再git commit,就相当于把两次修改合并后一块提交了:

第一次修改 -> git add -> 第二次修改 -> git add -> git commit
好,现在,把第二次修改提交了。

小结

现在,你又理解了Git是如何跟踪修改的,每次修改,如果不add到暂存区,那就不会加入到commit中。

撤销修改

自然,你是不会犯错的。不过现在是凌晨两点,你正在赶一份工作报告,你在readme.txt中添加了一行:

1
2
3
4
5
6
$ cat readme.txt
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes of files.
My stupid boss still prefers SVN.

在你准备提交前,一杯咖啡起了作用,你猛然发现了“stupid boss”可能会让你丢掉这个月的奖金!

既然错误发现得很及时,就可以很容易地纠正它。你可以删掉最后一行,手动把文件恢复到上一个版本的状态。如果用git status查看一下:

1
2
3
4
5
6
7
8
9
$ git status
# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: readme.txt
#
no changes added to commit (use "git add" and/or "git commit -a")

你可以发现,Git会告诉你,git checkout – file可以丢弃工作区的修改:

1
$ git checkout -- readme.txt

命令git checkout – readme.txt意思就是,把readme.txt文件在工作区的修改全部撤销,这里有两种情况:

一种是readme.txt自修改后还没有被放到暂存区,现在,撤销修改就回到和版本库一模一样的状态;

一种是readme.txt已经添加到暂存区后,又作了修改,现在,撤销修改就回到添加到暂存区后的状态。

总之,就是让这个文件回到最近一次git commit或git add时的状态。

现在,看看readme.txt的文件内容:

1
2
3
4
5
$ cat readme.txt
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes of files

文件内容果然复原了。
git checkout – file命令中的–很重要,没有–,就变成了“切换到另一个分支”的命令,我们在后面的分支管理中会再次遇到git checkout命令。

现在假定是凌晨3点,你不但写了一些胡话,还git add到暂存区了:

1
2
3
4
5
6
7
8
$ cat readme.txt
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes of files.
My stupid boss still prefers SVN.

$ git add readme.txt

庆幸的是,在commit之前,你发现了这个问题。用git status查看一下,修改只是添加到了暂存区,还没有提交:

1
2
3
4
5
6
7
$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# modified: readme.txt
#

Git同样告诉我们,用命令git reset HEAD file可以把暂存区的修改撤销掉(unstage),重新放回工作区:

1
2
3
$ git reset HEAD readme.txt
Unstaged changes after reset:
M readme.txt

git reset命令既可以回退版本,也可以把暂存区的修改回退到工作区。当我们用HEAD时,表示最新的版本。

再用git status查看一下,现在暂存区是干净的,工作区有修改:

1
2
3
4
5
6
7
8
9
$ git status
# On branch master
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: readme.txt
#
no changes added to commit (use "git add" and/or "git commit -a")

还记得如何丢弃工作区的修改吗?

1
2
3
4
5
$ git checkout -- readme.txt

$ git status
# On branch master
nothing to commit (working directory clean)

整个世界终于清静了!

现在,假设你不但改错了东西,还从暂存区提交到了版本库,怎么办呢?还记得版本回退一节吗?可以回退到上一个版本。不过,这是有条件的,就是你还没有把自己的本地版本库推送到远程。还记得Git是分布式版本控制系统吗?我们后面会讲到远程版本库,一旦你把“stupid boss”提交推送到远程版本库,你就真的惨了……

小结
场景1:当你改乱了工作区某个文件的内容,想直接丢弃工作区的修改时,用命令git checkout – file。

场景2:当你不但改乱了工作区某个文件的内容,还添加到了暂存区时,想丢弃修改,分两步,第一步用命令git reset HEAD file,就回到了场景1,第二步按场景1操作。

场景3:已经提交了不合适的修改到版本库时,想要撤销本次提交,参考版本回退一节,不过前提是没有推送到远程库。
》》》读者解读
文件层面操作

  • git add files 把当前文件放入暂存区域。
  • git commit 给暂存区域生成快照并提交。
  • git reset – files 用来撤销最后一次git add files,你也可以用git reset 撤销所有暂存区域文件。
  • git checkout – files 把文件从暂存区域复制到工作目录,用来丢弃本地修改

checkout
checkout命令用于从历史提交(或者暂存区域)中拷贝文件到工作目录,也可用于切换分支。当给定某个文件名时,git会从指定的提交中拷贝文件到暂存区域和工作目录。比如,git checkout HEAD~ foo.c会将提交节点HEAD~(即当前提交节点的父节点)中的foo.c复制到工作目录并且加到暂存区域中。(如果命令中没有指定提交节点,则会从暂存区域中拷贝内容。)注意当前分支不会发生变化。
checkout-branch.
当不指定文件名,而是给出一个(本地)分支时,那么HEAD标识会移动到那个分支(也就是说,我们“切换”到那个分支了),然后暂存区域和工作目录中的内容会和HEAD对应的提交节点一致。新提交节点(下图中的a47c3)中的所有文件都会被复制(到暂存区域和工作目录中);只存在于老的提交节点(ed489)中的文件会被删除;不属于上述两者的文件会被忽略,不受影响。
checkout-detached
如果既没有指定文件, 也没有指定分枝. 而是只给出一段提交的历史Hash, 只有HEAD会移动到相应的历史提交. 这会造成HEAD分离, 非常危险的操作, 这个命令的说明只是为了满足你的好奇心而已, 不要使用这个命令.
reset-commit
reset命令把当前分支指向另一个位置,并且有选择的变动工作目录和索引。也用来在从历史仓库中复制文件到索引,而不动工作目录。

如果不给选项,那么当前分支指向到那个提交。如果用–hard选项,那么工作目录也更新,如果用–soft选项,那么都不变。

reset
如果没有给出提交点的版本号,那么默认用HEAD。这样,分支指向不变,但是索引会回滚到最后一次提交,如果用–hard选项,工作目录也同样。
》》》

删除文件

在Git中,删除也是一个修改操作,我们实战一下,先添加一个新文件test.txt到Git并且提交:

1
2
3
4
5
$ git add test.txt
$ git commit -m "add test.txt"
[master 94cdc44] add test.txt
1 file changed, 1 insertion(+)
create mode 100644 test.txt

一般情况下,你通常直接在文件管理器中把没用的文件删了,或者用rm命令删了:

1
$ rm test.txt

这个时候,Git知道你删除了文件,因此,工作区和版本库就不一致了,git status命令会立刻告诉你哪些文件被删除了:

1
2
3
4
5
6
7
8
9
$ git status
# On branch master
# Changes not staged for commit:
# (use "git add/rm <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# deleted: test.txt
#
no changes added to commit (use "git add" and/or "git commit -a")

现在你有两个选择,一是确实要从版本库中删除该文件,那就用命令git rm删掉,并且git commit:

1
2
3
4
5
6
$ git rm test.txt
rm 'test.txt'
$ git commit -m "remove test.txt"
[master d17efd8] remove test.txt
1 file changed, 1 deletion(-)
delete mode 100644 test.txt

现在,文件就从版本库中被删除了。

另一种情况是删错了,因为版本库里还有呢,所以可以很轻松地把误删的文件恢复到最新版本:

1
$ git checkout -- test.txt

git checkout其实是用版本库里的版本替换工作区的版本,无论工作区是修改还是删除,都可以“一键还原”。

小结
命令git rm用于删除一个文件。如果一个文件已经被提交到版本库,那么你永远不用担心误删,但是要小心,你只能恢复文件到最新版本,你会丢失最近一次提交后你修改的内容。

远程仓库

到目前为止,我们已经掌握了如何在Git仓库里对一个文件进行时光穿梭,你再也不用担心文件备份或者丢失的问题了。

可是有用过集中式版本控制系统SVN的童鞋会站出来说,这些功能在SVN里早就有了,没看出Git有什么特别的地方。

没错,如果只是在一个仓库里管理文件历史,Git和SVN真没啥区别。为了保证你现在所学的Git物超所值,将来绝对不会后悔,同时为了打击已经不幸学了SVN的童鞋,本章开始介绍Git的杀手级功能之一(注意是之一,也就是后面还有之二,之三……):远程仓库。

Git是分布式版本控制系统,同一个Git仓库,可以分布到不同的机器上。怎么分布呢?最早,肯定只有一台机器有一个原始版本库,此后,别的机器可以“克隆”这个原始版本库,而且每台机器的版本库其实都是一样的,并没有主次之分。

你肯定会想,至少需要两台机器才能玩远程库不是?但是我只有一台电脑,怎么玩?

其实一台电脑上也是可以克隆多个版本库的,只要不在同一个目录下。不过,现实生活中是不会有人这么傻的在一台电脑上搞几个远程库玩,因为一台电脑上搞几个远程库完全没有意义,而且硬盘挂了会导致所有库都挂掉,所以我也不告诉你在一台电脑上怎么克隆多个仓库。

实际情况往往是这样,找一台电脑充当服务器的角色,每天24小时开机,其他每个人都从这个“服务器”仓库克隆一份到自己的电脑上,并且各自把各自的提交推送到服务器仓库里,也从服务器仓库中拉取别人的提交。

完全可以自己搭建一台运行Git的服务器,不过现阶段,为了学Git先搭个服务器绝对是小题大作。好在这个世界上有个叫GitHub的神奇的网站,从名字就可以看出,这个网站就是提供Git仓库托管服务的,所以,只要注册一个GitHub账号,就可以免费获得Git远程仓库。

在继续阅读后续内容前,请自行注册GitHub账号。由于你的本地Git仓库和GitHub仓库之间的传输是通过SSH加密的,所以,需要一点设置:

第1步:创建SSH Key。在用户主目录下,看看有没有.ssh目录,如果有,再看看这个目录下有没有id_rsa和id_rsa.pub这两个文件,如果已经有了,可直接跳到下一步。如果没有,打开Shell(Windows下打开Git Bash),创建SSH Key:

1
$ ssh-keygen -t rsa -C "youremail@example.com"

你需要把邮件地址换成你自己的邮件地址,然后一路回车,使用默认值即可,由于这个Key也不是用于军事目的,所以也无需设置密码。

如果一切顺利的话,可以在用户主目录里找到.ssh目录,里面有id_rsa和id_rsa.pub两个文件,这两个就是SSH Key的秘钥对,id_rsa是私钥,不能泄露出去,id_rsa.pub是公钥,可以放心地告诉任何人。

第2步:登陆GitHub,打开“Account settings”,“SSH Keys”页面:

然后,点“Add SSH Key”,填上任意Title,在Key文本框里粘贴id_rsa.pub文件的内容:
addSshKey
点“Add Key”,你就应该看到已经添加的Key:
keys
为什么GitHub需要SSH Key呢?因为GitHub需要识别出你推送的提交确实是你推送的,而不是别人冒充的,而Git支持SSH协议,所以,GitHub只要知道了你的公钥,就可以确认只有你自己才能推送。

当然,GitHub允许你添加多个Key。假定你有若干电脑,你一会儿在公司提交,一会儿在家里提交,只要把每台电脑的Key都添加到GitHub,就可以在每台电脑上往GitHub推送了。

最后友情提示,在GitHub上免费托管的Git仓库,任何人都可以看到喔(但只有你自己才能改)。所以,不要把敏感信息放进去。

如果你不想让别人看到Git库,有两个办法,一个是交点保护费,让GitHub把公开的仓库变成私有的,这样别人就看不见了(不可读更不可写)。另一个办法是自己动手,搭一个Git服务器,因为是你自己的Git服务器,所以别人也是看不见的。这个方法我们后面会讲到的,相当简单,公司内部开发必备。

确保你拥有一个GitHub账号后,我们就即将开始远程仓库的学习。

添加远程库

现在的情景是,你已经在本地创建了一个Git仓库后,又想在GitHub创建一个Git仓库,并且让这两个仓库进行远程同步,这样,GitHub上的仓库既可以作为备份,又可以让其他人通过该仓库来协作,真是一举多得。

首先,登陆GitHub,然后,在右上角找到“Create a new repo”按钮,创建一个新的仓库:
createRepo
在Repository name填入learngit,其他保持默认设置,点击“Create repository”按钮,就成功地创建了一个新的Git仓库:
confirm repo
目前,在GitHub上的这个learngit仓库还是空的,GitHub告诉我们,可以从这个仓库克隆出新的仓库,也可以把一个已有的本地仓库与之关联,然后,把本地仓库的内容推送到GitHub仓库。

现在,我们根据GitHub的提示,在本地的learngit仓库下运行命令:

1
$ git remote add origin git@github.com:michaelliao/learngit.git

请千万注意,把上面的michaelliao替换成你自己的GitHub账户名,否则,你在本地关联的就是我的远程库,关联没有问题,但是你以后推送是推不上去的,因为你的SSH Key公钥不在我的账户列表中。

添加后,远程库的名字就是origin,这是Git默认的叫法,也可以改成别的,但是origin这个名字一看就知道是远程库。

下一步,就可以把本地库的所有内容推送到远程库上:

1
2
3
4
5
6
7
8
9
$ git push -u origin master
Counting objects: 19, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (19/19), done.
Writing objects: 100% (19/19), 13.73 KiB, done.
Total 23 (delta 6), reused 0 (delta 0)
To git@github.com:michaelliao/learngit.git
* [new branch] master -> master
Branch master set up to track remote branch master from origin

把本地库的内容推送到远程,用git push命令,实际上是把当前分支master推送到远程。

由于远程库是空的,我们第一次推送master分支时,加上了-u参数,Git不但会把本地的master分支内容推送的远程新的master分支,还会把本地的master分支和远程的master分支关联起来,在以后的推送或者拉取时就可以简化命令。

推送成功后,可以立刻在GitHub页面中看到远程库的内容已经和本地一模一样:
files lise
从现在起,只要本地作了提交,就可以通过命令:

1
$ git push origin master

把本地master分支的最新修改推送至GitHub,现在,你就拥有了真正的分布式版本库!
SSH警告
当你第一次使用Git的clone或者push命令连接GitHub时,会得到一个警告:

1
2
3
The authenticity of host 'github.com (xx.xx.xx.xx)' can't be established.
RSA key fingerprint is xx.xx.xx.xx.xx.
Are you sure you want to continue connecting (yes/no)?

这是因为Git使用SSH连接,而SSH连接在第一次验证GitHub服务器的Key时,需要你确认GitHub的Key的指纹信息是否真的来自GitHub的服务器,输入yes回车即可。

Git会输出一个警告,告诉你已经把GitHub的Key添加到本机的一个信任列表里了:

1
Warning: Permanently added 'github.com' (RSA) to the list of known hosts.

这个警告只会出现一次,后面的操作就不会有任何警告了。
如果你实在担心有人冒充GitHub服务器,输入yes前可以对照GitHub的RSA Key的指纹信息是否与SSH连接给出的一致。

小结
要关联一个远程库,使用命令git remote add origin git@server-name:path/repo-name.git;
关联后,使用命令git push -u origin master第一次推送master分支的所有内容;
此后,每次本地提交后,只要有必要,就可以使用命令git push origin master推送最新修改;
分布式版本系统的最大好处之一是在本地工作完全不需要考虑远程库的存在,也就是有没有联网都可以正常工作,而SVN在没有联网的时候是拒绝干活的!当有网络的时候,再把本地提交推送一下就完成了同步,真是太方便了!

从远程库克隆

当远程库已经准备好了,下一步是用命令git clone克隆一个本地库:

1
2
3
4
5
6
7
8
9
$ git clone git@github.com:michaelliao/gitskills.git
Cloning into 'gitskills'...
remote: Counting objects: 3, done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Receiving objects: 100% (3/3), done.

$ cd gitskills
$ ls
README.md

注意把Git库的地址换成你自己的,然后进入gitskills目录看看,已经有README.md文件了。
如果有多个人协作开发,那么每个人各自从远程克隆一份就可以了。
你也许还注意到,GitHub给出的地址不止一个,还可以用https://github.com/michaelliao/**.git这样的地址。实际上,Git支持多种协议,默认的git://使用ssh,但也可以使用https等其他协议。

使用https除了速度慢以外,还有个最大的麻烦是每次推送都必须输入口令,但是在某些只开放http端口的公司内部就无法使用ssh协议而只能用https。

小结
要克隆一个仓库,首先必须知道仓库的地址,然后使用git clone命令克隆。
Git支持多种协议,包括https,但通过ssh支持的原生git协议速度最快。

分支管理

分支就是科幻电影里面的平行宇宙,当你正在电脑前努力学习Git的时候,另一个你正在另一个平行宇宙里努力学习SVN。

如果两个平行宇宙互不干扰,那对现在的你也没啥影响。不过,在某个时间点,两个平行宇宙合并了,结果,你既学会了Git又学会了SVN!
分支在实际中有什么用呢?假设你准备开发一个新功能,但是需要两周才能完成,第一周你写了50%的代码,如果立刻提交,由于代码还没写完,不完整的代码库会导致别人不能干活了。如果等代码全部写完再一次提交,又存在丢失每天进度的巨大风险。

现在有了分支,就不用怕了。你创建了一个属于你自己的分支,别人看不到,还继续在原来的分支上正常工作,而你在自己的分支上干活,想提交就提交,直到开发完毕后,再一次性合并到原来的分支上,这样,既安全,又不影响别人工作。

其他版本控制系统如SVN等都有分支管理,但是用过之后你会发现,这些版本控制系统创建和切换分支比蜗牛还慢,简直让人无法忍受,结果分支功能成了摆设,大家都不去用。

但Git的分支是与众不同的,无论创建、切换和删除分支,Git在1秒钟之内就能完成!无论你的版本库是1个文件还是1万个文件。

创建与合并分支

在版本回退里,你已经知道,每次提交,Git都把它们串成一条时间线,这条时间线就是一个分支。截止到目前,只有一条时间线,在Git里,这个分支叫主分支,即master分支。HEAD严格来说不是指向提交,而是指向master,master才是指向提交的,所以,HEAD指向的就是当前分支。

一开始的时候,master分支是一条线,Git用master指向最新的提交,再用HEAD指向master,就能确定当前分支,以及当前分支的提交点:
head master
每次提交,master分支都会向前移动一步,这样,随着你不断提交,master分支的线也越来越长。
我们创建新的分支,例如dev时,Git新建了一个指针叫dev,指向master相同的提交,再把HEAD指向dev,就表示当前分支在dev上:
head dev
你看,Git创建一个分支很快,因为除了增加一个dev指针,改改HEAD的指向,工作区的文件都没有任何变化!

不过,从现在开始,对工作区的修改和提交就是针对dev分支了,比如新提交一次后,dev指针往前移动一步,而master指针不变:

head pointer new

假如我们在dev上的工作完成了,就可以把dev合并到master上。Git怎么合并呢?最简单的方法,就是直接把master指向dev的当前提交,就完成了合并:
head merge

所以Git合并分支也很快!就改改指针,工作区内容也不变!

合并完分支后,甚至可以删除dev分支。删除dev分支就是把dev指针给删掉,删掉后,我们就剩下了一条master分支:
head new
真是太神奇了,你看得出来有些提交是通过分支完成的吗?

下面开始实战。
首先,我们创建dev分支,然后切换到dev分支:

1
2
$ git checkout -b dev
Switched to a new branch 'dev'

git checkout命令加上-b参数表示创建并切换,相当于以下两条命令:

1
2
3
$ git branch dev
$ git checkout dev
Switched to branch 'dev'

然后,用git branch命令查看当前分支:

1
2
3
$ git branch
* dev
master

git branch命令会列出所有分支,当前分支前面会标一个*号。

然后,我们就可以在dev分支上正常提交,比如对readme.txt做个修改,加上一行:

1
Creating a new branch is quick.

然后提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "branch test"
[dev fec145a] branch test
1 file changed, 1 insertion(+)

现在,dev分支的工作完成,我们就可以切换回master分支:

1
2
$ git checkout master
Switched to branch 'master'

切换回master分支后,再查看一个readme.txt文件,刚才添加的内容不见了!因为那个提交是在dev分支上,而master分支此刻的提交点并没有变:
head pointer master

现在,我们把dev分支的工作成果合并到master分支上:

1
2
3
4
5
$ git merge dev
Updating d17efd8..fec145a
Fast-forward
readme.txt | 1 +
1 file changed, 1 insertion(+)

git merge命令用于合并指定分支到当前分支。合并后,再查看readme.txt的内容,就可以看到,和dev分支的最新提交是完全一样的。

注意到上面的Fast-forward信息,Git告诉我们,这次合并是“快进模式”,也就是直接把master指向dev的当前提交,所以合并速度非常快。

当然,也不是每次合并都能Fast-forward,我们后面会讲其他方式的合并。

合并完成后,就可以放心地删除dev分支了:

1
2
$ git branch -d dev
Deleted branch dev (was fec145a).

删除后,查看branch,就只剩下master分支了:

1
2
$ git branch
* master

因为创建、合并和删除分支非常快,所以Git鼓励你使用分支完成某个任务,合并后再删掉分支,这和直接在master分支上工作效果是一样的,但过程更安全。

小结
Git鼓励大量使用分支:

  • 查看分支:git branch
  • 创建分支:git branch < name >
  • 切换分支:git checkout < name >
  • 创建+切换分支:git checkout -b < name >
  • 合并某分支到当前分支:git merge < name >
  • 删除分支:git branch -d < name >

解决冲突

人生不如意之事十之八九,合并分支往往也不是一帆风顺的。

准备新的feature1分支,继续我们的新分支开发:

1
2
$ git checkout -b feature1
Switched to a new branch 'feature1'

修改readme.txt最后一行,改为:

1
Creating a new branch is quick AND simple.

在feature1分支上提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "AND simple"
[feature1 75a857c] AND simple
1 file changed, 1 insertion(+), 1 deletion(-)

切换到master分支:

1
2
3
$ git checkout master
Switched to branch 'master'
Your branch is ahead of 'origin/master' by 1 commit.

Git还会自动提示我们当前master分支比远程的master分支要超前1个提交。

在master分支上把readme.txt文件的最后一行改为:

1
Creating a new branch is quick & simple.

提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "& simple"
[master 400b400] & simple
1 file changed, 1 insertion(+), 1 deletion(-)

现在,master分支和feature1分支各自都分别有新的提交,变成了这样:

master not same with branch
这种情况下,Git无法执行“快速合并”,只能试图把各自的修改合并起来,但这种合并就可能会有冲突,我们试试看:

1
2
3
4
$ git merge feature1
Auto-merging readme.txt
CONFLICT (content): Merge conflict in readme.txt
Automatic merge failed; fix conflicts and then commit the result.

果然冲突了!Git告诉我们,readme.txt文件存在冲突,必须手动解决冲突后再提交。git status也可以告诉我们冲突的文件:

1
2
3
4
5
6
7
8
9
10
$ git status
# On branch master
# Your branch is ahead of 'origin/master' by 2 commits.
#
# Unmerged paths:
# (use "git add/rm <file>..." as appropriate to mark resolution)
#
# both modified: readme.txt
#
no changes added to commit (use "git add" and/or "git commit -a")

我们可以直接查看readme.txt的内容:

1
2
3
4
5
6
7
8
9
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes of files.
<<<<<<< HEAD
Creating a new branch is quick & simple.
=======
Creating a new branch is quick AND simple.
>>>>>>> feature1

Git用<<<<<<<,=======,>>>>>>>标记出不同分支的内容,我们修改如下后保存:

1
Creating a new branch is quick and simple.

再提交:

1
2
3
$ git add readme.txt
$ git commit -m "conflict fixed"
[master 59bc1cb] conflict fixed

现在,master分支和feature1分支变成了下图所示:
branch fix on master

用带参数的git log也可以看到分支的合并情况:

1
2
3
4
5
6
7
8
$ git log --graph --pretty=oneline --abbrev-commit
* 59bc1cb conflict fixed
|\
| * 75a857c AND simple
* | 400b400 & simple
|/
* fec145a branch test
...

最后,删除feature1分支:

1
2
$ git branch -d feature1
Deleted branch feature1 (was 75a857c).

小结
当Git无法自动合并分支时,就必须首先解决冲突。解决冲突后,再提交,合并完成。
用git log –graph命令可以看到分支合并图。

分支管理策略

通常,合并分支时,如果可能,Git会用Fast forward模式,但这种模式下,删除分支后,会丢掉分支信息。

如果要强制禁用Fast forward模式,Git就会在merge时生成一个新的commit,这样,从分支历史上就可以看出分支信息。

下面我们实战一下–no-ff方式的git merge:

首先,仍然创建并切换dev分支:

1
2
$ git checkout -b dev
Switched to a new branch 'dev'

修改readme.txt文件,并提交一个新的commit:

1
2
3
4
$ git add readme.txt
$ git commit -m "add merge"
[dev 6224937] add merge
1 file changed, 1 insertion(+)

现在,我们切换回master:

1
2
$ git checkout master
Switched to branch 'master'

准备合并dev分支,请注意–no-ff参数,表示禁用Fast forward:

1
2
3
4
$ git merge --no-ff -m "merge with no-ff" dev
Merge made by the 'recursive' strategy.
readme.txt | 1 +
1 file changed, 1 insertion(+)

因为本次合并要创建一个新的commit,所以加上-m参数,把commit描述写进去。

合并后,我们用git log看看分支历史:

1
2
3
4
5
6
7
$ git log --graph --pretty=oneline --abbrev-commit
* 7825a50 merge with no-ff
|\
| * 6224937 add merge
|/
* 59bc1cb conflict fixed
...

可以看到,不使用Fast forward模式,merge后就像这样:
merge without fast forward

分支策略
在实际开发中,我们应该按照几个基本原则进行分支管理:

首先,master分支应该是非常稳定的,也就是仅用来发布新版本,平时不能在上面干活;

那在哪干活呢?干活都在dev分支上,也就是说,dev分支是不稳定的,到某个时候,比如1.0版本发布时,再把dev分支合并到master上,在master分支发布1.0版本;

你和你的小伙伴们每个人都在dev分支上干活,每个人都有自己的分支,时不时地往dev分支上合并就可以了。

所以,团队合作的分支看起来就像这样:
分支管理

小结
Git分支十分强大,在团队开发中应该充分应用。
合并分支时,加上–no-ff参数就可以用普通模式合并,合并后的历史有分支,能看出来曾经做过合并,而fast forward合并就看不出来曾经做过合并。

Bug分支

件开发中,bug就像家常便饭一样。有了bug就需要修复,在Git中,由于分支是如此的强大,所以,每个bug都可以通过一个新的临时分支来修复,修复后,合并分支,然后将临时分支删除。

当你接到一个修复一个代号101的bug的任务时,很自然地,你想创建一个分支issue-101来修复它,但是,等等,当前正在dev上进行的工作还没有提交:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git status
# On branch dev
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# new file: hello.py
#
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: readme.txt
#

并不是你不想提交,而是工作只进行到一半,还没法提交,预计完成还需1天时间。但是,必须在两个小时内修复该bug,怎么办?

幸好,Git还提供了一个stash功能,可以把当前工作现场“储藏”起来,等以后恢复现场后继续工作:

1
2
3
$ git stash
Saved working directory and index state WIP on dev: 6224937 add merge
HEAD is now at 6224937 add merge

现在,用git status查看工作区,就是干净的(除非有没有被Git管理的文件),因此可以放心地创建分支来修复bug。

首先确定要在哪个分支上修复bug,假定需要在master分支上修复,就从master创建临时分支:

1
2
3
4
5
$ git checkout master
Switched to branch 'master'
Your branch is ahead of 'origin/master' by 6 commits.
$ git checkout -b issue-101
Switched to a new branch 'issue-101'

现在修复bug,需要把“Git is free software …”改为“Git is a free software …”,然后提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "fix bug 101"
[issue-101 cc17032] fix bug 101
1 file changed, 1 insertion(+), 1 deletion(-)

修复完成后,切换到master分支,并完成合并,最后删除issue-101分支:

1
2
3
4
5
6
7
8
9
$ git checkout master
Switched to branch 'master'
Your branch is ahead of 'origin/master' by 2 commits.
$ git merge --no-ff -m "merged bug fix 101" issue-101
Merge made by the 'recursive' strategy.
readme.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
$ git branch -d issue-101
Deleted branch issue-101 (was cc17032).

太棒了,原计划两个小时的bug修复只花了5分钟!现在,是时候接着回到dev分支干活了!

1
2
3
4
5
$ git checkout dev
Switched to branch 'dev'
$ git status
# On branch dev
nothing to commit (working directory clean)

工作区是干净的,刚才的工作现场存到哪去了?用git stash list命令看看:

1
2
$ git stash list
stash@{0}: WIP on dev: 6224937 add merge

工作现场还在,Git把stash内容存在某个地方了,但是需要恢复一下,有两个办法:

一是用git stash apply恢复,但是恢复后,stash内容并不删除,你需要用git stash drop来删除;

另一种方式是用git stash pop,恢复的同时把stash内容也删了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git stash pop
# On branch dev
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# new file: hello.py
#
# Changes not staged for commit:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: readme.txt
#
Dropped refs/stash@{0} (f624f8e5f082f2df2bed8a4e09c12fd2943bdd40)

再用git stash list查看,就看不到任何stash内容了:

1
$ git stash list

你可以多次stash,恢复的时候,先用git stash list查看,然后恢复指定的stash,用命令:

1
$ git stash apply stash@{0}

小结
修复bug时,我们会通过创建新的bug分支进行修复,然后合并,最后删除;
当手头工作没有完成时,先把工作现场git stash一下,然后去修复bug,修复后,再git stash pop,回到工作现场。

Feature分支

软件开发中,总有无穷无尽的新的功能要不断添加进来。

添加一个新功能时,你肯定不希望因为一些实验性质的代码,把主分支搞乱了,所以,每添加一个新功能,最好新建一个feature分支,在上面开发,完成后,合并,最后,删除该feature分支。

现在,你终于接到了一个新任务:开发代号为Vulcan的新功能,该功能计划用于下一代星际飞船。

于是准备开发:

1
2
$ git checkout -b feature-vulcan
Switched to a new branch 'feature-vulcan'

5分钟后,开发完毕:

1
2
3
4
5
6
7
8
9
10
11
12
$ git add vulcan.py
$ git status
# On branch feature-vulcan
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# new file: vulcan.py
#
$ git commit -m "add feature vulcan"
[feature-vulcan 756d4af] add feature vulcan
1 file changed, 2 insertions(+)
create mode 100644 vulcan.py

切回dev,准备合并:

1
$ git checkout dev

一切顺利的话,feature分支和bug分支是类似的,合并,然后删除。但是,就在此时,接到上级命令,因经费不足,新功能必须取消!虽然白干了,但是这个分支还是必须就地销毁:

1
2
3
$ git branch -d feature-vulcan
error: The branch 'feature-vulcan' is not fully merged.
If you are sure you want to delete it, run 'git branch -D feature-vulcan'.

销毁失败。Git友情提醒,feature-vulcan分支还没有被合并,如果删除,将丢失掉修改,如果要强行删除,需要使用命令git branch -D feature-vulcan。

现在我们强行删除:

1
2
$ git branch -D feature-vulcan
Deleted branch feature-vulcan (was 756d4af).

终于删除成功!

小结

开发一个新feature,最好新建一个分支;如果要丢弃一个没有被合并过的分支,可以通过git branch -D < name >强行删除。

多人协作

当你从远程仓库克隆时,实际上Git自动把本地的master分支和远程的master分支对应起来了,并且,远程仓库的默认名称是origin。

要查看远程库的信息,用git remote:

1
2
$ git remote
origin

或者,用git remote -v显示更详细的信息:

1
2
3
$ git remote -v
origin git@github.com:michaelliao/learngit.git (fetch)
origin git@github.com:michaelliao/learngit.git (push)

上面显示了可以抓取和推送的origin的地址。如果没有推送权限,就看不到push的地址。

推送分支

推送分支,就是把该分支上的所有本地提交推送到远程库。推送时,要指定本地分支,这样,Git就会把该分支推送到远程库对应的远程分支上:

1
$ git push origin master

如果要推送其他分支,比如dev,就改成:

1
$ git push origin dev

但是,并不是一定要把本地分支往远程推送,那么,哪些分支需要推送,哪些不需要呢?

  • master分支是主分支,因此要时刻与远程同步;
  • dev分支是开发分支,团队所有成员都需要在上面工作,所以也需要与远程同步;
  • bug分支只用于在本地修复bug,就没必要推到远程了,除非老板要看看你每周到底修复了几个bug;
  • feature分支是否推到远程,取决于你是否和你的小伙伴合作在上面开发。

总之,就是在Git中,分支完全可以在本地自己藏着玩,是否推送,视你的心情而定!

抓取分支
多人协作时,大家都会往master和dev分支上推送各自的修改。
现在,模拟一个你的小伙伴,可以在另一台电脑(注意要把SSH Key添加到GitHub)或者同一台电脑的另一个目录下克隆:

1
2
3
4
5
6
7
$ git clone git@github.com:michaelliao/learngit.git
Cloning into 'learngit'...
remote: Counting objects: 46, done.
remote: Compressing objects: 100% (26/26), done.
remote: Total 46 (delta 16), reused 45 (delta 15)
Receiving objects: 100% (46/46), 15.69 KiB | 6 KiB/s, done.
Resolving deltas: 100% (16/16), done.

当你的小伙伴从远程库clone时,默认情况下,你的小伙伴只能看到本地的master分支。不信可以用git branch命令看看:

1
2
$ git branch
* master

现在,你的小伙伴要在dev分支上开发,就必须创建远程origin的dev分支到本地,于是他用这个命令创建本地dev分支:

1
$ git checkout -b dev origin/dev

现在,他就可以在dev上继续修改,然后,时不时地把dev分支push到远程:

1
2
3
4
5
6
7
8
9
10
11
$ git commit -m "add /usr/bin/env"
[dev 291bea8] add /usr/bin/env
1 file changed, 1 insertion(+)
$ git push origin dev
Counting objects: 5, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 349 bytes, done.
Total 3 (delta 0), reused 0 (delta 0)
To git@github.com:michaelliao/learngit.git
fc38031..291bea8 dev -> dev

你的小伙伴已经向origin/dev分支推送了他的提交,而碰巧你也对同样的文件作了修改,并试图推送:

1
2
3
4
5
6
7
8
9
10
11
12
$ git add hello.py
$ git commit -m "add coding: utf-8"
[dev bd6ae48] add coding: utf-8
1 file changed, 1 insertion(+)
$ git push origin dev
To git@github.com:michaelliao/learngit.git
! [rejected] dev -> dev (non-fast-forward)
error: failed to push some refs to 'git@github.com:michaelliao/learngit.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Merge the remote changes (e.g. 'git pull')
hint: before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

推送失败,因为你的小伙伴的最新提交和你试图推送的提交有冲突,解决办法也很简单,Git已经提示我们,先用git pull把最新的提交从origin/dev抓下来,然后,在本地合并,解决冲突,再推送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git pull
remote: Counting objects: 5, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 3 (delta 0)
Unpacking objects: 100% (3/3), done.
From github.com:michaelliao/learngit
fc38031..291bea8 dev -> origin/dev
There is no tracking information for the current branch.
Please specify which branch you want to merge with.
See git-pull(1) for details

git pull <remote> <branch>

If you wish to set tracking information for this branch you can do so with:

git branch --set-upstream dev origin/<branch>

git pull也失败了,原因是没有指定本地dev分支与远程origin/dev分支的链接,根据提示,设置dev和origin/dev的链接:

1
2
$ git branch --set-upstream dev origin/dev
Branch dev set up to track remote branch dev from origin.

再pull:

1
2
3
4
$ git pull
Auto-merging hello.py
CONFLICT (content): Merge conflict in hello.py
Automatic merge failed; fix conflicts and then commit the result.

这回git pull成功,但是合并有冲突,需要手动解决,解决的方法和分支管理中的解决冲突完全一样。解决后,提交,再push:

1
2
3
4
5
6
7
8
9
10
$ git commit -m "merge & fix hello.py"
[dev adca45d] merge & fix hello.py
$ git push origin dev
Counting objects: 10, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (6/6), 747 bytes, done.
Total 6 (delta 0), reused 0 (delta 0)
To git@github.com:michaelliao/learngit.git
291bea8..adca45d dev -> dev

因此,多人协作的工作模式通常是这样:

  • 首先,可以试图用git push origin branch-name推送自己的修改;
  • 如果推送失败,则因为远程分支比你的本地更新,需要先用git pull试图合并;
  • 如果合并有冲突,则解决冲突,并在本地提交;
  • 没有冲突或者解决掉冲突后,再用git push origin branch-name推送就能成功!
    如果git pull提示“no tracking information”,则说明本地分支和远程分支的链接关系没有创建,用命令git branch --set-upstream branch-name origin/branch-name。
    

这就是多人协作的工作模式,一旦熟悉了,就非常简单。

小结

  • 查看远程库信息,使用git remote -v;
  • 本地新建的分支如果不推送到远程,对其他人就是不可见的;
  • 从本地推送分支,使用git push origin branch-name,如果推送失败,先用git pull抓取远程的新提交;
  • 在本地创建和远程分支对应的分支,使用git checkout -b branch-name origin/branch-name,本地和远程分支的名称最好一致;
  • 建立本地分支和远程分支的关联,使用git branch –set-upstream branch-name origin/branch-name;
  • 从远程抓取分支,使用git pull,如果有冲突,要先处理冲突。

标签管理

发布一个版本时,我们通常先在版本库中打一个标签(tag),这样,就唯一确定了打标签时刻的版本。将来无论什么时候,取某个标签的版本,就是把那个打标签的时刻的历史版本取出来。所以,标签也是版本库的一个快照。

Git的标签虽然是版本库的快照,但其实它就是指向某个commit的指针(跟分支很像对不对?但是分支可以移动,标签不能移动),所以,创建和删除标签都是瞬间完成的。

Git有commit,为什么还要引入tag?
“请把上周一的那个版本打包发布,commit号是6a5819e…”,“一串乱七八糟的数字不好找!”

如果换一个办法:“请把上周一的那个版本打包发布,版本号是v1.2”,“好的,按照tag v1.2查找commit就行!”

所以,tag就是一个让人容易记住的有意义的名字,它跟某个commit绑在一起。

创建标签

在Git中打标签非常简单,首先,切换到需要打标签的分支上:

1
2
3
4
5
$ git branch
* dev
master
$ git checkout master
Switched to branch 'master'

然后,敲命令git tag < name >就可以打一个新标签:

1
$ git tag v1.0

可以用命令git tag查看所有标签:

1
2
$ git tag
v1.0

默认标签是打在最新提交的commit上的。有时候,如果忘了打标签,比如,现在已经是周五了,但应该在周一打的标签没有打,怎么办?

方法是找到历史提交的commit id,然后打上就可以了:

1
2
3
4
5
6
7
8
9
10
$ git log --pretty=oneline --abbrev-commit
6a5819e merged bug fix 101
cc17032 fix bug 101
7825a50 merge with no-ff
6224937 add merge
59bc1cb conflict fixed
400b400 & simple
75a857c AND simple
fec145a branch test
d17efd8 remove test.txt

比方说要对add merge这次提交打标签,它对应的commit id是6224937,敲入命令:

1
$ git tag v0.9 6224937

再用命令git tag查看标签:

1
2
3
$ git tag
v0.9
v1.0

注意,标签不是按时间顺序列出,而是按字母排序的。可以用git show < tagname >查看标签信息:

1
2
3
4
5
6
7
$ git show v0.9
commit 622493706ab447b6bb37e4e2a2f276a20fed2ab4
Author: Michael Liao <askxuefeng@gmail.com>
Date: Thu Aug 22 11:22:08 2013 +0800

add merge
...

可以看到,v0.9确实打在add merge这次提交上,还可以创建带有说明的标签,用-a指定标签名,-m指定说明文字。

1
$ git tag -a v0.1 -m "version 0.1 released" 3628164

用命令git show < tagname >可以看到说明文字:

1
2
3
4
5
6
7
8
9
10
11
12
$ git show v0.1
tag v0.1
Tagger: Michael Liao <askxuefeng@gmail.com>
Date: Mon Aug 26 07:28:11 2013 +0800

version 0.1 released

commit 3628164fb26d48395383f8f31179f24e0882e1e0
Author: Michael Liao <askxuefeng@gmail.com>
Date: Tue Aug 20 15:11:49 2013 +0800

append GPL

还可以通过-s用私钥签名一个标签:

1
$ git tag -s v0.2 -m "signed version 0.2 released" fec145a

签名采用PGP签名,因此,必须首先安装gpg(GnuPG),如果没有找到gpg,或者没有gpg密钥对,就会报错:

1
2
3
gpg: signing failed: secret key not available
error: gpg failed to sign the data
error: unable to sign the tag

如果报错,请参考GnuPG帮助文档配置Key,用命令git show < tagname >可以看到PGP签名信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git show v0.2
tag v0.2
Tagger: Michael Liao <askxuefeng@gmail.com>
Date: Mon Aug 26 07:28:33 2013 +0800

signed version 0.2 released
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.12 (Darwin)

iQEcBAABAgAGBQJSGpMhAAoJEPUxHyDAhBpT4QQIAKeHfR3bo...
-----END PGP SIGNATURE-----

commit fec145accd63cdc9ed95a2f557ea0658a2a6537f
Author: Michael Liao <askxuefeng@gmail.com>
Date: Thu Aug 22 10:37:30 2013 +0800

branch test

用PGP签名的标签是不可伪造的,因为可以验证PGP签名。验证签名的方法比较复杂,这里就不介绍了。

小结

  • 命令git tag < name >用于新建一个标签,默认为HEAD,也可以指定一个commit id;
  • git tag -a < tagname > -m “blablabla…”可以指定标签信息;
  • git tag -s < tagname > -m “blablabla…”可以用PGP签名标签;
  • 命令git tag可以查看所有标签。

操作标签

如果标签打错了,也可以删除:

1
2
$ git tag -d v0.1
Deleted tag 'v0.1' (was e078af9)

因为创建的标签都只存储在本地,不会自动推送到远程。所以,打错的标签可以在本地安全删除。
如果要推送某个标签到远程,使用命令git push origin < tagname >:

1
2
3
4
$ git push origin v1.0
Total 0 (delta 0), reused 0 (delta 0)
To git@github.com:michaelliao/learngit.git
* [new tag] v1.0 -> v1.0

或者,一次性推送全部尚未推送到远程的本地标签:

1
2
3
4
5
6
7
$ git push origin --tags
Counting objects: 1, done.
Writing objects: 100% (1/1), 554 bytes, done.
Total 1 (delta 0), reused 0 (delta 0)
To git@github.com:michaelliao/learngit.git
* [new tag] v0.2 -> v0.2
* [new tag] v0.9 -> v0.9

如果标签已经推送到远程,要删除远程标签就麻烦一点,先从本地删除:

1
2
$ git tag -d v0.9
Deleted tag 'v0.9' (was 6224937)

然后,从远程删除。删除命令也是push,但是格式如下:

1
2
3
$ git push origin :refs/tags/v0.9
To git@github.com:michaelliao/learngit.git
- [deleted] v0.9

要看看是否真的从远程库删除了标签,可以登陆GitHub查看。

小结

  • 命令git push origin < tagname >可以推送一个本地标签;
  • 命令git push origin –tags可以推送全部未推送过的本地标签;
  • 命令git tag -d < tagname>可以删除一个本地标签;
  • 命令git push origin :refs/tags/< tagname >(git push origin –delete tag v0.9 )?可以删除一个远程标签。

使用GitHub

我们一直用GitHub作为免费的远程仓库,如果是个人的开源项目,放到GitHub上是完全没有问题的。其实GitHub还是一个开源协作社区,通过GitHub,既可以让别人参与你的开源项目,也可以参与别人的开源项目。

在GitHub出现以前,开源项目开源容易,但让广大人民群众参与进来比较困难,因为要参与,就要提交代码,而给每个想提交代码的群众都开一个账号那是不现实的,因此,群众也仅限于报个bug,即使能改掉bug,也只能把diff文件用邮件发过去,很不方便。

但是在GitHub上,利用Git极其强大的克隆和分支功能,广大人民群众真正可以第一次自由参与各种开源项目了。

如何参与一个开源项目呢?比如人气极高的bootstrap项目,这是一个非常强大的CSS框架,你可以访问它的项目主页https://github.com/twbs/bootstrap,点“Fork”就在自己的账号下克隆了一个bootstrap仓库,然后,从自己的账号下clone:

1
git clone git@github.com:michaelliao/bootstrap.git

一定要从自己的账号下clone仓库,这样你才能推送修改。如果从bootstrap的作者的仓库地址git@github.com:twbs/bootstrap.git克隆,因为没有权限,你将不能推送修改。

Bootstrap的官方仓库twbs/bootstrap、你在GitHub上克隆的仓库my/bootstrap,以及你自己克隆到本地电脑的仓库,他们的关系就像下图显示的那样:
git clone
如果你想修复bootstrap的一个bug,或者新增一个功能,立刻就可以开始干活,干完后,往自己的仓库推送。

如果你希望bootstrap的官方库能接受你的修改,你就可以在GitHub上发起一个pull request。当然,对方是否接受你的pull request就不一定了。

如果你没能力修改bootstrap,但又想要试一把pull request,那就Fork一下我的仓库:https://github.com/michaelliao/learngit,创建一个your-github-id.txt的文本文件,写点自己学习Git的心得,然后推送一个pull request给我,我会视心情而定是否接受。

小结

  • 在GitHub上,可以任意Fork开源仓库;
  • 自己拥有Fork后的仓库的读写权限;
  • 可以推送pull request给官方仓库来贡献代码。

自定义Git

在安装Git一节中,我们已经配置了user.name和user.email,实际上,Git还有很多可配置项。比如,让Git显示颜色,会让命令输出看起来更醒目:

1
$ git config --global color.ui true

这样,Git会适当地显示不同的颜色,比如git status命令:
git设置好颜色后
文件名就会标上颜色,我们在后面还会介绍如何更好地配置Git,以便让你的工作更高效。

忽略特殊文件

有些时候,你必须把某些文件放到Git工作目录中,但又不能提交它们,比如保存了数据库密码的配置文件啦,等等,每次git status都会显示Untracked files …,有强迫症的童鞋心里肯定不爽。

好在Git考虑到了大家的感受,这个问题解决起来也很简单,在Git工作区的根目录下创建一个特殊的.gitignore文件,然后把要忽略的文件名填进去,Git就会自动忽略这些文件。

不需要从头写.gitignore文件,GitHub已经为我们准备了各种配置文件,只需要组合一下就可以使用了。所有配置文件可以直接在线浏览:https://github.com/github/gitignore

忽略文件的原则是:

  1. 忽略操作系统自动生成的文件,比如缩略图等;
  2. 忽略编译生成的中间文件、可执行文件等,也就是如果一个文件是通过另一个文件自动3. 生成的,那自动生成的文件就没必要放进版本库,比如Java编译产生的.class文件;
  3. 忽略你自己的带有敏感信息的配置文件,比如存放口令的配置文件。
    举个例子:
    假设你在Windows下进行Python开发,Windows会自动在有图片的目录下生成隐藏的缩略图文件,如果有自定义目录,目录下就会有Desktop.ini文件,因此你需要忽略Windows自动生成的垃圾文件:
    1
    2
    3
    4
    # Windows:
    Thumbs.db
    ehthumbs.db
    Desktop.ini

然后,继续忽略Python编译产生的.pyc、.pyo、dist等文件或目录:

1
2
3
4
5
6
7
# Python:
*.py[cod]
*.so
*.egg
*.egg-info
dist
build

加上你自己定义的文件,最终得到一个完整的.gitignore文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Windows:
Thumbs.db
ehthumbs.db
Desktop.ini

# Python:
*.py[cod]
*.so
*.egg
*.egg-info
dist
build

# My configurations:
db.ini
deploy_key_rsa

最后一步就是把.gitignore也提交到Git,就完成了!当然检验.gitignore的标准是git status命令是不是说working directory clean。

使用Windows的童鞋注意了,如果你在资源管理器里新建一个.gitignore文件,它会非常弱智地提示你必须输入文件名,但是在文本编辑器里“保存”或者“另存为”就可以把文件保存为.gitignore了。

有些时候,你想添加一个文件到Git,但发现添加不了,原因是这个文件被.gitignore忽略了:

1
2
3
4
$ git add App.class
The following paths are ignored by one of your .gitignore files:
App.class
Use -f if you really want to add them.

如果你确实想添加该文件,可以用-f强制添加到Git:

1
$ git add -f App.class

或者你发现,可能是.gitignore写得有问题,需要找出来到底哪个规则写错了,可以用git check-ignore命令检查:

1
2
$ git check-ignore -v App.class
.gitignore:3:*.class App.class

Git会告诉我们,.gitignore的第3行规则忽略了该文件,于是我们就可以知道应该修订哪个规则。

小结

  • 忽略某些文件时,需要编写.gitignore;
  • .gitignore文件本身要放到版本库里,并且可以对.gitignore做版本管理!

配置别名

有没有经常敲错命令?比如git status?status这个单词真心不好记。如果敲git st就表示git status那就简单多了,当然这种偷懒的办法我们是极力赞成的。我们只需要敲一行命令,告诉Git,以后st就表示status:

1
$ git config --global alias.st status

好了,现在敲git st看看效果,当然还有别的命令可以简写,很多人都用co表示checkout,ci表示commit,br表示branch:

1
2
3
$ git config --global alias.co checkout
$ git config --global alias.ci commit
$ git config --global alias.br branch

以后提交就可以简写成:

1
$ git ci -m "bala bala bala..."

–global参数是全局参数,也就是这些命令在这台电脑的所有Git仓库下都有用。

在撤销修改一节中,我们知道,命令git reset HEAD file可以把暂存区的修改撤销掉(unstage),重新放回工作区。既然是一个unstage操作,就可以配置一个unstage别名:

1
$ git config --global alias.unstage 'reset HEAD'

当你敲入命令:

1
$ git unstage test.py

实际上Git执行的是:

1
$ git reset HEAD test.py

配置一个git last,让其显示最后一次提交信息:

1
$ git config --global alias.last 'log -1'

这样,用git last就能显示最近一次的提交:

1
2
3
4
5
6
7
$ git last
commit adca45d317e6d8a4b23f9811c3d7b7f0f180bfe2
Merge: bd6ae48 291bea8
Author: Michael Liao <askxuefeng@gmail.com>
Date: Thu Aug 22 22:49:22 2013 +0800

merge & fix hello.py

甚至还有人丧心病狂地把lg配置成了:

1
git config --global alias.lg "log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"

来看看git lg的效果:
git 别名配置
为什么不早点告诉我?别激动,咱不是为了多记几个英文单词嘛!

配置文件

配置Git的时候,加上–global是针对当前用户起作用的,如果不加,那只针对当前的仓库起作用。配置文件放哪了?每个仓库的Git配置文件都放在.git/config文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = git@github.com:michaelliao/learngit.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
[alias]
last = log -1

别名就在[alias]后面,要删除别名,直接把对应的行删掉即可。

而当前用户的Git配置文件放在用户主目录下的一个隐藏文件.gitconfig中:

1
2
3
4
5
6
7
8
9
$ cat .gitconfig
[alias]
co = checkout
ci = commit
br = branch
st = status
[user]
name = Your Name
email = your@email.com

配置别名也可以直接修改这个文件,如果改错了,可以删掉文件重新通过命令配置。

搭建Git服务器

在远程仓库一节中,我们讲了远程仓库实际上和本地仓库没啥不同,纯粹为了7x24小时开机并交换大家的修改。

GitHub就是一个免费托管开源代码的远程仓库。但是对于某些视源代码如生命的商业公司来说,既不想公开源代码,又舍不得给GitHub交保护费,那就只能自己搭建一台Git服务器作为私有仓库使用。

搭建Git服务器需要准备一台运行Linux的机器,强烈推荐用Ubuntu或Debian,这样,通过几条简单的apt命令就可以完成安装。

假设你已经有sudo权限的用户账号,下面,正式开始安装。

第一步,安装git:

1
$ sudo apt-get install git

第二步,创建一个git用户,用来运行git服务:

1
$ sudo adduser git

第三步,创建证书登录:
收集所有需要登录的用户的公钥,就是他们自己的id_rsa.pub文件,把所有公钥导入到/home/git/.ssh/authorized_keys文件里,一行一个。

第四步,初始化Git仓库:

先选定一个目录作为Git仓库,假定是/srv/sample.git,在/srv目录下输入命令:

1
$ sudo git init --bare sample.git

Git就会创建一个裸仓库,裸仓库没有工作区,因为服务器上的Git仓库纯粹是为了共享,所以不让用户直接登录到服务器上去改工作区,并且服务器上的Git仓库通常都以.git结尾。然后,把owner改为git:

1
$ sudo chown -R git:git sample.git

第五步,禁用shell登录:

出于安全考虑,第二步创建的git用户不允许登录shell,这可以通过编辑/etc/passwd文件完成。找到类似下面的一行:

1
git:x:1001:1001:,,,:/home/git:/bin/bash

改为:

1
git:x:1001:1001:,,,:/home/git:/usr/bin/git-shell

这样,git用户可以正常通过ssh使用git,但无法登录shell,因为我们为git用户指定的git-shell每次一登录就自动退出。

第六步,克隆远程仓库:

现在,可以通过git clone命令克隆远程仓库了,在各自的电脑上运行

1
2
3
$ git clone git@server:/srv/sample.git
Cloning into 'sample'...
warning: You appear to have cloned an empty repository.

剩下的推送就简单了。

管理公钥

如果团队很小,把每个人的公钥收集起来放到服务器的/home/git/.ssh/authorized_keys文件里就是可行的。如果团队有几百号人,就没法这么玩了,这时,可以用Gitosis来管理公钥。

这里我们不介绍怎么玩Gitosis了,几百号人的团队基本都在500强了,相信找个高水平的Linux管理员问题不大。

管理权限

有很多不但视源代码如生命,而且视员工为窃贼的公司,会在版本控制系统里设置一套完善的权限控制,每个人是否有读写权限会精确到每个分支甚至每个目录下。因为Git是为Linux源代码托管而开发的,所以Git也继承了开源社区的精神,不支持权限控制。不过,因为Git支持钩子(hook),所以,可以在服务器端编写一系列脚本来控制提交等操作,达到权限控制的目的。Gitolite就是这个工具。

这里我们也不介绍Gitolite了,不要把有限的生命浪费到权限斗争中。

小结

  • 搭建Git服务器非常简单,通常10分钟即可完成;
  • 要方便管理公钥,用Gitosis;
  • 要像SVN那样变态地控制权限,用Gitolite。

来源
教程:http://www.liaoxuefeng.com/wiki/0013739516305929606dd18361248578c67b8067c8c017b000/

Bitmap压缩

发表于 2017-06-21 | 分类于 Android

知识准备

  要做图片压缩首先我们的了解bitmap占用内存的计算方式:
bitmap大小=图片长度X图片宽度X一个像素点占用的字节数
  而像素点占用的字节数通过压缩格式来指定:

来源 格式 所占字节数
Bitmap.Config ALPHA_8 表示8位Alpha位图,即A=8,一个像素点占用1个字节,它没有颜色,只有透明度
Bitmap.Config ARGB_4444 表示16位ARGB位图,即A=4,R=4,G=4,B=4,一个像素点占4+4+4+4=16位,2个字节
Bitmap.Config ARGB_8888 表示32位ARGB位图,即A=8,R=8,G=8,B=8,一个像素点占8+8+8+8=32位,4个字节
Bitmap.Config RGB_565 表示16位RGB位图,即R=5,G=6,B=5,它没有透明度,一个像素点占5+6+5=16位,2个字节

压缩方法介绍

  下面我们开始介绍几种压缩方式:

  1. 质量压缩
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    int quality = Integer.valueOf(editText.getText().toString());
    //quality-压缩率:1~100的整数,越小压缩出的图片越小质量越差,100则不压缩。
    bit.compress(CompressFormat.JPEG, quality, baos);
    byte[] bytes = baos.toByteArray();
    bm = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
    Log.i("wechat", "压缩后图片的大小" + (bm.getByteCount() / 1024 / 1024)
    + "M宽度为" + bm.getWidth() + "高度为" + bm.getHeight()
    + "bytes.length= " + (bytes.length / 1024) + "KB"
    + "quality=" + quality)

  其中quality是从edittext获取的数字或自己制定,可以从1–100改变,100表示百分百,也就是不压缩,原型输出,数字越小表示质量越差当然内存也就越小。注意bit.compress(CompressFormat.PNG, quality, baos);设定png格式,quality就没有作用了,bytes.length不会变化,因为png图片是无损的?(待验证,网上资料都是通过减小这个值来减少内存),不能进行压缩,CompressFormat还有一个属性是,CompressFormat.WEBP格式,该格式是google自己推出来一个图片格式。

  1. 采样率压缩
    1
    2
    3
    4
    5
    6
    7
    8
    //如果把options.inJustDecodeBounds 设置成true,表示只返回宽高。
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inSampleSize = 2;
    bm = BitmapFactory.decodeFile(Environment
    .getExternalStorageDirectory().getAbsolutePath()
    + "/DCIM/Camera/test.jpg", options);
    Log.i("wechat", "压缩后图片的大小" + (bm.getByteCount() / 1024 / 1024)
    + "M宽度为" + bm.getWidth() + "高度为" + bm.getHeight());

  设置inSampleSize的值(int类型)后,假如设为2,则宽和高都为原来的1/2,宽高都减少了,自然内存也降低了。上面的代码没用过options.inJustDecodeBounds = true; 因为我是固定来取样的数据,为什么这个压缩方法叫采样率压缩,是因为配合inJustDecodeBounds,先获取图片的宽、高【这个过程就是取样】,然后通过获取的宽高,动态的设置inSampleSize的值,当inJustDecodeBounds设置为true的时候,BitmapFactory通过decodeResource或者decodeFile解码图片时,将会返回空(null)的Bitmap对象,这样可以避免Bitmap的内存分配,但是它可以返回Bitmap的宽度、高度以及MimeType。

  1. 缩放法压缩(martix)
    1
    2
    3
    4
    5
    6
    Matrix matrix = new Matrix();
    matrix.setScale(0.5f, 0.5f);
    bm = Bitmap.createBitmap(bit, 0, 0, bit.getWidth(),
    bit.getHeight(), matrix, true);
    Log.i("wechat", "压缩后图片的大小" + (bm.getByteCount() / 1024 / 1024)
    + "M宽度为" + bm.getWidth() + "高度为" + bm.getHeight());

  通过scale的设定,bitmap的长度和宽度分别缩小了一半,图片大小缩小了四分之一。

  1. RGB_565法
    1
    2
    3
    4
    5
    6
    7
    8
    BitmapFactory.Options options2 = new BitmapFactory.Options();
    options2.inPreferredConfig = Bitmap.Config.RGB_565;

    bm = BitmapFactory.decodeFile(Environment
    .getExternalStorageDirectory().getAbsolutePath()
    + "/DCIM/Camera/test.jpg", options2);
    Log.i("wechat", "压缩后图片的大小" + (bm.getByteCount() / 1024 / 1024)
    + "M宽度为" + bm.getWidth() + "高度为" + bm.getHeight());

  图片大小会直接缩小了一半,长度和宽度也没有变,相比argb_8888减少了一半的内存。

注意:由于ARGB_4444的画质惨不忍睹,一般假如对图片没有透明度要求的话,可以改成RGB_565,相比ARGB_8888将节省一半的内存开销。

  1. createScaleBitmap
    1
    2
    3
    bm = Bitmap.createScaledBitmap(bit, 150, 150, true);
    Log.i("wechat", "压缩后图片的大小" + (bm.getByteCount() / 1024) + "KB宽度为"
    + bm.getWidth() + "高度为" + bm.getHeight());

  这里是将图片压缩成用户所期望的长度和宽度,但是这里要说,如果用户期望的长度和宽度和原图长度宽度相差太多的话,图片会很不清晰。

总结

  以上就是5种图片压缩的方法,需要强调的是他们的压缩仅仅只是对android中的bitmap来说的。如果将这些压缩后的bitmap另存为sd中,他们的内存大小并不一样。android手机中,图片的所占的内存大小和很多因素相关,计算起来也很麻烦。为了计算出一个图片的内存大小,可以将图片当做一个文件来间接计算,用如下的方法:

1
2
3
File file = new File(Environment.getExternalStorageDirectory()
.getAbsolutePath() + "/DCIM/Camera/test.jpg");
Log.i("wechat", "file.length()=" + file.length() / 1024);

或者

1
2
3
4
5
6
7
8
9
10
11
12
FileInputStream fis = null;
try {
fis = new FileInputStream(file);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
try {
Log.i("wechat", "fis.available()=" + fis.available() / 1024);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

  上面两个方法计算的结果是一样的。其实说白了,Bitmap压缩都是围绕公式:Bitmap所占用的内存 = 图片长度 x 图片宽度 x 一个像素点占用的字节数 ,3个参数,任意减少一个的值,就达到了压缩的效果。

注意 当我们通过BitmapFactory来decode网络或者本地图片是需要提前计算出压缩出来的图片大小,如果不提前处理会出现OOM,举个栗子:从本地读取一个4M的图片到bitmap时,bitmap的内存占用可能是20M,而系统分配给app的内存最大空间可能为16M,结果就是OOM了,-^-。

来源:http://blog.csdn.net/harryweasley/article/details/51955467
参考:
Android Bitmap 优化(1) - 图片压缩 http://anany.me/2015/10/15/bitmap1/
多图比较谷歌WebP和JPEG图像格式http://www.win7china.com/html/8668.html
Android-使用Matrix对Bitmap进行处理http://blog.csdn.net/nupt123456789/article/details/24600055

1…3456

CallteFoot

The blog from a Android coder

56 日志
23 分类
71 标签
© 2020 CallteFoot
由 Hexo 强力驱动
|
主题 — NexT.Gemini v5.1.4