Android编程中的注解深入探究
安安 2017-12-20 来源 :网络 阅读 1147 评论 0

摘要:本篇Android编程教程将为大家讲解Android中的注解深入探究,看完这篇文章会让你对Android编程的知识点有更加清晰的理解和运用。

本篇Android编程教程将为大家讲解Android中的注解深入探究,看完这篇文章会让你对Android编程的知识点有更加清晰的理解和运用。

 

本文系GDG Android Meetup分享内容总结文章

注解是我们经常接触的技术,Java有注解,Android也有注解,本文将试图介绍Android中的注解,以及ButterKnife和Otto这些基于注解的库的一些工作原理.

归纳而言,Android中的注解大概有以下好处

· 提高我们的开发效率

· 更早的发现程序的问题或者错误

· 更好的增加代码的描述能力

· 更加利于我们的一些规范约束

· 提供解决问题的更优解

准备工作

默认情况下,Android中的注解包并没有包括在framework中,它独立成一个单独的包,通常我们需要引入这个包.

dependencies {

    compile 'com.android.support:support-annotations:22.2.0'

}

但是如果我们已经引入了appcompat则没有必要再次引用support-annotations,因为appcompat默认包含了对其引用.

替代枚举

在最早的时候,当我们想要做一些值得限定实现枚举的效果,通常是

· 定义几个常量用于限定

· 从上面的常量选取值进行使用

一个比较描述上面问题的示例代码如下

public static final int COLOR_RED = 0;public static final int COLOR_GREEN = 1;public static final int COLOR_YELLOW = 2;

public void setColor(int color) {

    //some code here

}//调用

setColor(COLOR_RED)

然而上面的还是有不尽完美的地方

· setColor(COLOR_RED)与setColor(0)效果一样,而后者可读性很差,但却可以正常运行

· setColor方法可以接受枚举之外的值,比如setColor(3),这种情况下程序可能出问题

一个相对较优的解决方法就是使用Java中的Enum.使用枚举实现的效果如下

// ColorEnum.javapublic enum ColorEmun {

    RED,

    GREEN,

    YELLOW

}

public void setColorEnum(ColorEmun colorEnum) {

    //some code here

}

 

setColorEnum(ColorEmun.GREEN);

然而Enum也并非最佳,Enum因为其相比方案一的常量来说,占用内存相对大很多而受到曾经被Google列为不建议使用,为此Google特意引入了一些相关的注解来替代枚举.

Android中新引入的替代枚举的注解有IntDef和StringDef,这里以IntDef做例子说明一下.

public class Colors {

    @IntDef({RED, GREEN, YELLOW})

    @Retention(RetentionPolicy.SOURCE)

    public @interface LightColors{}

 

    public static final int RED = 0;

    public static final int GREEN = 1;

    public static final int YELLOW = 2;

}

· 声明必要的int常量

· 声明一个注解为LightColors

· 使用@IntDef修饰LightColors,参数设置为待枚举的集合

· 使用@Retention(RetentionPolicy.SOURCE)指定注解仅存在与源码中,不加入到class文件中

Null相关的注解

和Null相关的注解有两个

@Nullable 注解的元素可以是Null@NonNull 注解的元素不能是Null

上面的两个可以修饰如下的元素

· 成员属性

· 方法参数

· 方法的返回值

@Nullableprivate String obtainReferrerFromIntent(@NonNull Intent intent) {

    return intent.getStringExtra("apps_referrer");

}

NonNull检测生效的条件

· 显式传入null

· 在调用方法之前已经判断了参数为null时

setReferrer(null);//提示警告

//不提示警告String referrer = getIntent().getStringExtra("apps_referrer");

setReferrer(referrer);

//提示警告String referrer = getIntent().getStringExtra("apps_referrer");if (referrer == null) {

    setReferrer(referrer);

}

 

private void setReferrer(@NonNull String referrer) {

    //some code here

}

区间范围注解

Android中的IntRange和FloatRange是两个用来限定区间范围的注解,

float currentProgress;

public void setCurrentProgress(@FloatRange(from=0.0f, to=1.0f) float progress) {

    currentProgress = progress;

}

如果我们传入非法的值,如下所示

setCurrentProgress(11);

就会得到这样的错误

Value must be >=0.0 and <= 1.0(was 11)

长度以及数组大小限制

限制字符串的长度

private void setKey(@Size(6) String key) {

}

限定数组集合的大小

