Butterknife源碼解析

前言

Jake Wharton大神的Butterknife可謂是造福廣大Android開發者, 再也不用重復寫findViewById和setOnClickListener了.但是用了這么久的Butterknife, 一直以為用的是反射, 直到看了源碼...(路漫漫其修遠兮,吾將上下而求索)

Butterknife的使用

Git地址: https://github.com/JakeWharton/butterknife, 里面有具體的引用及使用說明, 這里不再介紹.

Butterknife的原理

談到Butterknife的原理, 不得不先提一下運行時注解和編譯時注解的區別:

  • 編譯時注解: (代碼生成)在編譯時掃描所有注解,對注解進行處理,在項目中生成一份代碼,然后在項目運行時直接調用;
  • 運行時注解: (代碼注入)在代碼中通過注解進行標記,在運行時通過反射在原代碼的基礎上完成注入;

Butterknife就是用的編譯時注解(Annotation Processing Tool),簡稱APT技術. 關于APT, 可以戳這里,里面有demo,介紹了編譯時自動生成代碼的整個過程. 看完這篇文章再看Butterknife就有章可循了.

Butterknife源碼解析

了解APT之后就知道了最重要的有兩步,一編譯時自動生成代碼,二運行時進行代碼綁定.下面以Butterknife最新版本8.8.1的源碼進行說明;

編譯時自動生成代碼

首先來看ButterKnifeProcessor類,繼承AbstractProcessor,需要復寫的方法:
init(): 主要初始化一些工具類

@Override 
public synchronized void init(ProcessingEnvironment env) {
    super.init(env);

    ... //獲取sdk和是否debug
    elementUtils = env.getElementUtils();//獲取元素相關信息的工具類
    typeUtils = env.getTypeUtils();//處理TypeMirror的工具類
    filer = env.getFiler();//生成java文件的工具類
    try {
      trees = Trees.instance(processingEnv);
    } catch (IllegalArgumentException ignored) {
    }
  }

getSupportedAnnotationTypes(): 返回支持的注解類型,包括BindAnim,BindArray等注解,以及OnClick,OnCheckedChanged等監聽.

private Set<Class<? extends Annotation>> getSupportedAnnotations() {
    Set<Class<? extends Annotation>> annotations = new LinkedHashSet<>();

    annotations.add(BindAnim.class);
    annotations.add(BindArray.class);
    annotations.add(BindBitmap.class);
    annotations.add(BindBool.class);
    annotations.add(BindColor.class);
    annotations.add(BindDimen.class);
    annotations.add(BindDrawable.class);
    annotations.add(BindFloat.class);
    annotations.add(BindFont.class);
    annotations.add(BindInt.class);
    annotations.add(BindString.class);
    annotations.add(BindView.class);
    annotations.add(BindViews.class);
    annotations.addAll(LISTENERS);

    return annotations;
  }

process():生成代碼的核心部分,分兩步,掃描處理代碼中所有的注解,然后通過javapoet生成java文件. 返回值表示這組注解是否被這個 Processor 接受,如果接受(返回true)后續子的 processor 不會再對這個注解進行處理.

@Override public boolean process(Set<? extends TypeElement> elements, RoundEnvironment env) {
    //掃描代碼中所有的注解,存到map中
    Map<TypeElement, BindingSet> bindingMap = findAndParseTargets(env);

    for (Map.Entry<TypeElement, BindingSet> entry : bindingMap.entrySet()) {
      TypeElement typeElement = entry.getKey();
      BindingSet binding = entry.getValue();
     //生成java文件
      JavaFile javaFile = binding.brewJava(sdk, debuggable);
      try {
        javaFile.writeTo(filer);
      } catch (IOException e) {
        error(typeElement, "Unable to write binding for type %s: %s", typeElement, e.getMessage());
      }
    }
    return false;
  }

RoundEnvironment表示當前或之前的運行環境,可以通過該對象查找注解.
TypeElement代表源代碼中的元素類型, 如包,類,屬性,方法, 源代碼中每一部分都是一種類型.包為PackageElement,類為TypeElement,屬性為VariableElement,方法為ExecuteableElement ,都是Element的子類.
然后看下findAndParseTargets方法是如何掃描所有注解的.

