全国免费咨询:

13245491521

VR图标白色 VR图标黑色
X

中高端软件定制开发服务商

与我们取得联系

13245491521     13245491521

2023-11-03_Android使用Hilt依赖注入,让人看不懂你代码

您的位置:首页 >> 新闻 >> 行业资讯

Android使用Hilt依赖注入,让人看不懂你代码 点击小卡片参与粉丝专属福利前言之前接手的一个项目里有些代码看得云里雾里的,找了半天没有找到对象创建的地方,后来才发现原来使用了Hilt进行了依赖注入。Hilt相比Dagger虽然已经比较简洁,但对初学者来说还是有些门槛,并且网上的许多文章都是搬自官网,入手容易深入难,如果你对Hilt不了解或是想了解得更多,那么接下来的内容将助力你玩转Hilt。 通过本篇文章,你将了解到: ?什么是依赖注入?Hilt 的引入与基本使用Hilt 的进阶使用Hilt 原理简单分析Android到底该不该使用DI框架??1. 什么是依赖注入?什么是依赖?以手机为例,要组装一台手机,我们需要哪些部件呢? 从宏观上分类:软件+硬件。 由此我们可以说:手机依赖了软件和硬件。 而反映到代码的世界: classFishPhone(){ valsoftware=Software() valhardware=Hardware() funcall(){ //打电话 software.handle() hardware.handle() } } //软件 classSoftware(){ funhandle(){} } //硬件 classHardware(){ funhandle(){} } FishPhone 依赖了两个对象:分别是Software和Hardware。 Software和Hardware是FishPhone的依赖(项)。 什么是注入?上面的Demo,FishPhone内部自主构造了依赖项的实例,考虑到依赖的变化挺大的,每次依赖项的改变都要改动到FishPhone,容易出错,也不是那么灵活,因此考虑从外部将依赖传进来,这种方式称之为:依赖注入(Dependency Injection 简称DI) 有几种方式: ?构造函数传入SetXX函数传入从其它对象间接获取?构造函数依赖注入: classFishPhone(valsoftware:Software,valhardware:Hardware){ funcall(){ //打电话 software.handle() hardware.handle() } } FishPhone的功能比较纯粹就是打电话功能,而依赖项都是外部传入提升了灵活性。 为什么需要依赖注入框架?手机制造出来后交给客户使用。 classCustomer(){ funusePhone(){ valsoftware=Software() valhardware=Hardware() FishPhone(software,hardware).call() } } 用户想使用手机打电话,还得自己创建软件和硬件,这个手机还能卖出去吗? 而不想创建软件和硬件那得让FishPhone自己负责去创建,那不是又回到上面的场景了吗? 你可能会说:FishPhone内部就依赖了两个对象而已,自己负责创建又怎么了? 解耦再看看如下Demo: interfaceISoftware{ funhandle() } //硬件 interfaceIHardware{ funhandle() } //软件 classSoftwareImpl():ISoftware{ overridefunhandle(){} } //硬件 classHardwareImpl:IHardware{ overridefunhandle(){} } classFishPhone(){ valsoftware:ISoftware=SoftwareImpl() valhardware:IHardware=HardwareImpl() funcall(){ //打电话 software.handle() hardware.handle() } } FishPhone 只关注软件和硬件的接口,至于具体怎么实现它不关心,这就达到了解耦的目的。既然要解耦,那么SoftwareImpl()、HardwareImpl()就不能出现在FishPhone里。 应该改为如下形式: classFishPhone(valsoftware:ISoftware,valhardware:IHardware){ funcall(){ //打电话 software.handle() hardware.handle() } } 消除模板代码即使我们不考虑解耦,假若HardwareImpl里又依赖了cpu、gpu、disk等模块: //硬件 classHardwareImpl:IHardware{ valcpu=CPU(Regisgter(),Cal(),Bus()) valgpu=GPU(Image(),Video()) valdisk=Disk(Block(),Flash()) //...其它模块 overridefunhandle(){} } 现在仅仅只是三个模块,若是依赖更多的模块或者模块的本身也需要依赖其它子模块,比如CPU需要依赖寄存器、运算单元等等,那么我们就需要写更多的模板代码,要是我们只需要声明一下想要使用的对象而不用管它的创建就好了。 classHardwareImpl(valcpu:CPU,valgpu:GPU,valdisk:Disk):IHardware{ overridefunhandle(){} } 可以看出,下面的代码比上面的简洁多了。 ?从解耦和消除模板代码的角度看,我们迫切需要一个能够自动创建依赖对象并且将依赖注入到目标代码的框架,这就是依赖注入框架依赖注入框架能够管理依赖对象的创建,依赖对象的注入,依赖对象的生命周期使用者仅仅只需要表明自己需要什么类型的对象,剩下的无需关心,都由框架自动完成?先想想若是我们想要实现这样的框架需要怎么做呢? 相信很多小伙伴最朴素的想法就是:使用工厂模式,你传参告诉我想要什么对象我给你构造出来。 这个想法是半自动注入,因为我们还要调用工厂方法去获取,而全自动的注入通常来说是使用注解标注实现的。 2. Hilt 的引入与基本使用Hilt的引入从Dagger到Dagger2再到Hilt(Android专用),配置越来越简单也比较容易上手。 前面说了依赖注入框架的必要性,我们就想迫不及待的上手,但难度可想而知,还好大神们早就造好了轮子。 以AGP 7.0 以上为例,来看看Hilt框架是如何引入的。 「一:project级别的build.gradle 引入如下代码:」 plugins{ //指定插件地址和版本 id'com.google.dagger.hilt.android'version'2.48.1'applyfalse } 「二:module级别的build.gradle引入如下代码:」 plugins{ id'com.android.application' id'org.jetbrains.kotlin.android' //使用插件 id'com.google.dagger.hilt.android' //kapt生成代码 id'kotlin-kapt' } //引入库 implementation'com.google.dagger:hilt-android:2.48.1' kapt'com.google.dagger:hilt-compiler:2.48.1' 实时更新最新版本以及AGP7.0以下的引用请参考:Hilt最新版本配置(https://dagger.dev/hilt/gradle-setup) Hilt的简单使用前置步骤整好了接下来看看如何使用。 「一:表明该App可以使用Hilt来进行依赖注入,添加如下代码:」 @HiltAndroidApp classMyApp:Application(){ overridefunonCreate(){ super.onCreate() } } @HiltAndroidApp 添加到App的入口,即表示依赖注入的环境已经搭建好。 「二:注入一个对象到MyApp里:」 有个类定义如下: classSoftware{ valname="fish" } 我们不想显示的构造它,想借助Hilt注入它,那得先告诉Hilt这个类你帮我注入一下,改为如下代码: classSoftware@Injectconstructor(){ valname="fish" } 在构造函数前添加了@Inject注解,表示该类可以被注入。 而在MyApp里使用Software对象: @HiltAndroidApp classMyApp:Application(){ @Inject lateinitvarsoftware:Software overridefunonCreate(){ super.onCreate() println("injectresult:${software.name}") } } 对引用的对象使用@Inject注解,表示期望Hilt帮我将这个对象new出来。 最后查看打印输出正确,说明Software对象被创建了。 这是最简单的Hilt应用,可以看出: ?我们并没有显式地创建Software对象,而Hilt在适当的时候就帮我们创建好了@HiltAndroidApp 只用于修饰Application?如何注入接口?「一:错误示范」上面提到过,使用DI的好处之一就是解耦,而我们上面注入的是类,现在我们将Software抽象为接口,很容易就会想到如下写法: interfaceISoftware{ funprintName() } classSoftwareImpl@Injectconstructor():ISoftware{ overridefunprintName(){ println("nameisfish") } } @HiltAndroidApp classMyApp:Application(){ @Inject lateinitvarsoftware:ISoftware overridefunonCreate(){ super.onCreate() println("injectresult:${software.printName()}") } } 不幸的是上述代码编译失败,Hilt提示说不能对接口使用注解,因为我们并没有告诉Hilt是谁实现了ISoftware,而接口本身不能直接实例化,因此我们需要为它指定具体的实现类。 「二:正确示范」 再定义一个类如下: @Module @InstallIn(SingletonComponent::class) abstractclassSoftwareModule{ @Binds abstractfunbindSoftware(impl:SoftwareImpl):ISoftware } ?@Module 表示该类是一个Hilt的Module,固定写法@InstallIn 表示模块在哪个组件生命周期内生效,SingletonComponent::class指的是全局一个抽象类,类名随意抽象方法,方法名随意,返回值是需要被注入的对象类型(接口),而参数是该接口的实现类,使用@Binds注解标记,?如此一来我们就告诉了Hilt,SoftwareImpl是ISoftware的实现类,于是Hilt注入ISoftware对象的时候就知道使用SoftwareImpl进行实例化。其它不变运行一下: 可以看出,实际注入的是SoftwareImpl。 ?@Binds 适用在我们能够修改类的构造函数的场景 ?如何注入第三方类上面的SoftwareImpl是我们可以修改的,因为使用了@Inject修饰其构造函数,所以可以在其它地方注入它。 在一些时候我们不想使用@Inject修饰或者说这个类我们不能修改,那该如何注入它们呢? 「一:定义Provides模块」 @Module @InstallIn(SingletonComponent::class) objectHardwareModule{ @Provides funprovideHardware():Hardware{ returnHardware() } } ?@Module和@InstallIn 注解是必须的定义object类定义函数,方法名随意,返回类型为我们需要注入的类型函数体里通过构造或是其它方式创建具体实例使用@Provides注解函数?「二:依赖使用」 而Hardware定义如下: classHardware{ funprintName(){ println("I'mfish") } } 在MyApp里引用Hardware: 虽然Hardware构造函数没有使用@Inject注解,但是我们依然能够使用依赖注入。 当然我们也可以注入接口: interfaceIHardware{ funprintName() } classHardwareImpl:IHardware{ overridefunprintName(){ println("nameisfish") } } 想要注入IHardware接口,需要定义provides模块: @Module @InstallIn(SingletonComponent::class) objectHardwareModule{ @Provides funprovideHardware():IHardware{ returnHardwareImpl() } } ?@Provides适用于无法修改类的构造函数的场景,多用于注入第三方的对象 ?3. Hilt 的进阶使用限定符上述 ISoftware的实现类只有一个,假设现在有两个实现类呢? 比如说这些软件可以是美国提供,也可以是中国提供的,依据上面的经验我们很容易写出如下代码: classSoftwareChina@Injectconstructor():ISoftware{ overridefunprintName(){ println("fromchina") } } classSoftwareUS@Injectconstructor():ISoftware{ overridefunprintName(){ println("fromUS") } } @Module @InstallIn(SingletonComponent::class) abstractclassSoftwareModule{ @Binds abstractfunbindSoftwareCh(impl:SoftwareChina):ISoftware @Binds abstractfunbindSoftwareUs(impl:SoftwareUS):ISoftware } //依赖注入: @Inject lateinitvarsoftware:ISoftware 兴高采烈的进行编译,然而却报错: 也就是说Hilt想要注入ISoftware,但不知道选择哪个实现类,SoftwareChina还是SoftwareUS?没人告诉它,所以它迷茫了,索性都绑定了。 这个时候我们需要借助注解:@Qualifier 限定符注解来对实现类进行限制。 改造一下: @Module @InstallIn(SingletonComponent::class) abstractclassSoftwareModule{ @Binds @China abstractfunbindSoftwareCh(impl:SoftwareChina):ISoftware @Binds @US abstractfunbindSoftwareUs(impl:SoftwareUS):ISoftware } @Qualifier @Retention(AnnotationRetention.BINARY) annotationclassUS @Qualifier @Retention(AnnotationRetention.BINARY) annotationclassChina 定义新的注解类,使用@Qualifier修饰。 而后在Module里,分别使用注解类修饰返回的函数,如bindSoftwareCh函数指定返回SoftwareChina来实现ISoftware接口。 最后在引用依赖注入的地方分别使用@China @US修饰。 @Inject @US lateinitvarsoftware1:ISoftware @Inject @China lateinitvarsoftware2:ISoftware 此时,虽然software1、software2都是ISoftware类型,但是由于我们指定了限定符@US、@China,因此最后真正的实现类分别是SoftwareChina、SoftwareUS。 ?@Qualifier 主要用在接口有多个实现类(抽象类有多个子类)的注入场景 ?预定义限定符上面提及的限定符我们还可以扩展其使用方式。 你可能发现了,上述提及的可注入的类构造函数都是无参的,很多时候我们的构造函数是需要有参数的,比如: classSoftware@Injectconstructor(valcontext:Context){ valname="fish" fungetWindowService():WindowManager?{ returncontext.getSystemService(Context.WINDOW_SERVICE)as?WindowManager } } //注入 @Inject lateinitvarsoftware:Software 这个时候编译会报错: 意思是Software依赖的Context没有进行注入,因此我们需要给它注入一个Context。 由上面的分析可知,Context类不是我们可以修改的,只能通过@Provides方式提供其注入实例,并且Context有很多子类,我们需要使用@Qualifier指定具体实现类,因此很容易我们就想到如下对策。 先定义Module: @Module @InstallIn(SingletonComponent::class) objectMyContextModule{ @Provides @GlobalContext funprovideContext():Context?{ returnMyApp.myapp } } @Qualifier @Retention(AnnotationRetention.BINARY) annotationclassGlobalContext 再注入Context: classSoftware@Injectconstructor(@GlobalContextvalcontext:Context?){ valname="fish" fungetWindowService():WindowManager?{ returncontext?.getSystemService(Context.WINDOW_SERVICE)as?WindowManager } } 可以看出,借助@Provides和@Qualifier,可以实现全局的Context。 当然了,实际上我们无需如此麻烦,因为这部分工作Hilt已经预先帮我们弄了。 与我们提供的限定符注解GlobalContext类似,Hilt预先提供了: @Qualifier @Target({ElementType.METHOD,ElementType.PARAMETER,ElementType.FIELD}) public@interfaceApplicationContext{} 因此我们只需要在需要的地方引用它即可: classSoftware@Injectconstructor(@ApplicationContextvalcontext:Context?){ valname="fish" fungetWindowService():WindowManager?{ returncontext?.getSystemService(Context.WINDOW_SERVICE)as?WindowManager } } 如此一来我们无需重新定义Module。 ?除了提供Application级别的上下文:@ApplicationContext,Hilt还提供了Activity级别的上下文:@ActivityContext,因为是Hilt内置的限定符,因此称为预定义限定符。如果想自己提供限定符,可以参照GlobalContext的做法。?组件作用域和生命周期Hilt支持的注入点(类)以上的demo都是在MyApp里进行依赖,MyApp里使用了注解:@HiltAndroidApp 修饰,表示当前App支持Hilt依赖,Application就是它支持的一个注入点,现在想要在Activity里使用Hilt呢? @AndroidEntryPoint classSecondActivity:AppCompatActivity(){ 除了Application和Activity,Hilt内置支持的注入点如下: 除了Application和ViewModel,其它注入点都是通过使用@AndroidEntryPoint修饰。 ?注入点其实就是依赖注入开始的点,比如Activity里需要注入A依赖,A里又需要注入B依赖,B里又需要注入C依赖,从Activity开始我们就能构建所有的依赖 ?Hilt组件的生命周期什么是组件?在Dagger时代我们需要自己写组件,而在Hilt里组件都是自动生成的,无需我们干预。依赖注入的本质实际上就是在某个地方悄咪咪地创建对象,这个地方的就是组件,Hilt专为Android打造,因此势必适配了Android的特性,比如生命周期这个Android里的重中之重。 因此Hilt的组件有两个主要功能: ?创建、注入依赖的对象管理对象的生命周期?Hilt组件如下: 可以看出,这些组件的创建和销毁深度绑定了Android常见的生命周期。 你可能会说:上面貌似没用到组件相关的东西,看了这么久也没看懂啊。 继续看个例子: @Module @InstallIn(SingletonComponent::class) objectHardwareModule{ @Provides funprovideHardware():IHardware{ returnHardwareImpl() } } @InstallIn(SingletonComponent::class) 表示把模块安装到SingletonComponent组件里, SingletonComponent组件顾名思义是全局的,对应的是Application级别。因此安装的这个模块可在整个App里使用。 「问题来了:SingletonComponent是不是表示@Provides修饰的函数返回的实例是同一个?」 答案是否定的。 这就涉及到组件的作用域。 组件的作用域想要上一小结的代码提供全局唯一实例,则可用组件作用域注解修饰函数: @Module @InstallIn(SingletonComponent::class) objectHardwareModule{ @Provides @Singleton funprovideHardware():IHardware{ returnHardwareImpl() } } 当我们在任何地方注入IHardware时,获取到的都是同一个实例。 除了@Singleton表示组件的作用域,还有其它对应组件的作用域: 简单解释作用域: @Singleton 被它修饰的构造函数或是函数,返回的始终是同一个实例 @ActivityRetainedScoped 被它修饰的构造函数或是函数,在Activity的重建前后返回同一实例 @ActivityScoped 被它修饰的构造函数或是函数,在同一个Activity对象里,返回的都是同一实例 @ViewModelScoped 被它修饰的构造函数或是函数,与ViewModel规则一致 ?Hilt默认不绑定任何作用域,由此带来的结果是每一次注入都是全新的对象组件的作用域要么不指定,要指定那必须和组件的生命周期一致?以下几种写法都不符合第二种限制: @Module @InstallIn(SingletonComponent::class) objectHardwareModule{ @Provides @ActivityScoped//错误,和组件的作用域不一致 funprovideHardware():IHardware{ returnHardwareImpl() } } @Module @InstallIn(ActivityComponent::class) objectHardwareModule{ @Provides @Singleton//错误,和组件的作用域不一致 funprovideHardware():IHardware{ returnHardwareImpl() } } @Module @InstallIn(ActivityRetainedComponent::class) objectHardwareModule{ @Provides @ActivityScoped//错误,和组件的作用域不一致 funprovideHardware():IHardware{ returnHardwareImpl() } } 除了修饰Module,作用域还可以用于修饰构造函数: @ActivityScoped classHardware@Injectconstructor(){ funprintName(){ println("I'mfish") } } @ActivityScoped表示不管注入几个Hardware,在同一个Activity里注入的实例都是一致的。 构造函数里无法注入的字段一个类的构造函数如果被@Inject注入,那么构造函数的其它参数都需要支持注入。 classHardware@Injectconstructor(valcontext:Context){ funprintName(){ println("I'mfish") } } 以上代码是无法编译通过的,因为Context不支持注入,而通过上面的分析可知,我们可以使用限定符: classHardware@Injectconstructor(@ApplicationContextvalcontext:Context){ funprintName(){ println("I'mfish") } } 这就可以成功注入了。 再看看此种场景: classHardware@Injectconstructor( @ApplicationContextvalcontext:Context, valversion:String, ){ funprintName(){ println("I'mfish") } } 很显然String不支持注入,当然我们可以向@ApplicationContext 一样也给String提供一个@Provides和@Qualifier注解,但可想而知很麻烦,关键是String是动态变化的,我们确实需要Hardware构造的时候传入合适的String。 「由此引入新的写法:辅助注入」 classHardware@AssistedInjectconstructor( @ApplicationContextvalcontext:Context, @Assisted valversion:String, ){ //辅助工厂类 @AssistedFactory interfaceFactory{ //不支持注入的参数都可以放这,返回值为待注入的类型 funcreate(version:String):Hardware } funprintName(){ println("I'mfish") } } 在引用注入的地方不能直接使用Hardware,而是需要通过辅助工厂进行创建: @AndroidEntryPoint classSecondActivity:AppCompatActivity(){ privatelateinitvarbinding:ActivitySecondBinding @Inject lateinitvarhardwareFactory:Hardware.Factory overridefunonCreate(savedInstanceState:Bundle?){ super.onCreate(savedInstanceState) binding=ActivitySecondBinding.inflate(layoutInflater) setContentView(binding.root) valhardware=hardwareFactory.create("3.3.2") println("${hardware.printName()}") } } 如此一来,通过辅助注入,我们还是可以使用Hilt,值得一提的是辅助注入不是Hilt独有,而是从Dagger继承来的功能。 自定义注入点Hilt仅仅内置了常用的注入点:Application、Activity、Fragment、ViewModel等。 思考一种场景:小明同学写的模块都是需要注入: classHardware@Injectconstructor( valgpu:GPU, valcpu:CPU, ){ funprintName(){ println("I'mfish") } } classGPU@Injectconstructor(valvideoStorage:VideoStorage){} //显存 classVideoStorage@Injectconstructor(){} classCPU@Injectconstructor(valregister:Register){} //寄存器 classRegister@Inject()constructor(){} 此时小刚需要引用Hardware,他有两种选择: ?使用注入方式很容易就引用了Hardware,可惜的是他没有注入点,仅仅只是工具类。不选注入方式,则需要构造Hardware实例,而Hardware依赖GPU和CPU,它们又分别依赖VideoStorage和Register,想要成功构造Hardware实例需要将其它的依赖实例都手动构造出来,可想而知很麻烦。?这个时候适合小刚的方案是: ?自定义注入点 ?方案实施步骤: 「一:定义入口点」 @InstallIn(SingletonComponent::class) interface HardwarePoint { //该注入点负责返回Hardware实例 fun getHardware(): Hardware } 「二:通过入口点获取实例」 classXiaoGangPhone{ fungetHardware(context:Context):Hardware{ valentryPoint=EntryPointAccessors.fromApplication(context,HardwarePoint::class.java) returnentryPoint.getHardware() } } 「三:使用Hardware」 valhardware=XiaoGangPhone().getHardware(this) println("${hardware.printName()}") 注入object类定义了object类,但在注入的时候也需要,可以做如下处理: objectMySystem{ fungetSelf():MySystem{ returnthis } funprintName(){ println("I'mfish") } } @Module @InstallIn(SingletonComponent::class) objectMiddleModule{ @Provides @Singleton funprovideSystem():MySystem{ returnMySystem.getSelf() } } //使用注入 classMiddleware@Injectconstructor( valmySystem:MySystem ){ } 4. Hilt 原理简单分析@AndroidEntryPoint classSecondActivity:AppCompatActivity(){} Hilt通过apt在编译时期生成代码: publicabstractclassHilt_SecondActivityextendsAppCompatActivityimplementsGeneratedComponentManagerHolder{ privatebooleaninjected=false; Hilt_SecondActivity(){ super(); //初始化注入监听 _initHiltInternal(); } Hilt_SecondActivity(intcontentLayoutId){ super(contentLayoutId); _initHiltInternal(); } privatevoid_initHiltInternal(){ addOnContextAvailableListener(newOnContextAvailableListener(){ @Override publicvoidonContextAvailable(Contextcontext){ //真正注入 inject(); } } protectedvoidinject(){ if(!injected){ injected=true; //通过manager获取组件,再通过组件注入 ((SecondActivity_GeneratedInjector)this.generatedComponent()).injectSecondActivity(UnsafeCasts.SecondActivityunsafeCast(this)); } } } 在编译期,SecondActivity的父类由AppCompatActivity变为Hilt_SecondActivity,因此当SecondActivity构造时就会调用父类的构造器监听create()的回调,回调调用时进行注入。 ?由此可见,Activity.onCreate()执行后,Hilt依赖注入的字段才会有值 ?真正注入的过程涉及到不少的类,都是自动生成的类,有兴趣可以对着源码查找流程,此处就不展开说了。 5. Android到底该不该使用DI框架?有人说DI比较复杂,还不如我直接构造呢? 又有人说那是你项目不复杂,用不到,在后端流行的Spring全家桶,依赖注入大行其道,Android复杂的项目也需要DI来解耦。 从个人的实践经验看,Android MVVM/MVI 模式还是比较适合引入Hilt的。 摘抄官网的:现代Android 应用架构 通常来说我们这么设计UI层到数据层的架构: classMyViewModel@Injectconstructor( valrepository:LoginRepository ):ViewModel(){} classLoginRepository@Injectconstructor( valrds:RemoteDataSource, vallds:LocalDataSource ){} //远程来源 classRemoteDataSource@Injectconstructor( valmyRetrofit:MyRetrofit ){} classMyRetrofit@Injectconstructor( ){} //本地来源 classLocalDataSource@Injectconstructor( valmyDataStore:MyDataStore ){} classMyDataStore@Injectconstructor(){} 可以看出,层次比较深,使用了Hilt简洁了许多。 本文基于 Hilt 2.48.1 参考文档: https://dagger.dev/hilt/gradle-setup https://developer.android.com/topic/architecture/recommendations?hl=zh-cn https://repo.maven.apache.org/maven2/com/google/dagger/hilt/android/com.google.dagger.hilt.android.gradle.plugin/ 如果文章对你有帮助的话欢迎「关注+点赞+收藏」 阅读原文

上一篇:2021-03-11_公开处刑:研究者自建Papers Without Code网站,张贴复现不了的论文 下一篇:2020-10-30_2021年泰晤士世界大学学科排名公布!清北均有学科位居世界前10

TAG标签:

18
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设网站改版域名注册主机空间手机网站建设网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。
项目经理在线

相关阅读 更多>>

猜您喜欢更多>>

我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!

不达标就退款

高性价比建站

免费网站代备案

1对1原创设计服务

7×24小时售后支持

 

全国免费咨询:

13245491521

业务咨询:13245491521 / 13245491521

节假值班:13245491521()

联系地址:

Copyright © 2019-2025      ICP备案:沪ICP备19027192号-6 法律顾问:律师XXX支持

在线
客服

技术在线服务时间:9:00-20:00

在网站开发,您对接的直接是技术员,而非客服传话!

电话
咨询

13245491521
7*24小时客服热线

13245491521
项目经理手机

微信
咨询

加微信获取报价