private void setData(@Size(max = 1) String[] data) {

}

setData(new String[]{"b", "a"});//error occurs

限定特殊的数组长度,比如3的倍数

private void setItemData(@Size(multiple = 3) String[] data) {

}

权限相关

在Android中,有很多场景都需要使用权限,无论是Marshmallow之前还是之后的动态权限管理.都需要在manifest中进行声明,如果忘记了,则会导致程序崩溃. 好在有一个注解能辅助我们避免这个问题.使用RequiresPermission注解即可.

@RequiresPermission(Manifest.permission.SET_WALLPAPER)

    public void changeWallpaper(Bitmap bitmap) throws IOException {

}

资源注解

在Android中几乎所有的资源都可以有对应的资源id.比如获取定义的字符串,我们可以通过下面的方法

public String getStringById(int stringResId) {

    return getResources().getString(stringResId);

}

使用这个方法,我们可以很容易的获取到定义的字符串,但是这样的写法也存在着风险.

getStringById(R.mipmap.ic_launcher)

如果我们在不知情或者疏忽情况下,传入这样的值,就会出现问题. 但是如果我们使用资源相关的注解修饰了参数,就能很大程度上避免错误的情况.

public String getStringById(@StringRes  int stringResId) {

    return getResources().getString(stringResId);

}

在Android中资源注解如下所示

· AnimRes

· AnimatorRes

· AnyRes

· ArrayRes

· AttrRes

· BoolRes

· ColorRes

· DimenRes

· DrawableRes

· FractionRes

· IdRes

· IntegerRes

· InterpolatorRes

· LayoutRes

· MenuRes

· PluralsRes

· RawRes

· StringRes

· StyleRes

· StyleableRes

· TransitionRes

· XmlRes

Color值限定

上面部分提到了ColorRes,用来限定颜色资源id,这里我们将使用ColorInt,一个用来限定Color值的注解. 在较早的TextView的setTextColor是这样实现的.

public void setTextColor(int color) {

    mTextColor = ColorStateList.valueOf(color);

    updateTextColors();

}

然而上面的方法在调用时常常会出现这种情况

myTextView.setTextColor(R.color.colorAccent);

如上,如果传递过去的参数为color的资源id就会出现颜色取错误的问题,这个问题在过去还是比较严重的.好在ColorInt出现了,改变了这一问题.

public void setTextColor(@ColorInt int color) {

    mTextColor = ColorStateList.valueOf(color);

    updateTextColors();

}

当我们再次传入Color资源值时,就会得到错误的提示.

CheckResult

这是一个关于返回结果的注解,用来注解方法,如果一个方法得到了结果,却没有使用这个结果,就会有错误出现,一旦出现这种错误,就说明你没有正确使用该方法。

@CheckResultpublic String trim(String s) {

    return s.trim();

}

线程相关

Android中提供了四个与线程相关的注解

· @UiThread,通常可以等同于主线程,标注方法需要在UIThread执行,比如View类就使用这个注解

· @MainThread 主线程,经常启动后创建的第一个线程

· @WorkerThread 工作者线程,一般为一些后台的线程,比如AsyncTask里面的doInBackground就是这样的.

· @BinderThread 注解方法必须要在BinderThread线程中执行,一般使用较少.

一些示例

new AsyncTask<Void, Void, Void>() {

        //doInBackground is already annotated with @WorkerThread

        @Override

        protected Void doInBackground(Void... params) {

            return null;

            updateViews();//error

        }

    };

@UiThreadpublic void updateViews() {

    Log.i(LOGTAG, "updateViews ThreadInfo=" + Thread.currentThread());

}

注意,这种情况下不会出现错误提示

new Thread(){

    @Override

    public void run() {

        super.run();

        updateViews();

    }

}.start();

虽然updateViews会在一个新的工作者线程中执行,但是在compile时没有错误提示.

因为它的判断依据是,如果updateView的线程注解(这里为@UiThread)和run(没有线程注解)不一致才会错误提示.如果run方法没有线程注解,则不提示.

CallSuper

重写的方法必须要调用super方法

使用这个注解,我们可以强制方法在重写时必须调用父类的方法 比如Application的onCreate,onConfigurationChanged等.

Keep

在Android编译生成APK的环节,我们通常需要设置minifyEnabled为true实现下面的两个效果

· 混淆代码

· 删除没有用的代码

