ButterKnife源码分析

11 篇文章 0 订阅

0 使用手册

http://jakewharton.github.io/butterknife/

1 使用方法简介


class ExampleActivity extends Activity {
  @InjectView(R.id.title) TextView title1;
  @InjectView(R.id.title) TextView title2;
  @InjectViews({ R.id.first_name, R.id.middle_name,R.id.last_name })
    List nameViews;

  @Override public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.simple_activity);
    ButterKnife.inject(this);
    // TODO Use "injected" views...
  }
}

@OnClick({ R.id.door1, R.id.door2, R.id.door3 })
public void pickDoor(DoorView door) {
  if (door.hasPrizeBehind()) {
    Toast.makeText(this, "You win!", LENGTH_SHORT).show();
  } else {
    Toast.makeText(this, "Try again", LENGTH_SHORT).show();
  }
}

@OnTextChanged(value=R.id.submit,callback=OnTextChanged.Callback.TEXT_CHANGED)
public void sayHi() {
  button.setText("Hello!");
}

@OnTextChanged(value=R.id.submit,callback=OnTextChanged.Callback.TEXT_CHANGED)
public void sayHello() {
  button.setText("Hello!");
}

2 基本原理

在编译之前,利用Java的预处理功能,扫描每一个有注解的类,找出类中注解的字段生成ViewBinding,每一个方法生成ListenerBinding,形成一个ViewInjector,再利用Java的Filer生成一个有注入代码的类文件,最后在执行阶段,当调用ButterKnife.inject()方法时调用生成的类文件完成注入功能。

3 数据结构分析

@ListenerClass:代表一个事件监听器,1部分中每注解一个方法都是使用了一个事件监听器,如@OnClick最后会生成一个OnClickListener对象,该注解表明了每一个方法注解所对应的事件监听器。callbacks:如果事件监听器中有多个响应方法,则通过这个字段区分,如果只有一个方法,则在method字段中注明。

@ListenerMethod:代表事件监听器中的一个方法,例如OnClickListener对象中的OnClick方法

@OnClick : 方法注解,表示需要生成对应类型的事件监听器。

@InjectView:字段注解,表示需要为该字段生成相应的注入代码

@InjectViews:数组或列表字段注解,表示需要为该字段生成响应的注入代码。

Finder:查找注解id,根据注解的id,找到响应的View。

ViewBinding:代表一个注解字段,例如1部分中的 title1,title2字段。

CollectionBinding:代表一个数组或列表注解字段,例如1部分中的nameViews字段。

ListenerBinding:代表一个注解方法,例如1部分中的sayHi() , sayHello()。

ViewInjection:代表一个注解id,将同一个id的所有注解字段,注解方法整合在一起,形成ViewInjection。

ViewInjector:代表一个注解类,例如1部分中的ExampleActivity ,每一个有注解的类都生成一个ViewInjector,在编译之前使用该ViewInjector,为每一个类生成XXXX$$ViewInjector,生成响应的注入代码

4 代码分析

ButterKnife.inject(Object target, Object source, Finder finder)
  //加载编译前生成的XXXX$$ViewInjector类,调用该类的inject方法,该inject方法利用finder根据注解中的id查找到响应的View,为该view设置事件监听器,再为target注入该view。
  static void inject(Object target, Object source, Finder finder) {
    Class<?> targetClass = target.getClass();
    try {
      if (debug) Log.d(TAG, "Looking up view injector for " + targetClass.getName());
      Injector<Object> injector = findInjectorForClass(targetClass);
      if (injector != null) {
        injector.inject(finder, target, source);
      }
    } catch (Exception e) {
      throw new RuntimeException("Unable to inject views for " + targetClass.getName(), e);
    }
  }
ButterKnifeProcessor.process
 @Override public boolean process(Set<? extends TypeElement> elements, RoundEnvironment env) {
         //先生成ViewInjector
    Map<TypeElement, ViewInjector> targetClassMap = findAndParseTargets(env);
        //使用ViewInjector动态生成XXXX$$ViewInjector.java代码
    for (Map.Entry<TypeElement, ViewInjector> entry : targetClassMap.entrySet()) {
      TypeElement typeElement = entry.getKey();
      ViewInjector viewInjector = entry.getValue();

      try {
        JavaFileObject jfo = filer.createSourceFile(viewInjector.getFqcn(), typeElement);
        Writer writer = jfo.openWriter();
        writer.write(viewInjector.brewJava());
        writer.flush();
        writer.close();
      } catch (IOException e) {
        error(typeElement, "Unable to write injector for type %s: %s", typeElement, e.getMessage());
      }
    }

    return true;
  }
