Android组件化(模块化)与路由

组件化modularization),也可翻译为模块化,是在大型APP开发中经常用到的一种技术。根据功能、职责、层级等条件,对项目代码进行拆分,从而得到边界清晰、易于维护、容易复用的各个组件。为避免歧义,本文统一用组件化指代这种技术。

单一工程架构

通常我们在新建一个简单APP项目时,采用的是单一工程架构,即项目内部只有一个组件(module),所有页面跳转、接口调用都是直接访问的目标类、接口代码。

单一工程架构的优点如下:

  • 结构简单:不存在复杂的依赖关系,代码之间可以在IDE中直接跳转,代码易于阅读
  • 整包构建效率高:单个组件的构建速度高于多个组件
  • 安全性高:如果接口有变化,编译期间就会发现问题,不会带到线上

但随着应用功能增加、团队规模扩大,单一工程架构也逐渐暴露出难以避免的缺点:

  • 功能混杂,边界不清:所有代码都位于同一个组件中,仅仅通过包名区分,难以进行强约束
  • 耦合严重:上下游调用关系缺乏约束,甚至会出现环状调用关系
  • 并行开发成本高 :所有团队成员向同一个组件提交代码,带来大量合并、解决冲突的成本(比如大家都修改了gradle文件)
  • 无法进行权限管理:所有人都可以直接看到整个项目的代码
  • 质量下降:某一功能出了问题,只能回滚整个APP,无法单独回滚出问题的组件
  • 无法复用:在应用A里已经开发完成的功能,无法再另一个应用B里直接使用

因此,业界发展出"组件化"的概念,不仅是Android,在iOS开发中同样也要对大型项目进行组件化。

组件化理论概述

  • 解决的问题:单一工程项目代码耦合严重、功能无法复用、代码逻辑职责不清、无法进行权限管控
  • 遵循的原则:关注点分离,高内聚低耦合,单向依赖
  • 采取的手段:厘清组件边界和层级,拆分组件,引入路由组件

组件化的优点

  • 代码逻辑清晰:每个组件各司其职,业务逻辑不再分散于APP各处
  • 构建效率高 :构建时不需要编译全部代码,而是所改动的,未改动的部分可以直接依赖编译好的aar
  • 稳定性好:组件存在版本的概念,对每个组件都存在一个当前最新的稳定版本,在这个基础上进行开发
  • 方便复用:一人开发,多人/多APP使用
  • 利于维护:解耦合,灵活添加和移除,利于扩展,避免冗长代码,便于理解
  • 权限安全:不同组件通过git仓库权限类似的机制,建立访问制度

进行组件化改造前的思考

组件化最大的难点,就是对组件的拆分和依赖关系处理,通常要先问自己以下几个问题,以明确组件的定位。

  1. 这个组件是为哪些业务服务的
  2. 它依赖于哪些更底层的功能,依赖关系是直接的还是间接的
  3. 它以何种方式对外提供服务/通信,是用于跳转的页面,还是用于调用的接口
  4. 它传递的参数是基本类型还是对象
  5. 组件的开发权限对哪些群体开放,源码又对哪些群体开放
  6. 组件是否要提供给公司内部跨团队使用,是否要发布到外网
  7. 它的更新、发布频率如何
  8. 当前是否已经存在具备类似功能的组件

应用组件化实践

以一个常见的电商APP为例,在笔者的实践经验中中,根据职责、依赖关系通常可以分为4层。

不同层次之间的依赖关系,描述如下:

  • 依赖单向传递,上层依赖下层,下层不对上层产生依赖
  • 上下层级之间直接依赖,在gradle配置文件中进行管理
  • 同一层级各个组件之间没有直接依赖,可以通过路由组件进行间接依赖

APP宿主层

不包含业务逻辑的壳工程,对应单一工程架构里面的整个项目。主要有两项职责:1.作为整个APP的入口2.管理各个组件初始化和注册。例如一个多Tab的首页结构,通常把底Tab的代码放在宿主层。

业务实现层

大部分页面(Activity、Fragment)代码都位于本层,例如推荐页、搜索结果页、用户信息页、购买结果页等等。每一个页面都有一个与之对应的路由Key,供其它页面向它跳转。

这一层既包含Native的页面,也包含Flutter、H5等页面,跨端技术通常集中在本层以及下一层------业务公共层。

本层的代码使用范围仅限定在当前APP。

业务公共层