但是出于某一些目的,我们需要不混淆某部分代码或者不删除某处代码,除了配置复杂的Proguard文件之外,我们还可以使用@Keep注解 .

@Keeppublic static int getBitmapWidth(Bitmap bitmap) {

    return bitmap.getWidth();

}

ButterKnife

ButterKnife是一个用来绑定View,资源和回调的提高效率的工具.作者为Jake Wharton. ButterKnife的好处

· 使用BindView替代繁琐的findViewById和类型转换

· 使用OnClick注解方法来替换显式声明的匿名内部类

· 使用BindString,BindBool,BindDrawable等注解实现资源获取

一个摘自Github的示例

class ExampleActivity extends Activity {

  @BindView(R.id.user) EditText username;

  @BindView(R.id.pass) EditText password;

 

  @BindString(R.string.login_error) String loginErrorMessage;

 

  @OnClick(R.id.submit) void submit() {

    // TODO call server...

  }

 

  @Override public void onCreate(Bundle savedInstanceState) {

    super.onCreate(savedInstanceState);

    setContentView(R.layout.simple_activity);

    ButterKnife.bind(this);

    // TODO Use fields...

  }

}

ButterKnife工作原理

以BindView注解使用为例,示例代码为

public class MainActivity extends AppCompatActivity {

    @BindView(R.id.myTextView)

    TextView myTextView;

 

    @Override

    protected void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_main);

        ButterKnife.bind(this);

    }

}

1.程序在compile时,会根据注解自动生成两个类,这里为MainActivity_ViewBinder.class和MainActivity_ViewBinding.class
2.当我们调用ButterKnife.bind(this);时,会查找当前类对应的ViewBinder类,并调用bind方法,这里会调用到MainActiivty_ViewBinder.bind方法.
3.MainActiivty_ViewBinder.bind方法实际上是调用了findViewById然后在进行类型转换,赋值给MainActivity的myTextView属性

ButterKnife的bind方法

public static Unbinder bind(@NonNull Activity target) {

    return getViewBinder(target).bind(Finder.ACTIVITY, target, target);

}

ButterKnife的getViewBinder和findViewBinderForClass

@NonNull @CheckResult @UiThread

  static ViewBinder<Object> getViewBinder(@NonNull Object target) {

    Class<?> targetClass = target.getClass();

    if (debug) Log.d(TAG, "Looking up view binder for " + targetClass.getName());

    return findViewBinderForClass(targetClass);

  }

 

  @NonNull @CheckResult @UiThread

  private static ViewBinder<Object> findViewBinderForClass(Class<?> cls) {

   //如果内存集合BINDERS中包含,则不再查找

    ViewBinder<Object> viewBinder = BINDERS.get(cls);

    if (viewBinder != null) {

      if (debug) Log.d(TAG, "HIT: Cached in view binder map.");

      return viewBinder;

    }

    String clsName = cls.getName();

    if (clsName.startsWith("android.") || clsName.startsWith("java.")) {

      if (debug) Log.d(TAG, "MISS: Reached framework class. Abandoning search.");

      return NOP_VIEW_BINDER;

    }

    //noinspection TryWithIdenticalCatches Resolves to API 19+ only type.

    try {

      //使用反射创建实例

      Class<?> viewBindingClass = Class.forName(clsName + "_ViewBinder");

      //noinspection unchecked

      viewBinder = (ViewBinder<Object>) viewBindingClass.newInstance();

      if (debug) Log.d(TAG, "HIT: Loaded view binder class.");

    } catch (ClassNotFoundException e) {

        //如果没有找到,对父类进行查找

      if (debug) Log.d(TAG, "Not found. Trying superclass " + cls.getSuperclass().getName());

      viewBinder = findViewBinderForClass(cls.getSuperclass());

    } catch (InstantiationException e) {

      throw new RuntimeException("Unable to create view binder for " + clsName, e);

    } catch (IllegalAccessException e) {

      throw new RuntimeException("Unable to create view binder for " + clsName, e);

    }

    //加入内存集合,便于后续的查找

    BINDERS.put(cls, viewBinder);

    return viewBinder;

  }

MainActivity_ViewBinder的反编译源码