ButterKnife.findAndParseTargets
private Map<TypeElement, ViewInjector> findAndParseTargets(RoundEnvironment env) {

      //处理@InjectView;
    for (Element element : env.getElementsAnnotatedWith(InjectView.class)) {
      try {
        parseInjectView(element, targetClassMap, erasedTargetNames);
      } catch (Exception e) {
        StringWriter stackTrace = new StringWriter();
        e.printStackTrace(new PrintWriter(stackTrace));

        error(element, "Unable to generate view injector for @InjectView.\n\n%s", stackTrace);
      }
    }

      //处理@InjectViews;
    // Process each @InjectViews element.
    for (Element element : env.getElementsAnnotatedWith(InjectViews.class)) {
      try {
        parseInjectViews(element, targetClassMap, erasedTargetNames);
      } catch (Exception e) {
        StringWriter stackTrace = new StringWriter();
        e.printStackTrace(new PrintWriter(stackTrace));

        error(element, "Unable to generate view injector for @InjectViews.\n\n%s", stackTrace);
      }
    }
      //处理Listener注解;
    // Process each annotation that corresponds to a listener.
    for (Class<? extends Annotation> listener : LISTENERS) {
      findAndParseListener(env, listener, targetClassMap, erasedTargetNames);
    }

      //设置父类Injector
    for (Map.Entry<TypeElement, ViewInjector> entry : targetClassMap.entrySet()) {
      String parentClassFqcn = findParentFqcn(entry.getKey(), erasedTargetNames);
      if (parentClassFqcn != null) {
        entry.getValue().setParentInjector(parentClassFqcn + SUFFIX);
      }
    }

    return targetClassMap;
  }
ButterKnife.parseInjectView
private void parseInjectView(Element element, Map<TypeElement, ViewInjector> targetClassMap,
      Set<String> erasedTargetNames) {
    boolean hasError = false;
      //获取字段所在的类
    TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

    // Verify that the target type extends from View.
    TypeMirror elementType = element.asType();
    if (elementType instanceof TypeVariable) {
      TypeVariable typeVariable = (TypeVariable) elementType;
      elementType = typeVariable.getUpperBound();
    }
      //判断是否是View的子类,或者接口
    if (!isSubtypeOfType(elementType, VIEW_TYPE) && !isInterface(elementType)) {
      error(element, "@InjectView fields must extend from View or be an interface. (%s.%s)",
          enclosingElement.getQualifiedName(), element.getSimpleName());
      hasError = true;
    }

      //判断字段和所在的类不能是Private的
    // Verify common generated code restrictions.
    hasError |= isInaccessibleViaGeneratedCode(InjectView.class, "fields", element);
      //判断不是java和android内部类
    hasError |= isBindingInWrongPackage(InjectView.class, element);

    // Check for the other field annotation.
    if (element.getAnnotation(InjectViews.class) != null) {
      error(element, "Only one of @InjectView and @InjectViews is allowed. (%s.%s)",
          enclosingElement.getQualifiedName(), element.getSimpleName());
      hasError = true;
    }

    if (hasError) {
      return;
    }

    // Assemble information on the injection point.
    int id = element.getAnnotation(InjectView.class).value();

     //判断该id仅出现了一次
    ViewInjector injector = targetClassMap.get(enclosingElement);
    if (injector != null) {
      ViewInjection viewInjection = injector.getViewInjection(id);
      if (viewInjection != null) {
        Iterator<ViewBinding> iterator = viewInjection.getViewBindings().iterator();
        if (iterator.hasNext()) {
          ViewBinding existingBinding = iterator.next();
          error(element,
              "Attempt to use @InjectView for an already injected ID %d on '%s'. (%s.%s)", id,
              existingBinding.getName(), enclosingElement.getQualifiedName(),
              element.getSimpleName());
          return;
        }
      }
    }

      //生成一个viewInjector和viewBinding
    String name = element.getSimpleName().toString();
    String type = elementType.toString();
    boolean required = element.getAnnotation(Optional.class) == null;

    ViewInjector viewInjector = getOrCreateTargetClass(targetClassMap, enclosingElement);
    ViewBinding binding = new ViewBinding(name, type, required);
    viewInjector.addView(id, binding);

    // Add the type-erased version to the valid injection targets set.
    erasedTargetNames.add(enclosingElement.toString());
  }
