Carlos

Carthage compatible

一个简单但灵活的缓存,使用 Swift 编写,适用于 iOS 13+WatchOS 6 应用。

重大变更

Carlos 1.0.0 已从 PiedPiper 依赖迁移到 Combine,因此最低支持的平台版本与 Combine 的最低支持平台版本相同。 有关更多信息,请参见发行说明页面。

本文档内容

什么是 Carlos?

Carlos 是一小组类和函数,用于在您的应用程序中实现自定义、灵活和强大的缓存层

使用函数式编程词汇,Carlos 构成一个幺半群缓存系统。 您可以在此处此视频中查看关于如何实现的最佳解释,感谢 @bkase 提供幻灯片。

默认情况下,Carlos 附带一个内存缓存、一个磁盘缓存、一个简单的网络获取器和一个 NSUserDefaults 缓存(磁盘缓存的灵感来自 HanekeSwift)。

使用 Carlos,您可以

安装

Swift Package Manager (首选)

通过 Xcode 将 Carlos 添加到您的项目,或将以下行添加到您的包依赖项中

.package("https://github.com/spring-media/Carlos", from: "1.0.0")

CocoaPods

Carlos 可通过 CocoaPods 获得。 要安装它,只需将以下行添加到您的 Podfile 中

pod "Carlos", :git => "https://github.com/spring-media/Carlos"

Carthage

也支持 Carthage

要求

使用

要运行示例项目,请克隆存储库。

使用示例

let cache = MemoryCacheLevel<String, NSData>().compose(DiskCacheLevel())

此行将生成一个缓存,该缓存接受 String 键并返回 NSData 值。 在此缓存上为给定键设置一个值将为两个级别设置该值。 在此缓存上为给定键获取一个值将首先尝试在内存级别上获取它,如果找不到,将询问磁盘级别。 如果两个级别都没有值,则请求将失败。 如果磁盘级别可以获取一个值,那么该值也将设置在内存级别上,以便下次获取会更快。

Carlos 附带一个 CacheProvider 类,以便可以轻松访问标准缓存。

以上方法总是创建新的实例(因此调用 CacheProvider.imageCache() 两次不会返回相同的实例,即使磁盘级别将有效地共享,因为它将使用磁盘上的相同文件夹,但这只是一个副作用,不应依赖它),您应该注意在您的应用程序层中保留结果。 如果您想始终获得相同的实例,则可以使用以下访问器

创建请求

要从缓存中获取一个值,请使用 get 方法。

cache.get("key")
  .sink( 
    receiveCompletion: { completion in 
      if case let .failure(error) = completion {
        print("An error occurred :( \(error)")
      }
    },
    receiveValue: { value in 
      print("I found \(value)!")
    }
  )

也可以使用 cancel() 方法取消请求,您可以通过在给定请求上调用 onCancel 来收到此事件的通知

let cancellable = cache.get(key)
                    .handleEvents(receiveCancel: { 
                      print("Looks like somebody canceled this request!")
                    })
                    .sink(...)
[... somewhere else]
cancellable.cancel()

但是,此缓存不是很有用。 它永远不会主动获取值,只会存储它们以供以后使用。 让我们尝试让它更有趣

let cache = MemoryCacheLevel()
              .compose(DiskCacheLevel())
              .compose(NetworkFetcher())

这将创建一个缓存级别,该缓存级别接受 URL 键并存储 NSData 值(该类型是从 NetworkFetcherURL 键和 NSData 值的硬性要求推断出来的,而 MemoryCacheLevelDiskCacheLevel 更加灵活,如下所述)。

键转换

键转换旨在使将缓存级别插入到您正在构建的任何缓存中成为可能。

让我们看看它们是如何工作的

// Define your custom ErrorType values
enum URLTransformationError: Error {
    case invalidURLString
}

let transformedCache = NetworkFetcher().transformKeys(
  OneWayTransformationBox(
    transform: {
      Future { promise in 
        let url = URL(string: $0) {
          promise(.success(url))
        } else {
          promise(.failure(URLTransformationError.invalidURLString))
        }
      }
    }
  )
)

在上面的代码行中,我们说进入 NetworkFetcher 级别的所有键都必须首先转换为 URL 值。 现在,我们可以将此缓存插入到先前定义的接受 String 键的缓存级别中

let cache = MemoryCacheLevel<String, NSData>().compose(transformedCache)

