Задача
Допустим, у нас есть MediaRecorder. Он должен уметь записывать видео, аудио, или и то, и другое. При этом, параметры для видео- и для аудиозаписи, конечно же, отличаются. Описание MediaRecorder выглядит как-то так:
data class MediaRecorder(
// video
var videoSource: VideoSource,
var resolution: Resolution,
var path: String,
// audio
var audioSource: AudioSource,
var audioEncoder: AudioEncoder, // указывается, если указан AudioSource
var noiseReduction: Boolean = false,
var noiseReductionLevel: Int = 0,
// общее
var outputFormat: OutputFormat
)
Очевидно, что если параметров становится много, то создание экземпляра такого класса становится неудобным.
Конечно, для решения этой проблемы, можно просто использовать обычный Builder (см. MediaRecorder в Android, там так и сделано), но тогда возникает две проблемы:
Для записи видео требуется указать набор параметров, который становится необязательным, если мы записываем только аудио (и наоборот), и это надо как-то контролировать.
Один параметр может "тянуть" за собой другие. К примеру, если мы укажем noiseReduction = true, то нам следует указать и noiseReductionLevel, либо ни то, ни другое.
При реализации "стандартного" Builder такие проверки можно написать, но выполнятся они уже будут при сборке объекта, то есть при выполнении, а хотелось бы, чтобы все параметры проверялись при компиляции.
Так и напишем такой сборщик.
Пишем свой Builder
Для реализации модифицируем MediaRecorder следующим образом:
Добавляем интерфейс для каждого параметра
lateinit var videoSource: VideoSource
private set
interface VideoSourceBuilder {
fun videoSource(value: VideoSource): ResolutionBuilder
}
Здесь при указании videoSource будет возвращаться не общий сборщик, как в классическом варианте, а "установщик следующего параметра". То есть как-то так:
lateinit var videoSource: VideoSource
private set
interface VideoSourceBuilder {
fun videoSource(value: VideoSource): ResolutionBuilder
}
lateinit var resolution: Resolution
private set
interface ResolutionBuilder { // указывается, если указан VideoSourceBuilder
fun resolution(resolution: Resolution): PathBuilder
}
lateinit var path: String
private set
interface PathBuilder {
fun path(path: String): OutputFormatBuilder
}
interface OutputFormatBuilder {
fun outputFormat(outputFormat: OutputFormat): MediaRecorderFinalBuilder
}
interface MediaRecorderFinalBuilder {
fun build(): MediaRecorder
}
Для того, чтобы можно было вызвать build, videoSource или audioSource, создадим еще общий интерфейс для сборщика, который уже будет возвращать собранный объект:
// VideoSourceBuilder, AudioSourceBuilder: сборку можно будет начать
// с одного из этих параметров
interface Builder : VideoSourceBuilder, AudioSourceBuilder
interface MediaRecorderFinalBuilder : Builder {
fun build(): MediaRecorder
}
Для "разветвления" нашей цепочки сборки, добавим еще интерфейс для "точки разветвления" такого вида:
// Либо указываем path, либо noiseReduction
interface Combine : PathBuilder, NoiseReductionBuilder
Теперь надо реализовать каждый интерфейс. Приведу сразу весь код целиком:
Релазиция MediaRecorder
package builder.sample
class MediaRecorder private constructor() {
companion object {
fun builder() = BuilderImpl()
class BuilderImpl : Builder {
private val mediaRecorder = MediaRecorder()
// Последний шаг сборки
// Или вызываем build(), или переходим к VideoSourceBuilder, AudioSourceBuilder
private inner class MediaRecorderFinalBuilderImpl : Builder by this@BuilderImpl, MediaRecorderFinalBuilder {
override fun build() = mediaRecorder
}
/**
* Реализуем ResolutionBuilder: устанавливаем resolution и возвращаем PathBuilder
*/
private inner class ResolutionBuilderimpl : ResolutionBuilder {
override fun resolution(resolution: Resolution): PathBuilder {
mediaRecorder.resolution = resolution
return PathBuilderImpl()
}
}
private inner class PathBuilderImpl : PathBuilder {
override fun path(path: String): OutputFormatBuilder {
mediaRecorder.path = path
return OutputFormatBuilderImpl()
}
}
private inner class OutputFormatBuilderImpl : OutputFormatBuilder {
override fun outputFormat(outputFormat: OutputFormat): MediaRecorderFinalBuilder {
mediaRecorder.outputFormat = outputFormat
return MediaRecorderFinalBuilderImpl()
}
}
private inner class NoiseReductionBuilderImpl : NoiseReductionBuilder {
override fun noiseReduction(): NoiseReductionLevelBuilder {
mediaRecorder.noiseReduction = true
return object : NoiseReductionLevelBuilder {
override fun noiseReductionLevel(noiseReductionLevel: Int): PathBuilder {
mediaRecorder.noiseReductionLevel = noiseReductionLevel
return PathBuilderImpl()
}
}
}
}
/**
* Точка ответвления: просто реализуем два интерфейса используя отдельные раилизации интерфейсов
*/
private inner class CombineImpl(pathBuilder: PathBuilderImpl = PathBuilderImpl(), noiseReductionBuilder: NoiseReductionBuilderImpl = NoiseReductionBuilderImpl())
: PathBuilder by pathBuilder, NoiseReductionBuilder by noiseReductionBuilder, Combine
private inner class AudioEncoderBuilderImpl : AudioEncoderBuilder<Combine> {
override fun audioEncoder(audioEncoder: AudioEncoder): Combine {
mediaRecorder.audioEncoder = audioEncoder
return CombineImpl()
}
}
override fun videoSource(value: VideoSource): ResolutionBuilder {
mediaRecorder.videoSource = value
return ResolutionBuilderimpl()
}
override fun audioSource(audioSource: AudioSource): AudioEncoderBuilder<Combine> {
mediaRecorder.audioSource = audioSource
return AudioEncoderBuilderImpl()
}
}
}
// video
lateinit var videoSource: VideoSource
private set
interface VideoSourceBuilder {
fun videoSource(value: VideoSource): ResolutionBuilder
}
lateinit var resolution: Resolution
private set
interface ResolutionBuilder { // указывается, если указан VideoSourceBuilder
fun resolution(resolution: Resolution): PathBuilder
}
lateinit var path: String
private set
interface PathBuilder {
fun path(path: String): OutputFormatBuilder
}
// audio
lateinit var audioSource: AudioSource
private set
interface AudioSourceBuilder {
fun audioSource(audioSource: AudioSource): AudioEncoderBuilder<Combine>
}
lateinit var audioEncoder: AudioEncoder // указывается, если указан AudioSource
private set
interface AudioEncoderBuilder<T> where T : PathBuilder, T : NoiseReductionBuilder {
fun audioEncoder(audioEncoder: AudioEncoder): T
}
var noiseReduction: Boolean = false
private set
interface NoiseReductionBuilder {
fun noiseReduction(): NoiseReductionLevelBuilder
}
var noiseReductionLevel: Int = 0
private set
interface NoiseReductionLevelBuilder {
fun noiseReductionLevel(noiseReductionLevel: Int): PathBuilder
}
// общее
lateinit var outputFormat: OutputFormat
private set
interface OutputFormatBuilder {
fun outputFormat(outputFormat: OutputFormat): MediaRecorderFinalBuilder
}
// Точки разветвления
interface Combine : PathBuilder, NoiseReductionBuilder
interface Builder : VideoSourceBuilder, AudioSourceBuilder
interface MediaRecorderFinalBuilder : Builder {
fun build(): MediaRecorder
}
}
enum class Resolution {
MIN, MAX
}
enum class VideoSource {
CAMERA, SCREEN
}
enum class AudioSource {
MIC, RADIO, SIGNAL_FROM_SPACE
}
enum class AudioEncoder {
AAC, OPUS
}
enum class OutputFormat {
AAC, THREE_GPP, MP4
}
Примеры
Теперь можно собирать наш MediaRecorder
val onlyVideo = MediaRecorder
.builder()
.videoSource(VideoSource.CAMERA)
.resolution(Resolution.MAX)
.path("PATH")
.outputFormat(OutputFormat.MP4)
.build()
Важно, что когда мы вызвали метод videoSource(...), мы просто не можем дальше написать ничего, кроме resolution(...) и далее по цепочке до последнего шага, на котором мы можем вызвать или build(), или audioSource(...) (и перейти на другую цепочку).
В данной реализации мы, конечно, можем вызвать на последнем шаге videoSource(...) снова и опять пройти по цепочке конфигурации видеозаписи (но это бессмысленно).
val onlyAudio = MediaRecorder
.builder()
.audioSource(AudioSource.MIC)
.audioEncoder(AudioEncoder.AAC)
.path("PATH")
.outputFormat(OutputFormat.AAC)
.build()
Комбинация двух цепочек:
val videoAndAudio = MediaRecorder
.builder()
.videoSource(VideoSource.CAMERA)
.resolution(Resolution.MAX)
.path("PATH")
.outputFormat(OutputFormat.MP4)
.audioSource(AudioSource.MIC)
.audioEncoder(AudioEncoder.AAC)
.path("PATH")
.outputFormat(OutputFormat.AAC)
.build()
Указать параметр, который требует указания дополнительного (noiseReduction и noiseReductionLevel):
val onlyAudioWithNoiseReduction = MediaRecorder
.builder()
.audioSource(AudioSource.MIC)
.audioEncoder(AudioEncoder.AAC)
.noiseReduction()
.noiseReductionLevel(10)
.path("PATH")
.outputFormat(OutputFormat.AAC)
.build()
При этом, нельзя написать такое:
MediaRecorder() // нет
MediaRecorder.builder().build() // нет
MediaRecorder
.builder()
.videoSource(VideoSource.CAMERA)
.build() // нет, не хватает resolution, path, outputFormat
MediaRecorder
.builder()
.audioSource(AudioSource.MIC)
.audioEncoder(AudioEncoder.AAC)
.noiseReduction()
.path("PATH") // нет, если указали noiseReduction, то где noiseReductionLevel?
.outputFormat(OutputFormat.AAC)
.build()
Недостатки
В качестве недостатка можно указать большой объем кода, который потребуется написать вручную, и при добавлении параметров придется дописывать интерфейсы и их реализации.
Кроме того, если есть сложные комбинации параметров, то это всё будет сложно поддерживать.
Ссылки
Похожие идеи упоминаются:
SO (англ)
Комментарии (5)
lgorSL
24.06.2022 12:50+4Конкретно в Вашем примере можно сделать audio и video отдельными сущностями (возможно, с отдельными билдерами для каждой).
Будет MediaRecorder(VideoRecoder?, AudioRecorder?, outputFormat)
Можно вместо билдера сделать три вспомогательные функции, которые принимают либо только видео, либо только аудио, либо видео+аудио.
Но в целом подход с цепочкой вызовов интересный.
soul_survivor
В статье описан паттерн Java, на котлин никто так не делает, так как dsl выигрывает и в читабельности и в поддерживаемости, посмотрите как kotlin gradle dsl отлично подошел для написания скриптов.
Kotlin билдер выглядит так:
Nucleotide Автор
Но ведь с DSL мы вернемся к тому, что audioSource и videoSource будут лежать внутри MediaRecorder, тогда можно будет написать и так:
А ведь мне нужно сделать так, чтобы при указании audioSource, параметр videoSource уже нельзя было выставить.
Или, подскажите более детально, что Вы имеете ввиду.
Prototik
Используйте типы. Введите интерфейс Source (можно даже sealed), от него наследуйте и VideoSource, и AudioSource. MediaRecorder (и его билдер) типизируйте с
<T : Source>
ну и в нём одно полеval source: T
.soul_survivor
лучше всего изолировать разные варианты на верхнем уровне, если нужно onlyAudio то создать AudioRecorder билдер который вернет MediaRecorder но с уже заданным VideoSource.NONE для MediaRecorder