ButterKnife.parseInjectViews
private void parseInjectViews(Element element, Map<TypeElement, ViewInjector> targetClassMap,
      Set<String> erasedTargetNames) {
    boolean hasError = false;
    TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

    // Verify that the type is a List or an array.
      //判断注解的是List或者数组
    TypeMirror elementType = element.asType();
    String erasedType = doubleErasure(elementType);
    TypeMirror viewType = null;
    CollectionBinding.Kind kind = null;
    if (elementType.getKind() == TypeKind.ARRAY) {
      ArrayType arrayType = (ArrayType) elementType;
      viewType = arrayType.getComponentType();
      kind = CollectionBinding.Kind.ARRAY;
    } else if (LIST_TYPE.equals(erasedType)) {
      DeclaredType declaredType = (DeclaredType) elementType;
      List<? extends TypeMirror> typeArguments = declaredType.getTypeArguments();
      if (typeArguments.size() != 1) {
        error(element, "@InjectViews List must have a generic component. (%s.%s)",
            enclosingElement.getQualifiedName(), element.getSimpleName());
        hasError = true;
      } else {
        viewType = typeArguments.get(0);
      }
      kind = CollectionBinding.Kind.LIST;
    } else {
      error(element, "@InjectViews must be a List or array. (%s.%s)",
          enclosingElement.getQualifiedName(), element.getSimpleName());
      hasError = true;
    }
      //???????
    if (viewType instanceof TypeVariable) {
      TypeVariable typeVariable = (TypeVariable) viewType;
      viewType = typeVariable.getUpperBound();
    }

      //判断是否是View的之类,或者接口
    // Verify that the target type extends from View.
    if (viewType != null && !isSubtypeOfType(viewType, VIEW_TYPE) && !isInterface(viewType)) {
      error(element, "@InjectViews type must extend from View or be an interface. (%s.%s)",
          enclosingElement.getQualifiedName(), element.getSimpleName());
      hasError = true;
    }
    //判断字段和所在的类不能是Private的
      //判断不是java和android内部类
    // Verify common generated code restrictions.
    hasError |= isInaccessibleViaGeneratedCode(InjectViews.class, "fields", element);
    hasError |= isBindingInWrongPackage(InjectViews.class, element);

    if (hasError) {
      return;
    }

    // Assemble information on the injection point.
    String name = element.getSimpleName().toString();
    int[] ids = element.getAnnotation(InjectViews.class).value();
    if (ids.length == 0) {
      error(element, "@InjectViews must specify at least one ID. (%s.%s)",
          enclosingElement.getQualifiedName(), element.getSimpleName());
      return;
    }

    Integer duplicateId = findDuplicate(ids);
    if (duplicateId != null) {
      error(element, "@InjectViews annotation contains duplicate ID %d. (%s.%s)", duplicateId,
          enclosingElement.getQualifiedName(), element.getSimpleName());
    }

    assert viewType != null; // Always false as hasError would have been true.
    String type = viewType.toString();
    boolean required = element.getAnnotation(Optional.class) == null;

    //生成一个viewInjector和CollectionBinding
    ViewInjector viewInjector = getOrCreateTargetClass(targetClassMap, enclosingElement);
    CollectionBinding binding = new CollectionBinding(name, type, kind, required);
    viewInjector.addCollection(ids, binding);

    erasedTargetNames.add(enclosingElement.toString());
  }
