动手撸一个ARouter (ARouter源码分析)

栏目: Java · 发布时间: 5年前

内容简介:为什么要重复造轮子呢?好了开发完成,让我们编译一下项目看看,编译结果如下图(ps:这里编译的是我自己的项目,但效果和ARouter是一样的):让我们带着这两个问题开始RouterManager之旅。

为什么要重复造轮子呢?

  • 我认为只有站在作者的角度才能更透彻的理解框架的设计思想
  • 去踩大神们所踩过的坑。
  • 才能深入的理解框架的所提供的功能
  • 学习优秀的作品中从而提高自己

在开始之前我先提出关于ARouter的几个问题

  • 为什么要在module的build.gradle文件中增加下面配置? 它的作用是什么?它跟我们定义的url中的分组有什么关系?
javaCompileOptions {
    annotationProcessorOptions {
        arguments = [moduleName: project.getName()]
    }
}
复制代码
  • 有这么一种业务场景,新建一个业务组件user,user组件中有页面UserActivity,配置url /user/main ;有一个服务接口,其实现类在app中,配置url为 /user/info ;代码如下:
//module:user
@Route(path = "/user/main")
public class UserActivity extends AppCompatActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.user_activity);
    }
}

public interface IUserService extends IProvider {
    void test(String s);
}

//module:app
//user服务
@Route(path = "/user/info")
public class UserServiceImpl implements IUserService {

    public void test(String test) {
        Log.d("xxxx->",test);
    }
}
复制代码

好了开发完成,让我们编译一下项目看看,编译结果如下图(ps:这里编译的是我自己的项目,但效果和ARouter是一样的):

动手撸一个ARouter (ARouter源码分析)

Why???

让我们带着这两个问题开始RouterManager之旅。

第一步架构设计思路(处理页面跳转)

我们的目标是根据一个url来打开指定的页面,该如何做呢?很简单,我们把url和对应的页面做一个对应关系,比如放到map中以url为key,对应的页面activity为value即可;这样当我们要打开这个activity时,根据传给我们的url去map中找到对应的activity,然后调用startActivity就OK了。

你可能会问那我们这个map该如何维护呢?我们怎么把这个对应关系存到map中呢?总不能手动去put吧,你别说貌似还真行,我们在app启动的时候先把我的映射关系手动初始化好,这样在打开页面时直接通过url来获取就行了。那么问题来了,大哥你累不累啊?对于一个懒人来说首先会想到的是能不能自动生成这个映射关系表呢?答案是肯定的。

思路总结

我们可以利用编译注解的特性,新增一个注解,给每个需要通过url打开的activity加上此注解。在注解处理器中获取所有被注解的类,动态生成映射关系表,然后在app启动时把所生成的映射关系load到内存即可。

第二部撸代码

0x01

首先我们需要创建三个module,如下图:

动手撸一个ARouter (ARouter源码分析)

为什么要三个项目呢?原因如下:

  • 我们需要用到的注解处理器AbstractProcessor是在javax包下,而android项目中是没有这个包的,因此我们需要建一个java library,也就是router-compiler,它的作用是帮我们动态生成代码,只存在于编译期间

  • 既然router-compiler只存在于编译期间,那我们的注解是需要在项目中用到的,这个类应该放在那里呢?这就有了第二个java library,router-annotation,用来专门存放我们定义的注解和一些要被打进app中代码。

  • 由于上述两个library都是 java 项目,而我们最终是要用到android工程中的,因此对外提供api时肯定会用到android工程中的类,如Context。所以就有了第三个module router-api用于处理生成产物。如把生成映射关系表load到内存,并提供统一的调用入口。

0x02

我们先定义我们自己的注解:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.CLASS)
public @interface Route {

    String path();

    String group() default "";

    String name() default "";

    int extras() default Integer.MIN_VALUE;

    int priority() default -1;
}
复制代码

定义自己的route处理器RouterProcessor

@AutoService(Processor.class)       //自动注册注解处理器
@SupportedOptions({Consts.KEY_MODULE_NAME})     //参数
@SupportedSourceVersion(SourceVersion.RELEASE_7)        //指定使用的Java版本
@SupportedAnnotationTypes({ANNOTATION_ROUTER_NAME}) //指定要处理的注解类型
public class RouterProcessor extends AbstractProcessor{