如果这看起来不是很安全(一个人总是可以将字符串垃圾作为键传递,它不会神奇地转换为 URL,从而导致 NetworkFetcher 静默失败),我们仍然可以使用特定于域的结构作为键,假设它包含 StringURL

struct Image {
  let identifier: String
  let URL: Foundation.URL
}

let imageToString = OneWayTransformationBox(transform: { (image: Image) -> AnyPublisher<String, String> in
    Just(image.identifier).eraseToAnyPublisher()
})

let imageToURL = OneWayTransformationBox(transform: { (image: Image) -> AnyPublisher<URL> in
    Just(image.URL).eraseToAnyPublisher()
})

let memoryLevel = MemoryCacheLevel<String, NSData>().transformKeys(imageToString)
let diskLevel = DiskCacheLevel<String, NSData>().transformKeys(imageToString)
let networkLevel = NetworkFetcher().transformKeys(imageToURL)

let cache = memoryLevel.compose(diskLevel).compose(networkLevel)

现在我们可以执行像这样的安全请求

let image = Image(identifier: "550e8400-e29b-41d4-a716-446655440000", URL: URL(string: "http://goo.gl/KcGz8T")!)

cache.get(image).sink {
  print("Found \(value)!")
}

自从 Carlos 0.5 起,您也可以将条件应用于用于键转换的 OneWayTransformers。 只需在转换器上调用 conditioned 函数并传递您的条件。 该条件也可以是异步的,并且必须返回一个 AnyPublisher<Bool, Error>,有机会为转换失败返回一个特定的错误。

let transformer = OneWayTransformationBox<String, URL>(transform: { key in
  Future { promise in 
    if let value = URL(string: key) {
      promise(.success(value))
    } else {
      promise(.failure(MyError.stringIsNotURL))
    }
  }.eraseToAnyPublisher()
}).conditioned { key in
  Just(key)
    .filter { $0.rangeOfString("http") != nil }
    .eraseToAnyPublisher()
}

let cache = CacheProvider.imageCache().transformKeys(transformer)

但这还不是全部。

如果我们的磁盘缓存只存储 Data,但我们希望我们的内存缓存方便地存储 UIImage 实例呢?

值转换

值转换器允许您拥有一个缓存(假设)存储 Data 并将其变异为一个存储 UIImage 值的缓存。 让我们看看如何操作

let dataTransformer = TwoWayTransformationBox(transform: { (image: UIImage) -> AnyPublisher<Data, Error> in
    Just(UIImagePNGRepresentation(image)).eraseToAnyPublisher()
}, inverseTransform: { (data: Data) -> AnyPublisher<UIImage, Error> in
    Just(UIImage(data: data)!).eraseToAnyPublisher()
})

let memoryLevel = MemoryCacheLevel<String, UIImage>().transformKeys(imageToString).transformValues(dataTransformer)

这个内存级别现在可以替换我们之前的内存级别,不同之处在于它将在内部存储 UIImage 值!

请记住,与键转换一样,如果您的转换闭包失败(正向转换或反向转换),则将跳过缓存级别,就好像获取失败一样。 相同的注意事项适用于 set 调用。

Carlos 附带一些开箱即用的值转换器,例如

Carlos 0.4 开始,可以使用 OneWayTransformer 转换来自 Fetcher 实例的值(与用于正常 CacheLevel 实例的必需 TwoWayTransformer 相反。 这是因为 Fetcher 协议不需要 set)。 这意味着您可以轻松地链接从互联网获取 JSON 并将其输出转换为模型对象(例如 struct)的 Fetcher,从而形成一个复杂的缓存管道,而无需创建一个虚拟的反向转换来满足 TwoWayTransformer 协议的要求。

Carlos 0.5 开始,所有转换器都原生支持异步计算,因此您可以在自定义转换器中进行昂贵的转换,而不会阻止其他操作。 实际上,开箱即用的 ImageTransformer 在后台队列中处理图像转换。

Carlos 0.5 开始,您还可以将条件应用于用于值转换的 TwoWayTransformers。 只需在转换器上调用 conditioned 函数并传递您的条件(一个用于正向转换,一个用于反向转换)。 该条件也可以是异步的,并且必须返回一个 AnyPublisher<Bool, Error>,有机会为转换失败返回一个特定的错误。