Butterknife.parseListenerAnnotation
private void parseListenerAnnotation(Class<? extends Annotation> annotationClass, Element element,
      Map<TypeElement, ViewInjector> targetClassMap, Set<String> erasedTargetNames)
      throws Exception {
    // This should be guarded by the annotation's @Target but it's worth a check for safe casting.
    if (!(element instanceof ExecutableElement) || element.getKind() != METHOD) {
      throw new IllegalStateException(
          String.format("@%s annotation must be on a method.", annotationClass.getSimpleName()));
    }

    ExecutableElement executableElement = (ExecutableElement) element;
    TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

    // Assemble information on the injection point.
    Annotation annotation = element.getAnnotation(annotationClass);
    Method annotationValue = annotationClass.getDeclaredMethod("value");
    if (annotationValue.getReturnType() != int[].class) {
      throw new IllegalStateException(
          String.format("@%s annotation value() type not int[].", annotationClass));
    }

      //获取该注解上的所有View的id
    int[] ids = (int[]) annotationValue.invoke(annotation);
    String name = executableElement.getSimpleName().toString();
    boolean required = element.getAnnotation(Optional.class) == null;

    // Verify that the method and its containing class are accessible via generated code.
    boolean hasError = isInaccessibleViaGeneratedCode(annotationClass, "methods", element);
    hasError |= isBindingInWrongPackage(annotationClass, element);

    Integer duplicateId = findDuplicate(ids);
    if (duplicateId != null) {
      error(element, "@%s annotation for method contains duplicate ID %d. (%s.%s)",
          annotationClass.getSimpleName(), duplicateId, enclosingElement.getQualifiedName(),
          element.getSimpleName());
      hasError = true;
    }

    ListenerClass listener = annotationClass.getAnnotation(ListenerClass.class);
    if (listener == null) {
      throw new IllegalStateException(
          String.format("No @%s defined on @%s.", ListenerClass.class.getSimpleName(),
              annotationClass.getSimpleName()));
    }

    for (int id : ids) {
      if (id == View.NO_ID) {
        if (ids.length == 1) {
          if (!required) {
            error(element, "ID free injection must not be annotated with @Optional. (%s.%s)",
                enclosingElement.getQualifiedName(), element.getSimpleName());
            hasError = true;
          }

          // Verify target type is valid for a binding without an id.
          String targetType = listener.targetType();
          if (!isSubtypeOfType(enclosingElement.asType(), targetType)
              && !isInterface(enclosingElement.asType())) {
            error(element, "@%s annotation without an ID may only be used with an object of type "
                    + "\"%s\" or an interface. (%s.%s)",
                    annotationClass.getSimpleName(), targetType,
                enclosingElement.getQualifiedName(), element.getSimpleName());
            hasError = true;
          }
        } else {
          error(element, "@%s annotation contains invalid ID %d. (%s.%s)",
              annotationClass.getSimpleName(), id, enclosingElement.getQualifiedName(),
              element.getSimpleName());
          hasError = true;
        }
      }
    }

    ListenerMethod method;
    ListenerMethod[] methods = listener.method();
    if (methods.length > 1) {
      throw new IllegalStateException(String.format("Multiple listener methods specified on @%s.",
          annotationClass.getSimpleName()));
    } else if (methods.length == 1) {
        //ListenerClass中如果填充的是method,则保证只有一个
      if (listener.callbacks() != ListenerClass.NONE.class) {
        throw new IllegalStateException(
            String.format("Both method() and callback() defined on @%s.",
                annotationClass.getSimpleName()));
      }
      method = methods[0];
    } else {
        //ListenerClass中如果没有填充method,则取callback
      Method annotationCallback = annotationClass.getDeclaredMethod("callback");
      Enum<?> callback = (Enum<?>) annotationCallback.invoke(annotation);
      Field callbackField = callback.getDeclaringClass().getField(callback.name());
      method = callbackField.getAnnotation(ListenerMethod.class);
      if (method == null) {
        throw new IllegalStateException(
            String.format("No @%s defined on @%s's %s.%s.", ListenerMethod.class.getSimpleName(),
                annotationClass.getSimpleName(), callback.getDeclaringClass().getSimpleName(),
                callback.name()));
      }
    }

    //527-548行确保注解的方法参数与返回值,跟注解中ListenerClass保存的方法参数和返回值一致
    // Verify that the method has equal to or less than the number of parameters as the listener.
    List<? extends VariableElement> methodParameters = executableElement.getParameters();
    if (methodParameters.size() > method.parameters().length) {
      error(element, "@%s methods can have at most %s parameter(s). (%s.%s)",
          annotationClass.getSimpleName(), method.parameters().length,
          enclosingElement.getQualifiedName(), element.getSimpleName());
      hasError = true;
    }

    // Verify method return type matches the listener.
    TypeMirror returnType = executableElement.getReturnType();
    if (returnType instanceof TypeVariable) {
      TypeVariable typeVariable = (TypeVariable) returnType;
      returnType = typeVariable.getUpperBound();
    }
    if (!returnType.toString().equals(method.returnType())) {
      error(element, "@%s methods must have a '%s' return type. (%s.%s)",
          annotationClass.getSimpleName(), method.returnType(),
          enclosingElement.getQualifiedName(), element.getSimpleName());
      hasError = true;
    }

    if (hasError) {
      return;
    }

    Parameter[] parameters = Parameter.NONE;
    if (!methodParameters.isEmpty()) {
      parameters = new Parameter[methodParameters.size()];
      BitSet methodParameterUsed = new BitSet(methodParameters.size());
      String[] parameterTypes = method.parameters();
      for (int i = 0; i < methodParameters.size(); i++) {
        VariableElement methodParameter = methodParameters.get(i);
        TypeMirror methodParameterType = methodParameter.asType();
        if (methodParameterType instanceof TypeVariable) {
          TypeVariable typeVariable = (TypeVariable) methodParameterType;
          methodParameterType = typeVariable.getUpperBound();
        }

        for (int j = 0; j < parameterTypes.length; j++) {
          if (methodParameterUsed.get(j)) {
            continue;
          }
          if (isSubtypeOfType(methodParameterType, parameterTypes[j])
              || isInterface(methodParameterType)) {
            parameters[i] = new Parameter(j, methodParameterType.toString());
            methodParameterUsed.set(j);
            break;
          }
        }
        if (parameters[i] == null) {
          StringBuilder builder = new StringBuilder();
          builder.append("Unable to match @")
              .append(annotationClass.getSimpleName())
              .append(" method arguments. (")
              .append(enclosingElement.getQualifiedName())
              .append('.')
              .append(element.getSimpleName())
              .append(')');
          for (int j = 0; j < parameters.length; j++) {
            Parameter parameter = parameters[j];
            builder.append("\n\n  Parameter #")
                .append(j + 1)
                .append(": ")
                .append(methodParameters.get(j).asType().toString())
                .append("\n    ");
            if (parameter == null) {
              builder.append("did not match any listener parameters");
            } else {
              builder.append("matched listener parameter #")
                  .append(parameter.getListenerPosition() + 1)
                  .append(": ")
                  .append(parameter.getType());
            }
          }
          builder.append("\n\nMethods may have up to ")
              .append(method.parameters().length)
              .append(" parameter(s):\n");
          for (String parameterType : method.parameters()) {
            builder.append("\n  ").append(parameterType);
          }
          builder.append(
              "\n\nThese may be listed in any order but will be searched for from top to bottom.");
          error(executableElement, builder.toString());
          return;
        }
      }
    }

    ListenerBinding binding = new ListenerBinding(name, Arrays.asList(parameters), required);
    ViewInjector viewInjector = getOrCreateTargetClass(targetClassMap, enclosingElement);
    for (int id : ids) {
      if (!viewInjector.addListener(id, listener, method, binding)) {
        error(element, "Multiple listener methods with return value specified for ID %d. (%s.%s)",
            id, enclosingElement.getQualifiedName(), element.getSimpleName());
        return;
      }
    }

    // Add the type-erased version to the valid injection targets set.
    erasedTargetNames.add(enclosingElement.toString());
  }
