Задача

Допустим, у нас есть 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, там так и сделано), но тогда возникает две проблемы:

  1. Для записи видео требуется указать набор параметров, который становится необязательным, если мы записываем только аудио (и наоборот), и это надо как-то контролировать.

  2. Один параметр может "тянуть" за собой другие. К примеру, если мы укажем 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()

Недостатки

В качестве недостатка можно указать большой объем кода, который потребуется написать вручную, и при добавлении параметров придется дописывать интерфейсы и их реализации.

Кроме того, если есть сложные комбинации параметров, то это всё будет сложно поддерживать.

Ссылки

Похожие идеи упоминаются:

  1. SO (англ)

  2. С интерфейсами, обобщениями, таблицами переходов (habr)

Комментарии (5)


  1. soul_survivor
    24.06.2022 12:09
    +5

    В статье описан паттерн Java, на котлин никто так не делает, так как dsl выигрывает и в читабельности и в поддерживаемости, посмотрите как kotlin gradle dsl отлично подошел для написания скриптов.
    Kotlin билдер выглядит так:

    val onlyAudio = MediaRecorder {
        audioSource = AudioSource.MIC
        audioEncoder = AudioEncoder.AAC
        path = "PATH"
        outputFormat = OutputFormat.AAC
    }
    


    1. Nucleotide Автор
      24.06.2022 12:34

      Но ведь с DSL мы вернемся к тому, что audioSource и videoSource будут лежать внутри MediaRecorder, тогда можно будет написать и так:

      val onlyAudio = MediaRecorder {
          audioSource = AudioSource.MIC
        	videoSource = VideoSource.CAMERA 
          audioEncoder = AudioEncoder.AAC
          path = "PATH"
          outputFormat = OutputFormat.AAC
      }

      А ведь мне нужно сделать так, чтобы при указании audioSource, параметр videoSource уже нельзя было выставить.
      Или, подскажите более детально, что Вы имеете ввиду.


      1. Prototik
        24.06.2022 12:58
        +3

        Используйте типы. Введите интерфейс Source (можно даже sealed), от него наследуйте и VideoSource, и AudioSource. MediaRecorder (и его билдер) типизируйте с <T : Source> ну и в нём одно поле val source: T.


      1. soul_survivor
        24.06.2022 13:49
        +2

        лучше всего изолировать разные варианты на верхнем уровне, если нужно onlyAudio то создать AudioRecorder билдер который вернет MediaRecorder но с уже заданным VideoSource.NONE для MediaRecorder


  1. lgorSL
    24.06.2022 12:50
    +4

    Конкретно в Вашем примере можно сделать audio и video отдельными сущностями (возможно, с отдельными билдерами для каждой).

    Будет MediaRecorder(VideoRecoder?, AudioRecorder?, outputFormat)

    Можно вместо билдера сделать три вспомогательные функции, которые принимают либо только видео, либо только аудио, либо видео+аудио.

    Но в целом подход с цепочкой вызовов интересный.