登录、支付、下载、安装等公司通用业务,按钮、弹窗、列表容器等通用UI控件,以及负责页面跳转和接口调用的路由组件,都位于本层。

本层的各个组件可以开放给集团/公司/部门内,其它APP共享使用。

基础层

业界开源的三方框架,例如图片(Glide)、网络(OkHttp、Retrofit)、数据库(ROOM、GreenDAO)等基础能力,微信分享、支付宝、微博分享等第三方SDK,公司内部的埋点、Crash监测、推送等平台能力,以及一些工具类和UI库

本层的各个组件可以开放给业界各个APP使用(公司内部能力除外)。

组件化实践过程中的注意点/难点

  • 依赖冲突:上层两个组件分别依赖了底层同一组件的不同版本
  • 资源冲突 :不同组件定义了具有相同名字的资源(stringcolorlayout等)
  • 公共聚合组件:对于不明确归属于现有哪个组件的代码,通常将其统统放入一个公共聚合组件中,会带来它的不断膨胀
  • 接口版本管理与兼容性:组件的版本升级策略

注意点------依赖冲突

如果游戏详情页和游戏列表页分别依赖了不同版本的Glide库,最终构建时生效的是两者中的较高版本,如下图。

当引入的库在不同版本之间API存在不兼容时,这会导致运行时的RuntimeException,因此,依赖冲突是我们必须处理的问题。

首先需要检测出项目里存在哪些依赖冲突:

bash 复制代码
./gradlew :app:dependencies --configuration compileCompileClasspath

然后把所有模块依赖的版本强制统一,这里有两种方式,个人推荐第一种。

方式一:在APP宿主层声明组件版本变量,其它组件直接引用该变量

项目目录 下的build.gradle中声明组件及版本,其它组件在各自的 build.gradle里直接引用变量。也可以单独拎出一个文件env.gradle,用于专门管理版本。

项目env.gradle

gradle 复制代码
ext {
    androidx = [
        ktx : 'androidx.core:core-ktx:1.7.0',
        appcompat : androidx.appcompat:appcompat:1.3.2'
    ]
}

组件A、B使用已声明的同一个版本三方库。

组件build.gradle

gradle 复制代码
dependencies {
    implementation androidx.ktx
    implementation androidx.appcompat
}

方式二:强制设置各个组件依赖的版本

项目build.gradle

kotlin 复制代码
 // 定义你需要的版本号
def lifecycle_version = "2.2.0"
def fragment_version = "1.2.0"
def exifinterface_version = "1.2.0"
def transition_version = "1.2.0"
configurations.all{
    resolutionStrategy.force 'androidx.annotation:annotation:1.1.0'
    resolutionStrategy.force "androidx.lifecycle:lifecycle-common:$lifecycle_version"
    resolutionStrategy.force "androidx.fragment:fragment:$fragment_version"
    resolutionStrategy.force "androidx.exifinterface:exifinterface:$exifinterface_version"
    resolutionStrategy.force "androidx.transition:transition:$transition_version"
}

注意点------资源冲突

这里的资源,既包括res目录下的各个stringcolor等变量,也包含layout中的xxx.xml文件。当资源发生冲突时,系统默认采用以下方式处理:

  • 上下两个层级的组件,定义了同名的资源------最终生效的是上层资源
  • 同一层级的组件,定义了同名的资源------最终生效的是它们之中被上层引用的那个

要避免在不同模块中定义同名的资源,通常采用在资源名前增加模块前缀来区分,例如对于埋点组件,其资源前增加stats_前缀,对于图片组件,增加img_前缀。仅仅靠人为是不够的,官方提供了resourcePrefix检测手段,在组件的build.gradle文件中增加如下配置来强制IDE进行资源检查,以library_前缀为例:

gradle 复制代码
resourcePrefix 'library_'

如果没有按照规范命名,会出现如下错误:

注意点------公共聚合组件

在版本开发时,会遇到这种场景:需要增加一项底层功能,但不属于已有的任何组件,并且其规模也不足以单独成为一个组件。通常采取的做法是,建立一个common-module统一收纳此类代码。但随着版本不断迭代,common-module会不断膨胀,同时也容易导致一种惰性,后面对于新增功能不假思索就往这里面丢。

因此,需要定期对common-module进行重构,拆分出独立的组件,防止技术债务越积越多。

