Como devo estruturar um sistema extensível de carregamento de ativos?

19

Para um mecanismo de jogo de hobby em Java, quero codificar um gerenciador de recursos / recursos simples, mas flexível. Ativos são sons, imagens, animação, modelos, texturas etc. Depois de algumas horas de navegação e algumas experiências de código, ainda não tenho certeza de como projetar isso.

Especificamente, estou procurando como projetar o gerente de uma maneira que abstraia como tipos específicos de ativos são carregados e de onde estão sendo carregados. Eu gostaria de poder suportar o sistema de arquivos e o armazenamento RDBMS sem que o restante do programa precise saber sobre isso. Da mesma forma, eu gostaria de adicionar um recurso de descrição de animação (FPS, quadros a serem renderizados, referência à imagem do sprite etc.) que é XML. Eu deveria ser capaz de escrever uma classe para isso com a funcionalidade de encontrar e ler um arquivo XML e criar e retornar uma AnimationAssetclasse com essas informações. Estou procurando um design orientado a dados .

Posso encontrar muitas informações sobre o que um gerente de ativos deve fazer, mas não sobre como fazê-lo. Os genéricos envolvidos parecem resultar em alguma forma de cascata de classes ou em alguma forma de classes auxiliares. No entanto, não vi um exemplo claro que não parecesse um hack pessoal ou um ponto de consenso.

user8363
fonte

Respostas:

23

Eu começaria não pensando em um gerente de ativos . Pensar na sua arquitetura em termos vagamente definidos (como "gerente") tende a permitir que você varra mentalmente muitos detalhes para baixo do tapete e, consequentemente, fica mais difícil escolher uma solução.

Concentre-se nas suas necessidades específicas, que parecem estar relacionadas à criação de um mecanismo de carregamento de recursos que abstrai o armazenamento de origem subjacente e permite extensibilidade do conjunto de tipos suportado. Não há realmente nada na sua pergunta a respeito, por exemplo, do armazenamento em cache de recursos já carregados - o que é bom, porque, de acordo com o princípio de responsabilidade única, você provavelmente deve criar um cache de ativos como uma entidade separada e agregar as duas interfaces em outros lugares. , como apropriado.

Para abordar sua preocupação específica, você deve projetar seu carregador para que ele não faça o carregamento de nenhum ativo, mas delegue essa responsabilidade a interfaces personalizadas para carregar tipos específicos de ativos. Por exemplo:

interface ITypeLoader {
  object Load (Stream assetStream);
}

Você pode criar novas classes que implementam essa interface, com cada nova classe sendo adaptada para carregar um tipo específico de dados de um fluxo. Ao usar um fluxo, o carregador de tipos pode ser gravado em uma interface comum e independente de armazenamento e não precisa ser codificado para carregar do disco ou de um banco de dados; isso permitiria que você carregasse seus ativos a partir de fluxos de rede (o que pode ser muito útil na implementação de recarga a quente de ativos quando o jogo estiver sendo executado em um console e suas ferramentas de edição em um PC conectado à rede).

Seu principal carregador de ativos precisa ser capaz de registrar e rastrear esses carregadores específicos de tipo:

class AssetLoader {
  public void RegisterType (string key, ITypeLoader loader) {
    loaders[key] = loader;
  }

  Dictionary<string, ITypeLoader> loaders = new Dictionary<string, ITypeLoader>();
}

A "chave" usada aqui pode ser o que você quiser - e não precisa ser uma string, mas é fácil começar com isso. A chave levará em consideração a forma como você espera que um usuário identifique um ativo específico e será usada para procurar o carregador apropriado. Como você deseja ocultar o fato de que a implementação pode estar usando um sistema de arquivos ou um banco de dados, não é possível ter usuários que se referem a ativos por um caminho do sistema de arquivos ou algo assim.

Os usuários devem se referir a um ativo com um mínimo de informações. Em alguns casos, apenas um nome de arquivo por si só seria suficiente, mas descobri que muitas vezes é desejável usar um par de tipo / nome para que tudo seja muito explícito. Assim, um usuário pode se referir a uma instância nomeada de um dos seus arquivos XML de animação como "AnimationXml","PlayerWalkCycle".