➜  androidannotationsample javap -c MainActivity_ViewBinderWarning: Binary file MainActivity_ViewBinder contains com.example.admin.androidannotationsample.MainActivity_ViewBinderCompiled from "MainActivity_ViewBinder.java"public final class com.example.admin.androidannotationsample.MainActivity_ViewBinder implements butterknife.internal.ViewBinder<com.example.admin.androidannotationsample.MainActivity> {

  public com.example.admin.androidannotationsample.MainActivity_ViewBinder();

    Code:

       0: aload_0

       1: invokespecial #1                  // Method java/lang/Object."<init>":()V

       4: return

 

  public butterknife.Unbinder bind(butterknife.internal.Finder, com.example.admin.androidannotationsample.MainActivity, java.lang.Object);

    Code:

       0: new           #2                  // class com/example/admin/androidannotationsample/MainActivity_ViewBinding

       3: dup

       4: aload_2

       5: aload_1

       6: aload_3                           // 创建ViewBinding实例

       7: invokespecial #3                  // Method com/example/admin/androidannotationsample/MainActivity_ViewBinding."<init>":(Lcom/example/admin/androidannotationsample/MainActivity;Lbutterknife/internal/Finder;Ljava/lang/Object;)V

      10: areturn

 

  public butterknife.Unbinder bind(butterknife.internal.Finder, java.lang.Object, java.lang.Object);

    Code:

       0: aload_0

       1: aload_1

       2: aload_2

       3: checkcast     #4                  // class com/example/admin/androidannotationsample/MainActivity

       6: aload_3                           //调用上面的重载方法

       7: invokevirtual #5                  // Method bind:(Lbutterknife/internal/Finder;Lcom/example/admin/androidannotationsample/MainActivity;Ljava/lang/Object;)Lbutterknife/Unbinder;

      10: areturn

}

MainActivity_ViewBinding的反编译源码

➜  androidannotationsample javap -c MainActivity_ViewBinding

Warning: Binary file MainActivity_ViewBinding contains com.example.admin.androidannotationsample.MainActivity_ViewBinding

Compiled from "MainActivity_ViewBinding.java"public class com.example.admin.androidannotationsample.MainActivity_ViewBinding<T extends com.example.admin.androidannotationsample.MainActivity> implements butterknife.Unbinder {

  protected T target;

 

  public com.example.admin.androidannotationsample.MainActivity_ViewBinding(T, butterknife.internal.Finder, java.lang.Object);

    Code:

       0: aload_0

       1: invokespecial #1                  // Method java/lang/Object."<init>":()V

       4: aload_0

       5: aload_1

       6: putfield      #2                  // Field target:Lcom/example/admin/androidannotationsample/MainActivity;

       9: aload_1

      10: aload_2

      11: aload_3                           //调用Finder.findRequireViewAsType找到View,并进行类型转换,并复制给MainActivity中对一个的变量

      12: ldc           #4                  // int 2131427412

      14: ldc           #5                  // String field 'myTextView'

      16: ldc           #6                  // class android/widget/TextView

                                            // 内部实际调用了findViewById

      18: invokevirtual #7                  // Method butterknife/internal/Finder.findRequiredViewAsType:(Ljava/lang/Object;ILjava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;

      21: checkcast     #6                  // class android/widget/TextView

      24: putfield      #8                  // Field com/example/admin/androidannotationsample/MainActivity.myTextView:Landroid/widget/TextView;

      27: return

 

  public void unbind();

    Code:

       0: aload_0

       1: getfield      #2                  // Field target:Lcom/example/admin/androidannotationsample/MainActivity;

       4: astore_1

       5: aload_1

       6: ifnonnull     19

       9: new           #9                  // class java/lang/IllegalStateException

      12: dup

      13: ldc           #10                 // String Bindings already cleared.

      15: invokespecial #11                 // Method java/lang/IllegalStateException."<init>":(Ljava/lang/String;)V

      18: athrow

      19: aload_1

      20: aconst_null                       // 解除绑定,设置对应的变量为null

      21: putfield      #8                  // Field com/example/admin/androidannotationsample/MainActivity.myTextView:Landroid/widget/TextView;

      24: aload_0

      25: aconst_null

      26: putfield      #2                  // Field target:Lcom/example/admin/androidannotationsample/MainActivity;

      29: return

}

Finder的源码

package butterknife.internal;

import android.app.Activity;import android.app.Dialog;import android.content.Context;import android.support.annotation.IdRes;import android.view.View;