private Map<TypeElement, BindingSet> findAndParseTargets(RoundEnvironment env) {
    Map<TypeElement, BindingSet.Builder> builderMap = new LinkedHashMap<>();
    Set<TypeElement> erasedTargetNames = new LinkedHashSet<>();
    //建立view與id的關系
    scanForRClasses(env);

   // Process each @BindView element.
   //env.getElementsAnnotatedWith(BindView.class))獲取所有注解是BindView的元素
    for (Element element : env.getElementsAnnotatedWith(BindView.class)) {
      // we don't SuperficialValidation.validateElement(element)
      // so that an unresolved View type can be generated by later processing rounds
      try {
        parseBindView(element, builderMap, erasedTargetNames);
      } catch (Exception e) {
        logParsingError(element, BindView.class, e);
      }
    }    return bindingMap;
  }

首先看scanForRClasses()如何建立view與id的關系

private void scanForRClasses(RoundEnvironment env) {
    if (trees == null) return;
    //R文件掃描器,掃描代碼中所有的R文件
    RClassScanner scanner = new RClassScanner();

    for (Class<? extends Annotation> annotation : getSupportedAnnotations()) {
      for (Element element : env.getElementsAnnotatedWith(annotation)) {
        JCTree tree = (JCTree) trees.getTree(element, getMirror(element, annotation));
        if (tree != null) { // tree can be null if the references are compiled types and not source
          //獲取R文件的包名
          String respectivePackageName =
              elementUtils.getPackageOf(element).getQualifiedName().toString();
          scanner.setCurrentPackageName(respectivePackageName);
          tree.accept(scanner);
        }
      }
    }

    for (Map.Entry<String, Set<String>> packageNameToRClassSet : scanner.getRClasses().entrySet()) {
      String respectivePackageName = packageNameToRClassSet.getKey();
      for (String rClass : packageNameToRClassSet.getValue()) {
        //解析R文件
        parseRClass(respectivePackageName, rClass);
      }
    }
  }

parseRClass()利用IdScanner尋找R文件內部類,如array,attr,string等

private void parseRClass(String respectivePackageName, String rClass) {
    Element element;

    try {
      element = elementUtils.getTypeElement(rClass);
    } catch (MirroredTypeException mte) {
      element = typeUtils.asElement(mte.getTypeMirror());
    }

    JCTree tree = (JCTree) trees.getTree(element);
    if (tree != null) { // tree can be null if the references are compiled types and not source
      //利用IdScanner尋找R文件內部類,如array,attr,string等
      IdScanner idScanner = new IdScanner(symbols, elementUtils.getPackageOf(element)
          .getQualifiedName().toString(), respectivePackageName);
      tree.accept(idScanner);
    } else {
      parseCompiledR(respectivePackageName, (TypeElement) element);
    }
  }

在IdScanner類內部利用VarScanner掃描R文件內部類(id,string等)的屬性(鍵值對:資源名和id)

private static class IdScanner extends TreeScanner {
    private final Map<QualifiedId, Id> ids;
    private final String rPackageName;
    private final String respectivePackageName;

    IdScanner(Map<QualifiedId, Id> ids, String rPackageName, String respectivePackageName) {
      this.ids = ids;
      this.rPackageName = rPackageName;
      this.respectivePackageName = respectivePackageName;
    }

    @Override public void visitClassDef(JCTree.JCClassDecl jcClassDecl) {
      for (JCTree tree : jcClassDecl.defs) {
        if (tree instanceof ClassTree) {
          ClassTree classTree = (ClassTree) tree;
          String className = classTree.getSimpleName().toString();
          if (SUPPORTED_TYPES.contains(className)) {
            ClassName rClassName = ClassName.get(rPackageName, "R", className);
            VarScanner scanner = new VarScanner(ids, rClassName, respectivePackageName);
            ((JCTree) classTree).accept(scanner);
          }
        }
      }
    }
  }

在VarScanner類內部記錄資源名稱及id

private static class VarScanner extends TreeScanner {
    private final Map<QualifiedId, Id> ids;
    private final ClassName className;
    private final String respectivePackageName;

    private VarScanner(Map<QualifiedId, Id> ids, ClassName className,
        String respectivePackageName) {
      this.ids = ids;
      this.className = className;
      this.respectivePackageName = respectivePackageName;
    }

    @Override public void visitVarDef(JCTree.JCVariableDecl jcVariableDecl) {
      if ("int".equals(jcVariableDecl.getType().toString())) {
        int id = Integer.valueOf(jcVariableDecl.getInitializer().toString());
        String resourceName = jcVariableDecl.getName().toString();
        QualifiedId qualifiedId = new QualifiedId(respectivePackageName, id);
        ids.put(qualifiedId, new Id(id, className, resourceName));
      }
    }
  }