ViewInjector.emitInject

生成inject方法

  private void emitInject(StringBuilder builder) {
    builder.append("  @Override ")
        .append("public void inject(final Finder finder, final T target, Object source) {\n");

    // Emit a call to the superclass injector, if any.
    if (parentInjector != null) {
      builder.append("    super.inject(finder, target, source);\n\n");
    }

    // Local variable in which all views will be temporarily stored.
    builder.append("    View view;\n");

    //生成字段注入代码和事件监听器注入代码
    for (ViewInjection injection : viewIdMap.values()) {
      emitViewInjection(builder, injection);
    }

    //生成数组注入代码
    for (Map.Entry<CollectionBinding, int[]> entry : collectionBindings.entrySet()) {
      emitCollectionBinding(builder, entry.getKey(), entry.getValue());
    }

    builder.append("  }\n");
  }
ViewInjector.emitViewBindings

生成的代码类似于

view = finder.findRequiredView(source, 2131361935, "field 'title1;'");
target.title1 = (android.widget.TextView) view;
  private void emitViewBindings(StringBuilder builder, ViewInjection injection) {
    Collection<ViewBinding> viewBindings = injection.getViewBindings();
    if (viewBindings.isEmpty()) {
      return;
    }

    for (ViewBinding viewBinding : viewBindings) {
      builder.append("    target.")
          .append(viewBinding.getName())
          .append(" = ");
      if (viewBinding.requiresCast()) {
        builder.append("finder.castView(view")
            .append(", ")
            .append(injection.getId())
            .append(", \"");
        emitHumanDescription(builder, viewBindings);
        builder.append("\");\n");
      } else {
        builder.append("view;\n");
      }
    }
  }
