学习ASP.NET Core, 怎能不了解请求处理管道[4]: 应用的入口——Startup
一个ASP.NET Core应用被启动之后就具有了针对请求的处理能力,而这个能力是由管道赋予的,所以应用的启动同时意味着管道的成功构建。由于管道是由注册的服务器和若干中间件构成的,所以应用启动过程中一个核心的工作就是完成中间节的注册。由于依赖注入在ASP.NET Core应用这得到非常广泛的应用,框架绝大部分的工作都会分配给我们预先注册的服务,所以服务注册也是启动WebHost过程的另一项核心工作。这两项在启动过程中必须完成的核心工作通过一个名为Startup的对象来承载。 [本文已经同步到《ASP.NET Core框架揭秘》之中]
目录
一、 DelegateStartup
二、ConventionBasedStartup
StartupMethods
StartupLoader
如何选择启动类型
如何选择服务注册方法和中间件注册方法
StartupMethods对象的创建
UseStartup方法究竟做了些什么?
三、选择哪一个Startup
这里所谓的Startup实际上是对所有实现了IStartup接口的所有类型以及对应对象的统称。如下面的代码片段所示,服务注册由ConfigureServices方法来实现,它返回一个ServiceProvider对象,至于另一个方法Configure则负责完成中间件的注册,方法输入参数是一个ApplicationBuilder对象。
- 1: public interface IStartup
- 2: {
- 3: IServiceProvider ConfigureServices(IServiceCollection services);
- 4: void Configure(IApplicationBuilder app);
- 5: }
IStartup接口所在的NuGet包中还定义了另一个实现了这个接口的抽象类StartupBase。如下面的代码片段所示,StartupBase实现了抽象方法ConfigureServices,该方法直接利用提供的ServiceCollection对象创建返回的ServiceProvider。换句话说,派生于StartupBase的Startup类型如果没用重写ConfigureServices方法,它们实际上只关心中间件的注册,而不需要注册额外的服务。
- 1: public abstract class StartupBase : IStartup
- 2: {
- 3: public abstract void Configure(IApplicationBuilder app);
- 4: public virtual IServiceProvider ConfigureServices(IServiceCollection services)
- 5: {
- 6: return services.BuildServiceProvider();
- 7: }
- 8: }
一、 DelegateStartup
我们来想想具体的应用中是如何注册中间件和服务的。中间件的注册可以采用两种方式,最简单的方式就是直接调用IWebHostBuilder的Configure方法。如下面的代码片段所示,这个方法直接上是借助于一个类型为Action<IApplicationBuilder>的委托对象将中间件注册到提供的ApplicationBuilder对象上。
- 1: public static class WebHostBuilderExtensions
- 2: {
- 3: public static IWebHostBuilder Configure(this IWebHostBuilder hostBuilder, Action<IApplicationBuilder> configureApp);
- 4: }
- 5:
- 6: new WebHostBuilder()
- 7: .Configure(app => app
- 8: .UseExceptionHandler("/Home/Error")
- 9: .UseStaticFiles()
- 10: .UseIdentity()
- 11: .UseMvc())
- 12: …
如果我们在应用中通过调用上面这个Configure方法来注册所需的中间件,WebHost在启动的时候会创建一个类型为DelegateStartup的Startup对象来完成真正的中间件注册工作。如下面的代码片段所示,DelegateStartup派生于StartupBase这个抽象类,它利用一个在构造时提供的Action<IApplicationBuilder>对象实现了Configure方法。很明显,我们调用IWebHostBuilder的Configure方法指定的Action<IApplicationBuilder>对象将用来创建这个DelegateStartup对象。
- 1: public class DelegateStartup : StartupBase
- 2: {
- 3: private Action<IApplicationBuilder> _configureApp;
- 4:
- 5: public DelegateStartup(Action<IApplicationBuilder> configureApp)
- 6: {
- 7: _configureApp = configureApp;
- 8: }
- 9:
- 10: public override void Configure(IApplicationBuilder app)
- 11: {
- 12: _configureApp(app);
- 13: }
- 14: }
如下的代码片段体现了 IWebHostBuilder的扩展方法Configure的实现逻辑。如下面的代码片段所示,这个方法根据提供的Action<IApplicationBuilder>对象创建了一个DelegateStartup对象,并调用ConfigureServices方法以淡例模式注册到WebHostBuilder的服务集合中。这段代码还体现了另一个细节,除了进行Startup的服务注册之外,该方法还对“ApplicationName”选项(对应WebHostOptions的ApplicationName)进行了设置。
- 1: public static class WebHostBuilderExtensions
- 2: {
- 3: public static IWebHostBuilder Configure(this IWebHostBuilder hostBuilder, Action<IApplicationBuilder> configureApp)
- 4: {
- 5: var startupAssemblyName = configureApp.GetMethodInfo().DeclaringType.GetTypeInfo().Assembly.GetName().Name;
- 6: return hostBuilder
- 7: .UseSetting("applicationName", startupAssemblyName)
- 8: .ConfigureServices(svcs => svcs.AddSingleton<IStartup>(new DelegateStartup(configureApp));
- 9: }
- 10: }
二、ConventionBasedStartup
我们知道应用中最常见的服务和中间件注册代码都定义在一个单独的类中,通常直接将其命名为Startup。为了与IStartup接口代表的Startup相区别,我们使用 “启动类(型)” 来称呼这个类。按照约定,启动类中会分别定义一个ConfigureServices和Configure方法来注册服务和中间件。一般情况下,这样的类型一般需要通过调用UseStartup<T>这个扩展方法注册到WebHostBuilder上。
- 1: public class Startup
- 2: {
- 3: public void ConfigureServies(IServiceCollection services);
- 4: public void Configure(IApplicationBuilder app);
- 5: }
- 6:
- 7: new WebHostBuilder()
- 8: .UseStartup<Startup>()
- 9: …
如果我们在应用中将服务和中间件注册的实现定义在启动类型中,当WebHost被启动的时候,ASP.NET Core会创建一个类型为ConventionBasedStartup的Startup对象。这个Startup类型之所以采用这样的命名方式,是因为ASP.NET Core并没有采用接口实现的方式为启动类型做强制性的约束,而仅仅是为作为启动类型的定义提供了一个约定而已,至于具体采用怎样的约定,我们将在后续部分进行详细介绍。
StartupMethods
在了解了启动类型的约定以及常见的定义形式之外,我们现在来讨论这对这个类型创建的ConventionBasedStartup就是怎样的对象。从下面的代码片段可以看出,一个ConventionBasedStartup对象是根据一个类型为StartupMethods对象创建的。顾名思义,StartupMethods只在提供两个用户注册服务和中间件的方法,这两个方法体现在由它的两个属性(ConfigureServicesDelegate和ConfigureDelegate)提供的两个委托对象,这两个委托对象分别实现了定义在ConventionBasedStartup的ConfigureServices和Configure方法。
- 1: public class ConventionBasedStartup : IStartup
- 2: {
- 3: public ConventionBasedStartup(StartupMethods methods);
- 4: public IServiceProvider ConfigureServices(IServiceCollection services);
- 5: public void Configure(IApplicationBuilder app);
- 6: }
- 7:
- 8: public class StartupMethods
- 9: {
- 10: public Func<IServiceCollection, IServiceProvider> ConfigureServicesDelegate { get; }
- 11: public Action<IApplicationBuilder> ConfigureDelegate { get; }
- 12:
- 13: public StartupMethods(Action<IApplicationBuilder> configure);
- 14: public StartupMethods(Action<IApplicationBuilder> configure, Func<IServiceCollection, IServiceProvider> configureServices);
- 15: }
StartupLoader
既然ConventionBasedStartup对象是根据提供的一个StartupMethods对象创建的,那么现在的核心问题则变成了这个StartupMethods对象如何根据启动类型创建的。StartupMethods的创建者是一个类型为StartupLoader的对象,如下面的代码片段所示,StartupLoader定了两个名为FindStartupType和LoadMethods静态方法,前者用于启动类型的解析,后者则实现了StartupMethods对象的创建。
- 1: public class StartupLoader
- 2: {
- 3: public static Type FindStartupType(string startupAssemblyName, string environmentName);
- 4: public static StartupMethods LoadMethods(IServiceProvider services, Type startupType, string environmentName);
- 5: }
如何选择启动类型
如果启动类型没有通过调用WebHostBuilder的如下两个扩展方法UseStartup/UseStartup<TStartup>的显式注册,那么StartupLoader的FindStartupType方法会被调用来解析出正确的启动类型。这个方法具有两个参数,分别代表启动类型所在的程序集名称和当前环境名称,它们实际上对应着WebHostOptions的两个同名属性。当FindStartupType方法被执行并成功加载了提供的程序集之后,它会按照约定的启动类型全名从该程序集中加载启动类型,候选的启动类型全名按照选择优先级排列如下:
- Startup{EnvironmentName} (无命名空间)
- {StartupAssemblyName}.Startup{EnvironmentName}
- Startup(无命名空间)
- {StartupAssemblyName}.Startup{EnvironmentName}
- **. Startup{EnvironmentName}(任意命名空间)
- **. Startup(任意命名空间)
这个列表体现了启动类型解析过程中选择有效类型名称的一个基本策略,即“环境名称优先”和“无命名空间优先”。我们可以通过一个简单的实例来证明这个策略的存在。我们在一个ASP.NET Core控制台应用中添加一个名为“StartupLib”(程序集也采用这个名称)的类库项目,然后在这个项目中定义如下两组启动类,其中一组具有命名空间,另一组则采用程序集名称作为命名空间。这些启动类都派生于我们自定义的基类StartupBase,后者的Configure方法中注册了一个中间件将自身的类型作为响应内容。对于每组中的三个启动类,一个命名为Startup,另外两个则分别以环境名称( “Development” 和 “Production” )作为后缀。
- 1: public class StartupBase
- 2: {
- 3: public void ConfigureServices(IServiceCollection services){}
- 4: public void Configure(IApplicationBuilder app)
- 5: {
- 6: app.Run(async context => await context.Response.WriteAsync(this.GetType().FullName));
- 7: }
- 8: }
- 9:
- 10: public class Startup : StartupBase{}
- 11: public class StartupDevelopment : StartupBase{}
- 12: public class StartProduction : StartupBase{}
- 13:
- 14: namespace StartupLib
- 15: {
- 16: public class Startup : StartupBase{}
- 17: public class StartupDevelopment : StartupBase{}
- 18: public class StartProduction : StartupBase{}
- 19: }
我们采用如下的程序来启动一个ASP.NET Core应用。如下面的代码代码片段所示,我们在利用WebHostBuilder创建并启动WebHost之前,调用UseSettings方法以配置的形式指定了启动程序集(“StartupLib”)和当前运行环境(“Development”)的名称。
- 1: public class Program
- 2: {
- 3: public static void Main()
- 4: {
- 5: new WebHostBuilder()
- 6: .UseKestrel()
- 7: .UseSetting("startupAssembly", "StartupLib")
- 8: .UseSetting("environment", "Development")
- 9: .Build()
- 10: .Run();
- 11: }
- 12: }
根据上述的启动类型解析规则,对于六个候选的启动类型,最终被选择的是不具有命名空间的StartupDevelopment类型。当应用启动之后,我们利用浏览器请求应用监听地址(“http://localhost:5000”),这个被选择的启动程序的名称将会以如下的形式直接显示出来。
如何选择服务注册方法和中间件注册方法
在了解了ASP.NET Core针对启动类型命名的约定之后,我们来讨论一下定义在启动类中用于注册服务和中间件的两个方法的约定。这两个方法可以是静态方法,也可以是实例方法。从方法命名来看,这两个方法除了命名为“ConfigureServices”和“Configure”之外,方法名还可以携带运行环境名称,具体采用的格式分别为“Configure{EnvironmentName}Services”和“Configure{EnvironmentName}”,后者具有更高的选择优先级。
ConfigureServices/Configure{EnvironmentName}Services方法具有一个类型为IServiceCollection接口的参数,表示存放注册服务的ServiceCollection对象。如过这个方法没有定义任何参数,它依然是合法的。一般来说,这个方法不具有返回值(返回类型为void),但是它也可以定义成一个返回类型为IServiceProvider的方法。如果这个方法返回一个ServiceProvider对象,后续过程中获取的所有服务将从这个ServiceProvider中提取。对于没有返回值的情况,系统会根据当前注册的服务创建一个ServiceProvider。
Configure/Configure{EnvironmentName}方法只要求只要求第一个参数类型采用IApplicationBuilder接口,至于这个方法可以包含多少个参数,各个参数应该具有怎样的类型,并未做任何规定。实际上我们为这个方法定义任意后续参数都是合法的。当ConventionBasedStartup在调用这个方法的时候,同样是采用依赖注入的方式来提供这些参数。如下面的代码片段所示,我们为启动类的Configure方法定义相应的参数来直接使用在ConfigureServices方法上注册的三个服务。
- 1: new WebHostBuilder()
- 2: .ConfigureServices(services => services.AddSingleton<IFoobar, Foobar>())
- 3: …
- 4:
- 5: public class Startup
- 6: {
- 7: public Startup(IFoobar foobar)
- 8: {
- 9: Debug.Assert(foobar.GetType() == typeof(Foobar));
- 10: }
- 11: public void ConfigureServies(IServiceCollection services) ;
- 12: public void Configure(IApplicationBuilder app) ;
- 13: }
StartupMethods对象的创建
除此之外,对于定义成实例类型的启动类,我们并不要求它具有一个默认无参的构造函数。如果构造函数具有参数,ConventionBasedStartup在实例化的时候会采用构造函数注入的方式来提供构造函数的参数。至于提供参数所用的ServiceProvider,就是WebHostBuilder在创建WebHost对象时作为构造函数参数提供的那个ServiceProvider。如下面的代码片段所示,我们利用WebHostBuilder创建并启动WebHost之前,调用他的ConfigureServices方法针对接口IFoobar注册了一个服务,那么注册为启动类的Startup类可以在构造函数中以注入的形式使用这个服务对象。
- 1: public class StartupLoader
- 2: {
- 3: public static StartupMethods LoadMethods(IServiceProvider serviceProvider, Type startupType, string environmentName)
- 4: {
- 5: return new StartupMethods(BuildConfigureDelegate(serviceProvider, startupType, environmentName),
- 6: BuildConfigureServicesDelegate(serviceProvider, startupType, environmentName));
- 7: }
- 8:
- 9: private static Func<IServiceCollection, IServiceProvider> BuildConfigureServicesDelegate(IServiceProvider serviceProvider, Type startupType, string environmentName)
- 10: {
- 11: MethodInfo method = FindMethod(startupType, $"Configure{environmentName}Services", "ConfigureServices");
- 12: object instance = method.IsStatic ? null : ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, startupType);
- 13: return services =>
- 14: {
- 15: object[] arguments = method.GetParameters().Length > 0 ? new object[] { services } : new object[0];
- 16: return (method.Invoke(instance, arguments) as IServiceProvider) ?? services.BuildServiceProvider();
- 17: };
- 18: }
- 19:
- 20: private static Action<IApplicationBuilder> BuildConfigureDelegate(IServiceProvider serviceProvider, Type startupType, string environmentName)
- 21: {
- 22: MethodInfo method = FindMethod(startupType, $"Configure{environmentName}", "Configure");
- 23: object instance = method.IsStatic ? null : ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, startupType);
- 24: object[] arguments = method.GetParameters().Select(p => serviceProvider.GetService(p.ParameterType)).ToArray();
- 25: return app =>
- 26: {
- 27: arguments[0] = app;
- 28: method.Invoke(instance, arguments);
- 29: };
- 30: }
- 31:
- 32: private static MethodInfo FindMethod(Type startupType, string method1, string method2)
- 33: {
- 34: BindingFlags bindAttribute = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static;
- 35: return startupType.GetMethod(method1, bindAttribute)?? startupType.GetMethod(method2, bindAttribute);
- 36: }
- 37: }
UseStartup方法究竟做了些什么?
当我们调用IWebHostBuilder接口的扩展方法UseStartup/UseStartup<TStartup>注册某个启动类的时候,该方法会按照如下的形式创建一个ConventionBasedStartup对象并注册到WebHostBuilder的服务集合上。和上面介绍的Configure方法一样,UseStartup方法同样会设置 “ApplicationName” 选项。除此之外,这段还体现了另一个细节,那就是如果我们直接定义一个实现了IStartup接口的启动类,UseStartup方法会直接注册这个类型,而不会再多此一举地创建一个ConventionBasedStartup对象。
- 1: public static class WebHostBuilderExtensions
- 2: {
- 3: public static IWebHostBuilder UseStartup<TStartup>(this IWebHostBuilder hostBuilder) where TStartup : class
- 4: {
- 5: return UseStartup(hostBuilder, typeof(TStartup));
- 6: }
- 7:
- 8: public static IWebHostBuilder UseStartup(this IWebHostBuilder hostBuilder, Type startupType)
- 9: {
- 10: var startupAssemblyName = startupType.GetTypeInfo().Assembly.GetName().Name;
- 11: return hostBuilder
- 12: .UseSetting("ApplicationName", startupAssemblyName)
- 13: .ConfigureServices(svcs =>
- 14: {
- 15: if (typeof(IStartup).IsAssignableFrom(startupType))
- 16: {
- 17: svcs.AddSingleton(typeof(IStartup), startupType);
- 18: }
- 19: else
- 20: {
- 21: svcs.AddSingleton<IStartup>(sp => new ConventionBasedStartup(StartupLoader.LoadMethods(sp, startupType, sp.GetService<IHostingEnvironment>().EnvironmentName)));
- 22: }
- 23: });
- 24: }
- 25: }
三、选择哪一个Startup
应用启动的时候,Startup帮助我们完成所需服务和中间件的注册,而Startup对象自身也是服务的形式被注册到WebHostBuilder或者WebHost的服务集合中。总的来说,Startup的注册具有如下三种途径:
- 调用IWebHostBuilder的扩展方法Configure方法创建并注册一个DelegateStartup对象。
- 调用IWebHostBuilder的扩展方法UseStartup或者UseStartup<TStartup>创建并注册一个ConventionBasedStartup对象。
- 如果设置了启动程序集名称(对应WebHostOptions的StartupAssembly属性)并且对应的程序集中存在一个满足约定的启动类型,也会创建并注册一个ConventionBasedStartup对象。
那么现在的问题来说,如果我们采用上述这三种途径创建并注册了多个Startup,那么系统是只选择其中一个,还是所有的Startup均有效呢?就如下这段程序来说,如果当前程序集同时定义了三个有效的Startup类型(Startup、Startup1和Startup2),最终将会有五个Startup对象被注册,其中两个是通过Configure方法注册的DelegateStartup对象,对于额外三个ConventionBasedStartup对象来说, 其中两个针对显式指定的启动类型(Startup1和Startup2),另外一个则是针对默认的约定解析出来的启动类型(Startup)。对于这个五个Startup对象,究竟哪些是有效的呢?
- 1: new WebHostBuilder()
- 2: .Configure(app => {})
- 3: .Configure(app => {})
- 4: .UseStartup<Startup1>()
- 5: .UseStartup<Startup2>()
- 6: .UseSetting("startupAssembly", Assembly.GetEntryAssembly().FullName)
- 7: …
不论我们注册多少个Startup,系统最终都只会其中一个来注册服务和中间件。由于WebHost会直接利用ServiceProvider来获取Startup对象,根据 “后来居上” 的原则,最终选择的总是最后注册的那个Startup。由于Configure方法和UseStartup方法最终都是调用WebHostBuilder的ConfigureServices方法进行服务注册,所以最后调用的方法具有最高的优先级。至于根据指定启动程序集名称而创建出来的ConventionBasedStartup,针对它的注册信息会放在最前面,所以具有最低优先级。根据这个策略,上面这段程序最终选择的启动类是Startup2。
学习ASP.NET Core, 怎能不了解请求处理管道[4]: 应用的入口——Startup的更多相关文章
- 学习ASP.NET Core, 怎能不了解请求处理管道[6]: 管道是如何随着WebHost的开启被构建出来的?
注册的服务器和中间件共同构成了ASP.NET Core用于处理请求的管道, 这样一个管道是在我们启动作为应用宿主的WebHost时构建出来的.要深刻了解这个管道是如何被构建出来的,我们就必须对WebH ...
- 学习ASP.NET Core, 怎能不了解请求处理管道[5]: 中间件注册可以除了可以使用Startup之外,还可以选择StartupFilter
中间件的注册除了可以借助Startup对象(DelegateStartup或者ConventionBasedStartup)来完成之外,也可以利用另一个叫做StartupFilter的对象来实现.所谓 ...
- 学习ASP.NET Core, 怎能不了解请求处理管道[3]: 自定义一个服务器感受一下管道是如何监听、接收和响应请求的
我们在<服务器在管道中的"龙头"地位>中对ASP.NET Core默认提供的具有跨平台能力的KestrelServer进行了介绍,为了让读者朋友们对管道中的服务器具有更 ...
- 学习ASP.NET Core,怎能不了解请求处理管道[2]: 服务器在管道中的“龙头”地位
ASP.NET Core管道由注册的服务器和一系列中间件构成.我们在上一篇中深入剖析了中间件,现在我们来了解一下服务器.服务器是ASP .NET Core管道的第一个节点,它负责完整请求的监听和接收, ...
- 学习ASP.NET Core,怎能不了解请求处理管道[1]: 中间件究竟是个什么东西?
ASP.NET Core管道虽然在结构组成上显得非常简单,但是在具体实现上却涉及到太多的对象,所以我们在 "通过重建Hosting系统理解HTTP请求在ASP.NET Core管道中的处理流 ...
- 学习ASP.NET Core,你必须了解无处不在的“依赖注入”
ASP.NET Core的核心是通过一个Server和若干注册的Middleware构成的管道,不论是管道自身的构建,还是Server和Middleware自身的实现,以及构建在这个管道的应用,都需要 ...
- 学习ASP.NET Core Razor 编程系列二——添加一个实体
在Razor页面应用程序中添加一个实体 在本篇文章中,学习添加用于管理数据库中的书籍的实体类.通过实体框架(EF Core)使用这些类来处理数据库.EF Core是一个对象关系映射(ORM)框架,它简 ...
- 学习ASP.NET Core Razor 编程系列四——Asp.Net Core Razor列表模板页面
学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...
- 学习ASP.NET Core Razor 编程系列五——Asp.Net Core Razor新建模板页面
学习ASP.NET Core Razor 编程系列目录 学习ASP.NET Core Razor 编程系列一 学习ASP.NET Core Razor 编程系列二——添加一个实体 学习ASP.NET ...
随机推荐
- 来,给Entity Framework热热身
先来看一下Entity Framework缓慢的初始化速度给我们更新程序带来的一种痛苦. 我们手动更新程序时通常的操作步骤如下: 1)把Web服务器从负载均衡中摘下来 2)更新程序 3)预热(发出一个 ...
- 使用webstorm+webpack构建简单入门级“HelloWorld”的应用&&引用jquery来实现alert
使用webstorm+webpack构建简单入门级"HelloWorld"的应用&&构建使用jquery来实现 1.首先你自己把webstorm安装完成. 请参考这 ...
- 【原】AFNetworking源码阅读(五)
[原]AFNetworking源码阅读(五) 本文转载请注明出处 —— polobymulberry-博客园 1. 前言 上一篇中提及到了Multipart Request的构建方法- [AFHTTP ...
- 旺财速啃H5框架之Bootstrap(一)
接下来的时间里,我将和大家一起对当前非常流行的前端框架Bootstrap进行速度的学习,以案例的形式.对刚开始想学习Bootstrap的同学而找不着边的就很有帮助了.如果你想详细的学习Bootstra ...
- ubuntu系统下如何修改host
Ubuntu系统的Hosts只需修改/etc/hosts文件,在目录中还有一个hosts.conf文件,刚开始还以为只需要修改这个就可以了,结果发现是需要修改hosts.修改完之后要重启网络.具体过程 ...
- 缓存工具类CacheHelper
代码: using System; using System.Collections.Generic; using System.Linq; using System.Text; using Syst ...
- Cesium简介以及离线部署运行
Cesium简介 cesium是国外一个基于JavaScript编写的使用WebGL的地图引擎,一款开源3DGIS的js库.cesium支持3D,2D,2.5D形式的地图展示,可以自行绘制图形,高亮区 ...
- Register-SPWorkflowService 404
最近需要做一个SharePoint 2013工作流演示环境. 于是在自己的本子上安装了一个虚拟机. 虚拟机操作系统是Windows Server 2012 R2,计划把AD.SQL Server 20 ...
- AndroidStudio — Error:Failed to resolve: junit:junit:4.12错误解决
原博客:http://blog.csdn.net/u013443865/article/details/50243193 最近使用AndroidStudio出现以下问题: 解决:打开app下的buil ...
- iOS - 模态Model视图跳转和Push视图跳转的混合需求实现原理
在研发中总会遇到一些莫名的需求,本着存在即合理的态度跟大家分享一下"模态Model视图跳转和Push视图跳转的需求实现",本文仅仅传授研发技术不传授产品以及UE的思想,请大家合理对 ...