let transformer = JSONTransformer().conditioned({ input in
  Just(myCondition).eraseToAnyPublisher()
}, inverseCondition: { input in
  Just(myCondition)eraseToAnyPublisher()
})

let cache = CacheProvider.dataCache().transformValues(transformer)

后处理输出

在某些情况下,您的缓存级别可能会返回正确的值,但格式可能并非最佳。例如,您可能希望清理从缓存整体获取的输出,而无需考虑返回该输出的具体层。

对于这些情况,Carlos 0.4 引入的 postProcess 函数可能会有所帮助。 该函数可以作为 CacheLevel 协议的协议扩展使用。

postProcess 函数接受一个 CacheLevel 和一个 TypeIn == TypeOutOneWayTransformer 作为参数,并输出一个装饰过的 BasicCache,其中嵌入了后处理步骤。

// Let's create a simple "to uppercase" transformer
let transformer = OneWayTransformationBox<NSString, String>(transform: { Just($0.uppercased() as String).eraseToAnyPublisher() })

// Our memory cache
let memoryCache = MemoryCacheLevel<String, NSString>()

// Our decorated cache
let transformedCache = memoryCache.postProcess(transformer)

// Lowercase value set on the memory layer
memoryCache.set("test String", forKey: "key")

// We get the lowercase value from the undecorated memory layer
memoryCache.get("key").sink { value in
  let x = value
}

// We get the uppercase value from the decorated cache, though
transformedCache.get("key").sink { value in
  let x = value
}

Carlos 0.5 开始,您还可以将条件应用于用于后处理转换的 OneWayTransformers。 只需在转换器上调用 conditioned 函数并传递您的条件即可。 该条件也可以是异步的,并且必须返回一个 AnyPublisher<Bool, Error>,这样就有机会为转换失败返回特定的错误。 请记住,该条件实际上会将缓存的输出作为输入,而不是用于获取此值的键! 如果要应用基于键的条件,请改用 conditionedPostProcess,但请记住,这尚不支持使用 OneWayTransformer 实例。

let processer = OneWayTransformationBox<NSData, NSData>(transform: { value in
      Future { promise in 
        if let value = String(data: value as Data, encoding: .utf8)?.uppercased().data(using: .utf8) as NSData? {
          promise(.success(value))
        } else {
          promise(.failure(FetchError.conditionNotSatisfied))
        }
      }
    }).conditioned { value in
      Just(value.length < 1000).eraseToAnyPublisher()
    }

let cache = CacheProvider.dataCache().postProcess(processer)

条件输出后处理

扩展了简单的 输出后处理 的情况,您还可以应用基于用于获取该值的键的条件转换。

对于这些情况,Carlos 0.6 引入的 conditionedPostProcess 函数可能会有所帮助。 该函数可以作为 CacheLevel 协议的协议扩展使用。

conditionedPostProcess 函数接受一个 CacheLevel 和一个符合 ConditionedOneWayTransformer 的条件转换器作为参数,并输出一个装饰过的 CacheLevel,其中嵌入了条件后处理步骤。

// Our memory cache
let memoryCache = MemoryCacheLevel<String, NSString>()