到此就建立了所有view與id的關系,然后看如何處理注解, 以BindView為例(其他類似). 將每一個BindView注解的元素存放到被綁定類的成員變量中.

private void parseBindView(Element element, Map<TypeElement, BindingSet.Builder> builderMap,
      Set<TypeElement> erasedTargetNames) {
    //獲取BindView注解元素的父元素, 如MainActivity
    TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

    // Start by verifying common generated code restrictions.
    //isInaccessibleViaGeneratedCode檢查父元素是否是類且非private
    //isBindingInWrongPackage檢查父元素是否是系統相關的類
    boolean hasError = isInaccessibleViaGeneratedCode(BindView.class, "fields", element)
        || isBindingInWrongPackage(BindView.class, element);

    // Verify that the target type extends from View.
    TypeMirror elementType = element.asType();
    if (elementType.getKind() == TypeKind.TYPEVAR) {
      TypeVariable typeVariable = (TypeVariable) elementType;
      elementType = typeVariable.getUpperBound();
    }
    Name qualifiedName = enclosingElement.getQualifiedName();
    Name simpleName = element.getSimpleName();
    //判斷BindView注解的元素是view的子類或接口,否則有錯return
    if (!isSubtypeOfType(elementType, VIEW_TYPE) && !isInterface(elementType)) {
      if (elementType.getKind() == TypeKind.ERROR) {
        note(element, "@%s field with unresolved type (%s) "
                + "must elsewhere be generated as a View or interface. (%s.%s)",
            BindView.class.getSimpleName(), elementType, qualifiedName, simpleName);
      } else {
        error(element, "@%s fields must extend from View or be an interface. (%s.%s)",
            BindView.class.getSimpleName(), qualifiedName, simpleName);
        hasError = true;
      }
    }

    if (hasError) {
      return;
    }

    // Assemble information on the field.
    //獲取BindView注解的value,及id
    int id = element.getAnnotation(BindView.class).value();
    //builderMap緩存所有被綁定類的信息, 鍵是父元素(如MainActivity),值是鍵對應的被綁定類的信息(如MainActivity_ViewBinding)
    //獲取BindingSet.Builder, 有則使用緩存,無則創建
    BindingSet.Builder builder = builderMap.get(enclosingElement);
    QualifiedId qualifiedId = elementToQualifiedId(element, id);
    if (builder != null) {
      //從被綁定類的成員變量中查找這個id,不為空,說明有多個view綁定了同一個id, 拋異常
      String existingBindingName = builder.findExistingBindingName(getId(qualifiedId));
      if (existingBindingName != null) {
        error(element, "Attempt to use @%s for an already bound ID %d on '%s'. (%s.%s)",
            BindView.class.getSimpleName(), id, existingBindingName,
            enclosingElement.getQualifiedName(), element.getSimpleName());
        return;
      }
    } else {
      builder = getOrCreateBindingBuilder(builderMap, enclosingElement);
    }
    //獲取注解元素的屬性名,類型,是否必須
    String name = simpleName.toString();
    TypeName type = TypeName.get(elementType);
    boolean required = isFieldRequired(element);
    //加入到成員變量集合中
    builder.addField(getId(qualifiedId), new FieldViewBinding(name, type, required));

    // Add the type-erased version to the valid binding targets set.
    erasedTargetNames.add(enclosingElement);
  }

再看如何創建被綁定類BindingSet

private BindingSet.Builder getOrCreateBindingBuilder(
      Map<TypeElement, BindingSet.Builder> builderMap, TypeElement enclosingElement) {
    BindingSet.Builder builder = builderMap.get(enclosingElement);
    if (builder == null) {
      builder = BindingSet.newBuilder(enclosingElement);
      builderMap.put(enclosingElement, builder);
    }
    return builder;
  }