    private Map<String,Set<RouteMeta>> groupMap = new HashMap<>();  //收集分组
    private Map<String,String> rootMap = new TreeMap<>();
    private Filer mFiler;
    private Logger logger;
    private Types types;
    private TypeUtils typeUtils;
    private Elements elements;
    private String moduleName = "app"; //默认app
    private TypeMirror iProvider = null; //IProvider类型
    
    //......
复制代码

其中SupportedAnnotationTypes指定的就是我们上面定义的注解Route

接下来就是收集所有被注解的类,生成映射关系,代码如下:

public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        if(CollectionUtils.isNotEmpty(set)) {
            //获取到所有被注解的类
            Set<? extends Element> elementsAnnotatedWith = roundEnvironment.getElementsAnnotatedWith(Route.class);
            try {
                logger.info(">>> Found routers,start... <<<");
                parseRoutes(elementsAnnotatedWith);
            } catch (IOException e) {
                logger.error(e);
            }
            return true;
        }
        return false;
    }
复制代码

获取完之后交给了parseRoutes方法:

private void parseRoutes(Set<? extends Element> routeElements) throws IOException {
        if(CollectionUtils.isNotEmpty(routeElements)) {

            logger.info(">>> Found routes, size is " + routeElements.size() + " <<<");
            rootMap.clear();
            //.......
            TypeMirror type_activity = elements.getTypeElement(ACTIVITY).asType();

            for (Element element : routeElements) {
                TypeMirror tm = element.asType();
                Route route = element.getAnnotation(Route.class);
                RouteMeta routeMeta;

                if(types.isSubtype(tm,type_activity)) { //activity
                    logger.info(">>> Found activity route: "+ tm.toString() + " <<<");
                    routeMeta = new RouteMeta(route,element,RouteType.ACTIVITY,null);
                } else if(types.isSubtype(tm,iProvider)) { //IProvider
                    logger.info(">>> Found provider route: " + tm.toString() + " <<<");
                    routeMeta = new RouteMeta(route,element,RouteType.PROVIDER,null);
                } else if(types.isSubtype(tm,type_fragment) || types.isSubtype(tm,type_v4_fragment)) { //Fragment
                    logger.info(">>> Found fragment route: " + tm.toString() + " <<< ");
                    routeMeta = new RouteMeta(route,element,RouteType.parse(FRAGMENT),null);
                } else {
                    throw new RuntimeException("ARouter::Compiler >>> Found unsupported class type, type = [" + types.toString() + "].");
                }

                categories(routeMeta);
            }
            
            //.......
复制代码

这个方法比较长,我们先看看最主要的处理,遍历routeElements,判断当前被注解的类的类型,分别是activity,IProvider,Fragment这三中,也就是说注解Route可以用来注解activity ,IProvider,和Fragment(注意这里fragment包括原生包中的和v4包中的fragment)然后根据类型构造出routeMeta对象,构造完之后传给了categories方法:

private void categories(RouteMeta routeMete) {
        if (routeVerify(routeMete)) {
            logger.info(">>> Start categories, group = " + routeMete.getGroup() + ", path = " + routeMete.getPath() + " <<<");
            //groupMap是一个全局变量,用来按分组存储routeMeta
            Set<RouteMeta> routeMetas = groupMap.get(routeMete.getGroup());
            if (CollectionUtils.isEmpty(routeMetas)) {
                Set<RouteMeta> routeMetaSet = new TreeSet<>(new Comparator<RouteMeta>() {
                    @Override
                    public int compare(RouteMeta r1, RouteMeta r2) {
                        try {
                            return r1.getPath().compareTo(r2.getPath());
                        } catch (NullPointerException npe) {
                            logger.error(npe.getMessage());
                            return 0;
                        }
                    }
                });
                routeMetaSet.add(routeMete);
                groupMap.put(routeMete.getGroup(), routeMetaSet);
            } else {
                routeMetas.add(routeMete);
            }
        } else {
            logger.warning(">>> Route meta verify error, group is " + routeMete.getGroup() + " <<<");
        }
    }
复制代码

我们看到这个方法中首先根据当前url分组去groupMap中查找,也就是看是否有该分组,如果有取出对应的RouterMeta集合,把本次生成的routeMeta放进去;没有就新存一个集合。

到这里我们已经把所有的注解类都获取到并且已经按分组分类。接下来就是生成java类来存放这些信息:

这里暂且只看对activity映射关系处理的代码:

// (1)
for (Map.Entry<String, Set<RouteMeta>> entry : groupMap.entrySet()) {
                String groupName = entry.getKey();

                // (2)
                MethodSpec.Builder loadIntoMethodOfGroupBuilder = MethodSpec.methodBuilder(METHOD_LOAD_INTO)
                        .addAnnotation(Override.class)
                        .addModifiers(Modifier.PUBLIC)
                        .addParameter(groupParamSpec);

                Set<RouteMeta> groupData = entry.getValue();

                for (RouteMeta meta : groupData) {
                    ClassName className = ClassName.get((TypeElement) meta.getRawType());
                    
                   //......   (3)

                    loadIntoMethodOfGroupBuilder.addStatement(
                            "atlas.put($S," +
                                    "$T.build($T." + meta.getType() + ",$T.class,$S,$S," + (StringUtils.isEmpty(mapBody) ? null : ("new java.util.HashMap<String, Integer>(){{" + mapBodyBuilder.toString() + "}}")) + ", " + meta.getPriority() + "," + meta.getExtra() + "))",
                            meta.getPath(),
                            routeMetaCn,
                            routeTypeCn,
                            className,
                            meta.getPath().toLowerCase(),
                            meta.getGroup().toLowerCase());
                }

                //Generate groups   (4)
                String groupFileName = NAME_OF_GROUP + groupName;
                JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
                        TypeSpec.classBuilder(groupFileName)
                                .addJavadoc(WARNING_TIPS)
                                .addSuperinterface(ClassName.get(type_IRouteGroup))
                                .addModifiers(Modifier.PUBLIC)
                                .addMethod(loadIntoMethodOfGroupBuilder.build())
                                .build()
                ).build().writeTo(mFiler);

                logger.info(">>> Generated group: " + groupName + "<<<");
                rootMap.put(groupName, groupFileName);
            }

            // (5)
            if(MapUtils.isNotEmpty(rootMap)) {
                for (Map.Entry<String, String> entry : rootMap.entrySet()) {
                    loadIntoMethodOfRootBuilder.addStatement("routes.put($S, $T.class)", entry.getKey(), ClassName.get(PACKAGE_OF_GENERATE_FILE, entry.getValue()));
                }
            }

            // ......

            // Write root meta into disk.   (6)
            String rootFileName = NAME_OF_ROOT + moduleName;
            JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
                    TypeSpec.classBuilder(rootFileName)
                            .addJavadoc(WARNING_TIPS)
                            .addSuperinterface(ClassName.get(elements.getTypeElement(IROUTE_ROOT)))
                            .addModifiers(PUBLIC)
                            .addMethod(loadIntoMethodOfRootBuilder.build())
                            .build()
            ).build().writeTo(mFiler);

            logger.info(">>> Generated root, name is " + rootFileName + " <<<");
        }