// Our decorated cache
let transformedCache = memoryCache.conditionedPostProcess(ConditionedOneWayTransformationBox(conditionalTransformClosure: { (key, value) in
	if key == "some sentinel value" {
	    return Just(value.uppercased()).eraseToAnyPublisher()
	} else {
	    return Just(value).eraseToAnyPublisher()
	}
})

// Lowercase value set on the memory layer
memoryCache.set("test String", forKey: "some sentinel value")

// We get the lowercase value from the undecorated memory layer
memoryCache.get("some sentinel value").sink { value in
  let x = value
}

// We get the uppercase value from the decorated cache, though
transformedCache.get("some sentinel value").sink { value in
  let x = value
}

条件值转换

扩展了简单的 值转换 的情况,您还可以应用基于用于获取或设置该值的键的条件转换。

对于这些情况,Carlos 0.6 引入的 conditionedValueTransformation 函数可能会有所帮助。 该函数可以作为 CacheLevel 协议的协议扩展使用。

conditionedValueTransformation 函数接受一个 CacheLevel 和一个符合 ConditionedTwoWayTransformer 的条件转换器作为参数,并输出一个装饰过的 CacheLevel,其中具有修改过的 OutputType(等于转换器的 TypeOut,就像在正常值转换情况下一样),并且嵌入了条件值转换步骤。

// Our memory cache
let memoryCache = MemoryCacheLevel<String, NSString>()

// Our decorated cache
let transformedCache = memoryCache.conditionedValueTransformation(ConditionedTwoWayTransformationBox(conditionalTransformClosure: { (key, value) in
	if key == "some sentinel value" {
	    return Just(1).eraseToAnyPublisher()
	} else {
	    return Just(0).eraseToAnyPublisher()
	}
}, conditionalInverseTransformClosure: { (key, value) in
    if key > 0 {
	    return Just("Positive").eraseToAnyPublisher()
	} else {
		return Just("Null or negative").eraseToAnyPublisher()
	}
})

// Value set on the memory layer
memoryCache.set("test String", forKey: "some sentinel value")

// We get the same value from the undecorated memory layer
memoryCache.get("some sentinel value").sink { value in
  let x = value
}

// We get 1 from the decorated cache, though
transformedCache.get("some sentinel value").sink { value in
  let x = value
}

// We set "Positive" on the decorated cache
transformedCache.set(5, forKey: "test")

组合转换器

Carlos 0.4 开始,可以组合多个 OneWayTransformer 对象。 这样,可以创建多个转换器模块来构建一个小库,然后根据应用程序的需要更方便地组合它们。

您可以使用与普通 CacheLevel 相同的方式组合转换器:使用 compose 协议扩展

let firstTransformer = ImageTransformer() // NSData -> UIImage
let secondTransformer = ImageTransformer().invert() // Trivial UIImage -> NSData

let identityTransformer = firstTransformer.compose(secondTransformer)

同样的方法可以应用于 TwoWayTransformer 对象(顺便说一句,它们也已经是 OneWayTransformer)。

默认情况下,Carlos 将提供许多转换器模块。

请求池化

当您有一个可以正常工作的缓存,但其中一些级别的成本很高(例如网络获取器或数据库获取器)时,**您可能希望对请求进行池化,以便在其中一个请求完成之前,对同一键的多个请求进行分组,以便当一个请求完成时,所有其他请求也完成,而无需实际多次执行成本高昂的操作**。

此功能随 Carlos 一起提供。

let cache = (memoryLevel.compose(diskLevel).compose(networkLevel)).pooled()

请记住,键必须符合 Hashable 协议,pooled 函数才能工作

extension Image: Hashable {
  var hashValue: Int {
    return identifier.hashValue
  }
}

extension Image: Equatable {}

func ==(lhs: Image, rhs: Image) -> Bool {
  return lhs.identifier == rhs.identifier && lhs.URL == rhs.URL
}

现在我们可以对同一个 Image 值执行多个获取操作,并确保只启动一个网络请求。

批量获取请求

Carlos 0.7 开始,您可以通过 batchGetSome 将键列表传递给您的 CacheLevel。 这将返回一个 AnyPublisher,当指定键的所有请求 *完成* 时(不一定是成功)它才会成功。 但是,您只会获得成功回调中的成功值。

Carlos 0.9 开始,您可以通过 allBatch 将您的 CacheLevel 转换为接受键列表的缓存级别。 在这样的 CacheLevel 上调用 get 会返回一个 AnyPublisher,只有当 **所有** 指定键的请求都成功时,它才会成功,并且 **只要** 指定键的请求 **之一** 失败,它就会失败。 如果您取消此 CacheLevel 返回的 AnyPublisher,则所有挂起的请求也会被取消。

一个用法示例

let cache = MemoryCacheLevel<String, Int>()

for iter in 0..<99 {
  cache.set(iter, forKey: "key_\(iter)")
}

let keysToBatch = (0..<100).map { "key_\($0)" }

cache.batchGetSome(keysToBatch).sink(
    receiveCompletion: { completion in 
        print("Failed because \($0)")
    },
    receiveValue: { values in 
        print("Got \(values.count) values in total")
    }
)

在这种情况下,allBatch().get 调用将失败,因为只设置了 99 个键,并且最后一个请求将导致整个批处理失败,并显示 valueNotInCache 错误。 相反,batchGetSome().get 将成功,并打印 Got 99 values in total

由于 allBatch 返回一个新的 CacheLevel 实例,因此可以像任何其他缓存一样对其进行组合或转换

在这种情况下,cache 是一个接受 String 键序列并返回 Int 值列表的 AnyPublisher 的缓存,但仅限于 3 个并发请求(有关限制并发请求的更多信息,请参见下一段)。

条件化缓存

有时,我们可能有应该仅在某些条件下查询的级别。 假设我们有一个 DatabaseLevel,只有当用户在应用程序中启用给定的设置(实际上开始在数据库中存储数据)时才应触发它。 我们可能希望避免首先在禁用该设置时访问数据库。

let conditionedCache = cache.conditioned { key in
  Just(appSettingIsEnabled).eraseToAnyPublisher()
}

该闭包获取缓存被要求获取的键,并且必须返回一个 AnyPublisher<Bool, Error> 对象,指示该请求是否可以继续或应该跳过该级别,并且可以选择使用特定的 Error 失败,以便将错误传达给调用者。

在运行时,如果变量 appSettingIsEnabledfalse,则 get 请求将跳过该级别(或者如果这是缓存中唯一或最后一个级别,则会失败)。 如果 true,则将执行 get 请求。

多缓存通道

如果您有一个复杂的场景,根据键或某些其他外部条件,应该使用一个或另一个缓存,那么 switchLevels 函数可能会很有用。

使用

let lane1 = MemoryCacheLevel<URL, NSData>() // The two lanes have to be equivalent (same key type, same value type).
let lane2 = CacheProvider.dataCache() // Keep in mind that you can always use key transformation or value transformations if two lanes don't match by default

let switched = switchLevels(lane1, lane2) { key in
  if key.scheme == "http" {
  	return .cacheA
  } else {
   	return .cacheB // The example is just meant to show how to return different lanes
  }
}

现在,根据键 URL 的方案,将使用第一个通道或第二个通道。

监听内存警告

如果我们在缓存级别中存储较大的对象,我们可能希望收到内存警告事件的通知。 这就是 listenToMemoryWarningsunsubscribeToMemoryWarnings 函数派上用场的地方

let token = cache.listenToMemoryWarnings()

稍后

unsubscribeToMemoryWarnings(token)

通过第一次调用,当出现内存警告时,缓存级别及其所有组成级别都将收到对 onMemoryWarning 的调用。

通过第二次调用,该行为将停止。

请记住,WatchOS 2 框架 CarlosWatch.framework 尚不支持此功能。

规范化

如果您需要在属性中存储多个 Carlos 组合调用的结果,则将属性的类型设置为 BasicCache 可能会很麻烦,因为某些调用会返回不同的类型(例如 PoolCache)。 在这种情况下,您可以在将缓存级别分配给该属性之前对其进行 normalize,它将被转换为 BasicCache 值。

import Carlos

class CacheManager {
  let cache: BasicCache<URL, NSData>

  init(injectedCache: BasicCache<URL, NSData>) {
	self.cache = injectedCache
  }
}

[...]

let manager = CacheManager(injectedCache: CacheProvider.dataCache().pooled()) // This won't compile

let manager = CacheManager(injectedCache: CacheProvider.dataCache().pooled().normalize()) // This will

作为提示,如果您需要将多个组合调用的结果分配给属性,请始终使用 normalize。 如果该值已经是 BasicCache,则该调用是一个空操作,因此在这种情况下不会有性能损失。

创建自定义级别

创建自定义级别既简单又鼓励(毕竟,如果您只需要内存、磁盘和网络功能,那么已经有多个缓存库可用!)。

让我们看看如何做到这一点

class MyLevel: CacheLevel {
  typealias KeyType = Int
  typealias OutputType = Float

  func get(_ key: KeyType) -> AnyPublisher<OutputType, Error> {
    Future {
      // Perform the fetch and either succeed or fail
    }.eraseToAnyPublisher()
  }

  func set(_ value: OutputType, forKey key: KeyType) -> AnyPublisher<Void, Error> {  
    Future {
      // Store the value (db, memory, file, etc) and call this on completion:
    }.eraseToAnyPublisher()
  }

  func clear() {
    // Clear the stored values
  }

  func onMemoryWarning() {
    // A memory warning event came. React appropriately
  }
}

上面的类符合 CacheLevel 协议。 我们首先需要声明我们接受哪些键类型以及我们返回哪些输出类型。 在此示例中,我们有 Int 键和 Float 输出值。

需要实现的必需方法有 4 个:getsetclearonMemoryWarning。 现在可以将此示例缓存流水线式地连接到其他缓存列表,如果需要,可以转换其键或值,正如我们在前面的段落中看到的那样。

创建自定义获取器

Carlos 0.4 中,引入了 Fetcher 协议,以使库用户更容易创建可以作为缓存中的只读级别使用的自定义获取器。 一直包含在 Carlos 中的“伪装的 Fetcher”的一个例子是 NetworkFetcher:您只能使用它从网络读取,而不能写入(setclearonMemoryWarning 是 **空操作**)。

现在实现您的自定义获取器就是这么容易

class CustomFetcher: Fetcher {
  typealias KeyType = String
  typealias OutputType = String

  func get(_ key: KeyType) -> Anypublisher<OutputType, Error> {
    return Just("Found an hardcoded value :)").eraseToAnyPublisher()
  }
}

您仍然需要声明您的 CacheLevel 处理什么 KeyTypeOutputType,当然,但然后您只需要实现 get。 减少了您的样板代码!

内置级别

Carlos 默认带有 3 个缓存级别

MemoryCacheLevel 是一个易失性缓存,它在内部将其值存储在 NSCache 实例中。 容量可以通过初始化程序指定,并且它支持在内存压力下进行清除(如果该级别 订阅了内存警告通知)。 它接受符合 StringConvertible 协议的任何给定类型的键,并且可以存储符合 ExpensiveObject 协议的任何给定类型的值。 DataNSDataStringNSStringUIImageURL 已经默认符合后一个协议,而 StringNSStringURL 符合 StringConvertible 协议。 此缓存级别是线程安全的。

DiskCacheLevel 是一个持久性缓存,它异步地将其值存储在磁盘上。 容量可以通过初始化程序指定,以便磁盘大小永远不会太大。 它接受符合 StringConvertible 协议的任何给定类型的键,并且可以存储符合 NSCoding 协议的任何给定类型的值。 此缓存级别是线程安全的,并且目前是唯一一个在调用 set 时可能失败的 CacheLevel,并显示 DiskCacheLevelError.diskArchiveWriteFailed 错误。

NetworkFetcher 是一个异步地通过网络获取值的缓存级别。 它接受 URL 键并返回 NSData 值。 此缓存级别是线程安全的。

NSUserDefaultsCacheLevel 是一个持久化缓存,它将值存储在具有特定名称的 UserDefaults 持久化域中。它接受任何符合 StringConvertible 协议的类型的键,并且可以存储任何符合 NSCoding 协议的类型的值。 它有一个内部软缓存,用于避免过于频繁地访问持久化存储,并且可以在不影响保存在 standardUserDefaults 或其他持久化域上的其他值的情况下进行清除。 此缓存级别是线程安全的。

日志

在决定如何在 Carlos 中处理日志记录时,我们选择了最灵活的方法,而无需编写完整的日志记录框架,即能够插入您自己的日志记录库。如果您希望仅在超过给定级别时才打印 Carlos 的输出,如果您想完全在发布版本中静默它,或者如果您想将其路由到文件或其他任何内容:只需将您的日志处理闭包分配给 Carlos.Logger.output

Carlos.Logger.output = { message, level in
   myLibrary.log(message) //Plug here your logging library
}

测试

Carlos 经过全面测试,以确保其设计提供的功能对于重构和尽可能地消除错误是安全的。

我们使用 QuickNimble 而不是 XCTest,以便拥有一个良好的 BDD 测试布局。

截至今天,Carlos 大约有 1000 个测试 (参见 Tests 文件夹),并且总体而言,测试代码库的规模是生产代码库的两倍

未来发展

Carlos 正在开发中,您可以在 这里 看到所有未解决的问题。 它们被分配给里程碑,以便您可以了解特定功能何时发布。

如果您想为此存储库做出贡献,请

使用 Carlos 的应用

正在使用 Carlos 吗? 请通过 Pull request 告知我们,我们很乐意提及您的应用程序!

作者

Carlos 是由 WeltN24 内部开发的

贡献者

Vittorio Monaco, vittorio.monaco@weltn24.de, @vittoriom on Github, @Vittorio_Monaco on Twitter

Esad Hajdarevic, @esad

许可证

Carlos 在 MIT 许可下可用。 有关更多信息,请参见 LICENSE 文件。

致谢

Carlos 内部使用

DiskCacheLevel 类受到 Haneke 的启发。 源代码已进行了大量修改,但是改编原始文件已被证明对 Carlos 的开发非常有价值。