@SuppressWarnings("UnusedDeclaration") // Used by generated code.public enum Finder {

  VIEW {

    @Override public View findOptionalView(Object source, @IdRes int id) {

      return ((View) source).findViewById(id);

    }

 

    @Override public Context getContext(Object source) {

      return ((View) source).getContext();

    }

 

    @Override protected String getResourceEntryName(Object source, @IdRes int id) {

      final View view = (View) source;

      // In edit mode, getResourceEntryName() is unsupported due to use of BridgeResources

      if (view.isInEditMode()) {

        return "<unavailable while editing>";

      }

      return super.getResourceEntryName(source, id);

    }

  },

  ACTIVITY {

    @Override public View findOptionalView(Object source, @IdRes int id) {

      return ((Activity) source).findViewById(id);

    }

 

    @Override public Context getContext(Object source) {

      return (Activity) source;

    }

  },

  DIALOG {

    @Override public View findOptionalView(Object source, @IdRes int id) {

      return ((Dialog) source).findViewById(id);

    }

 

    @Override public Context getContext(Object source) {

      return ((Dialog) source).getContext();

    }

  };

 

  //查找对应的Finder,如上面的ACTIVITY, DIALOG, VIEW

  public abstract View findOptionalView(Object source, @IdRes int id);

 

  public final <T> T findOptionalViewAsType(Object source, @IdRes int id, String who,

      Class<T> cls) {

    View view = findOptionalView(source, id);

    return castView(view, id, who, cls);

  }

 

  public final View findRequiredView(Object source, @IdRes int id, String who) {

    View view = findOptionalView(source, id);

    if (view != null) {

      return view;

    }

    String name = getResourceEntryName(source, id);

    throw new IllegalStateException("Required view '"

        + name

        + "' with ID "

        + id

        + " for "

        + who

        + " was not found. If this view is optional add '@Nullable' (fields) or '@Optional'"

        + " (methods) annotation.");

  }

 

  //来自ViewBinding的调用

  public final <T> T findRequiredViewAsType(Object source, @IdRes int id, String who,

      Class<T> cls) {

    View view = findRequiredView(source, id, who);

    return castView(view, id, who, cls);

  }

 

  public final <T> T castView(View view, @IdRes int id, String who, Class<T> cls) {

    try {

      return cls.cast(view);

    } catch (ClassCastException e) {

      String name = getResourceEntryName(view, id);

      throw new IllegalStateException("View '"

          + name

          + "' with ID "

          + id

          + " for "

          + who

          + " was of the wrong type. See cause for more info.", e);

    }

  }

 

  @SuppressWarnings("unchecked") // That's the point.

  public final <T> T castParam(Object value, String from, int fromPos, String to, int toPos) {

    try {

      return (T) value;

    } catch (ClassCastException e) {

      throw new IllegalStateException("Parameter #"

          + (fromPos + 1)

          + " of method '"

          + from

          + "' was of the wrong type for parameter #"

          + (toPos + 1)

          + " of method '"

          + to

          + "'. See cause for more info.", e);

    }

  }

 

  protected String getResourceEntryName(Object source, @IdRes int id) {

    return getContext(source).getResources().getResourceEntryName(id);

  }

 

  public abstract Context getContext(Object source);

}

Otto

Otto Bus 是一个专为Android改装的Event Bus,在很多项目中都有应用.由Square开源共享.

public class EventBusTest {

    private static final String LOGTAG = "EventBusTest";

    Bus mBus  = new Bus();

 

    public void test() {

        mBus.register(this);

    }

 

    class NetworkChangedEvent {

 

    }

 

    @Produce

    public NetworkChangedEvent sendNetworkChangedEvent() {

        return new NetworkChangedEvent();

    }

 

    @Subscribe

    public void onNetworkChanged(NetworkChangedEvent event) {

        Log.i(LOGTAG, "onNetworkChanged event=" + event);

    }

}

Otto 的工作原理

· 使用@Produce和@Subscribe标记方法

· 当调用bus.register方法,去检索注册对象的标记方法,并cache映射关系

· 当post事件时,将事件与handler方法对应加入事件队列

· 抽取事件队列,然后调用handler处理

如下为对Otto如何利用注解的分析

register的源码