注意点------接口版本管理与兼容性

  • 对外API需保证向后兼容,使用添加API的方式扩展现有能力,避免对原有API进行break change改动或移除
  • 使用对象封装传递参数和回调参数,避免对原有API进行修改
  • 使用大小版本来区分,小版本升级需要保证接口向后兼容,大版本升级表示不兼容。如非必要,勿进行大版本升级,这会给依赖方带来极大的更新成本

开源组件之:设计一个路由框架

维基百科:路由(routing)是通过互联的网络把信息从源地址传输到目的地址的活动。

在实践中,我们通常把组件之间寻址、唤醒、调用接口等职责交给路由框架处理。

组件的实现过程中,要进行代码隔离,同层级之间的组件是无法直接访问的。在业务中会遇到组件之间跳转、互相调用的需求,此时就要借助于路由框架来实现。常见的开源框架有ARouterTheRouterWMRouter等。它们的核心原理是相似的,本文就是基于同一原理实现一个简易的路由框架。

核心概念:查找表

路由框架的核心是查找表HashMap,可以通过字符串查找到对应的类。

kotlin 复制代码
object SimpleRouter {
    private val routes = HashMap<String, Class<*>>()
    
    fun register(path: String, clazz: String) = routes.apply {
        put(path, Class.forName(clazz))
    }
    
    fun navigation(path: String) = routes[path]

有了上面的路由表,就可以在字符串-页面类之间建立映射关系:

kotlin 复制代码
SimpleRouter.register("/account/sign_in", "pro.lilei.user.SignInActivity")

当我们想要跳转到登录页时,可以通过字符串/account/sign_in进行寻址并跳转:

kotlin 复制代码
SimpleRouter.navigation("/account/sign_in")?.let {
    startActivity(Intent(ctx, it))
}

在此基础上,用注解来实现自动注册:

kotlin 复制代码
@Route(path = "/account/sign_in")
class SignInActivity : AppCompatActivity() {
    ...
}

通过APT解析注解,完成页面向路由框架的注册过程。

跳转Activity

路由框架最主要的功能是进行页面跳转,在路由表中查询到目标class后,构造Intent对象并进行跳转。对于要传递参数的情况,可以把参数封装成一个PostCard类,借助bundle参数可以传递任意实现了Parcelable接口的对象,它内部也是通过Key-Value维护的。

kotlin 复制代码
data class PostCard(
    val path: String,
    val bundle: Bundle
)

可以把创建Intent、传递参数、获取返回值的操作都封装在SimpleRouter.navigation()函数里。

kotlin 复制代码
object SimpleRouter {
    fun navigation(ctx: Context, postCard: PostCard, reqCode: Int = -1) {
        val dest = routes[postCard.path] ?: throw IllegalStateException("Not route matches!")
        val intent = Intent(ctx, dest).putExtras(postCard.bundle)
        
        when {
            requestCode >= 0 -> if (context is Activity) context.startActivityForResult(intent, reqCode)
            else -> ctx.startActivity(intent)
        }
    }
}

之后就可以创建PostCard对象实现传参数跳转:

kotlin 复制代码
val postCard = PostCard("/user/login", Bundle().apply {put("email", email)})
SimpleRouter.navigation(ctx, postCard)

也可以把生成PostCard的过程封装进SimpleRouter.kt类中,不详述。

创建Fragment

Fragment的使用场景一般是,在Activity中我们创建一个Fragment对象并通过FragmentManager将其加入当前页面。实现思路依然是传入String,查找对照表,得到类对象后通过反射进行实例化。

对此我们要对查找表进行升级,新建一个RouteMeta类封装跳转目标。

kotlin 复制代码
data class RouteMeta(
    val dest: Class<*>,
    val type: RouteType,
)

enum class RouteType {
    ACTIVITY, FRAGMENT, UNKNOWN
}

路由表的类型也相应变为HashMap<String, RouteMeta>

kotlin 复制代码
object SimpleRouter {
    private val routes = HashMap<String, RouteMeta>()
    
    fun register(path: String, clazz: String) = routes.apply {
        val clazzObj = Class.forName(clazz)
        val type = when {
            Activity::class.java.isAssignableFrom(clazzObj) -> RouteType.ACTIVITY
            Fragment::class.java.isAssignableFrom(clazzObj) -> RouteType.FRAGMENT
            else -> RouteType.UNKNOWN
        }
        put(path, RouteMeta(clazzObj, type))
    }
}

在此基础上,就可以在跳转时判断目的地。

kotlin 复制代码
object SimpleRouter {
  
  // ...

  private lateinit var application: Application
  