复制代码

现将上述这段代码解释如下:

  • 遍历我们之前存储的groupMap,取出对应的集合,如注释(1)
  • 生成一个方法体,并且把集合中的所有映射关系都put到参数map中。如 (2)(3)
  • 生成java类,类名为RouterManager$$Group$$ + moduleName,这里的moduleName就是在build.gradle文件中配置的,如不配置,活获取为null 如(4)
  • 把每个分组和所生成的类做个映射关系,作用就是为了实现按分组加载功能 如 (5)(6)

下面我们看下一生成的产物

/**
 DO NOT EDIT THIS FILE!!! IT WAS GENERATED BY ROUTERMANAGER. */
public class RouterManager$$Root$$app implements IRouteRoot {
  @Override
  public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
    routes.put("service", RouterManager$$Group$$service.class);
  }
}

复制代码

存储分组对应关系

/**
 * DO NOT EDIT THIS FILE!!! IT WAS GENERATED BY ROUTERMANAGER. */
public class RouterManager$$Group$$service implements IRouteGroup {
  @Override
  public void loadInto(Map<String, RouteMeta> atlas) {
    atlas.put("/service/test/main",RouteMeta.build(RouteType.ACTIVITY,OtherActivity.class,"/service/test/main","service",null, -1,-2147483648));
  }
}