static Builder newBuilder(TypeElement enclosingElement) {
    //獲取注解父元素的類型,View/Activity/Dialog
    TypeMirror typeMirror = enclosingElement.asType();

    boolean isView = isSubtypeOfType(typeMirror, VIEW_TYPE);
    boolean isActivity = isSubtypeOfType(typeMirror, ACTIVITY_TYPE);
    boolean isDialog = isSubtypeOfType(typeMirror, DIALOG_TYPE);

    TypeName targetType = TypeName.get(typeMirror);
    if (targetType instanceof ParameterizedTypeName) {
      targetType = ((ParameterizedTypeName) targetType).rawType;
    }
    //獲取父元素的包名和類名
    String packageName = getPackage(enclosingElement).getQualifiedName().toString();
    String className = enclosingElement.getQualifiedName().toString().substring(
        packageName.length() + 1).replace('.', '$');
    //獲取綁定的類名,加后綴_ViewBinding(后面介紹生成加后綴_ViewBinding的java文件)
    ClassName bindingClassName = ClassName.get(packageName, className + "_ViewBinding");

    boolean isFinal = enclosingElement.getModifiers().contains(Modifier.FINAL);
    return new Builder(targetType, bindingClassName, isFinal, isView, isActivity, isDialog);
  }

到此完成了掃描所有注解的工作,再看如何生成java文件.就是process()中的binding.brewJava(sdk, debuggable);

JavaFile brewJava(int sdk, boolean debuggable) {
    //bindingClassName就是加了后綴_ViewBinding的類
    return JavaFile.builder(bindingClassName.packageName(), createType(sdk, debuggable))
        .addFileComment("Generated code from Butter Knife. Do not modify!")
        .build();
  }
private TypeSpec createType(int sdk, boolean debuggable) {
    TypeSpec.Builder result = TypeSpec.classBuilder(bindingClassName.simpleName())
        .addModifiers(PUBLIC);
    if (isFinal) {
      result.addModifiers(FINAL);
    }

    if (parentBinding != null) {
      result.superclass(parentBinding.bindingClassName);
    } else {
      result.addSuperinterface(UNBINDER);
    }

    if (hasTargetField()) {
      result.addField(targetTypeName, "target", PRIVATE);
    }
    //添加構造方法
    if (isView) {
      result.addMethod(createBindingConstructorForView());
    } else if (isActivity) {
      result.addMethod(createBindingConstructorForActivity());
    } else if (isDialog) {
      result.addMethod(createBindingConstructorForDialog());
    }
    if (!constructorNeedsView()) {
      // Add a delegating constructor with a target type + view signature for reflective use.
      result.addMethod(createBindingViewDelegateConstructor());
    }
    result.addMethod(createBindingConstructor(sdk, debuggable));

    if (hasViewBindings() || parentBinding == null) {
      result.addMethod(createBindingUnbindMethod(result));
    }

    return result.build();
  }

這里用到了javapoet,感興趣的可以戳這里, 通過TypeSpec生成類,并一步步地添加構造方法等.
編譯之后就會在build目錄下會產生原Activity.class以及原Activity_ViewBinding.class兩份代碼,.來看下_ViewBinding代碼的結構,以官方SimpleActivity_ViewBinding為例.在構造函數中生成控件以及事件監聽,在unbind對所有控件,事件以及原Activity的引用置空,等待GC回收.

public class SimpleActivity_ViewBinding<T extends SimpleActivity> implements Unbinder {
  protected T target;

  private View view2130968578;

  private View view2130968579;

  @UiThread
  public SimpleActivity_ViewBinding(final T target, View source) {
    this.target = target;

    View view;
    //target就是SimpleActivity,所以SimpleActivity中的title,subtitle等被BindView注解的元素都不能是private的.
    target.title = Utils.findRequiredViewAsType(source, R.id.title, "field 'title'", TextView.class);
    target.subtitle = Utils.findRequiredViewAsType(source, R.id.subtitle, "field 'subtitle'", TextView.class);
    view = Utils.findRequiredView(source, R.id.hello, "field 'hello', method 'sayHello', and method 'sayGetOffMe'");
    target.hello = Utils.castView(view, R.id.hello, "field 'hello'", Button.class);
    view2130968578 = view;
    view.setOnClickListener(new DebouncingOnClickListener() {
      @Override
      public void doClick(View p0) {
        target.sayHello();
      }
    });
    view.setOnLongClickListener(new View.OnLongClickListener() {
      @Override
      public boolean onLongClick(View p0) {
        return target.sayGetOffMe();
      }
    });
    view = Utils.findRequiredView(source, R.id.list_of_things, "field 'listOfThings' and method 'onItemClick'");
    target.listOfThings = Utils.castView(view, R.id.list_of_things, "field 'listOfThings'", ListView.class);
    view2130968579 = view;
    ((AdapterView<?>) view).setOnItemClickListener(new AdapterView.OnItemClickListener() {
      @Override
      public void onItemClick(AdapterView<?> p0, View p1, int p2, long p3) {
        target.onItemClick(p2);
      }
    });
    target.footer = Utils.findRequiredViewAsType(source, R.id.footer, "field 'footer'", TextView.class);
    target.headerViews = Utils.listOf(
        Utils.findRequiredView(source, R.id.title, "field 'headerViews'"), 
        Utils.findRequiredView(source, R.id.subtitle, "field 'headerViews'"), 
        Utils.findRequiredView(source, R.id.hello, "field 'headerViews'"));
  }