  fun init(application: Application) {
    this.application = application
  }

  fun navigation(ctx: Context? , postcard: Postcard, requestCode: Int = -1): Any? {
    val context = ctx ?: application
    val routeMeta = routes[postcard.path]
      ?: throw IllegalStateException("There is no route match the path [${postcard.path}]")
    val dest = routeMeta.dest
    return when (routeMeta.type) {
      RouteType.ACTIVITY -> {
        val intent = Intent(context, dest).putExtras(postcard.bundle)
        if (context !is Activity) {
          intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
        }
        if (requestCode >= 0) {
          if (context is Activity) {
            context.startActivityForResult(intent, requestCode)
          }
        } else {
          context.startActivity(intent)
        }
        null
      }
      RouteType.FRAGMENT -> {
        val fragmentMeta: Class<*> = dest
        try {
          val instance = fragmentMeta.getConstructor().newInstance()
          if (instance is Fragment) instance.arguments = postcard.bundle
          instance
        } catch (e: Exception) {
          null
        }
      }
      else -> null
    }
  }
}

在业务模块的Fragment类进行注册

kotlin 复制代码
// 也可通过注解
SimpleRouter.register("/account/me", "pro.lilei.user.MeFragment")

并在与它无依赖关系的模块里进行跳转

kotlin 复制代码
val frag = SimpleRouter.build("/account/me").navigation() as? Fragment

调用接口

组件之间互相调用接口并通信,是另一个组件化的重要场景。首先我们需要界定哪些类用于提供模块间通信,对于这样的类,声明一个IProvider接口,实现该接口的类可进行跨组件调用。

kotlin 复制代码
interface IProvider {
    fun init(ctx: Context)
}

同时,在RouteType中增加接口调用的枚举。

kotlin 复制代码
enum class RouteType {
    ACTIVITY,
    FRAGMENT,
    PROVIDER,
    UNKNOWN
}

当传入PROVIDER时,就用类似处理FRAGMENT的方式,进行实例化对象。与Fragment不同的是,对于创建出的对象,把它放入缓存以便后续使用。

这里声明Warehouse的仓库类,管理路由表以及缓存。

kotlin 复制代码
object Warehouse {
    val routes = HashMap<String, RouteMeta>()
    val providers = HashMap<Class<*>, IProvider>()
}

新建LogisticCenter用于进行服务注册。

kotlin 复制代码
object LogisticsCenter {
    private lateinit var ctx: Application
    
    fun init (application: Application) {
        ctx = application
    }
    
    fun register(path: String, clazzName: String) {
        val clazz = Class.forName(clazzName)
        val type = when {
            Activity::class.java.isAssignableFrom(clazz) -> RouteType.ACTIVITY
            Fragment::class.java.isAssignableFrom(clazz) -> RouteType.FRAGMENT
            IProvider::class.java.isAssignableFrom(clazz) -> RouteType.PROVIDER
            else -> RouteType.UNKNOWN
        }
        Warehouse.routes[path] = RouteMeta(clazz, type)
    }
}

在路由寻址器SimpleRouter.navigation()函数中,根据RouteType不同,执行三选一逻辑。

kotlin 复制代码
object SimpleRouter {
    fun register(path: String, clazzName: String) {
        LogisticsCenter.register(path, clazzName)
    }
    
    fun navigation(ctx: Context, postcard: PostCard, resquestCode: Int = -1): Any? {
    return when (postcard.type) {
        RouteType.ACTIVITY -> // 包装Intent进行跳转,支持onResult
        RouteType.FRAGMENT -> // 创建Fragment对象并返回
        RouteType.PROVIDER -> // 先在缓存查找,找到则返回,否则创建对象刷入缓存然后返回
        else -> null
    }
}

以下是实践IProvider跨组件接口调用能力。首先声明一个支持跨组件调用的接口,就叫账号服务。

kotlin 复制代码
interface AccountService : IProvider {
    val isSingIn: Boolean
    fun logout()
}

然后在账号组件内实现这个接口:

kotlin 复制代码
class AccountServiceProvider : AccountService {
    override val isSignIn: Boolean
        get() = // ... 业务逻辑
    
    override fun logout() {
        // ... 业务逻辑
    }
    