复制代码

就这样映射关系自动生成好了,那么该如何使用呢?下面就让我隆重介绍一下我们Api

0x03

由于我们的映射关系表是全局存在的,所以肯定需要在Application中做初始化操作,其目的就是把映射关系load到内存,下面让我们看看具体实现代码

首先我们得需要一个容器来存储我们的映射关系,因此就有了Warehouse类

class Warehouse {
    // Cache route and metas
    static Map<String, Class<? extends IRouteGroup>> groupsIndex = new HashMap<>();
    static Map<String, RouteMeta> routes = new HashMap<>();
    
    //......

    static void clear() {
        providers.clear();
        providersIndex.clear();
    }
}

复制代码

我们在此类中实例化两个map用来存储我们的分组信息和每个分组中的对应关系信息

groupIndex:用来存放分组信息,这个会优先load数据 routes:用来存储对应关系数据

接下来我们在App初始化时会调用如下代码来初始化:

RouterManager.init(this);
复制代码

那么我们进去init方法中看看具体干了什么?

public static synchronized void init(Application application){
        if(!hasInit) {
            hasInit = true;
            mContext = application;
            mHandler = new Handler(Looper.getMainLooper());
            logger = new DefaultLogger();
            LogisticsCenter.init(mContext,logger);
        }
    }
复制代码

可以看到这里最关键的一行代码是 LogisticsCenter.init(mContext,logger)

那就让我们继续去LogisticsCenter.init(mContext,logger);方法中看看:

public synchronized static void init(Context context, ILogger log) {
        logger = log;
        Set<String> routeMap;
        try {
            if(RouterManager.debuggable() || PackageUtils.isNewVersion(context)) { //开发模式或版本升级时扫描本地件
                logger.info(TAG,"当前环境为debug模式或者新版本,需要重新生成映射关系表");
                //these class was generated by router-compiler
                routeMap = ClassUtils.getFileNameByPackageName(context, Consts.ROUTE_ROOT_PAKCAGE);
                if(!routeMap.isEmpty()) {
                    PackageUtils.put(context,Consts.ROUTER_SP_KEY_MAP,routeMap);
                }
                PackageUtils.updateVersion(context);
            } else{ //读取缓存
                logger.info(TAG,"读取缓存中的router映射表");
                routeMap = PackageUtils.get(context,Consts.ROUTER_SP_KEY_MAP);
            }

            logger.info(TAG,"router map 扫描完成");
            //将分组数据加载到内存
            for (String className : routeMap) {
                //Root
                if(className.startsWith(Consts.ROUTE_ROOT_PAKCAGE + Consts.DOT + Consts.SDK_NAME + Consts.SEPARATOR + Consts.SUFFIX_ROOT)) {
                    ((IRouteRoot)(Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.groupsIndex);
                } 
                //......
            }

            logger.info(TAG,"将映射关系读到缓存中");

            if(Warehouse.groupsIndex.size() == 0) {
                logger.error(TAG,"No mapping files,check your configuration please!");
            }

            if (RouterManager.debuggable()) {
                logger.debug(TAG, String.format(Locale.getDefault(), "LogisticsCenter has already been loaded, GroupIndex[%d], ProviderIndex[%d]", Warehouse.groupsIndex.size(), Warehouse.providersIndex.size()));
            }

        } catch (Exception e) {
            e.printStackTrace();
            logger.error(TAG,"RouterManager init logistics center exception! [" + e.getMessage() + "]");
        }
    }
复制代码

具体解释如下:

1)、首先是根据包名去扫描所有生成的类文件,并放在routeMap中。当然这里会根据版本判断然后缓存到本地,目的是为了避免重复扫描 2)、遍历扫描到的数组,将所有分组信息缓存到Warehouse.groupIndex中