public void register(Object object) {

    if (object == null) {

      throw new NullPointerException("Object to register must not be null.");

    }

    enforcer.enforce(this);

    //查找object中的Subscriber

    Map<Class<?>, Set<EventHandler>> foundHandlersMap = handlerFinder.findAllSubscribers(object);

    for (Class<?> type : foundHandlersMap.keySet()) {

      Set<EventHandler> handlers = handlersByType.get(type);

      if (handlers == null) {

        //concurrent put if absent

        Set<EventHandler> handlersCreation = new CopyOnWriteArraySet<EventHandler>();

        handlers = handlersByType.putIfAbsent(type, handlersCreation);

        if (handlers == null) {

            handlers = handlersCreation;

        }

      }

      final Set<EventHandler> foundHandlers = foundHandlersMap.get(type);

      if (!handlers.addAll(foundHandlers)) {

        throw new IllegalArgumentException("Object already registered.");

      }

    }

 

    for (Map.Entry<Class<?>, Set<EventHandler>> entry : foundHandlersMap.entrySet()) {

      Class<?> type = entry.getKey();

      EventProducer producer = producersByType.get(type);

      if (producer != null && producer.isValid()) {

        Set<EventHandler> foundHandlers = entry.getValue();

        for (EventHandler foundHandler : foundHandlers) {

          if (!producer.isValid()) {

            break;

          }

          if (foundHandler.isValid()) {

            dispatchProducerResultToHandler(foundHandler, producer);

          }

        }

      }

    }

  }

HandlerFinder源码

interface HandlerFinder {

 

  Map<Class<?>, EventProducer> findAllProducers(Object listener);

 

  Map<Class<?>, Set<EventHandler>> findAllSubscribers(Object listener);

 

  //Otto注解查找器

  HandlerFinder ANNOTATED = new HandlerFinder() {

    @Override

    public Map<Class<?>, EventProducer> findAllProducers(Object listener) {

      return AnnotatedHandlerFinder.findAllProducers(listener);

    }

 

    @Override

    public Map<Class<?>, Set<EventHandler>> findAllSubscribers(Object listener) {

      return AnnotatedHandlerFinder.findAllSubscribers(listener);

    }

  };

具体查找实现

/** This implementation finds all methods marked with a {@link Subscribe} annotation. */

  static Map<Class<?>, Set<EventHandler>> findAllSubscribers(Object listener) {

    Class<?> listenerClass = listener.getClass();

    Map<Class<?>, Set<EventHandler>> handlersInMethod = new HashMap<Class<?>, Set<EventHandler>>();

 

    Map<Class<?>, Set<Method>> methods = SUBSCRIBERS_CACHE.get(listenerClass);

    if (null == methods) {

      methods = new HashMap<Class<?>, Set<Method>>();

      loadAnnotatedSubscriberMethods(listenerClass, methods);

    }

    if (!methods.isEmpty()) {

      for (Map.Entry<Class<?>, Set<Method>> e : methods.entrySet()) {

        Set<EventHandler> handlers = new HashSet<EventHandler>();

        for (Method m : e.getValue()) {

          handlers.add(new EventHandler(listener, m));

        }

        handlersInMethod.put(e.getKey(), handlers);

      }

    }

 

    return handlersInMethod;

  }

以上就是关于Android中注解的一些总结,文章部分内容参考自 Support Annotations ,希望能帮助大家对注解有基础的认识,并运用到实际的日常开发之中。

 

以上,关于Android编程的全部内容讲解完毕啦,欢迎大家继续关注!更多关于Android编程的干货请关注职坐标Android编程频道!

本文由 @安安 发布于职坐标。未经许可,禁止转载。
喜欢 | 0 不喜欢 | 0
看完这篇文章有何感觉?已经有0人表态,0%的人喜欢 快给朋友分享吧~
评论(0)
后参与评论

您输入的评论内容中包含违禁敏感词

我知道了

助您圆梦职场 匹配合适岗位
验证码手机号,获得海同独家IT培训资料
选择就业方向:
人工智能物联网
大数据开发/分析
人工智能Python
Java全栈开发
WEB前端+H5

请输入正确的手机号码

请输入正确的验证码

获取验证码

您今天的短信下发次数太多了,明天再试试吧!

提交

我们会在第一时间安排职业规划师联系您!

您也可以联系我们的职业规划师咨询:

小职老师的微信号:z_zhizuobiao
小职老师的微信号:z_zhizuobiao

版权所有 职坐标-一站式IT培训就业服务领导者 沪ICP备13042190号-4
上海海同信息科技有限公司 Copyright ©2015 www.zhizuobiao.com,All Rights Reserved.
 沪公网安备 31011502005948号    

©2015 www.zhizuobiao.com All Rights Reserved

208小时内训课程