Aqui, AnimationXmlseria a chave com a qual você se registrou AnimationXmlLoader, o que implementa IAssetLoader. Obviamente, PlayerWalkCycleidentifica o ativo específico. Dado um nome de tipo e um nome de recurso, o carregador de ativos pode consultar seu armazenamento persistente em busca dos bytes brutos desse ativo. Como estamos buscando a máxima generalidade aqui, você pode implementar isso fornecendo ao carregador um meio de acesso ao armazenamento ao criá-lo, permitindo substituir o meio de armazenamento por qualquer coisa que possa fornecer um fluxo posteriormente:

interface IAssetStreamProvider {
  Stream GetStream (string type, string name);
}

class AssetLoader {
  public AssetLoader (IAssetStreamProvider streamProvider) {
    provider = streamProvider;
  }

  object LoadAsset (string type, string name) {
    var loader = loaders[type];
    var stream = provider.GetStream(type, name);

    return loader.Load(stream);
  }

  public void RegisterType (string type, ITypeLoader loader) {
    loaders[type] = loader;
  }

  IAssetStreamProvider provider;
  Dictionary<string, ITypeLoader> loaders = new Dictionary<string, ITypeLoader>();
}

Um provedor de fluxo muito simples simplesmente procuraria em um diretório raiz de ativos especificado um subdiretório nomeado typee carregaria os bytes brutos do arquivo nomeado nameem um fluxo e o retornaria.

Em resumo, o que você tem aqui é um sistema em que:

  • Há uma classe que sabe ler bytes não processados ​​de algum tipo de armazenamento de back-end (um disco, um banco de dados, um fluxo de rede, qualquer que seja).
  • Existem classes que sabem como transformar um fluxo de bytes brutos em um tipo específico de recurso e devolvê-lo.
  • O seu "carregador de ativos" real apenas possui uma coleção dos itens acima e sabe como canalizar a saída do provedor de fluxo para o carregador específico do tipo e, assim, produzir um ativo concreto. Ao expor maneiras de configurar o provedor de fluxo e os carregadores específicos do tipo, você tem um sistema que pode ser estendido pelos clientes (ou por você) sem precisar modificar o código do carregador de ativos real.

Algumas advertências e notas finais:

  • O código acima é basicamente C #, mas deve ser traduzido para praticamente qualquer idioma com o mínimo de esforço. Para facilitar isso, omiti muitas coisas, como verificação de erros ou uso adequado IDisposablee outros idiomas que podem não se aplicar diretamente em outros idiomas. Esses são deixados como lição de casa para o leitor.

  • Da mesma forma, retorno o ativo concreto como objectacima, mas você pode usar genéricos ou modelos ou qualquer outra coisa para produzir um tipo de objeto mais específico, se quiser (você deve, é bom trabalhar com ele).

  • Como acima, eu não lido com cache aqui. No entanto, você pode adicionar o armazenamento em cache facilmente e com o mesmo tipo de generalidade e configurabilidade. Experimente e veja!

  • Há muitas, muitas e muitas maneiras de fazer isso, e certamente não há uma maneira ou consenso, e é por isso que você não conseguiu encontrar uma. Tentei fornecer código suficiente para transmitir os pontos específicos sem transformar essa resposta em uma parede de código dolorosamente longa. Já é extremamente longo como é. Se você tiver perguntas esclarecedoras, sinta-se à vontade para comentar ou me encontrar no chat .


fonte
1
Boa pergunta e boa resposta que direcionam a solução não apenas para um design orientado a dados, mas também a como começar a pensar de maneira orientada a dados.
Patrick Hughes
Resposta muito agradável e aprofundada. Adoro como você interpretou minha pergunta e me disse exatamente o que eu precisava saber enquanto a formulava tão mal. Obrigado! Por acaso, você poderia me indicar alguns recursos sobre o Streams?
user8363
Um "fluxo" é apenas uma sequência (potencialmente sem fim determinável) de bytes ou dados. Eu estava pensando especificamente no Stream do C # , mas você provavelmente está mais interessado nas classes de stream do Java - embora seja avisado que não conheço muito o Java, então essa pode não ser a classe ideal para usar.
Os fluxos geralmente são com estado, pois um determinado objeto de fluxo geralmente tem uma posição atual de leitura ou gravação no fluxo e qualquer E / S que você executa nele ocorre a partir dessa posição - é por isso que os usei como entradas para as interfaces de ativos acima, porque eles estão basicamente dizendo "aqui estão alguns dados brutos e por onde começar a ler, leia e faça o que quiser".
Essa abordagem respeita alguns dos princípios fundamentais do SOLID e do OOP . Bravo.
Adam Naylor