    override fun init(ctx: Context) {
        // ... 业务逻辑
    }
}

并在路由表注册该服务,这样其它同级组件可以通过/account/service来获取AccountService对象。

kotlin 复制代码
// 可优化成注解初始化
SipleRouter.register("/account/service", "pro.lilei.account.AccountServiceProvider")

例如,在商户详情页的组件中,可以这样判断当前用户是否登录:

kotlin 复制代码
val accountService = SimpleRouter.build("/account/service").navigation() as? AccountService
if (accountService?.isSignIn == true) {
    // ...
} else {
    // ...
}

路由原理小结

我们用一张层次图来说明"注册-调用"AccountService的过程。

api-module用于管理所有对外开放的IProvider接口,代码会经常变更。而router-module则是路由框架,代码稳定。

下图总结路由组件的页面跳转、方法调用流程:

开源组件之:Jetpack Startup启动注册组件

StartupJetpack开发包里的Google官方组件,内部通过ContentProvider实现。支持按顺序初始化,可以自定义初始化的依赖关系

使用方式

1.在项目build.gradle文件中增加依赖:

gradle 复制代码
implementation "androidx.startup:startup-runtime:1.1.1"

2.构建组件及依赖关系

需要通过Startup框架进行初始化的组件,需要实现Initializer接口。

kotlin 复制代码
class InitializerA : Initializer<A> {

    // 完成组件初始化,返回初始化后的结果
    override fun create(context: Context): A {
        return A.init(context)
    }

    // 依赖的组件
    override fun dependencies(): List<Class<out Initializer<*>>> {
        return listOf(InitializerB::class.java)
    }
}

class InitializerB : Initializer<B> {

    override fun create(context: Context): B {
        return B.init(context)
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        return  null
    }
}

该接口有2个函数需要实现:

  • create(): 调用组件初始化函数,并返回初始化结果
  • dependencies(): 返回它所依赖的组件,Startup框架会在这些依赖完成初始化后 ,对当前组件进行初始化。它的依赖列表也应当实现Initializer接口

在上例代码中,会先初始化B,再初始化A。

3-1.在Manifest文件中注册

xml 复制代码
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge"> // 注意这里用merge
    <meta-data
        android:name="leavesc.lifecyclecore.core.InitializerA"
        android:value="androidx.startup" />
</provider>

有几点需要注意:

  1. 此处只需要写InitializerA即可,因为InitializerB是A的依赖项,在初始化A之前,会自动初始化B
  2. 必须是merge,因为Manifest里面已经有声明过InitializationProvider了(在androidx库里),此处声明的需要与之前声明的合并

3-2.在Java代码中手动初始化

如果Initializer不需要一启动就初始化,则无需在AndroidManifest中声明,直接在用到的地方懒加载即可。

java 复制代码
val result = AppInitializer.getInstance(this).initializeComponent(InitializerA::class.java)

有几点注意:

  1. Startup内部会缓存Initializer初始化的结果,所以多次调用不会导致多次初始化
  2. 可以用这个方法,获取自动初始化的结果

原理浅析

《Android从点击应用图标到首帧展示的过程》一文中,我们分析过当APP进程启动时,ActivityThread会执行installContentProviders(),对AndroidManifest.xml中声明的各个ContentProvider进行初始化,执行其onCreate()函数。对于Startup框架,在这个过程中它做了这些事:

java 复制代码
public final class InitializationProvider extends ContentProvider {
    @Override
    public boolean onCreate() {
        Context context = getContext();
        if (context != null) {
            // 主要逻辑,调用AppInitializer完成
            AppInitializer.getInstance(context).discoverAndInitialize();
        } else {
            throw new StartupException("Context cannot be null");
        }
        return true;
    }
}
java 复制代码
void discoverAndInitialize() {
        ComponentName provider = new ComponentName(mContext.getPackageName(), InitializationProvider.class.getName());
        ProviderInfo providerInfo = mContext.getPackageManager().getProviderInfo(provider, GET_META_DATA);
        // 获取InitializationProvider下配置的meta-data参数对
        Bundle metadata = providerInfo.metaData;
        String startup = mContext.getString(R.string.androidx_startup);
        if (metadata != null) {
            Set<Class<?>> initializing = new HashSet<>();
            Set<String> keys = metadata.keySet();
            for (String key : keys) {
                String value = metadata.getString(key, null);
                // 如果value等于androidx.startup
                if (startup.equals(value)) {
                    Class<?> clazz = Class.forName(key);
                    // 如果key实现了Initializer接口
                    if (Initializer.class.isAssignableFrom(clazz)) {
                        Class<? extends Initializer<?>> component = (Class<? extends Initializer<?>>) clazz;
                        // 加入mDiscovered set
                        mDiscovered.add(component);
                        // 初始化这个component
                        doInitialize(component, initializing);
                    }
                }
            }
        }
}

discoverAndInitialize方法的主要作用是:

  • 通过PMS检索Manifest文件,找出InitializationProvider下的所有meta-data参数对。
  • 如果value等于androidx.startup,且key的类实现了Initializer接口,则加入mDiscovered set,调用doInitialize初始化这个component
java 复制代码
<T> T doInitialize(
        @NonNull Class<? extends Initializer<?>> component,
        @NonNull Set<Class<?>> initializing) {
    // sLock是一个静态对象
    synchronized (sLock) {
            if (initializing.contains(component)) {
                // 如果组件之间的依赖关系,出现了环,则直接抛出异常
                String message = String.format("Cannot initialize %s. Cycle detected.", component.getName());
                throw new IllegalStateException(message);
            }
            Object result;
            // 判断当前组件是否已被初始化
            if (!mInitialized.containsKey(component)) {
                // 加入initializing set
                initializing.add(component);
                try {
                    // 初始化component对象
                    Object instance = component.getDeclaredConstructor().newInstance();
                    Initializer<?> initializer = (Initializer<?>) instance;
                    List<Class<? extends Initializer<?>>> dependencies =initializer.dependencies();
                    if (!dependencies.isEmpty()) {
                        // 初始化所有依赖项
                        for (Class<? extends Initializer<?>> clazz : dependencies) {
                            if (!mInitialized.containsKey(clazz)) {
                                doInitialize(clazz, initializing);
                            }
                        }
                    }
                    // 初始化当前component
                    result = initializer.create(mContext);
                    // 从initializing set中移除component
                    initializing.remove(component);
                    // 放入mInitialized map中
                    mInitialized.put(component, result);
                } catch (Throwable throwable) {
                    throw new StartupException(throwable);
                }
            } else {
                result = mInitialized.get(component);
            }
            return (T) result;
    }
}

这个方法内部采用递归实现,其主要流程如下:

  • 在初始化之前,先判断当前的依赖关系图中是否有,如果有环,直接抛出异常。检验环的方式是,每当处理到一个组件时,将其加入Set,如果加入时发现它已经存在,则发现环
  • 判断当前组件是否已初始化,如果已初始化,直接返回result,防止重复初始化
  • 初始化组件对象,获取它的dependencies,先初始化dependencies
  • 所有dependencies初始化完成后,调用create方法初始化组件
  • 将初始化结果放入mInitialized map

使用痛点

Startup实现还是相对简单的,有以下几个痛点:

  • 不能无缝接入,需要修改第三方库的源码,或者等待第三方库接入,才能生效
  • 组件create方法都是在主线程调用,难以定制

可以自定义实现来规避以上痛点,不在本文详述。

关于组件化的思考

不存在完美的组件化方案,公司和业务的组织架构、人员组成、技术栈都处于时刻的变动中,APP架构也需要随着这个过程进行动态的调整与重构,避免积累大量技术债务。

不同职责的开发人员,关注点也应当有所区别。基础组件开发者关注更小粒度的性能,上层业务开发者则需要关注整体端对端链路的体验。

参考资料

相关推荐
时差9531 小时前
【面试题】Hive 查询:如何查找用户连续三天登录的记录
大数据·数据库·hive·sql·面试·database
CXDNW3 小时前
【网络面试篇】HTTP(2)(笔记)——http、https、http1.1、http2.0
网络·笔记·http·面试·https·http2.0
嚣张农民3 小时前
JavaScript中Promise分别有哪些函数?
前端·javascript·面试
阑梦清川7 小时前
在鱼皮的模拟面试里面学习有感
学习·面试·职场和发展
鱼跃鹰飞16 小时前
大厂面试真题-简单说说线程池接到新任务之后的操作流程
java·jvm·面试
程序员清风19 小时前
浅析Web实时通信技术!
java·后端·面试
测试199819 小时前
外包干了2年,快要废了。。。
自动化测试·软件测试·python·面试·职场和发展·单元测试·压力测试
mingzhi6120 小时前
渗透测试-快速获取目标中存在的漏洞(小白版)
安全·web安全·面试·职场和发展
嚣张农民20 小时前
一文简单看懂Promise实现原理
前端·javascript·面试
Liknana1 天前
Android 网易游戏面经
android·面试