  @Override
  @CallSuper
  public void unbind() {
    T target = this.target;
    if (target == null) throw new IllegalStateException("Bindings already cleared.");

    target.title = null;
    target.subtitle = null;
    target.hello = null;
    target.listOfThings = null;
    target.footer = null;
    target.headerViews = null;

    view2130968578.setOnClickListener(null);
    view2130968578.setOnLongClickListener(null);
    view2130968578 = null;
    ((AdapterView<?>) view2130968579).setOnItemClickListener(null);
    view2130968579 = null;

    this.target = null;
  }
}

編譯時自動生成代碼講完了, 然后來看代碼的綁定.

代碼綁定

用過Butterknife的朋友都知道是通過ButterKnife.bind(this)進行綁定(以Activity為例).

@NonNull @UiThread
  public static Unbinder bind(@NonNull Activity target) {
    //獲取跟布局view
    View sourceView = target.getWindow().getDecorView();
    return createBinding(target, sourceView);
  }
private static Unbinder createBinding(@NonNull Object target, @NonNull View source) {
    Class<?> targetClass = target.getClass();
    if (debug) Log.d(TAG, "Looking up binding for " + targetClass.getName());
    //找到被綁定類(如SimpleActivity_ViewBinding)的構造函數
    Constructor<? extends Unbinder> constructor = findBindingConstructorForClass(targetClass);

    if (constructor == null) {
      return Unbinder.EMPTY;
    }

    //noinspection TryWithIdenticalCatches Resolves to API 19+ only type.
    try {
    //返回被綁定類
      return constructor.newInstance(target, source);
    } catch (IllegalAccessException e) {
      throw new RuntimeException("Unable to invoke " + constructor, e);
    } catch (InstantiationException e) {
      throw new RuntimeException("Unable to invoke " + constructor, e);
    } catch (InvocationTargetException e) {
      Throwable cause = e.getCause();
      if (cause instanceof RuntimeException) {
        throw (RuntimeException) cause;
      }
      if (cause instanceof Error) {
        throw (Error) cause;
      }
      throw new RuntimeException("Unable to create binding instance.", cause);
    }
  }

到此就完成了原Activity與Activity_ViewBinding的綁定,即在ButterKnife.bind(this)時就完成原Activity注解控件及事件的初始化,原Activity就可以直接調用了.

如理解有誤,歡迎指正.最后附上相關技術的文章:
APT----《android-apt》
Javapoet----《javapoet——讓你從重復無聊的代碼中解放出來》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,936評論 6 535
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,744評論 3 421
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,879評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,181評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,935評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,325評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,384評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,534評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,084評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,892評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,623評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,322評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,735評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,990評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,800評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,084評論 2 375

推薦閱讀更多精彩內容

  • 本文主要介紹Android之神JakeWharton的一個注解框架,聽說現在面試官現在面試都會問知不知道JakeW...
    Zeit丶閱讀 997評論 4 6
  • 參考1參考2參考3參考4 一:基本原理 編譯時注解+APT編譯時注解:Rentention為CLASS的直接。保留...
    shuixingge閱讀 561評論 0 3
  • 使用Butterknife的主要目的是消除關于view實例化的樣板代碼,這是一個專為View類型的依賴注入框架。D...
    9bc96fd72f8e閱讀 351評論 0 5
  • 按照慣例,隨筆又偷懶了。 今天收到上海方面確認,被收了,現在是到了簽訂三方協議的階段,今天打算去離三方協議,雖然遇...
    夏十里閱讀 99評論 0 0
  • 今天回家了,家里杏花已經落完,青杏藏在滿樹綠葉中,絲毫不起眼。清明的雨已經下完,滋潤了整片大地,回升的氣溫使得萬...
    b2d8dedfdf11閱讀 247評論 0 0