可以看到初始化时只干了这两件事,扫描class文件,读取分组信息;仔细想想你会发现这里并没有去读取我们的url和activity映射关系信息,这就是所谓的按需加载。

到这里我们所有的准备工作都已完成了,那么该怎么使用呢?

下面让我们看看具体的用法

0x04

我们先来看一段代码:

RouterManager.getInstance().build("/user/main").navigation(MainActivity.this);
复制代码

上述代码是我们打开UserActivty页面所使用的方式,可以发现这里只传了一个url。那就让我们看看内部是如何实现的?

首先我们去build方法中看看具体的代码:

public Postcard build(String path) {
        if(TextUtils.isEmpty(path)) {
            throw new HandlerException("Parameter is invalid!");
        } else {
            return build(path,extractGroup(path));
        }
    }

    public Postcard build(String path,String group) {
        if(TextUtils.isEmpty(path)) {
            throw new HandlerException("Parameter is invalid!");
        } else {
            return new Postcard(path,group);
        }
    }
复制代码

发现这里是一个重载方法,最后返回的是一个Postcard对象,然后调用Postcard的navigation方法。可以看到这里Postcard其实只是一个携带数据的实体。下面看看navigation方法:

public Object navigation(Context context) {
        return RouterManager.getInstance().navigation(context,this,-1);
    }
复制代码

可以发现这里只是做了一个中转,最终调用的是RouterManager的navigation方法:

Object navigation(final Context context,final Postcard postcard,final int requestCode) {
        try {
            LogisticsCenter.completion(postcard);
        } catch (HandlerException e) {
            e.printStackTrace();
            return null;
        }

        final Context currentContext = context == null ? mContext : context;
        switch (postcard.getType()) {
            case ACTIVITY:
                final Intent intent = new Intent(currentContext,postcard.getDestination());
                intent.putExtras(postcard.getExtras());

                int flags = postcard.getFlags();
                if(flags != -1) {
                    intent.setFlags(flags);
                } else if(!(currentContext instanceof Activity)) { //如果当前上下文不是activity,则启动activity时需要new一个新的栈
                    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                }

                runInMainThread(new Runnable() {
                    @Override
                    public void run() {
                        startActivity(requestCode,currentContext,intent,postcard);
                    }
                });
                break;
            //......
        }
        return null;
    }
复制代码

由上述代码可以看出首先调用的是LogisticsCenter.completion()方法把postcard对象传进去,那让我们先去这个方法中看个究竟:

/**
     * 填充数据
     * @param postcard
     */
    public synchronized static void completion(Postcard postcard) {
        RouteMeta routeMeta = Warehouse.routes.get(postcard.getPath());
        if(routeMeta != null) {
            postcard.setDestination(routeMeta.getDestination());
            postcard.setType(routeMeta.getType());
            postcard.setPriority(routeMeta.getPriority());
            postcard.setExtra(routeMeta.getExtra());

            //......
        } else {
            Class<? extends IRouteGroup> groupMeta = Warehouse.groupsIndex.get(postcard.getGroup());
            if(groupMeta == null) {
                throw new NoRouteFoundException("There is no route match the path [" + postcard.getPath() + "], in group [" + postcard.getGroup() + "]");
            } else {
                try {
                    //按组加载数据,美其名曰-按需加载
                    IRouteGroup iRouteGroup = groupMeta.getConstructor().newInstance();
                    iRouteGroup.loadInto(Warehouse.routes);
                    Warehouse.groupsIndex.remove(postcard.getGroup());
                } catch (Exception e) {
                    throw new HandlerException("Fatal exception when loading group meta. [" + e.getMessage() + "]");
                }
            }

            completion(postcard); //分组加载完成后重新查找
        }
    }

复制代码

这里首先去根据url去Warehouse.routes中查找对应的RouteMeta信息,如何是首次调用的话这里一定是没有的,所以会执行else方法,else方法里先根据分组获取对应的分组class,然后反射其实例对象并调用loadInfo()方法,把该分组中的所有映射关系读取到Warehouse.routes中,然后继续调用当前方法填充相关的信息。

信息填充完成之后继续回到navigation方法中:

switch (postcard.getType()) {
            case ACTIVITY:
                final Intent intent = new Intent(currentContext,postcard.getDestination());
                intent.putExtras(postcard.getExtras());

                int flags = postcard.getFlags();
                if(flags != -1) {
                    intent.setFlags(flags);
                } else if(!(currentContext instanceof Activity)) { //如果当前上下文不是activity,则启动activity时需要new一个新的栈
                    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                }

                runInMainThread(new Runnable() {
                    @Override
                    public void run() {
                        startActivity(requestCode,currentContext,intent,postcard);
                    }
                });
                break;
            //......
        }
复制代码

可以看到这里使用的是常规的启动方式startActivity去启动一个新activity。

Ok到此为止整个流程算是走完了,至于传递参数,获取fragment,以及服务IProvider什么的套路都一样,这里不再重复赘述。

总结

ARouter的思路很好简单,就是通过编译时注解生成url与页面的映射关系表,然后在程序启动时将该映射关系表load到内存中,使用时直接去内存中查找然后执行常规的页面启动方式。

下面我们来回答前面提出的两个问题

第一:为什么要在每个build.gradle文件中配置一个moduleName呢?

这是因为编译时注解是以module为单位去生成代码的,也就是说我们需要给每个module项目都配置该注解生成器的依赖,为了保证生成java文件的名字不会重复需要加上module为后缀。此配置和分组没有任何关系。只是为了避免生成的分组类重复。

第二:为什么会报多个类重名的问题?

我们知道Router的映射表有两张表,第一张是用来存储分组和分组对应的class的,第二张是用来存储每个分组中具体url映射关系的。而在第一个问题中我们根据moduleName来避免存放分组的class重名的问题。那么每个分组class本身有没有重名的可能呢?答案是一定有的。比如:我们在user组件中配置的url:/user/main分组为user,这个时候在编译user组件时就会自动生成一个类名为 RouterManager$$Group$$user 的类,用来存放所有的以user为分组的页面映射关系。那么当我们在app的中也配置分组名为user的分组后,编译app时就会在app中生成类名为 RouterManager$$Group$$user 的类。而我们app项目是依赖的user组件的,这就导致有两个类名一样的文件。编译时自然就会报错。

对RouterManager的几点思考

  • RouterManager能否用于夸进程调用:

我认为是可以的,RouterManager的关系映射表是存在一个全局静态变量中的,当我们需要在其他进程访问时只需要提供一个接口来得到映射关系即可。

  • RouterManager能否在RePlugin中的使用:

答案也是可以的,由于RePlugin采用的是多个classloader机制,这就导致我们在主项目的classloader获取的对象和在插件classloader中获取的是两个独立的对象,如果想在插件中使用RouterManager去打开一个宿主的页面,直接调用的话肯定是没有对应的映射关系的,因为在插件里获取的RouterManager对象并不是宿主的单例对象,而是创建了一个新的对象。那怎么办呢?答案很简单,我们在插件中使用反射获取到宿主的RouterManager实例即可正常使用。

注:RouterManager框架的思路来源与ARouter,这里只实现了页面跳转,fragment获取和服务Provider的获取功能。至于其他的降级策略,依赖注入功能就不在一一实现了

项目源码请移驾到本人的github仓库查看: github.com/qiangzier/R…


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

着陆页:获取网络订单的关键

着陆页:获取网络订单的关键

谢松杰 / 电子工业出版社 / 2017-1-1 / CNY 55.00

着陆页是用户点击广告后看到的第一个页面,是相关产品和服务的商业模式与营销思想的载体,是实现客户转化的关键。本书从“宏观”和“微观”两个层面对着陆页的整体框架和局部细节进行了深入的讨论,既有理论和方法,又有技术与工具,为读者呈现了着陆页从策划到技术实现的完整知识体系,帮助读者用最低的成本实现网站最高的收益。 谢松杰老师作品《网站说服力》版权输出台湾,深受两岸读者喜爱。本书是《网站说服力》的姊妹......一起来看看 《着陆页:获取网络订单的关键》 这本书的介绍吧!

MD5 加密
MD5 加密

MD5 加密工具

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器