ButterKnife.emitListenerBindings

生成事件监听器的注入代码,生成的代码类似于

view = finder.findRequiredView(source, 2131361935, "field 'title1' and method 'sayHi'");
target.title1 = (android.widget.TextView) view;
((android.widget.TextView) view).addTextChangedListener(
      new android.text.TextWatcher() {
        @Override public void onTextChanged(
          java.lang.CharSequence p0,
          int p1,
          int p2,
          int p3
        ) {
          target.sayHi();
        }
        @Override public void beforeTextChanged(
          java.lang.CharSequence p0,
          int p1,
          int p2,
          int p3
        ) {

        }
        @Override public void afterTextChanged(
          android.text.Editable p0
        ) {

        }
      });
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
注意:本项目使用android studio开发,eclipse可能无法使用。 豆芽的名字取自“Douban, Yet Another”的缩写和中文词语“豆芽”的拼音正好相同的巧合。 取名之后,我才得知豆瓣的 Windows Phone 客户端的名字也叫做“豆芽”。所以相对于豆瓣官方应用“一个叫‘豆瓣’的App”,也正好将这个应用称为“另一个叫豆芽的应用”了。 为什么要有豆芽? 直接访问豆瓣的所有人里,最普遍而一致的用法是围绕电影、电视、书、唱片、活动(我们叫做”条目”的东西)的评分评论、发现和讨论。我们会把和网站同步的评分评论作为一个起点和基础,在手机上重新构建围绕个人兴趣的发现和讨论。 一个叫“豆瓣”的App——豆瓣日志 豆瓣从来不是一个单一的网站,而对于豆瓣的用法自然不尽相同。使用豆瓣是为了获取信息,但信息的获取是基于条目和算法,还是基于友邻和人,这个问题在豆瓣的多次改版中大概一直悬而未决。 这次,一个叫“豆瓣”的应用选择的是基于条目的推荐。但我个人作为一个重度豆瓣用户,重视的却恰好是基于人的推荐,喜欢的是友邻间的鸡犬相闻,以及闲逛时从条目评论、广播、日记中发现新友邻的惊喜。正如我在某次“还我旧版”运动中听到的声音,“不管怎么改版,只要友邻们还在就好”,改版是豆瓣不断良好发展的必经之路,但这句话中对友邻的珍重又令我感受到了豆瓣最宝贵的特质。 豆瓣作为一个工具的价值可以通过条目很好体现,但豆瓣作为一个独一无二的社区,只能通过它独有的、克制的、以人为本的方式才能维系。作为一个普通但也深爱豆瓣的用户,我希望豆瓣在这个方向上也不要失落,因为一个只有工具属性的网站对我而言将再也没有这样的归属感。 我在这一点上与豆瓣应用有了不同的追求,并且恰好有一些这方面的能力,又恰好豆瓣提供了开放的 API,于是就想要将这个想法实现出来了。 选择开始豆芽这个项目,还有一个原因是我希望在豆瓣继续看到平台原生的设计。 豆瓣广播在几年前就已经是国内少有的几个 Android Design 的应用,这一点一直令我钦佩和喜爱。在豆瓣应用最开始的版本中,也曾有过 Material Design 的尝试,但随着和 iOS 风格设计的杂糅,逐渐显得不合时宜,以至于最终选择了完全的 iOS 风格。我对此一直感到有些遗憾,况且 Material Design 也是一款更加优秀的设计语言。所以,我希望实现未能见到的另一种可能性。 部分特性 Material Design 首页友邻广播 启动速度优化 界面动画 支持屏幕旋转 平板多列视图 支持使用 Custom Tabs 打开网页 支持切换长/短链接显示 关于名字 指导原则 豆芽的最终目标是为豆瓣中基于友邻的信息获取方式提供在移动端的便利。为了优雅地实现这个目标,豆芽将主要遵循以下的原则: 遵循 Material Design 规范,且指导思想优先于细节规定。 像素完美,但更注重以人为本。 实现精确,代码可以自我辩护。 行为合格,支持屏幕旋转和平板布局。 功能崇尚简约,不打扰用户。 行为默认值合理,且用户可调节。 积极表现豆瓣特性,如广播、友邻、豆邮等。 通过细节设计,提倡用心、考虑到他人的内容。 规则可以被打破,但前提是理解规则。 功能架构 豆芽的架构将与当前网站的设计十分类似。 你可能会问,难道豆芽只是要做一个豆瓣网站的移动端界面么?并非如此。豆芽的最终目标是为基于友邻的信息获取方式提供便利,所以架构设计也是为此服务。而架构与当前网页端设计基本相同,则是因为现在网页端正是一个符合这个目标的设计,并且与移动端的导航也可以很好地契合。 让我们详细地规划一下豆芽吧。 导航采用抽屉一级导航 选项卡二级导航的方式。工具栏上将显示全局的动作。 工具栏 提醒:所有类别的提醒,可以查看历史提醒 豆邮:用户间的邮件往来,希望鼓励郑重而非聊天。 搜索:立即访问想要的内容。 用户:点击后显示个人页面,相当于“我的豆瓣”。 首页 友邻广播:友邻互动、友邻推荐、系统定制的推荐。 九点:友邻的日记、博客文章等,有深度的内容。 一刻:全站范围的热门内容推荐。 同城:基于地理位置的内容。 线上活动:基于共同兴趣的内容。 读书 分类浏览、首页推荐:入口,以及最有可能发现新内容的地方。 我读:管理自己的读书标记、创造内容。 动态:查看友邻的阅读动态,互动、获得推荐。 豆瓣猜:基于算法的推荐。 电影 类似读书。 音乐 类似读书。 设置:提供应用设置等。 在子页面设计中,豆芽将尽量鼓励长内容和用心的互动。因为我相信只有豆瓣值得这样尝试。 实现状况 我在最初的二十天内冲刺实现了应用的网络层、账户系统等基础架构,和查看友邻广播需要的大部分功能,大约 8000 行代码。 在接下来的八十天中,由于课业、其他事情和速度瓶颈,实现过程有所减慢。但是,应用的细节功能和界面交互都正在不断地被实现和优化。代码量达到了 14000 行,同时为此应用而写作的多个开源库的数千行代码并没有被计入。 此后项目经历了大型的重构,以适应代码复用和支持屏幕旋转的需求。在此之后,我得以实现了一个较为美观的个人资料页,并且对应用的许多细节进行了完善。 目前实现了原“豆瓣广播”应用的大部分功能。剩下的工作也正在继续进行中。 实现架构 数据层面 应用除了对少数内容进行缓存,其他内容均直接从网络获取。 基于 Android 账户系统提供用户账户和身份认证。 使用 Volley 及部分自定义增强处理网络请求。 使用 Gson 自动填充数据模型。 使用 Glide 加载图片。 使用 DiskLRUCache 及自定义增强对首页数据进行缓存。 使用 EventBus 同步不同页面间对象状态。 界面层面 使用 Support Library 中的 AppCompat、Design、CardView、RecyclerView 进行 Material Design 实现,在必要时引入/自己写作第三方库以实现部分界面元素和效果。 使用框架的 Shared Element Transition 实现在 Android 5.0 以上的界面过渡动画。 界面实现一般分为 Activity、Fragment、Adapter 三个模块,分别负责作为容器,发起请求、展示数据和用户交互,以及数据/交互绑定。 实现难点 网络请求 Volley 本身是一个不算十分完备的库,对于请求参数、重试、认证等方面都需要开发者自己实现。在豆芽中,应用对 Volley 进行了包装,增加了以上功能,并且尽力做到了通用,为之后 API 层建立提供了很多方便。 磁盘缓存 DiskLRUCache 是一个只实现了同步读取写入的库,因此豆芽对其进行了包装,提供了异步读写的 API,正确实现,提高了应用的响应速度。 状态同步 由于各个界面独自获取数据,数据本身与常规的 ContentProvider 机制中不同,是去中心化的,即可能遇到状态不同步的问题。 具体地说,即有可能用户在广播详情界面中点赞后,回到主界面列表视图,发现并未更新状态。 而豆芽解决方案则是使用 EventBus,在请求完成后通知所有界面刷新同一数据。 界面动画 Android 5.0 以上提供了 SharedElementTransition,然而默认情况下共享的界面元素在动画时却会被放置在其他界面元素之上,导致其突然越过 AppBar 或 StatusBar 的情况。 通过大量的文档阅读、源代码分析和调试,经过大约一周的时间,最终实现了较为理想的效果。 屏幕旋转 Android 在屏幕旋转时,会销毁视图和 Activity 并重建,此时如何保存视图状态和已加载的数据、正在进行的网络请求即是问题。 Android 对部分视图状态提供了自动保存恢复,而豆芽对于其他需要保存的状态则通过自定义的 onSaveViewState() 和 onRestoreViewState()。 对于数据,豆芽通过自定义的一个无界面的 RetainDataFragment 进行数据保留,并且接口十分简单易用。 同时,由于网络请求的异步特性,豆芽通过自定义的一个 RequestFragment 实现了网络请求在 Activity 重建期间的保留,并且能够在 Activity 重建完成后将请求前的状态和请求结果回调至新的 Activity。 平板适配 Android 本身的资源系统提供了对不同配置的很好支持,通过建立不同的资源文件,即可在手机和平板上使用不同的界面设定。 此外,由于采用了 RecyclerView,通过在运行时判断当前设备配置,可以动态给界面设置为 1、2、3 列视图,充分利用屏幕空间。 启动速度 Android 默认在冷启动应用进程至能够调用 Activity.onCreate() 前会加载应用主题中的背景作为预览,而默认背景是白色,与应用在上部拥有绿色 AppBar 的效果不相匹配。 为了生成适应于不同屏幕大小、系统版本的图片,我使用 bash 编写了一系列脚本,并实现了一个通用的模板化 SVG 格式,详情见 MaterialColdStart 和 AndroidSVGScripts。 经过自定义窗口背景和其他优化,应用在手机上已经可以达到立即启动的视觉效果。 派生开源库 为此项目诞生的五个开源库: MaterialColdStart,800 Stars MaterialProgressBar,500 Stars CustomTabsHelper,200 Stars MaterialEditText SystemUiHelper 第三方库 PhotoView Glide Gson ButterKnife DiskLruCache ThreeTenABP Volley EventBus CustomTabsHelper MaterialEditText MaterialProgressBar SystemUiHelper MaterialColdStart 构建 APK 文件可以在本项目的 Releases 中找到。 至于手动构建本项目的基本步骤: 创建 signing.properties: storeFile=YOUR_STORE_FILE storePassword= keyAlias= keyPassword= 执行 ./gradlew build。 使用 安装应用后,请安装 豆芽 API Key 设置向导 以设置 API Key。 暂时没有内置的更新渠道,请关注本项目的 Release。 请不要安装从不可靠的来源获取的 APK,以免泄漏您的用户名和密码。 本项目git地址:https://github.com/DreaminginCodeZH/Douya

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值